switchroom 0.18.3 → 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 +3 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/autoaccept-poll.js +140 -33
- package/dist/cli/notion-write-pretool.mjs +3 -1
- package/dist/cli/switchroom.js +386 -128
- package/dist/host-control/main.js +4 -2
- package/dist/vault/approvals/kernel-server.js +3 -1
- package/dist/vault/broker/server.js +38 -8
- 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/skills/switchroom-release/SKILL.md +78 -0
- package/telegram-plugin/auth-snapshot-format.ts +15 -1
- package/telegram-plugin/dist/bridge/bridge.js +22 -0
- package/telegram-plugin/dist/gateway/gateway.js +2852 -1032
- 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 +1331 -151
- 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/resolve-person.ts +304 -0
- package/telegram-plugin/gateway/resume-inbound-builder.ts +240 -2
- package/telegram-plugin/gateway/session-model-source.ts +73 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +21 -1
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +24 -1
- package/telegram-plugin/hooks/silent-end-scan.mjs +164 -40
- package/telegram-plugin/hooks/subagent-tracker-pretool.mjs +30 -7
- package/telegram-plugin/model-label.ts +69 -0
- package/telegram-plugin/operator-events.ts +45 -0
- package/telegram-plugin/pending-work-progress.ts +42 -7
- package/telegram-plugin/permission-diff.ts +128 -0
- package/telegram-plugin/quota-bar-format.ts +360 -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 +249 -46
- 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/auth-snapshot-format.test.ts +21 -0
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +263 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- 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 +17 -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/pending-work-progress.test.ts +116 -3
- package/telegram-plugin/tests/permission-diff.test.ts +111 -0
- package/telegram-plugin/tests/quota-bar-format.test.ts +444 -0
- package/telegram-plugin/tests/resolve-person.test.ts +290 -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-interrupt-stop-integration.test.ts +53 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +138 -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 +115 -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/unhandled-rejection-policy.test.ts +19 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +108 -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 +229 -15
- package/profiles/default/CLAUDE.md +0 -116
- package/telegram-plugin/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +0 -1
- package/vendor/hindsight-memory/scripts/__pycache__/directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/recall.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/retain.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/__pycache__/session_end.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/daemon.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/llm.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/state.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/lib/__pycache__/switchroom_envelope.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/__init__.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_config_client_casts.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_capture_nudge.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directive_verify.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_directives.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_gateway_ipc.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_context_slice.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_integration.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_tag_filters.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_topic_filter.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_recall_trivial_skip.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_retain_window.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_sender_routing.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/scripts/tests/__pycache__/test_switchroom_envelope.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_bank.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_client.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.0.3.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_config.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_content.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_drain_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_hooks.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_manifest.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_recall_exit_codes.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_session_end_pending.cpython-313.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313-pytest-9.1.1.pyc +0 -0
- package/vendor/hindsight-memory/tests/__pycache__/test_state.cpython-313.pyc +0 -0
|
@@ -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
|
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quota-bar block — a compact per-account ASCII-bar rendering of the 5-hour
|
|
3
|
+
* and 7-day utilization windows, for a live-refreshing Telegram card.
|
|
4
|
+
*
|
|
5
|
+
* JTBD: "at a glance, how much headroom does each account have, and how far
|
|
6
|
+
* through its reset window are we" — denser than the `/auth` table (Format 2,
|
|
7
|
+
* `auth-snapshot-format.ts`), meant for a small always-visible strip rather
|
|
8
|
+
* than a full snapshot.
|
|
9
|
+
*
|
|
10
|
+
* Locked output shape (operator-confirmed via live Telegram iteration —
|
|
11
|
+
* do not reformat without re-confirming):
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* - **you@example.com** (active)
|
|
15
|
+
* - 🟢 5h `[┃░░░░░░░░░] 0% / 4h20m left`
|
|
16
|
+
* - 🟡 7d `[████┃█░░░░] 47% / 3d1h left`
|
|
17
|
+
* - **alice@example.com** (exhausted)
|
|
18
|
+
* - 🟢 5h `[┃░░░░░░░░░] 0% / resets now`
|
|
19
|
+
* - 🔴 7d `[██████┃███] 100% / 2d16h left`
|
|
20
|
+
* ```
|
|
21
|
+
*
|
|
22
|
+
* Rules:
|
|
23
|
+
* 1. GFM `- ` bullet marker on EVERY line, including the account title
|
|
24
|
+
* line — a tight one-item-per-line list, no blank lines between rows
|
|
25
|
+
* (a no-bullet hard-break variant was tried live and rejected as
|
|
26
|
+
* "worse" — bullets are the final mechanism).
|
|
27
|
+
* 2. Title line: `- **<email>** (<status>)`, status derived from data
|
|
28
|
+
* (`active` | `exhausted` | `idle` — see `accountStatus`).
|
|
29
|
+
* 3. One row per window (5h, 7d): `- <dot> <window> \`[<bar>] <pct>% /
|
|
30
|
+
* <time-left>\``.
|
|
31
|
+
* 4. Bar is 10 cells of `█` (filled, proportional to utilization) /
|
|
32
|
+
* `░` (empty), with a single `┃` "pace" tick marking how far through
|
|
33
|
+
* the reset window we currently are. The tick ALWAYS renders at its
|
|
34
|
+
* computed position, overriding whatever fill character is there —
|
|
35
|
+
* even at 100% utilization — since the pace signal must stay visible
|
|
36
|
+
* (operator-confirmed; see `buildBar`).
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import type { QuotaUtilization } from './quota-check.js';
|
|
40
|
+
import { refillNormalizedUtils, isProbeThin } from '../src/auth/quota.js';
|
|
41
|
+
import type { AccountState, ListStateData } from '../src/auth/broker/client.js';
|
|
42
|
+
import { reviveLastQuota, recommendation, type AccountSnapshot } from './auth-snapshot-format.js';
|
|
43
|
+
import { escapeMarkdown } from './card-format.js';
|
|
44
|
+
import { maskEmail } from './demo-mask.js';
|
|
45
|
+
|
|
46
|
+
// ── dot thresholds ───────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Per-row status dot, purely a function of THAT window's own utilization
|
|
50
|
+
* percentage (not the account's overall exhausted flag — an exhausted
|
|
51
|
+
* account can still show a green 5h row if that window is fresh).
|
|
52
|
+
*
|
|
53
|
+
* - < 50% → 🟢 healthy
|
|
54
|
+
* - 50-89% → 🟡 getting close
|
|
55
|
+
* - >= 90% → 🔴 at/near the wall
|
|
56
|
+
*/
|
|
57
|
+
export function pickDot(pct: number): '🟢' | '🟡' | '🔴' {
|
|
58
|
+
const clamped = Math.max(0, Math.min(100, pct));
|
|
59
|
+
if (clamped >= 90) return '🔴';
|
|
60
|
+
if (clamped >= 50) return '🟡';
|
|
61
|
+
return '🟢';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── time-left formatting ─────────────────────────────────────────────
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Compact time-left string for the quota-bar row: `4h20m left`,
|
|
68
|
+
* `3d1h left`, `45m left`, or `resets now` when the reset has already
|
|
69
|
+
* passed (or is unknown — no reset timestamp to count down to).
|
|
70
|
+
*
|
|
71
|
+
* Deliberately more compact than `formatRelative` in
|
|
72
|
+
* `auth-snapshot-format.ts` (no space between the number and unit) to
|
|
73
|
+
* keep the fixed-width code-span row from wrapping on a phone screen.
|
|
74
|
+
*/
|
|
75
|
+
export function formatTimeLeft(target: Date | null, now: Date = new Date()): string {
|
|
76
|
+
if (!target) return 'resets now';
|
|
77
|
+
const deltaMs = target.getTime() - now.getTime();
|
|
78
|
+
// A malformed reset timestamp (invalid Date → NaN delta) must not leak a
|
|
79
|
+
// `"NaNm left"` string; treat a non-finite delta as "resets now".
|
|
80
|
+
if (!Number.isFinite(deltaMs) || deltaMs <= 0) return 'resets now';
|
|
81
|
+
const totalMin = Math.round(deltaMs / 60_000);
|
|
82
|
+
if (totalMin < 60) return `${totalMin}m left`;
|
|
83
|
+
const totalHours = Math.floor(totalMin / 60);
|
|
84
|
+
const m = totalMin % 60;
|
|
85
|
+
if (totalHours < 24) return `${totalHours}h${m}m left`;
|
|
86
|
+
const d = Math.floor(totalHours / 24);
|
|
87
|
+
const h = totalHours % 24;
|
|
88
|
+
return h > 0 ? `${d}d${h}h left` : `${d}d left`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── pace (elapsed-through-window) fraction ───────────────────────────
|
|
92
|
+
|
|
93
|
+
const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
|
|
94
|
+
const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* How far through the reset window we currently are, as a 0..1 fraction,
|
|
98
|
+
* derived from the time LEFT to reset and the window's nominal duration
|
|
99
|
+
* (we're never told the window's start time, only its end/reset — so this
|
|
100
|
+
* is an approximation, not an exact elapsed-time read).
|
|
101
|
+
*
|
|
102
|
+
* - no reset timestamp, or reset already passed → 0 (treat as a freshly
|
|
103
|
+
* started window rather than claiming we're at the very end of one we
|
|
104
|
+
* have no data for)
|
|
105
|
+
* - otherwise → `1 - timeLeftMs / windowDurationMs`, clamped to [0, 1]
|
|
106
|
+
*/
|
|
107
|
+
export function elapsedFraction(
|
|
108
|
+
target: Date | null,
|
|
109
|
+
windowDurationMs: number,
|
|
110
|
+
now: Date = new Date(),
|
|
111
|
+
): number {
|
|
112
|
+
if (!target) return 0;
|
|
113
|
+
const timeLeftMs = target.getTime() - now.getTime();
|
|
114
|
+
// A malformed reset timestamp (invalid Date → NaN delta) must not corrupt
|
|
115
|
+
// the tick placement; treat a non-finite delta as a freshly-started window.
|
|
116
|
+
if (!Number.isFinite(timeLeftMs) || timeLeftMs <= 0) return 0;
|
|
117
|
+
return Math.max(0, Math.min(1, 1 - timeLeftMs / windowDurationMs));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── bar rendering ─────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
const BAR_WIDTH = 10;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the 10-cell `[█…░…]` bar body (no surrounding brackets — callers
|
|
126
|
+
* add those) for one window row.
|
|
127
|
+
*
|
|
128
|
+
* - `fillCount = round(pct / 100 * 10)` cells are `█` from the left.
|
|
129
|
+
* - a single `┃` pace tick is placed at
|
|
130
|
+
* `round(elapsedFrac * (width - 1))`, and ALWAYS overwrites whatever
|
|
131
|
+
* cell is there — including a filled `█` cell, even at 100% utilization
|
|
132
|
+
* (rule 4 above; the pace signal must always be visible).
|
|
133
|
+
*/
|
|
134
|
+
export function buildBar(pct: number, elapsedFrac: number): string {
|
|
135
|
+
const clampedPct = Math.max(0, Math.min(100, pct));
|
|
136
|
+
const fillCount = Math.round((clampedPct / 100) * BAR_WIDTH);
|
|
137
|
+
const cells: string[] = new Array(BAR_WIDTH).fill('░');
|
|
138
|
+
for (let i = 0; i < fillCount; i++) cells[i] = '█';
|
|
139
|
+
const tickIndex = Math.max(
|
|
140
|
+
0,
|
|
141
|
+
Math.min(BAR_WIDTH - 1, Math.round(elapsedFrac * (BAR_WIDTH - 1))),
|
|
142
|
+
);
|
|
143
|
+
cells[tickIndex] = '┃';
|
|
144
|
+
return cells.join('');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// ── account status (title-line suffix) ───────────────────────────────
|
|
148
|
+
|
|
149
|
+
export type QuotaBarAccountStatus = 'active' | 'exhausted' | 'idle';
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Title-line status word. `active` wins over `exhausted` (the fleet's
|
|
153
|
+
* pinned account is reported as active even if the broker also flags it
|
|
154
|
+
* exhausted — matches the locked example where the operator wants to know
|
|
155
|
+
* WHICH account is live first, and its health second, from the two window
|
|
156
|
+
* rows underneath). Otherwise: `exhausted` if the broker's own flag says
|
|
157
|
+
* so, else `idle` (present, healthy, just not the current pick).
|
|
158
|
+
*/
|
|
159
|
+
export function accountStatus(isActive: boolean, exhausted: boolean): QuotaBarAccountStatus {
|
|
160
|
+
if (isActive) return 'active';
|
|
161
|
+
if (exhausted) return 'exhausted';
|
|
162
|
+
return 'idle';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── row / block assembly ─────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
/** One `- <dot> <window> \`[<bar>] <pct>% / <time-left>\`` line. */
|
|
168
|
+
export function formatWindowRow(
|
|
169
|
+
window: '5h' | '7d',
|
|
170
|
+
pct: number,
|
|
171
|
+
resetAt: Date | null,
|
|
172
|
+
now: Date = new Date(),
|
|
173
|
+
): string {
|
|
174
|
+
const windowMs = window === '5h' ? FIVE_HOUR_MS : SEVEN_DAY_MS;
|
|
175
|
+
const dot = pickDot(pct);
|
|
176
|
+
const bar = buildBar(pct, elapsedFraction(resetAt, windowMs, now));
|
|
177
|
+
const pctStr = `${Math.round(Math.max(0, Math.min(100, pct)))}%`;
|
|
178
|
+
const timeLeft = formatTimeLeft(resetAt, now);
|
|
179
|
+
return `- ${dot} ${window} \`[${bar}] ${pctStr} / ${timeLeft}\``;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One account's title line + its two window rows (5h then 7d). */
|
|
183
|
+
export function renderQuotaBarAccount(
|
|
184
|
+
label: string,
|
|
185
|
+
isActive: boolean,
|
|
186
|
+
exhausted: boolean,
|
|
187
|
+
quota: QuotaUtilization | null,
|
|
188
|
+
now: Date = new Date(),
|
|
189
|
+
demo = false,
|
|
190
|
+
): string[] {
|
|
191
|
+
const status = accountStatus(isActive, exhausted);
|
|
192
|
+
// Title line wraps `label` in GFM `**bold**`, NOT a code span — so this
|
|
193
|
+
// needs `escapeMarkdown` (backslash-escapes *, _, [, ], etc.), not
|
|
194
|
+
// `codeSpanSafe` (which only defuses backticks and is only correct
|
|
195
|
+
// inside literal `code spans` — see format.ts). Using codeSpanSafe here
|
|
196
|
+
// was a bug: a label containing e.g. `**` or `[x](url)` would break the
|
|
197
|
+
// bold run or inject a markdown link into the card.
|
|
198
|
+
const displayLabel = demo ? maskEmail(label) : label;
|
|
199
|
+
const lines: string[] = [`- **${escapeMarkdown(displayLabel)}** (${status})`];
|
|
200
|
+
if (!quota || isProbeThin(quota)) {
|
|
201
|
+
// Data-quality gap. A failed / thin probe carries NO real utilization
|
|
202
|
+
// signal, so it must NOT render as a healthy 🟢 0% bar — that's
|
|
203
|
+
// indistinguishable from a fresh account with full headroom, which was
|
|
204
|
+
// the #2959 review's blocking bug. Emit a distinct ⚠️ warning row per
|
|
205
|
+
// window with a "no data" label (thin vs failed) instead of a fake bar,
|
|
206
|
+
// keeping two rows so the block stays shape-stable.
|
|
207
|
+
const reason = !quota ? 'no data — probe failed' : 'no data — thin probe';
|
|
208
|
+
lines.push(`- ⚠️ 5h \`${reason}\``);
|
|
209
|
+
lines.push(`- ⚠️ 7d \`${reason}\``);
|
|
210
|
+
return lines;
|
|
211
|
+
}
|
|
212
|
+
const norm = refillNormalizedUtils(quota, now);
|
|
213
|
+
lines.push(formatWindowRow('5h', norm.fiveHourUtilizationPct, quota.fiveHourResetAt, now));
|
|
214
|
+
lines.push(formatWindowRow('7d', norm.sevenDayUtilizationPct, quota.sevenDayResetAt, now));
|
|
215
|
+
return lines;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export interface QuotaBarRenderOpts {
|
|
219
|
+
now?: Date;
|
|
220
|
+
/** Demo mode (the `/usage demo` suffix) — masks account-email labels. */
|
|
221
|
+
demo?: boolean;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Relative-age stamp for the freshness footer: "0s ago", "3m ago". Measured
|
|
226
|
+
* against `now` so tests with an injected clock get deterministic output.
|
|
227
|
+
* (Mirrors `formatAgeStamp` in auth-snapshot-format.ts, which is not exported.)
|
|
228
|
+
*/
|
|
229
|
+
function formatAgeStamp(atMs: number, now: Date = new Date()): string {
|
|
230
|
+
const ageSec = Math.max(0, Math.round((now.getTime() - atMs) / 1000));
|
|
231
|
+
return ageSec < 60 ? `${ageSec}s ago` : `${Math.round(ageSec / 60)}m ago`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export interface UsageCardRenderOpts extends QuotaBarRenderOpts {
|
|
235
|
+
/**
|
|
236
|
+
* The probe-on-open attempted a live refresh but it FAILED (or hit the TTL),
|
|
237
|
+
* so the card is served off the durable cache. When set, the footer shows an
|
|
238
|
+
* explicit "⚠ cached Nm ago" warning (age from this `capturedAt`) instead of
|
|
239
|
+
* a false live stamp. Takes precedence over `liveProbedAtMs`. Restores the
|
|
240
|
+
* signal `renderAuthSnapshotFormat2` carried before /usage went bar-only.
|
|
241
|
+
*/
|
|
242
|
+
staleCachedAtMs?: number;
|
|
243
|
+
/** Timestamp of the most recent live probe; renders "Live · refreshed Nm
|
|
244
|
+
* ago" when no stale-cache marker applies. Omit to render a bare "Live".
|
|
245
|
+
* Do NOT set this when the probe failed and no live data was obtained —
|
|
246
|
+
* set `probeFailed: true` instead so the footer doesn't claim "Live". */
|
|
247
|
+
liveProbedAtMs?: number;
|
|
248
|
+
/**
|
|
249
|
+
* True when the live probe returned no usable data for ANY account (the
|
|
250
|
+
* probe threw / timed out / returned zero rows, AND nothing was served
|
|
251
|
+
* from cache either). The footer then renders an explicit `⚠ probe failed
|
|
252
|
+
* — no live data` instead of a false "Live" stamp. Subscription-honesty:
|
|
253
|
+
* the /usage card exists to tell the operator the truth about quota state,
|
|
254
|
+
* so a footer that says "Live" while every row shows "⚠️ no data" is the
|
|
255
|
+
* exact lie this flag closes. Takes precedence over `liveProbedAtMs`;
|
|
256
|
+
* `staleCachedAtMs` (cache-served data) still takes precedence over this
|
|
257
|
+
* because in that case there IS real data, just stale.
|
|
258
|
+
*/
|
|
259
|
+
probeFailed?: boolean;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Render the full multi-account quota-bar block from `AccountSnapshot[]`
|
|
264
|
+
* (the same shape `auth-snapshot-format.ts` builds — reuse
|
|
265
|
+
* `buildSnapshotsFromCachedState` / `buildSnapshotsFromState` /
|
|
266
|
+
* `quotaBarSnapshotsFromListState` to get one).
|
|
267
|
+
*
|
|
268
|
+
* Additionally needs each account's `exhausted` flag (not carried on
|
|
269
|
+
* `AccountSnapshot`), passed as a parallel lookup keyed by label.
|
|
270
|
+
*/
|
|
271
|
+
export function renderQuotaBarBlock(
|
|
272
|
+
snapshots: AccountSnapshot[],
|
|
273
|
+
exhaustedByLabel: ReadonlyMap<string, boolean>,
|
|
274
|
+
opts: QuotaBarRenderOpts = {},
|
|
275
|
+
): string {
|
|
276
|
+
const now = opts.now ?? new Date();
|
|
277
|
+
const demo = opts.demo ?? false;
|
|
278
|
+
const lines: string[] = [];
|
|
279
|
+
for (const snap of snapshots) {
|
|
280
|
+
const exhausted = exhaustedByLabel.get(snap.label) ?? false;
|
|
281
|
+
lines.push(
|
|
282
|
+
...renderQuotaBarAccount(snap.label, snap.isActive, exhausted, snap.quota, now, demo),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
return lines.join('\n');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Convenience one-shot: build the quota-bar block directly from the shape
|
|
290
|
+
* `switchroom auth list --json` prints (`ListStateData`), using each
|
|
291
|
+
* account's cached `last_quota` (no live probe — same cache path
|
|
292
|
+
* `buildSnapshotsFromCachedState` uses). This is what the CLI script
|
|
293
|
+
* entrypoint (`scripts/print-quota-bar.ts`) calls.
|
|
294
|
+
*/
|
|
295
|
+
export function renderQuotaBarBlockFromListState(
|
|
296
|
+
state: ListStateData,
|
|
297
|
+
opts: QuotaBarRenderOpts = {},
|
|
298
|
+
): string {
|
|
299
|
+
const now = opts.now ?? new Date();
|
|
300
|
+
const exhaustedByLabel = new Map<string, boolean>(
|
|
301
|
+
state.accounts.map((a: AccountState) => [a.label, a.exhausted]),
|
|
302
|
+
);
|
|
303
|
+
const snapshots: AccountSnapshot[] = state.accounts.map((acc: AccountState) => ({
|
|
304
|
+
label: acc.label,
|
|
305
|
+
isActive: acc.label === state.active,
|
|
306
|
+
quota: reviveLastQuota(acc.last_quota ?? null),
|
|
307
|
+
quotaError: acc.last_quota ? undefined : 'no cached quota (no probe since broker start)',
|
|
308
|
+
expiresAtMs: acc.expiresAt,
|
|
309
|
+
capturedAtMs: acc.last_quota?.capturedAt,
|
|
310
|
+
}));
|
|
311
|
+
return renderQuotaBarBlock(snapshots, exhaustedByLabel, { now });
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The live `/usage` card — the compact quota-bar block, and ONLY the bar
|
|
316
|
+
* block. `/usage` used to append the full Format 2 health-grouped table
|
|
317
|
+
* (`renderAuthSnapshotFormat2`) underneath; the operator asked for the bar
|
|
318
|
+
* card alone (2026-07-10) since the two views were redundant and the table
|
|
319
|
+
* doubled the message length. Every field the table carried per-account
|
|
320
|
+
* (5h/7d utilization + reset) has an equivalent in the bar rows — the `pct%
|
|
321
|
+
* / <time-left>` segment of each window row is the reset info, just
|
|
322
|
+
* relative instead of absolute. `opts.now` is shared with the bar block;
|
|
323
|
+
* `opts.demo` masks account-email labels in the title line the same way the
|
|
324
|
+
* table used to.
|
|
325
|
+
*
|
|
326
|
+
* Footer: after the bar rows, two footer lines are appended —
|
|
327
|
+
* 1. the synthesized cross-account "switch now" verdict (`recommendation`,
|
|
328
|
+
* shared with the /auth snapshot) — the single most actionable line,
|
|
329
|
+
* dropped when /usage went bar-only and restored here (#2959 review).
|
|
330
|
+
* 2. a freshness marker — `⚠ cached Nm ago` when the data was served stale
|
|
331
|
+
* from cache (`staleCachedAtMs`), else `Live · refreshed Nm ago` /
|
|
332
|
+
* `Live` — so a cache-served card is never mistaken for a live one.
|
|
333
|
+
*/
|
|
334
|
+
export function renderUsageCard(
|
|
335
|
+
snapshots: AccountSnapshot[],
|
|
336
|
+
exhaustedByLabel: ReadonlyMap<string, boolean>,
|
|
337
|
+
opts: UsageCardRenderOpts = {},
|
|
338
|
+
): string {
|
|
339
|
+
const now = opts.now ?? new Date();
|
|
340
|
+
const demo = opts.demo ?? false;
|
|
341
|
+
const bar = renderQuotaBarBlock(snapshots, exhaustedByLabel, { now, demo });
|
|
342
|
+
const lines = [bar];
|
|
343
|
+
// Actionable cross-account verdict — restored from renderAuthSnapshotFormat2.
|
|
344
|
+
lines.push(`_${recommendation(snapshots, now, demo)}_`);
|
|
345
|
+
// Freshness signal: stale-cache warning takes precedence over a live stamp,
|
|
346
|
+
// which takes precedence over an explicit probe-failed marker (no live data
|
|
347
|
+
// AND no cache — the card is showing "⚠️ no data" rows, so "Live" would be
|
|
348
|
+
// a lie). The probeFailed branch is the honesty backstop: without it, a
|
|
349
|
+
// total probe failure rendered a bare `_Live_` footer next to no-data rows.
|
|
350
|
+
if (opts.staleCachedAtMs != null) {
|
|
351
|
+
lines.push(`_⚠ cached ${formatAgeStamp(opts.staleCachedAtMs, now)}_`);
|
|
352
|
+
} else if (opts.liveProbedAtMs != null) {
|
|
353
|
+
lines.push(`_Live · refreshed ${formatAgeStamp(opts.liveProbedAtMs, now)}_`);
|
|
354
|
+
} else if (opts.probeFailed) {
|
|
355
|
+
lines.push('_⚠ probe failed — no live data_');
|
|
356
|
+
} else {
|
|
357
|
+
lines.push('_Live_');
|
|
358
|
+
}
|
|
359
|
+
return lines.join('\n');
|
|
360
|
+
}
|
|
@@ -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.
|