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.
- package/dist/agent-scheduler/index.js +1 -0
- package/dist/auth-broker/index.js +1 -0
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +1 -0
- package/dist/cli/switchroom.js +269 -56
- package/dist/host-control/main.js +2 -1
- package/dist/vault/approvals/kernel-server.js +1 -0
- package/dist/vault/broker/server.js +1 -0
- package/package.json +3 -3
- package/profiles/_base/cron-session.sh.hbs +55 -16
- package/profiles/_base/start.sh.hbs +35 -16
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +1937 -580
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +438 -0
- package/telegram-plugin/gateway/approval-timeout-inbound-builders.ts +150 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1071 -130
- package/telegram-plugin/gateway/inbound-spool.ts +2 -1
- package/telegram-plugin/gateway/inject-handler.test.ts +19 -0
- package/telegram-plugin/gateway/inject-handler.ts +17 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +44 -2
- package/telegram-plugin/gateway/ipc-server.ts +40 -0
- package/telegram-plugin/gateway/model-command.ts +212 -51
- package/telegram-plugin/gateway/pending-card-expiry.ts +98 -0
- package/telegram-plugin/gateway/pending-card-store.ts +173 -0
- package/telegram-plugin/gateway/pending-inbound-buffer.ts +12 -2
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +4 -2
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +202 -42
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/operator-events.test.ts +1 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +202 -0
- package/telegram-plugin/tests/pending-card-expiry.test.ts +190 -0
- package/telegram-plugin/tests/pending-card-store.test.ts +173 -0
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/session-model-source.test.ts +67 -0
- package/telegram-plugin/tests/session-tail.test.ts +64 -0
- package/telegram-plugin/tests/silent-end.test.ts +46 -1
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +39 -0
- package/telegram-plugin/tests/subagent-watcher-boot-promotion-replay.test.ts +107 -4
- package/telegram-plugin/tests/subagent-watcher-handback-gaps.test.ts +42 -4
- package/telegram-plugin/tests/subagent-watcher-parent-turn-key.test.ts +47 -0
- package/telegram-plugin/tests/subagent-watcher-terminated-ids-cap.test.ts +150 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +54 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +37 -0
- package/telegram-plugin/tests/typing-wrap.test.ts +23 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +11 -0
- package/telegram-plugin/tests/worker-feed-dispatch.test.ts +126 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- 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 —
|
|
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
|
-
|
|
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
|