Skip to content
21 changes: 17 additions & 4 deletions docs/claude-mod.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,23 @@ cleans up the files when Claude exits.
The same entry point routes Agent/Task calls in a native `tool.call` hook. It pipes the
call as JSON to `ug claude-router-hook route-subagent-mod`, whose argument list arrives in
the `routeCommand` plugin option, and continues the call with the returned input. The
matching Agent row shows a spinner and elapsed seconds while the router answers, then the
selected model, routing time, and rationale. Results live in memory by `tool_use_id`, so a
plugin reload drops them. Disabled routing, command failures, and invalid responses keep
the original call; failures also log a diagnostic.
matching Agent row shows a spinner and elapsed seconds while the router answers. Claude
hides that row once a second Agent call joins it, so while any call is routing, the turn's
spinner line also reads `Selecting a model… Ns` or `Selecting models for N agents… Ns`,
timed from the oldest waiting call; Claude's own glyph and counters stay.
A lone call's decision is drawn under its Agent row: `↳ Selected Model:` in green with its
routing time, then the indented `Reason:`. Claude folds parallel Agent rows into its own
"Running N agents" block, which raises no render hook, so calls that were routing at the same
time are drawn instead as cards (bold task, selected model, rationale) above the parent's
next reply, in call order. A reply is claimed by its message id on its first block drawn after
routing; the message that issued the calls is drawn before routing finishes and is never used,
a call still routing waits for a later reply, and a call whose routing failed draws no card.
Cards survive a turn that ends while the reply waits on a background agent and are dropped
only when the turn is aborted. Nothing is drawn above the prompt and nothing is logged.
Decisions are kept for the session; a plugin reload clears them from the screen, and the
routing decision audit log is unchanged.
Disabled routing, command failures, and invalid responses keep the original call;
failures also log a diagnostic.

The old routing PreToolUse hook is no longer written, so a call is not routed twice. A
`route-subagent` hook that an earlier UG version persisted still gets its PreToolUse
Expand Down
10 changes: 8 additions & 2 deletions src/ucode/agents/claude_mods/register.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { EngineInterface, On, PluginOptions } from 'claude-code';
import { registerStatusBand, showStatusBand } from './smart-routing-status.ts';
import { registerSubagentRouting } from './subagent-routing.ts';
import { endStatusBandTurn, registerStatusBand, showStatusBand } from './smart-routing-status.ts';
import { endSubagentRoutingTurn, registerSubagentRouting } from './subagent-routing.ts';
import {
CLI_AGENT,
CLI_MODULE,
Expand Down Expand Up @@ -131,4 +131,10 @@ export function register(on: On, options: PluginOptions): void {
});
registerStatusBand(on);
registerSubagentRouting(on, options);
// Claude allows one unmatched turn.complete handler per plugin, so both modules share it.
on('turn.complete', ($, e, next) => {
endSubagentRoutingTurn(e.isAborted);
if (endStatusBandTurn()) $.ui.invalidate('ui.render');
return next(e);
});
}
18 changes: 9 additions & 9 deletions src/ucode/agents/claude_mods/smart-routing-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,15 @@ export function showStatusBand(): void {
band.isHideArmed = false
}

// Hides the band at the end of an armed turn; true when the band changed and needs a redraw.
// The register module owns the one turn.complete handler, which the routing cards share.
export function endStatusBandTurn(): boolean {
if (!band.isHideArmed) return false
band.isVisible = false
band.isHideArmed = false
return true
}

type RoutingState = { enabled: boolean; firstPrompt: boolean }

// Claude's capability scanner requires literal names in $.env.get calls.
Expand Down Expand Up @@ -50,15 +59,6 @@ export const registerStatusBand = (on: On): void => {
return next(e)
})

on('turn.complete', ($, e, next) => {
if (band.isHideArmed) {
band.isVisible = false
band.isHideArmed = false
$.ui.invalidate('ui.render')
}
return next(e)
})

