switchroom 0.18.6 → 0.18.8

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 (116) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +1 -0
  3. package/dist/cli/autoaccept-poll.js +140 -33
  4. package/dist/cli/notion-write-pretool.mjs +1 -0
  5. package/dist/cli/switchroom.js +1172 -812
  6. package/dist/host-control/main.js +2 -1
  7. package/dist/vault/approvals/kernel-server.js +1 -0
  8. package/dist/vault/broker/server.js +1 -0
  9. package/package.json +3 -3
  10. package/profiles/_base/cron-session.sh.hbs +55 -16
  11. package/profiles/_base/start.sh.hbs +146 -50
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/skills/switchroom-runtime/SKILL.md +2 -0
  14. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  15. package/telegram-plugin/dist/gateway/gateway.js +2965 -862
  16. package/telegram-plugin/dist/server.js +24 -0
  17. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  18. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  19. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  20. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  21. package/telegram-plugin/gateway/boot-card.ts +27 -0
  22. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  23. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  24. package/telegram-plugin/gateway/gateway.ts +1618 -198
  25. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  26. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  27. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  28. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  29. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  30. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  31. package/telegram-plugin/gateway/model-command.ts +227 -54
  32. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  33. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  34. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  35. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  36. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  37. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  38. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  39. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  40. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  41. package/telegram-plugin/hooks/hooks.json +10 -10
  42. package/telegram-plugin/hooks/run-hook.sh +84 -0
  43. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  44. package/telegram-plugin/model-label.ts +69 -0
  45. package/telegram-plugin/model-unavailable.ts +26 -0
  46. package/telegram-plugin/operator-events.ts +24 -0
  47. package/telegram-plugin/permission-diff.ts +128 -0
  48. package/telegram-plugin/pty-partial-handler.ts +39 -0
  49. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  50. package/telegram-plugin/registry/subagents.test.ts +90 -0
  51. package/telegram-plugin/render/rich-render.ts +79 -1
  52. package/telegram-plugin/retry-api-call.ts +62 -0
  53. package/telegram-plugin/session-tail.ts +28 -0
  54. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  55. package/telegram-plugin/silence-poke.ts +14 -0
  56. package/telegram-plugin/silent-end.ts +49 -4
  57. package/telegram-plugin/stream-controller.ts +156 -38
  58. package/telegram-plugin/subagent-watcher.ts +222 -37
  59. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  60. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  61. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  62. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  63. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  64. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  65. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  66. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  67. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  68. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  69. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
  70. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  71. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  72. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  73. package/telegram-plugin/tests/model-command.test.ts +203 -43
  74. package/telegram-plugin/tests/model-label.test.ts +64 -0
  75. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  76. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  77. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  78. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  79. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  80. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  81. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  82. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  83. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  84. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  85. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  86. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  87. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  88. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  89. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  90. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  91. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  92. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  93. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  94. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  95. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  96. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  97. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  98. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  99. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  100. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  101. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  102. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  103. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  104. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  105. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  106. package/telegram-plugin/tool-activity-summary.ts +22 -2
  107. package/telegram-plugin/typing-wrap.ts +72 -25
  108. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  109. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  110. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  111. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  112. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  113. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  114. package/telegram-plugin/voice-ondemand.ts +25 -1
  115. package/telegram-plugin/voice-send.ts +154 -0
  116. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared friendly-model formatter for progress / activity surfaces.
