switchroom 0.18.11 → 0.18.13

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 (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -155,6 +155,25 @@ export interface WorkerEntry {
155
155
  * every real long-runner always carries a `toolu_…` id.
156
156
  */
157
157
  inflightToolUseIds: Set<string>
158
+ /**
159
+ * Wall-clock ms of the most recent EMITTED "terminal synthesis deferred"
160
+ * log line for this entry, or null if the entry is not currently in the
161
+ * deferred state (never entered it, or resumed out of it). Drives the
162
+ * per-worker log rate-limit (#3092): null ⇒ this is a fresh entry into the
163
+ * deferred state and MUST log; non-null ⇒ log only once
164
+ * `deferralLogIntervalMs` has elapsed. Reset to null on the un-stall path
165
+ * so a stall → resume → stall cycle logs its first deferral again.
166
+ */
167
+ deferralLoggedAt: number | null
168
+ /**
169
+ * Count of deferral ticks whose log line was SUPPRESSED by the rate-limit
170
+ * since this entry entered the deferred state (#3092). Carried into the
171
+ * next emitted line (and into the resume / cap-crossing transition lines)
172
+ * so the suppression is itself visible and the operator can see the true
173
+ * tick volume without reading 1,547 lines. Reset alongside
174
+ * `deferralLoggedAt`.
175
+ */
176
+ deferralSuppressedTicks: number
158
177
  /**
159
178
  * True if the underlying JSONL file existed before the watcher started.
160
179
  * Historical entries are tracked for late state transitions but are
@@ -330,6 +349,22 @@ export interface SubagentWatcherConfig {
330
349
  * `SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS`.
331
350
  */
332
351
  inflightTerminalCapMs?: number
352
+ /**
353
+ * Rate-limit (ms) on the repeated "terminal synthesis deferred" log line
354
+ * for a single worker (#3092). The deferral decision is re-evaluated on
355
+ * every ~1s rescan tick; without this gate each re-evaluation logged,
356
+ * emitting ~1 line/sec for as long as the worker stayed blocked (1,547
357
+ * lines in 35 min on the live overlord gateway, drowning the concurrent
358
+ * 429 flood-ban lines of #3084).
359
+ *
360
+ * Semantics: the FIRST tick that enters the deferred state always logs;
361
+ * subsequent ticks log only once this many ms have passed since the last
362
+ * emitted line. State CHANGES are never suppressed — the cap-crossing
363
+ * ("proceeding with terminal synthesis") and the resume/un-stall lines
364
+ * fire regardless. Default 60_000 (1 min). Env override
365
+ * `SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS`.
366
+ */
367
+ deferralLogIntervalMs?: number
333
368
  /**
334
369
  * Freshness window (ms) for promoting a running-at-boot worker file to
335
370
  * live. A file whose last write (mtime) is older than this is treated as
@@ -600,6 +635,16 @@ const DEFAULT_SILENT_STALL_TERMINAL_MS = 300_000
600
635
  // reaper TTL, so the watcher — not the reaper — still owns the terminal
601
636
  // transition for a worker that died mid-tool.
602
637
  const DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60_000
638
+ // Minimum wall-clock gap between two "terminal synthesis deferred" log lines
639
+ // for the SAME worker (#3092). `checkStalls` runs on the ~1s rescan tick, and
640
+ // the deferral is re-evaluated (and, before this gate, re-logged) on every one
641
+ // of them. A single worker legitimately blocked inside a long tool call
642
+ // produced 1,547 near-identical lines in 35 minutes on the overlord gateway,
643
+ // burying the concurrent Telegram 429 flood-ban lines (#3084). The DECISION is
644
+ // re-made every tick — only the LOGGING is rate-limited: first entry into the
645
+ // deferred state logs immediately, then at most once per this interval, and
646
+ // every state CHANGE (resume, cap-crossing) logs unconditionally.
647
+ const DEFAULT_DEFERRAL_LOG_INTERVAL_MS = 60_000
603
648
 
604
649
  /**
605
650
  * Tools that legitimately run for minutes with ZERO intervening JSONL
@@ -1183,6 +1228,18 @@ export function readSubTail(
1183
1228
  log?.(`subagent-watcher: onUnstall callback error ${entry.agentId}: ${(cbErr as Error).message}`)
1184
1229
  }
1185
1230
  }
1231
+ // STATE CHANGE out of the deferred state (#3092) — never rate-
1232
+ // limited. A worker that was deferring terminal synthesis behind an
1233
+ // in-flight tool call has RESURRECTED; that transition is the
1234
+ // payoff of the deferral and must be visible even though the
1235
+ // intervening per-tick deferral lines were suppressed. Report the
1236
+ // suppressed volume so the quiet window is accounted for, then
1237
+ // re-arm so a subsequent re-stall logs its first deferral again.
1238
+ if (entry.deferralLoggedAt != null) {
1239
+ log?.(`subagent-watcher: in-flight deferral resolved for ${entry.agentId} (worker resumed after ${idleSecBeforeBump}s idle — deferral was correct, not a dead worker; ${entry.deferralSuppressedTicks} deferral tick(s) suppressed since the last deferral line)`)
1240
+ entry.deferralLoggedAt = null
1241
+ entry.deferralSuppressedTicks = 0
1242
+ }
1186
1243
  log?.(`subagent-watcher: stall cleared for ${entry.agentId} (activity resumed after ${idleSecBeforeBump}s — re-arming detection)`)
1187
1244
  }
1188
1245
  if (ev.kind === 'sub_agent_model') {
@@ -1457,6 +1514,10 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1457
1514
  config.inflightTerminalCapMs
1458
1515
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS')
1459
1516
  ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS
1517
+ const deferralLogIntervalMs =
1518
+ config.deferralLogIntervalMs
1519
+ ?? parseEnvMs('SWITCHROOM_SUBAGENT_DEFERRAL_LOG_INTERVAL_MS')
1520
+ ?? DEFAULT_DEFERRAL_LOG_INTERVAL_MS
1460
1521
  const inflightPromoteMaxAgeMs =
1461
1522
  config.inflightPromoteMaxAgeMs
1462
1523
  ?? parseEnvMs('SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS')
@@ -1603,6 +1664,8 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1603
1664
  stalledAt: null,
1604
1665
  completionNotified: false,
1605
1666
  stallTerminalSynthesised: false,
1667
+ deferralLoggedAt: null,
1668
+ deferralSuppressedTicks: 0,
1606
1669
  lastSummaryLine: '',
1607
1670
  lastResultText: '',
1608
1671
  lastProgressBucketIdx: null,
@@ -1716,21 +1779,39 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1716
1779
  maybySendStateTransition(agentId)
1717
1780
  }
1718
1781
 
1719
- // Set up FSWatcher
1720
- try {
1721
- tail.watcher = fs.watch(filePath, () => {
1722
- if (stopped) return
1723
- const entry = registry.get(agentId)
1724
- const t = tails.get(agentId)
1725
- if (!entry || !t) return
1726
- checkBootPromotionGrowth(entry, t, nowFn())
1727
- readSubTail(entry, t, nowFn(), (desc) => {
1728
- log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1729
- }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
1730
- maybySendStateTransition(agentId)
1731
- })
1732
- } catch (err) {
1733
- log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
1782
+ // Set up FSWatcher.
1783
+ //
1784
+ // FD-leak fix (H2): only open an inotify watch when it can do real work.
1785
+ // A historical entry that is done-at-boot (already `scheduleTerminalCleanup`d
1786
+ // just above) or a stale `running` entry that will never be promoted (no
1787
+ // `bootPromotionPending`) has NO live transition left to observe —
1788
+ // `checkStalls` skips every historical entry, so such an entry never
1789
+ // reaches a terminal transition and `scheduleTerminalCleanup` never runs
1790
+ // for it. Opening an `fs.watch` for one leaks its inotify FD for the whole
1791
+ // gateway lifetime (the dozens-to-hundreds of dead prior-session
1792
+ // `running` JSONLs a 24/7 agent accumulates). The poll loop still reads
1793
+ // any registered running entry defensively, so dropping the watcher here
1794
+ // costs nothing but the leaked FD. Open the watch only for a live worker
1795
+ // or a historical one still awaiting boot-promotion growth confirmation.
1796
+ const needsWatcher = !entry.historical || entry.bootPromotionPending != null
1797
+ if (needsWatcher) {
1798
+ try {
1799
+ tail.watcher = fs.watch(filePath, () => {
1800
+ if (stopped) return
1801
+ const entry = registry.get(agentId)
1802
+ const t = tails.get(agentId)
1803
+ if (!entry || !t) return
1804
+ checkBootPromotionGrowth(entry, t, nowFn())
1805
+ readSubTail(entry, t, nowFn(), (desc) => {
1806
+ log?.(`subagent-watcher: description updated for ${agentId}: ${desc}`)
1807
+ }, fs, log, db, parentStateDir, config.onUnstall, cleanupTerminalAgent, config.onProgress)
1808
+ maybySendStateTransition(agentId)
1809
+ })
1810
+ } catch (err) {
1811
+ log?.(`subagent-watcher: fs.watch failed for ${agentId}: ${(err as Error).message}`)
1812
+ }
1813
+ } else {
1814
+ log?.(`subagent-watcher: ${agentId} historical/terminal at registration — not opening an FSWatcher (no live transition to observe)`)
1734
1815
  }
1735
1816
  }
1736
1817
 
@@ -1792,7 +1873,17 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1792
1873
  }
1793
1874
  if (n >= pending.deadlineAt) {
1794
1875
  entry.bootPromotionPending = undefined
1795
- log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart)`)
1876
+ // FD-leak fix (H2): the boot-promotion window elapsed with no growth,
1877
+ // so this entry is now a permanent historical/orphan that `checkStalls`
1878
+ // skips — it will never reach terminal cleanup. Release the FSWatcher we
1879
+ // opened purely for growth-confirmation now; the poll loop remains the
1880
+ // fallback reader if the file ever resumes (rescan re-registers on a
1881
+ // fresh transition).
1882
+ if (tail.watcher) {
1883
+ try { tail.watcher.close() } catch { /* ignore */ }
1884
+ tail.watcher = null
1885
+ }
1886
+ log?.(`subagent-watcher: ${entry.agentId} never observed post-boot JSONL growth within the window — leaving historical/orphan (not promoting; avoids synthesising a stale 'completed' handback from a worker killed before this restart); released growth-confirmation FSWatcher`)
1796
1887
  }
