switchroom 0.18.6 → 0.18.7

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 (71) 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 +269 -56
  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 +35 -16
  12. package/profiles/default/CLAUDE.md.hbs +1 -1
  13. package/telegram-plugin/dist/bridge/bridge.js +22 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1937 -580
  15. package/telegram-plugin/dist/server.js +24 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
  17. package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
  18. package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
  19. package/telegram-plugin/gateway/gateway.ts +1071 -130
  20. package/telegram-plugin/gateway/inbound-spool.ts +2 -1
  21. package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
  22. package/telegram-plugin/gateway/inject-handler.ts +17 -0
  23. package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
  24. package/telegram-plugin/gateway/ipc-server.ts +40 -0
  25. package/telegram-plugin/gateway/model-command.ts +212 -51
  26. package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
  27. package/telegram-plugin/gateway/pending-card-store.ts +173 -0
  28. package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
  29. package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
  30. package/telegram-plugin/gateway/session-model-source.ts +73 -0
  31. package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
  32. package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
  33. package/telegram-plugin/model-label.ts +69 -0
  34. package/telegram-plugin/operator-events.ts +24 -0
  35. package/telegram-plugin/permission-diff.ts +128 -0
  36. package/telegram-plugin/registry/subagents-schema.ts +80 -1
  37. package/telegram-plugin/registry/subagents.test.ts +90 -0
  38. package/telegram-plugin/session-tail.ts +28 -0
  39. package/telegram-plugin/silent-end.ts +49 -4
  40. package/telegram-plugin/subagent-watcher.ts +222 -37
  41. package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
  42. package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
  43. package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
  44. package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
  46. package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
  47. package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
  48. package/telegram-plugin/tests/model-command.test.ts +202 -42
  49. package/telegram-plugin/tests/model-label.test.ts +64 -0
  50. package/telegram-plugin/tests/operator-events.test.ts +1 -0
  51. package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
  52. package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
  53. package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
  54. package/telegram-plugin/tests/permission-diff.test.ts +111 -0
  55. package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
  56. package/telegram-plugin/tests/session-model-source.test.ts +67 -0
  57. package/telegram-plugin/tests/session-tail.test.ts +64 -0
  58. package/telegram-plugin/tests/silent-end.test.ts +46 -1
  59. package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
  60. package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
  61. package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
  62. package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
  63. package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
  64. package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
  65. package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
  66. package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
  67. package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
  68. package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
  69. package/telegram-plugin/tool-activity-summary.ts +22 -2
  70. package/telegram-plugin/typing-wrap.ts +72 -25
  71. package/telegram-plugin/worker-activity-feed.ts +9 -0
@@ -55,7 +55,8 @@ const SCHEMA_SQL = `
55
55
  status TEXT NOT NULL,
56
56
  result_summary TEXT,
57
57
  jsonl_agent_id TEXT,
58
- parent_agent_id TEXT
58
+ parent_agent_id TEXT,
59
+ model TEXT
59
60
  );
60
61
  CREATE INDEX IF NOT EXISTS subagents_turn ON subagents(parent_turn_key);
61
62
  CREATE INDEX IF NOT EXISTS subagents_status ON subagents(status);
@@ -157,14 +158,14 @@ function spawnSql(dbPath, sql, cb) {
157
158
  // DB write
158
159
  // ---------------------------------------------------------------------------
159
160
 
160
- function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, now }, done) {
161
+ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, description, background, model, now }, done) {
161
162
  const INSERT_SQL = `
162
163
  INSERT OR IGNORE INTO subagents
