tumwater 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (220) hide show
  1. package/README.md +47 -19
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +34 -0
  4. package/dist/src/agent/claude/claude-account.js +243 -0
  5. package/dist/src/agent/claude/claude-args.js +104 -0
  6. package/dist/src/agent/claude/claude-backend.js +162 -0
  7. package/dist/src/agent/claude/claude-env.js +84 -0
  8. package/dist/src/agent/claude/claude-stream.js +384 -0
  9. package/dist/src/agent/mcp/role-notes-server.js +160 -0
  10. package/dist/src/agent/role-notes-write.js +65 -0
  11. package/dist/src/agent/run-agent.js +439 -0
  12. package/dist/src/agent/session-ledger.js +169 -0
  13. package/dist/src/backlog/backlog-eligibility.js +66 -12
  14. package/dist/src/backlog/backlog-md.js +34 -15
  15. package/dist/src/backlog/backlog-render.js +11 -20
  16. package/dist/src/backlog/backlog-structure.js +68 -63
  17. package/dist/src/backlog/backlog-write.js +488 -39
  18. package/dist/src/backlog/backlog.js +5 -16
  19. package/dist/src/baseline/main-baseline.js +152 -15
  20. package/dist/src/baseline/main-red.js +21 -18
  21. package/dist/src/brief.js +1 -1
  22. package/dist/src/build/build-check-counts.js +15 -3
  23. package/dist/src/build/build-check-detect.js +6 -3
  24. package/dist/src/build/build-check-events.js +51 -6
  25. package/dist/src/build/build-check-scoped.js +26 -27
  26. package/dist/src/build/build-check.js +5 -4
  27. package/dist/src/build/build-stage.js +41 -2
  28. package/dist/src/build/dep-install.js +5 -1
  29. package/dist/src/build/host-sleep.js +2 -0
  30. package/dist/src/cli/cli-args.js +30 -2
  31. package/dist/src/cli/cli-command-args.js +51 -59
  32. package/dist/src/cli/cli-flag-specs.js +16 -4
  33. package/dist/src/cli/cli-marker-commands.js +6 -5
  34. package/dist/src/cli/cli-query-commands.js +89 -26
  35. package/dist/src/cli/cli-repo-gate.js +15 -0
  36. package/dist/src/cli/cli-run.js +25 -4
  37. package/dist/src/cli/config-commands.js +72 -38
  38. package/dist/src/cli/help.js +116 -29
  39. package/dist/src/cli/log-commands.js +3 -8
  40. package/dist/src/cli/notify-command.js +68 -0
  41. package/dist/src/cli/question-commands.js +55 -18
  42. package/dist/src/cli.js +89 -32
  43. package/dist/src/collections.js +8 -2
  44. package/dist/src/concurrency/check-permit.js +2 -2
  45. package/dist/src/concurrency/semaphore.js +26 -4
  46. package/dist/src/config/config-editable-keys.js +13 -2
  47. package/dist/src/config/config-example.js +2 -2
  48. package/dist/src/config/config-field-checks.js +23 -6
  49. package/dist/src/config/config-live.js +4 -14
  50. package/dist/src/config/config-schema.js +18 -7
  51. package/dist/src/config/config-validation.js +57 -16
  52. package/dist/src/config/config-views.js +117 -46
  53. package/dist/src/config/config-write.js +282 -24
  54. package/dist/src/config/config.js +56 -15
  55. package/dist/src/config/model-selector.js +32 -1
  56. package/dist/src/doctor/doctor-backlog.js +22 -27
  57. package/dist/src/doctor/doctor-checks.js +180 -116
  58. package/dist/src/doctor/doctor-holds.js +122 -0
  59. package/dist/src/doctor/doctor-launch-services.js +5 -7
  60. package/dist/src/doctor/doctor-model-checks.js +228 -86
  61. package/dist/src/doctor/doctor-orphans.js +7 -15
  62. package/dist/src/doctor/doctor.js +13 -3
  63. package/dist/src/doctor/outcome.js +16 -0
  64. package/dist/src/events/event-format.js +60 -6
  65. package/dist/src/events/event-read.js +4 -2
  66. package/dist/src/events/event-window.js +82 -3
  67. package/dist/src/events/events.js +27 -35
  68. package/dist/src/events/notify.js +18 -8
  69. package/dist/src/failure/failure-cluster.js +25 -11
  70. package/dist/src/failure/failure-data.js +122 -16
  71. package/dist/src/failure/failure-render.js +29 -11
  72. package/dist/src/failure/failure-state-change.js +30 -3
  73. package/dist/src/failure/time-spend.js +54 -15
  74. package/dist/src/files/files.js +107 -1
  75. package/dist/src/files/json-files.js +5 -4
  76. package/dist/src/files/json-object.js +34 -17
  77. package/dist/src/files/stat-cache.js +15 -2
  78. package/dist/src/files/tail.js +2 -2
  79. package/dist/src/fleet/error-storm.js +2 -2
  80. package/dist/src/fleet/fleet-hold.js +21 -3
  81. package/dist/src/fleet/fleet-polls.js +95 -21
  82. package/dist/src/fleet/fleet-state.js +30 -6
  83. package/dist/src/fleet/orchestrator-info.js +18 -0
  84. package/dist/src/fleet/reclaim.js +28 -28
  85. package/dist/src/gates/bootstrap-gates.js +5 -3
  86. package/dist/src/gates/budget-gates.js +34 -21
  87. package/dist/src/gates/gate-polls.js +18 -11
  88. package/dist/src/gates/maintenance-quota.js +2 -2
  89. package/dist/src/gates/role-cap-gates.js +73 -30
  90. package/dist/src/gates/startup-gate.js +44 -11
  91. package/dist/src/git/commit-message.js +5 -4
  92. package/dist/src/git/git-diff.js +41 -44
  93. package/dist/src/git/git-run.js +2 -0
  94. package/dist/src/git/git.js +207 -5
  95. package/dist/src/git/slots-state.js +7 -4
  96. package/dist/src/git/worktree-pool.js +18 -14
  97. package/dist/src/git/worktree-use.js +19 -2
  98. package/dist/src/git/worktree.js +15 -15
  99. package/dist/src/gui/gui-args.js +49 -0
  100. package/dist/src/gui/gui-endpoint-commands.js +416 -54
  101. package/dist/src/gui/gui-endpoints.js +181 -26
  102. package/dist/src/gui/gui-server.js +16 -2
  103. package/dist/src/history/history-data.js +124 -20
  104. package/dist/src/history/history.js +54 -50
  105. package/dist/src/inbox/inbox-attachments.js +126 -7
  106. package/dist/src/inbox/inbox-cancel.js +20 -9
  107. package/dist/src/inbox/inbox-edit.js +100 -20
  108. package/dist/src/inbox/inbox-submit.js +13 -9
  109. package/dist/src/inbox/inbox.js +110 -27
  110. package/dist/src/inbox/pending-prompt.js +71 -19
  111. package/dist/src/inbox/prompt-commands.js +50 -106
  112. package/dist/src/init/init.js +18 -16
  113. package/dist/src/landing/landing-batch.js +23 -7
  114. package/dist/src/landing/landing-check-failures.js +3 -3
  115. package/dist/src/landing/landing-core.js +9 -5
  116. package/dist/src/landing/landing-drain.js +54 -9
  117. package/dist/src/landing/landing-merge.js +96 -43
  118. package/dist/src/landing/landing-pipeline.js +14 -0
  119. package/dist/src/landing/landing-queue-render.js +31 -0
  120. package/dist/src/landing/landing-queue.js +31 -5
  121. package/dist/src/landing/landing-slot.js +24 -7
  122. package/dist/src/landing/landing-vetting.js +6 -5
  123. package/dist/src/loop/leftover.js +8 -7
  124. package/dist/src/loop/loop-fallback.js +66 -0
  125. package/dist/src/loop/loop-pi.js +121 -33
  126. package/dist/src/loop/loop-recovery.js +90 -0
  127. package/dist/src/loop/loop-state.js +42 -18
  128. package/dist/src/loop/loop-tick.js +539 -0
  129. package/dist/src/loop/loop.js +85 -667
  130. package/dist/src/operator/operator-commands.js +27 -17
  131. package/dist/src/operator/operator-intent.js +53 -8
  132. package/dist/src/operator/operator-requests.js +129 -15
  133. package/dist/src/orchestrator/orchestrator-launch.js +1 -1
  134. package/dist/src/orchestrator/orchestrator-scheduling.js +41 -7
  135. package/dist/src/orchestrator/orchestrator.js +41 -24
  136. package/dist/src/orchestrator/retention.js +10 -1
  137. package/dist/src/paths.js +31 -2
  138. package/dist/src/pi/pi-args.js +3 -3
  139. package/dist/src/pi/pi-backend.js +90 -0
  140. package/dist/src/pi/pi-bin.js +83 -23
  141. package/dist/src/pi/pi-event-line.js +14 -12
  142. package/dist/src/pi/pi-models.js +65 -21
  143. package/dist/src/pi/pi-run-result.js +7 -1
  144. package/dist/src/pi/pi-stream.js +170 -44
  145. package/dist/src/pi/pi-watchdogs.js +31 -14
  146. package/dist/src/pi/pi.js +9 -304
  147. package/dist/src/pi-extension/bounded-output.js +1 -1
  148. package/dist/src/pi-extension/context-shake.js +8 -2
  149. package/dist/src/pi-extension/role-notes.js +11 -61
  150. package/dist/src/process/process.js +6 -4
  151. package/dist/src/process/run-marker.js +22 -9
  152. package/dist/src/process/supervisor.js +86 -29
  153. package/dist/src/prompt/dialect.js +35 -0
  154. package/dist/src/{gates → prompt}/gate-prompts.js +66 -21
  155. package/dist/src/prompt/prompt-followup.js +30 -8
  156. package/dist/src/prompt/prompt.js +32 -15
  157. package/dist/src/redeploy/redeploy.js +1 -1
  158. package/dist/src/redeploy/redeployer.js +5 -3
  159. package/dist/src/redeploy/self-reload.js +1 -2
  160. package/dist/src/report/report-data.js +148 -48
  161. package/dist/src/report/report-render.js +42 -17
  162. package/dist/src/report/report.js +22 -9
  163. package/dist/src/review/review-followup.js +12 -7
  164. package/dist/src/review/review-precheck.js +2 -2
  165. package/dist/src/review/review-verdict.js +103 -6
  166. package/dist/src/review/review.js +109 -24
  167. package/dist/src/review/suite-rerun.js +1 -1
  168. package/dist/src/roles/loop-ids.js +2 -3
  169. package/dist/src/roles/role-render.js +40 -13
  170. package/dist/src/roles/role-view.js +17 -3
  171. package/dist/src/roles/roles.js +2 -2
  172. package/dist/src/scheduling/backoff.js +1 -1
  173. package/dist/src/scheduling/claims.js +52 -7
  174. package/dist/src/scheduling/scheduling.js +3 -3
  175. package/dist/src/scheduling/work-landed-cache.js +1 -1
  176. package/dist/src/status/status-data.js +67 -20
  177. package/dist/src/text/datetime.js +9 -0
  178. package/dist/src/text/markdown.js +2 -2
  179. package/dist/src/text/suggest.js +1 -1
  180. package/dist/src/tick/qa-coverage.js +11 -3
  181. package/dist/src/tick/stage-check.js +25 -3
  182. package/dist/src/tick/tick-apply.js +21 -9
  183. package/dist/src/tick/tick-finalize.js +20 -4
  184. package/dist/src/tick/tick-prompt.js +23 -11
  185. package/dist/src/tick/tick-resume.js +71 -10
  186. package/dist/src/tick/tick-stage.js +21 -9
  187. package/dist/src/tick/tick-timing.js +23 -13
  188. package/dist/src/tick/tick-usage.js +34 -4
  189. package/dist/src/tick/tick-verdict.js +47 -2
  190. package/dist/src/ui/badges.js +82 -6
  191. package/dist/src/ui/gui/gui-client-boot.js +93 -5
  192. package/dist/src/ui/gui/gui-client-composer.js +13 -8
  193. package/dist/src/ui/gui/gui-client-drawer.js +141 -20
  194. package/dist/src/ui/gui/gui-client-fleet.js +263 -18
  195. package/dist/src/ui/gui/gui-client-history.js +84 -37
  196. package/dist/src/ui/gui/gui-client-loops.js +83 -9
  197. package/dist/src/ui/gui/gui-client-model.js +1 -1
  198. package/dist/src/ui/gui/gui-client-operator.js +90 -10
  199. package/dist/src/ui/gui/gui-client-pending.js +39 -7
  200. package/dist/src/ui/gui/gui-client-report.js +190 -50
  201. package/dist/src/ui/gui/gui-client-settings.js +69 -19
  202. package/dist/src/ui/gui/gui-client.js +44 -11
  203. package/dist/src/ui/gui/gui-page.js +7 -1
  204. package/dist/src/ui/gui/gui-styles.js +1 -0
  205. package/dist/src/ui/progress-data.js +32 -26
  206. package/dist/src/ui/status-model.js +22 -10
  207. package/dist/src/ui/status-payload.js +29 -16
  208. package/dist/src/ui/status-render.js +14 -6
  209. package/dist/src/ui/tone.js +1 -1
  210. package/dist/src/ui/transcript.js +4 -4
  211. package/dist/src/ui/tui/tui-backlog.js +8 -6
  212. package/dist/src/ui/tui/tui-frame.js +49 -9
  213. package/dist/src/ui/tui/tui-history.js +29 -0
  214. package/dist/src/ui/tui/tui-input.js +84 -6
  215. package/dist/src/ui/tui/tui-keys.js +1382 -50
  216. package/dist/src/ui/tui/tui-pane.js +132 -23
  217. package/dist/src/ui/tui/tui.js +8 -5
  218. package/dist/src/verdict/fix-claim.js +18 -31
  219. package/dist/src/verdict/reply-contract.js +16 -15
  220. package/package.json +3 -1