1797
1888
  }
1798
1889
 
@@ -1987,6 +2078,19 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
1987
2078
  existing.stallNotified = false
1988
2079
  existing.stalledAt = null
1989
2080
  existing.stallTerminalSynthesised = false
2081
+ // Re-arm the deferral log gate alongside its stall siblings (#3092).
2082
+ // Unreachable with stale state TODAY (both routes out of the deferred
2083
+ // state to `done` — the cap-crossing and the un-stall drain — already
2084
+ // null `deferralLoggedAt`), so this is belt-and-braces, not a live fix.
2085
+ // Reset it here anyway: a resurrected entry must be indistinguishable
2086
+ // from a fresh one, so a genuinely NEW stall always logs its FIRST
2087
+ // deferral line immediately. Leaving these to a reachability argument
2088
+ // makes the gate fragile by construction — a later reorder of the drain,
2089
+ // or a third path to `done`, would silently suppress that first line for
2090
+ // up to `deferralLogIntervalMs`, losing the exact signal this gate
2091
+ // exists to preserve.
2092
+ existing.deferralLoggedAt = null
2093
+ existing.deferralSuppressedTicks = 0
1990
2094
  // Re-arm the completion notification INTENTIONALLY (issue #3023). The
1991
2095
  // false synthesized finish already fired `onFinish` once, delivering a
