switchroom 0.18.20 → 0.18.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/dist/cli/switchroom.js +24 -1
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_shared/delegation-golden-rule.md.hbs +9 -0
  5. package/profiles/_shared/dev-protocol.md.hbs +2 -0
  6. package/profiles/_shared/execution-discipline.md.hbs +2 -2
  7. package/profiles/coding/CLAUDE.md.hbs +1 -1
  8. package/telegram-plugin/dist/gateway/gateway.js +268 -61
  9. package/telegram-plugin/flushed-turn-supersede.ts +230 -0
  10. package/telegram-plugin/gateway/gateway.ts +88 -1
  11. package/telegram-plugin/gateway/subagent-progress-inbound-builder.ts +17 -0
  12. package/telegram-plugin/registry/subagents-schema.ts +6 -0
  13. package/telegram-plugin/subagent-watcher.ts +86 -1
  14. package/telegram-plugin/tests/flushed-turn-supersede.test.ts +206 -0
  15. package/telegram-plugin/tests/nested-worker-visibility-harness.test.ts +20 -0
  16. package/telegram-plugin/tests/subagent-progress-inbound-builder.test.ts +30 -0
  17. package/telegram-plugin/tests/subagent-watcher-first-paint-independence.test.ts +171 -0
  18. package/telegram-plugin/tests/subagent-watcher-narrative-early-paint.test.ts +7 -5
  19. package/telegram-plugin/tests/subagent-watcher.test.ts +13 -12
  20. package/telegram-plugin/tests/turn-flush-safety.test.ts +71 -0
  21. package/telegram-plugin/tests/worker-activity-feed.test.ts +13 -8
  22. package/telegram-plugin/tests/worker-feed-coalesce.test.ts +196 -0
  23. package/telegram-plugin/tests/worker-feed-terminal-state-truthful.test.ts +40 -0
  24. package/telegram-plugin/turn-flush-safety.ts +74 -1
  25. package/telegram-plugin/worker-activity-feed.ts +155 -45
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Turn-flush supersede registry (2026-07 duplicate-reply fix).
3
+ *
4
+ * The duplicate-reply class this closes:
5
+ *
6
+ * 1. A turn-flush (the answer-ready quiescence flush OR the older turn-end
7
+ * backstop) posts the model's terminal text as a Telegram message, because
8
+ * the turn appeared to end without a `reply` tool call.
9
+ * 2. ~10 s later the model's REAL `reply` tool call lands (it was still
10
+ * composing it when the flush fired, or claude-code replayed an un-acked
11
+ * tool_call after a bridge reconnect).
12
+ * 3. The gateway sends that as a SECOND message → the user sees a duplicate.
13
+ *
14
+ * The pre-existing `OutboundDedupCache` did NOT catch this because it matches on
15
+ * EXACT normalised-text equality: a flush that dumped `narration\n\nanswer`
16
+ * never equals the clean `answer`-only reply, so the containment case slipped
17
+ * through (216 occurrences in older logs via the turn-end backstop alone).
18
+ *
19
+ * This registry SUBSTANTIALLY REDUCES the class, keyed on the turn IDENTITY
20
+ * (the per-turn `turnId` nonce) rather than on text. When a flush sends, it
21
+ * records `{ turnId, messageIds }` for the chat/thread. When a `reply` for the
22
+ * SAME turn later lands, the gateway SUPERSEDES the flushed message(s) — deletes
23
+ * them and lets the canonical reply send path deliver exactly one clean message
24
+ * (delete+resend; the caller may also edit-in-place). A reply for a DIFFERENT
25
+ * (newer) turn never supersedes — that turn owns its own message.
26
+ *
27
+ * NOT fully deterministic — a residual race remains. The flush→reply direction
28
+ * this registry covers, plus the controller's fire-time recount for the
29
+ * reply→flush direction, close the COMMON ~10 s replay-gap case; but if a reply
30
+ * and a flush interleave so the reply's `take()` runs BEFORE the flush's
31
+ * `record()` (a much smaller window), `take` finds no record and the duplicate
32
+ * can still slip through. We deliberately trade that residual window for the
33
+ * safety guarantee that we NEVER delete a message we cannot positively attribute
34
+ * to the reply's own turn (identity-only supersede — see `decideSupersede`).
35
+ *
36
+ * Pure module: no I/O, no globals, no clock reads beyond the caller-supplied
37
+ * `now`. Fully unit-testable; the gateway wires the actual delete/send.
38
+ */
39
+
40
+ /** TTL after which a recorded flush is forgotten. 60 s comfortably spans the
41
+ * observed ~10 s flush→reply replay gap with margin, and matches the outbound
42
+ * dedup window so the two mechanisms age out together. */
43
+ export const DEFAULT_SUPERSEDE_TTL_MS = 60_000
44
+
45
+ export interface FlushedTurnRecord {
46
+ /** The per-turn `turnId` nonce (`deriveTurnId` shape) of the flushed turn.
47
+ * Keying on this — NOT the stable `chatId:threadId` statusKey — is what makes
48
+ * the supersede turn-identity-scoped: a later, unrelated turn on the same
49
+ * chat has a different `turnId` and must NOT clobber this record's message. */
50
+ turnId: string | null
51
+ /** The Telegram message id(s) the flush posted (edit target + any extra
52
+ * chunk messages). Superseding deletes all of them. */
53
+ messageIds: number[]
54
+ /** The text the flush delivered — retained for diagnostics/logging only. The
55
+ * supersede decision NEVER compares text (that is the whole point: it fires
56
+ * even when the flushed text differs from the reply text). */
57
+ text: string
58
+ /** Wall-clock ms when recorded. */
59
+ ts: number
60
+ }
61
+
62
+ /**
63
+ * The supersede decision for a landing reply. Pure so the gateway runs the
64
+ * exact code the regression tests exercise (`gateway.ts` is not importable in
65
+ * tests — the repo's `decideTurnFlush` / `decideCapturedProseDelivery` pattern).
66
+ */
67
+ export interface SupersedeDecision {
68
+ /** True → the reply belongs to an already-flushed turn; the gateway must
69
+ * delete `deleteMessageIds` and deliver the reply as the single message. */
70
+ supersede: boolean
71
+ /** Message ids the gateway must delete before/instead of the fresh send.
72
+ * Empty when `supersede` is false. */
73
+ deleteMessageIds: number[]
74
+ /** Machine-readable reason (for logs / tests). */
75
+ reason: 'supersede' | 'no-record' | 'expired' | 'different-turn'
76
+ }
77
+
78
+ /**
79
+ * Decide whether a landing reply supersedes a recorded flush.
80
+ *
81
+ * Supersede IFF the record is present, fresh (within TTL), AND positively
82
+ * attributable to the reply's turn by IDENTITY:
83
+ * - a turnId-bearing record is superseded ONLY by a reply whose resolved
84
+ * `liveTurnId` equals `record.turnId`;
85
+ * - a null-turnId record (a synthetic/no-nonce flush) is superseded ONLY by a
86
+ * reply that ALSO has no resolvable turn (`liveTurnId == null`).
87
+ *
88
+ * Crucially, a reply with `liveTurnId == null` does NOT supersede a
89
+ * turnId-bearing record: we never delete a message we cannot positively
90
+ * attribute to the reply's own turn. (The earlier revision superseded ANY
91
+ * record on a null live turn, which — combined with a single overwriting lane
92
+ * slot — could late-delete a DIFFERENT turn's legitimate message. The gateway
93
+ * now resolves a last-known turnId for the reply before calling in, so the
94
+ * common late-replay case still matches by identity rather than relying on the
95
+ * promiscuous null branch.)
96
+ */
97
+ export function decideSupersede(
98
+ record: FlushedTurnRecord | undefined,
99
+ args: { liveTurnId: string | null; now: number; ttlMs?: number },
100
+ ): SupersedeDecision {
101
+ const ttlMs = args.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
102
+ if (record == null) return { supersede: false, deleteMessageIds: [], reason: 'no-record' }
103
+ if (args.now - record.ts > ttlMs) {
104
+ return { supersede: false, deleteMessageIds: [], reason: 'expired' }
105
+ }
106
+ const sameTurn =
107
+ record.turnId != null
108
+ ? record.turnId === args.liveTurnId
109
+ : args.liveTurnId == null
110
+ if (!sameTurn) {
111
+ return { supersede: false, deleteMessageIds: [], reason: 'different-turn' }
112
+ }
113
+ return { supersede: true, deleteMessageIds: [...record.messageIds], reason: 'supersede' }
114
+ }
115
+
116
+ /** Sentinel key for records whose flush carried no turnId nonce. */
117
+ const NULL_TURN_KEY = '<<null-turn>>'
118
+
119
+ function turnKey(turnId: string | null): string {
120
+ return turnId == null ? NULL_TURN_KEY : turnId
121
+ }
122
+
123
+ /**
124
+ * In-memory registry of recently-flushed turns, keyed by `chatId|threadId` and
125
+ * then by the flush's `turnId` nonce WITHIN that lane. Holding a per-turnId map
126
+ * (rather than a single overwriting slot) is what makes the identity guarantee
127
+ * airtight: two turns can each flush a message on the same lane, and each turn's
128
+ * later reply supersedes ONLY its own flushed message — turn B's reply can never
129
+ * reach turn A's record, and a late turn-A reply can never reach turn B's.
130
+ *
131
+ * Bounded by TTL eviction (swept on every `record`); chat count per gateway is
132
+ * small and a lane holds at most a handful of concurrent-turn records.
133
+ */
134
+ export class FlushedTurnSupersedeRegistry {
135
+ private readonly entries = new Map<string, Map<string, FlushedTurnRecord>>()
136
+ private readonly ttlMs: number
137
+
138
+ constructor(opts: { ttlMs?: number } = {}) {
139
+ this.ttlMs = opts.ttlMs ?? DEFAULT_SUPERSEDE_TTL_MS
140
+ }
141
+
142
+ /** Record a flush's posted message(s) so a later same-turn reply supersedes
143
+ * them, keyed on the flush's `turnId` within the chat/thread lane. No record
144
+ * is kept when the flush posted zero messages. Sweeps expired records first
145
+ * (lightweight active GC — LOW-2) so orphan records never accumulate. */
146
+ record(
147
+ chatId: string,
148
+ threadId: number | undefined,
149
+ rec: { turnId: string | null; messageIds: number[]; text: string },
150
+ now: number,
151
+ ): void {
152
+ if (rec.messageIds.length === 0) return
153
+ this.sweep(now)
154
+ const lane = makeKey(chatId, threadId)
155
+ let laneMap = this.entries.get(lane)
156
+ if (laneMap == null) {
157
+ laneMap = new Map<string, FlushedTurnRecord>()
158
+ this.entries.set(lane, laneMap)
159
+ }
160
+ laneMap.set(turnKey(rec.turnId), {
161
+ turnId: rec.turnId,
162
+ messageIds: [...rec.messageIds],
163
+ text: rec.text,
164
+ ts: now,
165
+ })
166
+ }
167
+
168
+ /** Decide supersede for a landing reply WITHOUT consuming the record. Selects
169
+ * the record whose turnId matches the reply's resolved `liveTurnId` (or the
170
+ * null-turnId record when `liveTurnId == null`). */
171
+ peek(
172
+ chatId: string,
173
+ threadId: number | undefined,
174
+ args: { liveTurnId: string | null; now: number },
175
+ ): SupersedeDecision {
176
+ const rec = this.entries.get(makeKey(chatId, threadId))?.get(turnKey(args.liveTurnId))
177
+ return decideSupersede(rec, { liveTurnId: args.liveTurnId, now: args.now, ttlMs: this.ttlMs })
178
+ }
179
+
180
+ /** Decide supersede AND, on a supersede, consume the matched record (so a
181
+ * second replay of the same reply doesn't try to delete the same — now gone —
182
+ * messages again). Returns the same decision `peek` would. */
183
+ take(
184
+ chatId: string,
185
+ threadId: number | undefined,
186
+ args: { liveTurnId: string | null; now: number },
187
+ ): SupersedeDecision {
188
+ const lane = makeKey(chatId, threadId)
189
+ const decision = this.peek(chatId, threadId, args)
190
+ if (decision.supersede) {
191
+ const laneMap = this.entries.get(lane)
192
+ laneMap?.delete(turnKey(args.liveTurnId))
193
+ if (laneMap != null && laneMap.size === 0) this.entries.delete(lane)
194
+ }
195
+ return decision
196
+ }
197
+
198
+ /** Drop all records for a chat/thread (e.g. after the flushed message was
199
+ * deleted through another path). Idempotent. */
200
+ forget(chatId: string, threadId: number | undefined): void {
201
+ this.entries.delete(makeKey(chatId, threadId))
202
+ }
203
+
204
+ /** Test-only: clear all entries. */
205
+ clear(): void {
206
+ this.entries.clear()
207
+ }
208
+
209
+ /** Evict every record past its TTL and prune emptied lanes. */
210
+ private sweep(now: number): void {
211
+ for (const [lane, laneMap] of this.entries) {
212
+ for (const [tk, rec] of laneMap) {
213
+ if (now - rec.ts > this.ttlMs) laneMap.delete(tk)
214
+ }
215
+ if (laneMap.size === 0) this.entries.delete(lane)
216
+ }
217
+ }
218
+
219
+ /** Test-only: live record count after TTL eviction. */
220
+ size(now: number): number {
221
+ this.sweep(now)
222
+ let total = 0
223
+ for (const laneMap of this.entries.values()) total += laneMap.size
224
+ return total
225
+ }
226
+ }
227
+
228
+ function makeKey(chatId: string, threadId: number | undefined): string {
229
+ return threadId == null ? chatId : `${chatId}|${threadId}`
230
+ }
@@ -88,6 +88,7 @@ import {
88
88
  type TelegraphAccount,
89
89
  } from '../telegraph.js'
