@ucsandman/legcli 0.8.0 → 0.9.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 (110) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/NOTICE +8 -0
  3. package/README.md +601 -560
  4. package/bin/fake-agent.mjs +4 -4
  5. package/bin/leg.mjs +21 -12
  6. package/docs/DECISIONS.md +20 -2
  7. package/docs/ERRORS.md +71 -0
  8. package/docs/README.md +2 -0
  9. package/docs/REUSE.md +1 -1
  10. package/docs/VOCABULARY.md +21 -0
  11. package/docs/board-guide.md +13 -0
  12. package/docs/cli-contracts.md +22 -1
  13. package/docs/concepts.md +42 -3
  14. package/docs/configuration.md +22 -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/fixtures/verified.json +1 -1
  19. package/package.json +7 -3
  20. package/scripts/build-docs-site.mjs +11 -4
  21. package/scripts/check-branding.mjs +118 -0
  22. package/scripts/check-claims.mjs +1 -1
  23. package/scripts/license-sign.mjs +1 -1
  24. package/scripts/limits-table.mjs +1 -1
  25. package/scripts/live-limits.mjs +1 -1
  26. package/scripts/npm-publish-gate.mjs +114 -0
  27. package/scripts/probe.mjs +4 -3
  28. package/scripts/seed-fake-cards.mjs +4 -3
  29. package/scripts/seed-floor-board.mjs +5 -4
  30. package/scripts/seed-wes-board.mjs +5 -4
  31. package/scripts/stripe-setup.mjs +1 -1
  32. package/scripts/sync-harness-engine.mjs +159 -0
  33. package/scripts/sync-leg-agents.mjs +127 -0
  34. package/src/accounts.mjs +1 -2
  35. package/src/adapters/codex.mjs +1 -1
  36. package/src/attach.mjs +75 -19
  37. package/src/auth.mjs +2 -2
  38. package/src/board/board.js +3 -3
  39. package/src/board/sessions.js +77 -3
  40. package/src/bundle.mjs +54 -8
  41. package/src/chain.mjs +1 -1
  42. package/src/contract.mjs +4 -3
  43. package/src/fsx.mjs +5 -2
  44. package/src/handoff.mjs +6 -6
  45. package/src/harness/cli.mjs +281 -0
  46. package/src/harness/fingerprint.mjs +68 -0
  47. package/src/harness/index.mjs +407 -0
  48. package/src/harness/registry.mjs +124 -0
  49. package/src/harness/vendor/agnostic-ai/LICENSE +21 -0
  50. package/src/harness/vendor/agnostic-ai/UPSTREAM.json +30 -0
  51. package/src/harness/vendor/agnostic-ai/core/safety/guards.json +96 -0
  52. package/src/harness/vendor/agnostic-ai/core/templates/targets.json +252 -0
  53. package/src/harness/vendor/agnostic-ai/engine/harness/README.md +199 -0
  54. package/src/harness/vendor/agnostic-ai/engine/harness/apply.cjs +247 -0
  55. package/src/harness/vendor/agnostic-ai/engine/harness/bundle.cjs +243 -0
  56. package/src/harness/vendor/agnostic-ai/engine/harness/capture.cjs +119 -0
  57. package/src/harness/vendor/agnostic-ai/engine/harness/common.cjs +375 -0
  58. package/src/harness/vendor/agnostic-ai/engine/harness/index.cjs +55 -0
  59. package/src/harness/vendor/agnostic-ai/engine/harness/sources/claude.cjs +330 -0
  60. package/src/harness/vendor/agnostic-ai/engine/harness/sources/codex.cjs +314 -0
  61. package/src/harness/vendor/agnostic-ai/engine/harness/status.cjs +171 -0
  62. package/src/harness/vendor/agnostic-ai/engine/harness/targets/agy.cjs +113 -0
  63. package/src/harness/vendor/agnostic-ai/engine/harness/targets/claude.cjs +158 -0
  64. package/src/harness/vendor/agnostic-ai/engine/harness/targets/codex.cjs +832 -0
  65. package/src/harness/vendor/agnostic-ai/engine/harness/targets/cursor.cjs +87 -0
  66. package/src/harness/vendor/agnostic-ai/engine/harness/targets/gemini.cjs +128 -0
  67. package/src/harness/vendor/agnostic-ai/engine/harness/targets/generic.cjs +424 -0
  68. package/src/harness/vendor/agnostic-ai/engine/harness/toml.cjs +149 -0
  69. package/src/harness/vendor/agnostic-ai/engine/hooks/shim.cjs +431 -0
  70. package/src/hook.mjs +49 -49
  71. package/src/land.mjs +7 -35
  72. package/src/launcher.mjs +38 -26
  73. package/src/ledger.mjs +6 -6
  74. package/src/license.mjs +10 -9
  75. package/src/live-capture.mjs +1 -1
  76. package/src/mergequeue.mjs +5 -5
  77. package/src/orchestrator.mjs +28 -4
  78. package/src/preferences.mjs +37 -3
  79. package/src/redact.mjs +1 -1
  80. package/src/resume.mjs +17 -15
  81. package/src/runner.mjs +2 -2
  82. package/src/scheduler.mjs +1 -1
  83. package/src/server.mjs +38 -10
  84. package/src/session-detail.mjs +15 -1
  85. package/src/sessions.mjs +6 -3
  86. package/src/share.mjs +2 -2
  87. package/src/stations/agent.mjs +1 -1
  88. package/src/sync/dashclaw.mjs +4 -4
  89. package/src/synthesis.mjs +165 -0
  90. package/src/taps/agy.mjs +2 -2
  91. package/src/taps/claude-usage.mjs +1 -1
  92. package/src/taps/claude.mjs +170 -170
  93. package/src/taps/codex.mjs +286 -286
  94. package/src/taps/grok.mjs +2 -2
  95. package/src/trust.mjs +205 -36
  96. package/src/usage.mjs +5 -1
  97. package/src/worktree.mjs +5 -4
  98. package/fixtures/live/agy/attempt-1-scratch-workspace.out.log +0 -1
  99. package/fixtures/live/agy/err.log +0 -0
  100. package/fixtures/live/agy/out.log +0 -1
  101. package/fixtures/live/agy/supervisor.log +0 -2
  102. package/fixtures/live/claude/err.log +0 -0
  103. package/fixtures/live/claude/out.log +0 -1
  104. package/fixtures/live/claude/supervisor.log +0 -2
  105. package/fixtures/live/codex/err.log +0 -1
  106. package/fixtures/live/codex/out.log +0 -8
  107. package/fixtures/live/codex/supervisor.log +0 -2
  108. package/fixtures/live/grok/err.log +0 -32
  109. package/fixtures/live/grok/out.log +0 -7
  110. package/fixtures/live/grok/supervisor.log +0 -2
@@ -1,170 +1,170 @@
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
+ // 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
+ }