switchroom 0.12.21 → 0.12.23

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.
@@ -47247,8 +47247,8 @@ var {
47247
47247
  } = import__.default;
47248
47248
 
47249
47249
  // src/build-info.ts
47250
- var VERSION = "0.12.21";
47251
- var COMMIT_SHA = "e32c064";
47250
+ var VERSION = "0.12.23";
47251
+ var COMMIT_SHA = "6c99950";
47252
47252
 
47253
47253
  // src/cli/agent.ts
47254
47254
  init_source();
@@ -48583,11 +48583,32 @@ function installHindsightPlugin(agentName, agentDir, switchroomConfig) {
48583
48583
  rmSync3(destPath, { recursive: true, force: true });
48584
48584
  }
48585
48585
  copyDirRecursive2(sourcePath, destPath);
48586
+ applyHindsightSettingsOverrides(destPath);
48586
48587
  const bankId = agentMemory?.collection ?? agentName;
48587
48588
  const mcpUrl = memory.config?.url ?? "http://127.0.0.1:8888/mcp/";
48588
48589
  const apiBaseUrl = mcpUrl.replace(/\/mcp\/?$/, "").replace(/\/$/, "");
48589
48590
  return { pluginDir: destPath, apiBaseUrl, bankId };
48590
48591
  }
