tumwater 0.4.1 → 0.4.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 (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
package/README.md CHANGED
@@ -42,7 +42,7 @@ and is also outside `npm test`.
42
42
  ## Status
43
43
 
44
44
  <!-- tumwater:status:start -->
45
- **v0.4.1**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
45
+ **v0.4.2**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
46
46
  which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
47
47
  issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
48
48
 
@@ -75,11 +75,9 @@ and `tumwater pause`, `resume`, `wake`, and `stop` control the roles. `tumwater
75
75
  so pair it with `--token <secret>`.
76
76
 
77
77
  `tumwater prompt --attach <image>` gives a role an image to read at its next run. When a role's
78
- model cannot read images, the command refuses the attachment up front (naming the fix) unless
79
- the role reroutes a blind read to a vision model, in which case it says where the message will
80
- run; `tumwater doctor` probes each claimed image-input model once (cached 7 days, `--reprobe` to
81
- force) and the dashboards and `tumwater role <id>` say which roles read images and where a blind
82
- run reroutes.
78
+ model cannot read images, the command refuses the attachment up front (naming the fix); the
79
+ dashboards and `tumwater role <id>` say which roles read images. Nothing reroutes a blind read
80
+ to another tier — a run that reads an image its model cannot see fails with `lastError`.
83
81
 
84
82
  Every command, flag, and TUI key is in [docs/cli.md](docs/cli.md); `tumwater help` prints the
85
83
  same list, and `tumwater completion bash|zsh|fish` prints shell completion. Settings live in
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "defe3e5dd243a9eb3c844751752328d88f5ad674",
3
- "builtAt": 1792989016668,
2
+ "sha": "115515a5f5134c37c2bf8fa9dfb0edf620dd94b6",
3
+ "builtAt": 1793081614993,
4
4
  "root": "/home/runner/work/tumwater/tumwater"
5
5
  }
@@ -95,3 +95,17 @@ export function backendDefaultLabel(backend) {
95
95
  * for one cannot go missing from the other (opencode classifies the same failure from message
96
96
  * text, not a status code, so it is not in the set). */
97
97
  export const SERVER_TRANSIENT_STATUS = new Set([500, 502, 503, 504, 529]);
98
+ /** Classify a failure message's prose into the transient-backend failure it names, when the
99
+ * wire carries no structured signal: a dropped connection or a timeout, read from the message
100
+ * text by claude's and opencode's stream translators, which both fall back to prose for the
101
+ * statuses their native events leave to the message (claude's classifyResult and opencode's
102
+ * classifyError end with this same connection/then-timeout tail). The single home of the
103
+ * connection/timeout vocabulary, so the two backends' tail regexes cannot drift. undefined
104
+ * when the text names neither. */
105
+ export function transientProseKind(text) {
106
+ if (/connection|econn|refused|reset|terminated/i.test(text))
107
+ return { kind: "transientBackend", backendKind: "connection" };
108
+ if (/timed out|timeout/i.test(text))
109
+ return { kind: "transientBackend", backendKind: "timeout" };
110
+ return undefined;
111
+ }
@@ -15,9 +15,9 @@
15
15
  * `agent_unknown_event` warning after the run. `cost_unknown` is recorded the same way when a
16
16
  * run produced no authoritative `run_cost`. */
17
17
  import { joinParts } from "../../text/text.js";
18
- import { secondsUntil } from "../../text/datetime.js";
19
- import { canonicalUsageBlock, isJsonObject, objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../../files/json-object.js";
20
- import { SERVER_TRANSIENT_STATUS } from "../backend-traits.js";
18
+ import { secondsUntilUnixStamp } from "../../text/datetime.js";
19
+ import { canonicalUsageBlock, isJsonObject, nonNegativeNumber, objectOrEmpty, optionalString, parseJsonObject } from "../../files/json-object.js";
20
+ import { SERVER_TRANSIENT_STATUS, transientProseKind } from "../backend-traits.js";
21
21
  /** claude's tool names → the canonical pi names the readers already know. Glob/Grep are kept
22
22
  * lower-cased; MultiEdit collapses onto edit (pi has one edit tool). Anything else is
23
23
  * lower-cased so a future tool still reads as a name rather than as `undefined`. */
@@ -32,9 +32,6 @@ const TOOL_NAMES = {
32
32
  };
33
33
  /** claude's HTTP status classes that mean a permanent configuration error. */
34
34
  const CONFIG_STATUS = new Set([400, 401, 403, 404]);
35
- function str(value) {
36
- return typeof value === "string" ? value : undefined;
37
- }
38
35
  /** claude's usage block → pi's canonical usage block, with `cost.total` 0 because the run's
39
36
  * authoritative cost rides the final `run_cost` event (a split-block message repeats its usage
40
37
  * on every event, so summing per event would double-count — the fold takes it once). The
@@ -49,15 +46,15 @@ function canonicalUsage(usage) {
49
46
  function canonicalBlock(block) {
50
47
  switch (block.type) {
51
48
  case "text":
52
- return { type: "text", text: str(block.text) ?? "" };
49
+ return { type: "text", text: optionalString(block.text) ?? "" };
53
50
  case "thinking":
54
- return { type: "thinking", thinking: str(block.thinking) ?? str(block.text) ?? "" };
51
+ return { type: "thinking", thinking: optionalString(block.thinking) ?? optionalString(block.text) ?? "" };
55
52
  case "tool_use": {
56
53
  const input = objectOrEmpty(block.input);
57
54
  const args = { ...input };
58
55
  if (typeof input.file_path === "string")
59
56
  args.path = input.file_path;
60
- const name = str(block.name) ?? "";
57
+ const name = optionalString(block.name) ?? "";
61
58
  // Own-property test, not a bare index: a tool name like `constructor`, `toString`, or
62
59
  // `__proto__` would otherwise inherit a function (or Object.prototype) from the table's
63
60
  // prototype chain and bypass the lower-cased fallback — JSON.stringify then drops the
@@ -65,7 +62,7 @@ function canonicalBlock(block) {
65
62
  // applies to EFFORT_BY_THINKING.
66
63
  return {
67
64
  type: "toolCall",
68
- id: str(block.id) ?? "",
65
+ id: optionalString(block.id) ?? "",
69
66
  name: Object.hasOwn(TOOL_NAMES, name) ? (TOOL_NAMES[name]) : name.toLowerCase(),
70
67
  arguments: args,
71
68
  };
@@ -81,12 +78,12 @@ function canonicalBlock(block) {
81
78
  * provider classification applies. `status` is `api_error_status`, which is null on the
82
79
  * not-logged-in result, so the text checks carry that case. */
83
80
  function classifyResult(result) {
84
- const terminal = str(result.terminal_reason);
81
+ const terminal = optionalString(result.terminal_reason);
85
82
  if (terminal === "aborted_tools")
86
83
  return {};
87
84
  if (terminal === "error_max_budget_usd")
88
85
  return { kind: "budgetCapped" };
89
- const text = joinParts([str(result.result), str(result.error)], " ");
86
+ const text = joinParts([optionalString(result.result), optionalString(result.error)], " ");
90
87
  if (/error_max_budget_usd/i.test(text))
91
88
  return { kind: "budgetCapped" };
92
89
  const status = nonNegativeNumber(result.api_error_status, undefined);
@@ -109,10 +106,9 @@ function classifyResult(result) {
109
106
  return { kind: "transientRateLimit" };
110
107
  if (/overloaded|internal server error|bad gateway|service unavailable|gateway timeout|\b5\d\d\b/i.test(text))
111
108
  return { kind: "transientBackend", backendKind: "server" };
112
- if (/connection|econn|refused|reset|terminated/i.test(text))
113
- return { kind: "transientBackend", backendKind: "connection" };
114
- if (/timed out|timeout/i.test(text))
115
- return { kind: "transientBackend", backendKind: "timeout" };
109
+ const prose = transientProseKind(text);
110
+ if (prose)
111
+ return prose;
116
112
  if (/\b(400|401|403|404)\b/i.test(text))
117
113
  return { kind: "configError" };
118
114
  return {};
@@ -169,14 +165,14 @@ export function claudeTranslator(opts = {}) {
169
165
  warnings.add(kind);
170
166
  };
171
167
  const handleSystem = (event) => {
172
- const subtype = str(event.subtype);
168
+ const subtype = optionalString(event.subtype);
173
169
  if (subtype === "init") {
174
170
  const out = [];
175
- const cwd = str(event.cwd);
176
- const id = str(event.session_id);
171
+ const cwd = optionalString(event.cwd);
172
+ const id = optionalString(event.session_id);
177
173
  out.push(JSON.stringify({ type: "session", ...(cwd !== undefined ? { cwd } : {}), ...(id !== undefined ? { id } : {}) }));
178
174
  out.push(JSON.stringify({ type: "agent_start" }));
179
- const billing = billingOf(str(event.apiKeySource), opts.thirdPartyProvider === true);
175
+ const billing = billingOf(optionalString(event.apiKeySource), opts.thirdPartyProvider === true);
180
176
  if (billing)
181
177
  out.push(JSON.stringify({ type: "billing", billing }));
182
178
  return out;
@@ -185,7 +181,7 @@ export function claudeTranslator(opts = {}) {
185
181
  const attempt = nonNegativeNumber(event.attempt, undefined);
186
182
  const maxAttempts = nonNegativeNumber(event.max_retries, undefined);
187
183
  const status = nonNegativeNumber(event.error_status, undefined);
188
- const error = str(event.error) ?? "";
184
+ const error = optionalString(event.error) ?? "";
189
185
  const errorMessage = joinParts([status !== undefined ? `status ${status}` : "", error], ": ");
190
186
  return [
191
187
  JSON.stringify({
@@ -207,7 +203,7 @@ export function claudeTranslator(opts = {}) {
207
203
  const handleAssistant = (event) => {
208
204
  sawActivity = true;
209
205
  const message = objectOrEmpty(event.message);
210
- const id = str(message.id) ?? `anon-${anonymous++}`;
206
+ const id = optionalString(message.id) ?? `anon-${anonymous++}`;
211
207
  // A new id flushes the open message; a continuing one flushes nothing. Either way the
212
208
  // fresh or open pending then folds this event's content, usage, and stop reason the
213
209
  // same way — that shared fold is why both branches live on one path.
@@ -230,7 +226,7 @@ export function claudeTranslator(opts = {}) {
230
226
  // Usage repeats on every event of the same message; the last read wins and is counted
231
227
  // once because the fold emits one message_end per id.
232
228
  pending.usage = canonicalUsage(isJsonObject(message.usage) ? message.usage : undefined);
233
- const stopReason = str(message.stop_reason);
229
+ const stopReason = optionalString(message.stop_reason);
234
230
  if (stopReason !== undefined)
235
231
  pending.stopReason = stopReason;
236
232
  return out;
@@ -244,7 +240,7 @@ export function claudeTranslator(opts = {}) {
244
240
  continue;
245
241
  out.push(JSON.stringify({
246
242
  type: "tool_execution_end",
247
- toolCallId: str(raw.tool_use_id) ?? "",
243
+ toolCallId: optionalString(raw.tool_use_id) ?? "",
248
244
  isError: raw.is_error === true,
249
245
  }));
250
246
  }
@@ -252,8 +248,8 @@ export function claudeTranslator(opts = {}) {
252
248
  };
253
249
  const handleRateLimitEvent = (event) => {
254
250
  const info = objectOrEmpty(event.rate_limit_info);
255
- const rateLimitType = str(info.rateLimitType);
256
- const status = str(info.status);
251
+ const rateLimitType = optionalString(info.rateLimitType);
252
+ const status = optionalString(info.status);
257
253
  // claude 2.1.295 reports every window's utilization and reset under `unifiedWindows`,
258
254
  // keyed by window type, and only the binding window's status at the top level.
259
255
  const windows = [];
@@ -281,14 +277,14 @@ export function claudeTranslator(opts = {}) {
281
277
  const utilization = nonNegativeNumber(info.utilization, undefined) ?? nonNegativeNumber(own?.utilization, undefined);
282
278
  if (utilization !== undefined)
283
279
  out.utilization = utilization;
284
- const overageStatus = str(info.overageStatus);
280
+ const overageStatus = optionalString(info.overageStatus);
285
281
  if (overageStatus !== undefined)
286
282
  out.overageStatus = overageStatus;
287
283
  if (info.isUsingOverage === true)
288
284
  out.isUsingOverage = true;
289
285
  if (status === "rejected" && resetsAt !== undefined) {
290
- // resetsAt is a unix-seconds stamp; secondsUntil folds it (×1000) to whole seconds.
291
- out.retryAfterSeconds = secondsUntil(resetsAt * 1000, Date.now());
286
+ // resetsAt is a unix-seconds stamp; secondsUntilUnixStamp folds it to whole seconds.
287
+ out.retryAfterSeconds = secondsUntilUnixStamp(resetsAt, Date.now());
292
288
  }
293
289
  return [JSON.stringify(out), ...windows];
294
290
  };
@@ -315,7 +311,7 @@ export function claudeTranslator(opts = {}) {
315
311
  content: [],
316
312
  stopReason: "error",
317
313
  };
318
- const errorMessage = str(event.result) ?? str(event.error);
314
+ const errorMessage = optionalString(event.result) ?? optionalString(event.error);
319
315
  if (errorMessage !== undefined)
320
316
  message.errorMessage = errorMessage;
321
317
  // tumwaterKind/backendKind ride the canonical EVENT (not its message): the parser
@@ -334,7 +330,7 @@ export function claudeTranslator(opts = {}) {
334
330
  if (!parsed)
335
331
  return []; // Non-JSON noise on stdout; ignore, as pi's parser does.
336
332
  const event = parsed;
337
- const type = str(event.type);
333
+ const type = optionalString(event.type);
338
334
  switch (type) {
339
335
  case "system": return handleSystem(event);
340
336
  case "assistant": return handleAssistant(event);
@@ -13,11 +13,8 @@
13
13
  import fs from "node:fs";
14
14
  import path from "node:path";
15
15
  import { walkFiles } from "../../files/files.js";
16
- import { isJsonObject, nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
16
+ import { isJsonObject, nonNegativeNumber, objectOrEmpty, optionalString, parseJsonObject } from "../../files/json-object.js";
17
17
  import { windowTypeName } from "./codex-stream.js";
18
- function str(value) {
19
- return typeof value === "string" ? value : undefined;
20
- }
21
18
  function finite(value) {
22
19
  return nonNegativeNumber(value, undefined);
23
20
  }
@@ -32,15 +29,11 @@ function windowStatus(windowType, rateLimitReachedType, usedPercent) {
32
29
  return "allowed_warning";
33
30
  return "allowed";
34
31
  }
35
- /** Read the records of ONE turn out of a rollout's text. A rollout is one file per thread that
36
- * appends a new `task_started` block per run, so the adapter's `afterRun` read (which knows
37
- * only the thread id that names the file, never a turn id) reads the LAST block — everything
38
- * after the last `task_started`. `sinceTurnStart` (optional) selects a specific turn's block
39
- * instead, which pins per-turn isolation: the thread total hides a resume's own figures
40
- * (finding 4). `requestedModel` is the `-m` tumwater passed; a turn whose `turn_context.model`
41
- * differs reports the switch. */
42
- export function rolloutLines(text, opts) {
43
- const { sinceTurnStart, requestedModel } = opts;
32
+ /** Scan ONE turn's records out of a rollout's text, tracking `task_started` blocks and
33
+ * collecting the read turn's `turn_context.model`, `token_usage_record` usage, `token_count`
34
+ * rate limits and `plan_type`. `rolloutLines` decides emptiness from `sawTurn`/`lastUsage`;
35
+ * this scanner never decides it. */
36
+ function scanTurnRecords(text, sinceTurnStart) {
44
37
  let currentTurn;
45
38
  let sawTurn = false;
46
39
  let lastUsage;
@@ -57,9 +50,9 @@ export function rolloutLines(text, opts) {
57
50
  // `task_started`, `task_complete` and `token_count` come wrapped in an `event_msg` whose
58
51
  // `payload.type` discriminates; `turn_context` and `token_usage_record` name themselves in
59
52
  // the outer `type`. Read either.
60
- const ptype = str(parsed.type) === "event_msg" ? str(payload.type) : str(parsed.type);
53
+ const ptype = optionalString(parsed.type) === "event_msg" ? optionalString(payload.type) : optionalString(parsed.type);
61
54
  if (ptype === "task_started") {
62
- currentTurn = str(payload.turn_id);
55
+ currentTurn = optionalString(payload.turn_id);
63
56
  // Each new task_started begins a fresh turn (a resume appends a second run to the same
64
57
  // file), so the collectors restart at the block we are reading. With no turn id (the
65
58
  // adapter's read) every task_started restarts them and the last block wins; with one,
@@ -78,7 +71,7 @@ export function rolloutLines(text, opts) {
78
71
  switch (ptype) {
79
72
  case "turn_context":
80
73
  if (model === undefined)
81
- model = str(payload.model);
74
+ model = optionalString(payload.model);
82
75
  break;
83
76
  case "token_usage_record": {
84
77
  const turnUsage = objectOrEmpty(payload.turn_token_usage);
@@ -90,7 +83,7 @@ export function rolloutLines(text, opts) {
90
83
  case "token_count": {
91
84
  const rateLimits = objectOrEmpty(payload.rate_limits);
92
85
  lastRateLimits = rateLimits;
93
- const plan = str(rateLimits.plan_type);
86
+ const plan = optionalString(rateLimits.plan_type);
94
87
  if (plan !== undefined)
95
88
  planType = plan;
96
89
  break;
@@ -99,12 +92,15 @@ export function rolloutLines(text, opts) {
99
92
  break;
100
93
  }
101
94
  }
102
- // No matching task_started, or the turn's usage record is missing or malformed (a truncated
103
- // or torn file): there is no reading. (currentTurn will have moved on to a later run's
104
- // task_started in a resume-shared file; sawTurn — not currentTurn — is the real signal.)
105
- if (!sawTurn || !isJsonObject(lastUsage)) {
106
- return { lines: [] };
107
- }
95
+ return { sawTurn, lastUsage, lastRateLimits, model, planType };
96
+ }
97
+ /** Assemble the reading's canonical `run_usage`, `billing` and up-to-two `rate_limit` lines
98
+ * (in that order), plus the `switchedTo` model comparison, from scanned records. */
99
+ function buildRolloutLines(records, requestedModel) {
100
+ // `rolloutLines` guarantees `lastUsage` is a Json object before calling (its presence check
101
+ // stays there, so this builder never reads `sawTurn`); narrow the shared interface here.
102
+ const lastUsage = records.lastUsage;
103
+ const { lastRateLimits, model, planType } = records;
108
104
  const lines = [];
109
105
  const input = finite(lastUsage.input_tokens) ?? 0;
110
106
  const cacheRead = finite(lastUsage.cached_input_tokens) ?? 0;
@@ -124,7 +120,7 @@ export function rolloutLines(text, opts) {
124
120
  // rollout has a token_usage_record but no token_count, so it yields run_usage with no
125
121
  // rate_limit (finding 23).
126
122
  if (lastRateLimits !== undefined) {
127
- const reached = str(lastRateLimits.rate_limit_reached_type);
123
+ const reached = optionalString(lastRateLimits.rate_limit_reached_type);
128
124
  for (const key of ["primary", "secondary"]) {
129
125
  const windowRaw = lastRateLimits[key];
130
126
  if (!isJsonObject(windowRaw))
@@ -146,6 +142,24 @@ export function rolloutLines(text, opts) {
146
142
  const switchedTo = model !== undefined && requestedModel !== undefined && model !== requestedModel ? model : undefined;
147
143
  return { lines, ...(switchedTo !== undefined ? { switchedTo } : {}) };
148
144
  }
145
+ /** Read the records of ONE turn out of a rollout's text. A rollout is one file per thread that
146
+ * appends a new `task_started` block per run, so the adapter's `afterRun` read (which knows
147
+ * only the thread id that names the file, never a turn id) reads the LAST block — everything
148
+ * after the last `task_started`. `sinceTurnStart` (optional) selects a specific turn's block
149
+ * instead, which pins per-turn isolation: the thread total hides a resume's own figures
150
+ * (finding 4). `requestedModel` is the `-m` tumwater passed; a turn whose `turn_context.model`
151
+ * differs reports the switch. */
152
+ export function rolloutLines(text, opts) {
153
+ const { sinceTurnStart, requestedModel } = opts;
154
+ const records = scanTurnRecords(text, sinceTurnStart);
155
+ // No matching task_started, or the turn's usage record is missing or malformed (a truncated
156
+ // or torn file): there is no reading. (currentTurn will have moved on to a later run's
157
+ // task_started in a resume-shared file; sawTurn — not currentTurn — is the real signal.)
158
+ if (!records.sawTurn || !isJsonObject(records.lastUsage)) {
159
+ return { lines: [] };
160
+ }
161
+ return buildRolloutLines(records, requestedModel);
162
+ }
149
163
  /** Locate the rollout file of `$CODEX_HOME` that names the session `id`: the first
150
164
  * `sessions/…/rollout-*-<id>.jsonl` under `home`, or null. It takes no start date and does no
151
165
  * date math — the id match alone decides (finding 5: Codex's rollout name carries local time,
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
17
17
  import { boundText, outputLimits } from "../../pi-extension/bounded-output.js";
18
- import { secondsUntil } from "../../text/datetime.js";
18
+ import { secondsUntilUnixStamp } from "../../text/datetime.js";
19
19
  import { SERVER_TRANSIENT_STATUS } from "../backend-traits.js";
20
20
  /** How codex wraps every shell command — the prefix to strip (with its quoting) so the
21
21
  * canonical `bash` call carries the model's own command, which suite-rerun detection matches. */
@@ -43,31 +43,31 @@ export function windowTypeName(windowMinutes) {
43
43
  return "seven_day";
44
44
  return `${windowMinutes}_minute`;
45
45
  }
46
- /** One codex `error` item's distinct message → a run warning; repeated identical messages are
47
- * warned once (finding 11). */
48
- /** How a `turn.failed` error message classifies into the canonical `tumwaterKind` (and, for a
49
- * transient backend, its kind), trusted by `PiStreamParser` before its text regexes. The inner
50
- * `message` is a JSON string carrying `status` and `error.type` / `error.code` (finding 11).
51
- * Returns the canonical lines to emit beside the closing `message_end`: a rejected rate-limit
52
- * reading when the error names a used-up window. */
53
- function classifyTurnFailed(parsed) {
54
- const kind = {};
46
+ /** Read the `turn.failed` inner JSON's four deciding fields. The inner `message` is a JSON
47
+ * string carrying `status` and `error.type` / `error.code` (finding 11). */
48
+ function readTurnFailedFields(parsed) {
55
49
  const error = objectOrEmpty(parsed.error);
56
- const code = typeof error.code === "string" ? error.code : undefined;
57
- const errType = typeof error.type === "string" ? error.type : undefined;
58
- const message = typeof error.message === "string" ? error.message : typeof parsed.message === "string" ? parsed.message : undefined;
59
- kind.errorMessage = message;
60
- const status = nonNegativeNumber(parsed.status, undefined);
50
+ return {
51
+ code: typeof error.code === "string" ? error.code : undefined,
52
+ errType: typeof error.type === "string" ? error.type : undefined,
53
+ message: typeof error.message === "string" ? error.message : typeof parsed.message === "string" ? parsed.message : undefined,
54
+ status: nonNegativeNumber(parsed.status, undefined),
55
+ };
56
+ }
57
+ /** Owns the context-window arm: a `context_window_exceeded` code, or a 400 whose message names
58
+ * the context window, classifies as `contextExceeded`. */
59
+ function isContextExceeded({ code, errType, message, status }) {
61
60
  // The context-window signal is tested BEFORE the generic 400 `invalid_request_error`
62
61
  // branch (as claude's classifyResult tests "prompt is too long" before CONFIG_STATUS): a
63
62
  // `context_window_exceeded` code, or a 400 whose message names the context window, is
64
63
  // `contextExceeded`, never `configError`.
65
64
  const namesContextWindow = (t) => t !== undefined &&
66
65
  /context window|context length|too long|exceeds? (the )?(model'?s )?context|prompt is too long/i.test(t);
67
- if (code === "context_window_exceeded" || (status === 400 && errType === "invalid_request_error" && namesContextWindow(message))) {
68
- kind.kind = "contextExceeded";
69
- return kind;
70
- }
66
+ return code === "context_window_exceeded" || (status === 400 && errType === "invalid_request_error" && namesContextWindow(message));
67
+ }
68
+ /** Owns the transient rate-limit arm: a `usage_limit_reached` / `rate_limit_exceeded` code or a
69
+ * 429 returns the rejected reading naming the used-up window, else undefined. */
70
+ function rateLimitReading(parsed, { code, status }) {
71
71
  if (code === "usage_limit_reached" || code === "rate_limit_exceeded" || status === 429) {
72
72
  // Which window the account hit, from the error's own `rate_limit_reached_type` (else the
73
73
  // five-hour window, the one Plus exhausts first). The rejected reading's wait comes from
@@ -75,42 +75,75 @@ function classifyTurnFailed(parsed) {
75
75
  const minutes = nonNegativeNumber(parsed.rate_limit_reached_type, undefined);
76
76
  const named = typeof parsed.rate_limit_reached_type === "string" ? parsed.rate_limit_reached_type : undefined;
77
77
  const resetsAt = nonNegativeNumber(parsed.resets_at, undefined);
78
- // secondsUntil folds the unix-seconds resets stamp (×1000 to epoch-ms), clamped at 0.
79
- const retryAfter = resetsAt === undefined ? undefined : secondsUntil(resetsAt * 1000, Date.now());
80
- kind.kind = "transientRateLimit";
81
- kind.rateLimit = {
78
+ return {
82
79
  type: "rate_limit",
83
80
  rateLimitType: typeof named === "string" && /_minute$|_hour$|_day$/.test(named)
84
81
  ? named
85
82
  : windowTypeName(minutes ?? 300),
86
83
  status: "rejected",
87
84
  utilization: 1,
88
- ...(resetsAt !== undefined ? { resetsAt } : {}),
89
- ...(retryAfter !== undefined ? { retryAfterSeconds: retryAfter } : {}),
85
+ // secondsUntilUnixStamp folds the unix-seconds resets stamp to epoch-ms, clamped at 0.
86
+ ...(resetsAt === undefined
87
+ ? {}
88
+ : { resetsAt, retryAfterSeconds: secondsUntilUnixStamp(resetsAt, Date.now()) }),
90
89
  };
91
- return kind;
92
90
  }
93
- if (code === "usage_not_included") {
94
- kind.kind = "configError";
91
+ return undefined;
92
+ }
93
+ /** Owns the used-up-account arm: `usage_not_included` is a permanent config error, never a
94
+ * transient retry. */
95
+ function isUsageNotIncluded({ code }) {
96
+ return code === "usage_not_included";
97
+ }
98
+ /** Owns the server-status arm: any `SERVER_TRANSIENT_STATUS` is a transient backend. */
99
+ function isTransientServer({ status }) {
100
+ return status !== undefined && SERVER_TRANSIENT_STATUS.has(status);
101
+ }
102
+ /** Owns the permanent-login arms: a reused/invalidated refresh token, a 401, and a 400
103
+ * `invalid_request_error` are permanent config errors. */
104
+ function isPermanentLogin({ message, status, errType }) {
105
+ // A refresh-token failure and an absent/invalid login are permanent config errors, which
106
+ // the doctor names with the login command (design §5).
107
+ if (/refresh_token_reused|invalidated refresh token/i.test(message ?? ""))
108
+ return true;
109
+ if (status === 401)
110
+ return true;
111
+ if (status === 400 && errType === "invalid_request_error") {
112
+ // A 400 `invalid_request_error` names the model as unsupported on a ChatGPT account.
113
+ return true;
114
+ }
115
+ return false;
116
+ }
117
+ /** How a `turn.failed` error message classifies into the canonical `tumwaterKind` (and, for a
118
+ * transient backend, its kind), trusted by `PiStreamParser` before its text regexes. The inner
119
+ * `message` is a JSON string carrying `status` and `error.type` / `error.code` (finding 11).
120
+ * Returns the canonical lines to emit beside the closing `message_end`: a rejected rate-limit
121
+ * reading when the error names a used-up window. The body is an ordered composition; each arm
122
+ * lives in its named helper: `readTurnFailedFields` (the field reads), `isContextExceeded`,
123
+ * `rateLimitReading`, `isUsageNotIncluded`, `isTransientServer` and `isPermanentLogin`. */
124
+ function classifyTurnFailed(parsed) {
125
+ const fields = readTurnFailedFields(parsed);
126
+ const kind = { errorMessage: fields.message };
127
+ if (isContextExceeded(fields)) {
128
+ kind.kind = "contextExceeded";
95
129
  return kind;
96
130
  }
97
- if (status !== undefined && SERVER_TRANSIENT_STATUS.has(status)) {
98
- kind.kind = "transientBackend";
99
- kind.backendKind = "server";
131
+ const rateLimit = rateLimitReading(parsed, fields);
132
+ if (rateLimit !== undefined) {
133
+ kind.kind = "transientRateLimit";
134
+ kind.rateLimit = rateLimit;
100
135
  return kind;
101
136
  }
102
- // A refresh-token failure and an absent/invalid login are permanent config errors, which
103
- // the doctor names with the login command (design §5).
104
- if (/refresh_token_reused|invalidated refresh token/i.test(message ?? "")) {
137
+ if (isUsageNotIncluded(fields)) {
105
138
  kind.kind = "configError";
106
139
  return kind;
107
140
  }
108
- if (status === 401) {
109
- kind.kind = "configError";
141
+ if (isTransientServer(fields)) {
142
+ kind.kind = "transientBackend";
143
+ kind.backendKind = "server";
110
144
  return kind;
111
145
  }
112
- if (status === 400 && errType === "invalid_request_error") {
113
- // A 400 `invalid_request_error` names the model as unsupported on a ChatGPT account.
146
+ if (isPermanentLogin(fields)) {
114
147
  kind.kind = "configError";
115
148
  return kind;
116
149
  }