switchroom 0.18.20 → 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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.18.20", COMMIT_SHA = "f82e440f";
2123
+ var VERSION = "0.18.21", COMMIT_SHA = "c237ff59";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -23799,6 +23799,14 @@ function renderDevProtocolFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23799
23799
  const template = import_handlebars.default.compile(source, { noEscape: true });
23800
23800
  return template(context).trimEnd();
23801
23801
  }
23802
+ function renderDelegationGoldenRuleFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23803
+ const fragPath = join4(resolve4(profilesRoot, "_shared"), "delegation-golden-rule.md.hbs");
23804
+ if (!existsSync7(fragPath))
23805
+ return "";
23806
+ const source = readFileSync7(fragPath, "utf-8");
23807
+ const template = import_handlebars.default.compile(source, { noEscape: true });
23808
+ return template(context).trimEnd();
23809
+ }
23802
23810
  function renderReplyDisciplineFragment(context = {}, profilesRoot = PROFILES_ROOT) {
23803
23811
  const fragPath = join4(resolve4(profilesRoot, "_shared"), "reply-discipline.md.hbs");
23804
23812
  if (!existsSync7(fragPath))
@@ -27579,6 +27587,13 @@ function scaffoldAgent(name, agentConfigRaw, agentsDir, telegramConfig, switchro
27579
27587
  rendered = rendered.trimEnd() + `
27580
27588
 
27581
27589
  ` + devProtocol + `
27590
+ `;
27591
+ }
27592
+ const delegationGoldenRule = renderDelegationGoldenRuleFragment(context);
27593
+ if (delegationGoldenRule) {
27594
+ rendered = rendered.trimEnd() + `
27595
+
27596
+ ` + delegationGoldenRule + `
27582
27597
  `;
27583
27598
  }
27584
27599
  }
@@ -28462,6 +28477,13 @@ ${baseAppend}` : TELEGRAM_FORMATTING_FLOOR_CARD;
28462
28477
  rendered = rendered.trimEnd() + `
28463
28478
 
28464
28479
  ` + devProtocol + `
