switchroom 0.17.10 → 0.18.3

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 (61) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -40,7 +40,12 @@ import { describe, it, expect } from 'vitest'
40
40
  import * as realFs from 'fs'
41
41
  import { startSubagentWatcher } from '../subagent-watcher.js'
42
42
  import { mayOpenActivityCard } from '../gateway/feed-open-gate.js'
43
- import { clipNarrative, appendActivityLabel } from '../tool-activity-summary.js'
43
+ import {
44
+ clipNarrative,
45
+ appendActivityLabel,
46
+ renderActivityFeedWithNested,
47
+ formatStepSuffix,
48
+ } from '../tool-activity-summary.js'
44
49
  import { evaluatePostAnswerLiveness } from '../turn-liveness-floor.js'
45
50
  import {
46
51
  createWorkerActivityFeed,
@@ -249,6 +254,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
249
254
  finalAnswerDeliveredAt: currentTurn!.finalAnswerDeliveredAt,
250
255
  now: currentTime + 5, // a heartbeat tick moments after the stamp
251
256
  staleCapMs: 30_000,
257
+ stillDispatched: false,
252
258
  })
253
259
  expect(verdictInWindow).toBe('emit')
254
260
  })
@@ -262,6 +268,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
262
268
  finalAnswerDeliveredAt: 1000,
263
269
  now: 50_000,
264
270
  staleCapMs: 30_000,
271
+ stillDispatched: false,
265
272
  })
266
273
  expect(verdict).toBe('idle')
267
274
  })
@@ -273,6 +280,7 @@ describe('Fix 2: post-answer background-agent liveness (watcher → gate → liv
273
280
  finalAnswerDeliveredAt: 1000,
274
281
  now: 1500,
275
282
  staleCapMs: 30_000,
283
+ stillDispatched: false,
276
284
  }),
277
285
  ).toBe('idle')
278
286
  })
@@ -310,9 +318,14 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
310
318
 
311
319
  // The gateway's feedHeartbeatTick post-answer entry, reduced to its decision:
312
320
  // `if (turn == null) return` (no-turn), else the REAL evaluatePostAnswerLiveness.
321
+ // `stillDispatched` defaults to false (a purely-background worker, no
322
+ // foreground `turn.foregroundSubAgents` tracking) so these existing tests
323
+ // keep proving the ORIGINAL staleness-cap behaviour is preserved for that
324
+ // case; a dedicated Fix-3 describe block below covers `stillDispatched: true`.
313
325
  function heartbeatVerdict(
314
326
  currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
315
327
  now: number,
328
+ stillDispatched = false,
316
329
  ): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
317
330
  const turn = currentTurn
318
331
  if (turn == null) return 'no-turn' // gateway: `if (turn == null) return`
@@ -322,6 +335,7 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
322
335
  finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
323
336
  now,
324
337
  staleCapMs: 30_000,
338
+ stillDispatched,
325
339
  })
326
340
  }
327
341
 
@@ -377,6 +391,146 @@ describe('Fix 2 / concern 2: currentTurn nulls at turn_end → heartbeat path in
377
391
  })
378
392
  })
379
393
 
