switchroom 0.20.9 → 0.20.11

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 (59) hide show
  1. package/bin/handoff-briefing.sh +57 -5
  2. package/bin/working-state-reload-hook.sh +262 -0
  3. package/dist/agent-scheduler/index.js +65 -2
  4. package/dist/auth-broker/index.js +204 -24
  5. package/dist/cli/notion-write-pretool.mjs +65 -2
  6. package/dist/cli/self-improve-apply-guard-pretool.mjs +357 -92
  7. package/dist/cli/self-improve-stop.mjs +889 -7
  8. package/dist/cli/skill-validate-pretool.mjs +82 -3
  9. package/dist/cli/switchroom.js +3699 -2110
  10. package/dist/host-control/main.js +67 -4
  11. package/dist/vault/approvals/kernel-server.js +66 -3
  12. package/dist/vault/broker/server.js +66 -3
  13. package/package.json +1 -1
  14. package/profiles/_base/start.sh.hbs +49 -0
  15. package/profiles/_shared/agent-self-service.md.hbs +15 -22
  16. package/profiles/_shared/delegation-golden-rule.md.hbs +1 -1
  17. package/profiles/_shared/dev-protocol.md.hbs +1 -1
  18. package/profiles/_shared/execution-discipline.md.hbs +4 -4
  19. package/profiles/_shared/vault-protocol.md.hbs +2 -18
  20. package/profiles/default/CLAUDE.md.hbs +3 -5
  21. package/telegram-plugin/auto-fallback-fleet.ts +37 -2
  22. package/telegram-plugin/dist/gateway/gateway.js +1414 -918
  23. package/telegram-plugin/fallback-card-collapse.ts +1 -0
  24. package/telegram-plugin/gateway/auth-command.ts +11 -1
  25. package/telegram-plugin/gateway/callback-query-handlers.ts +100 -0
  26. package/telegram-plugin/gateway/eval-case-proposal-card.ts +86 -0
  27. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.test.ts +74 -0
  28. package/telegram-plugin/gateway/fleet-fallback-notice-cooldown.ts +71 -0
  29. package/telegram-plugin/gateway/gateway.ts +85 -90
  30. package/telegram-plugin/gateway/ipc-protocol.ts +43 -0
  31. package/telegram-plugin/gateway/ipc-server.ts +28 -0
  32. package/telegram-plugin/gateway/narrative-lane.ts +33 -2
  33. package/telegram-plugin/gateway/privacy-reset.test.ts +216 -0
  34. package/telegram-plugin/gateway/privacy-reset.ts +87 -0
  35. package/telegram-plugin/gateway/privacy-state.test.ts +165 -0
  36. package/telegram-plugin/gateway/privacy-state.ts +206 -0
  37. package/telegram-plugin/gateway/self-improve-proposal-wiring.ts +176 -0
  38. package/telegram-plugin/gateway/stale-pin-sweep-wiring.ts +24 -14
  39. package/telegram-plugin/gateway/stale-pin-sweep.test.ts +123 -26
  40. package/telegram-plugin/gateway/stale-pin-sweep.ts +48 -32
  41. package/telegram-plugin/gateway/throttle-tier-wiring.ts +15 -4
  42. package/telegram-plugin/slot-banner-driver.ts +42 -5
  43. package/telegram-plugin/tests/auto-fallback-fleet.test.ts +24 -0
  44. package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +2 -0
  45. package/telegram-plugin/tests/narrative-lane-golden.test.ts +97 -0
  46. package/telegram-plugin/tests/privacy-reset-call-sites.test.ts +120 -0
  47. package/telegram-plugin/tests/status-pin-store.test.ts +25 -0
  48. package/telegram-plugin/tests/throttle-tier.test.ts +16 -0
  49. package/telegram-plugin/tests/turn-flush-safety.test.ts +67 -0
  50. package/telegram-plugin/throttle-tier.ts +12 -3
  51. package/telegram-plugin/turn-flush-safety.ts +97 -0
  52. package/vendor/hindsight-memory/CHANGELOG.md +31 -0
  53. package/vendor/hindsight-memory/hooks/hooks.json +2 -1
  54. package/vendor/hindsight-memory/scripts/retain.py +306 -0
  55. package/vendor/hindsight-memory/scripts/session_start.py +35 -8
  56. package/vendor/hindsight-memory/scripts/subagent_retain.py +29 -1
  57. package/vendor/hindsight-memory/scripts/tests/test_private_mode.py +415 -0
  58. package/vendor/hindsight-memory/scripts/tests/test_self_improve_correction_tag.py +167 -0
  59. package/vendor/hindsight-memory/scripts/tests/test_session_start_durability.py +107 -0
