switchroom 0.12.22 → 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.22";
47251
- var COMMIT_SHA = "332e23e";
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.22",
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.22";
47130
- var COMMIT_SHA = "332e23e";
47131
- var COMMIT_DATE = "2026-05-20T02:55:00Z";
47132
- var LATEST_PR = 1574;
47133
- var COMMITS_AHEAD_OF_TAG = 2;
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) {
@@ -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
+ }