@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.
@@ -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
+ }
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: install agent-os but not the auto-route model router
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
- // Both come from the same marketplace, so it is added or refreshed once:
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 skips only the model router")}`);
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, and
6
- * auto-route, a mod that picks the model and effort per turn. Both live in
7
- * this repo's marketplace. `init` brings each to the latest version whatever
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.