@sayansr26/agent-os 0.6.1 → 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 +12 -0
- package/CHANGELOG.md +20 -0
- package/README.md +10 -0
- package/package.json +1 -1
- 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/src/cli.mjs +5 -3
- package/src/plugin.mjs +5 -4
- package/src/selftest.mjs +4 -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
|
+
})
|
package/src/cli.mjs
CHANGED
|
@@ -35,7 +35,9 @@ Options
|
|
|
35
35
|
--force overwrite files agent-os did not generate (it refuses by default)
|
|
36
36
|
--no-plugin skip installing or updating the Claude Code plugins during init
|
|
37
37
|
--no-auto-route
|
|
38
|
-
init:
|
|
38
|
+
init: leave out the auto-route model router
|
|
39
|
+
--no-auto-handoff
|
|
40
|
+
init: leave out the auto-handoff session handoff
|
|
39
41
|
--global init: also write ~/.claude settings without asking
|
|
40
42
|
--no-global init: leave ~/.claude alone
|
|
41
43
|
`;
|
|
@@ -267,7 +269,7 @@ Pick one:
|
|
|
267
269
|
}
|
|
268
270
|
|
|
269
271
|
if (cmd === "init" && targets.includes("claude-code") && !argv.includes("--no-plugin")) {
|
|
270
|
-
//
|
|
272
|
+
// All come from the same marketplace, so it is added or refreshed once:
|
|
271
273
|
// by the first plugin whose run gets that far.
|
|
272
274
|
let refresh = true;
|
|
273
275
|
for (const plugin of PLUGINS.filter((p) => p === PLUGIN || !argv.includes(`--no-${p}`))) {
|
|
@@ -285,7 +287,7 @@ Pick one:
|
|
|
285
287
|
if (r.reason === "installed" || r.reason === "updated")
|
|
286
288
|
console.log(` ${dim("restart Claude Code, or /reload-plugins, to load it")}`);
|
|
287
289
|
}
|
|
288
|
-
console.log(`\n ${dim("--no-plugin skips this; --no-auto-route
|
|
290
|
+
console.log(`\n ${dim("--no-plugin skips this; --no-auto-route / --no-auto-handoff skip one mod")}`);
|
|
289
291
|
}
|
|
290
292
|
|
|
291
293
|
if (cmd === "init" && !dry) {
|
package/src/plugin.mjs
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
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
|
-
* auto-route,
|
|
7
|
-
*
|
|
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
|
|
8
9
|
* state it finds:
|
|
9
10
|
*
|
|
10
11
|
* not installed marketplace add + install, at project scope
|
|
@@ -28,7 +29,7 @@ export const MARKETPLACE = "sayansr26/agent-os";
|
|
|
28
29
|
export const MARKETPLACE_NAME = "sayan-plugins";
|
|
29
30
|
export const PLUGIN = "agent-os";
|
|
30
31
|
// Installed by `init` in this order; each can be skipped with --no-<name>.
|
|
31
|
-
export const PLUGINS = [PLUGIN, "auto-route"];
|
|
32
|
+
export const PLUGINS = [PLUGIN, "auto-route", "auto-handoff"];
|
|
32
33
|
const idOf = (plugin) => `${plugin}@${MARKETPLACE_NAME}`;
|
|
33
34
|
|
|
34
35
|
const run = (args, cwd) =>
|
package/src/selftest.mjs
CHANGED
|
@@ -102,10 +102,14 @@ try {
|
|
|
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
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");
|
|
105
106
|
ok((pi.stdout.match(/marketplace add/g) || []).length <= 1, "the marketplace is added once, not per plugin");
|
|
106
107
|
const na = cli(["init", "--dry-run", "--no-auto-route"], r4);
|
|
107
108
|
ok(!/install auto-route@sayan-plugins/.test(na.stdout) && /install agent-os@sayan-plugins/.test(na.stdout),
|
|
108
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");
|
|
109
113
|
rmSync(r4, { recursive: true, force: true });
|
|
110
114
|
|
|
111
115
|
// A hand-written file at a generated path must survive a sync.
|