@@ -0,0 +1,162 @@
1
+ /** The claude adapter for the agent seam (plans/agent-backends.md "The seam", "Sessions",
2
+ * "The claude invocation"). It shapes one run's argv/stdin/env, translates claude's
3
+ * `stream-json` into the canonical stream, and classifies the exit's stderr — everything
4
+ * backend-specific. src/agent/run-agent.ts owns the spawn and records this run in the session
5
+ * ledger; claude mints its own session id (a fresh UUID, or the resumed record's id), which
6
+ * `invocation` returns so the ledger can carry it.
7
+ *
8
+ * Sessions resume by id from the cwd the session started in, so the adapter consults the
9
+ * ledger's newest record of this run's kind and only passes `--resume` when that record names
10
+ * claude, this cwd, and a transcript file that exists. Otherwise it mints a fresh id — a
11
+ * missing transcript, a different cwd, or another kind's newer session all review fresh rather
12
+ * than resuming the wrong conversation.
13
+ *
14
+ * Registration lives in src/agent/run-agent.ts beside the pi adapter, so a test can register a
15
+ * fake under its own id; a test selects claude by setting `config.agent = "claude"` on a run. */
16
+ import fs from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+ import { fileURLToPath } from "node:url";
20
+ import { randomUUID } from "node:crypto";
21
+ import { resolvedAgentConfig } from "../../config/config-views.js";
22
+ import { MODEL_TIERS } from "../../config/config-schema.js";
23
+ import { routeModel } from "../../config/model-selector.js";
24
+ import { agentBaseEnv } from "../../process/run-marker.js";
25
+ import { resolveBackendBin } from "../../pi/pi-bin.js";
26
+ import { claudeArgs } from "./claude-args.js";
27
+ import { claudeEnv, claudeThirdPartyProvider } from "./claude-env.js";
28
+ import { claudeTranslator } from "./claude-stream.js";
29
+ import { resumableSession } from "../session-ledger.js";
30
+ /** claude's per-cwd project directory name under `~/.claude/projects/`: every character that
31
+ * is not a letter or digit becomes `-`. Exported with `claudeTranscriptPath` so the PATH fake
32
+ * and the tests create and look for exactly the file the resume check reads. */
33
+ export function claudeProjectSlug(cwd) {
34
+ return cwd.replace(/[^A-Za-z0-9]/g, "-");
35
+ }
36
+ /** The transcript file claude writes for a session started in `cwd`: its resume check must find
37
+ * a real file before passing `--resume`. claude's own layout is
38
+ * `~/.claude/projects/<slug>/<sessionId>.jsonl`; exported so the fake and the resume logic
39
+ * cannot drift. */
40
+ export function claudeTranscriptPath(cwd, id) {
41
+ return path.join(os.homedir(), ".claude", "projects", claudeProjectSlug(cwd), `${id}.jsonl`);
42
+ }
43
+ /** The CLI version every gated claude model needs (plans/agent-backends.md finding 9): on
44
+ * 2.1.216 Opus 5.5 is refused outright and Haiku 5.5 is mispriced about 250×, and fleet runs
45
+ * set DISABLE_AUTOUPDATER so they never update themselves. The gated models are the three
46
+ * tier defaults, derived from AGENT_DEFAULT_MODELS through the same routing the config uses
47
+ * rather than listed again, so this table cannot drift from the models a seam resolves. */
48
+ const CLAUDE_MIN_VERSION = "2.1.280";
49
+ const CLAUDE_GATED_MODELS = new Set(MODEL_TIERS.map((tier) => routeModel(undefined, undefined, "claude", tier).model).filter((model) => model !== undefined));
50
+ /** The minimum CLI version `model` needs, or undefined when this adapter gates no floor for
51
+ * it (a model outside the three tier defaults). Doctor reads it through `claudeBackend`. */
52
+ function claudeMinVersion(model) {
53
+ return CLAUDE_GATED_MODELS.has(model) ? CLAUDE_MIN_VERSION : undefined;
54
+ }
55
+ /** Classify claude's stderr: a nonzero exit naming a login or model problem is a permanent
56
+ * configuration error. The stream translator classifies the structured result signals; this is
57
+ * the stderr half, for a CLI that died before emitting one. A run the harness killed has its
58
+ * own cause and is never classified here. */
59
+ function classifyClaudeExit(info) {
60
+ if (info.killed)
61
+ return {};
62
+ if (info.code !== 0 &&
63
+ /Not logged in|issue with the selected model|does not support this model/i.test(info.stderr)) {
64
+ return { configError: true };
65
+ }
66
+ return {};
67
+ }
68
+ /** The inline `--mcp-config` JSON that runs the bundled role-notes server (part 5/7): the
69
+ * same dist module pi's extension resolves to, spawned by the same node running this harness
70
+ * so it needs no PATH lookup. */
71
+ function roleNotesMcpConfig() {
72
+ const server = fileURLToPath(new URL("../mcp/role-notes-server.js", import.meta.url));
73
+ return JSON.stringify({ mcpServers: { tumwater: { command: process.execPath, args: [server] } } });
74
+ }
75
+ /** The ledger record this run may resume, or null: the newest record of `opts.kind` for
76
+ * claude, at `opts.cwd` when one is given, whose transcript file exists. Shared by
77
+ * `resolveClaudeSession` and the backend's `resumable` hook, so the resume guard and the
78
+ * invocation can never drift on what counts as resumable. */
79
+ function claudeResumableRecord(dir, opts) {
80
+ const record = resumableSession(dir, opts);
81
+ if (record === null)
82
+ return null;
83
+ if (opts.cwd !== undefined && record.cwd !== opts.cwd)
84
+ return null;
85
+ if (!fs.existsSync(claudeTranscriptPath(record.cwd, record.id)))
86
+ return null;
87
+ return record;
88
+ }
89
+ /** The session id this run uses: for a continue, the ledger's newest record of this kind when
90
+ * it is claude's, this cwd's, and its transcript file exists; otherwise a fresh UUID. Returns
91
+ * the id and the `--resume` id when resuming. */
92
+ function resolveClaudeSession(run) {
93
+ if (run.continueSession) {
94
+ const record = claudeResumableRecord(run.sessionDir, {
95
+ kind: run.kind,
96
+ agent: "claude",
97
+ cwd: run.cwd,
98
+ });
99
+ if (record !== null)
100
+ return { sessionId: record.id, resumeSessionId: record.id };
101
+ }
102
+ return { sessionId: randomUUID() };
103
+ }
104
+ /** The agent-backend seam's claude adapter. */
105
+ export const claudeBackend = {
106
+ id: "claude",
107
+ label: "Claude Code",
108
+ defaultBin: "claude",
109
+ binEnv: "TUMWATER_CLAUDE_BIN",
110
+ minVersion: claudeMinVersion,
111
+ resolveBin(config) {
112
+ return resolveBackendBin(config, this);
113
+ },
114
+ invocation(run) {
115
+ // Part 4b/7's config routing fills a claude tier default before the seam, so a nameless
116
+ // run here is a caller bug (a config that named claude without going through the views).
117
+ if (run.config.model === undefined) {
118
+ throw new Error("claude backend: no model resolved for this run");
119
+ }
120
+ const { sessionId, resumeSessionId } = resolveClaudeSession(run);
121
+ const { args, stdin } = claudeArgs({
122
+ cwd: run.cwd,
123
+ model: run.config.model,
124
+ thinking: run.config.thinking,
125
+ ...(resumeSessionId !== undefined ? { resumeSessionId } : { sessionId }),
126
+ // The operator's `agents.claude.args` passthrough, appended after the harness flags (the
127
+ // builder rejects any entry that repeats a harness-managed flag). resolvedAgentConfig
128
+ // owns the view so a caller cannot mutate the loaded config through the returned array.
129
+ args: resolvedAgentConfig(run.config, "claude").args,
130
+ prompt: run.prompt,
131
+ // The caller computes the remaining daily budget for an API-billed claude run and the
132
+ // adapter forwards it; a subscription run or the director passes none (part 7a/7).
133
+ ...(run.maxBudgetUsd !== undefined ? { maxBudgetUsd: run.maxBudgetUsd } : {}),
134
+ // claude does not load the bundled pi extensions, so a run carrying a notebook gets the
135
+ // role_notes MCP server instead. Passed on EVERY claude invocation that carries a
136
+ // notebook — fresh or resumed, a transient retry, a SUMMARY follow-up — because claude
137
+ // keeps no flags across --resume, so a resume without this would lose the tool.
138
+ ...(run.notesPath !== undefined ? { mcpConfig: roleNotesMcpConfig() } : {}),
139
+ });
140
+ // agentBaseEnv applies the run marker and the macOS preload (shared with the pi adapter);
141
+ // claudeEnv layers the backend's own scrub-and-set on top. The notebook path rides beside
142
+ // the marker the same way pi's adapter carries it (pi-backend.ts withNotesPath), so the
143
+ // spawned MCP server — which inherits this environment — reads the same notes file.
144
+ const base = run.notesPath === undefined
145
+ ? agentBaseEnv(run.runMarker)
146
+ : { ...agentBaseEnv(run.runMarker), TUMWATER_NOTES_PATH: run.notesPath };
147
+ const env = claudeEnv(base, {
148
+ quietTimeoutSeconds: run.config.quietTimeoutSeconds,
149
+ });
150
+ return { args, stdin, env, sessionId };
151
+ },
152
+ translator() {
153
+ // A third-party provider (Bedrock/Vertex/Foundry) bills as API even when claude's
154
+ // `system/init` omits a usable `apiKeySource`; seedBilling reads the same environment to
155
+ // seed the account file, so both must classify it the same way.
156
+ return claudeTranslator({ thirdPartyProvider: claudeThirdPartyProvider(process.env) });
157
+ },
158
+ resumable(dir, opts) {
159
+ return claudeResumableRecord(dir, opts) !== null;
160
+ },
161
+ classifyExit: classifyClaudeExit,
162
+ };
@@ -0,0 +1,84 @@
1
+ /** Building the environment for one claude run (plans/agent-backends.md "The claude
2
+ * invocation", "Findings" 2 and 4) — pure scrub-and-set over a base environment, with no
3
+ * subprocess. The caller layers the run-marker, the macOS LaunchServices preload, and the
4
+ * notebook on first (src/pi/pi-backend.ts's `withNotesPath` shows the pi adapter doing the
5
+ * same), then hands the result here.
6
+ *
7
+ * Inherited Claude Code session variables break auth: spawned from inside a Claude Code
8
+ * session, a child that keeps `CLAUDECODE`, `CLAUDE_CODE_*`, `CLAUDE_AGENT_SDK_*` or the host's
9
+ * `ANTHROPIC_BASE_URL` reports "Not logged in" (finding 2). Everything the host session set is
10
+ * scrubbed, except the operator-intent allowlist; `ANTHROPIC_BASE_URL` is dropped only when
11
+ * `CLAUDECODE` was set, so an operator's own proxy still reaches a run started outside a
12
+ * session.
13
+ *
14
+ * The set half disables auto-memory, backgrounded Bash tasks (finding 4: claude refuses a long
15
+ * foreground command, backgrounds it, and abandons it when `-p` ends, which would report work
16
+ * never verified), the auto-updater (a mid-fleet self-update must not swap the binary under a
17
+ * run), telemetry, and error reporting; and raises the Bash timeouts so a slow suite runs in
18
+ * the foreground. `BASH_MAX_TIMEOUT_MS` is derived from `quietTimeoutSeconds` so claude's own
19
+ * Bash timeout fires before tumwater's quiet kill and the model sees a tool error rather than a
20
+ * dead run. */
21
+ /** The cloud-provider switches that make a claude run bill as API rather than a claude.ai
22
+ * subscription: Bedrock, Vertex, and Azure Foundry. They carry operator intent, so the run
23
+ * environment keeps them (the allowlist below); claude-account's seedBilling and this module's
24
+ * `claudeThirdPartyProvider` classify all three as API billing. */
25
+ const THIRD_PARTY_PROVIDER_VARS = [
26
+ "CLAUDE_CODE_USE_BEDROCK",
27
+ "CLAUDE_CODE_USE_VERTEX",
28
+ "CLAUDE_CODE_USE_FOUNDRY",
29
+ ];
30
+ /** True when `base` carries one of the third-party provider switches. seedBilling
31
+ * (claude-account.ts) and the run's stream translator (claude-backend.ts) both answer billing
32
+ * through this, so a provider run agrees with the account file about its own billing even when
33
+ * claude's `system/init` omits a usable `apiKeySource`. */
34
+ export function claudeThirdPartyProvider(base) {
35
+ return THIRD_PARTY_PROVIDER_VARS.some((name) => base[name] !== undefined);
36
+ }
37
+ /** The host-session variables that carry operator intent rather than the session's state, so a
38
+ * run inherits them: which cloud provider to authenticate through (Bedrock, Vertex, or Azure
39
+ * Foundry), an OAuth token, and an output cap. Every other `CLAUDE_CODE_*` is the session's
40
+ * own. The three provider switches must all survive together: seeds that classify billing
41
+ * treat any of them as API, so a run that dropped one would disagree with the account file
42
+ * about its own provider. */
43
+ const CLAUDE_CODE_ALLOWLIST = new Set([
44
+ ...THIRD_PARTY_PROVIDER_VARS,
45
+ "CLAUDE_CODE_OAUTH_TOKEN",
46
+ "CLAUDE_CODE_MAX_OUTPUT_TOKENS",
47
+ ]);
48
+ /** claude's default foreground Bash ceiling, matching the design's `BASH_DEFAULT_TIMEOUT_MS`
49
+ * (finding 4: claude's own default is 2 min, too short for a real suite). */
50
+ const BASH_DEFAULT_TIMEOUT_MS = 600_000;
51
+ /** The floor for the derived `BASH_MAX_TIMEOUT_MS`. A positive `quietTimeoutSeconds` below 60
52
+ * would derive a non-positive cap, which claude rejects (or reads as unlimited); clamp to one
53
+ * second so a very short quiet window still hands claude a usable, positive ceiling. */
54
+ const MIN_BASH_MAX_TIMEOUT_MS = 1_000;
55
+ /** Build the claude process environment from `base`. Never mutates `base`. */
56
+ export function claudeEnv(base, opts) {
57
+ const hadClaudeCode = base.CLAUDECODE !== undefined;
58
+ const env = {};
59
+ for (const [key, value] of Object.entries(base)) {
60
+ if (key === "CLAUDECODE" || key === "CLAUDE_PID" || key === "CLAUDE_EFFORT")
61
+ continue;
62
+ if (key.startsWith("CLAUDE_CODE_") && !CLAUDE_CODE_ALLOWLIST.has(key))
63
+ continue;
64
+ if (key.startsWith("CLAUDE_AGENT_SDK_"))
65
+ continue;
66
+ // The host session's proxy, not the operator's: only when CLAUDECODE says we are inside one.
67
+ if (key === "ANTHROPIC_BASE_URL" && hadClaudeCode)
68
+ continue;
69
+ env[key] = value;
70
+ }
71
+ env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
72
+ env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS = "1";
73
+ env.BASH_DEFAULT_TIMEOUT_MS = String(BASH_DEFAULT_TIMEOUT_MS);
74
+ if (opts.quietTimeoutSeconds > 0) {
75
+ // 60 s under the quiet kill, so claude's tool error arrives before the run is declared hung;
76
+ // floored so a quiet window under 60 s never derives a non-positive cap.
77
+ const derived = opts.quietTimeoutSeconds * 1000 - 60_000;
78
+ env.BASH_MAX_TIMEOUT_MS = String(Math.max(derived, MIN_BASH_MAX_TIMEOUT_MS));
79
+ }
80
+ env.DISABLE_AUTOUPDATER = "1";
81
+ env.DISABLE_TELEMETRY = "1";
82
+ env.DISABLE_ERROR_REPORTING = "1";
83
+ return env;
84
+ }
@@ -0,0 +1,384 @@
1
+ /** The claude adapter's native-to-canonical stream translator (plans/agent-backends.md
2
+ * "Canonical event stream"). Claude Code's `--output-format stream-json` vocabulary differs
3
+ * from pi's in every dimension the readers care about: one content block per `assistant`
4
+ * event (with the same `message.id` and its usage repeated on each), tool calls named `Bash`/
5
+ * `Read`/`File`-style, `file_path` rather than `path`, cost only in the final `result`, and
6
+ * errors carried as `is_error` + `api_error_status` + `terminal_reason` rather than as prose
7
+ * in a message. Rather than teach ~fifteen readers a second dialect, every native line is
8
+ * translated here into pi's vocabulary — the canonical stream — so `PiStreamParser`, the raw
9
+ * log, the transcript, progress, and the GUI all stay unchanged.
10
+ *
11
+ * Pure translation: no spawn, no backend registration, no config. The factory keeps per-run
12
+ * state (the pending split-block assistant message, the unmapped native types seen, whether a
13
+ * `run_cost` arrived), so call it once per run. Unmapped native types are recorded once each
14
+ * on the seam's optional `warnings()` — src/agent/run-agent.ts forwards each as an
15
+ * `agent_unknown_event` warning after the run. `cost_unknown` is recorded the same way when a
16
+ * run produced no authoritative `run_cost`. */
17
+ import { isJsonObject, objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../../files/json-object.js";
18
+ /** claude's tool names → the canonical pi names the readers already know. Glob/Grep are kept
19
+ * lower-cased; MultiEdit collapses onto edit (pi has one edit tool). Anything else is
20
+ * lower-cased so a future tool still reads as a name rather than as `undefined`. */
21
+ const TOOL_NAMES = {
22
+ Bash: "bash",
23
+ Read: "read",
24
+ Edit: "edit",
25
+ MultiEdit: "edit",
26
+ Write: "write",
27
+ Glob: "glob",
28
+ Grep: "grep",
29
+ };
30
+ /** claude's HTTP status classes that mean a transient backend failure. 529 is Anthropic's
31
+ * "overloaded" spelling. */
32
+ const SERVER_STATUS = new Set([500, 502, 503, 504, 529]);
33
+ /** claude's HTTP status classes that mean a permanent configuration error. */
34
+ const CONFIG_STATUS = new Set([400, 401, 403, 404]);
35
+ function str(value) {
36
+ return typeof value === "string" ? value : undefined;
37
+ }
38
+ /** claude's usage block → pi's canonical usage block, with `cost.total` 0 because the run's
39
+ * authoritative cost rides the final `run_cost` event (a split-block message repeats its usage
40
+ * on every event, so summing per event would double-count — the fold takes it once). */
41
+ function canonicalUsage(usage) {
42
+ const input = nonNegativeNumber(usage?.input_tokens, 0);
43
+ const cacheRead = nonNegativeNumber(usage?.cache_read_input_tokens, 0);
44
+ const cacheWrite = nonNegativeNumber(usage?.cache_creation_input_tokens, 0);
45
+ const output = nonNegativeNumber(usage?.output_tokens, 0);
46
+ return {
47
+ input,
48
+ cacheRead,
49
+ cacheWrite,
50
+ output,
51
+ totalTokens: input + cacheRead + cacheWrite + output,
52
+ cost: { total: 0 },
53
+ };
54
+ }
55
+ /** One claude content block → one pi content block, or undefined for a block pi has no
56
+ * vocabulary for (dropped, like any other unmapped native shape). `tool_use` becomes pi's
57
+ * `toolCall` with the canonical name and an `arguments` object carrying claude's `input` plus
58
+ * a `path` alias of `file_path` — `describeToolCall` reads either. */
59
+ function canonicalBlock(block) {
60
+ switch (block.type) {
61
+ case "text":
62
+ return { type: "text", text: str(block.text) ?? "" };
63
+ case "thinking":
64
+ return { type: "thinking", thinking: str(block.thinking) ?? str(block.text) ?? "" };
65
+ case "tool_use": {
66
+ const input = objectOrEmpty(block.input);
67
+ const args = { ...input };
68
+ if (typeof input.file_path === "string")
69
+ args.path = input.file_path;
70
+ const name = str(block.name) ?? "";
71
+ // Own-property test, not a bare index: a tool name like `constructor`, `toString`, or
72
+ // `__proto__` would otherwise inherit a function (or Object.prototype) from the table's
73
+ // prototype chain and bypass the lower-cased fallback — JSON.stringify then drops the
74
+ // canonical event's toolName or turns it into an empty object. The same guard claude-args
75
+ // applies to EFFORT_BY_THINKING.
76
+ return {
77
+ type: "toolCall",
78
+ id: str(block.id) ?? "",
79
+ name: Object.hasOwn(TOOL_NAMES, name) ? TOOL_NAMES[name] : name.toLowerCase(),
80
+ arguments: args,
81
+ };
82
+ }
83
+ default:
84
+ return undefined;
85
+ }
86
+ }
87
+ /** How a `result` event's error signals classify into the canonical `tumwaterKind` (and, for
88
+ * a transient backend, its kind), trusted by `PiStreamParser` before its text regexes. Empty
89
+ * when the result is not an error or carries no signal the vocabulary names — a SIGTERM
90
+ * (`terminal_reason: "aborted_tools"`) is deliberate: the harness killed the run, so no
91
+ * provider classification applies. `status` is `api_error_status`, which is null on the
92
+ * not-logged-in result, so the text checks carry that case. */
93
+ function classifyResult(result) {
94
+ const terminal = str(result.terminal_reason);
95
+ if (terminal === "aborted_tools")
96
+ return {};
97
+ if (terminal === "error_max_budget_usd")
98
+ return { kind: "budgetCapped" };
99
+ const text = [str(result.result), str(result.error)].filter(Boolean).join(" ");
100
+ if (/error_max_budget_usd/i.test(text))
101
+ return { kind: "budgetCapped" };
102
+ const status = nonNegativeNumber(result.api_error_status, undefined);
103
+ if (status === 429)
104
+ return { kind: "transientRateLimit" };
105
+ if (status !== undefined && SERVER_STATUS.has(status))
106
+ return { kind: "transientBackend", backendKind: "server" };
107
+ // The context-overflow text wins over a configuration status: claude reports an oversized
108
+ // prompt as HTTP 400 ("prompt is too long"), whose status alone would read as configError.
109
+ if (/prompt is too long/i.test(text))
110
+ return { kind: "contextExceeded" };
111
+ if (status !== undefined && CONFIG_STATUS.has(status))
112
+ return { kind: "configError" };
113
+ // Text fallbacks, for the statuses claude leaves null or omits.
114
+ if (/No conversation found with session ID/i.test(text))
115
+ return { kind: "resumeMissing" };
116
+ if (/Not logged in|issue with the selected model|does not support this model/i.test(text))
117
+ return { kind: "configError" };
118
+ if (/\b429\b|rate limit/i.test(text))
119
+ return { kind: "transientRateLimit" };
120
+ if (/overloaded|internal server error|bad gateway|service unavailable|gateway timeout|\b5\d\d\b/i.test(text))
121
+ return { kind: "transientBackend", backendKind: "server" };
122
+ if (/connection|econn|refused|reset|terminated/i.test(text))
123
+ return { kind: "transientBackend", backendKind: "connection" };
124
+ if (/timed out|timeout/i.test(text))
125
+ return { kind: "transientBackend", backendKind: "timeout" };
126
+ if (/\b(400|401|403|404)\b/i.test(text))
127
+ return { kind: "configError" };
128
+ return {};
129
+ }
130
+ /** Build a fresh per-run claude translator. */
131
+ export function claudeTranslator(opts = {}) {
132
+ /** The split-block assistant message being folded: claude emits one event per content
133
+ * block, so a text+tool_use turn arrives as two events with the same `message.id` and the
134
+ * full usage repeated on each. */
135
+ let pending;
136
+ let anonymous = 0;
137
+ let sawRunCost = false;
138
+ /** True once the run produced real work (an assistant turn or a result) — the signal that a
139
+ * missing `run_cost` is a gap rather than the quiet of an eventless stream. */
140
+ let sawActivity = false;
141
+ const seen = new Set();
142
+ const warnings = new Set();
143
+ /** Emit the pending assistant message (if any) as ONE canonical `message_end`. Tool calls
144
+ * open separately, the moment their `tool_use` block is folded (see `startToolCall`): claude
145
+ * emits the assistant message before it runs the tool, and the `tool_result` only after, so
146
+ * opening the call here at message close would expose it to the open-call trackers only when
147
+ * the closing `user` event carries the result — never while the tool is actually running. */
148
+ const flush = () => {
149
+ const message = pending;
150
+ if (!message)
151
+ return [];
152
+ pending = undefined;
153
+ return [
154
+ JSON.stringify({
155
+ type: "message_end",
156
+ message: {
157
+ role: "assistant",
158
+ content: message.content,
159
+ usage: message.usage,
160
+ ...(message.stopReason !== undefined ? { stopReason: message.stopReason } : {}),
161
+ },
162
+ }),
163
+ ];
164
+ };
165
+ /** One canonical `tool_execution_start`, emitted as soon as a `tool_use` block is folded —
166
+ * while claude is still running the tool, before the `tool_result` arrives. The open-call
167
+ * trackers (runPi's stall warning, the dashboard flag) only see a call while it is open, so
168
+ * this is what lets them judge a claude call by its duration instead of its silence. */
169
+ const startToolCall = (block) => JSON.stringify({
170
+ type: "tool_execution_start",
171
+ toolCallId: block.id,
172
+ toolName: block.name,
173
+ args: block.arguments,
174
+ });
175
+ const record = (kind) => {
176
+ if (seen.has(kind))
177
+ return;
178
+ seen.add(kind);
179
+ warnings.add(kind);
180
+ };
181
+ const translate = (line) => {
182
+ const parsed = parseJsonObject(line);
183
+ if (!parsed)
184
+ return []; // Non-JSON noise on stdout; ignore, as pi's parser does.
185
+ const event = parsed;
186
+ const type = str(event.type);
187
+ switch (type) {
188
+ case "system": {
189
+ const subtype = str(event.subtype);
190
+ if (subtype === "init") {
191
+ const out = [];
192
+ const cwd = str(event.cwd);
193
+ const id = str(event.session_id);
194
+ out.push(JSON.stringify({ type: "session", ...(cwd !== undefined ? { cwd } : {}), ...(id !== undefined ? { id } : {}) }));
195
+ out.push(JSON.stringify({ type: "agent_start" }));
196
+ const billing = billingOf(str(event.apiKeySource), opts.thirdPartyProvider === true);
197
+ if (billing)
198
+ out.push(JSON.stringify({ type: "billing", billing }));
199
+ return out;
200
+ }
201
+ if (subtype === "api_retry") {
202
+ const attempt = nonNegativeNumber(event.attempt, undefined);
203
+ const maxAttempts = nonNegativeNumber(event.max_retries, undefined);
204
+ const status = nonNegativeNumber(event.error_status, undefined);
205
+ const error = str(event.error) ?? "";
206
+ const errorMessage = [status !== undefined ? `status ${status}` : "", error].filter(Boolean).join(": ");
207
+ return [
208
+ JSON.stringify({
209
+ type: "auto_retry_start",
210
+ ...(attempt !== undefined ? { attempt } : {}),
211
+ ...(maxAttempts !== undefined ? { maxAttempts } : {}),
212
+ errorMessage: errorMessage || "api retry",
213
+ }),
214
+ ];
215
+ }
216
+ if (subtype === "compact_boundary")
217
+ return [JSON.stringify({ type: "compaction_start" })];
218
+ // A thinking-token estimate during extended thinking: known, carries nothing canonical.
219
+ if (subtype === "thinking_tokens")
220
+ return [];
221
+ record(`system:${subtype ?? "unknown"}`);
222
+ return [];
223
+ }
224
+ case "assistant": {
225
+ sawActivity = true;
226
+ const message = objectOrEmpty(event.message);
227
+ const id = str(message.id) ?? `anon-${anonymous++}`;
228
+ // A new id flushes the open message; a continuing one flushes nothing. Either way the
229
+ // fresh or open pending then folds this event's content, usage, and stop reason the
230
+ // same way — that shared fold is why both branches live on one path.
231
+ let out = [];
232
+ if (pending?.id !== id) {
233
+ out = flush();
234
+ pending = { id, content: [], usage: canonicalUsage(isJsonObject(message.usage) ? message.usage : undefined) };
235
+ }
236
+ const blocks = Array.isArray(message.content) ? message.content : [];
237
+ for (const raw of blocks) {
238
+ if (!isJsonObject(raw))
239
+ continue;
240
+ const block = canonicalBlock(raw);
241
+ if (!block)
242
+ continue;
243
+ pending.content.push(block);
244
+ if (block.type === "toolCall")
245
+ out.push(startToolCall(block));
246
+ }
247
+ // Usage repeats on every event of the same message; the last read wins and is counted
248
+ // once because the fold emits one message_end per id.
249
+ pending.usage = canonicalUsage(isJsonObject(message.usage) ? message.usage : undefined);
250
+ const stopReason = str(message.stop_reason);
251
+ if (stopReason !== undefined)
252
+ pending.stopReason = stopReason;
253
+ return out;
254
+ }
255
+ case "user": {
256
+ const out = flush();
257
+ const message = objectOrEmpty(event.message);
258
+ const blocks = Array.isArray(message.content) ? message.content : [];
259
+ for (const raw of blocks) {
260
+ if (!isJsonObject(raw) || raw.type !== "tool_result")
261
+ continue;
262
+ out.push(JSON.stringify({
263
+ type: "tool_execution_end",
264
+ toolCallId: str(raw.tool_use_id) ?? "",
265
+ isError: raw.is_error === true,
266
+ }));
267
+ }
268
+ return out;
269
+ }
270
+ case "rate_limit_event": {
271
+ const info = objectOrEmpty(event.rate_limit_info);
272
+ const rateLimitType = str(info.rateLimitType);
273
+ const status = str(info.status);
274
+ // claude 2.1.295 reports every window's utilization and reset under `unifiedWindows`,
275
+ // keyed by window type, and only the binding window's status at the top level.
276
+ const windows = [];
277
+ for (const [type, raw] of Object.entries(objectOrEmpty(info.unifiedWindows))) {
278
+ if (type === rateLimitType || !isJsonObject(raw))
279
+ continue;
280
+ const reading = { type: "rate_limit", rateLimitType: type };
281
+ const windowResets = nonNegativeNumber(raw.resetsAt, undefined);
282
+ if (windowResets !== undefined)
283
+ reading.resetsAt = windowResets;
284
+ const windowUtil = nonNegativeNumber(raw.utilization, undefined);
285
+ if (windowUtil !== undefined)
286
+ reading.utilization = windowUtil;
287
+ windows.push(JSON.stringify(reading));
288
+ }
289
+ const own = rateLimitType === undefined ? undefined : objectOrEmpty(objectOrEmpty(info.unifiedWindows)[rateLimitType]);
290
+ const resetsAt = nonNegativeNumber(info.resetsAt, undefined) ?? nonNegativeNumber(own?.resetsAt, undefined);
291
+ const out = { type: "rate_limit" };
292
+ if (rateLimitType !== undefined)
293
+ out.rateLimitType = rateLimitType;
294
+ if (status !== undefined)
295
+ out.status = status;
296
+ if (resetsAt !== undefined)
297
+ out.resetsAt = resetsAt;
298
+ const utilization = nonNegativeNumber(info.utilization, undefined) ?? nonNegativeNumber(own?.utilization, undefined);
299
+ if (utilization !== undefined)
300
+ out.utilization = utilization;
301
+ const overageStatus = str(info.overageStatus);
302
+ if (overageStatus !== undefined)
303
+ out.overageStatus = overageStatus;
304
+ if (info.isUsingOverage === true)
305
+ out.isUsingOverage = true;
306
+ if (status === "rejected" && resetsAt !== undefined) {
307
+ // resetsAt is a unix-seconds stamp; retryAfterSeconds is the wait the loop honors.
308
+ out.retryAfterSeconds = Math.max(0, Math.round(resetsAt - Date.now() / 1000));
309
+ }
310
+ return [JSON.stringify(out), ...windows];
311
+ }
312
+ case "result": {
313
+ sawActivity = true;
314
+ const out = flush();
315
+ // The assistant events' usage is the message-start snapshot, so the result's total
316
+ // is the run's real output-token count (thinking included).
317
+ const outputTokens = nonNegativeNumber(objectOrEmpty(event.usage).output_tokens, undefined);
318
+ if (outputTokens !== undefined)
319
+ out.push(JSON.stringify({ type: "run_usage", outputTokens }));
320
+ const total = nonNegativeNumber(event.total_cost_usd, undefined);
321
+ if (total !== undefined) {
322
+ sawRunCost = true;
323
+ out.push(JSON.stringify({ type: "run_cost", total }));
324
+ }
325
+ else {
326
+ warnings.add("cost_unknown");
327
+ }
328
+ if (event.is_error === true) {
329
+ const { kind, backendKind } = classifyResult(event);
330
+ const message = {
331
+ role: "assistant",
332
+ content: [],
333
+ stopReason: "error",
334
+ };
335
+ const errorMessage = str(event.result) ?? str(event.error);
336
+ if (errorMessage !== undefined)
337
+ message.errorMessage = errorMessage;
338
+ // tumwaterKind/backendKind ride the canonical EVENT (not its message): the parser
339
+ // reads event.tumwaterKind and trusts it before its text regexes.
340
+ out.push(JSON.stringify({
341
+ type: "message_end",
342
+ message,
343
+ ...(kind !== undefined ? { tumwaterKind: kind } : {}),
344
+ ...(backendKind !== undefined ? { backendKind } : {}),
345
+ }));
346
+ }
347
+ return out;
348
+ }
349
+ case "tool_progress":
350
+ // A 30 s heartbeat of a running tool call: known, and the open call already tracks liveness.
351
+ return [];
352
+ default:
353
+ record(type ?? "unknown");
354
+ return [];
355
+ }
356
+ };
357
+ return {
358
+ translate,
359
+ finalize() {
360
+ // The stream ended (a completed run's result already flushed, a killed one did not):
361
+ // emit whatever assistant message is still pending so the last turn is not lost.
362
+ return flush();
363
+ },
364
+ warnings() {
365
+ // A claude run's authoritative cost is the result's total_cost_usd; a stream that did
366
+ // real work yet never produced that total has no trustworthy cost. Recorded lazily so a
367
+ // killed run's translator still reports it.
368
+ if (!sawRunCost && sawActivity)
369
+ warnings.add("cost_unknown");
370
+ return [...warnings];
371
+ },
372
+ };
373
+ }
374
+ /** The run's billing, from `system/init.apiKeySource`: claude.ai OAuth reports `"none"`, an
375
+ * environment key reports `ANTHROPIC_API_KEY`, a helper reports `apiKeyHelper`. A
376
+ * third-party provider overrides both — it is API billing even when the init omitted a
377
+ * source. Undefined when the init named no source and no third-party flag applied. */
378
+ function billingOf(apiKeySource, thirdPartyProvider) {
379
+ if (thirdPartyProvider)
380
+ return "api";
381
+ if (apiKeySource === undefined || apiKeySource === "")
382
+ return undefined;
383
+ return apiKeySource === "none" ? "subscription" : "api";
384
+ }