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.
- 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 +1172 -812
- 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 +146 -50
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/skills/switchroom-runtime/SKILL.md +2 -0
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2965 -862
- package/telegram-plugin/dist/server.js +24 -0
- package/telegram-plugin/flood-circuit-breaker.ts +123 -0
- package/telegram-plugin/gateway/activity-card-store.ts +63 -18
- 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/boot-card.ts +27 -0
- package/telegram-plugin/gateway/busy-ack.ts +106 -0
- package/telegram-plugin/gateway/clean-shutdown-marker.ts +68 -20
- package/telegram-plugin/gateway/gateway.ts +1618 -198
- 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/mental-model-propose-diff.ts +61 -5
- package/telegram-plugin/gateway/model-command.ts +227 -54
- 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-file.ts +198 -0
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/status-pin-store.ts +82 -22
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
- package/telegram-plugin/hooks/hooks.json +10 -10
- package/telegram-plugin/hooks/run-hook.sh +84 -0
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/model-unavailable.ts +26 -0
- package/telegram-plugin/operator-events.ts +24 -0
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/pty-partial-handler.ts +39 -0
- package/telegram-plugin/registry/subagents-schema.ts +80 -1
- package/telegram-plugin/registry/subagents.test.ts +90 -0
- package/telegram-plugin/render/rich-render.ts +79 -1
- package/telegram-plugin/retry-api-call.ts +62 -0
- package/telegram-plugin/session-tail.ts +28 -0
- package/telegram-plugin/shared/bot-runtime.ts +8 -1
- package/telegram-plugin/silence-poke.ts +14 -0
- package/telegram-plugin/silent-end.ts +49 -4
- package/telegram-plugin/stream-controller.ts +156 -38
- package/telegram-plugin/subagent-watcher.ts +222 -37
- package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
- package/telegram-plugin/tests/always-allow-persist-queue.test.ts +529 -0
- package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
- package/telegram-plugin/tests/approval-timeout-inbound-builders.test.ts +94 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
- package/telegram-plugin/tests/busy-ack.test.ts +121 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
- package/telegram-plugin/tests/gateway-clean-shutdown-marker.test.ts +85 -27
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +179 -25
- package/telegram-plugin/tests/ipc-server-query-pending-permission.test.ts +157 -0
- package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -5
- package/telegram-plugin/tests/model-command.test.ts +203 -43
- package/telegram-plugin/tests/model-label.test.ts +64 -0
- package/telegram-plugin/tests/model-unavailable.test.ts +41 -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/pty-partial-handler.test.ts +56 -0
- package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +286 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
- package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
- package/telegram-plugin/tests/session-model-file.test.ts +132 -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/slot-banner-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
- package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
- package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
- 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/voice-send.test.ts +308 -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/tests/worker-pin-reaper.test.ts +132 -0
- package/telegram-plugin/tool-activity-summary.ts +22 -2
- package/telegram-plugin/typing-wrap.ts +72 -25
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
- package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
- package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
- package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
- package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
- package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
- package/telegram-plugin/voice-ondemand.ts +25 -1
- package/telegram-plugin/voice-send.ts +154 -0
- 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
|
+
})
|