@@ -368,6 +368,103 @@ describe('narrative-lane golden — silent-sentinel card suppression (#4348)', (
368
368
  })
369
369
  })
370
370
 
371
+ // ── #45 — HOLLOW activity card is SUPPRESSED (deleted, not finalized) ──────────
372
+ // A turn injected into the session (typically a duplicate sub-agent handback
373
+ // beat) adopts a per-turn activity card at turn START, then ends having done
374
+ // ZERO surfaced tool work, never calling reply, delivering no final answer, and
375
+ // emitting no captured/reply text. The card was posted but never got content —
376
+ // the contentless `🤖 Agent · done · 0 tools · Ns` record. The #4348 sentinel
377
+ // gate can't catch it (no NO_REPLY/HEARTBEAT_OK text to match), so the hollow
378
+ // gate must DELETE it. A turn with ANY real content keeps its card.
379
+ describe('narrative-lane golden — hollow-ghost card suppression (#45)', () => {
380
+ // Same open-then-stamp shape as the #4348 suite: open the feed card on a
381
+ // fresh (working) turn, then set the turn's terminal outcome and clear.
382
+ async function openThenClear(over: Partial<CurrentTurn>) {
383
+ const { lane, calls } = makeLane()
384
+ const turn = makeLaneTurn(lane)
385
+ lane.showNarrativeStep(turn, 'Doing the work now')
386
+ await turn.activityInFlight
387
+ const cardId = turn.activityMessageId
388
+ expect(cardId).not.toBeNull()
389
+ Object.assign(turn, over)
390
+ lane.clearActivitySummary(turn)
391
+ await settle()
392
+ return { calls, cardId, turn }
393
+ }
394
+
395
+ it('genuinely hollow turn (0 tools, no reply, no text, no narration): DELETES the card, no done edit', async () => {
396
+ // The exact #45 ghost-reply signature Ken reported: a card opened by the
397
+ // liveness timer that stayed contentless — `mirrorLines` EMPTY. FAILS
398
+ // pre-fix: clearActivitySummary finalizes a contentless `done · 0 tools`
399
+ // card with an editMessageText instead of deleting it.
400
+ //
401
+ // `mirrorLines: []` is set explicitly: `openThenClear` opens the card via
402
+ // `showNarrativeStep`, which pushes a line into `mirrorLines`; resetting it
403
+ // to empty here isolates Ken's real signature (card open via the timer, no
404
+ // surfaced narration) from the narration-only case pinned below.
405
+ const { calls, cardId, turn } = await openThenClear({
406
+ replyCalled: false,
407
+ labeledToolCount: 0,
408
+ mirrorLines: [],
409
+ capturedText: [],
410
+ lastReplyText: '',
411
+ finalAnswerEverDelivered: false,
412
+ })
413
+ expect(calls.filter((c) => c.method === 'deleteMessage' && c.message_id === cardId)).toHaveLength(1)
414
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId)).toHaveLength(0)
415
+ // Tracking released so a late render / superseded-prior finalize can't
416
+ // resurrect the card.
417
+ expect(turn.activityMessageId).toBeNull()
418
+ })
419
+
420
+ it('a narration-only turn (a rendered mirror line, 0 tools/reply/text) KEEPS its card (edit, no delete)', async () => {
421
+ // The regression the edit-flood-fuse pin
422
+ // (activity-drain-fuse-drop-not-failure.test.ts "a terminal card render is
423
+ // never shed") protects, pinned here in the fix's own suite: a turn that
424
+ // surfaced a `showNarrativeStep` line the user SAW is legitimate content —
425
+ // NOT Ken's empty ghost card — so the card must FINALIZE, never be deleted.
426
+ // `openThenClear` already surfaced "Doing the work now" into `mirrorLines`;
427
+ // we deliberately do NOT reset it, so the hollow gate must keep the card.
428
+ const { calls, cardId } = await openThenClear({
429
+ replyCalled: false,
430
+ labeledToolCount: 0,
431
+ capturedText: [],
432
+ lastReplyText: '',
433
+ finalAnswerEverDelivered: false,
434
+ })
435
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
436
+ .toBeGreaterThanOrEqual(1)
437
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
438
+ })
439
+
440
+ it('a turn that did surfaced tool work is NOT hollow — its card FINALIZES (edit, no delete)', async () => {
441
+ // The guarantee the fix must not break: a card carrying a real `✓ N steps`
442
+ // body stays as a record even when the turn never replied.
443
+ const { calls, cardId } = await openThenClear({
444
+ replyCalled: false,
445
+ labeledToolCount: 3,
446
+ capturedText: [],
447
+ lastReplyText: '',
448
+ finalAnswerEverDelivered: false,
449
+ })
450
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
451
+ .toBeGreaterThanOrEqual(1)
452
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
453
+ })
454
+
455
+ it('a turn that delivered a final answer keeps its card (edit, no delete)', async () => {
456
+ const { calls, cardId } = await openThenClear({
457
+ replyCalled: true,
458
+ labeledToolCount: 0,
459
+ lastReplyText: 'The three services are all green.',
460
+ finalAnswerEverDelivered: true,
461
+ })
462
+ expect(calls.filter((c) => c.method === 'editMessageText' && c.message_id === cardId).length)
463
+ .toBeGreaterThanOrEqual(1)
464
+ expect(calls.filter((c) => c.method === 'deleteMessage')).toHaveLength(0)
465
+ })
466
+ })
467
+
371
468
  // ── THREE-MODULE cross-surface dedup (Amendment 1) ────────────────────────