3
+ *
4
+ * Every progress card renders the model actually in use, sourced LIVE from the
5
+ * session transcripts (`message.model` on each `type:"assistant"` line — the
6
+ * exact resolved model for that API call). This module owns the two pure
7
+ * decisions both the capture side (session-tail projection) and the render side
8
+ * (activity/worker cards) depend on:
9
+ *
10
+ * - `isModelSentinel(v)` — is this a value we must NOT treat as a real model?
11
+ * Compaction / synthetic transcript lines carry `model:"<synthetic>"`, and
12
+ * test fixtures may carry junk. Anything starting with `<`, empty, or not
13
+ * shaped like a model id is a sentinel: the capture side SKIPS it and keeps
14
+ * the previous value; it is never rendered.
15
+ * - `formatModelLabel(v)` — the short friendly form shown on a card's metrics
16
+ * line (e.g. `claude-opus-4-8` → `opus 4.8`). Returns null for sentinels so
17
+ * the caller simply omits the model field (never guesses from config).
18
+ *
19
+ * Friendly-form rules (verified against live fleet transcript values):
20
+ * - `claude-opus-4-8` → `opus 4.8`
21
+ * - `claude-sonnet-5` → `sonnet 5`
22
+ * - `claude-haiku-4-5-20251001` → `haiku 4.5` (trailing YYYYMMDD dropped)
23
+ * - `sr-glm-5`, `sr-gpt-5.5` → verbatim (LiteLLM routing ids stay literal)
24
+ * - anything else → verbatim (unknown but model-shaped)
25
+ */
26
+
27
+ /** Trailing 8-digit date stamp (YYYYMMDD) on a claude model id — dropped. */
28
+ const DATE_SUFFIX = /^\d{8}$/
29
+
30
+ /**
31
+ * True when `model` must NOT be treated as a real resolved model. Compaction /
32
+ * synthetic lines write `model:"<synthetic>"`; fixtures may carry junk. Callers
33
+ * that see a sentinel keep the previously-seen model (or omit the field).
34
+ */
35
+ export function isModelSentinel(model: unknown): boolean {
36
+ if (typeof model !== 'string') return true
37
+ const m = model.trim()
38
+ if (m.length === 0) return true
39
+ // Synthetic / placeholder sentinels: `<synthetic>`, `<compact>`, etc.
40
+ if (m.startsWith('<')) return true
41
+ // Model ids are simple tokens (letters, digits, dot, dash, underscore,
42
+ // slash). Anything with whitespace or leading punctuation is junk.
43
+ if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(m)) return true
44
+ return false
45
+ }
46
+
47
+ /**
48
+ * Short friendly label for a resolved model id, or null when `model` is absent
49
+ * or a sentinel (caller omits the field — never guesses). See module header for
50
+ * the rules.
51
+ */
52
+ export function formatModelLabel(model: string | null | undefined): string | null {
53
+ if (model == null) return null
54
+ const m = model.trim()
55
+ if (isModelSentinel(m)) return null
56
+ // LiteLLM / self-routed ids stay verbatim — the sr- prefix is meaningful.
57
+ if (m.startsWith('sr-')) return m
58
+ if (m.startsWith('claude-')) {
59
+ const rest = m.slice('claude-'.length)
60
+ const parts = rest.split('-').filter((p) => p.length > 0)
61
+ if (parts.length === 0) return m
62
+ const family = parts[0]
63
+ const version = parts.slice(1).filter((p) => !DATE_SUFFIX.test(p))
64
+ if (version.length === 0) return family
65
+ return `${family} ${version.join('.')}`
66
+ }
67
+ // Unknown but model-shaped (a future family, a bare alias) — show verbatim.
68
+ return m
69
+ }
@@ -69,6 +69,32 @@ export function detectModelUnavailable(
69
69
  const sample = stderr.length > 16_384 ? stderr.slice(0, 16_384) : stderr
70
70
  const lower = sample.toLowerCase()
71
71
 
72
+ // ── 0. Transient / server-side 429 (NOT account quota) — issue #2922 ────
73
+ // Anthropic emits a `rate_limit_error` whose message explicitly negates the
74
+ // account-quota reading: "Server is temporarily limiting requests (not your
75
+ // usage limit)". A negation-blind substring match on "usage limit" (step 1
76
+ // below) would misclassify this as `quota_exhausted`, firing a phantom fleet
77
+ // failover that self-cancels and leaves the turn dead. These are upstream
78
+ // throttles Claude Code retries internally with backoff — classify them as
79
+ // `overload` (the calm rate-limit path) BEFORE the quota substrings run, so
80
+ // the negation is honoured and no failover is announced.
81
+ const transientUpstreamSignals = [
82
+ 'not your usage limit',
83
+ 'not your account',
84
+ "not your account's",
85
+ 'temporarily limiting requests',
86
+ 'temporarily rate',
87
+ 'server is temporarily',
88
+ 'would exceed your account’s rate limit',
89
+ "would exceed your account's rate limit",
90
+ ]
91
+ if (transientUpstreamSignals.some(s => lower.includes(s))) {
92
+ const resetAt = parseResetTime(sample)
93
+ return resetAt !== undefined
94
+ ? { kind: 'overload', resetAt, raw: stderr }
95
+ : { kind: 'overload', raw: stderr }
96
+ }
97
+
72
98
  // ── 1. Quota / billing exhaustion ──────────────────────────────────────
73
99
  const quotaSignals = [
74
100
  'out of extra usage',
@@ -27,6 +27,7 @@ export type OperatorEventKind =
27
27
  | 'unknown-4xx'
28
28
  | 'unknown-5xx'
29
29
  | 'config-warning'
30
+ | 'always-allow-persist-failed'
30
31
 
31
32
  export interface OperatorEvent {
32
33
  kind: OperatorEventKind
@@ -396,6 +397,29 @@ export function renderOperatorEvent(ev: OperatorEvent): RenderResult {
396
397
  ],
397
398
  },
398
399
  }
400
+
401
+ // #2973 pt.3 — a durable "Always allow" persist exhausted its retry
402
+ // budget (always-allow-persist-queue.ts) or hit a non-retryable error
403
+ // (e.g. E_CONFIG_EDIT_DISABLED). MUST be a NEW message, not a card
404
+ // edit — the original permission card was already edited to the
405
+ // interim "saving durably in background…" state and card edits don't
406
+ // ping the operator, so a silent edit here would leave the failure
407
+ // unnoticed indefinitely.
408
+ case 'always-allow-persist-failed':
409
+ return {
410
+ text: [
411
+ `⚠️ Your "Always allow" for **${agent}** didn't stick.`,
412
+ detail ? `_${detail}_` : '',
413
+ `It will ask again.`,
414
+ ]
415
+ .filter(Boolean)
416
+ .join('\n'),
417
+ keyboard: {
418
+ inline_keyboard: [
419
+ [{ text: '❌ Dismiss', callback_data: `op:dismiss:${encodeURIComponent(ev.agent)}` }],
420
+ ],
421
+ },
422
+ }
399
423
  }
