switchroom 0.18.19 → 0.18.21
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/cli/ms-365-write-pretool.mjs +92 -20
- package/dist/cli/switchroom.js +59 -6
- package/dist/host-control/main.js +1 -1
- package/package.json +1 -1
- package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
- package/profiles/_shared/dev-protocol.md.hbs +2 -0
- package/profiles/_shared/execution-discipline.md.hbs +2 -2
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/answer-ready-flush.ts +187 -0
- package/telegram-plugin/dist/gateway/gateway.js +1114 -184
- package/telegram-plugin/format.ts +179 -20
- package/telegram-plugin/gateway/cron-session.ts +32 -0
- package/telegram-plugin/gateway/gateway.ts +794 -106
- package/telegram-plugin/gateway/idle-clear.ts +170 -0
- package/telegram-plugin/gateway/inject-handler.ts +11 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +9 -9
- package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
- package/telegram-plugin/gateway/turn-record-status.ts +134 -0
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +23 -0
- package/telegram-plugin/hooks/silent-end-scan.mjs +98 -8
- package/telegram-plugin/narrative-flush.ts +181 -0
- package/telegram-plugin/pending-work-progress.ts +65 -1
- package/telegram-plugin/registry/subagents-schema.ts +6 -0
- package/telegram-plugin/session-tail.ts +6 -1
- package/telegram-plugin/silent-end.ts +182 -0
- package/telegram-plugin/stream-reply-handler.ts +14 -5
- package/telegram-plugin/subagent-watcher.ts +330 -82
- package/telegram-plugin/tests/answer-ready-flush.test.ts +343 -0
- package/telegram-plugin/tests/cron-inject-idle-clock.test.ts +54 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +13 -10
- package/telegram-plugin/tests/format-consistency.test.ts +54 -34
- package/telegram-plugin/tests/formatting-parse-regression.test.ts +6 -5
- package/telegram-plugin/tests/formatting-torture-set.ts +1 -1
- package/telegram-plugin/tests/idle-clear.test.ts +315 -37
- package/telegram-plugin/tests/narrative-flush.test.ts +213 -0
- package/telegram-plugin/tests/narrative-splice-before-finalize.test.ts +167 -0
- package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
- package/telegram-plugin/tests/outbound-send-path.test.ts +5 -4
- package/telegram-plugin/tests/paragraph-normalizer.test.ts +100 -42
- package/telegram-plugin/tests/paragraph-spacer-golden.test.ts +150 -0
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +4 -1
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +194 -0
- package/telegram-plugin/tests/silent-end.test.ts +296 -0
- package/telegram-plugin/tests/stream-reply-handler.test.ts +12 -9
- package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
- package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
- package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +220 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
- package/telegram-plugin/tests/telegram-format.test.ts +36 -23
- package/telegram-plugin/tests/turn-flush-safety.test.ts +21 -17
- package/telegram-plugin/tests/turn-record-status.test.ts +119 -0
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +218 -1
- package/telegram-plugin/tests/worker-feed-terminal-cleanup.test.ts +254 -0
- package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +165 -0
- package/telegram-plugin/tool-activity-summary.ts +78 -16
- package/telegram-plugin/turn-flush-safety.ts +4 -4
- package/telegram-plugin/worker-activity-feed.ts +181 -30
|
@@ -46,7 +46,8 @@ import { homedir } from 'os'
|
|
|
46
46
|
import { projectSubagentLine, sanitizeCwdToProjectName, detectErrorInTranscriptLine } from './session-tail.js'
|
|
47
47
|
import { sanitiseToolArg } from './fleet-state.js'
|
|
48
48
|
import { clipNarrative, describeToolUse } from './tool-activity-summary.js'
|
|
49
|
-
import { REPLY_TOOLS
|
|
49
|
+
import { REPLY_TOOLS } from './narrative-dedup.js'
|
|
50
|
+
import { NarrativeFlushController, PENDING_NARRATIVE_FLUSH_MS } from './narrative-flush.js'
|
|
50
51
|
import { truncate } from './card-format.js'
|
|
51
52
|
import { bumpSubagentActivity, recordSubagentStall, recordSubagentResume, recordSubagentEnd, reapStuckRunningRows, countRunningBackgroundSubagents, recordNestedSubagentDispatch, recordSubagentModel } from './registry/subagents-schema.js'
|
|
52
53
|
import { touchTurnActiveMarker } from './gateway/turn-active-marker.js'
|
|
@@ -73,6 +74,38 @@ export interface SubagentLivenessDb {
|
|
|
73
74
|
|
|
74
75
|
export type WorkerState = 'running' | 'done' | 'failed'
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* Per-entry narrative gate: the SAME `NarrativeFlushController` kernel the
|
|
79
|
+
* main-agent gateway path uses, driven by a POLL-driven scheduler instead of a
|
|
80
|
+
* real `setTimeout`. Constructed lazily on the first `sub_agent_text` block.
|
|
81
|
+
*
|
|
82
|
+
* Time-box parity with the main path (Residual A): the kernel's injected
|
|
83
|
+
* scheduler stamps `deadline = nowFn()+PENDING_NARRATIVE_FLUSH_MS` when a block
|
|
84
|
+
* is parked; `tick(now)` — called at the top of every `readSubTail` (which runs
|
|
85
|
+
* every ~1s poll for a running entry, regardless of file growth) — fires the
|
|
86
|
+
* parked block EARLY once `now >= deadline`, WITHOUT waiting for the worker's
|
|
87
|
+
* first tool. So a worker's opening narration surfaces ~the next poll tick
|
|
88
|
+
* after the flush window rather than gating on its first jsonl tool event.
|
|
89
|
+
*
|
|
90
|
+
* Depth-generic: one gate per `WorkerEntry`, and every sub-agent / worker /
|
|
91
|
+
* nested sub-worker at any depth is a `WorkerEntry` — so the early-paint
|
|
92
|
+
* applies uniformly at every nesting level.
|
|
93
|
+
*/
|
|
94
|
+
export interface WorkerNarrativeGate {
|
|
95
|
+
/** Fire the early-paint if a parked block's flush window has elapsed.
|
|
96
|
+
* Also refreshes the injected clock to this poll's `now`, so a separate
|
|
97
|
+
* clock-refresh entrypoint is unnecessary. */
|
|
98
|
+
tick(now: number): void
|
|
99
|
+
/** Gate step 1: park a new `sub_agent_text` block (SHOW any prior pending). */
|
|
100
|
+
stage(text: string): void
|
|
101
|
+
/** Gate step 2: a tool_use lookahead. Returns whether a narrative cue fired. */
|
|
102
|
+
resolveOnTool(toolName: string | null, input: Record<string, unknown> | undefined): boolean
|
|
103
|
+
/** Gate step 3: turn_end lookahead. Returns whether a narrative cue fired. */
|
|
104
|
+
resolveAtTurnEnd(lastReplyText: string): boolean
|
|
105
|
+
/** Cancel the timer + drop pending state (resurrection / teardown). */
|
|
106
|
+
reset(): void
|
|
107
|
+
}
|
|
108
|
+
|
|
76
109
|
export interface WorkerEntry {
|
|
77
110
|
/** Sub-agent JSONL file stem, e.g. "a75d4757a81e7b1f8". */
|
|
78
111
|
readonly agentId: string
|
|
@@ -197,15 +230,18 @@ export interface WorkerEntry {
|
|
|
197
230
|
* worker left no narrative result of its own. */
|
|
198
231
|
errorDetail?: string
|
|
199
232
|
/**
|
|
200
|
-
* Narrative-dedup gate
|
|
201
|
-
* `sub_agent_text` block is
|
|
202
|
-
* `sub_agent_tool_use` / `sub_agent_turn_end` can decide
|
|
203
|
-
* (SUPPRESS — it duplicates the worker's reply) vs
|
|
204
|
-
* — fire `onProgress({latestSummary})`)
|
|
205
|
-
*
|
|
206
|
-
*
|
|
233
|
+
* Narrative-dedup + early-paint gate (JSONL-text-narrative primitive). A
|
|
234
|
+
* `sub_agent_text` block is parked in the kernel for ONE lookahead step so
|
|
235
|
+
* the next `sub_agent_tool_use` / `sub_agent_turn_end` can decide
|
|
236
|
+
* draft-then-send (SUPPRESS — it duplicates the worker's reply) vs
|
|
237
|
+
* working-narration (SHOW — fire `onProgress({latestSummary})`), AND
|
|
238
|
+
* time-boxed so an opening narration paints EARLY if no lookahead arrives
|
|
239
|
+
* within `PENDING_NARRATIVE_FLUSH_MS`. Reuses the SAME
|
|
240
|
+
* `NarrativeFlushController` kernel as the main-agent gateway path
|
|
241
|
+
* (`makeNarrativeGate`), driven by a poll scheduler. Constructed lazily on
|
|
242
|
+
* the first `sub_agent_text`; null before then. Reset on resurrection.
|
|
207
243
|
*/
|
|
208
|
-
|
|
244
|
+
narrativeGate?: WorkerNarrativeGate | null
|
|
209
245
|
/**
|
|
210
246
|
* NIT 3 (sub-agent turn_end symmetry). Most-recently-seen
|
|
211
247
|
* reply/stream_reply `input.text` for this sub-agent — the actual answer a
|
|
@@ -538,6 +574,26 @@ export interface SubagentWatcherConfig {
|
|
|
538
574
|
*/
|
|
539
575
|
background: boolean | undefined
|
|
540
576
|
}) => void
|
|
577
|
+
/**
|
|
578
|
+
* Fires EXACTLY when a sub-agent is swept out of the watcher's registry by
|
|
579
|
+
* `cleanupTerminalAgent` — the single, authoritative "this agent is terminal
|
|
580
|
+
* and being forgotten" signal that EVERY terminal path funnels through
|
|
581
|
+
* (real `turn_end`, silent-stall synthesis, failed, boot done-at-boot orphan,
|
|
582
|
+
* AND the JSONL-vanished path — where Claude Code reaped the parent session's
|
|
583
|
+
* `subagents/` dir, `onFileVanished` → `cleanupTerminalAgent` runs DIRECTLY,
|
|
584
|
+
* bypassing `onFinish`).
|
|
585
|
+
*
|
|
586
|
+
* Why this exists (worker-feed ghost leak): the live worker-activity feed
|
|
587
|
+
* removes a worker's row ONLY from the gateway's `onFinish` handler. The
|
|
588
|
+
* JSONL-vanished and boot-orphan terminal paths never fire `onFinish`, so a
|
|
589
|
+
* worker that was live in the feed leaks there forever — the shared card never
|
|
590
|
+
* empties, so it never collapses/unpins and heartbeat-edits indefinitely
|
|
591
|
+
* while buried up-chat. Wiring feed removal to THIS callback (the same sweep
|
|
592
|
+
* the watcher already performs) makes cleanup and feed-remove impossible to
|
|
593
|
+
* diverge. Best-effort; idempotent on the feed side (a no-op once the worker
|
|
594
|
+
* was already removed by `onFinish`).
|
|
595
|
+
*/
|
|
596
|
+
onTerminalCleanup?: (agentId: string) => void
|
|
541
597
|
/**
|
|
542
598
|
* #1720: fires on every `sub_agent_text` event for a running
|
|
543
599
|
* sub-agent. The gateway decides whether to materialise a
|
|
@@ -572,6 +628,12 @@ export interface SubagentWatcherConfig {
|
|
|
572
628
|
* assistant line — the gateway then falls back to the registry's
|
|
573
629
|
* dispatch-time model. */
|
|
574
630
|
model?: string
|
|
631
|
+
/** True for a growth-INDEPENDENT skeleton liveness cue (#3231): fired on a
|
|
632
|
+
* no-growth poll for a running entry so the card can first-paint / stay
|
|
633
|
+
* alive without waiting for JSONL growth. Carries the entry's real state
|
|
634
|
+
* but an EMPTY `latestSummary`/`progressLine` — never fabricated content.
|
|
635
|
+
* Consumers that count real narrative/tool cues must exclude it. */
|
|
636
|
+
skeleton?: boolean
|
|
575
637
|
}) => void
|
|
576
638
|
/** `Date.now` override for tests. */
|
|
577
639
|
now?: () => number
|
|
@@ -635,6 +697,21 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
|
|
|
635
697
|
// reaper TTL, so the watcher — not the reaper — still owns the terminal
|
|
636
698
|
// transition for a worker that died mid-tool.
|
|
637
699
|
const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
|
|
700
|
+
/**
|
|
701
|
+
* Resolve the effective in-flight terminal-synthesis cap — the maximum wall-
|
|
702
|
+
* clock a genuinely-live worker can go with ZERO JSONL writes before the
|
|
703
|
+
* watcher declares it terminal (a worker mid-very-long `Bash`). This is the
|
|
704
|
+
* SAME resolution `startSubagentWatcher` uses internally (explicit config →
|
|
705
|
+
* `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS` env → default), exported so
|
|
706
|
+
* downstream liveness surfaces (the worker-activity feed's backstop TTL) can
|
|
707
|
+
* DERIVE their bound from it in code rather than hardcoding an assumption about
|
|
708
|
+
* its value. Keeps the "TTL must exceed the watcher's terminal-transition
|
|
709
|
+
* latency" invariant code-enforced: if an operator raises the cap via env, any
|
|
710
|
+
* derived TTL moves with it instead of falsely reaping a still-live worker.
|
|
711
|
+
*/
|
|
712
|
+
export function resolveInflightTerminalCapMs(configVal?: number): number {
|
|
713
|
+
return configVal ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS') ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
|
|
714
|
+
}
|
|
638
715
|
// Minimum wall-clock gap between two "terminal synthesis deferred" log lines
|
|
639
716
|
// for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
|
|
640
717
|
// the deferral is re-evaluated (and, before this gate, re-logged) on every one
|
|
@@ -1017,15 +1094,101 @@ export function readSubTail(
|
|
|
1017
1094
|
progressLine?: string
|
|
1018
1095
|
/** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
|
|
1019
1096
|
model?: string
|
|
1097
|
+
/** Growth-independent skeleton liveness cue (#3231). See the identically
|
|
1098
|
+
* named field on SubagentWatcherConfig.onProgress. */
|
|
1099
|
+
skeleton?: boolean
|
|
1020
1100
|
}) => void,
|
|
1021
1101
|
): void {
|
|
1022
1102
|
try {
|
|
1103
|
+
// Early-paint tick (Residual A): fire a parked opening narration whose
|
|
1104
|
+
// flush window has elapsed, WITHOUT waiting for a tool. Runs BEFORE the
|
|
1105
|
+
// `size === cursor` no-growth early-return below, so it fires on a quiet
|
|
1106
|
+
// poll (the "narrate, then think" gap) — the whole point of the time-box.
|
|
1107
|
+
// This function is the per-poll defensive read for every running entry, so
|
|
1108
|
+
// the deadline is observed within one poll tick of PENDING_NARRATIVE_FLUSH_MS.
|
|
1109
|
+
if (entry.narrativeGate != null) entry.narrativeGate.tick(now)
|
|
1023
1110
|
const stat = fs.statSync(entry.filePath)
|
|
1024
1111
|
if (stat.size < tail.cursor) {
|
|
1025
1112
|
tail.cursor = 0
|
|
1026
1113
|
tail.pendingPartial = ''
|
|
1027
1114
|
}
|
|
1028
|
-
if (stat.size === tail.cursor)
|
|
1115
|
+
if (stat.size === tail.cursor) {
|
|
1116
|
+
// First-paint independence (#3231): the worker card is otherwise driven
|
|
1117
|
+
// ONLY by growth-triggered progress cues below, so a running worker whose
|
|
1118
|
+
// JSONL is not currently growing surfaces NOTHING. That is the ~90-205s
|
|
1119
|
+
// invisible-card bug observed live (a57fbf, 2026-07-13): an async
|
|
1120
|
+
// foreground sub-agent did two Bash calls, then its first tool BLOCKED for
|
|
1121
|
+
// ~99s (no JSONL growth → no cue), and — because its spawning turn had
|
|
1122
|
+
// already ended — no nest and no worker-feed row existed to paint. Its
|
|
1123
|
+
// card did not appear until 205s after registration, on the next growth
|
|
1124
|
+
// event that happened to be classified to the feed. Fire a growth-INDEPENDENT
|
|
1125
|
+
// skeleton liveness cue on every no-growth poll for a live entry so the
|
|
1126
|
+
// gateway can paint (and keep alive) the card from registration onward,
|
|
1127
|
+
// uniformly across ALL spawn origins/nesting levels. The cue carries the
|
|
1128
|
+
// entry's REAL current state (lastTool/toolCount/model) but an EMPTY step
|
|
1129
|
+
// line — no fabricated content: it is inert on the foreground-nest path
|
|
1130
|
+
// (empty child → no-op, the parent's own card owns the live turn) and
|
|
1131
|
+
// creates/refreshes the orphan/background worker-feed row (→ "starting…",
|
|
1132
|
+
// whose first paint the feed's own firstPaintMin + heartbeat then owns).
|
|
1133
|
+
if (onProgress != null && entry.state === 'running' && !entry.historical) {
|
|
1134
|
+
// Child-aware suppression (#3233): the skeleton cue exists to paint a
|
|
1135
|
+
// LEAF worker whose card would otherwise be invisible (the 205s
|
|
1136
|
+
// blackout). A pure-ORCHESTRATOR parent — one that has dispatched a
|
|
1137
|
+
// descendant of its own — must NOT earn a redundant "starting…"
|
|
1138
|
+
// liveness row: the child surfaces its own live row in the same worker
|
|
1139
|
+
// feed, so an extra skeleton row for the parent is pure feed clutter
|
|
1140
|
+
// (fails the no-noise / never-storm bar). The discriminator is
|
|
1141
|
+
// deliberately NOT "0 own tools" — a leaf that registers and BLOCKS on
|
|
1142
|
+
// its very first tool has 0 completed tools and MUST still paint.
|
|
1143
|
+
// Instead, suppress when THIS entry has EVER dispatched a child (any
|
|
1144
|
+
// child registry row keyed by parent_agent_id = this entry's jsonl
|
|
1145
|
+
// agentId; recordNestedSubagentDispatch stamps it). "Ever", not "a
|
|
1146
|
+
// currently-running child": the skeleton cue is only the NO-GROWTH
|
|
1147
|
+
// fallback, so suppressing it for an orchestrator never hides real
|
|
1148
|
+
// work — if the parent does its own tools, those fire real growth
|
|
1149
|
+
// cues and paint the row; if it only orchestrates, its children carry
|
|
1150
|
+
// the liveness. Using "currently running" instead would re-paint a
|
|
1151
|
+
// spurious orchestrator "starting…" the moment its child finished. A
|
|
1152
|
+
// genuine leaf has no child row at all, so it keeps firing the
|
|
1153
|
+
// skeleton cue and paints promptly — the 205s-blackout class is intact.
|
|
1154
|
+
let hasChild = false
|
|
1155
|
+
if (db != null) {
|
|
1156
|
+
try {
|
|
1157
|
+
const kid = db
|
|
1158
|
+
.prepare(
|
|
1159
|
+
'SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1',
|
|
1160
|
+
)
|
|
1161
|
+
.get(entry.agentId)
|
|
1162
|
+
hasChild = kid != null
|
|
1163
|
+
} catch (kidErr) {
|
|
1164
|
+
// Best-effort: an absent/failed linkage read is treated as "leaf"
|
|
1165
|
+
// so we never suppress a genuine blackout paint on a DB hiccup.
|
|
1166
|
+
log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${(kidErr as Error).message}`)
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
if (!hasChild) {
|
|
1170
|
+
try {
|
|
1171
|
+
onProgress({
|
|
1172
|
+
agentId: entry.agentId,
|
|
1173
|
+
description: entry.description,
|
|
1174
|
+
latestSummary: '',
|
|
1175
|
+
elapsedMs: now - entry.dispatchedAt,
|
|
1176
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
1177
|
+
setBucketIdx: (b: number) => {
|
|
1178
|
+
entry.lastProgressBucketIdx = b
|
|
1179
|
+
},
|
|
1180
|
+
lastTool: entry.lastTool,
|
|
1181
|
+
toolCount: entry.toolCount,
|
|
1182
|
+
model: entry.currentModel,
|
|
1183
|
+
skeleton: true,
|
|
1184
|
+
})
|
|
1185
|
+
} catch (cbErr) {
|
|
1186
|
+
log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return
|
|
1191
|
+
}
|
|
1029
1192
|
|
|
1030
1193
|
const buf = Buffer.alloc(stat.size - tail.cursor)
|
|
1031
1194
|
const fd = fs.openSync(entry.filePath, 'r')
|
|
@@ -1126,70 +1289,142 @@ export function readSubTail(
|
|
|
1126
1289
|
if (errInfo.detail) entry.errorDetail = errInfo.detail.slice(0, SUBAGENT_RESULT_TEXT_MAX)
|
|
1127
1290
|
}
|
|
1128
1291
|
const events = projectSubagentLine(line, entry.agentId, startState)
|
|
1129
|
-
// Narrative
|
|
1130
|
-
//
|
|
1131
|
-
//
|
|
1132
|
-
// (
|
|
1133
|
-
//
|
|
1134
|
-
//
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1292
|
+
// Narrative gate (JSONL-text-narrative primitive) — the SAME
|
|
1293
|
+
// `NarrativeFlushController` kernel the main-agent gateway path uses,
|
|
1294
|
+
// driven here by a POLL-driven scheduler so a worker's opening narration
|
|
1295
|
+
// paints EARLY (~the next poll after PENDING_NARRATIVE_FLUSH_MS) instead
|
|
1296
|
+
// of gating on its first tool. `show` fires the narrative onProgress cue
|
|
1297
|
+
// for a SHOWN block (`latestSummary` = entry.lastResultText, never tool
|
|
1298
|
+
// labels — matching the historical wire shape); the kernel owns the
|
|
1299
|
+
// SHOW/SUPPRESS/early-paint/retract decisions (dedup lives in
|
|
1300
|
+
// narrative-dedup.ts, timer + retract in narrative-flush.ts).
|
|
1301
|
+
const buildNarrativeGate = (): WorkerNarrativeGate => {
|
|
1302
|
+
// Injected clock the kernel's scheduler reads. Refreshed each poll via
|
|
1303
|
+
// tick(now) so `deadline = nowRef.value + flushMs` uses the CURRENT
|
|
1304
|
+
// poll's `now` even though the kernel persists across polls.
|
|
1305
|
+
const nowRef = { value: now }
|
|
1306
|
+
// Whether the LAST kernel effect painted a narrative cue — read back by
|
|
1307
|
+
// resolveOnTool/resolveAtTurnEnd for the tool-label clobber guard.
|
|
1308
|
+
let cueFired = false
|
|
1309
|
+
const fireCue = (): boolean => {
|
|
1310
|
+
if (onProgress == null || entry.state !== 'running' || entry.historical) return false
|
|
1311
|
+
try {
|
|
1312
|
+
onProgress({
|
|
1313
|
+
agentId: entry.agentId,
|
|
1314
|
+
description: entry.description,
|
|
1315
|
+
latestSummary: entry.lastResultText,
|
|
1316
|
+
elapsedMs: nowRef.value - entry.dispatchedAt,
|
|
1317
|
+
prevBucketIdx: entry.lastProgressBucketIdx,
|
|
1318
|
+
setBucketIdx: (b: number) => {
|
|
1319
|
+
entry.lastProgressBucketIdx = b
|
|
1320
|
+
},
|
|
1321
|
+
lastTool: entry.lastTool,
|
|
1322
|
+
toolCount: entry.toolCount,
|
|
1323
|
+
model: entry.currentModel,
|
|
1324
|
+
})
|
|
1325
|
+
return true
|
|
1326
|
+
} catch (cbErr) {
|
|
1327
|
+
log?.(`subagent-watcher: onProgress callback error ${entry.agentId}: ${(cbErr as Error).message}`)
|
|
1328
|
+
return false
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
// Poll-driven scheduler: `arm` stamps a deadline off the injected clock;
|
|
1332
|
+
// the watcher's `tick` fires it once the poll clock reaches it — the
|
|
1333
|
+
// deterministic, clock-injected equivalent of the gateway's setTimeout.
|
|
1334
|
+
const scheduler = {
|
|
1335
|
+
armedFn: null as (() => void) | null,
|
|
1336
|
+
deadline: 0,
|
|
1337
|
+
}
|
|
1338
|
+
const controller = new NarrativeFlushController(
|
|
1339
|
+
{
|
|
1340
|
+
show: () => {
|
|
1341
|
+
cueFired = fireCue()
|
|
1146
1342
|
},
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1343
|
+
// Retract on the worker path is a documented near-no-op: unlike the
|
|
1344
|
+
// gateway (which splices a persisted narration mirror), the worker
|
|
1345
|
+
// card is replace-on-write and the worker's reply is a
|
|
1346
|
+
// Telegram-surface tool that `describeToolUse` never renders — so a
|
|
1347
|
+
// timer-painted block can never be DOUBLE-printed as both a card
|
|
1348
|
+
// step and the reply (the gateway's catastrophe is structurally
|
|
1349
|
+
// impossible here). The transient trail line self-heals via the
|
|
1350
|
+
// rolling window; the worker's true result rides
|
|
1351
|
+
// lastResultText/onFinish regardless of the gate. See DESIGN.md
|
|
1352
|
+
// "COVERAGE LIMIT" for the narrow >250ms-gap draft edge.
|
|
1353
|
+
retractShown: () => {
|
|
1354
|
+
log?.(
|
|
1355
|
+
`subagent-watcher: narrative early-paint retract (no-op on replace-on-write worker card) ${entry.agentId}`,
|
|
1356
|
+
)
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
{
|
|
1360
|
+
arm: (fn, ms) => {
|
|
1361
|
+
scheduler.armedFn = fn
|
|
1362
|
+
scheduler.deadline = nowRef.value + ms
|
|
1363
|
+
},
|
|
1364
|
+
disarm: () => {
|
|
1365
|
+
scheduler.armedFn = null
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1368
|
+
PENDING_NARRATIVE_FLUSH_MS,
|
|
1369
|
+
)
|
|
1370
|
+
return {
|
|
1371
|
+
tick: (n) => {
|
|
1372
|
+
nowRef.value = n
|
|
1373
|
+
if (scheduler.armedFn != null && n >= scheduler.deadline) {
|
|
1374
|
+
const fn = scheduler.armedFn
|
|
1375
|
+
scheduler.armedFn = null
|
|
1376
|
+
fn() // → onTimerFire → show → fireCue (EARLY paint, no tool needed)
|
|
1377
|
+
}
|
|
1378
|
+
},
|
|
1379
|
+
// stage/resolve run synchronously within a readSubTail call whose
|
|
1380
|
+
// clock was already refreshed onto nowRef by the top-of-function
|
|
1381
|
+
// `tick(now)` (or by buildNarrativeGate on the first block), so they
|
|
1382
|
+
// must NOT re-stamp nowRef with a captured (stale) `now`.
|
|
1383
|
+
stage: (text) => {
|
|
1384
|
+
controller.stage(text)
|
|
1385
|
+
},
|
|
1386
|
+
resolveOnTool: (toolName, input) => {
|
|
1387
|
+
cueFired = false
|
|
1388
|
+
// NIT 3 (turn_end symmetry) lives inside the kernel: it compares the
|
|
1389
|
+
// pending block against a REPLY_TOOL's input.text (tool path) and,
|
|
1390
|
+
// at turn_end, against the delivered reply text passed by
|
|
1391
|
+
// resolveAtTurnEnd. Here we only forward the tool lookahead.
|
|
1392
|
+
controller.resolveOnTool(toolName ?? '', input)
|
|
1393
|
+
return cueFired
|
|
1394
|
+
},
|
|
1395
|
+
resolveAtTurnEnd: (lastReplyText) => {
|
|
1396
|
+
cueFired = false
|
|
1397
|
+
controller.flushAtTurnEnd(lastReplyText)
|
|
1398
|
+
return cueFired
|
|
1399
|
+
},
|
|
1400
|
+
reset: () => {
|
|
1401
|
+
controller.teardown()
|
|
1402
|
+
scheduler.armedFn = null
|
|
1403
|
+
},
|
|
1155
1404
|
}
|
|
1156
1405
|
}
|
|
1157
1406
|
// Resolve a pending sub-agent narrative against a lookahead event.
|
|
1158
|
-
// SUPPRESS only when the pending block drafts a reply/stream_reply
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1161
|
-
//
|
|
1162
|
-
// -
|
|
1163
|
-
//
|
|
1164
|
-
//
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
//
|
|
1168
|
-
//
|
|
1169
|
-
//
|
|
1170
|
-
// (`entry.lastReplyText`) and suppress a draft. Background workers
|
|
1171
|
-
// never set lastReplyText, so their trailing narration still SHOWs.
|
|
1172
|
-
// Returns true iff a narrative onProgress cue actually fired this
|
|
1173
|
-
// call — callers use this to skip a redundant/clobbering tool-label
|
|
1174
|
-
// onProgress cue for the SAME tick (see #1042 below: without this,
|
|
1175
|
-
// the tool-description onProgress unconditionally fires right after
|
|
1176
|
-
// and its replace-on-write onProgress always wins, so the narration
|
|
1177
|
-
// shown here is never actually visible on the pinned card).
|
|
1407
|
+
// SUPPRESS only when the pending block drafts a reply/stream_reply tool's
|
|
1408
|
+
// text; otherwise SHOW (fire the cue). Two lookahead shapes:
|
|
1409
|
+
// - sub_agent_tool_use: forward the tool — the kernel suppresses a draft
|
|
1410
|
+
// of THIS tool's reply text (REPLY_TOOLS only).
|
|
1411
|
+
// - sub_agent_turn_end: `toolName` is null → resolveAtTurnEnd with
|
|
1412
|
+
// entry.lastReplyText, so a FOREGROUND sub-agent's trailing draft of
|
|
1413
|
+
// its delivered answer is suppressed (background workers never set
|
|
1414
|
+
// lastReplyText, so their trailing narration still SHOWs).
|
|
1415
|
+
// Returns true iff a narrative onProgress cue actually fired — callers use
|
|
1416
|
+
// this to skip a redundant/clobbering tool-label onProgress cue for the
|
|
1417
|
+
// SAME tick (the tool-description onProgress's replace-on-write always
|
|
1418
|
+
// wins, so firing both back-to-back hides the narration).
|
|
1178
1419
|
const resolvePendingSubNarrative = (
|
|
1179
1420
|
toolName: string | null,
|
|
1180
1421
|
toolInput: Record<string, unknown> | undefined,
|
|
1181
1422
|
): boolean => {
|
|
1182
|
-
if (entry.
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
if (toolName != null && REPLY_TOOLS.has(toolName)) {
|
|
1186
|
-
const replyText = typeof toolInput?.text === 'string' ? (toolInput.text as string) : ''
|
|
1187
|
-
if (isDraftOfReply(pending.text, replyText)) return false // draft of the reply → SUPPRESS
|
|
1188
|
-
} else if (toolName == null && entry.lastReplyText != null && entry.lastReplyText.length > 0) {
|
|
1189
|
-
// turn_end path: suppress a trailing draft of the delivered answer.
|
|
1190
|
-
if (isDraftOfReply(pending.text, entry.lastReplyText)) return false
|
|
1423
|
+
if (entry.narrativeGate == null) return false
|
|
1424
|
+
if (toolName == null) {
|
|
1425
|
+
return entry.narrativeGate.resolveAtTurnEnd(entry.lastReplyText ?? '')
|
|
1191
1426
|
}
|
|
1192
|
-
return
|
|
1427
|
+
return entry.narrativeGate.resolveOnTool(toolName, toolInput)
|
|
1193
1428
|
}
|
|
1194
1429
|
for (const ev of events) {
|
|
1195
1430
|
const idleSecBeforeBump = Math.round((now - entry.lastActivityAt) / 1000)
|
|
@@ -1396,18 +1631,18 @@ export function readSubTail(
|
|
|
1396
1631
|
// args or file content — consistent with the watcher's
|
|
1397
1632
|
// "descriptions only" privacy posture.
|
|
1398
1633
|
entry.lastResultText = ev.text.trim().slice(0, SUBAGENT_RESULT_TEXT_MAX)
|
|
1399
|
-
// #1720 + JSONL-text-narrative gate step 1: stage this block for
|
|
1400
|
-
//
|
|
1401
|
-
// immediately.
|
|
1402
|
-
//
|
|
1403
|
-
// THIS
|
|
1404
|
-
// sub_agent_tool_use / sub_agent_turn_end
|
|
1405
|
-
//
|
|
1406
|
-
//
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
entry.
|
|
1634
|
+
// #1720 + JSONL-text-narrative gate step 1: stage this block for one
|
|
1635
|
+
// lookahead step (AND arm the early-paint timer) instead of firing
|
|
1636
|
+
// the progress cue immediately. The kernel's `stage` SHOWs any prior
|
|
1637
|
+
// pending block (it had nothing reply-shaped after it → pure
|
|
1638
|
+
// narration) then parks THIS one; its eventual SHOW/SUPPRESS is
|
|
1639
|
+
// decided by the next sub_agent_tool_use / sub_agent_turn_end, or by
|
|
1640
|
+
// the early-paint timer if neither arrives within the flush window.
|
|
1641
|
+
// `lastResultText` / `lastSummaryLine` above already updated
|
|
1642
|
+
// unconditionally — the handback payload is independent of the
|
|
1643
|
+
// progress-cue decision. Gate is built lazily on the first block.
|
|
1644
|
+
if (entry.narrativeGate == null) entry.narrativeGate = buildNarrativeGate()
|
|
1645
|
+
entry.narrativeGate.stage(ev.text)
|
|
1411
1646
|
} else if (ev.kind === 'sub_agent_tool_result') {
|
|
1412
1647
|
// The tool call completed — clear it from the in-flight set so
|
|
1413
1648
|
// the terminal-synthesis gate re-opens. Idempotent: a result
|
|
@@ -1510,10 +1745,7 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1510
1745
|
config.silentStallTerminalMs
|
|
1511
1746
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS')
|
|
1512
1747
|
?? DEFAULT_SILENT_STALL_TERMINAL_MS
|
|
1513
|
-
const inflightTerminalCapMs =
|
|
1514
|
-
config.inflightTerminalCapMs
|
|
1515
|
-
?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
|
|
1516
|
-
?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
|
|
1748
|
+
const inflightTerminalCapMs = resolveInflightTerminalCapMs(config.inflightTerminalCapMs)
|
|
1517
1749
|
const deferralLogIntervalMs =
|
|
1518
1750
|
config.deferralLogIntervalMs
|
|
1519
1751
|
?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
|
|
@@ -1862,7 +2094,11 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
1862
2094
|
// worker's card reaches real activity instead of a frozen stub.
|
|
1863
2095
|
entry.toolCount = 0
|
|
1864
2096
|
entry.lastTool = null
|
|
1865
|
-
|
|
2097
|
+
// Cancel any armed early-paint timer + drop pending state before the
|
|
2098
|
+
// from-scratch replay so a stale parked block can't fire against the
|
|
2099
|
+
// rebuilt cursor. Rebuilt lazily on the first replayed sub_agent_text.
|
|
2100
|
+
entry.narrativeGate?.reset()
|
|
2101
|
+
entry.narrativeGate = null
|
|
1866
2102
|
tail.cursor = 0
|
|
1867
2103
|
tail.pendingPartial = ''
|
|
1868
2104
|
tail.hasEmittedStart = false
|
|
@@ -2012,6 +2248,18 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
|
|
|
2012
2248
|
}
|
|
2013
2249
|
terminatedAgentIds.add(agentId)
|
|
2014
2250
|
log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`)
|
|
2251
|
+
// Authoritative terminal sweep → notify the worker-activity feed so its row
|
|
2252
|
+
// for this agent is removed even on the paths that never fire `onFinish`
|
|
2253
|
+
// (JSONL vanished, boot done-at-boot orphan). Without this the feed row
|
|
2254
|
+
// leaks and the shared card goes immortal/unpinned (worker-feed ghost leak).
|
|
2255
|
+
// Best-effort: a callback throw must never wedge the watcher's cleanup.
|
|
2256
|
+
if (config.onTerminalCleanup) {
|
|
2257
|
+
try {
|
|
2258
|
+
config.onTerminalCleanup(agentId)
|
|
2259
|
+
} catch (cbErr) {
|
|
2260
|
+
log?.(`subagent-watcher: onTerminalCleanup callback error ${agentId}: ${(cbErr as Error).message}`)
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2015
2263
|
}
|
|
2016
2264
|
|
|
2017
2265
|
// ─── Card resurrection (issue #3023) ─────────────────────────────────────
|