// The prompt hint row always carries the state as a plain tail, so it stays visible after
// the band hides and flips as soon as a toggle succeeds.
on('ui.render', { component: 'PromptHint' }, async ($, e, next) => {
Expand Down
129 changes: 104 additions & 25 deletions src/ucode/agents/claude_mods/subagent-routing.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { On, PluginOptions } from 'claude-code'

type RoutingRecord = { model: string; rationale: string; seconds?: number }
type RoutingRecord = { task: string; model: string; rationale: string; seconds?: number }

const shortModel = (model: string): string => model.replace(/^system\.ai\./, '').replace(/^databricks-/, '')
// Claude's own spinner glyphs, played forwards then back as Claude does.
Expand All @@ -21,10 +21,62 @@ const secondsSince = async ($: any, start: number | undefined): Promise<number |
return current === undefined || start === undefined ? undefined : Math.max(0, current - start) / 1000
}

// The selected model with its routing time, then the reason. Labels recede so the routed
// model, in the status band's ON green, stands out; the time is omitted without a clock.
const selection = ($: any, e: any, record: RoutingRecord, arrow: string, reasonPadding: number) => {
const { Box, Text } = $.ui.resolve(e)
const lines = [Box({
flexDirection: 'row',
children: [
Text({ dimColor: true, children: [`${arrow}Selected Model: `] }),
Text({ color: 'green', bold: true, children: [shortModel(record.model)] }),
...(record.seconds === undefined ? [] : [
Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] }),
]),
],
})]
if (record.rationale) {
lines.push(Box({
paddingLeft: reasonPadding,
children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })],
}))
}
return lines
}

// A grouped call's decision: its task, then the selection.
const card = ($: any, e: any, record: RoutingRecord) => {
const { Box, Text } = $.ui.resolve(e)
return Box({
flexDirection: 'column',
paddingLeft: 2,
marginBottom: 1,
children: [Text({ bold: true, children: [record.task] }), ...selection($, e, record, '↳ ', 2)],
})
}

// Grouped calls not yet drawn, in call order. They live outside registration so the
// register module's one turn.complete handler can drop them.
const cards = { unplaced: [] as string[] }

// An aborted turn draws no reply, so its calls must not reach a later turn. A turn that
// merely ends, as when the reply waits on a background agent, keeps them.
export function endSubagentRoutingTurn(aborted: boolean): void {
if (aborted) cards.unplaced = []
}

export const registerSubagentRouting = (on: On, options: PluginOptions): void => {
const records = new Map<string, RoutingRecord>()
// Calls waiting on the router, by when routing started, shown with a spinner and timer.
const pending = new Map<string, number | undefined>()
// Claude folds parallel Agent rows into its own block, which no hook reaches, so a call
// that was routing alongside another is drawn as a card above the parent's next reply.
// A lone call keeps its decision under its own row.
const grouped = new Set<string>()
const records = new Map<string, RoutingRecord>()
// The reply each batch is drawn above.
const anchors = new Map<string, string[]>()
// Replies already drawn before a batch was routed are never its anchor.
const seen = new Set<string>()

on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => {
const command = options.routeCommand as readonly string[]
Expand All @@ -33,6 +85,18 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void =>
let route
let spinner: { cancel: () => void } | undefined
if (tool_use_id) {
// Parallel calls start together, so one still routing means this call is not alone.
if (pending.size) {
for (const id of pending.keys()) {
if (!grouped.has(id)) {
grouped.add(id)
cards.unplaced.push(id)
}
}
grouped.add(tool_use_id)
cards.unplaced.push(tool_use_id)
}
pending.set(tool_use_id, undefined)
pending.set(tool_use_id, await now($))
// Mods have no global timers; $.clock redraws the spinner while the router answers.
// The frame comes from the clock, so parallel calls do not speed it up.
Expand Down Expand Up @@ -62,10 +126,17 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void =>
} finally {
spinner?.cancel()
}
const seconds = tool_use_id ? await secondsSince($, pending.get(tool_use_id)) : undefined
if (tool_use_id) {
const seconds = await secondsSince($, pending.get(tool_use_id))
pending.delete(tool_use_id)
if (route) records.set(tool_use_id, { model: route.model, rationale: route.rationale, seconds })
if (route) {
records.set(tool_use_id, {
task: input.description || input.subagent_type || tool,
model: shortModel(route.model),
rationale: route.rationale,
seconds,
})
}
$.ui.invalidate('ui.render')
}
if (!route) return next(e)
Expand All @@ -92,27 +163,35 @@ export const registerSubagentRouting = (on: On, options: PluginOptions): void =>
],
})] })
}
// Claude's row can name only the agent type, so the routed model is always shown.
const children = [original, Box({
flexDirection: 'row',
children: [
// Labels recede so the routed model stands out.
Text({ dimColor: true, children: [' ↳ Selected Model: '] }),
// The same green as the status band's ON pill.
Text({ color: 'green', bold: true, children: [shortModel(record.model)] }),
// How long routing took; omitted when no clock was available.
...(record.seconds === undefined
? []
: [Text({ dimColor: true, children: [` (routed in ${record.seconds.toFixed(1)}s)`] })]),
],
})]
// Align the reason, including its wrapped lines, under "Selected Model" after the arrow.
if (record.rationale) {
children.push(Box({
paddingLeft: 4,
children: [Text({ children: [Text({ dimColor: true, children: ['Reason: '] }), record.rationale] })],
}))
if (grouped.has(e.requestId)) return original
// Claude's row can name only the agent type, so the routed model is always shown,
// with the reason aligned under "Selected Model" after the arrow.
return Box({ flexDirection: 'column', children: [original, ...selection($, e, record, ' ↳ ', 4)] })
})