163
164
  (id, parent_session_id, parent_turn_key, agent_type, description,
164
- background, started_at, last_activity_at, status)
165
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running')
165
+ background, started_at, last_activity_at, status, model)
166
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'running', ?)
166
167
  `
167
- const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now]
168
+ const params = [id, parentSessionId, parentTurnKey, agentType, description, background, now, now, model ?? null]
168
169
 
169
170
  // Resolve a synchronous SQLite binding. Try in order:
170
171
  // 1. node:sqlite (Node 22+, production path) — exposes DatabaseSync
@@ -207,6 +208,13 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
207
208
  if (hasParentAgentCol == null) {
208
209
  db.exec('ALTER TABLE subagents ADD COLUMN parent_agent_id TEXT')
209
210
  }
211
+ // Migrate older DBs that pre-date the live-model column.
212
+ const hasModelCol = db
213
+ .prepare("SELECT name FROM pragma_table_info('subagents') WHERE name = 'model'")
214
+ .get()
215
+ if (hasModelCol == null) {
216
+ db.exec('ALTER TABLE subagents ADD COLUMN model TEXT')
217
+ }
210
218
  // Verify the marker-derived parent_turn_key (snapParams[2]) actually has
211
219
  // a row in the turns table before trusting it. The gateway writes the
212
220
  // turn-active marker even when recordTurnStart's INSERT failed (the two
@@ -235,7 +243,7 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
235
243
  return
236
244
  }
237
245
 
238
- // sqlite3 CLI fallback — two non-blocking spawns sequenced via callbacks.
246
+ // sqlite3 CLI fallback — non-blocking spawns sequenced via callbacks.
239
247
  // This legacy path (neither node:sqlite nor bun:sqlite available) can't
240
248
  // cheaply verify the marker's turn_key against the turns table, so drop
241
249
  // parent_turn_key and let the gateway's window backfill attribute it.
@@ -244,7 +252,15 @@ function writeRow(dbPath, { id, parentSessionId, parentTurnKey, agentType, descr
244
252
  params[2] = null
245
253
  spawnSql(dbPath, SCHEMA_SQL.replace(/\n\s+/g, ' '), (err) => {
246
254
  if (err) { done(err); return }
247
- spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
255
+ // Best-effort model-column migration for a legacy DB: the INSERT below
256
+ // references the model column, which a pre-model table lacks (CREATE TABLE
257
+ // IF NOT EXISTS is a no-op there, so the schema exec doesn't add it). The
258
+ // ALTER fails with "duplicate column name" when the column already exists —
259
+ // that error is EXPECTED and deliberately ignored; on any failure we still
260
+ // proceed to the INSERT, which surfaces a real problem via `done`.
261
+ spawnSql(dbPath, 'ALTER TABLE subagents ADD COLUMN model TEXT', () => {
262
+ spawnSql(dbPath, fillPlaceholders(INSERT_SQL.trim(), params), done)
263
+ })
248
264
  })
249
265
  }
250
266
 
@@ -363,6 +379,13 @@ function main() {
363
379
  agentType: input.subagent_type ?? null,
364
380
  description: input.description ?? null,
365
381
  background: input.run_in_background === true ? 1 : 0,
382
+ // First-paint model for the worker card: the Agent tool payload carries
383
+ // the model the sub-agent will run under (`tool_input.model`), available
384
+ // BEFORE the sub-agent writes its first assistant line. Persisted so the
385
+ // card can render the model from dispatch; the watcher later overwrites it
386
+ // from the worker's own transcript (transcript wins). Only a non-empty
387
+ // string is stored — never guess from config.
388
+ model: typeof input.model === 'string' && input.model.length > 0 ? input.model : null,
366
389
  now: Date.now(),
367
390
  },
368
391
  (err) => {
@@ -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
+ }
@@ -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
 
@@ -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
+ })
@@ -42,6 +42,7 @@ function isMultiAgentEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
42
42
  }
43
43
  import { classifyClaudeError, type OperatorEventKind } from './operator-events.js'
44
44
  import { createToolLabelSidecar, type ToolLabelSidecar } from './tool-label-sidecar.js'
45
+ import { isModelSentinel } from './model-label.js'
45
46
 
46
47
  /** Match Claude Code's cli.js VX() function. */
47
48
  export function sanitizeCwdToProjectName(cwd: string): string {
@@ -92,6 +93,13 @@ export type SessionEvent =
92
93
  | { kind: 'enqueue'; chatId: string | null; messageId: string | null; threadId: string | null; rawContent: string; isSync?: boolean }
93
94
  | { kind: 'dequeue' }
94
95
  | { kind: 'thinking' }
96
+ // Live model in use for the MAIN session, extracted from `message.model` on
97
+ // each `type:"assistant"` transcript line (the exact model that served that
98
+ // API call). Emitted FIRST among a line's events so a card rendered on the
99
+ // same batch already reflects the current model. Sentinels (`<synthetic>` on
100
+ // compaction lines, fixture junk) are filtered at projection — see
101
+ // isModelSentinel — so this only ever carries a real resolved model id.
102
+ | { kind: 'model'; model: string }
95
103
  | { kind: 'tool_use'; toolName: string; toolUseId?: string | null; input?: Record<string, unknown>; precomputedLabel?: string }
96
104
  // Real-time tool label from the PreToolUse-hook sidecar — fires when the
97
105
  // hook writes the label (synchronous at tool-call time), independent of
@@ -115,6 +123,11 @@ export type SessionEvent =
115
123
  // filename stem (e.g. "aac6f1…"). Routed through the same ingest path
116
124
  // as parent events; the reducer fans them out to per-sub-agent state.
117
125
  | { kind: 'sub_agent_started'; agentId: string; firstPromptText: string; subagentType?: string }
126
+ // Live model in use for a SUB-AGENT, extracted from `message.model` on each
127
+ // of its `type:"assistant"` transcript lines. Same contract as the main
128
+ // `model` kind (sentinel-filtered, emitted first) but agent-scoped so the
129
+ // watcher can track it per WorkerEntry and thread it onto the worker card.
130
+ | { kind: 'sub_agent_model'; agentId: string; model: string }
118
131
  | { kind: 'sub_agent_tool_use'; agentId: string; toolUseId: string | null; toolName: string; input?: Record<string, unknown>; precomputedLabel?: string }
119
132
  // Same shared contract as the main-agent `text` kind — see its doc above
120
133
  // (including the `lastInMessage` projection-artifact note). The wire-kind
@@ -320,6 +333,15 @@ export function projectTranscriptLine(line: string): SessionEvent[] {
320
333
  const content = message?.content as Array<Record<string, unknown>> | undefined
321
334
  if (!Array.isArray(content)) return []
322
335
  const events: SessionEvent[] = []
336
+ // Live model capture: `message.model` is the exact resolved model that
337
+ // served THIS assistant API call. Emit it FIRST (before the content events)
338
+ // so a card rendered on the same read batch already carries the current
339
+ // model. Sentinels (`<synthetic>` on compaction lines, fixture junk) are
340
+ // skipped — the reducer keeps the last real value.
341
+ const mainModel = message?.model
342
+ if (typeof mainModel === 'string' && !isModelSentinel(mainModel)) {
343
+ events.push({ kind: 'model', model: mainModel })
344
+ }
323
345
  // Text→narrative projection comes from the ONE shared kernel
324
346
  // (projectAssistantTextBlocks): it owns the empty-drop + blockIndex +
325
347
  // lastInMessage contract. We emit its events at their source positions
@@ -468,6 +490,12 @@ export function projectSubagentLine(
468
490
  const content = message?.content as Array<Record<string, unknown>> | undefined
469
491
  if (!Array.isArray(content)) return []
470
492
  const events: SessionEvent[] = []
493
+ // Live model capture (sub-agent tier): same contract as the main-agent
494
+ // branch — emit the sub-agent's resolved model first, sentinel-filtered.
495
+ const subModel = message?.model
496
+ if (typeof subModel === 'string' && !isModelSentinel(subModel)) {
497
+ events.push({ kind: 'sub_agent_model', agentId, model: subModel })
498
+ }
471
499
  // Text→narrative projection comes from the SAME shared kernel as the
472
500
  // main agent (projectAssistantTextBlocks): one source for the empty-drop
473
501
  // + blockIndex + lastInMessage contract. The `make` adapter only changes