1992
2096
  // (possibly wrong / incomplete) synthesized handback to the parent.
@@ -2212,10 +2316,34 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2212
2316
  if (entry.inflightToolUseIds.size > 0) {
2213
2317
  const totalIdleMs = n - entry.lastActivityAt
2214
2318
  if (totalIdleMs < inflightTerminalCapMs) {
2215
- log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)`)
2319
+ // Log-rate gate (#3092). The deferral DECISION above is re-made on
2320
+ // every ~1s rescan tick and is correct; only its LOGGING is gated.
2321
+ // Emit on first entry into the deferred state, then at most once per
2322
+ // `deferralLogIntervalMs`. Everything else is counted, not printed —
2323
+ // the suppressed count rides the next emitted line so no volume is
2324
+ // lost, just the 1/sec repetition (1,547 lines / 35 min on overlord,
2325
+ // burying the concurrent #3084 flood-ban lines).
2326
+ const lastLoggedAt = entry.deferralLoggedAt
2327
+ const firstEntry = lastLoggedAt == null
2328
+ if (firstEntry || n - lastLoggedAt >= deferralLogIntervalMs) {
2329
+ const suppressed = entry.deferralSuppressedTicks
2330
+ const suffix = firstEntry
2331
+ ? ` — further deferral ticks for this worker are rate-limited to 1 line / ${Math.floor(deferralLogIntervalMs / 1000)}s until it resumes or crosses the cap`
2332
+ : ` — still deferred (${suppressed} tick(s) suppressed since the last line)`
2333
+ log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} — ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)${suffix}`)
2334
+ entry.deferralLoggedAt = n
2335
+ entry.deferralSuppressedTicks = 0
2336
+ } else {
2337
+ entry.deferralSuppressedTicks++
2338
+ }
2216
2339
  continue
2217
2340
  }
2218
- log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis`)
2341
+ // STATE CHANGE never rate-limited. The deferral ends here and a
2342
+ // terminal synthesis fires; this is the transition an operator needs.
2343
+ const suppressedTotal = entry.deferralSuppressedTicks
2344
+ log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) — treating as died-mid-tool, proceeding with terminal synthesis (${suppressedTotal} deferral tick(s) suppressed since the last deferral line)`)
2345
+ entry.deferralLoggedAt = null
2346
+ entry.deferralSuppressedTicks = 0
2219
2347
  }