394
+ // ─── Fix 3 — sub-agent-delegation freeze (operator-confirmed: card frozen on
395
+ // "Running a command" for 41s / 3m12s while a sub-agent ran underneath) ──
396
+
397
+ describe('Fix 3: a still-tracked foreground sub-agent bypasses the staleness cap (no mid-delegation freeze)', () => {
398
+ /**
399
+ * Reproduces the EXACT scenario the operator's 3 screenshots showed: a
400
+ * foreground `Task`/`Agent` dispatch is still outstanding (one long silent
401
+ * step — no new watcher narrative tick), so `subagentActivityAt` stops
402
+ * advancing, yet the worker has NOT reported finished. Before Fix 3 the
403
+ * staleness cap could not tell this apart from genuine completion and froze
404
+ * the card after 30s regardless. `stillDispatched: true` — sourced from
405
+ * `turn.foregroundSubAgents.size > 0` at the real call site — is the
406
+ * positive signal that closes the gap.
407
+ */
408
+
409
+ // Local copy of the gateway's post-answer decision (same as the "Fix 2 /
410
+ // concern 2" describe block above — duplicated here rather than hoisted to
411
+ // module scope so each describe stays self-contained/readable).
412
+ function heartbeatVerdict(
413
+ currentTurn: { finalAnswerDelivered: boolean; finalAnswerDeliveredAt?: number; subagentActivityAt?: number } | null,
414
+ now: number,
415
+ stillDispatched = false,
416
+ ): 'no-turn' | 'pre-answer' | ReturnType<typeof evaluatePostAnswerLiveness> {
417
+ const turn = currentTurn
418
+ if (turn == null) return 'no-turn'
419
+ if (!turn.finalAnswerDelivered) return 'pre-answer'
420
+ return evaluatePostAnswerLiveness({
421
+ subagentActivityAt: turn.subagentActivityAt,
422
+ finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
423
+ now,
424
+ staleCapMs: 30_000,
425
+ stillDispatched,
426
+ })
427
+ }
428
+
429
+ it('long silent single step (41s, no new narrative) — bypasses the cap while still dispatched', () => {
430
+ const turn = {
431
+ finalAnswerDelivered: true,
432
+ finalAnswerDeliveredAt: 1000,
433
+ subagentActivityAt: 2000 as number | undefined,
434
+ }
435
+ const currentTurn: typeof turn | null = turn
436
+ // 41s after the last (and only) narrative advance — well past the 30s cap.
437
+ // Legacy (stillDispatched: false) behaviour would already be 'stale' here
438
+ // (proven by the concern-3 test above); with a tracked foreground worker
439
+ // it must stay 'emit' so the card keeps climbing instead of freezing.
440
+ expect(heartbeatVerdict(currentTurn, 2000 + 41_000, /* stillDispatched */ true)).toBe('emit')
441
+ })
442
+
443
+ it('long silent single step (3m12s) — still bypasses the cap while still dispatched', () => {
444
+ const turn = {
445
+ finalAnswerDelivered: true,
446
+ finalAnswerDeliveredAt: 1000,
447
+ subagentActivityAt: 2000 as number | undefined,
448
+ }
449
+ const currentTurn: typeof turn | null = turn
450
+ const threeMinTwelveSecMs = 3 * 60_000 + 12_000
451
+ expect(heartbeatVerdict(currentTurn, 2000 + threeMinTwelveSecMs, true)).toBe('emit')
452
+ })
453
+
454
+ it('once the foreground sub-agent is no longer tracked (finished), the cap re-applies normally', () => {
455
+ // The gateway removes the agentId from `turn.foregroundSubAgents` when it
456
+ // finishes (gateway.ts ~27032/27037) — `stillDispatched` then reads false
457
+ // again and the ORIGINAL runaway-climb protection re-engages exactly as
458
+ // before Fix 3. This is the adversarial check that Fix 3 doesn't remove
459
+ // the cap's protection outright, only bypasses it while genuinely unknown.
460
+ const turn = {
461
+ finalAnswerDelivered: true,
462
+ finalAnswerDeliveredAt: 1000,
463
+ subagentActivityAt: 2000 as number | undefined,
464
+ }
465
+ const currentTurn: typeof turn | null = turn
466
+ expect(heartbeatVerdict(currentTurn, 2000 + 41_000, false)).toBe('stale')
467
+ })
468
+
469
+ it('idle-gap suppression still wins over stillDispatched (no watcher activity ever ⇒ silent, reply-is-last preserved)', () => {
470
+ // Adversarial check: a turn that dispatched a foreground sub-agent but has
471
+ // had NO watcher tick at all since the answer (subagentActivityAt
472
+ // undefined) must stay 'idle', never 'emit', even if stillDispatched is
473
+ // true — a still-registered worker with zero ticks yet is not the same as
474
+ // one whose last tick went stale. This preserves the reply-is-last
475
+ // invariant for a turn that answered and dispatched but hasn't surfaced
476
+ // any post-answer step yet.
477
+ const turn = {
478
+ finalAnswerDelivered: true,
479
+ finalAnswerDeliveredAt: 1000,
480
+ subagentActivityAt: undefined as number | undefined,
481
+ }
482
+ const currentTurn: typeof turn | null = turn
483
+ expect(heartbeatVerdict(currentTurn, 50_000, true)).toBe('idle')
484
+ })
485
+
486
+ it('end-to-end: the RENDERED card text keeps changing across real heartbeat ticks while a foreground sub-agent is silently working (not just the verdict function)', () => {
487
+ // Full simulated turn: dispatch a foreground Task, one narrative tick
488
+ // lands, then NOTHING further for over three minutes (one long silent
489
+ // Bash inside the sub-agent) — the exact operator-reported shape. Drives
490
+ // the REAL `evaluatePostAnswerLiveness` verdict AND the REAL
491
+ // `renderActivityFeedWithNested`/`composeTurnActivity`-shaped render at
492
+ // each tick, asserting the VISIBLE text (not an internal label) actually
493
+ // climbs instead of freezing.
494
+ const turn = {
495
+ finalAnswerDelivered: true,
496
+ finalAnswerDeliveredAt: 1_000,
497
+ subagentActivityAt: 2_000 as number | undefined,
498
+ mirrorLines: ['Delegating to a sub-agent'],
499
+ foregroundSubAgents: new Map<string, string[]>([['agent-1', ['Running a command']]]),
500
+ }
501
+
502
+ function renderCardAt(now: number): string | null {
503
+ const verdict = evaluatePostAnswerLiveness({
504
+ subagentActivityAt: turn.subagentActivityAt,
505
+ finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
506
+ now,
507
+ staleCapMs: 30_000,
508
+ stillDispatched: turn.foregroundSubAgents.size > 0,
509
+ })
510
+ if (verdict !== 'emit') return null // the pre-Fix-3 freeze point
511
+ const childLines = [...turn.foregroundSubAgents.values()].flat()
512
+ const header = { label: 'Agent', elapsedMs: now - 0, toolCount: 1, state: 'running' as const }
513
+ return renderActivityFeedWithNested(turn.mirrorLines, childLines, false, formatStepSuffix(now - turn.subagentActivityAt!), undefined, header)
514
+ }
515
+
516
+ const tick1 = renderCardAt(2_000 + 6_000) // 6s after the last tick
517
+ const tick2 = renderCardAt(2_000 + 41_000) // 41s — the first operator-reported freeze point
518
+ const tick3 = renderCardAt(2_000 + (3 * 60_000 + 12_000)) // 3m12s — the second
519
+
520
+ expect(tick1).not.toBeNull()
521
+ expect(tick2).not.toBeNull()
522
+ expect(tick3).not.toBeNull()
523
+ // The visible text must actually differ tick-over-tick (climbing elapsed),
524
+ // not be frozen at the same string for minutes.
525
+ expect(tick2).not.toBe(tick1)
526
+ expect(tick3).not.toBe(tick2)
527
+ // And it must contain the growing wall-clock evidence the operator was
528
+ // missing — a live elapsed suffix on the nested step.
529
+ expect(tick2).toContain('41s')
530
+ expect(tick3).toContain('3m12s')
531
+ })
532
+ })
533
+
380
534
  // ─── Fix 2 — concern 2 resolution: the decoupled worker surfaces via the
