@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,170 +1,177 @@
1
- // claude tap — how `baton claude` sees inside a normal interactive Claude Code.
2
- // Nothing in ~/.claude is edited: the session gets one extra settings file via
3
- // `--settings` (hooks merge with the user's; statusLine is the only key that
4
- // replaces, so Baton's status line runs the user's own command first).
5
- // Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
6
- // docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
7
- // resets_at), docs/en/settings (`--settings` sits above user settings).
8
- import { existsSync, readFileSync } from 'node:fs'
9
- import { join, dirname, resolve } from 'node:path'
10
- import { fileURLToPath } from 'node:url'
11
- import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
12
- import { recordUsage, markLimited, WARN_PCT } from '../usage.mjs'
13
- import { writeJsonAtomic } from '../fsx.mjs'
14
- import { LAYOUT } from '../accounts.mjs'
15
-
16
- const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs')
17
- const q = (p) => `"${String(p).replace(/\\/g, '\\\\')}"`
18
-
19
- // The user's own statusLine command, if any, so it keeps running.
20
- export function userStatusLine(configDir = LAYOUT.claude.home()) {
21
- for (const f of ['settings.json', 'settings.local.json']) {
22
- const p = join(configDir, f)
23
- if (!existsSync(p)) continue
24
- try {
25
- const s = JSON.parse(readFileSync(p, 'utf8'))
26
- if (s.statusLine?.type === 'command' && s.statusLine.command) return s.statusLine
27
- } catch {}
28
- }
29
- return null
30
- }
31
-
32
- export function settingsFor(sessionId, { statusLine = null } = {}) {
33
- const cmd = (kind) => ({ type: 'command', command: `node ${q(HOOK)} ${kind} --session ${sessionId}`, timeout: 20 })
34
- const settings = {
35
- hooks: {
36
- SessionStart: [{ hooks: [cmd('claude-hook')] }],
37
- UserPromptSubmit: [{ hooks: [cmd('claude-hook')] }],
38
- PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [cmd('claude-hook')] }],
39
- Stop: [{ hooks: [cmd('claude-hook')] }],
40
- StopFailure: [{ hooks: [cmd('claude-hook')] }],
41
- SessionEnd: [{ hooks: [cmd('claude-hook')] }],
42
- },
43
- statusLine: { type: 'command', command: `node ${q(HOOK)} claude-statusline --session ${sessionId}`, padding: statusLine?.padding ?? 0 },
44
- // Baton owns the limit: it hands the work to the next option instead of
45
- // waiting in the session for the reset.
46
- autoContinueAtUsageLimit: false,
47
- }
48
- return settings
49
- }
50
-
51
- export function writeSettings(sessionId, opts) {
52
- const file = join(sessionDir(sessionId), 'claude-settings.json')
53
- writeJsonAtomic(file, settingsFor(sessionId, opts))
54
- return file
55
- }
56
-
57
- function textOf(content) {
58
- if (typeof content === 'string') return content
59
- if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
60
- return ''
61
- }
62
-
63
- // Last human/assistant messages from a Claude Code transcript (jsonl).
64
- export function transcriptTail(path, limit = 8) {
65
- if (!path || !existsSync(path)) return []
66
- const out = []
67
- for (const line of readFileSync(path, 'utf8').split('\n')) {
68
- if (!line) continue
69
- let j
70
- try { j = JSON.parse(line) } catch { continue }
71
- if (j.type !== 'user' && j.type !== 'assistant') continue
72
- if (j.isSidechain) continue
73
- const text = textOf(j.message?.content).trim()
74
- if (!text || /^<[a-z-]+>/.test(text)) continue
75
- out.push({ role: j.type, text: text.slice(0, 1500), ts: j.timestamp ?? null })
76
- }
77
- return out.slice(-limit)
78
- }
79
-
80
- export function firstPrompt(path) {
81
- const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
82
- return t ? t.text.slice(0, 500) : null
83
- }
84
-
85
- function limitsFrom(rl) {
86
- if (!rl) return null
87
- const w = (x) => (x && Number.isFinite(x.used_percentage) ? { pct: x.used_percentage, resets_at: x.resets_at ?? null } : null)
88
- return { five_hour: w(rl.five_hour), seven_day: w(rl.seven_day) }
89
- }
90
-
91
- // Hook payload → session record. Returns a short line for the hook log.
92
- export function handleHook(sessionId, p) {
93
- const s = readSession(sessionId)
94
- if (!s) return 'no session'
95
- const base = { agent_session_id: p.session_id ?? s.agent_session_id, transcript_path: p.transcript_path ?? s.transcript_path, last_activity: new Date().toISOString() }
96
- switch (p.hook_event_name) {
97
- case 'SessionStart':
98
- // reducer: one hook process per tool call, so read `cur` inside the lock
99
- updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'agent_ready', summary: `claude session ${p.session_id ?? '?'} (${p.source ?? 'startup'})` } })
100
- return 'session start'
101
- case 'UserPromptSubmit': {
102
- updateSession(sessionId, (cur) => ({ ...base, task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
103
- return 'prompt'
104
- }
105
- case 'PostToolUse': {
106
- const file = p.tool_input?.file_path ?? p.tool_input?.notebook_path ?? null
107
- if (!file) return 'tool (no file)'
108
- const rel = relTo(workRoot(s), file)
109
- // reducer: concurrent Edit/Write hooks each add their own file without
110
- // the last writer overwriting the others' additions
111
- updateSession(sessionId, (cur) => ({ ...base, files_touched: cur.files_touched.includes(rel) ? cur.files_touched : [...cur.files_touched, rel].slice(-200) }))
112
- return `touched ${rel}`
113
- }
114
- case 'Stop':
115
- // reducer: never turn a 'limit'/'handing_off' back to 'running' by racing
116
- updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
117
- return 'stop'
118
- case 'StopFailure': {
119
- if (p.error === 'rate_limit') {
120
- // a simulated wall (baton sessions simulate-limit) clears after two minutes so a test never walls the real login for hours
121
- const simulated = Boolean(p.leg_simulated || p.baton_simulated)
122
- const u = markLimited('claude', s.account, { reason: 'rate_limit', source: simulated ? 'leg simulate-limit' : 'claude StopFailure', resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null })
123
- updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail: String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300), resets_at: u.limited_until, at: new Date().toISOString(), simulated } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` } })
124
- return 'LIMIT'
125
- }
126
- appendEvent(sessionId, { type: 'error', summary: `claude ${p.error}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` })
127
- updateSession(sessionId, base)
128
- return `error ${p.error}`
129
- }
130
- case 'SessionEnd':
131
- updateSession(sessionId, { ...base, agent_exit_reason: p.reason ?? null }, { event: { type: 'agent_exit', summary: `claude session ended (${p.reason ?? 'unknown'})` } })
132
- return 'session end'
133
- default:
134
- updateSession(sessionId, base)
135
- return p.hook_event_name ?? 'unknown'
136
- }
137
- }
138
-
139
- export function relTo(root, file) {
140
- const a = String(file).replace(/\\/g, '/')
141
- const r = String(root ?? '').replace(/\\/g, '/').replace(/\/$/, '')
142
- return r && a.toLowerCase().startsWith(r.toLowerCase() + '/') ? a.slice(r.length + 1) : a
143
- }
144
-
145
- // Status line: record the limits and print. Returns the text to print.
146
- export function handleStatusline(sessionId, p) {
147
- const s = readSession(sessionId)
148
- if (!s) return { text: '', limits: null }
149
- const limits = limitsFrom(p.rate_limits)
150
- if (limits) recordUsage('claude', s.account, limits, 'claude statusline')
151
- const hot = limits ? [['5h', limits.five_hour], ['7d', limits.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
152
- const warn = hot && hot[1].pct >= WARN_PCT
153
- // reducer: the status-line hook is its own process; only nudge running↔warning
154
- // from `cur`, so it never overwrites a 'limit' a StopFailure set at the same time
155
- updateSession(sessionId, (cur) => {
156
- const patch = { last_activity: new Date().toISOString() }
157
- if (limits) patch.limits = limits
158
- if (p.session_id && !cur.agent_session_id) patch.agent_session_id = p.session_id
159
- if (p.transcript_path && !cur.transcript_path) patch.transcript_path = p.transcript_path
160
- if (warn && !cur.warning) patch.warning = { window: hot[0], pct: hot[1].pct, resets_at: hot[1].resets_at, at: new Date().toISOString() }
161
- if (!warn && cur.warning) patch.warning = null
162
- if (warn && cur.status === 'running') patch.status = 'warning'
163
- if (!warn && cur.status === 'warning') patch.status = 'running'
164
- return patch
165
- }, warn && !s.warning ? { event: { type: 'warning', summary: `claude ${hot[0]} window at ${Math.round(hot[1].pct)}%` } } : {})
166
- const next = s.chain?.[0] ? `${s.chain[0].agent}${s.chain[0].account !== 'default' ? '/' + s.chain[0].account : ''}` : 'nothing'
167
- const pct = limits ? ` 5h ${limits.five_hour ? Math.round(limits.five_hour.pct) + '%' : '-'} · 7d ${limits.seven_day ? Math.round(limits.seven_day.pct) + '%' : '-'}` : ''
168
- const text = warn ? `⚠ leg: ${hot[0]} at ${Math.round(hot[1].pct)}% → next ${next}${pct}` : `leg ·${pct || ' limits pending'} · next ${next} · board ${s.board_url ?? ''}`
169
- return { text, limits, warn }
170
- }
1
+ // claude tap — how `leg claude` sees inside a normal interactive Claude Code.
2
+ // Nothing in ~/.claude is edited: the session gets one extra settings file via
3
+ // `--settings` (hooks merge with the user's; statusLine is the only key that
4
+ // replaces, so Leg's status line runs the user's own command first).
5
+ // Sources: code.claude.com/docs/en/hooks (StopFailure `error: rate_limit`),
6
+ // docs/en/statusline (rate_limits.five_hour/seven_day used_percentage,
7
+ // resets_at), docs/en/settings (`--settings` sits above user settings).
8
+ import { existsSync, readFileSync } from 'node:fs'
9
+ import { join, dirname, resolve } from 'node:path'
10
+ import { fileURLToPath } from 'node:url'
11
+ import { sessionDir, updateSession, appendEvent, readSession, workRoot } from '../sessions.mjs'
12
+ import { recordUsage, markLimited, WARN_PCT } from '../usage.mjs'
13
+ import { writeJsonAtomic } from '../fsx.mjs'
14
+ import { LAYOUT } from '../accounts.mjs'
15
+
16
+ const HOOK = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'hook.mjs')
17
+ const q = (p) => `"${String(p).replace(/\\/g, '\\\\')}"`
18
+
19
+ // The user's own statusLine command, if any, so it keeps running.
20
+ export function userStatusLine(configDir = LAYOUT.claude.home()) {
21
+ for (const f of ['settings.json', 'settings.local.json']) {
22
+ const p = join(configDir, f)
23
+ if (!existsSync(p)) continue
24
+ try {
25
+ const s = JSON.parse(readFileSync(p, 'utf8'))
26
+ if (s.statusLine?.type === 'command' && s.statusLine.command) return s.statusLine
27
+ } catch {}
28
+ }
29
+ return null
30
+ }
31
+
32
+ export function settingsFor(sessionId, { statusLine = null } = {}) {
33
+ const cmd = (kind) => ({ type: 'command', command: `node ${q(HOOK)} ${kind} --session ${sessionId}`, timeout: 20 })
34
+ const settings = {
35
+ hooks: {
36
+ SessionStart: [{ hooks: [cmd('claude-hook')] }],
37
+ UserPromptSubmit: [{ hooks: [cmd('claude-hook')] }],
38
+ PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [cmd('claude-hook')] }],
39
+ Stop: [{ hooks: [cmd('claude-hook')] }],
40
+ StopFailure: [{ hooks: [cmd('claude-hook')] }],
41
+ SessionEnd: [{ hooks: [cmd('claude-hook')] }],
42
+ },
43
+ statusLine: { type: 'command', command: `node ${q(HOOK)} claude-statusline --session ${sessionId}`, padding: statusLine?.padding ?? 0 },
44
+ // Leg owns the limit: it hands the work to the next option instead of
45
+ // waiting in the session for the reset.
46
+ autoContinueAtUsageLimit: false,
47
+ }
48
+ return settings
49
+ }
50
+
51
+ export function writeSettings(sessionId, opts) {
52
+ const file = join(sessionDir(sessionId), 'claude-settings.json')
53
+ writeJsonAtomic(file, settingsFor(sessionId, opts))
54
+ return file
55
+ }
56
+
57
+ function textOf(content) {
58
+ if (typeof content === 'string') return content
59
+ if (Array.isArray(content)) return content.filter((c) => c?.type === 'text' && c.text).map((c) => c.text).join('\n')
60
+ return ''
61
+ }
62
+
63
+ // Human/assistant messages from Claude Code transcript lines (jsonl). Shared
64
+ // with history discovery, which hands in the tail of a file it never reads whole.
65
+ export function messagesFromLines(lines, limit = 8) {
66
+ if (!(limit > 0)) return []
67
+ const out = []
68
+ for (const line of lines) {
69
+ if (!line) continue
70
+ let j
71
+ try { j = JSON.parse(line) } catch { continue }
72
+ if (j.type !== 'user' && j.type !== 'assistant') continue
73
+ if (j.isSidechain) continue
74
+ const text = textOf(j.message?.content).trim()
75
+ if (!text || /^<[a-z-]+>/.test(text)) continue
76
+ out.push({ role: j.type, text: text.slice(0, 1500), ts: j.timestamp ?? null })
77
+ }
78
+ return out.slice(-limit)
79
+ }
80
+
81
+ // Last human/assistant messages from a Claude Code transcript (jsonl).
82
+ export function transcriptTail(path, limit = 8) {
83
+ if (!path || !existsSync(path)) return []
84
+ return messagesFromLines(readFileSync(path, 'utf8').split('\n'), limit)
85
+ }
86
+
87
+ export function firstPrompt(path) {
88
+ const t = transcriptTail(path, 1000).find((m) => m.role === 'user')
89
+ return t ? t.text.slice(0, 500) : null
90
+ }
91
+
92
+ function limitsFrom(rl) {
93
+ if (!rl) return null
94
+ const w = (x) => (x && Number.isFinite(x.used_percentage) ? { pct: x.used_percentage, resets_at: x.resets_at ?? null } : null)
95
+ return { five_hour: w(rl.five_hour), seven_day: w(rl.seven_day) }
96
+ }
97
+
98
+ // Hook payload session record. Returns a short line for the hook log.
99
+ export function handleHook(sessionId, p) {
100
+ const s = readSession(sessionId)
101
+ if (!s) return 'no session'
102
+ const base = { agent_session_id: p.session_id ?? s.agent_session_id, transcript_path: p.transcript_path ?? s.transcript_path, last_activity: new Date().toISOString() }
103
+ switch (p.hook_event_name) {
104
+ case 'SessionStart':
105
+ // reducer: one hook process per tool call, so read `cur` inside the lock
106
+ updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'agent_ready', summary: `claude session ${p.session_id ?? '?'} (${p.source ?? 'startup'})` } })
107
+ return 'session start'
108
+ case 'UserPromptSubmit': {
109
+ updateSession(sessionId, (cur) => ({ ...base, task: cur.task ?? (p.prompt ? String(p.prompt).slice(0, 500) : null), turns: (cur.turns ?? 0) + 1 }), { event: { type: 'turn', summary: `prompt: ${String(p.prompt ?? '').slice(0, 120)}` } })
110
+ return 'prompt'
111
+ }
112
+ case 'PostToolUse': {
113
+ const file = p.tool_input?.file_path ?? p.tool_input?.notebook_path ?? null
114
+ if (!file) return 'tool (no file)'
115
+ const rel = relTo(workRoot(s), file)
116
+ // reducer: concurrent Edit/Write hooks each add their own file without
117
+ // the last writer overwriting the others' additions
118
+ updateSession(sessionId, (cur) => ({ ...base, files_touched: cur.files_touched.includes(rel) ? cur.files_touched : [...cur.files_touched, rel].slice(-200) }))
119
+ return `touched ${rel}`
120
+ }
121
+ case 'Stop':
122
+ // reducer: never turn a 'limit'/'handing_off' back to 'running' by racing
123
+ updateSession(sessionId, (cur) => ({ ...base, status: cur.status === 'starting' ? 'running' : cur.status }), { event: { type: 'turn_done', summary: String(p.last_assistant_message ?? '').slice(0, 160) || 'turn done' } })
124
+ return 'stop'
125
+ case 'StopFailure': {
126
+ if (p.error === 'rate_limit') {
127
+ // a simulated wall (baton sessions simulate-limit) clears after two minutes so a test never walls the real login for hours
128
+ const simulated = Boolean(p.leg_simulated || p.baton_simulated)
129
+ const u = markLimited('claude', s.account, { reason: 'rate_limit', source: simulated ? 'leg simulate-limit' : 'claude StopFailure', resets_at: simulated ? Math.floor(Date.now() / 1000) + 120 : null })
130
+ updateSession(sessionId, { ...base, status: 'limit', limit: { reason: 'rate_limit', detail: String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 300), resets_at: u.limited_until, at: new Date().toISOString(), simulated } }, { event: { type: 'limit', summary: `claude usage limit${simulated ? ' (simulated)' : ''}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` } })
131
+ return 'LIMIT'
132
+ }
133
+ appendEvent(sessionId, { type: 'error', summary: `claude ${p.error}: ${String(p.last_assistant_message ?? p.error_details ?? '').slice(0, 160)}` })
134
+ updateSession(sessionId, base)
135
+ return `error ${p.error}`
136
+ }
137
+ case 'SessionEnd':
138
+ updateSession(sessionId, { ...base, agent_exit_reason: p.reason ?? null }, { event: { type: 'agent_exit', summary: `claude session ended (${p.reason ?? 'unknown'})` } })
139
+ return 'session end'
140
+ default:
141
+ updateSession(sessionId, base)
142
+ return p.hook_event_name ?? 'unknown'
143
+ }
144
+ }
145
+
146
+ export function relTo(root, file) {
147
+ const a = String(file).replace(/\\/g, '/')
148
+ const r = String(root ?? '').replace(/\\/g, '/').replace(/\/$/, '')
149
+ return r && a.toLowerCase().startsWith(r.toLowerCase() + '/') ? a.slice(r.length + 1) : a
150
+ }
151
+
152
+ // Status line: record the limits and print. Returns the text to print.
153
+ export function handleStatusline(sessionId, p) {
154
+ const s = readSession(sessionId)
155
+ if (!s) return { text: '', limits: null }
156
+ const limits = limitsFrom(p.rate_limits)
157
+ if (limits) recordUsage('claude', s.account, limits, 'claude statusline')
158
+ const hot = limits ? [['5h', limits.five_hour], ['7d', limits.seven_day]].filter(([, w]) => w).sort((a, b) => b[1].pct - a[1].pct)[0] : null
159
+ const warn = hot && hot[1].pct >= WARN_PCT
160
+ // reducer: the status-line hook is its own process; only nudge running↔warning
161
+ // from `cur`, so it never overwrites a 'limit' a StopFailure set at the same time
162
+ updateSession(sessionId, (cur) => {
163
+ const patch = { last_activity: new Date().toISOString() }
164
+ if (limits) patch.limits = limits
165
+ if (p.session_id && !cur.agent_session_id) patch.agent_session_id = p.session_id
166
+ if (p.transcript_path && !cur.transcript_path) patch.transcript_path = p.transcript_path
167
+ if (warn && !cur.warning) patch.warning = { window: hot[0], pct: hot[1].pct, resets_at: hot[1].resets_at, at: new Date().toISOString() }
168
+ if (!warn && cur.warning) patch.warning = null
169
+ if (warn && cur.status === 'running') patch.status = 'warning'
170
+ if (!warn && cur.status === 'warning') patch.status = 'running'
171
+ return patch
172
+ }, warn && !s.warning ? { event: { type: 'warning', summary: `claude ${hot[0]} window at ${Math.round(hot[1].pct)}%` } } : {})
173
+ const next = s.chain?.[0] ? `${s.chain[0].agent}${s.chain[0].account !== 'default' ? '/' + s.chain[0].account : ''}` : 'nothing'
174
+ const pct = limits ? ` 5h ${limits.five_hour ? Math.round(limits.five_hour.pct) + '%' : '-'} · 7d ${limits.seven_day ? Math.round(limits.seven_day.pct) + '%' : '-'}` : ''
175
+ const text = warn ? `⚠ leg: ${hot[0]} at ${Math.round(hot[1].pct)}% → next ${next}${pct}` : `leg ·${pct || ' limits pending'} · next ${next} · board ${s.board_url ?? ''}`
176
+ return { text, limits, warn }
177
+ }