@ucsandman/legcli 0.8.0 → 0.10.0

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 (125) hide show
  1. package/CHANGELOG.md +121 -0
  2. package/NOTICE +8 -0
  3. package/README.md +639 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +43 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +205 -0
  8. package/docs/README.md +5 -1
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +22 -0
  11. package/docs/board-guide.md +33 -1
  12. package/docs/cli-contracts.md +36 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +23 -1
  15. package/docs/faq.md +19 -0
  16. package/docs/getting-started.md +272 -251
  17. package/docs/harness.md +319 -0
  18. package/docs/history.md +172 -0
  19. package/docs/runtime-tap.md +156 -0
  20. package/fixtures/verified.json +1 -1
  21. package/package.json +7 -3
  22. package/scripts/build-docs-site.mjs +18 -4
  23. package/scripts/check-branding.mjs +118 -0
  24. package/scripts/check-claims.mjs +1 -1
  25. package/scripts/license-sign.mjs +1 -1
  26. package/scripts/limits-table.mjs +1 -1
  27. package/scripts/live-limits.mjs +1 -1
  28. package/scripts/npm-publish-gate.mjs +114 -0
  29. package/scripts/probe.mjs +4 -3
  30. package/scripts/seed-fake-cards.mjs +4 -3
  31. package/scripts/seed-floor-board.mjs +5 -4
  32. package/scripts/seed-wes-board.mjs +5 -4
  33. package/scripts/stripe-setup.mjs +1 -1
  34. package/scripts/sync-harness-engine.mjs +159 -0
  35. package/scripts/sync-leg-agents.mjs +127 -0
  36. package/src/accounts.mjs +6 -4
  37. package/src/adapters/codex.mjs +1 -1
  38. package/src/attach.mjs +125 -23
  39. package/src/auth.mjs +2 -2
  40. package/src/board/board.css +23 -1
  41. package/src/board/board.js +17 -5
  42. package/src/board/history.js +377 -0
  43. package/src/board/index.html +33 -0
  44. package/src/board/sessions.js +95 -7
  45. package/src/bundle.mjs +54 -8
  46. package/src/chain.mjs +1 -1
  47. package/src/contract.mjs +4 -3
  48. package/src/fsx.mjs +5 -2
  49. package/src/handoff.mjs +6 -6
  50. package/src/harness/cli.mjs +281 -0
  51. package/src/harness/fingerprint.mjs +68 -0
  52. package/src/harness/index.mjs +407 -0
  53. package/src/harness/registry.mjs +124 -0
  54. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  55. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  56. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  57. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  69. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  70. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  71. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  72. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  73. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  74. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  75. package/src/history/cli.mjs +159 -0
  76. package/src/history/common.mjs +119 -0
  77. package/src/history/index.mjs +429 -0
  78. package/src/history/providers/agy.mjs +91 -0
  79. package/src/history/providers/claude.mjs +161 -0
  80. package/src/history/providers/codex.mjs +133 -0
  81. package/src/history/providers/copilot.mjs +94 -0
  82. package/src/history/providers/grok.mjs +138 -0
  83. package/src/history/worktrees.mjs +116 -0
  84. package/src/hook.mjs +49 -49
  85. package/src/land.mjs +7 -35
  86. package/src/launcher.mjs +38 -26
  87. package/src/ledger.mjs +6 -6
  88. package/src/license.mjs +10 -9
  89. package/src/live-capture.mjs +1 -1
  90. package/src/mergequeue.mjs +5 -5
  91. package/src/orchestrator.mjs +28 -4
  92. package/src/preferences.mjs +37 -3
  93. package/src/redact.mjs +24 -6
  94. package/src/resume.mjs +17 -15
  95. package/src/runner.mjs +2 -2
  96. package/src/scheduler.mjs +1 -1
  97. package/src/server.mjs +224 -18
  98. package/src/session-detail.mjs +15 -1
  99. package/src/sessions.mjs +15 -3
  100. package/src/share.mjs +2 -2
  101. package/src/stations/agent.mjs +1 -1
  102. package/src/sync/dashclaw.mjs +4 -4
  103. package/src/synthesis.mjs +165 -0
  104. package/src/taps/agy.mjs +2 -2
  105. package/src/taps/claude-usage.mjs +1 -1
  106. package/src/taps/claude.mjs +177 -170
  107. package/src/taps/codex.mjs +286 -286
  108. package/src/taps/grok.mjs +2 -2
  109. package/src/taps/mod.mjs +340 -0
  110. package/src/trust.mjs +205 -36
  111. package/src/usage.mjs +5 -1
  112. package/src/worktree.mjs +6 -5
  113. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  114. package/fixtures/live/agy/err.log +0 -0
  115. package/fixtures/live/agy/out.log +0 -1
  116. package/fixtures/live/agy/supervisor.log +0 -2
  117. package/fixtures/live/claude/err.log +0 -0
  118. package/fixtures/live/claude/out.log +0 -1
  119. package/fixtures/live/claude/supervisor.log +0 -2
  120. package/fixtures/live/codex/err.log +0 -1
  121. package/fixtures/live/codex/out.log +0 -8
  122. package/fixtures/live/codex/supervisor.log +0 -2
  123. package/fixtures/live/grok/err.log +0 -32
  124. package/fixtures/live/grok/out.log +0 -7
  125. package/fixtures/live/grok/supervisor.log +0 -2
