@sayansr26/agent-os 0.6.0 → 0.6.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +23 -0
- package/CHANGELOG.md +39 -1
- package/README.md +32 -0
- package/package.json +2 -2
- package/plugins/agent-os/.claude-plugin/icon.png +0 -0
- package/plugins/agent-os/.claude-plugin/plugin.json +2 -0
- package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
- package/plugins/auto-handoff/LICENSE +21 -0
- package/plugins/auto-handoff/README.md +131 -0
- package/plugins/auto-handoff/UPSTREAM.md +22 -0
- package/plugins/auto-handoff/hooks/brief.ts +190 -0
- package/plugins/auto-handoff/hooks/config.ts +64 -0
- package/plugins/auto-handoff/hooks/hooks.json +1 -0
- package/plugins/auto-handoff/hooks/panel.tsx +45 -0
- package/plugins/auto-handoff/hooks/register.tsx +615 -0
- package/plugins/auto-handoff/hooks/server.ts +32 -0
- package/plugins/auto-handoff/hooks/templates.ts +17 -0
- package/plugins/auto-handoff/hooks/viewer.ts +173 -0
- package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
- package/plugins/auto-handoff/templates/brief.md +34 -0
- package/plugins/auto-handoff/templates/instructions.md +10 -0
- package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
- package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
- package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
- package/plugins/auto-handoff/tsconfig.json +4 -0
- package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
- package/plugins/auto-route/hooks/hooks.json +1 -0
- package/plugins/auto-route/hooks/register.ts +143 -0
- package/plugins/auto-route/tests/route.test.ts +87 -0
- package/plugins/auto-route/tsconfig.json +3 -0
- package/plugins/auto-route/types/index.d.ts +12 -0
- package/src/cli.mjs +25 -15
- package/src/plugin.mjs +39 -23
- package/src/selftest.mjs +9 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { test, expect } from 'claude-code/testing'
|
|
2
|
+
import { linkify } from '../hooks/config.ts'
|
|
3
|
+
|
|
4
|
+
const PLUGIN = 'auto-handoff'
|
|
5
|
+
const PATH = '/home/me/.claude/state/auto-handoff/dbd019e5-410b-4d91-8db9-6755b2924e48.md'
|
|
6
|
+
const URL = 'http://100.85.122.99:3846/dbd019e5-410b-4d91-8db9-6755b2924e48.html'
|
|
7
|
+
const SEED = `[auto-handoff] ↪ Handoff from session dbd019e5. The previous session hit its context limit and was cleared. Read the brief at ${PATH} before doing anything else (readable copy: ${URL}) and follow its Instructions section. Open your first reply with the line "↪ Handoff from session dbd019e5".`
|
|
8
|
+
|
|
9
|
+
const row = (text: string, origin: Record<string, unknown>) => ({
|
|
10
|
+
component: 'UserMessage' as const,
|
|
11
|
+
props: { text, origin, isExpanded: false } as never,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
test('linkify turns the brief path and viewer URL into markdown links', async () => {
|
|
15
|
+
const out = linkify(SEED)
|
|
16
|
+
expect(out).toContain(`[dbd019e5-410b-4d91-8db9-6755b2924e48.md](file://${PATH})`)
|
|
17
|
+
expect(out).toContain(`[${URL}](${URL})`)
|
|
18
|
+
// Everything else stays as written.
|
|
19
|
+
expect(out.startsWith('[auto-handoff] ↪ Handoff from session dbd019e5.')).toBe(true)
|
|
20
|
+
expect(out).toContain('follow its Instructions section.')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
test('linkify leaves a seed with no viewer link alone except for the path', async () => {
|
|
24
|
+
const text = `[auto-handoff] ↪ Handoff from session abc. Read the brief at ${PATH} before doing anything else and follow its Instructions section.`
|
|
25
|
+
const out = linkify(text)
|
|
26
|
+
expect(out).toContain(`(file://${PATH})`)
|
|
27
|
+
expect(out).not.toContain('http')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
test('the seed row draws as a Markdown with clickable links on every surface', async $ => {
|
|
31
|
+
for (const surface of ['terminal', 'desktop', 'vscode', 'mobile'] as const) {
|
|
32
|
+
const ui = await $.ui.mount({ plugin: PLUGIN, surface, ...row(SEED, { kind: 'plugin', name: PLUGIN }) })
|
|
33
|
+
const md = await ui.find({ type: 'Markdown' })
|
|
34
|
+
expect(md).toBeDefined()
|
|
35
|
+
expect(md?.text).toContain(`(file://${PATH})`)
|
|
36
|
+
expect(md?.text).toContain(`[${URL}](${URL})`)
|
|
37
|
+
await ui.unmount()
|
|
38
|
+
}
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
test('rows from other plugins or the person are left to the engine', async ($, on) => {
|
|
42
|
+
// Stands for the engine's own drawing beneath the plugin: a plain Text of the row.
|
|
43
|
+
on('ui.render', { component: 'UserMessage' }, async ($, e) => {
|
|
44
|
+
const { Text } = $.ui.resolve(e)
|
|
45
|
+
return <Text>{e.props.text}</Text>
|
|
46
|
+
})
|
|
47
|
+
const cases: Array<[string, Record<string, unknown>]> = [
|
|
48
|
+
[SEED, { kind: 'plugin', name: 'some-other-plugin' }],
|
|
49
|
+
[SEED, { kind: 'composer' }],
|
|
50
|
+
['[auto-handoff] Not run: the context is past the handoff threshold.', { kind: 'plugin', name: PLUGIN }],
|
|
51
|
+
]
|
|
52
|
+
for (const [text, origin] of cases) {
|
|
53
|
+
const ui = await $.ui.mount({ plugin: PLUGIN, surface: 'terminal', ...row(text, origin) })
|
|
54
|
+
expect(await ui.find({ type: 'Markdown' })).toBeUndefined()
|
|
55
|
+
expect((await ui.find({ type: 'Text' }))?.text).toBe(text)
|
|
56
|
+
await ui.unmount()
|
|
57
|
+
}
|
|
58
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, test } from 'claude-code/testing'
|
|
2
|
+
import { chainOf, parseBrief, renderPage, sections, viewerLink, withHeader } from '../hooks/viewer.ts'
|
|
3
|
+
import { SERVER_JS } from '../hooks/server.ts'
|
|
4
|
+
|
|
5
|
+
describe('viewer', () => {
|
|
6
|
+
test('parseBrief extracts header and body', () => {
|
|
7
|
+
const brief = `---
|
|
8
|
+
from: abc123
|
|
9
|
+
to: def456
|
|
10
|
+
chain: main-chain
|
|
11
|
+
tokens: 150000
|
|
12
|
+
at: 2026-10-04T12:00:00Z
|
|
13
|
+
cwd: /home/user/project
|
|
14
|
+
---
|
|
15
|
+
## Work in Progress
|
|
16
|
+
Building the viewer.
|
|
17
|
+
|
|
18
|
+
## Next Step
|
|
19
|
+
Test it.`
|
|
20
|
+
const { header, body } = parseBrief(brief)
|
|
21
|
+
expect(header.from).toBe('abc123')
|
|
22
|
+
expect(header.chain).toBe('main-chain')
|
|
23
|
+
expect(body).toContain('## Work in Progress')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
test('parseBrief handles briefs with no header', () => {
|
|
27
|
+
const brief = `## Work in Progress\nDoing stuff`
|
|
28
|
+
const { header, body } = parseBrief(brief)
|
|
29
|
+
expect(header).toEqual({})
|
|
30
|
+
expect(body).toBe(brief)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('withHeader round-trips a brief', () => {
|
|
34
|
+
const header = { from: 'abc', to: 'def', tokens: '150000' }
|
|
35
|
+
const body = '## Work\nStuff'
|
|
36
|
+
const result = withHeader(header, body)
|
|
37
|
+
const { header: h2, body: b2 } = parseBrief(result)
|
|
38
|
+
expect(h2.from).toBe('abc')
|
|
39
|
+
expect(h2.to).toBe('def')
|
|
40
|
+
expect(b2).toBe(body)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('viewerLink is the short served link, or the local file with no server', () => {
|
|
44
|
+
expect(viewerLink({ host: '100.64.0.1', port: '3846' }, '/pages', 'abc12345')).toBe('http://100.64.0.1:3846/abc12345')
|
|
45
|
+
expect(viewerLink(undefined, '/pages', 'abc12345')).toBe('file:///pages/abc12345.html')
|
|
46
|
+
expect(viewerLink(undefined, 'C:\\Users\\x/.claude/pages', 'abc12345')).toBe('file:///C:/Users/x/.claude/pages/abc12345.html')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('renderPage links the chain and escapes a brief that tries to close the script tag', () => {
|
|
50
|
+
const a = { id: 'aaaaaaaa-1', header: { chain: 'aaaaaaaa-1', at: '2026-10-04T00:00:00Z', to: 'bbbbbbbb-2' }, body: '## Work in Progress\nFirst.' }
|
|
51
|
+
const b = { id: 'bbbbbbbb-2', header: { chain: 'aaaaaaaa-1', from: 'aaaaaaaa-1', at: '2026-10-04T01:00:00Z' }, body: '## Next Step\n</script><b>x</b>' }
|
|
52
|
+
const page = renderPage(b, [a, b])
|
|
53
|
+
expect(page).toContain('href="aaaaaaaa-1.html"')
|
|
54
|
+
expect(page).toContain('Handoff Chain')
|
|
55
|
+
expect(page).not.toContain('</script><b>')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
test('previous and next follow the chain, even where a header lost its from', () => {
|
|
59
|
+
const e = (id: string, at: string, header: Record<string, string>) => ({ id, header: { at, ...header }, body: '' })
|
|
60
|
+
const a = e('aaaaaaaa-1', '1', { to: 'bbbbbbbb-2' })
|
|
61
|
+
const b = e('bbbbbbbb-2', '2', { to: 'cccccccc-3' }) // a reload dropped its from
|
|
62
|
+
const c = e('cccccccc-3', '3', { from: 'bbbbbbbb-2', to: 'dddddddd-4' })
|
|
63
|
+
const mid = renderPage(b, [a, b, c])
|
|
64
|
+
expect(mid).toContain('<a href="aaaaaaaa-1.html">← previous</a>')
|
|
65
|
+
expect(mid).toContain('<a href="cccccccc-3.html">next →</a>')
|
|
66
|
+
const last = renderPage(c, [a, b, c])
|
|
67
|
+
expect(last).toContain('next: dddddddd, still running')
|
|
68
|
+
expect(last).not.toContain('href="dddddddd-4.html"')
|
|
69
|
+
expect(renderPage(a, [a, b, c])).not.toContain('← previous')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('sections extracts visible sections', () => {
|
|
73
|
+
const body = `## Work in Progress
|
|
74
|
+
Doing X
|
|
75
|
+
|
|
76
|
+
## Instructions
|
|
77
|
+
Don't read this
|
|
78
|
+
|
|
79
|
+
## Next Step
|
|
80
|
+
Do Y
|
|
81
|
+
|
|
82
|
+
## How to Use This Brief
|
|
83
|
+
Also don't read`
|
|
84
|
+
const visible = sections(body)
|
|
85
|
+
expect(visible.length).toBe(2)
|
|
86
|
+
expect(visible[0]).toContain('Work in Progress')
|
|
87
|
+
expect(visible[1]).toContain('Next Step')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('chainOf joins a run whose chain id broke partway, through from/to links', () => {
|
|
91
|
+
const e = (id: string, at: string, header: Record<string, string>) => ({ id, header: { at, ...header }, body: '' })
|
|
92
|
+
const entries = [
|
|
93
|
+
e('a', '1', { chain: 'a', to: 'b' }),
|
|
94
|
+
e('b', '2', { chain: 'a', from: 'a', to: 'c' }),
|
|
95
|
+
e('c', '3', { chain: 'c', to: 'd' }), // the reload lost its from
|
|
96
|
+
e('d', '4', { chain: 'c', from: 'c' }),
|
|
97
|
+
e('x', '5', { chain: 'x' }),
|
|
98
|
+
]
|
|
99
|
+
expect(chainOf(entries, 'd').map(x => x.id)).toEqual(['a', 'b', 'c', 'd'])
|
|
100
|
+
expect(chainOf(entries, 'a').map(x => x.id)).toEqual(['a', 'b', 'c', 'd'])
|
|
101
|
+
expect(chainOf(entries, 'x').map(x => x.id)).toEqual(['x'])
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// A second session finds the port taken. Its child used to die with an unhandled EADDRINUSE
|
|
105
|
+
// stack trace in the log; it now says so in one line and exits 0. (Run live: see the 0.8.2 commit.)
|
|
106
|
+
test('the server exits quietly when the port is taken', () => {
|
|
107
|
+
expect(SERVER_JS).toContain("err.code === 'EADDRINUSE'")
|
|
108
|
+
expect(SERVER_JS).toContain('process.exit(0)')
|
|
109
|
+
expect(SERVER_JS.indexOf(".on('error'")).toBeLessThan(SERVER_JS.indexOf('.listen('))
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "auto-route",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Picks the model and effort level for each turn from what the prompt asks for",
|
|
5
|
+
"types": "./types/index.d.ts",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Sayan Choudhury",
|
|
8
|
+
"email": "sayan.choudhury.in@gmail.com"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/sayansr26/agent-os",
|
|
11
|
+
"repository": "https://github.com/sayansr26/agent-os",
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"model-routing",
|
|
15
|
+
"effort",
|
|
16
|
+
"mod"
|
|
17
|
+
]
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "modules": ["./register.ts"] }
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { atom, read, update } from 'claude-code'
|
|
2
|
+
import type { ModelEffort, Register } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import type { Mode, Route, Tier } from '../types'
|
|
5
|
+
|
|
6
|
+
// Edit this table to change what each tier runs on.
|
|
7
|
+
const TIERS: Record<Tier, { model: string; effort: ModelEffort }> = {
|
|
8
|
+
light: { model: 'claude-haiku-5-5', effort: 'low' },
|
|
9
|
+
standard: { model: 'claude-sonnet-5-5', effort: 'medium' },
|
|
10
|
+
deep: { model: 'claude-opus-5-5', effort: 'high' },
|
|
11
|
+
max: { model: 'claude-opus-5-5', effort: 'max' },
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Checked top to bottom; the first tier whose pattern matches wins.
|
|
15
|
+
const RULES: { tier: Tier; reason: string; pattern: RegExp }[] = [
|
|
16
|
+
{
|
|
17
|
+
tier: 'max',
|
|
18
|
+
reason: 'asked to think hard',
|
|
19
|
+
pattern: /\b(ultrathink|think (really )?hard|think harder|deep dive)\b/i,
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
tier: 'max',
|
|
23
|
+
reason: 'security or concurrency',
|
|
24
|
+
pattern:
|
|
25
|
+
/\b(security (audit|review)|vulnerabilit|race condition|deadlock|memory leak|incident|data loss)/i,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
tier: 'deep',
|
|
29
|
+
reason: 'design or debugging',
|
|
30
|
+
pattern:
|
|
31
|
+
/\b(architect|design|refactor|migrat|debug|root cause|investigate|why (is|does|doesn't|isn't)|plan|implement|build (a|the|an)|new feature|end[- ]to[- ]end|across|whole (repo|codebase|app)|optimi[sz]e|performance|review)/i,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
tier: 'light',
|
|
35
|
+
reason: 'small or lookup task',
|
|
36
|
+
pattern:
|
|
37
|
+
/^(what|which|where|who|when|is|are|does|do|can|show|list|print|explain briefly|rename|fix (the )?typo|format|lint|bump|commit message|thanks|thank you|ok|yes|no)\b/i,
|
|
38
|
+
},
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
export const classify = (text: string): Route => {
|
|
42
|
+
const words = text.trim().split(/\s+/).filter(Boolean).length
|
|
43
|
+
const rule = RULES.find(one => one.pattern.test(text))
|
|
44
|
+
|
|
45
|
+
if (rule && !(rule.tier === 'light' && words > 40)) {
|
|
46
|
+
return { tier: rule.tier, reason: rule.reason }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (words > 150) {
|
|
50
|
+
return { tier: 'deep', reason: 'long, detailed prompt' }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return words <= 8
|
|
54
|
+
? { tier: 'light', reason: 'short prompt' }
|
|
55
|
+
: { tier: 'standard', reason: 'regular coding task' }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const MODES: readonly Mode[] = ['auto', 'off', 'light', 'standard', 'deep', 'max']
|
|
59
|
+
|
|
60
|
+
const mode = atom({ plugin: 'auto-route', key: 'mode' } as const, 'auto')
|
|
61
|
+
const route = atom({ plugin: 'auto-route', key: 'route' } as const, null)
|
|
62
|
+
|
|
63
|
+
const describe = (current: Mode, last: Route | null) => {
|
|
64
|
+
if (current === 'off') {
|
|
65
|
+
return 'auto-route: off'
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const pinned = current === 'auto' ? '' : ' (pinned)'
|
|
69
|
+
const tier = current === 'auto' ? last?.tier : current
|
|
70
|
+
|
|
71
|
+
if (!tier) {
|
|
72
|
+
return 'auto-route: waiting for a prompt'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const { model, effort } = TIERS[tier]
|
|
76
|
+
|
|
77
|
+
return `auto-route: ${tier}${pinned} · ${model.replace(/^claude-/, '')} / ${effort}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const register: Register = on => {
|
|
81
|
+
on('session.start', async ($, e, next) => {
|
|
82
|
+
await $.command.register({
|
|
83
|
+
name: 'route',
|
|
84
|
+
description: 'Auto model routing: auto, off, or pin light | standard | deep | max',
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
return next(e)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
on('command.run', { command: 'route' }, async ($, e) => {
|
|
91
|
+
const asked = e.args.trim().toLowerCase()
|
|
92
|
+
|
|
93
|
+
if (asked !== '') {
|
|
94
|
+
const next = MODES.find(one => one === asked)
|
|
95
|
+
|
|
96
|
+
if (!next) {
|
|
97
|
+
return { text: `auto-route: unknown mode "${asked}". Use ${MODES.join(', ')}.` }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await update($, mode, () => next)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const text = describe(await read($, mode), await read($, route))
|
|
104
|
+
$.ui.status(text)
|
|
105
|
+
|
|
106
|
+
return { text: text.replace(/^auto-route: /, '') }
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
on('prompt.submit', async ($, e, next) => {
|
|
110
|
+
// A prompt delivered into a running turn keeps that turn's route.
|
|
111
|
+
const current = await read($, mode)
|
|
112
|
+
const isRouted = e.turnId === undefined && !e.command && current === 'auto'
|
|
113
|
+
$.ui.log(`auto-route: prompt.submit mode=${current} turnId=${e.turnId ?? '-'} command=${e.command ? 'yes' : 'no'} routed=${isRouted}`)
|
|
114
|
+
|
|
115
|
+
if (isRouted) {
|
|
116
|
+
const picked = classify(e.text)
|
|
117
|
+
await update($, route, () => picked)
|
|
118
|
+
$.ui.status(describe('auto', picked))
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return next(e)
|
|
122
|
+
}).catch(($, e, next) => {
|
|
123
|
+
$.ui.log('auto-route: prompt.submit hook failed')
|
|
124
|
+
|
|
125
|
+
return next(e)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
on('turn.step', async function* ($, e, next) {
|
|
129
|
+
const current = await read($, mode)
|
|
130
|
+
const last = await read($, route)
|
|
131
|
+
const tier = current === 'auto' ? last?.tier : current === 'off' ? undefined : current
|
|
132
|
+
|
|
133
|
+
// Subagents choose their own model; leave their requests alone.
|
|
134
|
+
if (e.agentId !== undefined || !tier) {
|
|
135
|
+
return yield* next(e)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { model, effort } = TIERS[tier]
|
|
139
|
+
$.ui.log(`auto-route: turn.step ${e.model}/${e.effort ?? '-'} -> ${model}/${effort}`)
|
|
140
|
+
|
|
141
|
+
return yield* next({ ...e, model, effort })
|
|
142
|
+
})
|
|
143
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { expect, test } from 'claude-code/testing'
|
|
2
|
+
import type { HookStream, TurnStepChunk, TurnStepInput, TurnStepResult } from 'claude-code'
|
|
3
|
+
|
|
4
|
+
import { classify } from '../hooks/register'
|
|
5
|
+
|
|
6
|
+
const STEP: TurnStepInput = {
|
|
7
|
+
turnId: 't1',
|
|
8
|
+
index: 0,
|
|
9
|
+
model: 'claude-opus-5-5',
|
|
10
|
+
effort: 'high',
|
|
11
|
+
messageCount: 1,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const ORIGIN = { kind: 'composer' } as const
|
|
15
|
+
|
|
16
|
+
const prompt = (text: string) => ({ text, wait: false, origin: ORIGIN })
|
|
17
|
+
|
|
18
|
+
const route = (args: string) => ({
|
|
19
|
+
command: 'route',
|
|
20
|
+
args,
|
|
21
|
+
origin: ORIGIN,
|
|
22
|
+
presentation: { isFullscreen: false, columns: 120 },
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
// Reads the request's stream to its end, as the engine would.
|
|
26
|
+
type Steps = { turn: { step: (e: TurnStepInput) => HookStream<TurnStepChunk, TurnStepResult> } }
|
|
27
|
+
|
|
28
|
+
const step = async ($: Steps, e: TurnStepInput) => {
|
|
29
|
+
const stream = $.turn.step(e)
|
|
30
|
+
for await (const _ of stream) {
|
|
31
|
+
}
|
|
32
|
+
return stream.result
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
test('prompts land in the expected tier', async () => {
|
|
36
|
+
expect(classify('what does this function return?').tier).toBe('light')
|
|
37
|
+
expect(classify('ok').tier).toBe('light')
|
|
38
|
+
expect(
|
|
39
|
+
classify('add a loading spinner to the submit button on the ask form and disable it while saving').tier,
|
|
40
|
+
).toBe('standard')
|
|
41
|
+
expect(classify('refactor the fix ranking so it weighs environment matches').tier).toBe('deep')
|
|
42
|
+
expect(classify('debug why the MCP search is slow').tier).toBe('deep')
|
|
43
|
+
expect(classify('ultrathink about the verification model').tier).toBe('max')
|
|
44
|
+
expect(classify('is there a race condition in the release watcher?').tier).toBe('max')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
test('the routed model and effort reach the request, subagents untouched', async ($, on) => {
|
|
48
|
+
const sent: TurnStepInput[] = []
|
|
49
|
+
on('ui.status', () => ({ value: undefined }))
|
|
50
|
+
on('prompt.submit', (_, e) => ({ text: e.text }))
|
|
51
|
+
on('turn.step', async function* (_, e) {
|
|
52
|
+
sent.push(e)
|
|
53
|
+
return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
await $.prompt.submit(prompt('what is in package.json?'))
|
|
57
|
+
await step($, STEP)
|
|
58
|
+
await step($, { ...STEP, agentId: 'a1' })
|
|
59
|
+
|
|
60
|
+
expect(sent[0]?.model).toBe('claude-haiku-5-5')
|
|
61
|
+
expect(sent[0]?.effort).toBe('low')
|
|
62
|
+
expect(sent[1]?.model).toBe('claude-opus-5-5')
|
|
63
|
+
expect(sent[1]?.effort).toBe('high')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('/route pins a tier and off stops routing', async ($, on) => {
|
|
67
|
+
const sent: TurnStepInput[] = []
|
|
68
|
+
on('command.register', () => ({ value: { command: 'route' } }))
|
|
69
|
+
on('session.start', (_, e) => ({ cwd: e.cwd }))
|
|
70
|
+
on('ui.status', () => ({ value: undefined }))
|
|
71
|
+
on('prompt.submit', (_, e) => ({ text: e.text }))
|
|
72
|
+
on('turn.step', async function* (_, e) {
|
|
73
|
+
sent.push(e)
|
|
74
|
+
return { turnId: e.turnId, index: e.index, answer: '', toolUses: [], stopReason: 'end_turn', usage: null }
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
await $.session.start({ cwd: '/', surface: 'terminal', isInteractive: true })
|
|
78
|
+
await $.command.run(route('max'))
|
|
79
|
+
await $.prompt.submit(prompt('ok'))
|
|
80
|
+
await step($, STEP)
|
|
81
|
+
expect(sent[0]?.effort).toBe('max')
|
|
82
|
+
|
|
83
|
+
await $.command.run(route('off'))
|
|
84
|
+
await step($, { ...STEP, model: 'claude-sonnet-5-5', effort: 'medium' })
|
|
85
|
+
expect(sent[1]?.model).toBe('claude-sonnet-5-5')
|
|
86
|
+
expect(sent[1]?.effort).toBe('medium')
|
|
87
|
+
})
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type Tier = 'light' | 'standard' | 'deep' | 'max'
|
|
2
|
+
|
|
3
|
+
// 'auto' classifies each prompt; a tier pins it; 'off' leaves the session's own.
|
|
4
|
+
export type Mode = 'auto' | 'off' | Tier
|
|
5
|
+
|
|
6
|
+
export type Route = { tier: Tier; reason: string }
|
|
7
|
+
|
|
8
|
+
declare module 'claude-code' {
|
|
9
|
+
interface PluginState {
|
|
10
|
+
'auto-route': { mode: Mode; route: Route | null }
|
|
11
|
+
}
|
|
12
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { join } from "node:path";
|
|
|
5
5
|
import { detect, summarise, TOOLS } from "./detect.mjs";
|
|
6
6
|
import { load, write, readIfExists, matches, BANNER, DIR } from "./source.mjs";
|
|
7
7
|
import { adopt } from "./adopt.mjs";
|
|
8
|
-
import { ensurePlugin, MARKETPLACE_NAME, PLUGIN } from "./plugin.mjs";
|
|
8
|
+
import { ensurePlugin, MARKETPLACE_NAME, PLUGIN, PLUGINS } from "./plugin.mjs";
|
|
9
9
|
import { setupState, describeState } from "../plugins/agent-os/skills/init/scripts/state.mjs";
|
|
10
10
|
import { applyScope, describe } from "./claude-setup.mjs";
|
|
11
11
|
import { createInterface } from "node:readline/promises";
|
|
@@ -33,7 +33,11 @@ Options
|
|
|
33
33
|
--root <dir> project directory (default: cwd)
|
|
34
34
|
--dry-run print what would change, write nothing
|
|
35
35
|
--force overwrite files agent-os did not generate (it refuses by default)
|
|
36
|
-
--no-plugin skip installing or updating the Claude Code
|
|
36
|
+
--no-plugin skip installing or updating the Claude Code plugins during init
|
|
37
|
+
--no-auto-route
|
|
38
|
+
init: leave out the auto-route model router
|
|
39
|
+
--no-auto-handoff
|
|
40
|
+
init: leave out the auto-handoff session handoff
|
|
37
41
|
--global init: also write ~/.claude settings without asking
|
|
38
42
|
--no-global init: leave ~/.claude alone
|
|
39
43
|
`;
|
|
@@ -265,19 +269,25 @@ Pick one:
|
|
|
265
269
|
}
|
|
266
270
|
|
|
267
271
|
if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
272
|
+
// All come from the same marketplace, so it is added or refreshed once:
|
|
273
|
+
// by the first plugin whose run gets that far.
|
|
274
|
+
let refresh = true;
|
|
275
|
+
for (const plugin of PLUGINS.filter((p) => p === PLUGIN || !argv.includes(`--no-${p}`))) {
|
|
276
|
+
console.log(`\n${bold("Claude Code plugin")} ${dim(`${plugin}@${MARKETPLACE_NAME}`)}\n`);
|
|
277
|
+
const r = ensurePlugin({ root, dry, plugin, refresh });
|
|
278
|
+
if (r.done.length || r.reason === "no-cli") refresh = false;
|
|
279
|
+
for (const line of r.done) console.log(` ${dim(line)}`);
|
|
280
|
+
if (r.ok) console.log({
|
|
281
|
+
installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
|
|
282
|
+
updated: `\n updated ${r.from} → ${r.to}`,
|
|
283
|
+
current: `\n already the latest (${r.to})`,
|
|
284
|
+
"dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
|
|
285
|
+
}[r.reason]);
|
|
286
|
+
for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
|
|
287
|
+
if (r.reason === "installed" || r.reason === "updated")
|
|
288
|
+
console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
|
|
289
|
+
}
|
|
290
|
+
console.log(`\n ${dim("--no-plugin skips this; --no-auto-route / --no-auto-handoff skip one mod")}`);
|
|
281
291
|
}
|
|
282
292
|
|
|
283
293
|
if (cmd === "init" && !dry) {
|
package/src/plugin.mjs
CHANGED
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
* Install or update the Claude Code half of agent-os during `init`.
|
|
3
3
|
*
|
|
4
4
|
* The CLI compiles rules for every tool. Claude Code additionally gets the
|
|
5
|
-
* agents, skills, per-agent memory and hooks, which ship as a plugin
|
|
6
|
-
*
|
|
5
|
+
* agents, skills, per-agent memory and hooks, which ship as a plugin; plus two
|
|
6
|
+
* mods: auto-route, which picks the model and effort per turn, and
|
|
7
|
+
* auto-handoff, which hands a full session to a fresh one instead of
|
|
8
|
+
* compacting. All three live in this repo's marketplace. `init` brings each to the latest version whatever
|
|
9
|
+
* state it finds:
|
|
7
10
|
*
|
|
8
11
|
* not installed marketplace add + install, at project scope
|
|
9
12
|
* installed, disabled enable
|
|
@@ -19,18 +22,25 @@
|
|
|
19
22
|
* on. The rules are the part that must not depend on this.
|
|
20
23
|
*/
|
|
21
24
|
import { spawnSync } from "node:child_process";
|
|
22
|
-
import { realpathSync } from "node:fs";
|
|
23
|
-
import {
|
|
25
|
+
import { readFileSync, realpathSync } from "node:fs";
|
|
26
|
+
import { cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
|
|
24
27
|
|
|
25
28
|
export const MARKETPLACE = "sayansr26/agent-os";
|
|
26
29
|
export const MARKETPLACE_NAME = "sayan-plugins";
|
|
27
30
|
export const PLUGIN = "agent-os";
|
|
28
|
-
|
|
31
|
+
// Installed by `init` in this order; each can be skipped with --no-<name>.
|
|
32
|
+
export const PLUGINS = [PLUGIN, "auto-route", "auto-handoff"];
|
|
33
|
+
const idOf = (plugin) => `${plugin}@${MARKETPLACE_NAME}`;
|
|
29
34
|
|
|
30
35
|
const run = (args, cwd) =>
|
|
31
36
|
spawnSync("claude", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 120000 });
|
|
32
37
|
const json = (r) => { try { return JSON.parse(r.stdout); } catch { return null; } };
|
|
33
38
|
const real = (p) => { try { return realpathSync(p); } catch { return p; } };
|
|
39
|
+
/** The version this package ships for a plugin, from its plugin.json. */
|
|
40
|
+
const bundled = (plugin) => {
|
|
41
|
+
try { return JSON.parse(readFileSync(new URL(`../plugins/${plugin}/.claude-plugin/plugin.json`, import.meta.url), "utf8")).version || null; }
|
|
42
|
+
catch { return null; }
|
|
43
|
+
};
|
|
34
44
|
const tail = (r) => (r.stderr || r.stdout || String(r.error || "")).trim().split("\n").slice(-3);
|
|
35
45
|
|
|
36
46
|
/** Is the Claude Code CLI usable from here? */
|
|
@@ -39,40 +49,46 @@ export function claudeAvailable() {
|
|
|
39
49
|
return !r.error && r.status === 0;
|
|
40
50
|
}
|
|
41
51
|
|
|
42
|
-
/** This project's install of
|
|
43
|
-
export function installed(root) {
|
|
52
|
+
/** This project's install of a plugin, per `claude plugin list --json`, or null. */
|
|
53
|
+
export function installed(root, plugin = PLUGIN) {
|
|
44
54
|
const list = json(run(["plugin", "list", "--json"], root));
|
|
45
55
|
if (!Array.isArray(list)) return null;
|
|
46
|
-
const mine = list.filter((p) => p.id ===
|
|
56
|
+
const mine = list.filter((p) => p.id === idOf(plugin) &&
|
|
47
57
|
(p.scope === "user" || (p.projectPath && real(p.projectPath) === real(root))));
|
|
48
58
|
return mine.find((p) => p.scope === "project" || p.scope === "local") || mine[0] || null;
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/**
|
|
52
|
-
* Bring
|
|
62
|
+
* Bring a plugin to the latest version for this project.
|
|
53
63
|
* Returns { ok, reason, from, to, done, hint } with reason one of
|
|
54
64
|
* "installed" | "updated" | "current" | "dry-run" | "no-cli" | "failed".
|
|
65
|
+
* `refresh: false` skips the marketplace step when an earlier call this run
|
|
66
|
+
* already added or updated it.
|
|
55
67
|
*/
|
|
56
|
-
export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project" } = {}) {
|
|
68
|
+
export function ensurePlugin({ root = process.cwd(), dry = false, scope = "project", plugin = PLUGIN, refresh = true } = {}) {
|
|
69
|
+
const ID = idOf(plugin);
|
|
57
70
|
if (!claudeAvailable())
|
|
58
71
|
return {
|
|
59
72
|
ok: false, reason: "no-cli", done: [],
|
|
60
73
|
hint: [
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
74
|
+
...(refresh ? [
|
|
75
|
+
"The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
|
|
76
|
+
"Inside Claude Code, run:",
|
|
77
|
+
` /plugin marketplace add ${MARKETPLACE}`,
|
|
78
|
+
] : []),
|
|
64
79
|
` /plugin install ${ID} (or /plugin update ${ID} if it is already there)`,
|
|
65
80
|
],
|
|
66
81
|
};
|
|
67
82
|
|
|
68
|
-
const before = installed(root);
|
|
69
|
-
const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
|
|
70
|
-
const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
|
|
71
|
-
|
|
83
|
+
const before = installed(root, plugin);
|
|
72
84
|
const steps = [];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
85
|
+
if (refresh) {
|
|
86
|
+
const markets = json(run(["plugin", "marketplace", "list", "--json"], root));
|
|
87
|
+
const haveMarket = Array.isArray(markets) && markets.some((m) => m.name === MARKETPLACE_NAME);
|
|
88
|
+
steps.push(haveMarket
|
|
89
|
+
? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
|
|
90
|
+
: ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
|
|
91
|
+
}
|
|
76
92
|
if (!before) steps.push(["plugin", "install", ID, "--scope", scope, "--yes"]);
|
|
77
93
|
else {
|
|
78
94
|
if (before.enabled === false) steps.push(["plugin", "enable", ID, "--scope", before.scope]);
|
|
@@ -99,14 +115,14 @@ export function ensurePlugin({ root = process.cwd(), dry = false, scope = "proje
|
|
|
99
115
|
done.push(`claude ${args.join(" ")}`);
|
|
100
116
|
}
|
|
101
117
|
|
|
102
|
-
const after = installed(root);
|
|
118
|
+
const after = installed(root, plugin);
|
|
103
119
|
const to = after?.version || null;
|
|
104
120
|
const from = before?.version || null;
|
|
105
|
-
const want =
|
|
121
|
+
const want = bundled(plugin);
|
|
106
122
|
const hint = [];
|
|
107
123
|
// The marketplace serves what is pushed to GitHub; this package may be newer.
|
|
108
124
|
if (to && want && cmpVersion(to, want) < 0)
|
|
109
|
-
hint.push(`Marketplace still serves ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
|
|
125
|
+
hint.push(`Marketplace still serves ${plugin} ${to}; this agent-os ships ${want}. It updates once ${MARKETPLACE} is pushed.`);
|
|
110
126
|
return {
|
|
111
127
|
ok: true,
|
|
112
128
|
reason: !before ? "installed" : from !== to ? "updated" : "current",
|
package/src/selftest.mjs
CHANGED
|
@@ -101,6 +101,15 @@ try {
|
|
|
101
101
|
ok(pi.stdout.includes("SETUP FRESH"), "init on an empty project reports a fresh setup");
|
|
102
102
|
ok(/marketplace add sayansr26\/agent-os|plugin marketplace add|not on PATH/.test(pi.stdout),
|
|
103
103
|
"init names the marketplace step or says why it could not run it");
|
|
104
|
+
ok(pi.stdout.includes("auto-route@sayan-plugins"), "init sets up auto-route by default");
|
|
105
|
+
ok(pi.stdout.includes("auto-handoff@sayan-plugins"), "init sets up auto-handoff by default");
|
|
106
|
+
ok((pi.stdout.match(/marketplace add/g) || []).length <= 1, "the marketplace is added once, not per plugin");
|
|
107
|
+
const na = cli(["init", "--dry-run", "--no-auto-route"], r4);
|
|
108
|
+
ok(!/install auto-route@sayan-plugins/.test(na.stdout) && /install agent-os@sayan-plugins/.test(na.stdout),
|
|
109
|
+
"--no-auto-route keeps agent-os and skips auto-route");
|
|
110
|
+
const nh = cli(["init", "--dry-run", "--no-auto-handoff"], r4);
|
|
111
|
+
ok(!/install auto-handoff@sayan-plugins/.test(nh.stdout) && /install auto-route@sayan-plugins/.test(nh.stdout),
|
|
112
|
+
"--no-auto-handoff skips only auto-handoff");
|
|
104
113
|
rmSync(r4, { recursive: true, force: true });
|
|
105
114
|
|
|
106
115
|
// A hand-written file at a generated path must survive a sync.
|