// Claude's parallel-agent block hides the row spinner, so the turn's own spinner line
// also says routing is under way, timed from the oldest call still waiting.
on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
if (!pending.size) return next(e)
const starts = [...pending.values()].filter((start) => start !== undefined) as number[]
const seconds = starts.length ? await secondsSince($, Math.min(...starts)) : undefined
const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s`
const what = pending.size > 1 ? `Selecting models for ${pending.size} agents` : 'Selecting a model'
return next({ ...e, props: { ...e.props, message: `${what}…${elapsed}`, suffix: '' } })
})

on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
const original = await next(e)
if (!seen.has(e.requestId)) {
seen.add(e.requestId)
// A call still routing waits for a later reply; one that failed routing is dropped.
const ready = cards.unplaced.filter((id) => records.has(id))
cards.unplaced = cards.unplaced.filter((id) => pending.has(id))
if (ready.length) anchors.set(e.requestId, ready)
}
return Box({ flexDirection: 'column', children })
const ids = anchors.get(e.requestId)
if (!ids) return original
const { Box } = $.ui.resolve(e)
return Box({ flexDirection: 'column', children: [...ids.map((id) => card($, e, records.get(id)!)), original] })
})
}
8 changes: 8 additions & 0 deletions tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ TypeScript event-forwarding and launch-option tests in CI. The existing Claude
skill-toggle integration journey checks the live recipe payload field across on/off/on.
See [the mod guide](../docs/claude-mod.md).

Native mod component tests cover a lone call's decision under its Agent row, parallel calls
drawn as cards above the parent's next reply in call order (replies drawn before routing
never used, calls still routing waiting for a later reply, cards kept across a turn that
ends while a background agent runs and dropped on abort), distinct IDs for identical tasks,
nothing above the prompt, pending row and turn-spinner routing timers, and disabled/failed
routing. They do not establish live
parallel-agent TUI or gateway coverage.

`test_ug_headers.py` covers the `UG_HEADERS` parser and the `--header` flag, `test_databricks.py`
ug's own requests carrying them, and `test_agent_codex.py` `UG_HEADERS` reaching a Codex launch's
`-c` headers (plain and smart-routed) without writing its profile. No automated test checks the
Expand Down
6 changes: 6 additions & 0 deletions tests/integration/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Integration tests

Native mod component tests in `../native_claude/test_mod.py` cover a lone call's decision under
its Agent row, parallel calls as cards above the parent's next reply in call order, calls
still routing waiting for a later reply, distinct
IDs for identical tasks, pending row and turn-spinner routing timers, and disabled/failed routing. These are not live
parallel-agent TUI or gateway checks.

`../test_claude_launch_settings.py` covers oversized Windows Claude launch
settings with controlled process boundaries: UTF-16 command length, merged
settings, literal prompts, temporary-file lifetime and cleanup, launch failures,
Expand Down
Loading
Loading