@@ -0,0 +1,127 @@
1
+ #!/usr/bin/env node
2
+ // Keep packages/leg-agents on the same version as @ucsandman/legcli.
3
+ // Source of truth: the root package.json version and engines.node.
4
+ //
5
+ // node scripts/sync-leg-agents.mjs write version, pin, LICENSE, NOTICE
6
+ // node scripts/sync-leg-agents.mjs --check exit 1 if any of those drifted
7
+ //
8
+ // npm test runs --check. `npm version` runs the writer so a bump cannot
9
+ // leave the alias behind.
10
+ import { copyFileSync, readFileSync, writeFileSync } from 'node:fs'
11
+ import { execFileSync } from 'node:child_process'
12
+ import { dirname, join, resolve } from 'node:path'
13
+ import { fileURLToPath, pathToFileURL } from 'node:url'
14
+ import { resolveCommand } from '../src/commands.mjs'
15
+
16
+ export const ALIAS_NAME = 'leg-agents'
17
+ export const PRIMARY_NAME = '@ucsandman/legcli'
18
+ export const PACKED_FILES = ['package.json', 'bin/leg.mjs', 'LICENSE', 'NOTICE', 'README.md']
19
+
20
+ const root = join(dirname(fileURLToPath(import.meta.url)), '..')
21
+
22
+ export function inspect (rootDir = root) {
23
+ const alias = join(rootDir, 'packages', 'leg-agents')
24
+ const rootPkg = JSON.parse(readFileSync(join(rootDir, 'package.json'), 'utf8'))
25
+ const aliasPkg = JSON.parse(readFileSync(join(alias, 'package.json'), 'utf8'))
26
+ const wrapper = readFileSync(join(alias, 'bin', 'leg.mjs'), 'utf8')
27
+ const rootLicense = readFileSync(join(rootDir, 'LICENSE'))
28
+ const rootNotice = readFileSync(join(rootDir, 'NOTICE'))
29
+ const aliasLicense = readFileSync(join(alias, 'LICENSE'))
30
+ const aliasNotice = readFileSync(join(alias, 'NOTICE'))
31
+ const problems = []
32
+
33
+ if (rootPkg.name !== PRIMARY_NAME) {
34
+ problems.push(`root package name is ${rootPkg.name}, expected ${PRIMARY_NAME}`)
35
+ }
36
+ if (aliasPkg.name !== ALIAS_NAME) {
37
+ problems.push(`alias package name is ${aliasPkg.name}, expected ${ALIAS_NAME}`)
38
+ }
39
+ if (aliasPkg.version !== rootPkg.version) {
40
+ problems.push(`alias version ${aliasPkg.version} != ${PRIMARY_NAME} ${rootPkg.version}`)
41
+ }
42
+ const pin = aliasPkg.dependencies?.[PRIMARY_NAME]
43
+ if (pin !== rootPkg.version) {
44
+ problems.push(`alias pins ${PRIMARY_NAME}@${pin ?? '(missing)'}, expected ${rootPkg.version}`)
45
+ }
46
+ if (aliasPkg.engines?.node !== rootPkg.engines?.node) {
47
+ problems.push(`alias engines.node ${aliasPkg.engines?.node} != root ${rootPkg.engines?.node}`)
48
+ }
49
+ if (aliasPkg.bin?.leg !== 'bin/leg.mjs') {
50
+ problems.push(`alias bin.leg is ${aliasPkg.bin?.leg}, expected bin/leg.mjs`)
51
+ }
52
+ if (!Array.isArray(rootPkg.files) || rootPkg.files.includes('packages') || rootPkg.files.some((f) => String(f).startsWith('packages/'))) {
53
+ problems.push('root package.json files must not ship packages/leg-agents')
54
+ }
55
+ if (!rootLicense.equals(aliasLicense)) problems.push('packages/leg-agents/LICENSE does not match root LICENSE')
56
+ if (!rootNotice.equals(aliasNotice)) problems.push('packages/leg-agents/NOTICE does not match root NOTICE')
57
+ if (!wrapper.startsWith('#!/usr/bin/env node')) {
58
+ problems.push('packages/leg-agents/bin/leg.mjs is missing the node shebang')
59
+ }
60
+ if (!wrapper.includes("require.resolve('@ucsandman/legcli/package.json')")) {
61
+ problems.push('alias wrapper does not resolve @ucsandman/legcli/package.json')
62
+ }
63
+ if (!wrapper.includes('pathToFileURL')) {
64
+ problems.push('alias wrapper must import via pathToFileURL so Windows absolute paths work')
65
+ }
66
+ if (!wrapper.includes("join(pkgRoot, 'bin', 'leg.mjs')")) {
67
+ problems.push('alias wrapper does not load bin/leg.mjs from the pinned package')
68
+ }
69
+
70
+ return { rootPkg, aliasPkg, wrapper, problems, aliasDir: alias }
71
+ }
72
+
73
+ export function packedFileList (dir) {
74
+ const { bin, args } = resolveCommand('npm pack --dry-run --json --ignore-scripts')
75
+ const stdout = execFileSync(bin, args, { cwd: dir, encoding: 'utf8', windowsHide: true })
76
+ const startArr = stdout.indexOf('[')
77
+ const startObj = stdout.indexOf('{')
78
+ const start = startArr === -1 ? startObj : startObj === -1 ? startArr : Math.min(startArr, startObj)
79
+ if (start === -1) throw new Error(`npm pack produced no JSON:\n${stdout}`)
80
+ const parsed = JSON.parse(stdout.slice(start))
81
+ const entry = Array.isArray(parsed) ? parsed[0] : parsed
82
+ const files = (entry.files || []).map((f) => f.path || f)
83
+ return files.sort()
84
+ }
85
+
86
+ export function sync (rootDir = root) {
87
+ const { rootPkg, aliasPkg } = inspect(rootDir)
88
+ const alias = join(rootDir, 'packages', 'leg-agents')
89
+ const next = {
90
+ ...aliasPkg,
91
+ version: rootPkg.version,
92
+ dependencies: { ...aliasPkg.dependencies, [PRIMARY_NAME]: rootPkg.version },
93
+ engines: { ...aliasPkg.engines, node: rootPkg.engines.node }
94
+ }
95
+ writeFileSync(join(alias, 'package.json'), JSON.stringify(next, null, 2) + '\n')
96
+ copyFileSync(join(rootDir, 'LICENSE'), join(alias, 'LICENSE'))
97
+ copyFileSync(join(rootDir, 'NOTICE'), join(alias, 'NOTICE'))
98
+ return next
99
+ }
100
+
101
+ const isMain = Boolean(process.argv[1]) && import.meta.url === pathToFileURL(resolve(process.argv[1])).href
102
+
103
+ if (isMain) {
104
+ const check = process.argv.includes('--check')
105
+ if (!check) sync()
106
+ const { rootPkg, problems, aliasDir: dir } = inspect()
107
+ let packed
108
+ try {
109
+ packed = packedFileList(dir)
110
+ } catch (error) {
111
+ problems.push(`npm pack --dry-run failed: ${error.message}`)
112
+ }
113
+ if (packed) {
114
+ const expected = [...PACKED_FILES].sort()
115
+ if (packed.join('\n') !== expected.join('\n')) {
116
+ problems.push(`packed files are [${packed.join(', ')}], expected [${expected.join(', ')}]`)
117
+ }
118
+ }
119
+ if (problems.length) {
120
+ console.error(`leg-agents is out of lockstep with ${PRIMARY_NAME}@${rootPkg.version}:\n`)
121
+ for (const p of problems) console.error(` - ${p}`)
122
+ console.error('\nRun: node scripts/sync-leg-agents.mjs')
123
+ process.exitCode = 1
124
+ } else {
125
+ console.log(`leg-agents: in lockstep with ${PRIMARY_NAME}@${rootPkg.version}`)
126
+ }
127
+ }
package/src/accounts.mjs CHANGED
@@ -43,13 +43,15 @@ export const LAYOUT = {
43
43
  export function accountsFile() { return join(home(), 'accounts.json') }
44
44
  export function accountDir(agent, name) { return join(home(), 'accounts', agent, name) }
45
45
 
46
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]{0,29}$/i
47
+
46
48
  export function readAccounts() {
47
- const base = { claude: ['default'], codex: ['default'], agy: ['default'] }
49
+ const base = { claude: ['default'], codex: ['default'], agy: ['default'], grok: ['default'] }
48
50
  if (!existsSync(accountsFile())) return base
49
51
  try {
50
52
  const j = JSON.parse(readFileSync(accountsFile(), 'utf8'))
51
- for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => n !== 'default')]
52
- if (Array.isArray(j?.grok)) base.grok = ['default', ...j.grok.filter((n) => n !== 'default')]
53
+ // the same shape addAccount accepts: a name is a directory segment, never a path
54
+ for (const k of Object.keys(base)) if (Array.isArray(j[k])) base[k] = ['default', ...j[k].filter((n) => typeof n === 'string' && n !== 'default' && NAME_RE.test(n))]
53
55
  } catch {}