2220
2348
  // TODO(#3023/PR #3029): the cap-reached path above is a SECOND source of
2221
2349
  // possibly-false terminal synthesis (a worker mid-very-long-tool that is
@@ -2286,8 +2414,35 @@ export function startSubagentWatcher(config: SubagentWatcherConfig): SubagentWat
2286
2414
  * We walk: <agentDir>/.claude/projects/ → each project dir → each session dir
2287
2415
  * → subagents/ → agent-*.jsonl
2288
2416
  */
2417
+ /**
2418
+ * FD-leak fix (H1): close and forget any directory FSWatcher whose watched
2419
+ * directory no longer exists on disk. Every new Claude session mints a fresh
2420
+ * `<sessionId>/subagents/` dir (and `workflows/wf_<id>` sub-dirs); when Claude
2421
+ * Code reaps a prior session's directory, the scan loop below simply skips
2422
+ * the vanished path (`existsSync` guard) WITHOUT closing the watcher it had
2423
+ * opened for it — Node only frees the inotify FD on `.close()`, so each dead
2424
+ * session leaked one dir watcher for the whole gateway lifetime. Sweeping on
2425
+ * every rescan releases them deterministically the tick after the dir goes
2426
+ * away, and also covers a slug that transitions to "foreign" (skipped by the
2427
+ * allow-list filter below). Deleting the current key while iterating a Map's
2428
+ * entries() is safe in JS.
2429
+ */
2430
+ function pruneVanishedDirWatchers(): void {
2431
+ for (const [dirPath, w] of dirWatchers) {
2432
+ if (!fs.existsSync(dirPath)) {
2433
+ try { w.close() } catch { /* ignore */ }
2434
+ dirWatchers.delete(dirPath)
2435
+ log?.(`subagent-watcher: released dir watcher for vanished ${dirPath}`)
2436
+ }
2437
+ }
2438
+ }
2439
+
2289
2440
  function rescanSubagentDirs(): void {
2290
2441
  if (stopped) return
2442
+ // Release watchers for directories reaped since the last tick BEFORE the
2443
+ // early-returns below, so a projectsRoot that vanishes entirely still frees
2444
+ // every child dir watcher.
2445
+ pruneVanishedDirWatchers()
2291
2446
  const claudeHome = join(agentDir, '.claude')
2292
2447
  const projectsRoot = join(claudeHome, 'projects')
2293
2448
  if (!fs.existsSync(projectsRoot)) return
@@ -66,14 +66,27 @@ describe('activity-card durability wiring', () => {
66
66
  })
67
67
 
68
68
  it('the boot reaper runs ONLY after the startup mutex is won (never at import time)', () => {
69
- // Both invocation sites (mutex-won + mutex-fallback) sit AFTER
70
- // acquireStartupLock, alongside statusPinBootCleanup.
69
+ // #3026: the reaper is now invoked via the mutex-gated orchestrator
70
+ // runBootPinCleanupAndDmSweep() (which awaits it alongside
71
+ // statusPinBootCleanup + queuedCardBootReaper, then runs the DM
72
+ // stale-pin sweep). Invariant unchanged: reachable only post-lock.
73
+ // (1) The orchestrator awaits the reaper.
74
+ const orchestrator = between(
75
+ gatewaySrc,
76
+ 'async function runBootPinCleanupAndDmSweep()',
77
+ 'dmPinSweepEligible = true',
78
+ )
79
+ expect(orchestrator).toMatch(/await activityCardBootReaper\(\)/)
80
+ // (2) Both orchestrator invocation sites (mutex-won + mutex-fallback)
81
+ // sit AFTER acquireStartupLock.
71
82
  const afterLock = between(gatewaySrc, 'await acquireStartupLock({', 'catch (err)')
72
- expect(afterLock).toMatch(/void activityCardBootReaper\(\)/)
73
- // And it must NOT be invoked at module import time — the same losing-double-
74
- // boot hazard the NOTE at statusPinBootCleanup documents.
83
+ expect(afterLock).toMatch(/void runBootPinCleanupAndDmSweep\(\)/)
84
+ // (3) Neither the reaper nor the orchestrator is invoked at module import
85
+ // time — the same losing-double-boot hazard the NOTE at
86
+ // statusPinBootCleanup documents.
75
87
  const beforeMain = gatewaySrc.split('await acquireStartupLock({')[0] ?? ''
76
88
  expect(beforeMain).not.toMatch(/^\s*void activityCardBootReaper\(\)/m)
89
+ expect(beforeMain).not.toMatch(/^\s*void runBootPinCleanupAndDmSweep\(\)/m)
77
90
  })
78
91
 
79
92
  it('the reaper wrapper counts a benign-400 as vanished, not finalized (honest boot log)', () => {