381
535
  // currentTurn-INDEPENDENT workerActivityFeed (and is bounded) ────────────
382
536
 
@@ -9,14 +9,17 @@
9
9
  * Run with:
10
10
  * bun test telegram-plugin/tests/worktree-watch-cwds.test.ts
11
11
  */
12
- import { describe, it, expect } from "vitest";
12
+ import { describe, it, expect, beforeEach } from "vitest";
13
13
  import {
14
14
  ownedWorktreeCwds,
15
+ __resetIdentityEscalationForTests,
15
16
  type WorktreeOwnershipRecord,
16
17
  } from "../worktree-watch-cwds.js";
17
18
 
18
19
  const idPath = (p: string) => p; // identity realpath for deterministic tests
19
20
 
21
+ beforeEach(() => __resetIdentityEscalationForTests());
22
+
20
23
  describe("ownedWorktreeCwds", () => {
21
24
  const records: WorktreeOwnershipRecord[] = [
22
25
  { path: "/wt/mine-1", ownerAgent: "klanker" },
@@ -25,18 +28,110 @@ describe("ownedWorktreeCwds", () => {
25
28
  { path: "/wt/ownerless" }, // ownerAgent undefined
26
29
  ];
27
30
 
28
- it("returns NOTHING when the agent identity is unset (fail-closed, no #1116 leak)", () => {
31
+ it("returns NOTHING when the agent identity is unset and no durable fallback given (fail-closed, no #1116 leak)", () => {
29
32
  expect(
30
33
  ownedWorktreeCwds({ self: undefined, listRecords: () => records, realpath: idPath }),
31
34
  ).toEqual([]);
32
35
  });
33
36
 
34
- it("returns NOTHING when the agent identity is empty string", () => {
37
+ it("returns NOTHING when the agent identity is empty string and no durable fallback given", () => {
35
38
  expect(
36
39
  ownedWorktreeCwds({ self: "", listRecords: () => records, realpath: idPath }),
37
40
  ).toEqual([]);
38
41
  });
39
42
 
43
+ // ---- Layer 2: durable, non-env identity fallback (#1116 / #2893) ----
44
+
45
+ it("(a) env SET → ownership resolves exactly as before (fast path unchanged)", () => {
46
+ expect(
47
+ ownedWorktreeCwds({
48
+ self: "klanker",
49
+ agentDir: "/home/x/.switchroom/agents/SHOULD_BE_IGNORED",
50
+ listRecords: () => records,
51
+ realpath: idPath,
52
+ }),
53
+ ).toEqual(["/wt/mine-1", "/wt/mine-2"]);
54
+ });
55
+
56
+ it("(b) env UNSET but agentDir present → identity derived from dir basename, ownership resolves", () => {
57
+ const out = ownedWorktreeCwds({
58
+ self: undefined,
59
+ agentDir: "/home/x/.switchroom/agents/klanker",
60
+ listRecords: () => records,
61
+ realpath: idPath,
62
+ });
63
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
64
+ });
65
+
66
+ it("(b) env EMPTY but agentDir present → same durable derivation", () => {
67
+ const out = ownedWorktreeCwds({
68
+ self: "",
69
+ agentDir: "/home/x/.switchroom/agents/klanker",
70
+ listRecords: () => records,
71
+ realpath: idPath,
72
+ });
73
+ expect(out).toEqual(["/wt/mine-1", "/wt/mine-2"]);
74
+ });
75
+
76
+ it("(b) durable fallback NEVER mis-attributes: a dir for a different agent matches only THAT agent's records, never klanker's", () => {
77
+ // agentDir names 'reggie' → resolves reggie's worktrees, never klanker's
78
+ // or the ownerless ones. A wrong basename fails-closed to [], it can never
79
+ // attribute to the WRONG owner.
80
+ const out = ownedWorktreeCwds({
81
+ self: undefined,
82
+ agentDir: "/home/x/.switchroom/agents/reggie",
83
+ listRecords: () => records,
84
+ realpath: idPath,
85
+ });
86
+ expect(out).toEqual(["/wt/theirs"]);
87
+ expect(out).not.toContain("/wt/ownerless");
88
+ });
89
+
90
+ it("(c) BOTH env and agentDir unavailable → returns [] AND emits an escalated ERROR (no throw, no mis-attribution)", () => {
91
+ const logs: string[] = [];
92
+ const out = ownedWorktreeCwds({
93
+ self: undefined,
94
+ agentDir: undefined,
95
+ listRecords: () => records,
96
+ realpath: idPath,
97
+ log: (m) => logs.push(m),
98
+ });
99
+ expect(out).toEqual([]);
100
+ expect(logs).toHaveLength(1);
101
+ expect(logs[0]).toContain("ERROR");
102
+ expect(logs[0]).toContain("identity resolution FAILED");
103
+ });
104
+
105
+ it("(c) blank agentDir (whitespace) is treated as unavailable → [] + escalated error", () => {
106
+ const logs: string[] = [];
107
+ const out = ownedWorktreeCwds({
108
+ self: "",
109
+ agentDir: " ",
110
+ listRecords: () => records,
111
+ realpath: idPath,
112
+ log: (m) => logs.push(m),
113
+ });
114
+ expect(out).toEqual([]);
115
+ expect(logs).toHaveLength(1);
116
+ expect(logs[0]).toContain("identity resolution FAILED");
117
+ });
118
+
119
+ it("(c) the escalated error is one-shot per process (not re-logged every rescan tick)", () => {
120
+ const logs: string[] = [];
121
+ const call = () =>
122
+ ownedWorktreeCwds({
123
+ self: undefined,
124
+ agentDir: undefined,
125
+ listRecords: () => records,
126
+ realpath: idPath,
127
+ log: (m) => logs.push(m),
128
+ });
129
+ call();
130
+ call();
131
+ call();
132
+ expect(logs).toHaveLength(1);
133
+ });
134
+
40
135
  it("does NOT match ownerless records even when identity is set", () => {
41
136
  const out = ownedWorktreeCwds({ self: "klanker", listRecords: () => records, realpath: idPath });
42
137
  expect(out).not.toContain("/wt/ownerless");
@@ -227,13 +227,47 @@ export interface PostAnswerLivenessInput {
227
227
  now: number
228
228
  /** Staleness cap in ms; `<= 0` disables the cap. */
229
229
  staleCapMs: number
230
+ /**
231
+ * A POSITIVE, independent signal that a sub-agent dispatch is still known to
232
+ * be outstanding (e.g. `turn.foregroundSubAgents.size > 0` — a foreground
233
+ * `Task`/`Agent` this turn dispatched and has not yet reported finished).
234
+ *
235
+ * ## The gap this closes
236
+ *
237
+ * The staleness cap above was built to stop the card climbing FOREVER once
238
+ * a worker's `onFinish` froze `subagentActivityAt` and nothing further would
239
+ * ever arrive — but the ONLY signal it read (`now - subagentActivityAt`) is
240
+ * identical whether the worker (a) actually finished, or (b) is still
241
+ * genuinely running a SINGLE long silent step (one long Bash call, a slow
242
+ * fetch) that simply hasn't produced a NEW distinguishable watcher tick.
243
+ * Case (b) is exactly the scenario `feed-heartbeat-climb.ts`'s 0-label climb
244
+ * and `worker-activity-feed.ts`'s own heartbeat both exist to handle
245
+ * deterministically elsewhere — this post-answer branch alone lacked that
246
+ * fallback, so it froze the card mid-delegation (the confirmed operator
247
+ * symptom: "Running a command" stuck for 41s / 3m12s while a sub-agent ran
248
+ * underneath).
249
+ *
250
+ * When `stillDispatched` is `true` we have POSITIVE evidence the worker has
251
+ * not reported completion, so the staleness cap is bypassed entirely and the
252
+ * verdict stays `'emit'` — the caller keeps climbing the card deterministically
253
+ * off wall-clock elapsed exactly like the sibling 0-label/worker-feed paths.
254
+ * When `false` (no such tracking available — e.g. a purely-background worker
255
+ * with no foreground registration), the ORIGINAL cap behaviour is preserved
256
+ * unchanged, so the runaway-climb-after-completion protection this cap was
257
+ * built for still applies wherever we have no better signal.
258
+ */
259
+ stillDispatched: boolean
230
260
  }
231
261
 
232
262
  export function evaluatePostAnswerLiveness(input: PostAnswerLivenessInput): PostAnswerLivenessVerdict {
233
- const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs } = input
263
+ const { subagentActivityAt, finalAnswerDeliveredAt, now, staleCapMs, stillDispatched } = input
234
264
  const answeredAt = finalAnswerDeliveredAt ?? 0
235
265
  // idle-gap: nothing surfaced after the answer → silent (reply-is-last preserved).
236
266
  if (subagentActivityAt == null || subagentActivityAt <= answeredAt) return 'idle'
267
+ // A positive "still dispatched" signal overrides the staleness cap — we KNOW
268
+ // the worker hasn't reported done, so a quiet stretch is a long silent step,
269
+ // not completion. Never freeze the card while that's true.
270
+ if (stillDispatched) return 'emit'
237
271
  // staleness cap: the worker's last advance is older than the cap → stop emitting.
238
272
  if (staleCapMs > 0 && now - subagentActivityAt >= staleCapMs) return 'stale'
239
273
  return 'emit'
@@ -56,9 +56,20 @@
56
56
  import { describe, it, expect } from "vitest";
57
57
  import { spinUp } from "../harness.js";
58
58
  import type { ObservedMessage } from "../driver.js";
59
+ import { richRenderEnabled } from "../../render/rich-render.js";
59
60
 
60
61
  const AGENT = "test-harness";
61
62
 
63
+ // The rich-render wiring (parse -> IR -> renderSafe -> sendRichMessage) is
64
+ // gated behind `SWITCHROOM_RICH_RENDER` (default OFF). The expandable /
65
+ // collapsible round-trip proof below only runs when BOTH the driver creds AND
66
+ // the flag are present — the flag must be set in the gateway process under
67
+ // test for the renderer to actually shape the outbound message, so gating the
68
+ // scenario on the same flag keeps it honest (no false green when the wiring
69
+ // isn't live). When the flag is off, this scenario self-skips green exactly
70
+ // like the credential-less case.
71
+ const RICH_RENDER_ON = richRenderEnabled();
72
+
62
73
  // The driver session is the load-bearing credential. Absent it, spinUp()
63
74
  // throws in resolveConfig — so guard at describe level and self-skip green.
64
75
  const HAS_DRIVER_CREDS =
@@ -105,13 +116,18 @@ const SAMPLE_LINES = [
105
116
  * trip on the uat-host (PR #2745 first live run: present set was
106
117
  * `bold, italic, code, strikethrough, text_link, blockquote, pre`).
107
118
  *
108
- * `spoiler` is deliberately NOT hard-asserted: on the live wire the `||…||`
109
- * span did NOT surface as a `spoiler`/`textMarked` entity (the model either
110
- * dropped the syntax or Telegram's chat-message GFM parser doesn't map it the
111
- * way the IV table-of-contents `textMarked` node does). Rather than red the
112
- * whole render gate on a construct the round trip doesn't reliably produce, we
113
- * observe spoiler softly (logged below). The decoder's `textMarked spoiler`
114
- * mapping is still pinned deterministically by the hosted unit suite.
119
+ * `spoiler` is soft-observed HERE only because of THIS harness's decode path:
120
+ * the `||…||` span did NOT reliably surface as a `spoiler`/`textMarked` entity
121
+ * through the MTProto send→IV→decode round trip the UAT driver uses. This is a
122
+ * harness-decoder limitation, NOT a wire failure: a 2026-07 direct probe of the
123
+ * Bot API `sendRichMessage` endpoint (the actual production send path) confirmed
124
+ * `||spoiler||` DOES parse to a `spoiler` entity and `==highlight==` to `marked`
125
+ * on the live wire see `reference/rfcs/telegram-native-formatting.md` §6a.
126
+ * Spoiler ships default-on; it stays soft here purely so this MTProto-decode
127
+ * gate doesn't red on its own decode gap. (Underline `__…__`, by contrast, is a
128
+ * genuine wire exclusion — it parses as bold; see §6a.) The decoder's
129
+ * `textMarked → spoiler` mapping is still pinned deterministically by the
130
+ * hosted unit suite.
115
131
  *
116
132
  * Lists and dividers carry no first-class Bot API entity (they render as
117
133
  * bulleted/numbered/rule TEXT), so they are asserted on `reply.text` below.
@@ -252,3 +268,79 @@ function kindsPresent(msg: ObservedMessage): Set<string> {
252
268
  );
253
269
  },
254
270
  );
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Rich-render wiring proof — expandable / collapsible content round-trip.
274
+ //
275
+ // This is the piece the unit suite CANNOT prove: that a REAL markdown reply
276
+ // carrying the Bot API 10.1 expandable-blockquote marker (`**> `) flows
277
+ // through the live send path — parse.ts (marker -> IR `expandable: true`) ->
278
+ // render.ts (IR -> `**> ` markdown) -> renderSafe -> sendRichMessage — and
279
+ // that Telegram actually parses it back to a blockquote entity on the wire.
280
+ //
281
+ // Runs ONLY when the rich-render flag is on in this process (see
282
+ // RICH_RENDER_ON) AND the driver creds are present; self-skips green
283
+ // otherwise, so default CI (flag off) never reds on it.
284
+ // ---------------------------------------------------------------------------
285
+
286
+ const EXP_MARKER = "COLLAPSE9";
287
+ const EXPANDABLE_SAMPLE = [
288
+ `Reply with EXACTLY this and NOTHING else, keeping ${EXP_MARKER} verbatim:`,
289
+ "",
290
+ `${EXP_MARKER}: here is a collapsible section.`,
291
+ "",
292
+ "**> first hidden line of the expandable quote",
293
+ "> second hidden line",
294
+ "> third hidden line",
295
+ ].join("\n");
296
+
297
+ (HAS_DRIVER_CREDS && RICH_RENDER_ON ? describe : describe.skip)(
298
+ "uat: expandable/collapsible content round-trips through the live renderer",
299
+ () => {
300
+ it(
301
+ "a `**>` reply parses -> renders -> sends -> decodes as a blockquote entity",
302
+ async () => {
303
+ const sc = await spinUp({ agent: AGENT });
304
+ try {
305
+ await sc.sendDM(EXPANDABLE_SAMPLE);
306
+
307
+ const reply = await sc.expectMessage(
308
+ (m: ObservedMessage) =>
309
+ m.text.includes(EXP_MARKER) || m.text === "\x01",
310
+ { from: "bot", timeout: 90_000 },
311
+ );
312
+
313
+ // Decode regression gate (same as the primary scenario).
314
+ expect(
315
+ reply.text,
316
+ "expandable reply decoded as the \\x01 unsupported-media sentinel",
317
+ ).not.toBe("\x01");
318
+ expect(reply.text).toContain(EXP_MARKER);
319
+
320
+ // The collapsible construct must survive as a real blockquote entity
321
+ // on the wire — proof the parse->render->send path produced valid
322
+ // Bot API 10.1 markdown, not stray `**>` literal text.
323
+ const present = kindsPresent(reply);
324
+ expect(
325
+ [...present],
326
+ `expandable quote did not decode as a blockquote entity ` +
327
+ `(present kinds: ${[...present].join(", ")})`,
328
+ ).toContain("blockquote");
329
+
330
+ // The quoted body text must round-trip (content never lost).
331
+ expect(reply.text).toContain("first hidden line");
332
+
333
+ console.info(
334
+ "[uat] expandable round-trip entity structure: " +
335
+ JSON.stringify(
336
+ reply.entities.map((e) => ({ kind: e.kind, text: e.text })),
337
+ ),
338
+ );
339
+ } finally {
340
+ await sc.tearDown();
341
+ }
342
+ },
343
+ 120_000,
344
+ );
345
+ },
346
+ );
@@ -1,29 +1,52 @@
1
1
  /**
2
2
  * Ownership filter for the worktree-isolated cwds the subagent-watcher should
3
3
  * additionally watch (deterministic-turn-liveness.md Known Gap 2 + the #2893
4
- * ownership-predicate review fix).
4
+ * ownership-predicate review fix + the #1116 / #2893 durable-identity fix).
5
5
  *
6
6
  * A sub-agent dispatched into a `switchroom worktree claim` cwd runs under a
7
7
  * different project-dir slug than the agent's own `agentCwd`, so the #1116
8
8
  * foreign-slug filter would skip it forever unless the watcher also watches
9
9
  * the slugs of worktrees THIS agent owns. This helper derives that set from
10
- * the host-global worktree registry, and it is deliberately fail-CLOSED:
10
+ * the host-global worktree registry, filtered by the agent's own identity.
11
11
  *
12
- * - Unset/empty identity contribute NOTHING. `ownerAgent` is optional in
13
- * the registry, so a naive `r.ownerAgent === process.env.SWITCHROOM_AGENT_NAME`
14
- * with the env var unset becomes `undefined === undefined` and matches
15
- * every OWNERLESS record in the host-global registry — including other
16
- * agents' worktrees. That is precisely the #1116 leak the filter exists to
17
- * prevent, reintroduced by failing OPEN. With no identity we cannot prove
18
- * ownership of anything, so we return `[]`.
19
- * - A registry read failure `[]` (best-effort; never disturb the base
20
- * agentCwd watch).
21
- * - Owner match include, realpath'd. Claude Code mints the project slug off
22
- * the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp`
12
+ * Identity resolution is two-tier (durable fix for the gap where a worktree
13
+ * worker whose identity can't be attributed gets NO live progress feed):
14
+ *
15
+ * 1. FAST PATH `self` (`process.env.SWITCHROOM_AGENT_NAME`). Set
16
+ * authoritatively by compose env (compose.ts) AND hoisted in start.sh
17
+ * before the gateway fork, so this is present in the overwhelming
18
+ * majority of runs.
19
+ * 2. DURABLE FALLBACK when `self` is unset/empty, derive the identity
20
+ * from `agentDir` (the agent's own directory, itself derived from
21
+ * `TELEGRAM_STATE_DIR` = `<agentDir>/telegram`, which the gateway
22
+ * already requires to be present before it even starts the watcher).
23
+ * The basename of `agentDir` is `resolve(agents_dir, <name>)`'s leaf —
24
+ * i.e. this agent's OWN name. This can only ever resolve to THIS
25
+ * agent's identity, never another agent's, so it cannot mis-attribute:
26
+ * a wrong basename matches zero registry records (fail-closed), it
27
+ * never matches a DIFFERENT owner. Env is just the fast path; ownership
28
+ * resolves correctly from durable config when env is missing.
29
+ *
30
+ * Fail-CLOSED, deliberately, and never mis-attributing:
31
+ *
32
+ * - Owner match ⇒ include, realpath'd. Claude Code mints the project slug
33
+ * off the process's PHYSICAL cwd, so a symlinked base (macOS `/tmp` →
23
34
  * `/private/tmp`) would otherwise derive a slug that misses the physical
24
35
  * one; realpath best-effort, falling back to the raw path.
36
+ * - Ownerless registry records (`ownerAgent` undefined) are NEVER matched,
37
+ * even with identity set — a naive `undefined === undefined` would leak
38
+ * every other agent's ownerless worktree (the #1116 leak this exists to
39
+ * prevent).
40
+ * - A registry read failure ⇒ `[]` (best-effort; never disturb the base
41
+ * agentCwd watch).
42
+ * - BOTH env AND agentDir-derived identity unavailable ⇒ `[]` (same
43
+ * fail-closed contract as before this fix — we never guess) but escalate
44
+ * the log from the #2893 one-shot warn to a clear ERROR naming that
45
+ * identity resolution fully failed, so the lost live feed is diagnosable.
46
+ * Never throws, never mis-attributes.
25
47
  */
26
48
  import { realpathSync } from "node:fs";
49
+ import { basename } from "node:path";
27
50
 
28
51
  export interface WorktreeOwnershipRecord {
29
52
  path: string;
@@ -31,22 +54,74 @@ export interface WorktreeOwnershipRecord {
31
54
  }
32
55
 
33
56
  export interface OwnedWorktreeCwdsOptions {
34
- /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME`. */
57
+ /** The agent's identity — `process.env.SWITCHROOM_AGENT_NAME` (fast path). */
35
58
  self: string | undefined;
36
59
  /** The host-global registry read (`listRecords` from src/worktree/registry). */
37
60
  listRecords: () => WorktreeOwnershipRecord[];
61
+ /**
62
+ * Durable, non-env fallback source for identity: the agent's OWN directory
63
+ * (`resolveAgentDirFromEnv()` in the gateway). When `self` is unset/empty,
64
+ * the identity is derived as `basename(agentDir)`. Omit to disable the
65
+ * fallback (the pre-fix, env-only behaviour — used by the kill-switch).
66
+ */
67
+ agentDir?: string | null;
38
68
  /** Injectable for tests; defaults to `fs.realpathSync`. */
39
69
  realpath?: (p: string) => string;
70
+ /**
71
+ * Injectable derivation of the agent name from `agentDir`. Defaults to
72
+ * `path.basename`. Returns "" when it cannot derive a usable name.
73
+ */
74
+ deriveName?: (agentDir: string) => string;
75
+ /** Escalated-failure sink (both identity sources unavailable). */
76
+ log?: (msg: string) => void;
77
+ }
78
+
79
+ // One-shot guard so the escalated "identity fully unresolved" ERROR is emitted
80
+ // ONCE per process rather than every rescan tick (the provider is re-invoked on
81
+ // every tick). Mirrors the #2893 one-shot-warn ethos; exported reset for tests.
82
+ let identityEscalated = false;
83
+ export function __resetIdentityEscalationForTests(): void {
84
+ identityEscalated = false;
85
+ }
86
+
87
+ function defaultDeriveName(agentDir: string): string {
88
+ if (!agentDir || agentDir.trim().length === 0) return "";
89
+ const leaf = basename(agentDir).trim();
90
+ return leaf;
40
91
  }
41
92
 
42
93
  export function ownedWorktreeCwds(opts: OwnedWorktreeCwdsOptions): string[] {
43
- const { self } = opts;
44
- if (self == null || self === "") return [];
94
+ // Tier 1: env fast path. Tier 2: durable agentDir-derived fallback.
95
+ let resolved: string = opts.self != null ? opts.self : "";
96
+ if (resolved === "" && opts.agentDir != null && opts.agentDir !== "") {
97
+ const derive = opts.deriveName ?? defaultDeriveName;
98
+ resolved = derive(opts.agentDir) || "";
99
+ }
100
+
101
+ if (resolved === "") {
102
+ // Both env and durable config unavailable. Keep the historical
103
+ // fail-closed contract (return [] — never guess, never mis-attribute) but
104
+ // ESCALATE past the #2893 one-shot warn: name that identity resolution
105
+ // fully failed and the live worktree-worker feed is lost for this run.
106
+ if (!identityEscalated) {
107
+ identityEscalated = true;
108
+ opts.log?.(
109
+ "ERROR: worktree identity resolution FAILED — both " +
110
+ "SWITCHROOM_AGENT_NAME and the agentDir-derived fallback are " +
111
+ "unavailable. Worktree ownership cannot be attributed; a " +
112
+ "worktree-isolated background sub-agent will get NO live progress " +
113
+ "feed this run (its registry row is still reaped by the 1h safety " +
114
+ "net). This is a configuration fault, not a transient error.",
115
+ );
116
+ }
117
+ return [];
118
+ }
119
+
45
120
  const rp = opts.realpath ?? realpathSync;
46
121
  try {
47
122
  return opts
48
123
  .listRecords()
49
- .filter((r) => r.ownerAgent === self)
124
+ .filter((r) => r.ownerAgent === resolved)
50
125
  .map((r) => {
51
126
  try {
52
127
  return rp(r.path);