28480
+ `;
28481
+ }
28482
+ const delegationGoldenRule = renderDelegationGoldenRuleFragment(claudeContext);
28483
+ if (delegationGoldenRule) {
28484
+ rendered = rendered.trimEnd() + `
28485
+
28486
+ ` + delegationGoldenRule + `
28465
28487
  `;
28466
28488
  }
28467
28489
  if (agentConfig.claude_md_raw) {
@@ -78779,6 +78801,7 @@ function applySubagentsSchema(db) {
78779
78801
  db.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
78780
78802
  }
78781
78803
  db.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
78804
+ db.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
78782
78805
  }
78783
78806
  function mapSubagentRow(row) {
78784
78807
  return {
@@ -26605,7 +26605,7 @@ import { existsSync as existsSync9, readFileSync as readFileSync7 } from "node:f
26605
26605
  import { dirname as dirname4, join as join7 } from "node:path";
26606
26606
 
26607
26607
  // src/build-info.ts
26608
- var VERSION = "0.18.20";
26608
+ var VERSION = "0.18.21";
26609
26609
 
26610
26610
  // src/cli/resolve-version.ts
26611
26611
  function readPackageVersion() {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.18.20",
4
+ "version": "0.18.21",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -0,0 +1,9 @@
1
+ ## Delegation — the last word
2
+
3
+ This is the tail reminder on purpose: whatever the grounding, execution-bias, and development-protocol guidance above told you to *do*, the default way to do execution-class work is to **delegate it, not to run it inline.**
4
+
5
+ - **Golden rule: when in doubt, delegate.** Any code change, research spanning 3+ file reads, file/report generation, build/deploy, or a task needing 3+ sequential tool calls without user input goes to a sub-agent (`@worker` for changes, `@researcher` for research, `@reviewer` for review) — per the Sub-Agent Delegation section. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention.
6
+ - **The main session is for conversation.** Keep your own turns short — dispatch and acknowledge. The user should never wait more than 10 seconds for a response. "Act in-turn" is satisfied by dispatching the worker, not by doing the work yourself.
7
+ - **Anti-pattern:** starting a task inline "because it's almost done", then racking up 5+ tool calls in the foreground. If it's execution-class, hand it off at the first tool call, not the fifth.
8
+
9
+ If no sub-agents are configured, do the work yourself.
@@ -2,6 +2,8 @@
2
2
 
3
3
  How development work gets done here — orient, clarify, align, ship, communicate. These are procedural rules for any substantive coding, infra, or debugging task. For the long-form playbook (design reports, adversarial review structure, re-review verdicts), load the bundled `dev-protocol` skill before starting substantive development work.
4
4
 
5
+ **This protocol governs HOW delegated work is done, not a license to do it inline.** Substantive dev work is execution-class — per the Sub-Agent Delegation section it is dispatched to `@worker`/sub-agents so the foreground session stays free for the user. The orient / clarify / design-align / pipeline / communicate rules below bind that delegated work (and the sub-agent doing it); they are not an invitation for the main session to start editing, building, and testing in-turn instead of delegating.
6
+
5
7
  ### Orient — ground before you build
6
8
 
7
9
  - **Validate, don't assume.** Read the actual code, config, and system state before forming a theory. Never assert a fact you haven't checked this turn.
@@ -14,5 +14,5 @@ When you genuinely can't verify something this turn, say so plainly ("I haven't
14
14
 
15
15
  How you should decide what to do next. These are procedural rules, not vibe.
16
16
 
17
- - **Act in-turn.** If the request is actionable, do it this turn. Don't finish with a plan or promise when tools can move it forward.
18
- - **Non-final turn:** use tools to advance, or ask the one clarifying question that unblocks safe progress. One question, not five.
17
+ - **Act in-turn.** If the request is actionable, move it forward this turn. Don't finish with a plan or promise when tools can advance it. **But "act" composes with the Sub-Agent Delegation rules — it does not override them:** for an execution-class task (any code change, research spanning 3+ file reads, multi-step infra, report generation — see that section), the in-turn act IS dispatching the sub-agent, not doing the work inline. Acting immediately and delegating are the same move here; keep your own turn short and let the worker execute.
18
+ - **Non-final turn:** use tools to advance (dispatch the worker when the task is execution-class), or ask the one clarifying question that unblocks safe progress. One question, not five.
@@ -49,7 +49,7 @@ Save proactively: architecture decisions, codebase patterns, conventions, known
49
49
 
50
50
  ## Sub-Agent Delegation
51
51
 
52
- If sub-agents are configured, delegate implementation to `@worker` (background, own worktree) and research to `@researcher` (background). Keep your turns short — dispatch and acknowledge quickly so you stay available for the user.
52
+ **Golden rule: when in doubt, delegate.** The main session is for conversation; execution belongs in sub-agents. If sub-agents are configured, delegate implementation to `@worker` (background, own worktree), research spanning 3+ file reads to `@researcher` (background), and review to `@reviewer`. Unnecessary delegation costs a few tokens; a blocked foreground session costs the user's attention. Keep your turns short — dispatch and acknowledge quickly so you stay available for the user; the user should never wait more than 10 seconds for a response. Acting in-turn on an execution-class task means dispatching the worker, not doing the work inline.
53
53
 
54
54
  If the user amends in-flight delegated work mid-turn, steer the running worker now (`SendMessage` to the worker by name, or by the agent id from its spawn result) instead of holding the update for handback — and say in your reply whether you folded the update into the running worker or queued it as a separate task; never classify silently. If unsure whether a message amends in-flight work, queue it and say so — queue is the default. If the steer lands too late (worker effectively done), say so and apply the update yourself.
55
55
 
@@ -77259,6 +77259,9 @@ function decideSubagentProgress(input) {
77259
77259
  if (isEnvFlagOn(input.disableEnvValue)) {
77260
77260
  return { deliver: false, reason: "env-disabled" };
77261
77261
  }
77262
+ if (input.skeleton === true) {
77263
+ return { deliver: false, reason: "skeleton-liveness" };
77264
+ }
77262
77265
  if (!input.isBackground) {
77263
77266
  return { deliver: false, reason: "foreground" };
77264
77267
  }
@@ -79121,8 +79124,40 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
79121
79124
  tail.cursor = 0;
79122
79125
  tail.pendingPartial = "";
79123
79126
  }
79124
- if (stat.size === tail.cursor)
79127
+ if (stat.size === tail.cursor) {
79128
+ if (onProgress != null && entry.state === "running" && !entry.historical) {
79129
+ let hasChild = false;
79130
+ if (db2 != null) {
79131
+ try {
79132
+ const kid = db2.prepare("SELECT 1 FROM subagents WHERE parent_agent_id = ? LIMIT 1").get(entry.agentId);
79133
+ hasChild = kid != null;
79134
+ } catch (kidErr) {
79135
+ log?.(`subagent-watcher: skeleton child-check error ${entry.agentId}: ${kidErr.message}`);
79136
+ }
79137
+ }
79138
+ if (!hasChild) {
79139
+ try {
79140
+ onProgress({
79141
+ agentId: entry.agentId,
79142
+ description: entry.description,
79143
+ latestSummary: "",
79144
+ elapsedMs: now - entry.dispatchedAt,
79145
+ prevBucketIdx: entry.lastProgressBucketIdx,
79146
+ setBucketIdx: (b) => {
79147
+ entry.lastProgressBucketIdx = b;
79148
+ },
79149
+ lastTool: entry.lastTool,
79150
+ toolCount: entry.toolCount,
79151
+ model: entry.currentModel,
79152
+ skeleton: true
79153
+ });
79154
+ } catch (cbErr) {
79155
+ log?.(`subagent-watcher: onProgress (skeleton) callback error ${entry.agentId}: ${cbErr.message}`);
79156
+ }
79157
+ }
79158
+ }
79125
79159
  return;
79160
+ }
79126
79161
  const buf = Buffer.alloc(stat.size - tail.cursor);
79127
79162
  const fd = fs2.openSync(entry.filePath, "r");
79128
79163
  try {
@@ -82723,10 +82758,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
82723
82758
  }
82724
82759
 
82725
82760
  // ../src/build-info.ts
82726
- var VERSION = "0.18.20";
82727
- var COMMIT_SHA = "f82e440f";
82728
- var COMMIT_DATE = "2026-07-13T22:44:46+10:00";
82729
- var LATEST_PR = 3230;
82761
+ var VERSION = "0.18.21";
82762
+ var COMMIT_SHA = "c237ff59";
82763
+ var COMMIT_DATE = "2026-07-14T01:11:18+10:00";
82764
+ var LATEST_PR = 3234;
82730
82765
  var COMMITS_AHEAD_OF_TAG = 0;
82731
82766
 
82732
82767
  // gateway/boot-version.ts
@@ -84414,6 +84449,7 @@ function applySubagentsSchema(db2) {
84414
84449
  db2.exec("ALTER TABLE subagents ADD COLUMN model TEXT");
84415
84450
  }
84416
84451
  db2.exec("CREATE INDEX IF NOT EXISTS subagents_jsonl_id ON subagents(jsonl_agent_id)");
84452
+ db2.exec("CREATE INDEX IF NOT EXISTS subagents_parent_agent ON subagents(parent_agent_id)");
84417
84453
  }
84418
84454
  function mapSubagentRow(row) {
84419
84455
  return {
@@ -99333,7 +99369,7 @@ var didOneTimeSetup = false;
99333
99369
  process.stderr.write(`telegram gateway: subagent-handback queued agent=${agentId} outcome=${outcome} chat=${decision.chatId} resultChars=${resultText.length}
