@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
@@ -1,4 +1,4 @@
1
- // Machine-wide defaults for interactive Baton terminals. A new terminal takes
1
+ // Machine-wide defaults for interactive Leg terminals. A new terminal takes
2
2
  // a copy of these preferences when it starts; later edits do not silently
3
3
  // change terminals that are already running.
4
4
  import { existsSync, mkdirSync, readFileSync } from 'node:fs'
@@ -27,6 +27,38 @@ export function requireHandoffOrder(value) {
27
27
  return [...value]
28
28
  }
29
29
 
30
+ // Portable harness (src/harness/): off for every existing install. `enabled`
31
+ // is the explicit consent `leg harness enable` records; `policy` is what an
32
+ // unattended hand-off may do (warn: report only; sync: write managed state
33
+ // when it is safe; strict: refuse a destination that cannot be made safe);
34
+ // `source` is the client whose harness is the one being carried.
35
+ export const HARNESS_POLICIES = ['warn', 'sync', 'strict']
36
+ export const HARNESS_SOURCES = ['claude', 'codex']
37
+ export const HARNESS_DEFAULTS = Object.freeze({ enabled: false, policy: 'warn', source: null })
38
+
39
+ export function normalizeHarness(value) {
40
+ const v = value && typeof value === 'object' ? value : {}
41
+ return {
42
+ enabled: v.enabled === true,
43
+ policy: HARNESS_POLICIES.includes(v.policy) ? v.policy : HARNESS_DEFAULTS.policy,
44
+ source: HARNESS_SOURCES.includes(v.source) ? v.source : null,
45
+ }
46
+ }
47
+
48
+ export function requireHarness(patch, current = HARNESS_DEFAULTS) {
49
+ const next = { ...normalizeHarness(current) }
50
+ if (patch?.enabled !== undefined) next.enabled = Boolean(patch.enabled)
51
+ if (patch?.policy !== undefined) {
52
+ if (!HARNESS_POLICIES.includes(patch.policy)) throw new TypeError(`harness policy must be one of ${HARNESS_POLICIES.join(', ')}`)
53
+ next.policy = patch.policy
54
+ }
55
+ if (patch?.source !== undefined) {
56
+ if (patch.source !== null && !HARNESS_SOURCES.includes(patch.source)) throw new TypeError(`harness source must be one of ${HARNESS_SOURCES.join(', ')}`)
57
+ next.source = patch.source
58
+ }
59
+ return next
60
+ }
61
+
30
62
  export function preferencesFile() { return join(home(), 'preferences.json') }
31
63
 