54
56
  return base
55
57
  }
@@ -75,7 +77,7 @@ function junction(target, link) {
75
77
 
76
78
  // Create the account dir, junction the shared harness in, copy the settings.
77
79
  export function addAccount(agent, name) {
78
- if (!/^[a-z0-9][a-z0-9_-]{0,29}$/i.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
80
+ if (!NAME_RE.test(name) || name === 'default') throw new Error(`invalid account name "${name}" (letters, digits, - and _; not "default")`)
79
81
  const l = LAYOUT[agent]
80
82
  if (!l) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
81
83
  if (!l.env) throw new Error(`${agent} has no config-dir override in the installed version; extra accounts are not possible`)
@@ -19,7 +19,7 @@ const adapter = {
19
19
  forbiddenFlags: ['danger-full-access', '--dangerously-bypass-approvals-and-sandbox', '--dangerously-bypass-hook-trust', '--full-auto', '--approve-for-me'],
20
20
  resolve() {
21
21
  if ((process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN)) {
22
- const bin = process.env.BATON_CODEX_BIN
22
+ const bin = process.env.LEG_CODEX_BIN || process.env.BATON_CODEX_BIN
23
23
  return { bin, viaNode: /\.(mjs|cjs|js)$/.test(bin), entry: null }
24
24
  }
25
25
  // The npm package's bin/codex.js only spawns the platform package's native
package/src/attach.mjs CHANGED
@@ -1,5 +1,5 @@
1
- // attach — `baton claude|codex|agy [args…]`: the normal interactive agent in
2
- // this terminal, with Baton alongside it. Baton (1) makes sure the board is
1
+ // attach — `leg claude|codex|agy [args…]`: the normal interactive agent in
2
+ // this terminal, with Leg alongside it. Leg (1) makes sure the board is
3
3
  // up and opens it once, (2) registers the session so it shows on the board,
4
4
  // (3) taps the agent for usage (claude: hooks + status line via --settings;
5
5
  // codex: its rollout file; agy: its log), (4) polls git for the files the
@@ -8,6 +8,7 @@
8
8
  // same terminal from that bundle. Subscription logins only: API keys are
9
9
  // stripped from the child environment (src/env.mjs).
10
10
  import http from 'node:http'
11
+ import net from 'node:net'
11
12
  import { spawn, spawnSync } from 'node:child_process'
12
13
  import { existsSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
13
14
  import { join, dirname, resolve, relative } from 'node:path'
@@ -28,16 +29,27 @@ import { findRollout, createTail, parseLines, readCodexUsage, transcriptTail as
28
29
  import { scanLog, promptsSince, logSize } from './taps/agy.mjs'
29
30
  import { fetchGrokUsage, scanLog as scanGrokLog, promptsSince as grokPromptsSince } from './taps/grok.mjs'
30
31
  import { fetchClaudeUsage } from './taps/claude-usage.mjs'
31
- import { saveSessionBundle, resumePrompt } from './bundle.mjs'
32
+ import { saveSessionBundle, resumePrompt, sessionCommitDelta } from './bundle.mjs'
32
33
  import { endSessionPointer } from './resume.mjs'
33
34
  import { openBoard, pidfile } from './launcher.mjs'
34
35
  import { LAYOUT } from './accounts.mjs'
35
36
  import { captureLive } from './live-capture.mjs'
36
37
  import { waitForReset, fmtCountdown } from './wait.mjs'
37
38
  import { readPreferences, normalizeHandoffOrder, resolveAutoApprove } from './preferences.mjs'
39
+ import { prepareHarnessForHandoff, harnessLine } from './harness/index.mjs'
40
+ import { insideKnownStore } from './history/index.mjs'
38
41
 
39
42
  const SRC = dirname(fileURLToPath(import.meta.url))
40
- const SERVER = join(SRC, 'server.mjs')
43
+ function resolveServer() {
44
+ const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(SRC)
45
+ if (wtMatch) {
46
+ const root = SRC.slice(0, wtMatch.index)
47
+ const mainServer = join(root, 'src', 'server.mjs')
48
+ if (existsSync(mainServer)) return mainServer
49
+ }
50
+ return join(SRC, 'server.mjs')
51
+ }
52
+ const SERVER = resolveServer()
41
53
  const POLL_MS = Number(process.env.LEG_ATTACH_POLL_MS || process.env.BATON_ATTACH_POLL_MS || 2000)
42
54
  const GIT_EVERY = 3 // polls
43
55
  const USAGE_MS = Number(process.env.LEG_USAGE_POLL_MS || process.env.BATON_USAGE_POLL_MS || 60000)
@@ -70,6 +82,19 @@ function health(port, host = '127.0.0.1') {
70
82
  })
71
83
  }
72
84
 
85
+ // Does anything own this port? A completed TCP connect is the question, so a
86
+ // board too busy to answer /api/health still counts as one. Nothing is sent.
87
+ function portTaken(port, host = '127.0.0.1') {
88
+ return new Promise((res) => {
89
+ const sock = net.connect({ host: host === '0.0.0.0' ? '127.0.0.1' : host, port })
90
+ const done = (v) => { sock.destroy(); res(v) }
91
+ sock.setTimeout(2000)
92
+ sock.on('connect', () => done(true))
93
+ sock.on('error', () => done(false))
94
+ sock.on('timeout', () => done(false))
95
+ })
96
+ }
97
+
73
98
  export async function ensureBoard({ open = true } = {}) {
74
99
  // with share on the board lives on the shared address, not loopback
75
100
  const share = readShare()
@@ -78,18 +103,31 @@ export async function ensureBoard({ open = true } = {}) {
78
103
  const host = shared ? share.bind : '127.0.0.1'
79
104
  const url = `http://${host}:${port}`
80
105
  if ((process.env.LEG_NO_BOARD || process.env.BATON_NO_BOARD) === '1') return { url: null, started: false, skipped: true }
81
- if (await health(port, host)) return { url, started: false }
106
+ // the board is opened whether or not this terminal is the one that started
107
+ // it: `leg claude` in a second terminal still means "show me the board"
108
+ if (await health(port, host)) { if (open) openBoard(url); return { url, started: false } }
109
+ // A board that is merely busy misses the health deadline while still owning
110
+ // the port. Treating that as "no board" spawned a second server that could
111
+ // only die of EADDRINUSE, and the poll below then waited the full fifteen
112
+ // seconds for a child already gone — the whole delay before the agent
113
+ // starts, and the reason no browser ever opened. A listener on the port is
114
+ // a board: attach to it and open it.
115
+ if (await portTaken(port, host)) {
116
+ say(`the board on ${url} is busy; attaching to it`)
117
+ if (open) openBoard(url)
118
+ return { url, started: false, busy: true }
119
+ }
82
120
  mkdirSync(home(), { recursive: true })
83
121
  const logFd = (await import('node:fs')).openSync(join(home(), 'board.log'), 'a')
84
- const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
122
+ const child = spawn(process.execPath, [SERVER], { detached: true, windowsHide: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, LEG_PORT: String(port), LEG_BIND: host, LEG_QUIET: '0', BATON_PORT: String(port), BATON_BIND: host, BATON_QUIET: '0' } })
85
123
  child.unref()
86
124
  const t0 = Date.now()
87
125
  while (Date.now() - t0 < 15000) {
88
126
  const h = await health(port, host)
89
127
  if (h) {
90
128
  // only claim the pidfile for a child we actually started: under a race,
91
- // another `baton` won the port and ours died on EADDRINUSE — writing our
92
- // dead pid would make `baton down` kill nothing and report "not running"
129
+ // another `leg` won the port and ours died on EADDRINUSE — writing our
130
+ // dead pid would make `leg down` kill nothing and report "not running"
93
131
  const ours = h.pid ? h.pid === child.pid : (child.exitCode === null && Boolean(child.pid))
94
132
  if (ours) writeFileSync(pidfile(), JSON.stringify({ pid: child.pid, port, bind: host, children: [child.pid], detached: true, started_by: 'attach', started_at: new Date().toISOString() }, null, 2) + '\n')
95
133
  if (open) openBoard(url)
@@ -195,7 +233,7 @@ export async function spawnSpec(agent, { account, args, sessionId, prompt, cwd,
195
233
  const argv = []
196
234
  // viaNode: either an npm entry (codex bin/codex.js) or a BATON_<AGENT>_BIN that names a .mjs (tests)
197
235
  if (viaNode) argv.push(entry ?? bin)
198
- // a leg Baton starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
236
+ // a leg Leg starts on its own (after a hand-off) takes BATON_<AGENT>_ARGS,
199
237
  // e.g. BATON_CODEX_ARGS="-m gpt-5-mini" to keep a test chain on cheap models
200
238
  if (prompt) args = [...(process.env[`LEG_${agent.toUpperCase()}_ARGS`] ?? process.env[`BATON_${agent.toUpperCase()}_ARGS`] ?? '').split(/\s+/).filter(Boolean), ...args]
201
239
  if (agent === 'claude') {
@@ -259,6 +297,13 @@ async function runLeg({ agent, account, args, session, prompt, boardUrl, autoApp
259
297
 
260
298
  // taps
261
299
  let rollout = null; let tail = null
300
+ // a continued codex thread appends to its old rollout, which findRollout
301
+ // (newest file since this leg started) would never pick: bind it up front
302
+ // and read only what the thread writes from here on
303
+ if (agent === 'codex' && !prompt && session.transcript_path && session.agent_session_id && existsSync(session.transcript_path)) {
304
+ rollout = { path: session.transcript_path, meta: { id: session.agent_session_id } }
305
+ tail = createTail(rollout.path, { from: logSize(rollout.path) })
306
+ }
262
307
  let polls = 0; let warned = false
263
308
  let stop = null
264
309
  const done = new Promise((res) => { stop = res })
@@ -484,14 +529,14 @@ function messagesFor(agent, s) {
484
529
  // an order save is consumed here, or the editor sees handing_off and refuses.
485
530
  // No eligible choice leaves the session unclaimed so all-out waiting can keep
486
531
  // accepting order edits.
487
- export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000) }) {
532
+ export function claimHandoffChoice({ sid, agent, account, installed, bundle = null, reason = 'limit', nowS = Math.floor(Date.now() / 1000), exclude = [] }) {
488
533
  let choice = { next: null, out: [] }
489
534
  let claimed = false
490
535
  const session = updateSession(sid, (current) => {
491
536
  const accounts = readAccounts()
492
537
  const order = normalizeHandoffOrder(current.handoff_order)
493
- choice = chooseNext({ agent, account, accounts, installed, order, nowS })
494
- if (!choice.next && isAvailable(readUsage(agent, account), nowS)) choice = { next: { agent, account }, out: [] }
538
+ choice = chooseNext({ agent, account, accounts, installed, order, nowS, exclude })
539
+ if (!choice.next && isAvailable(readUsage(agent, account), nowS) && !exclude.some((x) => x.agent === agent && x.account === account)) choice = { next: { agent, account }, out: [] }
495
540
  if (!choice.next) return {}
496
541
  claimed = true
497
542
  return {
@@ -510,15 +555,34 @@ export function claimHandoffChoice({ sid, agent, account, installed, bundle = nu
510
555
  return { choice, claimed, session }
511
556
  }
512
557
 
558
+ // The portable harness, decided before a leg starts (src/harness/index.mjs).
559
+ // Off by default: then this records nothing and changes nothing. On, it
560
+ // carries the source agent's working environment to the agent about to run,
561
+ // per the saved policy, and the session keeps the outcome so the board can say
562
+ // what transferred and what did not. Never throws into the session.
563
+ function prepareLegHarness({ sid, from, to }) {
564
+ let outcome
565
+ try { outcome = prepareHarnessForHandoff({ from, to, sessionId: sid }) } catch (err) { outcome = { state: 'error', policy: 'unknown', proceed: true, to, target: to, reason: String(err.message).slice(0, 200), summary: `harness error: ${String(err.message).slice(0, 120)}` } }
566
+ if (outcome.state === 'off') return outcome
567
+ updateSession(sid, { harness: outcome })
568
+ const line = harnessLine(outcome)
569
+ if (line) { say(line); appendEvent(sid, { type: outcome.proceed ? 'harness' : 'harness_blocked', summary: line, body: outcome.dropped?.length || outcome.attention?.length ? [...(outcome.attention ?? []).map((a) => `attention ${a.component}: ${a.reason}`), ...(outcome.dropped ?? []).map((d) => `dropped ${d.component}: ${d.item}: ${d.reason}`)].join('\n') : undefined }) }
570
+ return outcome
571
+ }
572
+
513
573
  // ---- the command ----
514
- export async function attach(agent, args = [], { open = true } = {}) {
574
+ // `cwd` and `continued` are how `leg history continue` starts a leg on a
575
+ // conversation the agent's own store holds (src/history/cli.mjs): the leg runs
576
+ // in that conversation's folder, shares the checkout (its files are already
577
+ // there), and the session record carries the agent's id from the start.
578
+ export async function attach(agent, args = [], { open = true, cwd: cwdOpt = null, continued = null } = {}) {
515
579
  if (!SUPERVISED_AGENTS.includes(agent)) throw new Error(`unknown agent "${agent}" (claude|codex|agy|grok)`)
516
580
  // the paid gate: a valid key, or no session (exit 4). The bare agent is never
517
- // affected; only what Baton adds is licensed.
581
+ // affected; only what Leg adds is licensed.
518
582
  const ent = entitlement()
519
583
  if (!allows(ent, 'run')) { say(describeLicense(ent)); return 4 }
520
- // --no-worktree is Baton's flag, not the agent's: it never passes through
521
- const shareCheckout = args.includes('--no-worktree')
584
+ // --no-worktree is Leg's flag, not the agent's: it never passes through
585
+ const shareCheckout = args.includes('--no-worktree') || Boolean(continued)
522
586
  args = args.filter((a) => a !== '--no-worktree')
523
587
  let autoApproveCli = null
524
588
  if (args.includes('--no-auto-approve')) {
@@ -529,7 +593,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
529
593
  args = args.filter((a) => a !== '--auto-approve')
530
594
  }
531
595
  const autoApprove = resolveAutoApprove({ cliFlag: autoApproveCli })
532
- const cwd = process.cwd()
596
+ const cwd = cwdOpt ? realPath(cwdOpt) : process.cwd()
533
597
  const board = await ensureBoard({ open })
534
598
  let accounts = readAccounts()
535
599
  const installed = await installedAgents()
@@ -562,6 +626,13 @@ export async function attach(agent, args = [], { open = true } = {}) {
562
626
  // `git worktree add` still leaves a card (with a Remove button), never a
563
627
  // silent orphan under .baton-worktrees with no record and no button
564
628
  createSession({ id: sid, agent, account, cwd, repo: g.repo, branch: g.branch, argv: args, chain, worktree: null, owner: whoami(), handoffOrder, installed, runtimeCapabilities: [HANDOFF_ORDER_CAPABILITY] })
629
+ if (continued) {
630
+ // the agent's own id and transcript are known before the first turn, so
631
+ // history dedups this leg against the conversation it continues at once
632
+ const safeTranscript = (continued.transcript_path && insideKnownStore(continued.transcript_path)) ? continued.transcript_path : null
633
+ updateSession(sid, { agent_session_id: continued.native_id ?? null, transcript_path: safeTranscript, task: continued.title ?? null, continued_from: { id: continued.id, provider: continued.provider, native_id: continued.native_id ?? null } },
634
+ { event: { type: 'continued', summary: `continuing ${continued.id}${continued.title ? `: ${String(continued.title).slice(0, 120)}` : ''}` } })
635
+ }
565
636
  let iso = null
566
637
  if (g.repo && !shareCheckout) {
567
638
  try { iso = isolate({ g, cwd, sid }) } catch (err) { say(`could not make a worktree (${String(err.message).split('\n')[0].slice(0, 200)}); sharing the checkout`); try { removeWorktree(g.repo, sid) } catch {} }
@@ -578,6 +649,9 @@ export async function attach(agent, args = [], { open = true } = {}) {
578
649
  let prompt = null
579
650
  let legArgs = args
580
651
  let exit = 0
652
+ // the first leg is the agent the human chose: its harness is prepared per
653
+ // policy and recorded, never refused (strict applies to hand-offs)
654
+ prepareLegHarness({ sid, from: null, to: agent })
581
655
  // unbounded: the 12-leg cap below stops a runaway chain, and the all-out wait
582
656
  // bounds a wait; a normal session runs one leg and exits
583
657
  for (let leg = 0; ; leg++) {
@@ -593,10 +667,14 @@ export async function attach(agent, args = [], { open = true } = {}) {
593
667
  // saveSessionBundle writes the notes file before it shells out to chb, so
594
668
  // even when chb is missing and the save throws, the context is on disk
595
669
  const notesFile = join(workRoot(cur) ?? cur.cwd, '.leg', `session-${sid}.md`)
596
- let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
670
+ // destinations the strict harness policy refused during this hand-off
671
+ const excluded = []
672
+ let claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
597
673
  let choice = claim.choice
598
674
  let cancelled = false
675
+ let blocked = false
599
676
  while (!choice.next) {
677
+ if (excluded.length && !choice.out.length) { blocked = true; break }
600
678
  // every option is out: keep the terminal, count down to the SOONEST reset
601
679
  // (the current agent's own wall included — it may be the first back), then
602
680
  // start that option from the bundle. Ctrl-C (or End) quits with exit 3.
@@ -612,7 +690,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
612
690
  updateSession(sid, { status: 'waiting', all_out: all, waiting: first ? { agent: first.agent, account: first.account, resets_at: first.resets_at, since: new Date().toISOString() } : null }, { event: { type: 'all_out', summary: `every option is out; waiting for ${label} at ${first ? fmtReset(first.resets_at) : 'unknown'}` } })
613
691
  const r2 = await waitInTerminal({ sid, label, resetsAt: first?.resets_at ?? null })
614
692
  if (r2 === 'cancelled') { cancelled = true; break }
615
- claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason })
693
+ claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
616
694
  choice = claim.choice
617
695
  }
618
696
  if (cancelled) {
@@ -620,6 +698,24 @@ export async function attach(agent, args = [], { open = true } = {}) {
620
698
  exit = 3
621
699
  break
622
700
  }
701
+ // strict harness policy: a destination whose harness cannot be made safe is
702
+ // refused and the next option is tried; when none is left the session ends
703
+ // with exit 5 rather than launching an agent without its environment
704
+ while (!blocked && choice.next) {
705
+ const prepared = prepareLegHarness({ sid, from: agent, to: choice.next.agent })
706
+ if (prepared.proceed) break
707
+ excluded.push(choice.next)
708
+ say(`${choice.next.agent} refused by the strict harness policy: ${prepared.reason ?? prepared.state}`)
709
+ claim = claimHandoffChoice({ sid, agent, account, installed, bundle, reason: r.reason, exclude: excluded })
710
+ choice = claim.choice
711
+ if (!choice.next) blocked = true
712
+ }
713
+ if (blocked) {
714
+ say('every remaining option was refused by the strict harness policy; stopping (exit 5). Fix what needs attention (leg harness status) or relax the policy (leg harness policy sync), then run leg again in this directory.')
715
+ updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: 5, waiting: null }, { event: { type: 'ended', summary: 'strict harness policy refused every destination; stopped (exit 5)' } })
716
+ exit = 5
717
+ break
718
+ }
623
719
  const next = choice.next
624
720
  // bound the number of hand-offs in one terminal so a chain that limits
625
721
  // instantly can never loop forever; stopping is explicit, not a silent exit 0
@@ -630,9 +726,15 @@ export async function attach(agent, args = [], { open = true } = {}) {
630
726
  break
631
727
  }
632
728
  appendEvent(sid, { type: 'handoff', summary: `${agent}${account !== 'default' ? '/' + account : ''} → ${next.agent}${next.account !== 'default' ? '/' + next.account : ''}${bundle ? ` (bundle ${bundle.id})` : ''}` })
633
- prompt = bundle
634
- ? resumePrompt(cur, bundle, next)
635
- : `You are taking over an interactive coding session from ${agent}.${existsSync(notesFile) ? ` Read ${notesFile} in this directory first (the previous agent's notes: task, last messages, dirty files).` : ''} Check git status and git diff, then continue the work. The task: ${cur.task ?? 'see the recent changes'}`
729
+ if (bundle) {
730
+ prompt = resumePrompt(cur, bundle, next)
731
+ } else {
732
+ const delta = sessionCommitDelta(workRoot(cur) ?? cur.cwd, cur)
733
+ const fallbackAction = delta.isClean && delta.newCommits.length > 0
734
+ ? `The previous agent committed changes (${delta.newCommits.length} commit(s)) and left a clean working tree. Check git log and verify whether the task is already complete before doing redundant work; continue only if work remains.`
735
+ : 'Check git status and git diff, then continue the work.'
736
+ prompt = `You are taking over an interactive coding session from ${agent}.${existsSync(notesFile) ? ` Read ${notesFile} in this directory first (the previous agent's notes: task, last messages, dirty files).` : ''} ${fallbackAction} The task: ${cur.task ?? 'see the recent changes'}`
737
+ }
636
738
  say(`starting ${next.agent}${next.account !== 'default' ? '/' + next.account : ''} in this terminal from the bundle`)
637
739
  agent = next.agent; account = next.account; legArgs = []
638
740
  // the chain is what comes after the agent now taking over, not after the
@@ -646,7 +748,7 @@ export async function attach(agent, args = [], { open = true } = {}) {
646
748
  if (fin && fin.status !== 'ended') updateSession(sid, { status: 'ended', ended_at: new Date().toISOString(), exit_code: exit }, { event: { type: 'ended', summary: `session ended (exit ${exit})` } })
647
749
  // Every way out of the loop arrives here: a clean exit, a cancelled wait, the
648
750
  // 12-leg cap, an agent that never started. The terminal is gone, so RESUME.md
649
- // must stop describing it as live — Baton owns that file, and leaving the last
751
+ // must stop describing it as live — Leg owns that file, and leaving the last
650
752
  // hand-off sitting there is exactly the lie this rewrite exists to stop.
651
753
  try { endSessionPointer(readSession(sid)) } catch (err) { appendEvent(sid, { type: 'error', summary: `resume pointer not rewritten: ${err.message.slice(0, 160)}` }) }
652
754
  try { rmSync(join(sessionDir(sid), 'control.json'), { force: true }) } catch {}
package/src/auth.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // auth — who is allowed in. Loopback with no token is open; any other bind
2
2
  // address refuses to start without a token, and with a token every /api
3
- // request needs `Authorization: Bearer <token>` (timing-safe). With `baton
3
+ // request needs `Authorization: Bearer <token>` (timing-safe). With `leg
4
4
  // share` on, each human has their own token and the board knows their name
5
5
  // and role (src/share.mjs); a loopback request is still the owner, so the
6
6
  // machine's own browser needs nothing.
@@ -75,7 +75,7 @@ export function authorize({ token, req, url, share = null, bind = null, loopback
75
75
  return { ok: false, subject: null, person: null }
76
76
  }
77
77
  // checkBind's invariant, re-applied per request: share.json stops reading as
78
- // on (`baton share off`, a truncated file) while the shared address is still
78
+ // on (`leg share off`, a truncated file) while the shared address is still
79
79
  // bound, and nobody but this machine may be the local owner in that window
80
80
  if (!token && loopbackOwner === false) return { ok: false, subject: null, person: null }
81
81
  if (!token && bind && !isLoopback(bind) && !isLoopback(remoteAddress(req))) return { ok: false, subject: null, person: null }
@@ -130,6 +130,7 @@
130
130
  --id-codex: #69DBBA; /* oklch(0.815 0.115 172) cool green */
131
131
  --id-agy: #CC97F3; /* oklch(0.760 0.140 310) violet, 46 degrees clear of the accent */
132
132
  --id-grok: #70B8FF; /* oklch(0.750 0.130 240) azure blue */
133
+ --id-copilot: #F0A3B8; /* oklch(0.790 0.110 5) rose; lists and reads, never runs */
133
134
  --id-fake: #A0A6AE; /* oklch(0.720 0.010 258) the scripted adapter, neutral in hue */
134
135
 
135
136
  /* type scale. Fixed rem, range 13 to 52. The old board ran 13 to 21, which
@@ -222,6 +223,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
222
223
  .dot.id-codex { background: var(--id-codex); }
223
224
  .dot.id-agy { background: var(--id-agy); }
224
225
  .dot.id-grok { background: var(--id-grok); }
226
+ .dot.id-copilot { background: var(--id-copilot); }
225
227
 
226
228
  /* ---- logins ------------------------------------------------------------- */
227
229
  .logins { display: grid; gap: 20px; }
@@ -323,6 +325,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
323
325
  .chip-id-codex { color: var(--id-codex); }
324
326
  .chip-id-agy { color: var(--id-agy); }
325
327
  .chip-id-grok { color: var(--id-grok); }
328
+ .chip-id-copilot { color: var(--id-copilot); }
326
329
  .chip-id-fake { color: var(--id-fake); }
327
330
  .chip-state-ok { color: var(--ok-text); }
328
331
  .chip-state-warn { color: var(--warn-text); }
@@ -370,7 +373,7 @@ code { font-family: var(--mono); font-size: 0.94em; }
370
373
  .drawer-msg-when { font-size: var(--t--1); color: var(--text-3); font-family: var(--mono); }
371
374
 
372
375
  /* ---- ledger: on the ground, unpanelled --------------------------------- */
373
- .ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
376
+ .ledger { margin-top: 56px; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 40px; padding-top: 32px; border-top: 1px solid var(--edge); }
374
377
  .ledger-cell h3 { font-size: var(--t-2); font-weight: var(--w-head); letter-spacing: -.012em; }
375
378
  .ledger-cell .region-meta { margin-top: 6px; font-size: var(--t-0); color: var(--text-3); }
376
379
  .ledger-actions { margin-top: 18px; display: flex; gap: 10px; flex-wrap: wrap; }
@@ -384,6 +387,23 @@ code { font-family: var(--mono); font-size: 0.94em; }
384
387
  .line-main { color: var(--text); }
385
388
  .line-meta { color: var(--text-2); }
386
389
  .line-when { color: var(--text-3); text-align: right; }
390
+ /* history: the conversations every agent keeps, one row each, opening in place */
391
+ .history-filters { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px 14px; align-items: center; margin: 4px 0 18px; max-width: 64ch; }
392
+ .history-filters label { font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
393
+ .history-filters label:has(input[type="checkbox"]) { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; font-weight: var(--w-text); }
394
+ .history-list { display: block; }
395
+ .history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 6px 20px; align-items: baseline; padding: 12px 0; font-size: var(--t-0); }
396
+ .history-row + .history-row, .history-row + .history-detail, .history-detail + .history-row { border-top: 1px solid var(--line); }
397
+ .history-title { display: block; width: 100%; text-align: left; background: none; border: 0; padding: 0; font: inherit; color: var(--text); cursor: pointer; line-height: 1.4; }
398
+ .history-title:hover { color: var(--accent-hi); }
399
+ .history-title:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; border-radius: var(--r-control); }
400
+ .history-register { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; font-size: var(--t--1); color: var(--text-3); }
401
+ .history-when { color: var(--text-3); white-space: nowrap; font-size: var(--t--1); }
402
+ .history-detail { padding: 14px 0 20px; }
403
+ .history-detail .kv { margin-bottom: 14px; }
404
+ .history-command { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; font-size: var(--t-0); }
405
+ .history-empty { font-size: var(--t-0); color: var(--text-3); padding: 12px 0; }
406
+
387
407
  .group-head { display: flex; align-items: baseline; gap: 10px; margin-top: 24px; margin-bottom: 4px; font-size: var(--t-0); font-weight: var(--w-head); color: var(--text-2); }
388
408
  .group-head:first-child { margin-top: 0; }
389
409
  .group-head span { font-weight: var(--w-text); color: var(--text-3); }
@@ -556,6 +576,8 @@ dialog::backdrop { background: rgba(0,0,0,.6); }
556
576
  .kv { grid-template-columns: 1fr; gap: 4px 0; }
557
577
  .kv-val + .kv-key { margin-top: 12px; }
558
578
  .ledger { grid-template-columns: 1fr; gap: 32px; margin-top: 40px; }
579
+ .history-row { grid-template-columns: 1fr; }
580
+ .history-filters { grid-template-columns: 1fr; }
559
581
  .line { grid-template-columns: 1fr auto; gap: 6px 14px; }
560
582
  .line-main { grid-column: 1 / -1; }
561
583
  .line-meta { grid-column: 1; }
@@ -253,10 +253,10 @@
253
253
  }
254
254
 
255
255
  function formatActor(actor) {
256
- if (!actor) return 'baton'
256
+ if (!actor) return 'leg'
257
257
  if (actor.type === 'human') return `human:${actor.id || 'local'}`
258
258
  if (actor.type === 'agent') return `agent:${actor.adapter || '?'}`
259
- return 'baton'
259
+ return 'leg'
260
260
  }
261
261
 
262
262
  function baseAdapterName(name) { return String(name || '').replace(/^fake-/, '') }
@@ -292,6 +292,15 @@
292
292
  : `${s === 'reconnecting' ? 'Reconnecting' : 'Connecting'} to Leg on ${state.bind}.`
293
293
  }
294
294
 
295
+ // sessions.js owns the terminals region and listens for `leg:sessions`.
296
+ // `baton:sessions` is the old name, still emitted for anything outside this
297
+ // page that listens for it; nothing in the board may listen for both, because
298
+ // each listener rebuilds the whole grid.
299
+ function publishSessions(detail) {
300
+ window.dispatchEvent(new CustomEvent('leg:sessions', { detail }))
301
+ window.dispatchEvent(new CustomEvent('baton:sessions', { detail }))
302
+ }
303
+
295
304
  function connectSse() {
296
305
  if (state.es) { try { state.es.close() } catch { /* ignore */ } }
297
306
  const request = ++state.sseRequest
@@ -312,9 +321,12 @@
312
321
  // nothing between the drop and this hello was replayed: an open detail
313
322
  // region is as old as the gap
314
323
  scheduleDrawerRefresh()
315
- if (data.sessions) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: data.sessions })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: data.sessions }));
324
+ if (data.sessions) publishSessions(data.sessions)
316
325
  })
317
- es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) window.dispatchEvent(new CustomEvent('leg:sessions', { detail: JSON.parse(e.data) })); window.dispatchEvent(new CustomEvent('baton:sessions', { detail: JSON.parse(e.data) })); })
326
+ // one parse, one publish, and both inside the staleness guard: the missing
327
+ // braces meant a superseded EventSource still drove a full rebuild, and the
328
+ // payload (a quarter of a megabyte) was parsed twice to do it
329
+ es.addEventListener('sessions', (e) => { if (state.es === es && request === state.sseRequest) publishSessions(JSON.parse(e.data)) })
318
330
  es.addEventListener('card', (e) => { if (state.es === es && request === state.sseRequest) upsertCard(JSON.parse(e.data)) })
319
331
  es.addEventListener('removed', (e) => { if (state.es === es && request === state.sseRequest) dropCard(JSON.parse(e.data).card_id) })
320
332
  es.addEventListener('event', (e) => { if (state.es === es && request === state.sseRequest) onLedgerEvent(JSON.parse(e.data)) })
@@ -1042,7 +1054,7 @@
1042
1054
  for (const a of adapters) adapterSelect.appendChild(el('option', { value: a.name }, [adapterLabel(a)]))
1043
1055
  // Add fallback agent used to default to the first option in the list, which
1044
1056
  // is normally the agent already chosen as First agent: the summary line then
1045
- // read "Baton tries claude, then agy, then claude", a fallback that cannot
1057
+ // read "Leg tries claude, then agy, then claude", a fallback that cannot
1046
1058
  // fire. rebuildDefaultFallbacks already applies this filter.
1047
1059
  if (!preferred && !first) {
1048
1060
  const used = chosenAdapters(ui)