90
90
  import { OutboundDedupCache } from '../recent-outbound-dedup.js'
91
+ import { FlushedTurnSupersedeRegistry } from '../flushed-turn-supersede.js'
91
92
  import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
92
93
  import {
93
94
  splitCoalescedAttachments,
@@ -2220,6 +2221,14 @@ const deferredDoneReactions = new DeferredDoneReactions<StatusReactionController
2220
2221
  // path threw `outboundDedup is not defined` at runtime, blocking ALL outbound
2221
2222
  // from the agent. Restore the module-level singleton here.
2222
2223
  const outboundDedup = new OutboundDedupCache()
2224
+ // 2026-07 duplicate-reply fix — turnId-keyed supersede. When a turn-flush
2225
+ // (answer-ready quiescence OR the turn-end backstop) posts the model's terminal
2226
+ // text and the model's REAL `reply` for the SAME turn lands later, the reply
2227
+ // SUPERSEDES the flushed message (delete + canonical resend) instead of shipping
2228
+ // a second message. Keyed on the per-turn `turnId` nonce, NOT on text — so it
2229
+ // catches the containment case the exact-text `outboundDedup` misses (a
2230
+ // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2231
+ const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2223
2232
  /**
2224
2233
  * Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
2225
2234
  * the FIRST message in a chat creates a controller without the filter (null
@@ -12902,6 +12911,50 @@ async function executeReply(args: Record<string, unknown>): Promise<{ content: A
12902
12911
  }
12903
12912
  }
12904
12913
 
12914
+ // 2026-07 duplicate-reply fix — turnId-keyed supersede (consumption side).
12915
+ // If an earlier turn-flush (answer-ready quiescence OR the turn-end backstop)
12916
+ // already posted THIS turn's terminal text and the model's REAL `reply` for
12917
+ // the same turn is landing now (it was still composing the tool call when the
12918
+ // flush fired, or claude-code replayed the tool_call after a bridge
12919
+ // reconnect), delete the flushed message(s) so the canonical reply below
12920
+ // delivers exactly one clean message instead of a second one. Keyed on the
12921
+ // per-turn `turnId` nonce, so it fires even when the flushed narration+answer
12922
+ // blob differs from the clean answer-only reply — the containment case the
12923
+ // exact-text `outboundDedup` above structurally cannot catch. A reply for a
12924
+ // DIFFERENT newer live turn never supersedes (decideSupersede → different-turn),
12925
+ // so a fresh turn's answer is never clobbered.
12926
+ {
12927
+ const replyThreadId = args.message_thread_id != null ? Number(args.message_thread_id) : undefined
12928
+ // Resolve the turnId this reply belongs to by IDENTITY, not just the live
12929
+ // `currentTurn`. A late reply lands with `currentTurn == null` (silence poke
12930
+ // cleared it — Bug D) or with a transiently-cleared currentTurn while its own
12931
+ // turn is really still the owner (LOW-1); in both cases the turn is still
12932
+ // resolvable from the `origin_turn_id` nonce the model echoes back (Tier 2 —
12933
+ // the same last-known-turn resolver the chat-routing / obligation code uses).
12934
+ // Passing this resolved turnId means supersede matches the flushed record by
12935
+ // identity instead of falling back to a null-liveTurnId branch that would
12936
+ // otherwise be free to delete a DIFFERENT turn's legitimate message.
12937
+ const resolvedTurnId =
12938
+ turn?.turnId ?? findTurnByOriginId(args.origin_turn_id as string | undefined)?.turnId ?? null
12939
+ const decision = flushedTurnSupersede.take(
12940
+ chat_id,
12941
+ replyThreadId,
12942
+ { liveTurnId: resolvedTurnId, now: Date.now() },
12943
+ )
12944
+ if (decision.supersede) {
12945
+ process.stderr.write(
12946
+ `telegram gateway: reply: superseding flushed turn message(s) ` +
12947
+ `chatId=${chat_id} ids=${JSON.stringify(decision.deleteMessageIds)}\n`,
12948
+ )
12949
+ for (const id of decision.deleteMessageIds) {
12950
+ await swallowingApiCall(
12951
+ () => lockedBot.api.deleteMessage(chat_id, id),
12952
+ { chat_id, verb: 'reply.supersedeFlushed' },
12953
+ )
12954
+ }
12955
+ }
12956
+ }
12957
+
12905
12958
  const files = (args.files as string[] | undefined) ?? []
12906
12959
  const quoteOptIn = args.quote !== false
12907
12960
  let reply_to = args.reply_to != null ? Number(args.reply_to) : undefined
@@ -18264,6 +18317,22 @@ function handleSessionEvent(ev: SessionEvent): void {
18264
18317
  Date.now(),
18265
18318
  currentTurn?.registryKey ?? null,
18266
18319
  )
18320
+ // 2026-07 duplicate-reply fix — record the flushed message id(s)
18321
+ // keyed on THIS turn's `turnId` nonce so a late `reply` for the same
18322
+ // turn supersedes them (delete + canonical resend) instead of
18323
+ // shipping a duplicate. `turn` is the ending turn atom captured at
18324
+ // the top of this branch (endCurrentTurnAtomic nulled currentTurn,
18325
+ // but the captured `turn` still carries the honest turnId). Covers
18326
+ // BOTH flush paths — answer-ready quiescence and the turn-end
18327
+ // backstop both funnel through this single IIFE.
18328
+ if (sentIds.length > 0) {
18329
+ flushedTurnSupersede.record(
18330
+ backstopChatId,
18331
+ backstopThreadId,
18332
+ { turnId: turn.turnId, messageIds: sentIds, text: capturedText },
18333
+ Date.now(),
18334
+ )
18335
+ }
18267
18336
  // #1713: route the backstop terminal through finalize() —
18268
18337
  // single terminal path keeps the controller contract clean.
18269
18338
  if (backstopCtrl) backstopCtrl.finalize('done')
@@ -30542,7 +30611,7 @@ void (async () => {
30542
30611
  // suppresses stale-after-restart delivery (a 4-h-old
30543
30612
  // "still working (5m)" would be a lie). Sweep on handback
30544
30613
  // lives in the `onFinish` block just above.
30545
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
30614
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
30546
30615
  let fleetChatId = ''
30547
30616
  try {
30548
30617
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -30632,6 +30701,15 @@ void (async () => {
30632
30701
  return
30633
30702
  }
30634
30703
  if (surface !== 'nest') return // 'skip' — orphan-status off
30704
+ // #3233: a skeleton liveness cue carries NO step content by
30705
+ // construction (empty latestSummary/progressLine) — it exists
30706
+ // ONLY to create/keep-alive the orphan worker-feed row handled
30707
+ // just above. Branch EXPLICITLY on the `skeleton` discriminator
30708
+ // rather than inferring "no content" from an empty step line:
30709
+ // a skeleton cue must never nest into the parent's live turn
30710
+ // card (there is nothing to render, and the parent's own card
30711
+ // already owns the turn). Deterministic, controls-in-code.
30712
+ if (skeleton) return
30635
30713
  const turn = currentTurn
30636
30714
  if (turn == null) return // defensive: 'nest' implies a live turn
30637
30715
  // Render regardless of `replyCalled` — a foreground Task
@@ -30777,8 +30855,17 @@ void (async () => {
30777
30855
  return
30778
30856
  }
30779
30857
 
30858
+ // #3233: with the worker feed DISABLED, the legacy bucket relay
30859
+ // below injects a synthesized "still working" inbound turn. A
30860
+ // skeleton liveness cue carries an EMPTY latestSummary, so
30861
+ // letting it reach the relay would queue a blank/contentless
30862
+ // progress card. The `skeleton` discriminator is threaded into
30863
+ // the pure decision (gate 1b → 'skeleton-liveness'), which drops
30864
+ // it deterministically (controls-in-code, unit-tested) rather
30865
+ // than an opaque inline return here.
30780
30866
  const progressOrigin = resolveSubagentOriginChat(agentId)
30781
30867
  const decision = decideSubagentProgress({
30868
+ skeleton: skeleton === true,
30782
30869
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
30783
30870
  isBackground,
30784
30871
  // Prefer the conversation the Task was dispatched from over
@@ -176,12 +176,20 @@ export interface SubagentProgressDecisionInput {
176
176
  * passes it in; the decision returns the new bucket idx on
177
177
  * `deliver: true` so the caller can update its tracker. */
178
178
  lastBucketIdx: number | null
179
+ /** #3233: true for a growth-independent SKELETON liveness cue (empty
180
+ * `latestSummary`, no step content). It exists ONLY to first-paint /
181
+ * keep-alive the in-message worker-feed row; the legacy bucket relay would
182
+ * turn it into a synthesized "still working" inbound with no content — a
183
+ * blank card. Suppressed deterministically here so the worker-feed-DISABLED
184
+ * path degrades to a no-op rather than a blank envelope. */
185
+ skeleton?: boolean
179
186
  /** Deterministic clock for tests. */
180
187
  nowMs?: number
181
188
  }