32
64
  export function resolveAutoApprove({ env = process.env, preferences = null, cliFlag = null } = {}) {
@@ -41,15 +73,16 @@ export function resolveAutoApprove({ env = process.env, preferences = null, cliF
41
73
 
42
74
  export function readPreferences() {
43
75
  const file = preferencesFile()
44
- if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
76
+ if (!existsSync(file)) return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
45
77
  try {
46
78
  const value = JSON.parse(readFileSync(file, 'utf8'))
47
79
  return {
48
80
  handoff_order: normalizeHandoffOrder(value?.handoff_order),
49
81
  auto_approve: value?.auto_approve !== false,
82
+ harness: normalizeHarness(value?.harness),
50
83
  }
51
84
  } catch {
52
- return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true }
85
+ return { handoff_order: [...HANDOFF_AGENTS], auto_approve: true, harness: { ...HARNESS_DEFAULTS } }
53
86
  }
54
87
  }
55
88
 
@@ -61,6 +94,7 @@ export function writePreferences(patch) {
61
94
  const next = { ...current }
62
95
  if (order !== undefined) next.handoff_order = order
63
96
  if (patch?.auto_approve !== undefined) next.auto_approve = Boolean(patch.auto_approve)
97
+ if (patch?.harness !== undefined) next.harness = requireHarness(patch.harness, current.harness)
64
98
  writeJsonAtomic(preferencesFile(), next)
65
99
  return next
66
100
  })
package/src/redact.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // redact — the one list of secret shapes. `scrub()` rewrites (logs, bundles,
2
2
  // launcher output); the ledger's assertNoSecrets refuses (src/ledger.mjs).
3
- // The source tooling kept two copies on purpose; Baton keeps one here.
3
+ // The source tooling kept two copies on purpose; Leg keeps one here.
4
4
  // Values the launcher's own process holds for the well-known key variables
5
5
  // are read once at startup and never printed.
6
6
  // Every prefix shape starts at a token boundary: the `sk-` inside
@@ -11,19 +11,37 @@ const PATTERNS = [
11
11
  ['Anthropic key (sk-ant-)', /(?<![A-Za-z0-9_-])sk-ant-[A-Za-z0-9_-]{8,}/g],
12
12
  ['DashClaw key (oc_live_)', /(?<![A-Za-z0-9_-])oc_live_[a-f0-9]\w*/g],
13
13
  ['bearer token', /Bearer\s+[A-Za-z0-9._-]{16,}/g],
14
- ['GitHub token (ghp_)', /(?<![A-Za-z0-9_-])ghp_[A-Za-z0-9]{20,}/g],
15
- ['GitHub server token (ghs_)', /(?<![A-Za-z0-9_-])ghs_[A-Za-z0-9]{20,}/g],
14
+ ['GitHub token (gh[pousr]_)', /(?<![A-Za-z0-9_-])gh[pousr]_[A-Za-z0-9]{20,}/g],
16
15
  ['GitHub fine-grained token (github_pat_)', /(?<![A-Za-z0-9_-])github_pat_[A-Za-z0-9_]{20,}/g],
17
16
  ['AWS key (AKIA)', /(?<![A-Za-z0-9_-])AKIA[0-9A-Z]{12,}/g],
18
- ['Slack token (xox)', /(?<![A-Za-z0-9_-])xox[bp]-\S*/g],
19
- ['key=value secret', /api[_-]?key\s*[=:]\s*\S+/gi],
17
+ ['Slack token (xox/xapp)', /(?<![A-Za-z0-9_-])(?:xox[baprs]|xapp)-\S+/g],
18
+ ['key=value secret', /api[_-]?key[ \t]*[=:][ \t]*\S+/gi],
19
+ // shapes a discovered transcript from another agent carries that the list
20
+ // above missed (measured 2026-09-16: 15 of 18 common shapes went through)
21
+ ['Stripe key (sk_live_/rk_live_)', /(?<![A-Za-z0-9_-])[sr]k_(?:live|test)_[A-Za-z0-9]{8,}/g],
22
+ ['Google API key (AIza)', /(?<![A-Za-z0-9_-])AIza[0-9A-Za-z_-]{30,}/g],
23
+ ['xAI key (xai-)', /(?<![A-Za-z0-9_-])xai-[A-Za-z0-9]{16,}/g],
24
+ ['npm token (npm_)', /(?<![A-Za-z0-9_-])npm_[A-Za-z0-9]{20,}/g],
25
+ ['GitLab token (glpat-)', /(?<![A-Za-z0-9_-])glpat-[A-Za-z0-9_-]{16,}/g],
26
+ ['Hugging Face token (hf_)', /(?<![A-Za-z0-9_-])hf_[A-Za-z0-9]{20,}/g],
27
+ ['JWT', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{8,}\.eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g],
28
+ ['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g],
29
+ // a digit or two capitals somewhere: base64 of any credential has one of
30
+ // them, "Basic authentication/authorization" in prose has neither
31
+ ['basic auth header', /Basic\s+(?=[A-Za-z0-9+/=]*(?:[0-9]|[A-Z][A-Za-z0-9+/=]*[A-Z]))[A-Za-z0-9+/=]{16,}/g],
32
+ ['URL with credentials', /(?<=[a-z][a-z0-9+.-]*:\/\/[^\s/:@]+:)[^\s@/]{4,}(?=@)/gi],
33
+ // an environment-style NAME (upper case): `cache_key = build(...)` and
34
+ // `refresh_token: string` in code are not secrets; a value never crosses a
35
+ // line break, so `SECRET_KEY=` at the end of a line takes nothing after it
36
+ ['env-style secret assign', /(?:[A-Z0-9_]*_(?:KEY|TOKEN|SECRET|PASSWORD)|aws_secret_access_key)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/g],
37
+ ['password=value', /\b(?:password|passwd)[ \t]*[=:][ \t]*["']?[^\s"',;]{8,}/gi],
20
38
  ]
21
39
 
22
40
  export const SECRET_RES = PATTERNS.map(([, re]) => re)
23
41
  // non-global copies for `.test()` (a /g regex carries lastIndex state)
24
42
  export const SECRET_PATTERNS = PATTERNS.map(([name, re]) => [name, new RegExp(re.source, re.flags.replace('g', ''))])
25
43
 
26
- const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'BATON_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN']
44
+ const ENV_KEYS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'OPENAI_API_KEY', 'DASHCLAW_API_KEY', 'LEG_TOKEN', 'BATON_TOKEN', 'LEG_LICENSE_PRIVATE_KEY', 'BATON_LICENSE_PRIVATE_KEY', 'GITHUB_TOKEN', 'GH_TOKEN', 'STRIPE_SECRET_KEY', 'STRIPE_TEST_SECRET_KEY', 'RESEND_API_KEY', 'NPM_TOKEN']
27
45
  let envValues = null
28
46
  function heldValues() {
29
47
  if (envValues) return envValues
package/src/resume.mjs CHANGED
@@ -1,17 +1,18 @@
1
1
  // resume — the pointer that cannot describe a picture that is no longer true.
2
2
  //
3
- // `.baton/RESUME.md` is the file humans and other agents open by habit. It used
3
+ // `.leg/RESUME.md` is the file humans and other agents open by habit (legacy
4
+ // `.baton/RESUME.md` is still read). It used
4
5
  // to be an unowned convenience copy: written once per hand-off, never touched
5
6
  // again, with no stamp and no expiry, so a normally exited terminal left hours
6
7
  // old text sitting there looking live.
7
8
  //
8
9
  // Two rules fix that, and they are the whole module:
9
- // 1. Every resume file Baton writes carries a stamp of the git state and the
10
+ // 1. Every resume file Leg writes carries a stamp of the git state and the
10
11
  // live terminals it was written against (an HTML comment, invisible in
11
12
  // rendered markdown).
12
13
  // 2. Freshness is never remembered — it is recomputed from git at READ time.
13
14
  // A file cannot lie about HEAD to a reader who re-asks git.
14
- // Baton owns the file: it rewrites it when a session ends and when the board
15
+ // Leg owns the file: it rewrites it when a session ends and when the board
15
16
  // starts, so nothing is left describing a terminal that is gone.
16
17
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
18
  import { spawnSync } from 'node:child_process'
@@ -24,8 +25,8 @@ export const STAMP_PREFIX = '<!-- leg-resume '
24
25
  export const LEGACY_STAMP_PREFIX = '<!-- baton-resume '
25
26
  const STAMP_SUFFIX = ' -->'
26
27
  const STAMP_VERSION = 1
27
- // Baton's own directories dirty the tree on every write; a reader must not see
28
- // Baton's bookkeeping as the human's work moving on.
28
+ // Leg's own directories dirty the tree on every write; a reader must not see
29
+ // Leg's bookkeeping as the human's work moving on.
29
30
  const LEG_DIRS = /^(\.leg|\.baton|\.context-handoffs|\.dashclaw-local)[\\/]/
30
31
  const MAX_REASONS = 5
31
32
 
@@ -81,6 +82,7 @@ export function perSessionFile(cwd, id) {
81
82
  if (existsSync(join(cwd, '.baton')) && !existsSync(join(cwd, '.leg'))) return baton
82
83
  return leg
83
84
  }
85
+ export { synthesisFile } from './synthesis.mjs'
84
86
 
85
87
  // An agent started deeper in the tree still finds its checkout's pointer.
86
88
  export function findResume(startDir) {
@@ -117,7 +119,7 @@ export function readStamp(text) {
117
119
  } catch { return null }
118
120
  }
119
121
 
120
- // The text without its stamp: what a human reads and what `baton resume` prints.
122
+ // The text without its stamp: what a human reads and what `leg resume` prints.
121
123
  export function bodyOf(text) {
122
124
  const s = String(text ?? '')
123
125
  return readStamp(s) ? s.slice(s.indexOf('\n') + 1).replace(/^\n+/, '') : s
@@ -200,10 +202,10 @@ export function lastHandoffIn(root, sessions = listSessions()) {
200
202
  }
201
203
 
202
204
  function idleBody(last, live) {
203
- const lines = ['# Baton: nothing in flight', '']
205
+ const lines = ['# Leg: nothing in flight', '']
204
206
  lines.push(live.length
205
207
  ? `No hand-off is waiting to be picked up here. Still live in this checkout: ${live.map((s) => `${s.session_id} (${s.agent})`).join(', ')}.`
206
- : 'No Baton terminal is live in this checkout.')
208
+ : 'No Leg terminal is live in this checkout.')
207
209
  lines.push('')
208
210
  if (last) {
209
211
  const at = last.at ? String(last.at).slice(0, 16).replace('T', ' ') : 'an unrecorded time'
@@ -221,7 +223,7 @@ function idleBody(last, live) {
221
223
  }
222
224
 
223
225
  // The "nothing in flight" pointer. Always writes; the callers that must not
224
- // create a file in a checkout Baton never handed off in check first.
226
+ // create a file in a checkout Leg never handed off in check first.
225
227
  export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
226
228
  if (!root) return null
227
229
  const dir = existsSync(join(root, '.baton')) && !existsSync(join(root, '.leg')) ? join(root, '.baton') : join(root, '.leg')
@@ -235,7 +237,7 @@ export function writeIdlePointer(root, { sessions = listSessions() } = {}) {
235
237
  }
236
238
 
237
239
  // A session ending must not leave its hand-off sitting there looking live.
238
- // Only ever rewrites a pointer that already exists: Baton owns RESUME.md where
240
+ // Only ever rewrites a pointer that already exists: Leg owns RESUME.md where
239
241
  // it wrote one, and creates none in a checkout it never handed off in.
240
242
  export function endSessionPointer(session) {
241
243
  const root = workRoot(session)
@@ -243,7 +245,7 @@ export function endSessionPointer(session) {
243
245
  return writeIdlePointer(root)
244
246
  }
245
247
 
246
- // Board start: every checkout Baton wrote a pointer in gets it recomputed, so a
248
+ // Board start: every checkout Leg wrote a pointer in gets it recomputed, so a
247
249
  // terminal that crashed instead of exiting cannot leave a live-looking hand-off
248
250
  // behind. A checkout whose terminal really is live keeps its hand-off text.
249
251
  // Returns the roots rewritten.
@@ -260,9 +262,9 @@ export function refreshPointers() {
260
262
  const v = resumeVerdict(root, { sessions })
261
263
  if (v.state === 'fresh') continue
262
264
  // The terminal that wrote a hand-off owns it while it is still running: its
263
- // text is the live description, and "the repo moved on" is for `baton resume
265
+ // text is the live description, and "the repo moved on" is for `leg resume
264
266
  // --check` to report, not for the board to overwrite. Anything else — a
265
- // hand-off from a terminal that is gone, a file no Baton stamped — is
267
+ // hand-off from a terminal that is gone, a file no Leg stamped — is
266
268
  // replaced even when some OTHER terminal happens to be live in the
267
269
  // checkout, which is the case that left three day old text sitting there.
268
270
  if (v.session?.active) continue
@@ -284,14 +286,14 @@ function describe(list) { return list.map((s) => `${s.id ?? s.session_id} (${s.a
284
286
  export function resumeVerdict(cwd, { sessions = listSessions() } = {}) {
285
287
  const found = findResume(cwd)
286
288
  if (!found) {
287
- return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .baton/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
289
+ return { state: 'missing', exit_code: EXIT.missing, root: null, file: null, kind: null, stamp: null, reasons: ['there is no .leg/RESUME.md in this checkout'], summary: 'no resume pointer in this checkout', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
288
290
  }
289
291
  const { root, file } = found
290
292
  let text = ''
291
293
  try { text = readFileSync(file, 'utf8') } catch { /* raced a rewrite */ }
292
294
  const stamp = readStamp(text)
293
295
  if (!stamp) {
294
- return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Baton stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Baton stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
296
+ return { state: 'unstamped', exit_code: EXIT.unstamped, root, file, kind: null, stamp: null, reasons: ['this file carries no Leg stamp, so its freshness cannot be checked against git'], summary: 'cannot be checked: no Leg stamp', head: null, dirty: null, live: null, session: null, written_at: null, age_ms: null }
295
297
  }
296
298
 
297
299
  const now = gitState(root)
package/src/runner.mjs CHANGED
@@ -129,7 +129,7 @@ function errTail(path, lines = 10) {
129
129
 
130
130
  function killTree(pid, log) {
131
131
  if ((process.env.LEG_SKIP_KILL || process.env.BATON_SKIP_KILL) === '1') { // test seam: unkillable agent
132
- log('BATON_SKIP_KILL=1: killTree skipped')
132
+ log('LEG_SKIP_KILL=1: killTree skipped')
133
133
  return
134
134
  }
135
135
  if (process.platform === 'win32') {
@@ -151,7 +151,7 @@ function gitHead(cwd) {
151
151
  function gitDiff(cwd, headAtStart) {
152
152
  const r = spawnSync('git', ['status', '--porcelain'], { cwd, windowsHide: true, encoding: 'utf8', env: { ...process.env, MSYS_NO_PATHCONV: '1' } })
153
153
  if (r.status !== 0) return null
154
- const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.baton[\\/]/.test(l)).length
154
+ const files = r.stdout.split(/\r?\n/).filter(Boolean).filter((l) => !/\.(baton|leg)[\\/]/.test(l)).length
155
155
  const head = gitHead(cwd)
156
156
  return { changed: files > 0 || (headAtStart !== null && head !== headAtStart), files, head_at_start: headAtStart, head }
157
157
  }
package/src/scheduler.mjs CHANGED
@@ -46,7 +46,7 @@ export function pickRunnable(cards, { max = MAX_CONCURRENT, landing = new Set()
46
46
 
47
47
  export function pidfile() { return join(home(), 'scheduler.pid') }
48
48
 
49
- export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'baton' } } = {}) {
49
+ export function createScheduler({ max = MAX_CONCURRENT, intervalMs = 1000, actor = { type: 'leg' } } = {}) {
50
50
  const state = { blockedKeys: new Map(), inflight: new Map(), stopped: false, ticks: 0 }
51
51
 
52
52
  async function tick() {
package/src/server.mjs CHANGED
@@ -2,11 +2,11 @@
2
2
  // server — the board's HTTP + SSE backend. Ledger-backed: every handler reads
3
3
  // card.json / events-*.jsonl on demand (no module-level card store), so a
4
4
  // restart shows the same board and a second process sees the same truth.
5
- // BATON_BIND (127.0.0.1) + BATON_PORT (4747) + BATON_TOKEN are the
6
- // multiplayer seams (src/auth.mjs).
5
+ // LEG_BIND (127.0.0.1) + LEG_PORT (4747) + LEG_TOKEN are the
6
+ // multiplayer seams (src/auth.mjs). BATON_* names still work as fallback.
7
7
  import http from 'node:http'
8
- import { spawnSync } from 'node:child_process'
9
- import { existsSync, readFileSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
8
+ import { spawnSync, execFile } from 'node:child_process'
9
+ import { existsSync, readFileSync, readdirSync, statSync, rmSync, watch as fsWatch, mkdirSync, openSync, fstatSync, readSync, closeSync } from 'node:fs'
10
10
  import { join, dirname, resolve, extname, sep } from 'node:path'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import { checkBind, authorize, remoteAddress, presentedToken, isLoopback, isLoopbackRequest, tokenMatches } from './auth.mjs'
@@ -26,15 +26,28 @@ import { scrub } from './runner.mjs'
26
26
  import { resolveChb } from './handoff.mjs'
27
27
  import { listSessions, readSession, readEvents as readSessionEvents, requestControl, removeSession, overlaps, isActive, sessionsRoot, reapLost, readLand, readLandings, readRequests, writeRequests, appendEvent as appendSessionEvent, updateSession, HANDOFF_ORDER_CAPABILITY } from './sessions.mjs'
28
28
  import { sessionDetail, sessionDiff, DiffInputError } from './session-detail.mjs'
29
+ import { hasRecentSynthesis } from './synthesis.mjs'
29
30
  import { refreshPointers } from './resume.mjs'
30
31
  import { landSession, landBlocker, landingNow, pruneSessionWorktree, canLand, prepareLanding, applyLandFix } from './land.mjs'
31
32
  import { readUsage, recordUsage, usageIsStale, candidates, isAvailable } from './usage.mjs'
32
33
  import { readAccounts, envFor, LAYOUT } from './accounts.mjs'
33
34
  import { readCodexUsage } from './taps/codex.mjs'
34
35
  import { readPreferences, writePreferences, normalizeHandoffOrder, requireHandoffOrder } from './preferences.mjs'
36
+ import { listHistory, findRecord, recordDetail, refreshIndex, readIndex, providerSupport, HistoryInputError, PROVIDER_NAMES } from './history/index.mjs'
37
+ import { listWorktrees } from './history/worktrees.mjs'
35
38
 
36
39
  const SELF = fileURLToPath(import.meta.url)
37
- const BOARD_DIR = join(dirname(SELF), 'board')
40
+ export function resolveBoardDir() {
41
+ const dir = join(dirname(SELF), 'board')
42
+ if (existsSync(dir)) return dir
43
+ const wtMatch = /[\\/]\.(?:leg|baton)-worktrees(?:[\\/].*)?$/.exec(dirname(SELF))
44
+ if (wtMatch) {
45
+ const root = dirname(SELF).slice(0, wtMatch.index)
46
+ const fallback = join(root, 'src', 'board')
47
+ if (existsSync(fallback)) return fallback
48
+ }
49
+ return dir
50
+ }
38
51
  const VERSION = JSON.parse(readFileSync(join(dirname(SELF), '..', 'package.json'), 'utf8')).version
39
52
  const DEFAULT_ORDER = ['plan', 'build', 'review', 'test', 'land']
40
53
  const TYPES = { '.html': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.json': 'application/json; charset=utf-8', '.woff2': 'font/woff2' }
@@ -215,6 +228,64 @@ async function adaptersInfo() {
215
228
  }
216
229
 
217
230
  // ---- sessions (baton claude|codex|agy) ----
231
+ // The worktree list runs git once per known repository: cached for a short
232
+ // while so a board that polls does not fork fifty processes a second.
233
+ const WORKTREES_TTL = 20000
234
+ const worktreesCache = new Map()
235
+ // A stale index is refreshed by a child `leg history refresh`, never in this
236
+ // process: the scan stats thousands of files and walks every cwd, and inside
237
+ // the board's event loop that is seconds of no SSE frames and no clicks. The
238
+ // child takes the same index lock a CLI refresh would, so the two never tear
239
+ // one file; the next listing reads what it wrote.
240
+ const LEG_BIN = join(dirname(SELF), '..', 'bin', 'leg.mjs')
241
+ let historyRefreshing = false
242
+ function backgroundHistoryRefresh() {
243
+ if (historyRefreshing) return
244
+ historyRefreshing = true
245
+ try {
246
+ execFile(process.execPath, [LEG_BIN, 'history', 'refresh', '--json'], { env: process.env, windowsHide: true, timeout: 120000 }, () => { historyRefreshing = false })
247
+ } catch { historyRefreshing = false }
248
+ }
249
+ function worktreesFor({ repo = null, dirty = true } = {}) {
250
+ const key = `${repo ?? ''}|${dirty}`
251
+ const hit = worktreesCache.get(key)
252
+ if (hit && Date.now() - hit.at < WORKTREES_TTL) return hit.data
253
+ const data = listWorktrees({ repo, dirty, dirtyLimit: 20, repoLimit: 20 })
254
+ worktreesCache.set(key, { at: Date.now(), data })
255
+ return data
256
+ }
257
+
258
+ // canLand shells out to git several times for one worktree, and the view runs
259
+ // it for every terminal that ever had one — over a second of subprocesses on a
260
+ // board with a few dozen records, paid again on every SSE push. A terminal that
261
+ // has ended never moves, so the answer is cached against the record's own
262
+ // revision with the short TTL the trunk already uses, which still notices a
263
+ // commit made by hand in the worktree within that window.
264
+ // A terminal that is still running can change what it can land from one turn to
265
+ // the next. One that has ended only moves if someone works in its worktree by
266
+ // hand, and a landing clears this cache outright, so it is re-read a great deal
267
+ // less often.
268
+ const CAN_LAND_TTL = 15000
269
+ const CAN_LAND_TTL_ENDED = 60000
270
+ const canLandCache = new Map()
271
+ function canLandFor(s) {
272
+ const key = `${s.session_id}|${s.updated_at ?? ''}`
273
+ const hit = canLandCache.get(key)
274
+ if (hit && Date.now() < hit.until) return hit.data
275
+ const data = canLand(s)
276
+ // Expiries are spread across the window instead of falling together: twenty
277
+ // worktrees re-read in one pass is another second of git inside the event
278
+ // loop, which is the stall this cache exists to remove. Staggered, the board
279
+ // pays for about one of them per push and never blocks on the set.
280
+ const ttl = isActive(s) ? CAN_LAND_TTL : CAN_LAND_TTL_ENDED
281
+ const until = Date.now() + ttl / 2 + Math.random() * ttl
282
+ // the key carries updated_at, so a busy terminal leaves a dead entry per
283
+ // write: drop the whole map rather than grow it for the life of the process
284
+ if (canLandCache.size > 500) canLandCache.clear()
285
+ canLandCache.set(key, { until, data })
286
+ return data
287
+ }
288
+
218
289
  const trunkCache = new Map()
219
290
  function trunkFor(repo) {
220
291
  const hit = trunkCache.get(repo)
@@ -258,6 +329,8 @@ function redactSession(s) {
258
329
  // the branch is already on the worktree chip: naming it again costs nothing
259
330
  // and is what the board's land line reads
260
331
  land: s.land ? { state: s.land.state, branch: s.land.branch ?? null, base: s.land.base ?? null, sha: s.land.sha ?? null, reason: s.land.reason ?? null } : null,
332
+ // the chip's word only: paths, dropped items and attention text stay on this machine
333
+ harness: s.harness ? { state: s.harness.state, target: s.harness.target ?? null } : null,
261
334
  task: null, cwd: null, files: [], overlap: [], requests: [], hidden: true,
262
335
  land_blocker: `read-only: this terminal belongs to ${s.owner ?? 'someone else'}`,
263
336
  }
@@ -280,7 +353,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
280
353
  const preferredNext = chain[0] ?? null
281
354
  const availabilityKnown = Boolean(s.installed)
282
355
  const eligibleNext = availabilityKnown ? (chain.find((next) => s.installed[next.agent] !== false && isAvailable(readUsage(next.agent, next.account))) ?? null) : null
283
- const can = s.worktree ? canLand(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
356
+ const can = s.worktree ? canLandFor(s) : { ok: false, blockers: [{ code: 'no_worktree', message: 'this terminal works in the checkout itself: there is no branch of its own to land', fix: null }] }
284
357
  return {
285
358
  ...s,
286
359
  handoff_order: handoffOrder,
@@ -290,6 +363,7 @@ export function sessionsView({ viewer = null, share = null } = {}) {
290
363
  handoff_availability_known: availabilityKnown,
291
364
  can_edit_handoff_order: s.runtime_capabilities?.includes(HANDOFF_ORDER_CAPABILITY) ?? false,
292
365
  active: isActive(s),
366
+ has_synthesis: hasRecentSynthesis(s),
293
367
  overlap: ov.get(s.session_id) ?? [],
294
368
  elapsed_ms: Date.now() - Date.parse(s.started_at),
295
369
  // Older attached Codex processes can retain one parser mistake where an
@@ -352,16 +426,17 @@ function readBody(req) {
352
426
  }
353
427
 
354
428
  function serveStatic(res, urlPath) {
355
- const map = { '/': 'index.html', '/floor': 'floor.html' }
429
+ const map = { '/': 'index.html', '/board': 'index.html', '/board/': 'index.html', '/floor': 'floor.html', '/floor/': 'floor.html' }
356
430
  const rel = map[urlPath] ?? urlPath.replace(/^\/+/, '')
357
- const file = resolve(BOARD_DIR, rel)
358
- if (!file.startsWith(BOARD_DIR + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
431
+ const boardDir = resolveBoardDir()
432
+ const file = resolve(boardDir, rel)
433
+ if (!file.startsWith(boardDir + sep) || !existsSync(file) || !statSync(file).isFile()) return send(res, 404, 'not found')
359
434
  res.writeHead(200, { 'Content-Type': TYPES[extname(file)] ?? 'application/octet-stream', 'Cache-Control': 'no-store' })
360
435
  res.end(readFileSync(file))
361
436
  }
362
437
 
363
438
  // ---- SSE: watch $BATON_HOME/cards for fs events and push only what changed ----
364
- function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
439
+ function createSse({ healthIntervalMs = 10000, debounceMs = 30, sessionsDebounceMs = 300, sessionsMinIntervalMs = 2000, viewFor = () => sessionsView(), reauth = (c) => c.viewer } = {}) {
365
440
  const clients = new Set() // { res, viewer, token, loopback, sig }
366
441
  let watcher = null
367
442
  let healthTimer = null
@@ -411,13 +486,70 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
411
486
  }
412
487
  let sessionsWatcher = null
413
488
  let sessionsTimer = null
414
- const pushSessions = () => { sessionsTimer = null; try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) } }
489
+ let lastSessionsPush = 0
490
+ const pushSessions = () => {
491
+ sessionsTimer = null
492
+ try { broadcast('sessions', (viewer) => viewFor(viewer)) } catch (err) { log(`sessions view: ${err.message}`) }
493
+ lastSessionsPush = Date.now()
494
+ // the health tick pushes on its own schedule: record what it sent, so the
495
+ // next watcher hint is measured against the page's real contents
496
+ lastFingerprint = sessionsFingerprint()
497
+ }
498
+ // One live agent rewrites its record about every six seconds and takes a
499
+ // control lock about once a second, and every one of those touches the
500
+ // sessions tree. Rebuilding the whole view costs a second or more of `git`,
501
+ // so a watcher that answers every touch turns a single running terminal into
502
+ // a permanent busy loop on the one event loop this board serves every
503
+ // request from: the board then takes seconds to hand over a stylesheet and
504
+ // `leg` itself times out probing /api/health.
505
+ //
506
+ // A watcher event is only a hint. Locks and the temp files an atomic write
507
+ // leaves behind are dropped by name, but taking a lock inside a session
508
+ // directory also changes that directory's own mtime, and that event arrives
509
+ // carrying nothing but the directory name — no filter on the name can tell
510
+ // it from a real write. So the hint is checked against the data: a stat over
511
+ // the files the view is actually built from costs a fraction of a
512
+ // millisecond and answers the question the event cannot.
513
+ const NOISE = /(\.lock|\.tmp)$/i
514
+ const sessionsChangeMatters = (filename) => !filename || !NOISE.test(String(filename))
515
+ const sessionsFingerprint = () => {
516
+ const root = sessionsRoot()
517
+ let dirs
518
+ try { dirs = readdirSync(root) } catch { return '' }
519
+ let sig = ''
520
+ for (const name of dirs) {
521
+ if (!name.startsWith('s-')) continue
522
+ for (const file of ['session.json', 'land.json', 'requests.json']) {
523
+ try { const st = statSync(join(root, name, file)); sig += `${name}/${file}:${st.mtimeMs}:${st.size};` } catch { /* not written yet */ }
524
+ }
525
+ }
526
+ return sig
527
+ }
528
+ let lastFingerprint = null
529
+ const pushIfChanged = () => {
530
+ sessionsTimer = null
531
+ const sig = sessionsFingerprint()
532
+ // the hint was noise: the view would rebuild to exactly what the page
533
+ // already has, so nothing is rebuilt and nothing is sent
534
+ if (sig === lastFingerprint) return
535
+ lastFingerprint = sig
536
+ pushSessions()
537
+ }
538
+ const scheduleSessionsPush = () => {
539
+ if (sessionsTimer) return
540
+ const wait = Math.max(sessionsDebounceMs, sessionsMinIntervalMs - (Date.now() - lastSessionsPush))
541
+ sessionsTimer = setTimeout(pushIfChanged, wait)
542
+ }
415
543
  const startWatch = () => {
416
544
  if (watcher) return
417
545
  try {
418
546
  const sdir = sessionsRoot()
419
547
  mkdirSync(sdir, { recursive: true })
420
- sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, () => { if (!sessionsTimer) sessionsTimer = setTimeout(pushSessions, 300) })
548
+ // the client that opened this watch was handed the current view with its
549
+ // hello frame, so the fingerprint starts from what it already has: an
550
+ // unprimed one makes the first hint of any kind look like a change
551
+ lastFingerprint = sessionsFingerprint()
552
+ sessionsWatcher = fsWatch(realPath(sdir), { recursive: true }, (_event, filename) => { if (sessionsChangeMatters(filename)) scheduleSessionsPush() })
421
553
  } catch (err) { log(`sessions watch: ${err.message}`); sessionsWatcher = null }
422
554
  // watch the real long path: libuv's recursive watcher asserts when the
423
555
  // watched dir is an 8.3 short path (fs-event.c, seen on a GitHub runner)
@@ -460,7 +592,7 @@ function createSse({ healthIntervalMs = 10000, debounceMs = 30, viewFor = () =>
460
592
  // ---- the server ----
461
593
  export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN || process.env.BATON_TOKEN || '', scheduler = (process.env.LEG_NO_SCHEDULER || process.env.BATON_NO_SCHEDULER) !== '1', share, usagePolling = false, usageReader = readCodexUsage } = {}) {
462
594
  // An explicit `share` (tests) is fixed; the real server passes none and reads
463
- // share.json from disk, re-reading it per request (mtime-cached) so `baton
595
+ // share.json from disk, re-reading it per request (mtime-cached) so `leg
464
596
  // share add|rotate|rm` takes effect on a live board — a new link works at
465
597
  // once and a removed or rotated one stops at once — without a restart.
466
598
  const explicitShare = share !== undefined
@@ -560,7 +692,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
560
692
  const guest = shared && viewer.role !== 'owner'
561
693
  const ownsSession = (s) => !shared || viewer.role === 'owner' || (s.owner ?? share.owner) === viewer.name
562
694
  const parts = path.split('/').filter(Boolean) // ['api', ...]
563
- if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
695
+ // history and worktrees are the whole machine's project map (every path,
696
+ // every conversation on it): the owner's, never a guest's, as a group
697
+ if (guest && ['cards', 'floor', 'presets', 'adapters', 'leases', 'trunk', 'history', 'worktrees'].includes(parts[1])) return send(res, 403, { error: 'the pipeline board belongs to the owner of this machine' })
564
698
  try {
565
699
  if (req.method === 'GET' && path === '/api/health') {
566
700
  const you = { ...viewer, share: { on: shared, people: shared ? share.people.length : 0 } }
@@ -599,7 +733,20 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
599
733
  if (req.method === 'POST' || req.method === 'PATCH') {
600
734
  const body = await readBody(req)
601
735
  try {
602
- const preferences = writePreferences({ handoff_order: requireHandoffOrder(body.handoff_order) })
736
+ const patch = {}
737
+ if (body.handoff_order !== undefined) patch.handoff_order = requireHandoffOrder(body.handoff_order)
738
+ if (body.harness !== undefined) {
739
+ // the board may narrow the policy or turn the feature off; turning
740
+ // it on is the first-run consent flow, which shows what will be
741
+ // written before it writes (leg harness enable)
742
+ if (body.harness?.enabled === true) return send(res, 400, { error: 'turn the portable harness on from a terminal: leg harness enable shows what it will write before it writes it' })
743
+ const rank = ['warn', 'sync', 'strict']
744
+ const current = readPreferences().harness
745
+ if (body.harness?.policy !== undefined && rank.indexOf(body.harness.policy) > rank.indexOf(current.policy)) return send(res, 400, { error: `the board may only narrow the harness policy (now ${current.policy}); widen it from a terminal: leg harness policy ${body.harness.policy}` })
746
+ patch.harness = { policy: body.harness?.policy, enabled: body.harness?.enabled === false ? false : undefined }
747
+ }
748
+ if (!Object.keys(patch).length) return send(res, 400, { error: 'nothing to change: send handoff_order or harness' })
749
+ const preferences = writePreferences(patch)
603
750
  sse.broadcast('sessions', (v) => viewFor(v))
604
751
  return send(res, 200, { preferences })
605
752
  } catch (err) {
@@ -710,7 +857,9 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
710
857
  if (why) return send(res, 409, { error: why })
711
858
  landSession(sess, { by: actor.id, autoCommit: true })
712
859
  .catch((err) => log(`land ${id}: ${err.message}`))
713
- .finally(() => { trunkCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
860
+ // a landing moves the branch under every worktree cut from it, so
861
+ // the cached land-ability goes with the cached trunk
862
+ .finally(() => { trunkCache.clear(); canLandCache.clear(); try { sse.broadcast('sessions', (v) => viewFor(v)) } catch {} })
714
863
  log(`land requested for ${id} by ${actor.id}`)
715
864
  return send(res, 202, { ok: true, requested: 'land' })
716
865
  }
@@ -738,6 +887,63 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
738
887
  return send(res, 200, { removed: id, worktree })
739
888
  }
740
889
  }
890
+ // ---- history: the read-only index over every agent's own store ----
891
+ if (parts[1] === 'history') {
892
+ const q = url.searchParams
893
+ const int = (v, def, max) => { const n = parseInt(v ?? '', 10); return Number.isFinite(n) && n >= 0 ? Math.min(n, max) : def }
894
+ if (req.method === 'GET' && parts.length === 2) {
895
+ const provider = q.get('provider') || null
896
+ if (provider && provider.split(',').some((p) => !PROVIDER_NAMES.includes(p.trim()))) return send(res, 400, { error: `unknown provider in "${provider}" (${PROVIDER_NAMES.join('|')})` })
897
+ const tri = (v) => (v === '1' || v === 'true' ? true : v === '0' || v === 'false' ? false : null)
898
+ const explicitRefresh = tri(q.get('refresh')) === true
899
+ let refreshArg = null
900
+ if (!explicitRefresh) {
901
+ const idx = readIndex()
902
+ if (idx) {
903
+ const age = idx.refreshed_at ? Date.now() - Date.parse(idx.refreshed_at) : Infinity
904
+ if (age > 60000) backgroundHistoryRefresh()
905
+ refreshArg = false
906
+ }
907
+ } else {
908
+ refreshArg = true
909
+ }
910
+ return send(res, 200, listHistory({
911
+ provider, repo: q.get('repo') || null, search: q.get('search') || null,
912
+ before: q.get('before') || null,
913
+ // never the whole index in one response: a page is 1 to 200 rows
914
+ limit: Math.max(1, int(q.get('limit'), 50, 200)), offset: int(q.get('offset'), 0, 1e6),
915
+ managed: tri(q.get('managed')), live: tri(q.get('live')), includeHidden: tri(q.get('hidden')) === true,
916
+ refresh: refreshArg,
917
+ }))
918
+ }
919
+ if (req.method === 'GET' && parts[2] === 'providers') return send(res, 200, { providers: providerSupport() })
920
+ if (req.method === 'POST' && parts[2] === 'refresh') {
921
+ const body = await readBody(req)
922
+ const t = Date.now()
923
+ try {
924
+ const r = refreshIndex({ force: body.full === true })
925
+ return send(res, 200, { ms: Date.now() - t, refreshed_at: r.index.refreshed_at, stats: r.stats })
926
+ } catch (err) { return send(res, 409, { error: `refresh did not run: ${err.message}` }) }
927
+ }
928
+ if (req.method === 'GET' && parts.length === 3) {
929
+ // the id is a lookup key, never a path: findRecord compares strings,
930
+ // and the transcript it names is read only from inside a known store
931
+ let rec
932
+ let wanted
933
+ try { wanted = decodeURIComponent(parts[2]) } catch { return send(res, 400, { error: 'malformed id' }) }
934
+ try { rec = findRecord(wanted, { refresh: false }) } catch (err) {
935
+ if (err instanceof HistoryInputError) return send(res, 400, { error: err.message })
936
+ throw err
937
+ }
938
+ if (!rec) return send(res, 404, { error: `no conversation matches ${parts[2]}` })
939
+ return send(res, 200, recordDetail(rec, { messages: int(q.get('messages'), 8, 50) }))
940
+ }
941
+ return send(res, 404, { error: 'not found' })
942
+ }
943
+ if (req.method === 'GET' && path === '/api/worktrees') {
944
+ const q = url.searchParams
945
+ return send(res, 200, worktreesFor({ repo: q.get('repo') || null, dirty: q.get('dirty') !== '0' }))
946
+ }
741
947
  if (req.method === 'GET' && path === '/api/floor') return send(res, 200, floor(listCards()))
742
948
  if (req.method === 'GET' && path === '/api/trunk') return send(res, 200, trunk(listCards(), parseSince(url.searchParams.get('since'))))
743
949
  if (req.method === 'GET' && path === '/api/leases') return send(res, 200, { leases: held(listCards()) })
@@ -814,12 +1020,12 @@ export function createBoardServer({ bind, port, token = process.env.LEG_TOKEN ||
814
1020
  const addr = server.address()
815
1021
  log(`listening on http://${bind}:${addr.port} (home ${home()}${token ? ', token required' : ', loopback open'})`)
816
1022
  // A terminal that crashed instead of exiting left its hand-off in
817
- // .baton/RESUME.md looking live. The board is the thing that starts
1023
+ // .leg/RESUME.md looking live. The board is the thing that starts
818
1024
  // after a crash, so it is where that gets corrected.
819
1025
  setImmediate(() => {
820
1026
  try {
821
1027
  const touched = refreshPointers()
822
- if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Baton stamped it`)
1028
+ if (touched.length) log(`rewrote ${touched.length} stale resume pointer${touched.length === 1 ? '' : 's'}: the terminal each described is gone, or no Leg stamped it`)
823
1029
  } catch (err) { log(`resume pointers not refreshed: ${err.message}`) }
824
1030
  })
825
1031
  if (scheduler) {