372
469
  // The REAL P4-A handleSessionEvent turn-flush, with the REAL P4-B lane wired
373
470
  // into its deps, records the delivered answer into ONE OutboundDedupCache;
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Call-site pin for the gateway-owned session-start privacy reset (PR3).
3
+ *
4
+ * The whole point of PR3 (FIX 2 / FIX 3) is that the reset fires on GENUINE new
5
+ * sessions and is EXEMPT on paths that reattach to a persisting session. That
6
+ * is a wiring invariant, not a behavior a unit test on the module can observe —
7
+ * so this test parses gateway.ts and asserts WHERE `resetPrivacyForNewSession`
8
+ * is (and is not) called, the same source-parsing approach as
9
+ * `gateway-handler-registration-wiring.test.ts`.
10
+ *
11
+ * Instrumented (must call resetPrivacyForNewSession):
12
+ * - the `'boot'` boot-card branch (cold start / crash / planned restart)
13
+ * - the `bot.command('clear')` handler
14
+ * - the idle-clear dispatch (`maybeIdleClear`)
15
+ *
16
+ * Exempt by construction (must NOT call it):
17
+ * - `bridge-reconnect` (reattaches to a persisting session)
18
+ * - `bot.command('compact')` and resume/continue
19
+ *
20
+ * Run: npx vitest run telegram-plugin/tests/privacy-reset-call-sites.test.ts
21
+ */
22
+
23
+ import { describe, it, expect } from 'vitest'
24
+ import { readFileSync } from 'node:fs'
25
+ import { fileURLToPath } from 'node:url'
26
+ import { dirname, resolve } from 'node:path'
27
+ import ts from 'typescript'
28
+
29
+ const __dirname = dirname(fileURLToPath(import.meta.url))
30
+ const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
31
+ const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
32
+ const RESET = 'resetPrivacyForNewSession'
33
+
34
+ const sourceFile = ts.createSourceFile(
35
+ GATEWAY_PATH,
36
+ GATEWAY_SRC,
37
+ ts.ScriptTarget.Latest,
38
+ true,
39
+ ts.ScriptKind.TS,
40
+ )
41
+
42
+ /** Line number (1-based) of every CALL to `resetPrivacyForNewSession(...)`. */
43
+ function resetCallLines(): number[] {
44
+ const lines: number[] = []
45
+ const visit = (node: ts.Node): void => {
46
+ if (
47
+ ts.isCallExpression(node) &&
48
+ ts.isIdentifier(node.expression) &&
49
+ node.expression.text === RESET
50
+ ) {
51
+ lines.push(sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1)
52
+ }
53
+ ts.forEachChild(node, visit)
54
+ }
55
+ visit(sourceFile)
56
+ return lines.sort((a, b) => a - b)
57
+ }
58
+
59
+ /** The enclosing function/arrow name for a source line, best-effort. */
60
+ function enclosingContext(line: number): string {
61
+ const idx = line - 1
62
+ // Walk upward for the nearest recognizable anchor.
63
+ const lines = GATEWAY_SRC.split('\n')
64
+ for (let i = idx; i >= 0 && i > idx - 200; i--) {
65
+ const t = lines[i]
66
+ if (/bot\.command\('clear'/.test(t)) return 'command:clear'
67
+ if (/function maybeIdleClear/.test(t)) return 'maybeIdleClear'
68
+ if (/resolveBootChatId\(marker, markerAgeMs\)/.test(t)) return 'boot'
69
+ }
70
+ return 'unknown'
71
+ }
72
+
73
+ describe('privacy reset call sites (PR3 FIX 2 / FIX 3)', () => {
74
+ it('calls resetPrivacyForNewSession exactly THREE times', () => {
75
+ expect(resetCallLines()).toHaveLength(3)
76
+ })
77
+
78
+ it('the three calls are the boot branch, the /clear handler, and idle-clear', () => {
79
+ const contexts = resetCallLines().map(enclosingContext).sort()
80
+ expect(contexts).toEqual(['boot', 'command:clear', 'maybeIdleClear'])
81
+ })
82
+
83
+ it('the boot reset is GUARDED against a --continue transcript-restore (MAJOR fix)', () => {
84
+ // The boot-branch call must be gated by !isContinueRestoreBoot(...) so a
85
+ // continue/auto restart that replays the SAME transcript does NOT flip
86
+ // privacy to public. The /clear + idle calls are always genuine new
87
+ // sessions and must NOT carry the guard.
88
+ const lines = GATEWAY_SRC.split('\n')
89
+ const bootLine = resetCallLines().find(l => enclosingContext(l) === 'boot')
90
+ expect(bootLine).toBeDefined()
91
+ expect(lines[bootLine! - 1]).toContain('isContinueRestoreBoot')
92
+ for (const l of resetCallLines()) {
93
+ if (enclosingContext(l) === 'boot') continue
94
+ expect(lines[l - 1]).not.toContain('isContinueRestoreBoot')
95
+ }
96
+ })
97
+
98
+ it('is NOT wired into the bridge-reconnect boot-card path', () => {
99
+ // Scope tightly to the bridge-reconnect block: from its dedupe tag to the
100
+ // `startBootCard(` it posts. The boot-path reset sits right before ITS
101
+ // startBootCard, so if the reconnect path were (wrongly) instrumented the
102
+ // reset would land in this same window. It must not.
103
+ const reconnectIdx = GATEWAY_SRC.indexOf("shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, 'bridge-reconnect')")
104
+ expect(reconnectIdx).toBeGreaterThan(-1)
105
+ const reconnectBootCard = GATEWAY_SRC.indexOf('startBootCard(', reconnectIdx)
106
+ expect(reconnectBootCard).toBeGreaterThan(reconnectIdx)
107
+ const window = GATEWAY_SRC.slice(reconnectIdx, reconnectBootCard)
108
+ expect(window.includes(RESET)).toBe(false)
109
+ })
110
+
111
+ it("is NOT wired into the /compact handler", () => {
112
+ const compactIdx = GATEWAY_SRC.indexOf("bot.command('compact'")
113
+ const clearIdx = GATEWAY_SRC.indexOf("bot.command('clear'")
114
+ expect(compactIdx).toBeGreaterThan(-1)
115
+ expect(clearIdx).toBeGreaterThan(compactIdx)
116
+ // The compact handler body sits between the compact and clear registrations.
117
+ const compactBody = GATEWAY_SRC.slice(compactIdx, clearIdx)
118
+ expect(compactBody.includes(RESET)).toBe(false)
119
+ })
120
+ })
@@ -569,6 +569,31 @@ describe("status-pin-store — envelope version compat", () => {
569
569
  expect(loadStatusPins(PATH, fs)[0].threadId).toBe(77);
570
570
  });
571
571
 
572
+ it("D4: a forum-topic foreground pin threads its topic THROUGH the orchestrator into the persisted row", async () => {
573
+ // The narrative-lane fix passes `thread` as reconcileStatusPin's 4th arg;
574
+ // this locks the plumbing beneath it — runStatusPinReconcile → the durable
575
+ // row — so a forum-topic foreground card orphaned by a crash keeps a
576
+ // threadId the sweep can aim `unpinAllForumTopicMessages` at. Before the
577
+ // fix the producer dropped `thread`, landing a threadId-less row here.
578
+ const { fs } = memFs();
579
+ const claims = new Map<string, StatusPinClaim>();
580
+ await runStatusPinReconcile({
581
+ pinKey: "fg:-100123:9",
582
+ chatId: "-100123",
583
+ threadId: 9,
584
+ prev: null,
585
+ desired: { pinned: true, messageId: 715 },
586
+ persist: { path: PATH, fs },
587
+ runPin: async (action) =>
588
+ action.kind === "pin" ? { messageId: action.messageId } : null,
589
+ claims,
590
+ });
591
+
592
+ // The durable row AND the in-memory claim both carry the topic.
593
+ expect(loadStatusPins(PATH, fs)[0]?.threadId).toBe(9);
594
+ expect(claims.get("fg:-100123:9")?.threadId).toBe(9);
595
+ });
596
+
572
597
  it("drops a row with a non-numeric threadId", () => {
573
598
  const { fs } = memFs({
574
599
  [PATH]: JSON.stringify({
@@ -427,6 +427,22 @@ describe('renderThrottleEscalationNotice — corroborated-wall announcement', ()
427
427
  expect(text).toContain('all blocked')
428
428
  expect(text).toContain('/auth add')
429
429
  })
430
+
431
+ it('strict-pinned caller: null rolledTo renders the riding-it-out variant, NOT all-blocked', () => {
432
+ // A strict pin (agents.<name>.auth.strict) deliberately does not roll —
433
+ // the broker reports caller_pinned_strict so this null is not misread as
434
+ // a fleet-wide exhaustion while the fleet is healthy.
435
+ const text = renderThrottleEscalationNotice({
436
+ account: 'work',
437
+ agent: 'workbot',
438
+ rolledTo: null,
439
+ callerPinnedStrict: true,
440
+ })
441
+ expect(text).toContain('strictly pinned')
442
+ expect(text).toContain('fleet is unaffected')
443
+ expect(text).not.toContain('all blocked')
444
+ expect(text).not.toContain('/auth add')
445
+ })
430
446
  })
431
447
 
432
448
  describe('rate_limited escalation card wording (model-unavailable integration)', () => {
@@ -21,6 +21,7 @@ import {
21
21
  isCompositeSilentNoise,
22
22
  endsWithSilentMarker,
23
23
  isSilentSentinelCardOutcome,
24
+ isHollowGhostCardOutcome,
24
25
  isTurnFlushSafetyEnabled,
25
26
  selectFlushDeliveryText,
26
27
  FLUSH_SUBSTANTIVE_MIN_CHARS,
@@ -976,3 +977,69 @@ describe('isSilentSentinelCardOutcome (#4348)', () => {
976
977
  expect(isSilentSentinelCardOutcome({ ...base })).toBe(false)
977
978
  })
978
979
  })
980
+
981
+ describe('isHollowGhostCardOutcome (#45)', () => {
982
+ const base = {
983
+ replyCalled: false,
984
+ labeledToolCount: 0,
985
+ mirrorLines: [] as string[],
986
+ capturedText: [] as string[],
987
+ lastReplyText: '',
988
+ finalAnswerEverDelivered: false,
989
+ }
990
+
991
+ it('the ghost-reply signature — zero tools, no reply, no text, no narration — IS hollow', () => {
992
+ // The exact #45 condition the sentinel gate misses (no NO_REPLY text to
993
+ // match): the card adopted at turn start (liveness timer) never got any
994
+ // content — mirrorLines empty, no tool, no reply, no text.
995
+ expect(isHollowGhostCardOutcome({ ...base })).toBe(true)
996
+ })
997
+
998
+ it('a narration-only turn (a rendered mirror line, zero tools/reply/text) is NOT hollow', () => {
999
+ // The blocker this fix closes: a turn that surfaced a `showNarrativeStep`
1000
+ // ("Doing the work now") but did no labeled tool work, never replied, and
1001
+ // emitted no captured/reply text pushes a line into `mirrorLines`. That is
1002
+ // real content the user saw in the card — the card must be KEPT, not deleted.
1003
+ // This is the exact scenario the pre-existing edit-flood-fuse pin
1004
+ // (activity-drain-fuse-drop-not-failure.test.ts "a terminal card render is
1005
+ // never shed") protects.
1006
+ expect(
1007
+ isHollowGhostCardOutcome({ ...base, mirrorLines: ['Doing the work now'] }),
1008
+ ).toBe(false)
1009
+ })
1010
+
1011
+ it('whitespace-only mirror lines do NOT rescue the card — still hollow', () => {
1012
+ // Symmetric with the capturedText/lastReplyText blank guards: a mirrorLines
1013
+ // array of only blank strings surfaced nothing visible, so it stays hollow.
1014
+ expect(isHollowGhostCardOutcome({ ...base, mirrorLines: [' ', '\n'] })).toBe(true)
1015
+ })
1016
+
1017
+ it('whitespace-only captured / reply text still counts as hollow', () => {
1018
+ expect(isHollowGhostCardOutcome({ ...base, capturedText: [' ', '\n'] })).toBe(true)
1019
+ expect(isHollowGhostCardOutcome({ ...base, lastReplyText: ' ' })).toBe(true)
1020
+ })
1021
+
1022
+ it('a delivered final answer keeps the card — never hollow', () => {
1023
+ expect(isHollowGhostCardOutcome({ ...base, finalAnswerEverDelivered: true })).toBe(false)
1024
+ })
1025
+
1026
+ it('any surfaced tool work keeps the card — the ✓ N steps body is real', () => {
1027
+ expect(isHollowGhostCardOutcome({ ...base, labeledToolCount: 1 })).toBe(false)
1028
+ })
1029
+
1030
+ it('a reply-called turn is never hollow (sentinel gate owns dropped sentinels)', () => {
1031
+ expect(isHollowGhostCardOutcome({ ...base, replyCalled: true })).toBe(false)
1032
+ })
1033
+
1034
+ it('captured terminal text keeps the card (not hollow)', () => {
1035
+ expect(
1036
+ isHollowGhostCardOutcome({ ...base, capturedText: ['Here is the answer.'] }),
1037
+ ).toBe(false)
1038
+ })
1039
+
1040
+ it('non-blank last reply text keeps the card (not hollow)', () => {
1041
+ expect(
1042
+ isHollowGhostCardOutcome({ ...base, lastReplyText: 'The three services are green.' }),
1043
+ ).toBe(false)
1044
+ })
1045
+ })
@@ -367,8 +367,13 @@ export function renderThrottleNotice(opts: {
367
367
  export function renderThrottleEscalationNotice(opts: {
368
368
  account: string | null
369
369
  agent: string
370
- /** The account the fleet/agents rolled to; null = every fallback blocked. */
370
+ /** The account the fleet/agents rolled to; null = every fallback blocked —
371
+ * UNLESS `callerPinnedStrict`, where null just means the caller stayed put. */
371
372
  rolledTo: string | null
373
+ /** True when the triggering agent has a strict pin (`auth.strict`): its
374
+ * null `rolledTo` is "riding out the wall on its own account", not a
375
+ * fleet-wide all-blocked. Absent from pre-flag brokers → false. */
376
+ callerPinnedStrict?: boolean
372
377
  }): string {
373
378
  const acct = opts.account ? `\`${escapeMarkdown(opts.account)}\`` : 'the active account'
374
379
  const head =
@@ -376,7 +381,11 @@ export function renderThrottleEscalationNotice(opts: {
376
381
  `(trigger: **${escapeMarkdown(opts.agent)}**) were corroborated by a live quota probe.`
377
382
  const tail = opts.rolledTo
378
383
  ? `Marked exhausted and rolled to \`${escapeMarkdown(opts.rolledTo)}\`.`
379
- : `Marked exhausted — no fallback account had quota (all blocked). ` +
380
- `Use \`/auth add <label>\` to attach another subscription.`
384
+ : opts.callerPinnedStrict
385
+ ? `Marked exhausted — **${escapeMarkdown(opts.agent)}** is strictly pinned to ${acct} ` +
386
+ `(\`auth.strict\`), so it rides out the wall on its own account. ` +
387
+ `The rest of the fleet is unaffected.`
388
+ : `Marked exhausted — no fallback account had quota (all blocked). ` +
389
+ `Use \`/auth add <label>\` to attach another subscription.`
381
390
  return `${head}\n${tail}`
382
391
  }
@@ -214,6 +214,103 @@ export function isSilentSentinelCardOutcome(input: SilentSentinelCardInput): boo
214
214
  return false
215
215
  }
216
216
 
217
+ /**
218
+ * Inputs for {@link isHollowGhostCardOutcome} — the ending-turn fields the
219
+ * hollow-card gate reads. A strict superset of the silent-sentinel inputs plus
220
+ * `labeledToolCount` (the deterministic surfaced-tool total). All are already
221
+ * tracked on the gateway's `CurrentTurn`; passed as a plain struct so the
222
+ * decision stays a pure, unit-testable core.
223
+ */
224
+ export interface HollowGhostCardInput {
225
+ /** True when the model called `reply` / `stream_reply` at least once. */
226
+ replyCalled: boolean
227
+ /**
228
+ * Count of surfaced (non-suppressed) tool steps this turn
229
+ * (`CurrentTurn.labeledToolCount`) — the same source that drives the card's
230
+ * `✓ N steps` total and the `tools=` lifecycle field. Zero means the turn did
231
+ * no surfaced tool work, so the card has no step body to record.
232
+ */
233
+ labeledToolCount: number
234
+ /**
235
+ * Rendered narrative/mirror lines surfaced into the activity card this turn
236
+ * (`CurrentTurn.mirrorLines`) — every `showNarrativeStep` appends one via
237
+ * `appendActivityLabel`. A NON-EMPTY array means the user saw real narration
238
+ * content in the card (e.g. "Doing the work now"), so the card is a legitimate
239
+ * record even with zero tool steps and no reply — it is NOT the empty ghost
240
+ * card. This is the axis that separates Ken's #45 bug (a card opened by the
241
+ * liveness timer that stayed contentless — `🤖 Agent · done · 0 tools · 40s`,
242
+ * `mirrorLines` empty) from a narration-only turn (a rendered step, no tool).
243
+ */
244
+ mirrorLines: string[]
245
+ /**
246
+ * Raw assistant text blocks accumulated across the turn
247
+ * (`CurrentTurn.capturedText`) — the turn-flush classifies the same source.
248
+ */
249
+ capturedText: string[]
250
+ /**
251
+ * The most-recent `reply` / `stream_reply` `input.text` this turn
252
+ * (`CurrentTurn.lastReplyText`); empty when the reply tool was never called.
253
+ */
254
+ lastReplyText: string
255
+ /**
256
+ * A SUBSTANTIVE final answer reached the user at some point this turn
257
+ * (`CurrentTurn.finalAnswerEverDelivered`). When true the card is a legitimate
258
+ * record beside a delivered answer and is NEVER suppressed.
259
+ */
260
+ finalAnswerEverDelivered: boolean
261
+ }
262
+
263
+ /**
264
+ * Decide whether an ending turn produced a genuinely HOLLOW activity card — the
265
+ * `#45` ghost-reply condition — so the gateway can DELETE the card instead of
266
+ * finalizing it to a contentless `🤖 Agent · done · 0 tools · Ns` record.
267
+ *
268
+ * The symptom (#45): a turn injected into the session (typically a duplicate
269
+ * sub-agent handback beat) adopts a per-turn activity card at turn START, then
270
+ * ends having done ZERO surfaced tool work, never calling reply, delivering no
271
+ * final answer, and emitting no captured terminal text. The card was already
272
+ * posted but never gets any content — the gateway already WARNs on this exact
273
+ * signature (`ghost-reply detected — turn ended with zero outbound messages …
274
+ * replyCalled=false capturedText=empty`) but does not suppress the empty card.
275
+ *
276
+ * This is the complement of {@link isSilentSentinelCardOutcome}: that gate
277
+ * handles a turn whose outcome was an INTENTIONAL silent sentinel (a NO_REPLY /
278
+ * HEARTBEAT_OK the model actually emitted, which reaches `lastReplyText` /
279
+ * `capturedText`). A hollow ghost turn emitted NOTHING at all — no sentinel text
280
+ * for the sentinel gate to match — so `decideTurnFlush` classifies it as
281
+ * `empty-text`, not `silent-marker`, and the sentinel gate returns false (see
282
+ * the `isSilentSentinelCardOutcome` test "a genuinely empty dark turn … is NOT a
283
+ * sentinel"). Both gates share the same conservative floor.
284
+ *
285
+ * Conservative by construction — every guarantee that keeps a real card is an
286
+ * early false:
287
+ * - `finalAnswerEverDelivered` — a substantive answer reached the user; the
288
+ * card is a legitimate record.
289
+ * - `replyCalled` — the model called reply (even a dropped sentinel-only reply
290
+ * is the SENTINEL gate's job, not this one); never treated as hollow here.
291
+ * - `labeledToolCount > 0` — the turn did surfaced tool work, so the card
292
+ * carries a real `✓ N steps` body worth keeping.
293
+ * - any non-blank `mirrorLines` — the turn surfaced narrative content into the
294
+ * card that the user saw (a `showNarrativeStep`), so the card is a real
295
+ * record, NOT the contentless ghost card. This is the axis that keeps a
296
+ * narration-only turn (rendered step, zero tools/reply/text) from being
297
+ * mistaken for Ken's empty `done · 0 tools` card, whose `mirrorLines` is
298
+ * empty because the card was opened by the liveness timer, not narration.
299
+ * - any non-blank `capturedText` / `lastReplyText` — the turn produced terminal
300
+ * text; not hollow (and, if it is silent-sentinel noise, the sentinel gate
301
+ * already suppresses it).
302
+ * Only a turn that is empty on ALL of those axes is hollow.
303
+ */
304
+ export function isHollowGhostCardOutcome(input: HollowGhostCardInput): boolean {
305
+ if (input.finalAnswerEverDelivered) return false
306
+ if (input.replyCalled) return false
307
+ if (input.labeledToolCount > 0) return false
308
+ if (Array.isArray(input.mirrorLines) && input.mirrorLines.join('').trim().length > 0) return false
309
+ if (Array.isArray(input.capturedText) && input.capturedText.join('').trim().length > 0) return false
310
+ if (typeof input.lastReplyText === 'string' && input.lastReplyText.trim().length > 0) return false
311
+ return true
312
+ }
313
+
217
314
  /**
218
315
  * Substantive-answer floor (chars, trimmed). Mirrors
219
316
  * `final-answer-detect.ts` `FINAL_ANSWER_MIN_CHARS` and
@@ -54,6 +54,37 @@
54
54
 
55
55
  ### Changed (switchroom divergence)
56
56
 
57
+ - **SessionStart hook runs async so its durability work stops being killed
58
+ mid-drain** (`hooks/hooks.json`, `scripts/session_start.py`). The hook does
59
+ the recovery a prior session's abrupt death skipped — drain the
60
+ SessionEnd-queued retains (#1071) and reconcile un-committed transcript turns
61
+ (#3244). Those two carry independent 4s wall-clock budgets
62
+ (`HINDSIGHT_DRAIN_BUDGET_S` / `HINDSIGHT_RECONCILE_BUDGET_S`), each sized in
63
+ isolation against the old synchronous 5s SessionStart timeout, plus a ~2s
64
+ Mode-1 external-server health probe. Summed, they routinely overran 5s, so
65
+ Claude Code SIGKILLed the hook part-way through — truncating exactly the
66
+ durability work it exists to do and letting the pending-retains backlog grow
67
+ (fleet transcripts carry 1000+ `hook_cancelled` attachments for
68
+ `session_start.py`, all `timedOut: true` with `durationMs > 5000`; a
69
+ successful firing injects no `additionalContext` and so leaves no record,
70
+ which made the failures look like ~100% of firings when the true rate is
71
+ unmeasurable from attachments). Setting `"async": true` on the hook (the same
72
+ non-blocking pattern the Stop-event `retain.py` already uses) detaches it from
73
+ the SessionStart critical path: it injects no context, so nothing depends on
74
+ it finishing first and async's dropped context costs nothing. The `timeout`
75
+ ceiling rises 5s → 30s purely as a background-lifetime bound above the ~10s
76
+ summed sub-budgets — it never re-introduces a startup block, because drain and
77
+ reconcile self-cap at their budgets regardless. The drain stays in the hook
78
+ (not deferred wholly to the `hindsight-drain` sidecar, which `start.sh` only
79
+ starts conditionally — when it is absent this hook is the sole backlog path)
80
+ and `reconcile_tail`, which has no sidecar equivalent, stays here and now runs
81
+ to completion; its over-budget remainder still resumes on the next boot.
82
+ Concurrent drain with the sidecar stays safe via `drain_pending`'s exclusive
83
+ `fcntl.flock`. Pinned by `scripts/tests/test_session_start_durability.py`
84
+ (drain + reconcile are still invoked, in order) and
85
+ `tests/hindsight-session-start-async.test.ts` (the async flag + budget-clearing
86
+ ceiling survive the scaffold's hooks-override round-trip).
87
+
57
88
  - **Recall/retain hygiene guard-rail batch** (`scripts/recall.py`,
58
89
  `scripts/subagent_retain.py`, `scripts/lib/content.py`, `scripts/tests/**`).
59
90
  Closes guard-rail debt in the fork's hook scripts before extending them:
@@ -6,7 +6,8 @@
6
6
  {
7
7
  "type": "command",
8
8
  "command": "python3 \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.py\"",
9
- "timeout": 5
9
+ "async": true,
10
+ "timeout": 30
10
11
  }
11
12
  ]
12
13
  }