48592
+ function applyHindsightSettingsOverrides(pluginDestPath) {
48593
+ const settingsPath = join8(pluginDestPath, "settings.json");
48594
+ if (!existsSync11(settingsPath))
48595
+ return;
48596
+ let raw;
48597
+ try {
48598
+ raw = readFileSync11(settingsPath, "utf-8");
48599
+ } catch {
48600
+ return;
48601
+ }
48602
+ let settings;
48603
+ try {
48604
+ settings = JSON.parse(raw);
48605
+ } catch {
48606
+ return;
48607
+ }
48608
+ settings.retainEveryNTurns = 1;
48609
+ writeFileSync5(settingsPath, JSON.stringify(settings, null, 2) + `
48610
+ `, "utf-8");
48611
+ }
48591
48612
  function buildWorkspaceContext(args) {
48592
48613
  const {
48593
48614
  name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.12.21",
3
+ "version": "0.12.23",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -47126,11 +47126,11 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
47126
47126
  }
47127
47127
 
47128
47128
  // ../src/build-info.ts
47129
- var VERSION = "0.12.21";
47130
- var COMMIT_SHA = "e32c064";
47131
- var COMMIT_DATE = "2026-05-20T02:05:49Z";
47132
- var LATEST_PR = 1572;
47133
- var COMMITS_AHEAD_OF_TAG = null;
47129
+ var VERSION = "0.12.23";
47130
+ var COMMIT_SHA = "6c99950";
47131
+ var COMMIT_DATE = "2026-05-20T04:16:33Z";
47132
+ var LATEST_PR = 1580;
47133
+ var COMMITS_AHEAD_OF_TAG = 1;
47134
47134
 
47135
47135
  // gateway/boot-version.ts
47136
47136
  function formatRelativeAgo(iso) {
@@ -51275,6 +51275,7 @@ async function handleInbound(ctx, text, downloadImage, attachment) {
51275
51275
  return;
51276
51276
  }
51277
51277
  const inboundReceivedAt = Date.now();
51278
+ const turnInFlightAtReceipt = activeTurnStartedAt.size > 0;
51278
51279
  const access = result.access;
51279
51280
  const from = ctx.from;
51280
51281
  const chat_id = String(ctx.chat.id);
@@ -51834,7 +51835,7 @@ ${preBlock(write.output)}`;
51834
51835
  };
51835
51836
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
51836
51837
  if (decideInboundDelivery({
51837
- turnInFlight: activeTurnStartedAt.size > 0,
51838
+ turnInFlight: turnInFlightAtReceipt,
51838
51839
  isSteering
51839
51840
  }) === "buffer-until-idle") {
51840
51841
  pendingInboundBuffer.push(selfAgent, inboundMsg);
@@ -6637,6 +6637,39 @@ async function handleInbound(
6637
6637
  // network RTT) but not a user-perceived end-to-end measurement.
6638
6638
  const inboundReceivedAt = Date.now()
6639
6639
 
6640
+ // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
6641
+ // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
6642
+ // for THIS inbound. The #1556 delivery gate further down asks "is a
6643
+ // turn ALREADY in flight" — but if we read activeTurnStartedAt.size
6644
+ // at the gate, we see the entry this handler just wrote, and buffer
6645
+ // the very message that just started the turn. Symptom: every first
6646
+ // post-restart message in each thread was held 5 minutes until the
6647
+ // silence-poke fallback drained the buffer; the "5-min blank after
6648
+ // restart" wedge documented in
6649
+ // feedback_5min_restart_wedge_violates_vision.md.
6650
+ //
6651
+ // Why ONLY first-after-restart, not every steady-state message: the
6652
+ // fresh-turn branch fires only when `priorActive = activeStatusReactions.get(key)`
6653
+ // returns null (no controller currently running for this chat+thread).
6654
+ // In a live conversation, the controller from the previous turn
6655
+ // typically hasn't been cleared yet when the user's follow-up
6656
+ // arrives (or follow-ups arrive mid-turn, taking the queued path) —
6657
+ // so the else branch at ~7313 is skipped and the .set never fires.
6658
+ // A fresh container after restart has EMPTY `activeStatusReactions`,
6659
+ // so the very first message in each thread is guaranteed to enter
6660
+ // the fresh-turn branch and trigger the self-block.
6661
+ //
6662
+ // Why snapshot, not move-the-set(): the .set() at ~7357 is embedded
6663
+ // in a coupled init bundle (controller, msgIds, signalTracker.reset,
6664
+ // silencePoke.startTurn, the 👀 reaction emit). Moving only the .set
6665
+ // splits the bundle in ways future maintainers will drift; moving
6666
+ // the WHOLE bundle past the gate changes user-visible ack timing
6667
+ // (👀 wouldn't land until after the gate decides to deliver, hiding
6668
+ // an ack on the buffered path). The snapshot is the minimal precise
6669
+ // fix. Phase 2b's state-machine extraction will revisit this
6670
+ // structurally.
6671
+ const turnInFlightAtReceipt = activeTurnStartedAt.size > 0
6672
+
6640
6673
  const access = result.access
6641
6674
  const from = ctx.from!
6642
6675
  const chat_id = String(ctx.chat!.id)
@@ -7546,9 +7579,15 @@ async function handleInbound(
7546
7579
  // idle-drain flush it the instant claude goes idle, where the channel
7547
7580
  // notification submits cleanly as a fresh turn. Steering messages are
7548
7581
  // exempt — reaching claude mid-turn is the whole point of /steer.
7582
+ //
7583
+ // CRITICAL: turnInFlight reads the snapshot taken at receipt above,
7584
+ // not `activeTurnStartedAt.size > 0` live. The fresh-turn branch at
7585
+ // line ~7357 already populated the Map for THIS inbound's turn;
7586
+ // reading the live size here would self-block (see the comment on
7587
+ // turnInFlightAtReceipt for the wedge symptom this fixes).
7549
7588
  if (
7550
7589
  decideInboundDelivery({
7551
- turnInFlight: activeTurnStartedAt.size > 0,
7590
+ turnInFlight: turnInFlightAtReceipt,
7552
7591
  isSteering,
7553
7592
  }) === 'buffer-until-idle'
7554
7593
  ) {
@@ -0,0 +1,435 @@
1
+ /**
2
+ * InboundDeliveryStateMachine — pure transition function for the
3
+ * gateway's inbound→bridge→outbound pipeline.
4
+ *
5
+ * Per `docs/rfcs/inbound-delivery-state-machine.md` (RFC merged in
6
+ * PR #1576): the gateway's delivery state was implicit and scattered
7
+ * across 8+ pieces of mutable state. The wedge cluster of 2026-05-19
8
+ * (9 PRs in 36h all patching variants of "inbound stranded → 5-min
9
+ * silence-poke fallback") and the v0.12.22 self-blocking gate bug
10
+ * (#1573, symptom-level) shared one root cause: no model anywhere in
11
+ * the codebase said "given these inputs, what should the gateway do."
12
+ *
13
+ * This module IS that model.
14
+ *
15
+ * ## Contract
16
+ *
17
+ * transition(state, event) → { state', effects[] }
18
+ *
19
+ * Pure. No I/O. No timers. No mutation of inputs. The gateway
20
+ * dispatcher receives `{ state', effects[] }` and EXECUTES the
21
+ * effects against the real bridge/buffer/spool/Telegram. The
22
+ * machine never touches those directly.
23
+ *
24
+ * Property-tested by 5 invariants (see
25
+ * `tests/inbound-delivery-machine.test.ts`):
26
+ *
27
+ * #1 — Every `inbound` event is delivered XOR persisted
28
+ * #2 — Every `setTurnStarted(key)` paired with `clearTurnStarted(key)`
29
+ * before the next end-of-life event for that key
30
+ * #3 — Per-chat sibling-key cleanup on `turnEnd`
31
+ * #4 — `permVerdict` delivered iff bridge alive; else persisted +
32
+ * re-delivered on next `bridgeUp`
33
+ * #5 — Spurious-fallback suppression (no `firePoke('fallback')` if
34
+ * the model produced an outbound for this key in the last 60s)
35
+ *
36
+ * ## Scope of this PR
37
+ *
38
+ * This is PR 1 of the 3-PR cutover (per RFC). The module is exported
39
+ * but NOT WIRED into `gateway.ts`. PR 2 will swap the gateway's
40
+ * imperative paths to dispatch through this machine. PR 3 will
41
+ * delete the now-redundant primitives.
42
+ *
43
+ * Zero production behavior change in this PR. The property test is
44
+ * the only gate.
45
+ */
46
+
47
+ // ─────────────────────────────────────────────────────────────────────
48
+ // Branded types — chat-key namespace
49
+ // ─────────────────────────────────────────────────────────────────────
50
+
51
+ /**
52
+ * Canonical chat-thread key. Use the existing `chatKey()` helper from
53
+ * `./chat-key.ts` to construct one — that helper collapses
54
+ * 0/null/undefined thread IDs to the same token (#1564 sibling-key
55
+ * canonicalization). The state machine treats `ChatKey` as opaque.
56
+ */
57
+ export type ChatKey = string & { readonly __brand: 'ChatKey' }
58
+
59
+ // ─────────────────────────────────────────────────────────────────────
60
+ // State
61
+ // ─────────────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * Global delivery state. Mirrors the existing `currentTurn` singleton
65
+ * but explicit. The gateway has ONE bridge connection (single claude
66
+ * process per agent container), so global state is the right model.
67
+ */
68
+ export type GlobalState =
69
+ | { kind: 'bridge_dead' }
70
+ | { kind: 'bridge_alive_idle' }
71
+ | { kind: 'bridge_alive_in_turn'; activeTurn: ChatKey }
72
+
73
+ /**
74
+ * Per-key state. Lifts the scattered `activeTurnStartedAt` Map and
75
+ * silence-poke's per-key `lastOutboundAt` tracking into ONE place.
76
+ *
77
+ * `turnStartedAt`: when this chat's current turn began (null = no
78
+ * turn active for this key). Mirrors the existing
79
+ * `activeTurnStartedAt[key]` value.
80
+ *
81
+ * `lastOutboundAt`: when the model last produced an outbound for
82
+ * this key. CARRIES ACROSS TURNS — this is invariant #5's data: even
83
+ * if a new turn starts (overlapping turns case from the
84
+ * 2026-05-20 mid-turn silence wedge), the model's recent outbound is
85
+ * preserved so a spurious fallback fire is suppressed.
86
+ */
87
+ export interface PerKeyState {
88
+ readonly turnStartedAt: number | null
89
+ readonly lastOutboundAt: number | null
90
+ }
91
+
92
+ export interface State {
93
+ readonly global: GlobalState
94
+ readonly perKey: ReadonlyMap<ChatKey, PerKeyState>
95
+ }
96
+
97
+ export function initialState(): State {
98
+ return {
99
+ global: { kind: 'bridge_dead' },
100
+ perKey: new Map(),
101
+ }
102
+ }
103
+
104
+ // ─────────────────────────────────────────────────────────────────────
105
+ // Events
106
+ // ─────────────────────────────────────────────────────────────────────
107
+
108
+ export interface InboundMessage {
109
+ readonly msgId: number
110
+ readonly isSteering: boolean
111
+ readonly payload: unknown
112
+ }
113
+
114
+ export interface PermissionVerdict {
115
+ readonly requestId: string
116
+ readonly behavior: 'allow' | 'deny' | 'allow_once' | 'allow_always'
117
+ readonly payload: unknown
118
+ }
119
+
120
+ export interface SpooledInbound {
121
+ readonly key: ChatKey
122
+ readonly msg: InboundMessage
123
+ }
124
+
125
+ export type Event =
126
+ | { kind: 'bridgeUp'; at: number }
127
+ | { kind: 'bridgeDown'; at: number }
128
+ | { kind: 'turnStart'; key: ChatKey; at: number }
129
+ | { kind: 'turnEnd'; key: ChatKey; at: number; outboundEmitted: boolean }
130
+ | { kind: 'inbound'; key: ChatKey; msg: InboundMessage; at: number }
131
+ | { kind: 'permVerdict'; verdict: PermissionVerdict; at: number }
132
+ | { kind: 'modelOutbound'; key: ChatKey; at: number }
133
+ | { kind: 'tick'; now: number }
134
+
135
+ // ─────────────────────────────────────────────────────────────────────
136
+ // Effects (returned, not performed)
137
+ // ─────────────────────────────────────────────────────────────────────
138
+
139
+ export type Effect =
140
+ | { kind: 'deliverToBridge'; key: ChatKey; msg: InboundMessage }
141
+ | { kind: 'bufferInbound'; key: ChatKey; msg: InboundMessage }
142
+ | { kind: 'persistInbound'; key: ChatKey; msg: InboundMessage }
143
+ | { kind: 'drainBuffer' }
144
+ | { kind: 'setTurnStarted'; key: ChatKey; at: number }
145
+ | { kind: 'clearTurnStarted'; key: ChatKey }
146
+ | { kind: 'noteOutbound'; key: ChatKey; at: number }
147
+ | { kind: 'firePoke'; key: ChatKey; level: 'soft' | 'firm' | 'fallback' }
148
+ | { kind: 'deliverPermVerdict'; verdict: PermissionVerdict }
149
+ | { kind: 'persistPermVerdict'; verdict: PermissionVerdict }
150
+ | { kind: 'redeliverPersistedPermVerdicts' }
151
+ | { kind: 'logTrace'; stage: string; key?: ChatKey; metadata?: Readonly<Record<string, unknown>> }
152
+
153
+ // ─────────────────────────────────────────────────────────────────────
154
+ // Tunable timings (match the production silence-poke ladder for now;
155
+ // the RFC includes a recommendation to tighten these in a follow-up,
156
+ // but parity-first for the PR-2 cutover).
157
+ // ─────────────────────────────────────────────────────────────────────
158
+
159
+ export const TURN_TTL_MS = 300_000 // 5 min — silence-poke fallback threshold
160
+ export const SOFT_POKE_MS = 75_000
161
+ export const FIRM_POKE_MS = 180_000
162
+ export const OUTBOUND_RECENT_MS = 60_000 // invariant #5 suppression window
163
+
164
+ // ─────────────────────────────────────────────────────────────────────
165
+ // Transition function
166
+ // ─────────────────────────────────────────────────────────────────────
167
+
168
+ export interface Transition {
169
+ readonly state: State
170
+ readonly effects: readonly Effect[]
171
+ }
172
+
173
+ function emptyPerKey(): PerKeyState {
174
+ return { turnStartedAt: null, lastOutboundAt: null }
175
+ }
176
+
177
+ function updatePerKey(
178
+ state: State,
179
+ key: ChatKey,
180
+ update: (prior: PerKeyState) => PerKeyState,
181
+ ): State {
182
+ const prior = state.perKey.get(key) ?? emptyPerKey()
183
+ const next = update(prior)
184
+ const m = new Map(state.perKey)
185
+ // Empty entries (both fields null) are pruned to keep the map tight
186
+ // — invariant #2's test reads the map size and we don't want stale
187
+ // empty entries inflating it.
188
+ if (next.turnStartedAt == null && next.lastOutboundAt == null) {
189
+ m.delete(key)
190
+ } else {
191
+ m.set(key, next)
192
+ }
193
+ return { ...state, perKey: m }
194
+ }
195
+
196
+ function chatIdOfKey(key: ChatKey): string {
197
+ // ChatKey shape is `${chatId}:${threadOrUnderscore}`. Splitting on
198
+ // the FIRST colon gives the chatId — robust to threads/suffixes.
199
+ const idx = key.indexOf(':')
200
+ return idx === -1 ? key : key.slice(0, idx)
201
+ }
202
+
203
+ /**
204
+ * Sweep all sibling keys for a chatId — Invariant #3. After the last
205
+ * turnEnd for a chatId, no sibling thread keys should remain.
206
+ *
207
+ * Effect: emits `clearTurnStarted` for every sibling key still
208
+ * holding `turnStartedAt != null`. Returns updated state.
209
+ *
210
+ * The state machine's invariant is enforced because we PROACTIVELY
211
+ * purge siblings on turnEnd. The production sibling-key sweep
212
+ * (#1564's `purgeStaleTurnsForChat`) becomes redundant.
213
+ */
214
+ function sweepSiblings(
215
+ state: State,
216
+ chatId: string,
217
+ exceptKey: ChatKey,
218
+ ): { state: State; effects: Effect[] } {
219
+ const effects: Effect[] = []
220
+ let next = state
221
+ for (const [k, v] of state.perKey) {
222
+ if (k === exceptKey) continue
223
+ if (chatIdOfKey(k) !== chatId) continue
224
+ if (v.turnStartedAt == null) continue
225
+ effects.push({ kind: 'clearTurnStarted', key: k })
226
+ next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }))
227
+ }
228
+ return { state: next, effects }
229
+ }
230
+
231
+ export function transition(state: State, event: Event): Transition {
232
+ switch (event.kind) {
233
+ case 'bridgeUp': {
234
+ if (state.global.kind !== 'bridge_dead') {
235
+ // Idempotent: a second bridgeUp is a no-op.
236
+ return { state, effects: [{ kind: 'logTrace', stage: 'bridgeUp_redundant' }] }
237
+ }
238
+ return {
239
+ state: { ...state, global: { kind: 'bridge_alive_idle' } },
240
+ effects: [
241
+ { kind: 'redeliverPersistedPermVerdicts' },
242
+ { kind: 'drainBuffer' },
243
+ { kind: 'logTrace', stage: 'bridge_recover' },
244
+ ],
245
+ }
246
+ }
247
+
248
+ case 'bridgeDown': {
249
+ // Keep perKey state intact — the next bridgeUp + turnEnd will
250
+ // resolve naturally. Clearing turn state on bridge flap was the
251
+ // wedge-cluster's "drain on disconnect" footgun.
252
+ return {
253
+ state: { ...state, global: { kind: 'bridge_dead' } },
254
+ effects: [{ kind: 'logTrace', stage: 'bridge_flap' }],
255
+ }
256
+ }
257
+
258
+ case 'inbound': {
259
+ const isSteering = event.msg.isSteering
260
+ const inTurn = state.global.kind === 'bridge_alive_in_turn'
261
+ const alive = state.global.kind !== 'bridge_dead'
262
+
263
+ if (!alive) {
264
+ return {
265
+ state,
266
+ effects: [
267
+ { kind: 'bufferInbound', key: event.key, msg: event.msg },
268
+ { kind: 'persistInbound', key: event.key, msg: event.msg },
269
+ { kind: 'logTrace', stage: 'inbound_bridge_dead_buffer', key: event.key },
270
+ ],
271
+ }
272
+ }
273
+
274
+ if (inTurn && !isSteering) {
275
+ // Mid-turn non-steering inbound: buffer (the #1556 contract).
276
+ return {
277
+ state,
278
+ effects: [
279
+ { kind: 'bufferInbound', key: event.key, msg: event.msg },
280
+ { kind: 'persistInbound', key: event.key, msg: event.msg },
281
+ { kind: 'logTrace', stage: 'inbound_held_mid_turn', key: event.key, metadata: { msgId: event.msg.msgId } },
282
+ ],
283
+ }
284
+ }
285
+
286
+ // Alive + (idle OR steering): deliver immediately.
287
+ // Steering messages reach claude mid-turn intentionally; they
288
+ // do NOT start a new turn (existing turn continues).
289
+ if (isSteering) {
290
+ return {
291
+ state,
292
+ effects: [
293
+ { kind: 'deliverToBridge', key: event.key, msg: event.msg },
294
+ { kind: 'logTrace', stage: 'steer_delivered_mid_turn', key: event.key },
295
+ ],
296
+ }
297
+ }
298
+
299
+ // Fresh turn: state transitions to in_turn(key), perKey
300
+ // turnStartedAt is set, message delivered.
301
+ const next: State = {
302
+ global: { kind: 'bridge_alive_in_turn', activeTurn: event.key },
303
+ perKey: state.perKey,
304
+ }
305
+ return {
306
+ state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
307
+ effects: [
308
+ { kind: 'setTurnStarted', key: event.key, at: event.at },
309
+ { kind: 'deliverToBridge', key: event.key, msg: event.msg },
310
+ { kind: 'logTrace', stage: 'fresh_turn_deliver', key: event.key, metadata: { msgId: event.msg.msgId } },
311
+ ],
312
+ }
313
+ }
314
+
315
+ case 'turnStart': {
316
+ // External signal that a turn has begun (e.g. session_event
317
+ // from bridge). Distinct from the implicit turn-start in
318
+ // `inbound`: a turn can begin without a fresh inbound (cron
319
+ // injection, scheduled fire).
320
+ const next: State = state.global.kind === 'bridge_alive_in_turn'
321
+ ? state
322
+ : { ...state, global: { kind: 'bridge_alive_in_turn', activeTurn: event.key } }
323
+ return {
324
+ state: updatePerKey(next, event.key, (p) => ({ ...p, turnStartedAt: event.at })),
325
+ effects: [
326
+ { kind: 'setTurnStarted', key: event.key, at: event.at },
327
+ { kind: 'logTrace', stage: 'turn_start', key: event.key },
328
+ ],
329
+ }
330
+ }
331
+
332
+ case 'turnEnd': {
333
+ // Clear turn state for the ending key AND sweep siblings
334
+ // (invariant #3). Transition global to idle if the ending turn
335
+ // was the active one.
336
+ const chatId = chatIdOfKey(event.key)
337
+ const stateAfterClear = updatePerKey(state, event.key, (p) => ({
338
+ turnStartedAt: null,
339
+ lastOutboundAt: event.outboundEmitted ? event.at : p.lastOutboundAt,
340
+ }))
341
+ const sweep = sweepSiblings(stateAfterClear, chatId, event.key)
342
+ const wasActive = state.global.kind === 'bridge_alive_in_turn' && state.global.activeTurn === event.key
343
+ const next: State = wasActive
344
+ ? { ...sweep.state, global: { kind: 'bridge_alive_idle' } }
345
+ : sweep.state
346
+ const effects: Effect[] = [
347
+ { kind: 'clearTurnStarted', key: event.key },
348
+ ...sweep.effects,
349
+ ]
350
+ if (event.outboundEmitted) {
351
+ effects.push({ kind: 'noteOutbound', key: event.key, at: event.at })
352
+ }
353
+ effects.push({ kind: 'drainBuffer' })
354
+ effects.push({ kind: 'logTrace', stage: 'turn_complete', key: event.key, metadata: { outboundEmitted: event.outboundEmitted } })
355
+ return { state: next, effects }
356
+ }
357
+
358
+ case 'modelOutbound': {
359
+ // Updates lastOutboundAt for the key. Does NOT change global
360
+ // state. This is invariant #5's data: it carries across turn
361
+ // boundaries so a spurious fallback can be suppressed.
362
+ return {
363
+ state: updatePerKey(state, event.key, (p) => ({ ...p, lastOutboundAt: event.at })),
364
+ effects: [
365
+ { kind: 'noteOutbound', key: event.key, at: event.at },
366
+ ],
367
+ }
368
+ }
369
+
370
+ case 'permVerdict': {
371
+ const alive = state.global.kind !== 'bridge_dead'
372
+ if (alive) {
373
+ return {
374
+ state,
375
+ effects: [
376
+ { kind: 'deliverPermVerdict', verdict: event.verdict },
377
+ { kind: 'logTrace', stage: 'perm_verdict_delivered' },
378
+ ],
379
+ }
380
+ }
381
+ return {
382
+ state,
383
+ effects: [
384
+ { kind: 'persistPermVerdict', verdict: event.verdict },
385
+ { kind: 'logTrace', stage: 'perm_verdict_persisted' },
386
+ ],
387
+ }
388
+ }
389
+
390
+ case 'tick': {
391
+ // Scan perKey for stale turns. For each entry with a non-null
392
+ // turnStartedAt where `now - turnStartedAt > TURN_TTL_MS`:
393
+ // - Check lastOutboundAt: if it's null OR more than
394
+ // OUTBOUND_RECENT_MS old, fire the fallback poke + clear.
395
+ // - Otherwise suppress (invariant #5).
396
+ const effects: Effect[] = []
397
+ let next = state
398
+ for (const [k, v] of state.perKey) {
399
+ if (v.turnStartedAt == null) continue
400
+ const age = event.now - v.turnStartedAt
401
+ if (age <= TURN_TTL_MS) {
402
+ // Not yet stale enough for fallback. Soft/firm pokes are
403
+ // not modeled here yet — they're advisory, the gateway
404
+ // emits them; the state machine governs the fallback gate.
405
+ continue
406
+ }
407
+ // Stale enough for fallback. Check the suppression window.
408
+ const recentOutbound =
409
+ v.lastOutboundAt != null && (event.now - v.lastOutboundAt) < OUTBOUND_RECENT_MS
410
+ if (recentOutbound) {
411
+ // Invariant #5: model recently broke silence; suppress fire.
412
+ effects.push({ kind: 'logTrace', stage: 'fallback_suppressed', key: k, metadata: { recentOutboundMs: event.now - (v.lastOutboundAt ?? 0) } })
413
+ continue
414
+ }
415
+ // Fire the fallback + clear the turn.
416
+ effects.push({ kind: 'firePoke', key: k, level: 'fallback' })
417
+ effects.push({ kind: 'clearTurnStarted', key: k })
418
+ next = updatePerKey(next, k, (p) => ({ ...p, turnStartedAt: null }))
419
+ // If this was the active turn globally, drop to idle.
420
+ if (next.global.kind === 'bridge_alive_in_turn' && next.global.activeTurn === k) {
421
+ next = { ...next, global: { kind: 'bridge_alive_idle' } }
422
+ }
423
+ }
424
+ return { state: next, effects }
425
+ }
426
+ }
427
+ }
428
+
429
+ // ─────────────────────────────────────────────────────────────────────
430
+ // Test-only helpers — mirror silence-poke.ts's __XForTests idiom
431
+ // ─────────────────────────────────────────────────────────────────────
432
+
433
+ export function __chatIdOfKeyForTests(key: ChatKey): string {
434
+ return chatIdOfKey(key)
435
+ }