@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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +23 -0
  2. package/CHANGELOG.md +39 -1
  3. package/README.md +32 -0
  4. package/package.json +2 -2
  5. package/plugins/agent-os/.claude-plugin/icon.png +0 -0
  6. package/plugins/agent-os/.claude-plugin/plugin.json +2 -0
  7. package/plugins/auto-handoff/.claude-plugin/plugin.json +49 -0
  8. package/plugins/auto-handoff/LICENSE +21 -0
  9. package/plugins/auto-handoff/README.md +131 -0
  10. package/plugins/auto-handoff/UPSTREAM.md +22 -0
  11. package/plugins/auto-handoff/hooks/brief.ts +190 -0
  12. package/plugins/auto-handoff/hooks/config.ts +64 -0
  13. package/plugins/auto-handoff/hooks/hooks.json +1 -0
  14. package/plugins/auto-handoff/hooks/panel.tsx +45 -0
  15. package/plugins/auto-handoff/hooks/register.tsx +615 -0
  16. package/plugins/auto-handoff/hooks/server.ts +32 -0
  17. package/plugins/auto-handoff/hooks/templates.ts +17 -0
  18. package/plugins/auto-handoff/hooks/viewer.ts +173 -0
  19. package/plugins/auto-handoff/statusline/handoff-link.sh +15 -0
  20. package/plugins/auto-handoff/templates/brief.md +34 -0
  21. package/plugins/auto-handoff/templates/instructions.md +10 -0
  22. package/plugins/auto-handoff/tests/auto-handoff.test.tsx +931 -0
  23. package/plugins/auto-handoff/tests/seed-row.test.tsx +58 -0
  24. package/plugins/auto-handoff/tests/viewer.test.ts +111 -0
  25. package/plugins/auto-handoff/tsconfig.json +4 -0
  26. package/plugins/auto-route/.claude-plugin/plugin.json +18 -0
  27. package/plugins/auto-route/hooks/hooks.json +1 -0
  28. package/plugins/auto-route/hooks/register.ts +143 -0
  29. package/plugins/auto-route/tests/route.test.ts +87 -0
  30. package/plugins/auto-route/tsconfig.json +3 -0
  31. package/plugins/auto-route/types/index.d.ts +12 -0
  32. package/src/cli.mjs +25 -15
  33. package/src/plugin.mjs +39 -23
  34. 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,4 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json",
3
+ "compilerOptions": { "allowImportingTsExtensions": true, "noEmit": true }
4
+ }
@@ -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,3 @@
1
+ {
2
+ "extends": "./.claude-plugin/types/tsconfig.json"
3
+ }
@@ -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 plugin during init
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
- console.log(`\n${bold("Claude Code plugin")} ${dim(`${PLUGIN}@${MARKETPLACE_NAME}`)}\n`);
269
- const r = ensurePlugin({ root, dry });
270
- for (const line of r.done) console.log(` ${dim(line)}`);
271
- if (r.ok) console.log({
272
- installed: `\n installed ${r.to || ""} at project scope ${dim("— .claude/settings.json, so it travels with the repo")}`,
273
- updated: `\n updated ${r.from} → ${r.to}`,
274
- current: `\n already the latest (${r.to})`,
275
- "dry-run": `\n ${dim(r.from ? `installed: ${r.from} — would refresh and update` : "not installed — would install")}`,
276
- }[r.reason]);
277
- for (const line of r.hint) console.log(` ${r.ok ? dim(line) : line}`);
278
- if (r.reason === "installed" || r.reason === "updated")
279
- console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
280
- console.log(`\n ${dim("--no-plugin skips this")}`);
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. `init`
6
- * brings that plugin to the latest version whatever state it finds:
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 { bundledVersion, cmpVersion } from "../plugins/agent-os/skills/init/scripts/state.mjs";
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
- const ID = `${PLUGIN}@${MARKETPLACE_NAME}`;
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 the plugin, per `claude plugin list --json`, or null. */
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 === 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 the plugin to the latest version for this project.
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
- "The `claude` CLI is not on PATH, so the plugin was not installed or updated.",
62
- "Inside Claude Code, run:",
63
- ` /plugin marketplace add ${MARKETPLACE}`,
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
- steps.push(haveMarket
74
- ? ["plugin", "marketplace", "update", MARKETPLACE_NAME]
75
- : ["plugin", "marketplace", "add", MARKETPLACE, "--scope", scope]);
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 = bundledVersion();
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.