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
@@ -0,0 +1,425 @@
1
+ /**
2
+ * Shared harness for the approval-hold outcome tests (#3084 follow-up).
3
+ *
4
+ * gateway.ts has top-level side effects and is NOT unit-importable (the same
5
+ * constraint `permission-card-routing.test.ts:18` documents), so a literal
6
+ * "boot the gateway" integration test is not available. Instead this harness
7
+ * composes the REAL primitives the gateway composes, in the same order:
8
+ *
9
+ * - `createRetryApiCall` — the real retry policy, incl. its pre-call
10
+ * flood-window short-circuit (#3094)
11
+ * - `retryWithThreadFallback` — the real send wrapper
12
+ * - `makeFloodWaitRecorder` / `makeFloodWaitProbe` — the real on-disk window
13
+ * - `selectHeldForRedelivery` / `isHeldUndeliverable` — the real hold decisions
14
+ * - `createBlockedApprovalStore` — the real surface record
15
+ * - `ttlForTool` — the real TTL
16
+ *
17
+ * Only two things are fakes: the Telegram transport (a counting send spy) and
18
+ * the IPC verdict dispatch (a spy — this is what proves no `deny` was ever
19
+ * fabricated). Everything that makes a DECISION is production code.
20
+ *
21
+ * Deliberately free of `vi.useFakeTimers()` / `vi.setSystemTime()` and of any
22
+ * import from `fake-bot-api.ts` (which pulls in `vi` at module scope): this
23
+ * harness must work under BOTH vitest and bun. It uses an explicit virtual
24
+ * clock instead — the pattern from `typing-emitter.test.ts`.
25
+ */
26
+
27
+ import { mkdtempSync } from 'node:fs'
28
+ import { tmpdir } from 'node:os'
29
+ import { join } from 'node:path'
30
+ import { GrammyError } from 'grammy'
31
+ import { createRetryApiCall, retryWithThreadFallback, isFloodWaitActiveError, GIVE_UP_MESSAGE } from '../retry-api-call.js'
32
+ import {
33
+ floodStatePath,
34
+ makeFloodWaitRecorder,
35
+ makeFloodWaitProbe,
36
+ } from '../flood-circuit-breaker.js'
37
+ import {
38
+ createBlockedApprovalStore,
39
+ blockedApprovalsDir,
40
+ selectHeldForRedelivery,
41
+ selectOldestHeld,
42
+ safeActionForRecord,
43
+ holdReasonFor,
44
+ heldRetryBackoffMs,
45
+ type UndeliverableMark,
46
+ type BlockedApprovalStore,
47
+ } from '../gateway/approval-hold.js'
48
+ import { ttlForTool } from '../gateway/permission-timeout.js'
49
+ import { sweepPermissionTtl } from '../gateway/permission-ttl-sweep.js'
50
+ import { naturalAction } from '../permission-title.js'
51
+
52
+ /** A pending permission, shaped exactly like gateway.ts's `pendingPermissions` value. */
53
+ export interface Pending {
54
+ tool_name: string
55
+ description: string
56
+ input_preview: string
57
+ startedAt: number
58
+ card_text: string
59
+ cards: { chatId: string; messageId: number; threadId?: number | null }[]
60
+ undeliverable?: UndeliverableMark | null
61
+ redeliveryFailures?: number
62
+ }
63
+
64
+ /** An IPC verdict as `dispatchPermissionVerdict` would emit it. */
65
+ export interface Verdict {
66
+ requestId: string
67
+ behavior: 'allow' | 'deny'
68
+ message?: string
69
+ }
70
+
71
+ /** Build the real 429 GrammyError Telegram sends during a flood ban. */
72
+ export function floodWait429(retryAfterSec: number): GrammyError {
73
+ return new GrammyError(
74
+ `Call to 'sendRichMessage' failed! (429: Too Many Requests: retry after ${retryAfterSec})`,
75
+ {
76
+ ok: false,
77
+ error_code: 429,
78
+ description: `Too Many Requests: retry after ${retryAfterSec}`,
79
+ parameters: { retry_after: retryAfterSec } as GrammyError['parameters'],
80
+ },
81
+ 'sendRichMessage',
82
+ {},
83
+ )
84
+ }
85
+
86
+ /**
87
+ * The REAL failures a card send actually hits. Each throws exactly what Telegram
88
+ * or the network would; `retryApiCall` then decides what surfaces to the caller.
89
+ * The point of driving real errors is that our classification of them is the
90
+ * thing under test.
91
+ */
92
+ export type FaultKind = 'tg_502' | 'tg_500' | 'econnreset' | 'fetch_failed' | 'tg_400' | 'tg_403'
93
+
94
+ const grammyErr = (code: number, desc: string) => new GrammyError(
95
+ `Call to 'sendRichMessage' failed! (${code}: ${desc})`,
96
+ { ok: false, error_code: code, description: desc } as GrammyError['payload'] & { ok: false },
97
+ 'sendRichMessage',
98
+ {},
99
+ )
100
+
101
+ export const FAULTS: Record<FaultKind, () => unknown> = {
102
+ // TRANSIENT — the operator never got to answer. Must be HELD.
103
+ tg_502: () => grammyErr(502, 'Bad Gateway'),
104
+ tg_500: () => grammyErr(500, 'Internal Server Error'),
105
+ econnreset: () => new Error('socket hang up ECONNRESET'),
106
+ fetch_failed: () => new Error('fetch failed'),
107
+ // PERMANENT — this card will never render. Falls through to the TTL.
108
+ tg_400: () => grammyErr(400, "Bad Request: can't parse entities"),
109
+ tg_403: () => grammyErr(403, 'Forbidden: bot was blocked by the user'),
110
+ }
111
+
112
+ export interface Harness {
113
+ stateDir: string
114
+ clock: { now: () => number; advance: (ms: number) => void }
115
+ /** Every send the transport actually saw. Length === card send count. */
116
+ sends: { chatId: string; requestId: string }[]
117
+ /** Every IPC verdict dispatched. A `deny` here for an unseen card is THE bug. */
118
+ verdicts: Verdict[]
119
+ /** True whenever the transport was invoked while the window was still open. */
120
+ sentIntoOpenWindow: boolean
121
+ pending: Map<string, Pending>
122
+ blockedStore: BlockedApprovalStore
123
+ /** Simulate Telegram flood-banning the bot for `ms`. */
124
+ banFor: (ms: number) => void
125
+ /** Make a target throw a REAL error. `kind` picks which real failure. */
126
+ breakTarget: (chatId: string, kind: FaultKind) => void
127
+ /** The channel recovers — the target starts working again. */
128
+ healTarget: (chatId: string) => void
129
+ /** Flood-ban ONE target, so another can succeed in the same fan-out. */
130
+ banTarget: (chatId: string, ms: number) => void
131
+ /** Hang one target's send until `releaseTarget` — lets a tick overlap it. */
132
+ hangTarget: (chatId: string) => void
133
+ releaseTarget: (chatId: string) => void
134
+ /** Make the NEXT postPermissionCard for `requestId` throw SYNCHRONOUSLY (one-shot). */
135
+ breakSync: (requestId: string) => void
136
+ /** Remaining ms of the open window, per the real probe. */
137
+ floodRemainingMs: () => number
138
+ /** The gateway's `postPermissionCard` — first delivery AND re-delivery. */
139
+ postPermissionCard: (requestId: string) => Promise<void>
140
+ /** Raise a permission request, as `onPermissionRequest` does. */
141
+ raise: (requestId: string, toolName: string, inputPreview: string) => Promise<void>
142
+ /** One 60s reaper tick: held-card re-delivery sweep, then the TTL sweep. */
143
+ tick: () => Promise<void>
144
+ /** Await every in-flight send (the reaper itself never awaits them). */
145
+ settle: () => Promise<void>
146
+ /** tick() then settle() — the common case. */
147
+ tickSettled: () => Promise<void>
148
+ /** Is this request still mid-send? (the in-flight guard, made observable) */
149
+ isInFlight: (requestId: string) => boolean
150
+ /** Drain the microtask queue so pending .then/.catch chains run to completion. */
151
+ flush: () => Promise<void>
152
+ /** Operator taps Allow on a live card. */
153
+ tap: (requestId: string, behavior: 'allow' | 'deny') => void
154
+ }
155
+
156
+ export function createHarness(opts: { cap?: number; targets?: string[] } = {}): Harness {
157
+
158
+ const stateDir = mkdtempSync(join(tmpdir(), 'approval-hold-'))
159
+ const floodPath = floodStatePath(stateDir)
160
+
161
+ // Anchored at real `Date.now()` so the virtual clock and the `untilTs` that
162
+ // retryApiCall stamps (which uses real Date.now()) stay coherent at T0.
163
+ let t = Date.now()
164
+ const clock = { now: () => t, advance: (ms: number) => { t += ms } }
165
+
166
+ const sends: { chatId: string; requestId: string }[] = []
167
+ const verdicts: Verdict[] = []
168
+ const pending = new Map<string, Pending>()
169
+ const heldInFlight = new Set<string>()
170
+ const blockedStore = createBlockedApprovalStore(blockedApprovalsDir(stateDir), 'overlord')
171
+
172
+ const targetChats = opts.targets ?? ['-100200']
173
+ /** Every send promise issued, so a test can await quiescence. */
174
+ const inFlightSends: Promise<void>[] = []
175
+ let banUntil = 0
176
+ let sentIntoOpenWindow = false
177
+
178
+ /** Per-chat fault: a factory for the REAL error the transport should throw. */
179
+ const faults = new Map<string, () => unknown>()
180
+ /** Chats banned individually (so one target can flood while another succeeds). */
181
+ const perChatBan = new Map<string, number>()
182
+ /** Requests whose NEXT postPermissionCard throws SYNCHRONOUSLY (one-shot). */
183
+ const syncFaults = new Set<string>()
184
+ /** Chats whose send HANGS until released — lets a tick overlap an in-flight send. */
185
+ const gates = new Map<string, Promise<void>>()
186
+ const gateReleases = new Map<string, () => void>()
187
+
188
+ const probe = makeFloodWaitProbe(floodPath, clock.now)
189
+ const robustApiCall = createRetryApiCall({
190
+ sleep: async () => {}, // never actually sleep — the clock is virtual
191
+ onFloodWait: makeFloodWaitRecorder(floodPath, clock.now),
192
+ floodWaitRemainingMs: probe,
193
+ })
194
+
195
+ /** The fake Telegram transport. Rejects with a real 429 while banned. */
196
+ async function transport(chatId: string, requestId: string): Promise<{ message_id: number }> {
197
+ // Did this request reach Telegram while we ALREADY KNEW the window was
198
+ // open? That is the amplifier #3094 exists to prevent, and the one way this
199
+ // design could re-earn the ban. (The very FIRST send is how the ban is
200
+ // discovered — the on-disk window is empty until a 429 records it — so it
201
+ // is legitimately not "into a known-open window".)
202
+ if (probe() > 0) sentIntoOpenWindow = true
203
+
204
+ // Hang here until the test releases us — models a send still in flight when
205
+ // the next 60s reaper tick fires.
206
+ const gate = gates.get(chatId)
207
+ if (gate != null) { await gate }
208
+
209
+ const fault = faults.get(chatId)
210
+ if (fault != null) {
211
+ // Throw the REAL error Telegram/the network would produce, and let the REAL
212
+ // retryApiCall decide what surfaces. The previous harness threw
213
+ // GIVE_UP_MESSAGE directly — the *conclusion* — which hard-coded a premise
214
+ // that turned out to be false (a 5xx is re-thrown RAW, not as a give-up) and
215
+ // made a live auto-deny bug pass green.
216
+ throw fault()
217
+ }
218
+
219
+ const until = Math.max(banUntil, perChatBan.get(chatId) ?? 0)
220
+ const remaining = until - clock.now()
221
+ if (remaining > 0) throw floodWait429(Math.ceil(remaining / 1000))
222
+ sends.push({ chatId, requestId })
223
+ return { message_id: 1000 + sends.length }
224
+ }
225
+
226
+ /**
227
+ * Mirrors gateway.ts's reconcileBlockedApprovals — and shares its SELECTION
228
+ * with production via `selectOldestHeld` (extracted in #3107's late fix), so
229
+ * the harness cannot drift from the real oldest-held-wins rule.
230
+ */
231
+ function reconcile(): void {
232
+ const oldest = selectOldestHeld(pending)
233
+ if (oldest == null) { blockedStore.clear(); return }
234
+ const { requestId, pend: p, mark: u } = oldest
235
+ blockedStore.write({
236
+ agent: 'overlord',
237
+ requestId,
238
+ toolName: p.tool_name,
239
+ action: safeActionForRecord(naturalAction, p.tool_name, p.input_preview),
240
+ blockedSince: p.startedAt,
241
+ undeliverableSince: u.since,
242
+ retryableAt: u.retryableAt,
243
+ reason: u.reason,
244
+ })
245
+ }
246
+
247
+ // ── postPermissionCard — mirrors gateway.ts's extracted send ──────────────
248
+ // Fans out to N targets, exactly as `resolvePermissionCardTargets()` does (it
249
+ // ends in `allowFrom.map(...)`). N>1 is the COMMON case on the re-delivery
250
+ // path, so a harness pinned at N=1 cannot see the double-delivery and
251
+ // permanent-wedge bugs that live in the multi-target interleaving.
252
+ function postPermissionCard(requestId: string): Promise<void> {
253
+ const pend = pending.get(requestId)
254
+ if (pend == null) { heldInFlight.delete(requestId); return Promise.resolve() }
255
+
256
+ // Mirrors gateway.ts: the flag is registered at the TOP of postPermissionCard
257
+ // itself, so the initial-delivery caller is covered as well as the sweep —
258
+ // otherwise a tick landing while a slow INITIAL send is in flight re-posts.
259
+ heldInFlight.add(requestId)
260
+
261
+ try {
262
+ // A sync throw in the gateway's prologue (resolveScopedAllowChoices /
263
+ // buildPermissionActionRow / resolvePermissionCardTargets), made drivable.
264
+ if (syncFaults.delete(requestId)) {
265
+ throw new Error(`sync fault building card for ${requestId}`)
266
+ }
267
+
268
+ // One increment per ATTEMPT, not per failing target (mirrors gateway.ts):
269
+ // every failing target in the same fan-out stamps the SAME attempt number,
270
+ // so N failing targets don't ratchet the backoff ladder N× faster.
271
+ const attemptFailures = (pend.redeliveryFailures ?? 0) + 1
272
+
273
+ const sends = targetChats.map(async (chatId) => {
274
+ try {
275
+ const sent = await retryWithThreadFallback<{ message_id: number }>(
276
+ robustApiCall,
277
+ () => transport(chatId, requestId),
278
+ { threadId: undefined, chat_id: chatId, verb: 'permission_request' },
279
+ )
280
+ const live = pending.get(requestId)
281
+ if (live && sent) {
282
+ live.cards.push({ chatId, messageId: sent.message_id })
283
+ if (live.undeliverable != null) {
284
+ live.undeliverable = null
285
+ live.redeliveryFailures = 0
286
+ // PR 3: the operator's decision window starts NOW — until this
287
+ // moment they had nothing to answer.
288
+ live.startedAt = clock.now()
289
+ reconcile()
290
+ }
291
+ }
292
+ } catch (e) {
293
+ const live = pending.get(requestId)
294
+ if (live == null) return
295
+ // A card landed on ANOTHER target — the ask is NOT undeliverable.
296
+ if (live.cards.length > 0) return
297
+ const reason = holdReasonFor(e)
298
+ if (reason == null) return // permanent — falls through to the TTL
299
+ const failures = attemptFailures
300
+ live.redeliveryFailures = failures
301
+ const now = clock.now()
302
+ const retryableAt = isFloodWaitActiveError(e)
303
+ ? (e as { untilTs: number }).untilTs
304
+ : now + heldRetryBackoffMs(failures)
305
+ live.undeliverable = { since: live.undeliverable?.since ?? now, retryableAt, reason }
306
+ reconcile()
307
+ }
308
+ })
309
+
310
+ // ONE settle across ALL targets — the flag must not release while any
311
+ // target is still in flight.
312
+ return Promise.allSettled(sends).then(() => { heldInFlight.delete(requestId) })
313
+ } catch (e) {
314
+ // Mirrors gateway.ts: a sync throw never reaches the settle above, so
315
+ // release the flag here and rethrow to the caller (raise, or tick's catch).
316
+ heldInFlight.delete(requestId)
317
+ throw e
318
+ }
319
+ }
320
+
321
+ async function raise(requestId: string, toolName: string, inputPreview: string): Promise<void> {
322
+ pending.set(requestId, {
323
+ tool_name: toolName,
324
+ description: '',
325
+ input_preview: inputPreview,
326
+ startedAt: clock.now(),
327
+ card_text: `card for ${requestId}`,
328
+ cards: [],
329
+ })
330
+ await postPermissionCard(requestId)
331
+ }
332
+
333
+ // ── the 60s reaper tick ───────────────────────────────────────────────────
334
+ async function tick(): Promise<void> {
335
+ const now = clock.now()
336
+
337
+ // PR 2 — re-deliver held cards once the window closes.
338
+ const { send } = selectHeldForRedelivery(pending.entries(), {
339
+ floodRemainingMs: probe(),
340
+ inFlight: heldInFlight,
341
+ now,
342
+ ...(opts.cap != null ? { cap: opts.cap } : {}),
343
+ })
344
+ // Fire-and-forget, exactly like the real reaper (`void postPermissionCard(...)`).
345
+ // Awaiting here would make an overlapping tick impossible — and an
346
+ // overlapping tick is precisely how a per-target in-flight clear
347
+ // double-delivers. Tests that need the send settled `await h.settle()`.
348
+ // postPermissionCard registers the in-flight flag itself; a SYNC throw is
349
+ // caught here (mirrors gateway's sweep) so it can't kill the tick, and the
350
+ // flag is deleted defensively so the entry stays re-selectable.
351
+ for (const id of send) {
352
+ try {
353
+ inFlightSends.push(postPermissionCard(id))
354
+ } catch {
355
+ heldInFlight.delete(id)
356
+ }
357
+ }
358
+
359
+ // The TTL sweep, as gateway.ts runs it.
360
+ // The TTL sweep — the REAL production function, not a copy. This is the whole
361
+ // point: the guard AND the auto-deny it gates live in ONE place that both the
362
+ // gateway and this harness call, so there is no private copy here that could
363
+ // silently compensate for a guard deleted from production.
364
+ sweepPermissionTtl({
365
+ entries: pending,
366
+ now,
367
+ ttlForTool,
368
+ onExpire: (k) => {
369
+ verdicts.push({ requestId: k, behavior: 'deny', message: 'timed out' })
370
+ pending.delete(k)
371
+ reconcile()
372
+ },
373
+ })
374
+ }
375
+
376
+ function tap(requestId: string, behavior: 'allow' | 'deny'): void {
377
+ const pend = pending.get(requestId)
378
+ // A tap only resolves a card that actually EXISTS to be tapped.
379
+ if (pend == null || pend.cards.length === 0) return
380
+ verdicts.push({ requestId, behavior })
381
+ pending.delete(requestId)
382
+ reconcile()
383
+ }
384
+
385
+ return {
386
+ stateDir,
387
+ clock,
388
+ sends,
389
+ verdicts,
390
+ get sentIntoOpenWindow() { return sentIntoOpenWindow },
391
+ pending,
392
+ blockedStore,
393
+ banFor: (ms: number) => { banUntil = clock.now() + ms },
394
+ breakTarget: (chatId: string, kind: FaultKind) => { faults.set(chatId, FAULTS[kind]) },
395
+ healTarget: (chatId: string) => { faults.delete(chatId) },
396
+ banTarget: (chatId: string, ms: number) => { perChatBan.set(chatId, clock.now() + ms) },
397
+ hangTarget: (chatId: string) => {
398
+ let release!: () => void
399
+ gates.set(chatId, new Promise<void>(res => { release = res }))
400
+ gateReleases.set(chatId, release)
401
+ },
402
+ releaseTarget: (chatId: string) => {
403
+ gateReleases.get(chatId)?.()
404
+ gates.delete(chatId)
405
+ gateReleases.delete(chatId)
406
+ },
407
+ breakSync: (requestId: string) => { syncFaults.add(requestId) },
408
+ floodRemainingMs: () => probe(),
409
+ postPermissionCard,
410
+ raise,
411
+ tick,
412
+ settle: async () => { await Promise.allSettled(inFlightSends.splice(0)) },
413
+ tickSettled: async () => {
414
+ await tick()
415
+ await Promise.allSettled(inFlightSends.splice(0))
416
+ },
417
+ isInFlight: (requestId: string) => heldInFlight.has(requestId),
418
+ flush: async () => {
419
+ // Generous microtask drain — retryApiCall's give-up path hops several
420
+ // awaits (3 attempts x noop sleep) before its catch finally runs.
421
+ for (let i = 0; i < 50; i++) await Promise.resolve()
422
+ },
423
+ tap,
424
+ }
425
+ }