Repository navigation
Render subagent routing using claude code mod #979
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
9d84069
Add ENABLE_CLAUDE_CODE_MODS flag and truthy env reader
lilly-luo 0bb2661
Add reusable Claude Mods infra; smart-routing status band
lilly-luo 7d23b05
Render subagent routing as a compact line (mods); silence plain-text …
lilly-luo 46f9f77
Render Smart Router status in Claude and verify CUJ 4 toggles
lilly-luo cfb31e2
Merge branch 'main' into claude-mods-render-status
lilly-luo 8431fb6
Relax smart router status band assertions
lilly-luo 02be464
Merge claude-mods-render-status and resolve subagent routing conflicts
lilly-luo 68a6b55
Remove tier meter from subagent routing display
lilly-luo d722bbb
Remove Claude mods flag and inline plugin source copying
lilly-luo acf9123
Route Claude subagents through native mod without sidecar
lilly-luo c9ef9ad
Simplify native Claude subagent routing
lilly-luo 5bf32b3
Merge origin/main into claude-mods-subagent-routing
lilly-luo 36bf993
Keep the Smart Router notice for persisted route-subagent hooks
lilly-luo f203341
Mark Smart Router subagent rows and drop UG's routed agent names
lilly-luo e0124a1
Remove the lightning bolt from Smart Router subagent rows
lilly-luo 287d25a
Show the selected model, routing progress, and routing time on subage…
lilly-luo 484379a
Attribute Claude subagent routing rows by prompt order in live journeys
lilly-luo 91dd0a8
Trim unrelated changes, redundant tests, and duplicate docs from suba…
lilly-luo 82f11cd
Use Claude's own spinner glyphs while a subagent is routed
lilly-luo b3551b7
Address review: steady spinner, split routing replies, drop dead argv…
lilly-luo 1f1eb1b
Keep one Claude subagent routing function to keep the change small
lilly-luo File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import type { On, PluginOptions } from 'claude-code' | ||
|
|
||
| type RoutingRecord = { 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. | ||
| const SPINNER_GLYPHS = ['·', '✢', '✳', '✶', '✻', '✽'] | ||
| const SPINNER = [...SPINNER_GLYPHS, ...SPINNER_GLYPHS.slice(1, -1).reverse()] | ||
| const SPINNER_INTERVAL_MS = 100 | ||
|
|
||
| // The timer is cosmetic: without a clock it is omitted rather than blocking routing. | ||
| const now = async ($: any): Promise<number | undefined> => { | ||
| try { | ||
| return await $.clock.now() | ||
| } catch { | ||
| return undefined | ||
| } | ||
| } | ||
| const secondsSince = async ($: any, start: number | undefined): Promise<number | undefined> => { | ||
| const current = start === undefined ? undefined : await now($) | ||
| return current === undefined || start === undefined ? undefined : Math.max(0, current - start) / 1000 | ||
| } | ||
|
|
||
| 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>() | ||
|
|
||
| on('tool.call', { tool: ['Agent', 'Task'] }, async ($, e, next) => { | ||
| const command = options.routeCommand as readonly string[] | ||
| if (!command.length) return next(e) | ||
| const { tool, tool_use_id, agentId, ...input } = e | ||
| let route | ||
| let spinner: { cancel: () => void } | undefined | ||
| if (tool_use_id) { | ||
| 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. | ||
| try { | ||
| spinner = $.clock.every(SPINNER_INTERVAL_MS, () => $.ui.invalidate('ui.render')) | ||
| } catch {} | ||
| $.ui.invalidate('ui.render') | ||
| } | ||
| try { | ||
| const session_id = await $.session.id() | ||
| const result = await $.process.run(command, { | ||
| stdin: JSON.stringify({ session_id, tool_use_id, tool_name: tool, tool_input: input }), | ||
| timeoutMs: 35000, | ||
| }) | ||
| if (result.exitCode !== 0) throw new Error('Router command failed') | ||
| // An empty response means routing is disabled or no model could be selected. | ||
| route = result.stdout.trim() ? JSON.parse(result.stdout) : undefined | ||
| if (route !== undefined && ( | ||
| typeof route?.model !== 'string' || !route.model || | ||
| typeof route.rationale !== 'string' || | ||
| typeof route.updatedInput?.subagent_type !== 'string' || !route.updatedInput.subagent_type)) { | ||
| throw new Error('Invalid routing response') | ||
| } | ||
| } catch { | ||
| route = undefined | ||
| $.ui.log('Smart Router could not route this subagent; using its original settings.') | ||
| } finally { | ||
| spinner?.cancel() | ||
| } | ||
| 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 }) | ||
| $.ui.invalidate('ui.render') | ||
| } | ||
| if (!route) return next(e) | ||
| // Preserve Claude's permission checks and every other mod's tool handlers. | ||
| return next({ ...route.updatedInput, tool, tool_use_id, agentId }) | ||
| }) | ||
|
|
||
| on('ui.render', { component: 'ToolUse' }, async ($, e, next) => { | ||
| const original = await next(e) | ||
| const record = records.get(e.requestId) | ||
| if (!record && !pending.has(e.requestId)) return original | ||
|
|
||
| const { Box, Text } = $.ui.resolve(e) | ||
| if (!record) { | ||
| const glyph = SPINNER[Math.floor(((await now($)) ?? 0) / SPINNER_INTERVAL_MS) % SPINNER.length] | ||
| const seconds = await secondsSince($, pending.get(e.requestId)) | ||
| const elapsed = seconds === undefined ? '' : ` ${Math.floor(seconds)}s` | ||
| return Box({ flexDirection: 'column', children: [original, Box({ | ||
| flexDirection: 'row', | ||
| children: [ | ||
| Text({ dimColor: true, children: [' ↳ '] }), | ||
| Text({ color: 'green', children: [`${glyph} `] }), | ||
| Text({ dimColor: true, children: [`Selecting a model…${elapsed}`] }), | ||
| ], | ||
| })] }) | ||
| } | ||
| // 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] })], | ||
| })) | ||
| } | ||
| return Box({ flexDirection: 'column', children }) | ||
| }) | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.