400
424
  }
401
425
 
@@ -156,6 +156,73 @@ function locateAllowLine(
156
156
  return null;
157
157
  }
158
158
 
159
+ /** Internal: a located multi-line flow list (`allow:` then `[` on a
160
+ * subsequent line, one entry per line, closing `]` on its own line — the
161
+ * shape clerk's config uses, `switchroom.yaml:507-514`). */
162
+ interface MultilineFlowList {
163
+ /** 0-based index of the line containing the opening `[`. */
164
+ openIdx: number;
165
+ /** 0-based index of the line containing the matching closing `]`. */
166
+ closeIdx: number;
167
+ /** Indentation to use for a newly-inserted entry line. */
168
+ itemIndent: number;
169
+ /** 0-based index of the last existing entry line, or -1 if the list is
170
+ * empty (`[` immediately followed by `]`, possibly across lines). */
171
+ lastEntryIdx: number;
172
+ }
173
+
174
+ /**
175
+ * Scan forward from `scanStart` (the line right after `allow:` when its
176
+ * inline remainder is empty) for a flow list whose opening `[` is on its
177
+ * OWN subsequent line (issue #2973 — clerk's exact shape). Tracks bracket
178
+ * depth char-by-char so a matching `]` is found even several lines down.
179
+ * Returns null if the first non-blank/comment line doesn't open a flow
180
+ * list, or the list is unterminated within the block.
181
+ */
182
+ function locateMultilineFlowList(
183
+ lines: string[],
184
+ scanStart: number,
185
+ blockEnd: number,
186
+ ): MultilineFlowList | null {
187
+ let i = scanStart;
188
+ while (i < blockEnd && isBlankOrComment(lines[i]!)) i++;
189
+ if (i >= blockEnd) return null;
190
+ const firstLine = lines[i]!;
191
+ if (!firstLine.trim().startsWith("[")) return null;
192
+ const openIdx = i;
193
+
194
+ let depth = 0;
195
+ let closeIdx = -1;
196
+ for (let j = openIdx; j < blockEnd && closeIdx === -1; j++) {
197
+ const line = lines[j]!;
198
+ for (const ch of line) {
199
+ if (ch === "[") depth++;
200
+ else if (ch === "]") {
201
+ depth--;
202
+ if (depth === 0) {
203
+ closeIdx = j;
204
+ break;
205
+ }
206
+ }
207
+ }
208
+ }
209
+ if (closeIdx === -1) return null; // unterminated — caller falls back.
210
+
211
+ // Entry lines: strictly between the open and close bracket lines,
212
+ // one per line (the observed shape). Lines that carry entries AND a
213
+ // bracket on the same line (e.g. inline `[ all,`) aren't split out
214
+ // here — they're handled upstream by the single-line flow-list case.
215
+ let lastEntryIdx = -1;
216
+ for (let j = openIdx + 1; j < closeIdx; j++) {
217
+ if (isBlankOrComment(lines[j]!)) continue;
218
+ lastEntryIdx = j;
219
+ }
220
+ const itemIndent =
221
+ lastEntryIdx !== -1 ? indentOf(lines[lastEntryIdx]!) : indentOf(firstLine) + 2;
222
+
223
+ return { openIdx, closeIdx, itemIndent, lastEntryIdx };
224
+ }
225
+
159
226
  /**
160
227
  * Build a unified-diff hunk from a contiguous slice of the original
161
228
  * file. `lines` is the whole file split on `\n`. The hunk replaces the
@@ -282,6 +349,45 @@ export function synthesizeAllowRuleDiff(
282
349
  return wrapDiff(hunk);
283
350
  }
284
351
 
352
+ // Case (a2): multi-line flow list — `allow:` has an EMPTY inline
353
+ // remainder, but the `[` opens on a subsequent line and entries run one
354
+ // per line down to a closing `]` on its own line (clerk's exact shape,
355
+ // issue #2973). Must be checked before the block-sequence fallback below
356
+ // — that fallback would otherwise misread the `[`/entries/`]` lines as
357
+ // block-sequence context and insert a `- <rule>` line ABOVE the `[`,
358
+ // corrupting the YAML (`E_YAML_UNSAFE_CONSTRUCT`).
359
+ if (inlineTrimmed.length === 0) {
360
+ const flow = locateMultilineFlowList(lines, allow.idx + 1, block.agentBlockEnd);
361
+ if (flow) {
362
+ if (flow.lastEntryIdx === -1) {
363
+ // Empty multi-line list (`[` immediately followed by `]`) — insert
364
+ // the first entry right after the opening bracket line.
365
+ const insertAt = flow.openIdx + 1;
366
+ const added = [`${" ".repeat(flow.itemIndent)}${rule}`];
367
+ const hunk = buildHunk(lines, insertAt, insertAt, [], added);
368
+ return wrapDiff(hunk);
369
+ }
370
+ // Ensure the current last entry carries a trailing comma (YAML flow
371
+ // sequences accept it, and it's required here since we're adding a
372
+ // sibling entry on its own line), then append the new entry —
373
+ // replacing the single last-entry line with both lines.
374
+ const lastLine = lines[flow.lastEntryIdx]!;
375
+ const lastTrimmedEnd = lastLine.replace(/\s+$/, "");
376
+ const fixedLast = lastTrimmedEnd.endsWith(",")
377
+ ? lastTrimmedEnd
378
+ : `${lastTrimmedEnd},`;
379
+ const itemLine = `${" ".repeat(flow.itemIndent)}${rule}`;
380
+ const hunk = buildHunk(
381
+ lines,
382
+ flow.lastEntryIdx,
383
+ flow.lastEntryIdx + 1,
384
+ [lastLine],
385
+ [fixedLast, itemLine],
386
+ );
387
+ return wrapDiff(hunk);
388
+ }
389
+ }
390
+
285
391
  // Case (b): block sequence. Find the last `- ` entry at indent
286
392
  // allow.indent + 2 and insert a new entry after it.
287
393
  const itemIndent = allow.indent + 2;
@@ -348,6 +454,14 @@ export function extractAddedAllowRule(unifiedDiff: string): string | null {
348
454
  })
349
455
  .filter((x): x is string => x !== null);
350
456
  if (items.length === 1) return items[0]!;
457
+ // Multi-line flow-list first-entry insert (list was empty): a single
458
+ // bare `+` line with no `- ` prefix — the plain entry text itself,
459
+ // possibly comma-suffixed.
460
+ if (items.length === 0 && plus.length === 1) {
461
+ const bare = plus[0]!.trim();
462
+ const stripped = bare.endsWith(",") ? bare.slice(0, -1).trim() : bare;
463
+ return stripped.length > 0 ? stripped : null;
464
+ }
351
465
  return null;
352
466
  }
353
467
 
@@ -368,6 +482,20 @@ export function extractAddedAllowRule(unifiedDiff: string): string | null {
368
482
  return added;
369
483
  }
370
484
 
485
+ // Multi-line flow-list append: one `-` line (the previous last entry)
486
+ // replaced by TWO `+` lines — that same entry now comma-terminated,
487
+ // plus the new entry on its own line.
488
+ if (minus.length === 1 && plus.length === 2) {
489
+ const orig = minus[0]!.trim();
490
+ const first = plus[0]!.trim();
491
+ const second = plus[1]!.trim();
492
+ const origStripped = orig.endsWith(",") ? orig.slice(0, -1).trim() : orig;
493
+ const firstStripped = first.endsWith(",") ? first.slice(0, -1).trim() : first;
494
+ if (firstStripped !== origStripped) return null;
495
+ const added = second.endsWith(",") ? second.slice(0, -1).trim() : second;
496
+ return added.length > 0 ? added : null;
497
+ }
498
+
371
499
  return null;
372
500
  }
373
501
 
@@ -32,6 +32,7 @@ export type PtyPartialAction =
32
32
  | 'dedup-skip' // same text as the previous partial; no-op
33
33
  | 'update-existing' // pushed into an already-live stream
34
34
  | 'update-new' // created a new stream and pushed into it
35
+ | 'error-suppressed' // raw API-error TUI line; dropped (issue #2922 Bug 3)
35
36
 
36
37
  export interface PtyHandlerState {
37
38
  /**
@@ -84,6 +85,32 @@ export interface PtyHandlerDeps {
84
85
  writeError?: (line: string) => void
85
86
  }
86
87
 
88
+ /**
89
+ * Detect a raw API-error line scraped from Claude Code's TUI — issue #2922
90
+ * Bug 3. When the model 429s / errors, the CLI renders an `API Error: … ·
91
+ * b'{"type":"error",…}'` line into the terminal; the PTY tail would otherwise
92
+ * scrape it as the assistant reply and relay the raw bytes verbatim to chat.
93
+ * These lines are suppressed here so the model-unavailable operator-event
94
+ * pipeline owns the user-facing rendering (a clean ⚠️ card), not the raw tail.
95
+ *
96
+ * Kept deliberately tight (anchored error markers, not any mention of "error")
97
+ * so genuine assistant text that happens to discuss errors is NOT swallowed.
98
+ */
99
+ export function looksLikeRawApiError(text: string): boolean {
100
+ if (typeof text !== 'string' || text.length === 0) return false
101
+ const lower = text.toLowerCase()
102
+ return (
103
+ lower.includes('api error:')
104
+ || lower.includes('"type":"error"')
105
+ || lower.includes("'type': 'error'")
106
+ || lower.includes('rate_limit_error')
107
+ || lower.includes('overloaded_error')
108
+ || lower.includes('"is_error":true')
109
+ // The CLI's Python-style raw-body render: · b'{...}'
110
+ || / b'\{/.test(text)
111
+ )
112
+ }
113
+
87
114
  function streamKey(chatId: string, threadId?: number): string {
88
115
  // Canonical chat-key derivation lives in gateway/chat-key.ts — keep this
89
116
  // expression in lockstep (treats 0/null/undefined the same). See #1564.
@@ -96,6 +123,13 @@ function streamKey(chatId: string, threadId?: number): string {
96
123
  *
97
124
  * Returns the action taken. All state mutation happens through the
98
125
  * supplied `state` object so callers can inspect before/after.
126
+ *
127
+ * NOTE on `looksLikeRawApiError` suppression (#2922 Bug 3): this is
128
+ * *preview-only* scope. PTY partials are the live-streaming terminal tail,
129
+ * never the authoritative reply (that flows through the operator-event /
130
+ * reply pipelines). So the worst case of an over-eager match here is a
131
+ * missing streaming flicker for one snapshot — NOT a dropped user answer.
132
+ * That asymmetry is why the matcher can stay aggressive on raw error shapes.
99
133
  */
100
134
  export function handlePtyPartialPure(
101
135
  text: string,
@@ -132,6 +166,11 @@ export function handlePtyPartialPure(
132
166
 
133
167
  if (suppressed) return 'suppressed'
134
168
 
169
+ // Drop raw API-error TUI lines so they never leak to chat as the reply —
170
+ // the model-unavailable card (operator-event pipeline) renders these
171
+ // instead. See issue #2922 Bug 3.
172
+ if (looksLikeRawApiError(text)) return 'error-suppressed'
173
+
135
174
  if (state.lastPtyPreviewByChat.get(sKey) === text) return 'dedup-skip'
136
175
 
137
176
  const isFirst = !state.lastPtyPreviewByChat.has(sKey)
@@ -116,6 +116,16 @@ export interface Subagent {
116
116
  * chain instead.
117
117
  */
118
118
  parent_agent_id: string | null
119
+ /**
120
+ * Live model the sub-agent is running, as a raw resolved model id (e.g.
121
+ * `claude-opus-4-8`, `sr-glm-5`). Seeded at dispatch from the Agent tool's
122
+ * `tool_input.model` (first-paint fallback, written by the pretool hook), then
123
+ * updated on change by the watcher from the worker's own transcript
124
+ * `message.model` (transcript wins). Persisted so boot-replay / handback cards
125
+ * can render the model even with no live watcher entry. NULL when never
126
+ * observed — the card omits the model rather than guessing from config.
127
+ */
128
+ model: string | null
119
129
  }
120
130
 
121
131
  export interface RecordSubagentStartArgs {
@@ -207,7 +217,8 @@ const SUBAGENTS_SCHEMA_SQL = `
207
217
  status TEXT NOT NULL,
208
218
  result_summary TEXT,
209
219
  jsonl_agent_id TEXT,
210
- parent_agent_id TEXT
220
+ parent_agent_id TEXT,
221
+ model TEXT
211
222
  );
212
223
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
213
224
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -240,6 +251,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
240
251
  if (!hasParentAgentId) {
241
252
  db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
242
253
  }
254
+ // Idempotent migration for DBs created before the live-model column existed
255
+ // (progress-card live model — see the Subagent.model doc).
256
+ const hasModel = cols.some((c) => c.name === 'model')
257
+ if (!hasModel) {
258
+ db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
259
+ }
243
260
  // Always (re-)apply the index. `IF NOT EXISTS` makes this a no-op when it
244
261
  // already exists. Splitting it from SUBAGENTS_SCHEMA_SQL is what fixes the
245
262
  // pre-existing-table failure mode — by the time we reach this line, the
@@ -316,6 +333,7 @@ interface RawSubagentRow {
316
333
  result_summary: string | null
317
334
  jsonl_agent_id: string | null
318
335
  parent_agent_id?: string | null
336
+ model?: string | null
319
337
  }
320
338
 
321
339
  function mapSubagentRow(row: RawSubagentRow): Subagent {
@@ -333,6 +351,7 @@ function mapSubagentRow(row: RawSubagentRow): Subagent {
333
351
  result_summary: row.result_summary,
334
352
  jsonl_agent_id: row.jsonl_agent_id,
335
353
  parent_agent_id: row.parent_agent_id ?? null,
354
+ model: row.model ?? null,
336
355
  }
337
356
  }
338
357
 
@@ -566,6 +585,29 @@ export function bumpSubagentActivity(db: SqliteDatabase, args: BumpSubagentActiv
566
585
  `).run(args.ts, args.id)
567
586
  }
568
587
 
588
+ export interface RecordSubagentModelArgs {
589
+ id: string
590
+ /** Raw resolved model id (e.g. `claude-opus-4-8`). Callers pass only
591
+ * non-sentinel, non-empty values — the watcher filters at the projection. */
592
+ model: string
593
+ }
594
+
595
+ /**
596
+ * Persist the live model on a subagent row (update-on-change, like
597
+ * last_activity_at). Written by the watcher whenever it observes a NEW model on
598
+ * the worker's transcript, so a later boot-replay / handback card renders the
599
+ * model even with no live in-memory entry. Unconditional UPDATE by `id`; no-ops
600
+ * gracefully if the row is not found. Idempotent — the caller only calls it on
601
+ * an actual change, but a repeat write is harmless.
602
+ */
603
+ export function recordSubagentModel(db: SqliteDatabase, args: RecordSubagentModelArgs): void {
604
+ db.prepare(`
605
+ UPDATE subagents
606
+ SET model = ?
607
+ WHERE id = ?
608
+ `).run(args.model, args.id)
609
+ }
610
+
569
611
  /**
570
612
  * Return all subagents, optionally filtered by status, ordered by
571
613
  * started_at DESC. Intended for the REST API endpoint
@@ -590,6 +632,43 @@ export function listSubagents(
590
632
  return rows.map(mapSubagentRow)
591
633
  }
592
634
 
635
+ /**
636
+ * List the sub-agents of a given parent turn that had NOT reached a terminal
637
+ * state (`completed` / `failed`) — i.e. `running` or `stalled`. Ordered by
638
+ * `started_at ASC` (dispatch order) so the resume inbound lists them the way
639
+ * they were spawned.
640
+ *
641
+ * This is the boot-resume accessor: when a turn is interrupted mid-flight, its
642
+ * in-flight workers were killed with it, so the resumed session needs to know
643
+ * which ones didn't finish to re-dispatch them. Deliberately includes
644
+ * `stalled` alongside `running` — a row the reaper flipped to `stalled` (1h
645
+ * TTL, JSONL linkage missing) still never completed, so it belongs in the
646
+ * "these died, re-dispatch if still needed" list. Only genuine terminals are
647
+ * excluded. This also makes the read robust to boot ordering: even if the
648
+ * watcher's reaper transitions a row to `stalled` before this runs, the row is
649
+ * still surfaced rather than dropped.
650
+ *
651
+ * Known gap: rows with NULL parent_turn_key are silently omitted — the
652
+ * INSERT-time stamp can be missing (no turn-active marker at dispatch, e.g.
653
+ * nested workers) and the watcher's async backfill may not have run before the
654
+ * killing restart. Those workers won't appear in the resume inbound; the
655
+ * wake-audit orphan-check (switchroom-runtime skill) is the backstop.
656
+ */
657
+ export function listNonTerminalSubagentsForTurn(
658
+ db: SqliteDatabase,
659
+ parentTurnKey: string,
660
+ ): Subagent[] {
661
+ const rows = db
662
+ .prepare(`
663
+ SELECT * FROM subagents
664
+ WHERE parent_turn_key = ?
665
+ AND status NOT IN ('completed', 'failed')
666
+ ORDER BY started_at ASC
667
+ `)
668
+ .all(parentTurnKey) as RawSubagentRow[]
669
+ return rows.map(mapSubagentRow)
670
+ }
671
+
593
672
  /**
594
673
  * Retrieve a single subagent row by id. Returns null if not found.
595
674
  * Useful in tests and for callers that need to inspect current state.
@@ -26,9 +26,11 @@ import {
26
26
  recordSubagentStall,
27
27
  recordSubagentResume,
28
28
  bumpSubagentActivity,
29
+ recordSubagentModel,
29
30
  getSubagent,
30
31
  reapStuckRunningRows,
31
32
  countRunningBackgroundSubagents,
33
+ listNonTerminalSubagentsForTurn,
32
34
  } from './subagents-schema.js'
33
35
 
34
36
  // ---------------------------------------------------------------------------
@@ -68,6 +70,54 @@ describe('migration on fresh DB', () => {
68
70
  expect(() => applySubagentsSchema(db)).not.toThrow()
69
71
  db.close()
70
72
  })
73
+
74
+ it('creates the model column on a fresh DB', () => {
75
+ const db = openFreshSubagentsDbInMemory()
76
+ const cols = db.prepare("SELECT name FROM pragma_table_info('subagents')").all() as { name: string }[]
77
+ expect(cols.map((c) => c.name)).toContain('model')
78
+ db.close()
79
+ })
80
+ })
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Live-model column — migration + record helper
84
+ // ---------------------------------------------------------------------------
85
+
86
+ describe('live-model column', () => {
87
+ it('migrates a pre-model subagents table by adding the model column (idempotent)', () => {
88
+ const db = openFreshSubagentsDbInMemory()
89
+ // Simulate a legacy table with no model column.
90
+ db.exec('ALTER TABLE subagents DROP COLUMN model')
91
+ let cols = db.prepare("SELECT name FROM pragma_table_info('subagents')").all() as { name: string }[]
92
+ expect(cols.map((c) => c.name)).not.toContain('model')
93
+ // Re-applying the schema must add it back, and be safe to repeat.
94
+ applySubagentsSchema(db)
95
+ applySubagentsSchema(db)
96
+ cols = db.prepare("SELECT name FROM pragma_table_info('subagents')").all() as { name: string }[]
97
+ expect(cols.map((c) => c.name)).toContain('model')
98
+ db.close()
99
+ })
100
+
101
+ it('recordSubagentStart leaves model null; recordSubagentModel persists on change', () => {
102
+ const db = openFreshSubagentsDbInMemory()
103
+ const now = Date.now()
104
+ recordSubagentStart(db, { id: 'toolu_m1', background: true, startedAt: now, jsonlAgentId: 'a1' })
105
+ expect(getSubagent(db, 'toolu_m1')?.model).toBeNull()
106
+
107
+ recordSubagentModel(db, { id: 'toolu_m1', model: 'claude-opus-4-8' })
108
+ expect(getSubagent(db, 'toolu_m1')?.model).toBe('claude-opus-4-8')
109
+
110
+ // Update-on-change: a later model overwrites.
111
+ recordSubagentModel(db, { id: 'toolu_m1', model: 'sr-glm-5' })
112
+ expect(getSubagent(db, 'toolu_m1')?.model).toBe('sr-glm-5')
113
+ db.close()
114
+ })
115
+
116
+ it('recordSubagentModel no-ops gracefully on a missing id', () => {
117
+ const db = openFreshSubagentsDbInMemory()
118
+ expect(() => recordSubagentModel(db, { id: 'nope', model: 'claude-opus-4-8' })).not.toThrow()
119
+ db.close()
120
+ })
71
121
  })
72
122
 
73
123
  // ---------------------------------------------------------------------------
@@ -616,3 +666,43 @@ describe('reapStuckRunningRows', () => {
616
666
  expect(result.ids.sort()).toEqual(['sa-0', 'sa-1', 'sa-2', 'sa-3', 'sa-4'])
617
667
  })
618
668
  })
669
+
670
+ // ---------------------------------------------------------------------------
671
+ // listNonTerminalSubagentsForTurn — the boot-resume accessor
672
+ // ---------------------------------------------------------------------------
673
+
674
+ describe('listNonTerminalSubagentsForTurn', () => {
675
+ it('returns running + stalled workers of a turn, excluding terminals, ordered by started_at', () => {
676
+ const db = openFreshSubagentsDbInMemory()
677
+ const turn = 'chat-1:7'
678
+ recordSubagentStart(db, { id: 'sa-run', parentTurnKey: turn, agentType: 'worker', description: 'refactor auth', background: true, startedAt: 1000 })
679
+ recordSubagentStart(db, { id: 'sa-stall', parentTurnKey: turn, agentType: 'researcher', description: 'survey competitors', background: true, startedAt: 2000 })
680
+ recordSubagentStall(db, { id: 'sa-stall', stalledAt: 3000 })
681
+ recordSubagentStart(db, { id: 'sa-done', parentTurnKey: turn, agentType: 'worker', description: 'done work', background: true, startedAt: 500 })
682
+ recordSubagentEnd(db, { id: 'sa-done', endedAt: 4000, status: 'completed' })
683
+ recordSubagentStart(db, { id: 'sa-fail', parentTurnKey: turn, background: true, startedAt: 600 })
684
+ recordSubagentEnd(db, { id: 'sa-fail', endedAt: 4100, status: 'failed' })
685
+
686
+ const rows = listNonTerminalSubagentsForTurn(db, turn)
687
+ expect(rows.map((r) => r.id)).toEqual(['sa-run', 'sa-stall']) // started_at ASC, terminals excluded
688
+ expect(rows[0].agent_type).toBe('worker')
689
+ expect(rows[0].description).toBe('refactor auth')
690
+ expect(rows[1].status).toBe('stalled')
691
+ db.close()
692
+ })
693
+
694
+ it('does not return workers of a different turn', () => {
695
+ const db = openFreshSubagentsDbInMemory()
696
+ recordSubagentStart(db, { id: 'sa-a', parentTurnKey: 'chat:1', background: true, startedAt: 1000 })
697
+ recordSubagentStart(db, { id: 'sa-b', parentTurnKey: 'chat:2', background: true, startedAt: 1000 })
698
+ const rows = listNonTerminalSubagentsForTurn(db, 'chat:1')
699
+ expect(rows.map((r) => r.id)).toEqual(['sa-a'])
700
+ db.close()
701
+ })
702
+
703
+ it('returns empty for a turn with no in-flight workers', () => {
704
+ const db = openFreshSubagentsDbInMemory()
705
+ expect(listNonTerminalSubagentsForTurn(db, 'nope:0')).toEqual([])
706
+ db.close()
707
+ })
708
+ })