182
189
 
183
190
  export type SubagentProgressSkipReason =
184
191
  | 'env-disabled'
192
+ | 'skeleton-liveness'
185
193
  | 'foreground'
186
194
  | 'no-chat'
187
195
  | 'bucket-already-fired'
@@ -199,6 +207,8 @@ export type SubagentProgressDecision =
199
207
  *
200
208
  * Gates, in order:
201
209
  * 1. kill-switch — `SWITCHROOM_DISABLE_SUBAGENT_PROGRESS=1` disables.
210
+ * 1b. skeleton-liveness (#3233) — a contentless skeleton cue is never
211
+ * relayed as a synthesized inbound (worker-feed row only).
202
212
  * 2. foreground — foreground sub-agents stream natively.
203
213
  * 3. no-chat — nowhere to deliver.
204
214
  * 4. missing-jsonl-id — the dedup key. Without it we'd lose
@@ -233,6 +243,13 @@ export function decideSubagentProgress(
233
243
  if (isEnvFlagOn(input.disableEnvValue)) {
234
244
  return { deliver: false, reason: 'env-disabled' }
235
245
  }
246
+ // #3233: a skeleton liveness cue carries no step content — never relay it as
247
+ // a synthesized progress inbound (that would be a blank card). Its whole job
248
+ // is the in-message worker-feed row; when that surface is off, degrade to a
249
+ // no-op. Checked before bucketing so it can never advance the bucket tracker.
250
+ if (input.skeleton === true) {
251
+ return { deliver: false, reason: 'skeleton-liveness' }
252
+ }
236
253
  if (!input.isBackground) {
237
254
  return { deliver: false, reason: 'foreground' }
238
255
  }
@@ -277,6 +277,12 @@ export function applySubagentsSchema(db: SqliteDatabase): void {
277
277
  // column is guaranteed to exist (either created with the table or added by
278
278
  // the migration above).
279
279
  db.exec('CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)')
280
+ // Same deferred-index rationale as jsonl_agent_id above: parent_agent_id is
281
+ // added by the ALTER migration for pre-existing tables, so its index must be
282
+ // created here (after the column is guaranteed to exist), not in the base SQL.
283
+ // Backs the per-poll child-existence probe in subagent-watcher.ts
284
+ // (`SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1`).
285
+ db.exec('CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)')
280
286
  }
281
287
 
282
288
  // ---------------------------------------------------------------------------
@@ -628,6 +628,12 @@ export interface SubagentWatcherConfig {
628
628
  * assistant line — the gateway then falls back to the registry's
629
629
  * dispatch-time model. */
630
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
631
637
  }) => void
632
638
  /** `Date.now` override for tests. */
633
639
  now?: () => number
@@ -1088,6 +1094,9 @@ export function readSubTail(
1088
1094
  progressLine?: string
1089
1095
  /** Live model this worker is running (see SubagentWatcherConfig.onProgress). */
1090
1096
  model?: string
1097
+ /** Growth-independent skeleton liveness cue (#3231). See the identically
1098
+ * named field on SubagentWatcherConfig.onProgress. */
1099
+ skeleton?: boolean
1091
1100
  }) => void,
1092
1101
  ): void {
1093
1102
  try {
@@ -1103,7 +1112,83 @@ export function readSubTail(
1103
1112
  tail.cursor = 0
1104
1113
  tail.pendingPartial = ''
1105
1114
  }
1106
- if (stat.size === tail.cursor) return
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
+ }
1107
1192
 
1108
1193
  const buf = Buffer.alloc(stat.size - tail.cursor)
1109
1194
  const fd = fs.openSync(entry.filePath, 'r')