99334
99370
  `);
99335
99371
  },
99336
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
99372
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
99337
99373
  let fleetChatId = "";
99338
99374
  try {
99339
99375
  const fleets = progressDriver?.peekAllFleets() ?? [];
@@ -99376,6 +99412,8 @@ var didOneTimeSetup = false;
99376
99412
  }
99377
99413
  if (surface !== "nest")
99378
99414
  return;
99415
+ if (skeleton)
99416
+ return;
99379
99417
  const turn = currentTurn;
99380
99418
  if (turn == null)
99381
99419
  return;
@@ -99435,6 +99473,7 @@ var didOneTimeSetup = false;
99435
99473
  }
99436
99474
  const progressOrigin = resolveSubagentOriginChat(agentId);
99437
99475
  const decision = decideSubagentProgress({
99476
+ skeleton: skeleton === true,
99438
99477
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
99439
99478
  isBackground,
99440
99479
  fleetChatId: progressOrigin?.chatId || fleetChatId,
@@ -30542,7 +30542,7 @@ void (async () => {
30542
30542
  // suppresses stale-after-restart delivery (a 4-h-old
30543
30543
  // "still working (5m)" would be a lie). Sweep on handback
30544
30544
  // lives in the `onFinish` block just above.
30545
- onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model }) => {
30545
+ onProgress: ({ agentId, description, latestSummary, elapsedMs, prevBucketIdx, setBucketIdx, lastTool, toolCount, progressLine, model, skeleton }) => {
30546
30546
  let fleetChatId = ''
30547
30547
  try {
30548
30548
  const fleets = progressDriver?.peekAllFleets() ?? []
@@ -30632,6 +30632,15 @@ void (async () => {
30632
30632
  return
30633
30633
  }
30634
30634
  if (surface !== 'nest') return // 'skip' — orphan-status off
30635
+ // #3233: a skeleton liveness cue carries NO step content by
30636
+ // construction (empty latestSummary/progressLine) — it exists
30637
+ // ONLY to create/keep-alive the orphan worker-feed row handled
30638
+ // just above. Branch EXPLICITLY on the `skeleton` discriminator
30639
+ // rather than inferring "no content" from an empty step line:
30640
+ // a skeleton cue must never nest into the parent's live turn
30641
+ // card (there is nothing to render, and the parent's own card
30642
+ // already owns the turn). Deterministic, controls-in-code.
30643
+ if (skeleton) return
30635
30644
  const turn = currentTurn
30636
30645
  if (turn == null) return // defensive: 'nest' implies a live turn
30637
30646
  // Render regardless of `replyCalled` — a foreground Task
@@ -30777,8 +30786,17 @@ void (async () => {
30777
30786
  return
30778
30787
  }
30779
30788
 
30789
+ // #3233: with the worker feed DISABLED, the legacy bucket relay
30790
+ // below injects a synthesized "still working" inbound turn. A
30791
+ // skeleton liveness cue carries an EMPTY latestSummary, so
30792
+ // letting it reach the relay would queue a blank/contentless
30793
+ // progress card. The `skeleton` discriminator is threaded into
30794
+ // the pure decision (gate 1b → 'skeleton-liveness'), which drops
30795
+ // it deterministically (controls-in-code, unit-tested) rather
30796
+ // than an opaque inline return here.
30780
30797
  const progressOrigin = resolveSubagentOriginChat(agentId)
30781
30798
  const decision = decideSubagentProgress({
30799
+ skeleton: skeleton === true,
30782
30800
  disableEnvValue: process.env.SWITCHROOM_DISABLE_SUBAGENT_PROGRESS,
30783
30801
  isBackground,
30784
30802
  // 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')
@@ -267,6 +267,26 @@ describe('nested (depth-2+) worker — end-to-end visibility harness', () => {
267
267
  expect(lastChild.text).not.toContain('starting…')
268
268
  expect(lastChild.text).toContain('index.ts')
269
269
 
270
+ // #3233 — DELIBERATE orchestrator-suppression behaviour. The depth-1
271
+ // 'depth-1 orchestrator' parent ran ZERO tools of its own: it only
272
+ // DISPATCHED the nested child. The skeleton first-paint cue (#3231) fires
273
+ // on every no-growth poll, so a naive implementation gives that pure
274
+ // orchestrator its OWN persistent "starting…" worker-feed row — redundant
275
+ // clutter, because the child's row already carries the liveness. The
276
+ // watcher suppresses the skeleton cue for any entry that has dispatched a
277
+ // child (parent_agent_id linkage), so the orchestrator NEVER earns a
278
+ // "starting…" row while its child provides the live signal. This is NOT a
279
+ // "0 own tools" rule (that would re-break the 205s-blackout incident this
280
+ // PR fixes — a leaf that blocks on its FIRST tool also has 0 completed
281
+ // tools and MUST still paint); it keys strictly on the parent/child link.
282
+ const allFeed = [...h.bot.sent, ...h.bot.edits]
283
+ const orchestratorStartingRows = allFeed.filter(
284
+ (m) => m.text.includes('depth-1 orchestrator') && m.text.includes('starting…'),
285
+ )
286
+ expect(orchestratorStartingRows.length).toBe(0)
287
+ // …while the child (a genuine leaf) DID surface real, live tool activity.
288
+ expect(childMsgs.some((m) => m.text.includes('index.ts'))).toBe(true)
289
+
270
290
  // More tool activity → climbing tool count, still live.
271
291
  h.appendWorker('child01', toolUse('t2', 'Bash', { command: 'ls -la /repo' }))
272
292
  h.advance(1000)
@@ -264,6 +264,36 @@ describe('decideSubagentProgress', () => {
264
264
  if (!d.deliver) expect(d.reason).toBe('foreground')
265
265
  })
266
266
 
267
+ // #3233 — worker-feed-DISABLED legacy path: a contentless skeleton liveness
268
+ // cue must NOT be relayed as a synthesized "still working" inbound (that
269
+ // would be a blank card). It degrades to a no-op, deterministically, BEFORE
270
+ // bucketing so it can never advance the bucket tracker.
271
+ it('skeleton liveness cue is dropped (no blank card) even when every other gate would pass', () => {
272
+ // Same input that DELIVERS in the happy-path test above (bucket 1, chat
273
+ // resolves) — only `skeleton` flips it off. Empty summary mirrors the real
274
+ // skeleton cue.
275
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '' }))
276
+ expect(d.deliver).toBe(false)
277
+ if (!d.deliver) expect(d.reason).toBe('skeleton-liveness')
278
+ })
279
+
280
+ it('skeleton suppression fires before bucketing — a background skeleton at bucket>=1 never delivers', () => {
281
+ // ≤1 relay per interval is trivially satisfied: skeleton cues deliver ZERO
282
+ // inbounds regardless of how many no-growth polls fire within a bucket.
283
+ for (const elapsedMs of [7 * 60 * 1000, 8 * 60 * 1000, 9 * 60 * 1000]) {
284
+ const d = decideSubagentProgress(baseInput({ skeleton: true, latestSummary: '', elapsedMs }))
285
+ expect(d.deliver, `elapsedMs=${elapsedMs}`).toBe(false)
286
+ }
287
+ })
288
+
289
+ it('a NON-skeleton cue with identical inputs still delivers (guard is skeleton-scoped, not summary-scoped)', () => {
290
+ // Red-on-regression companion: proves the drop keys on `skeleton`, not on
291
+ // the empty summary — a real tool-only cue (empty prose summary) still
292
+ // delivers, so the guard cannot silently swallow genuine progress.
293
+ const d = decideSubagentProgress(baseInput({ skeleton: false, latestSummary: '' }))
294
+ expect(d.deliver).toBe(true)
295
+ })
296
+
267
297
  it('falls back to owner chat when fleet chat is empty', () => {
268
298
  const d = decideSubagentProgress(baseInput({ fleetChatId: '' }))
269
299
  expect(d.deliver).toBe(true)
@@ -0,0 +1,171 @@
1
+ /**
2
+ * First-paint independence (#3231) regression.
3
+ *
4
+ * LIVE BUG (a57fbf, 2026-07-13): an async foreground sub-agent registered at
5
+ * 13:17:27, did two Bash calls, then its first tool BLOCKED for ~99s (no JSONL
6
+ * growth). Its spawning turn ended at 13:17:48 while it kept running, so there
7
+ * was neither a live parent turn to nest into NOR a worker-feed row to paint —
8
+ * and because the worker card is driven ONLY by growth-triggered onProgress
9
+ * cues, NOTHING surfaced. The card did not appear until 13:20:52, 205s after
10
+ * registration, on the next growth event that happened to route to the feed.
11
+ *
12
+ * The watcher's contract fix: a running, non-historical entry must emit a
13
+ * growth-INDEPENDENT skeleton liveness cue on every no-growth poll — an empty
14
+ * step line carrying the entry's real current state — so the gateway can paint
15
+ * (and keep alive) the card from registration onward, regardless of whether the
16
+ * worker's JSONL is currently growing. The gateway routes it: inert on the
17
+ * foreground-nest path (empty child), row-creating on the orphan/background
18
+ * worker-feed path.
19
+ *
20
+ * These assert OUTCOMES on the onProgress cue stream with an INJECTED clock.
21
+ * A skeleton cue is identified by its explicit `skeleton: true` discriminator
22
+ * (it also carries an empty `latestSummary` and no `progressLine`).
23
+ *
24
+ * RED-ON-REGRESSION: before the fix, readSubTail early-returns on a no-growth
25
+ * poll BEFORE firing any onProgress cue, so ZERO skeleton cues are emitted and
26
+ * every assertion below fails — reproducing the invisible-card window.
27
+ */
28
+
29
+ import { describe, it, expect, afterEach } from 'vitest'
30
+ import { mkdtempSync, mkdirSync, writeFileSync, appendFileSync, rmSync } from 'fs'
31
+ import { tmpdir } from 'os'
32
+ import { join } from 'path'
33
+ import { startSubagentWatcher } from '../subagent-watcher.js'
34
+
35
+ function buildJSONL(...lines: object[]): string {
36
+ return lines.map((l) => JSON.stringify(l)).join('\n') + '\n'
37
+ }
38
+ function subAgentUserMsg(promptText: string) {
39
+ return { type: 'user', message: { content: [{ type: 'text', text: promptText }] } }
40
+ }
41
+ function subAgentToolUse(name: string, id: string) {
42
+ return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
43
+ }
44
+
45
+ interface Cue { agentId: string; progressLine?: string; latestSummary: string; elapsedMs: number; skeleton?: boolean }
46
+
47
+ describe('sub-agent card first-paint independence (#3231)', () => {
48
+ let tmpRoot = ''
49
+ const started: Array<ReturnType<typeof startSubagentWatcher>> = []
50
+
51
+ afterEach(() => {
52
+ while (started.length) {
53
+ try { started.pop()?.stop() } catch { /* ignore */ }
54
+ }
55
+ if (tmpRoot) {
56
+ try { rmSync(tmpRoot, { recursive: true, force: true }) } catch { /* ignore */ }
57
+ tmpRoot = ''
58
+ }
59
+ })
60
+
61
+ const RESCAN_MS = 1000
62
+
63
+ function startWatcher(agentDir: string) {
64
+ let currentTime = 100_000
65
+ const cues: Cue[] = []
66
+ const intervals: Array<{ fn: () => void; ref: number }> = []
67
+ let nextRef = 1
68
+ const watcher = startSubagentWatcher({
69
+ agentDir,
70
+ onFinish: () => {},
71
+ onProgress: ({ agentId, progressLine, latestSummary, elapsedMs, skeleton }) => {
72
+ cues.push({ agentId, progressLine, latestSummary, elapsedMs, skeleton })
73
+ },
74
+ stallThresholdMs: 600_000,
75
+ silentSynthesisStallThresholdMs: 600_000,
76
+ rescanMs: RESCAN_MS,
77
+ now: () => currentTime,
78
+ setInterval: (fn) => { const ref = nextRef++; intervals.push({ fn, ref }); return { ref } },
79
+ clearInterval: (handle) => {
80
+ const { ref } = handle as { ref: number }
81
+ const idx = intervals.findIndex((i) => i.ref === ref)
82
+ if (idx !== -1) intervals.splice(idx, 1)
83
+ },
84
+ setTimeout: () => ({ ref: nextRef++ }),
85
+ clearTimeout: () => {},
86
+ log: () => {},
87
+ })
88
+ started.push(watcher)
89
+ return {
90
+ watcher,
91
+ cues,
92
+ poll: () => intervals[0]?.fn(),
93
+ advance: (ms: number) => { currentTime += ms },
94
+ now: () => currentTime,
95
+ }
96
+ }
97
+
98
+ const skeletonCues = (cues: Cue[]): Cue[] =>
99
+ cues.filter((c) => c.skeleton === true)
100
+
101
+ function makeSubagentDir(root: string): string {
102
+ const agentDir = join(root, 'agent')
103
+ const subagentsDir = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents')
104
+ mkdirSync(subagentsDir, { recursive: true })
105
+ return agentDir
106
+ }
107
+
108
+ it('emits a growth-independent skeleton cue on the first no-growth poll after registration', () => {
109
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-'))
110
+ const agentDir = makeSubagentDir(tmpRoot)
111
+ const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-deadbeef.jsonl')
112
+
113
+ // Start the watcher on an empty subagents dir, THEN the worker spawns — the
114
+ // real async Agent-tool path (the JSONL appears post-boot, so the entry is
115
+ // live/non-historical, not a boot-time rediscovery). Only its prompt is on
116
+ // disk, no assistant output yet (it is "thinking"): the pre-content window
117
+ // the user stares at.
118
+ const h = startWatcher(agentDir)
119
+ h.poll() // boot scan over the empty dir
120
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
121
+ h.advance(RESCAN_MS)
122
+ h.poll() // discover + register as a live worker
123
+
124
+ // Next poll: the JSONL has NOT grown. Pre-fix, readSubTail early-returns and
125
+ // NO cue fires — the card is invisible. Post-fix, a skeleton cue surfaces so
126
+ // the gateway can paint the card without waiting for the worker's output.
127
+ h.advance(RESCAN_MS)
128
+ h.poll()
129
+
130
+ const skel = skeletonCues(h.cues)
131
+ expect(skel.length, 'a skeleton cue must fire on a no-growth poll').toBeGreaterThanOrEqual(1)
132
+ // Tight bound vs the ~205s live behaviour: first cue is within a couple polls
133
+ // of registration, NOT minutes.
134
+ expect(skel[0].elapsedMs).toBeLessThanOrEqual(2 * RESCAN_MS)
135
+ })
136
+
137
+ it('keeps surfacing skeleton cues while a worker is silent after an early tool burst (blocked first tool)', () => {
138
+ tmpRoot = mkdtempSync(join(tmpdir(), 'sr-firstpaint-silent-'))
139
+ const agentDir = makeSubagentDir(tmpRoot)
140
+ const jsonlPath = join(agentDir, '.claude', 'projects', 'p1', 'session-abc', 'subagents', 'agent-a57fbf00.jsonl')
141
+
142
+ const h = startWatcher(agentDir)
143
+ h.poll() // boot scan over the empty dir
144
+ writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Debug delegation regression')))
145
+ h.advance(RESCAN_MS)
146
+ h.poll() // discover + register as a live worker
147
+
148
+ // The worker does two quick Bash calls (the a57fbf shape) …
149
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b1')))
150
+ h.advance(RESCAN_MS)
151
+ h.poll()
152
+ appendFileSync(jsonlPath, buildJSONL(subAgentToolUse('Bash', 'b2')))
153
+ h.advance(RESCAN_MS)
154
+ h.poll()
155
+
156
+ // … then its tool BLOCKS: no JSONL growth for a long stretch (~30 polls).
157
+ // The card must NOT go dark — a skeleton cue must fire on essentially every
158
+ // no-growth poll so the feed row is created/kept-alive and the heartbeat can
159
+ // climb the elapsed. Pre-fix, zero cues fire across the entire silent window.
160
+ const before = h.cues.length
161
+ for (let i = 0; i < 30; i++) {
162
+ h.advance(RESCAN_MS)
163
+ h.poll()
164
+ }
165
+ const duringSilence = h.cues.slice(before)
166
+ const skel = duringSilence.filter((c) => c.skeleton === true)
167
+ expect(skel.length, 'silent worker must keep emitting skeleton cues').toBeGreaterThanOrEqual(20)
168
+ // No skeleton cue ever fabricates content — the step line stays empty.
169
+ for (const c of skel) expect(c.latestSummary).toBe('')
170
+ })
171
+ })
@@ -39,7 +39,7 @@ function subAgentToolUse(name: string, id: string) {
39
39
  return { type: 'assistant', message: { content: [{ type: 'tool_use', name, id, input: {} }] } }
40
40
  }
41
41
 
42
- interface Cue { progressLine?: string; latestSummary: string }
42
+ interface Cue { progressLine?: string; latestSummary: string; skeleton?: boolean }
43
43
 
44
44
  describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
45
45
  let tmpRoot = ''
@@ -64,8 +64,8 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
64
64
  const watcher = startSubagentWatcher({
65
65
  agentDir,
66
66
  onFinish: () => {},
67
- onProgress: ({ agentId, progressLine, latestSummary }) => {
68
- cues.push({ agentId, progressLine, latestSummary })
67
+ onProgress: ({ agentId, progressLine, latestSummary, skeleton }) => {
68
+ cues.push({ agentId, progressLine, latestSummary, skeleton })
69
69
  },
70
70
  stallThresholdMs: 600_000,
71
71
  silentSynthesisStallThresholdMs: 600_000,
@@ -91,8 +91,10 @@ describe('worker/sub-agent opening-narration early paint (Residual A)', () => {
91
91
  }
92
92
 
93
93
  function narrativeCues(cues: Cue[]): string[] {
94
- // Narrative cues carry NO progressLine; tool-label cues do.
95
- return cues.filter((c) => c.progressLine == null).map((c) => c.latestSummary)
94
+ // Narrative cues carry NO progressLine; tool-label cues do. Skeleton
95
+ // liveness cues (#3231) also carry no progressLine but an empty summary —
96
+ // exclude them here so this counts only real narrative content.
97
+ return cues.filter((c) => !c.skeleton && c.progressLine == null).map((c) => c.latestSummary)
96
98
  }
97
99
 
98
100
  it('paints a parked opening narration after the flush window with NO tool event', () => {
@@ -584,9 +584,10 @@ describe('startSubagentWatcher', () => {
584
584
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
585
585
  const h = startWatcherSync({
586
586
  agentDir,
587
- onProgress: ({ progressLine, latestSummary }) => {
587
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
588
588
  // Narrative ticks carry NO progressLine (tool ticks do); record them.
589
- if (progressLine == null) narrativeCues.push(latestSummary)
589
+ // Skeleton liveness cues (#3231) are not narrative — exclude them.
590
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
590
591
  },
591
592
  })
592
593
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo path')))
@@ -614,8 +615,8 @@ describe('startSubagentWatcher', () => {
614
615
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
615
616
  const h = startWatcherSync({
616
617
  agentDir,
617
- onProgress: ({ progressLine, latestSummary }) => {
618
- if (progressLine == null) narrativeCues.push(latestSummary)
618
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
619
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
619
620
  },
620
621
  })
621
622
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
@@ -647,8 +648,8 @@ describe('startSubagentWatcher', () => {
647
648
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
648
649
  const h = startWatcherSync({
649
650
  agentDir,
650
- onProgress: ({ progressLine, latestSummary }) => {
651
- allCues.push({ progressLine, latestSummary })
651
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
652
+ if (!skeleton) allCues.push({ progressLine, latestSummary })
652
653
  },
653
654
  })
654
655
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Find the repo')))
@@ -698,8 +699,8 @@ describe('startSubagentWatcher', () => {
698
699
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
699
700
  const h = startWatcherSync({
700
701
  agentDir,
701
- onProgress: ({ progressLine, latestSummary }) => {
702
- if (progressLine == null) narrativeCues.push(latestSummary)
702
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
703
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
703
704
  },
704
705
  })
705
706
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Do the task')))
@@ -728,8 +729,8 @@ describe('startSubagentWatcher', () => {
728
729
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
729
730
  const h = startWatcherSync({
730
731
  agentDir,
731
- onProgress: ({ progressLine, latestSummary }) => {
732
- if (progressLine == null) narrativeCues.push(latestSummary)
732
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
733
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
733
734
  },
734
735
  })
735
736
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
@@ -761,8 +762,8 @@ describe('startSubagentWatcher', () => {
761
762
  const jsonlPath = join(subagentsDir, 'agent-deadbeef.jsonl')
762
763
  const h = startWatcherSync({
763
764
  agentDir,
764
- onProgress: ({ progressLine, latestSummary }) => {
765
- if (progressLine == null) narrativeCues.push(latestSummary)
765
+ onProgress: ({ progressLine, latestSummary, skeleton }) => {
766
+ if (!skeleton && progressLine == null) narrativeCues.push(latestSummary)
766
767
  },
767
768
  })
768
769
  writeFileSync(jsonlPath, buildJSONL(subAgentUserMsg('Summarise the diff')))
@@ -87,6 +87,46 @@ describe('worker terminal state is truthful for reaped workers (Residual B)', ()
87
87
  expect(last).not.toContain('incomplete')
88
88
  })
89
89
 
90
+ // #3233 terminal-window race: the skeleton first-paint cue fires on EVERY
91
+ // no-growth poll, so one can land on a row that `finish()` just finalized
92
+ // (watcher poll cadence ≈ seconds). The `finalized` latch must absorb it —
93
+ // a late skeleton `update()` must NOT resurrect a fresh `running` card on an
94
+ // already-done worker. This exercises that path with a skeleton-shaped cue
95
+ // (empty step line, running state) arriving after finish.
96
+ it('a late skeleton update() after finish() is absorbed by the finalized latch (no resurrection)', async () => {
97
+ let clock = 1000
98
+ const { feed, edits } = makeFeed(() => clock)
99
+ await feed.update('w', 'chat', runningView('background job', 'doing work', 1000))
100
+ await drain()
101
+ clock = 2000
102
+ await feed.finish('w', {
103
+ description: 'background job',
104
+ lastTool: null,
105
+ toolCount: 5,
106
+ latestSummary: 'the delivered result paragraph',
107
+ elapsedMs: 2000,
108
+ state: 'done',
109
+ })
110
+ await drain()
111
+ const editsAfterFinish = edits.length
112
+ // Late skeleton cue (empty latestSummary, running state) — the shape the
113
+ // watcher emits on a no-growth poll — lands AFTER finalization.
114
+ clock = 2500
115
+ await feed.update('w', 'chat', {
116
+ description: 'background job',
117
+ lastTool: null,
118
+ toolCount: 5,
119
+ latestSummary: '',
120
+ elapsedMs: 2500,
121
+ state: 'running',
122
+ })
123
+ await drain()
124
+ // No further edit, no resurrection: the terminal card stays `done`.
125
+ expect(edits.length).toBe(editsAfterFinish)
126
+ expect(edits[edits.length - 1].text).toContain('done')
127
+ expect(feed.has('w')).toBe(false)
128
+ })
129
+
90
130
  it('renderWorkerActivity renders the `incomplete` state as a finished card without a fabricated result', () => {
91
131
  const card = renderWorkerActivity({
92
132
  description: 'background job',