switchroom 0.19.37 → 0.19.38

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.
@@ -2120,7 +2120,7 @@ var init_esm = __esm(() => {
2120
2120
  });
2121
2121
 
2122
2122
  // src/build-info.ts
2123
- var VERSION = "0.19.37", COMMIT_SHA = "5d2f9a17";
2123
+ var VERSION = "0.19.38", COMMIT_SHA = "9ec88983";
2124
2124
 
2125
2125
  // src/cli/resolve-version.ts
2126
2126
  import { existsSync, readFileSync } from "node:fs";
@@ -45994,13 +45994,15 @@ var init_install_cron = () => {};
45994
45994
  function inParts(memories) {
45995
45995
  return Math.round(memories * PARTS_PER_MEMORY);
45996
45996
  }
45997
- var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
45997
+ var RING_MAX = 8, MAX_SAMPLE_AGE_MS, MIN_RETAIN_SAMPLES = 20, RETAIN_FAILURE_RATE = 0.1, PARTS_PER_MEMORY = 6, QUEUE_FLOOR_MEMORIES = 100, QUEUE_FLOOR, QUEUE_GROWTH_FRACTION = 0.1, QUEUE_GROWTH_MIN_ABS_MEMORIES = 20, QUEUE_GROWTH_MIN_ABS, BREACHES_TO_FIRE_LEVEL = 2, BREACHES_TO_FIRE_EDGE = 1, CLEARS_TO_RESOLVE = 2, RENOTIFY_MS, DEFAULT_METRICS_URL = "http://127.0.0.1:18888/metrics", DEFAULT_CONTAINER = "switchroom-hindsight", METRICS_MAX_BYTES, METRICS_TIMEOUT_MS = 1e4, RECALL_OWN_BANK_TIMEOUT_WARN = 0.05, RECALL_OWN_BANK_TIMEOUT_PAGE = 0.15, RECALL_ZERO_MEMORY_WARN = 0.25, RECALL_ZERO_MEMORY_PAGE = 0.4, RECALL_POOL_MEDIAN_WARN = 8, RECALL_POOL_MEDIAN_PAGE = 3, RECALL_SCORE_P50_WARN = 0.02, RECALL_SCORE_P50_PAGE = 0.01, RECALL_WALL_MS, CONSOLIDATION_AGE_WARN_S, CONSOLIDATION_AGE_PAGE_S, CONSOLIDATION_FAILURE_STREAK_WARN = 3, CONSOLIDATION_FAILURE_STREAK_PAGE = 10, CONSOLIDATION_STREAK_RECENCY_S, PENDING_CONSOLIDATION_FLOOR = 500, PENDING_CONSOLIDATION_GROWTH_FRACTION = 0.5, PENDING_CONSOLIDATION_GROWTH_WARN_ABS = 500, PENDING_CONSOLIDATION_GROWTH_PAGE_ABS = 5000, VECTOR_INDEX_SAMPLE_ROWS = 3, RECALL_MIN_SAMPLES = 30, LLM_FAILURE_RATE_WARN = 0.02, LLM_FAILURE_RATE_PAGE = 0.1, LLM_MIN_SAMPLES = 20;
45998
45998
  var init_thresholds = __esm(() => {
45999
+ init_hindsight_recall_tunables();
45999
46000
  MAX_SAMPLE_AGE_MS = 3 * 60 * 60000;
46000
46001
  QUEUE_FLOOR = inParts(QUEUE_FLOOR_MEMORIES);
46001
46002
  QUEUE_GROWTH_MIN_ABS = inParts(QUEUE_GROWTH_MIN_ABS_MEMORIES);
46002
46003
  RENOTIFY_MS = 6 * 60 * 60 * 1000;
46003
46004
  METRICS_MAX_BYTES = 16 * 1024 * 1024;
46005
+ RECALL_WALL_MS = resolveHindsightRecallTunables(undefined).parallelDeadlineSeconds * 1000;
46004
46006
  CONSOLIDATION_AGE_WARN_S = 2 * 60 * 60;
46005
46007
  CONSOLIDATION_AGE_PAGE_S = 12 * 60 * 60;
46006
46008
  CONSOLIDATION_STREAK_RECENCY_S = 2 * 60 * 60;
@@ -105312,7 +105314,7 @@ function latencySuffix(r) {
105312
105314
  return "";
105313
105315
  const n = typeof r.elapsedConsidered === "number" ? r.elapsedConsidered : 0;
105314
105316
  return `
105315
- recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the 8000ms per-bank budget leaves no healthy band below it)`;
105317
+ recall wall time p95 ${Math.round(p95)}ms over ${n} fire(s) (not thresholded \u2014 the ${RECALL_WALL_MS}ms recall deadline leaves no healthy band below it)`;
105316
105318
  }
105317
105319
  function evaluateRecallOwnBankTimeout(ring) {
105318
105320
  const signal = "recall-own-bank-timeout";
@@ -105911,14 +105913,25 @@ function runInstallCron(cronUser) {
105911
105913
  `);
105912
105914
  return 0;
105913
105915
  }
105914
- async function notifyOperator(agentsDir, text, log) {
105916
+ var PREFERRED_RELAY_AGENT = "overlord";
105917
+ function orderRelayCandidates(candidates, preferred = PREFERRED_RELAY_AGENT) {
105918
+ const first = candidates.filter((c) => c.agent === preferred);
105919
+ const rest = candidates.filter((c) => c.agent !== preferred);
105920
+ return [...first, ...rest];
105921
+ }
105922
+ function collectRelayCandidates(agentsDir) {
105915
105923
  const candidates = [];
105924
+ for (const name of readdirSync45(agentsDir).sort()) {
105925
+ const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
105926
+ if (existsSync112(sock))
105927
+ candidates.push({ agent: name, sock });
105928
+ }
105929
+ return orderRelayCandidates(candidates);
105930
+ }
105931
+ async function notifyOperator(agentsDir, text, log) {
105932
+ let candidates = [];
105916
105933
  try {
105917
- for (const name of readdirSync45(agentsDir).sort()) {
105918
- const sock = resolve63(agentsDir, name, "telegram", "gateway.sock");
105919
- if (existsSync112(sock))
105920
- candidates.push({ agent: name, sock });
105921
- }
105934
+ candidates = collectRelayCandidates(agentsDir);
105922
105935
  } catch (e) {
105923
105936
  log(`hindsight-watch: agents dir unreadable (${e.message})`);
105924
105937
  }
@@ -21311,7 +21311,7 @@ function allocateAgentUid(name) {
21311
21311
  }
21312
21312
 
21313
21313
  // src/build-info.ts
21314
- var VERSION = "0.19.37";
21314
+ var VERSION = "0.19.38";
21315
21315
 
21316
21316
  // src/setup/hindsight-recall-tunables.ts
21317
21317
  var RECALL_DEADLINE_HEADROOM_SECONDS = 2;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "switchroom",
3
3
  "//version": "NOT the release version — source of truth is the git tag, resolved by scripts/build.mjs:resolveVersion() (see CLAUDE.md > Standard release process). This field is stale by design and only the Layer-4 dev/non-tag fallback for build.mjs + src/cli/resolve-version.ts; do NOT bump it expecting a release to pick it up. npm-pack tarball naming needs a real version — do that as an UNCOMMITTED pack-time bump (see release step 6), never a committed one.",
4
- "version": "0.19.37",
4
+ "version": "0.19.38",
5
5
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
6
6
  "type": "module",
7
7
  "bin": {
@@ -53405,6 +53405,12 @@ function createHandbackPreturnSignal(deps) {
53405
53405
  entry.reapTimer = null;
53406
53406
  if (entry.consumed)
53407
53407
  return;
53408
+ if (deps.isClaudeBusy?.() === true) {
53409
+ log(`handback-preturn-signal: reap deferred key=${entry.statusKey} ` + `(claude busy \u2014 handback enqueued behind an in-flight turn, not orphaned)
53410
+ `);
53411
+ entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs);
53412
+ return;
53413
+ }
53408
53414
  entry.consumed = true;
53409
53415
  stopTypingUnlessTurnLive(entry, "orphan reap");
53410
53416
  if (entry.activityMessageId != null) {
@@ -99672,10 +99678,10 @@ function startOutboxSweep(deps) {
99672
99678
  }
99673
99679
 
99674
99680
  // ../src/build-info.ts
99675
- var VERSION2 = "0.19.37";
99676
- var COMMIT_SHA = "5d2f9a17";
99677
- var COMMIT_DATE = "2026-07-30T08:57:38Z";
99678
- var LATEST_PR = 4023;
99681
+ var VERSION2 = "0.19.38";
99682
+ var COMMIT_SHA = "9ec88983";
99683
+ var COMMIT_DATE = "2026-07-30T09:50:08Z";
99684
+ var LATEST_PR = 4031;
99679
99685
  var COMMITS_AHEAD_OF_TAG = 0;
99680
99686
 
99681
99687
  // gateway/boot-version.ts
@@ -104885,7 +104891,7 @@ async function runMidSessionCardReaper() {
104885
104891
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
104886
104892
  path: ACTIVITY_CARD_STORE_PATH,
104887
104893
  fs: activityCardStoreFs,
104888
- isLive: (record2) => topicKeys.has(record2.turnKey),
104894
+ isLive: (record2) => topicKeys.has(record2.turnKey) || handbackPreturnSignal.isPreTurnRecord(record2.turnKey) && isMachineInTurn(),
104889
104895
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
104890
104896
  now,
104891
104897
  finalizeCard: (record2) => {
@@ -107525,7 +107531,8 @@ var handbackPreturnSignal = createHandbackPreturnSignal({
107525
107531
  if (live == null || live.endedAt != null)
107526
107532
  return false;
107527
107533
  return statusKey(live.sessionChatId, live.sessionThreadId) === key;
107528
- }
107534
+ },
107535
+ isClaudeBusy: () => isMachineInTurn()
107529
107536
  });
107530
107537
  var memoryLegibilityStager = new MemoryLegibilityStager;
107531
107538
  function sendMemoryLegibilityLine(event, chatId, threadId) {
@@ -8909,10 +8909,14 @@ async function runMidSessionCardReaper(): Promise<void> {
8909
8909
  const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
8910
8910
  path: ACTIVITY_CARD_STORE_PATH,
8911
8911
  fs: activityCardStoreFs,
8912
- // A synthetic pre-turn record's key is never a live topic key, so it is
8913
- // correctly seen as ownerless here (the seam's own age-based self-reap is
8914
- // the fast path; this is the crash / long-lived backstop).
8915
- isLive: (record) => topicKeys.has(record.turnKey),
8912
+ // A synthetic pre-turn key reads as ownerless here (self-reap is the fast
8913
+ // path; this is the crash backstop). EXCEPTION: a pre-turn handback still
8914
+ // enqueued behind an in-flight turn is NOT an orphan keep it live while
8915
+ // claude is busy, else this 15-min backstop re-posts the false nudge the
8916
+ // self-reap gate removed. (Boot reaper stays ungated: boot is never mid-turn.)
8917
+ isLive: (record) =>
8918
+ topicKeys.has(record.turnKey) ||
8919
+ (handbackPreturnSignal.isPreTurnRecord(record.turnKey) && isMachineInTurn()),
8916
8920
  ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
8917
8921
  now,
8918
8922
  finalizeCard: (record) => {
@@ -13827,6 +13831,10 @@ const handbackPreturnSignal = createHandbackPreturnSignal({
13827
13831
  // dark-indicator bug). Flag-ON is already per-key; the check is a no-op there.
13828
13832
  return statusKey(live.sessionChatId, live.sessionThreadId) === key
13829
13833
  },
13834
+ // Queue-state gate for the orphan reap (see the signal's `isClaudeBusy` doc):
13835
+ // while a turn is in flight the handback is enqueued behind it, not orphaned.
13836
+ // False for both `bridge_alive_idle` and `bridge_dead` → genuine orphan reaps.
13837
+ isClaudeBusy: () => isMachineInTurn(),
13830
13838
  })
13831
13839
 
13832
13840
  /**
@@ -54,9 +54,23 @@
54
54
  * On adoption the durable record is re-keyed from its synthetic pre-turn key
55
55
  * to the real `statusKey` so that teardown matches.
56
56
  *
57
- * • NEVER-ADOPTED ORPHAN REAP. A degenerate case (bridge death after release,
58
- * no enqueue ever arrives) would leave a frozen card + a forever `typing…`
59
- * loop. The pre-turn card is persisted as a COMPLETE `ActivityCardRecord`
57
+ * • NEVER-ADOPTED ORPHAN REAP GATED ON A GLOBAL-BUSY QUEUE CHECK. A
58
+ * degenerate case (bridge death after release, no enqueue ever arrives)
59
+ * would leave a frozen card + a forever `typing…` loop. But the reap must
60
+ * fire ONLY for that genuine orphan, never for a handback that is correctly
61
+ * enqueued and simply waiting behind a long in-flight turn: claude has ONE
62
+ * bridge / ONE input queue, so a released handback mints its adopting turn
63
+ * only when claude next goes idle. A flat age-based reap cannot tell the two
64
+ * apart and (before this gate) posted a false "never started — needs a
65
+ * nudge" card every time a parent turn out-ran the timeout (the 3-cards-in-
66
+ * 4-min incident). The reap therefore consults `isClaudeBusy()` — the
67
+ * machine-level "a turn is in flight right now" signal — and DEFERS
68
+ * (re-arms for another `adoptTimeoutMs`) while claude is busy, finalizing
69
+ * only once claude is idle AND the handback still has not been adopted. That
70
+ * idle-with-no-adoption state is the genuine bridge-death / dropped-inbound
71
+ * degenerate case. Lengthening the flat timeout would merely move the false
72
+ * positive; the queue-state gate removes it. The pre-turn card is persisted
73
+ * as a COMPLETE `ActivityCardRecord`
60
74
  * under a SYNTHETIC `turnKey` (`preturn:<statusKey>:<startedAt>`) — synthetic
61
75
  * so a sibling REAL turn on the same topic key can neither upsert-clobber it
62
76
  * nor keep it "live" (the mid-session reaper's `isLive` keys on live topic
@@ -171,6 +185,22 @@ export interface HandbackPreturnSignalDeps {
171
185
  * canonical turn-end owns the stop. Optional; defaults to "no live turn"
172
186
  * (the pre-#3544 behaviour). */
173
187
  hasLiveTurn?: (statusKey: string) => boolean
188
+ /** True IFF claude is CURRENTLY producing a turn (ANY topic — the machine has
189
+ * ONE bridge / ONE input queue). Consulted by the orphan reap: a released
190
+ * handback is delivered into that single queue and only mints its adopting
191
+ * turn when claude next goes idle, so while claude is busy the handback is
192
+ * NOT orphaned — it is correctly enqueued BEHIND the in-flight turn (a long
193
+ * parent turn can run many minutes). Finalizing it as an orphan in that
194
+ * window posts a false "never started — needs a nudge" card. The reap
195
+ * therefore DEFERS (re-arms) while this is true and finalizes only once
196
+ * claude is idle and the handback still has not been adopted — the genuine
197
+ * bridge-death / dropped-inbound degenerate case the reap exists for.
198
+ * Distinct from `hasLiveTurn`, which is PER-KEY and only governs the
199
+ * typing-loop stop; this is the GLOBAL busy signal because the handback
200
+ * queues behind whatever turn is running, regardless of topic. Optional;
201
+ * defaults to "not busy" (immediate reap — the pre-fix behaviour), so the
202
+ * genuine-orphan path is unchanged. */
203
+ isClaudeBusy?: () => boolean
174
204
  now?: () => number
175
205
  /** Debounce before painting the pre-turn card (kills sub-second flicker). */
176
206
  debounceMs?: number
@@ -349,6 +379,25 @@ export function createHandbackPreturnSignal(
349
379
  function reap(entry: PreTurnEntry): void {
350
380
  entry.reapTimer = null
351
381
  if (entry.consumed) return
382
+ // PRIMARY GATE (queue state, not a flat timeout): a released handback is
383
+ // delivered into claude's SINGLE input queue and mints its adopting turn
384
+ // only when claude next goes idle. If claude is STILL in a turn, this
385
+ // handback is not orphaned — it is correctly enqueued behind the in-flight
386
+ // turn (a long parent turn can run many minutes). Finalizing now would post
387
+ // a false "never started — needs a nudge" card (the 3-cards-in-4-min
388
+ // incident). Defer: re-arm and re-check. We finalize only once claude is
389
+ // idle AND the handback still has not been adopted (the genuine bridge-
390
+ // death / dropped-inbound case). This cannot loop forever on a healthy
391
+ // agent: claude processes its queue FIFO, so the handback is either adopted
392
+ // (cancelling the reap) or claude eventually goes idle (letting it fire).
393
+ if (deps.isClaudeBusy?.() === true) {
394
+ log(
395
+ `handback-preturn-signal: reap deferred key=${entry.statusKey} ` +
396
+ `(claude busy — handback enqueued behind an in-flight turn, not orphaned)\n`,
397
+ )
398
+ entry.reapTimer = setTimer(() => reap(entry), adoptTimeoutMs)
399
+ return
400
+ }
352
401
  entry.consumed = true
353
402
  // Stop the forever-running typing loop and finalize the frozen card.
354
403
  stopTypingUnlessTurnLive(entry, 'orphan reap')
@@ -300,6 +300,73 @@ describe('handback-preturn-signal — dead-air pre-turn emit→adopt→reap', ()
300
300
  expect(h.signal.pendingCount()).toBe(0) // map empty
301
301
  })
302
302
 
303
+ // ── queued-behind-a-long-turn is NOT an orphan (the 3-cards-in-4-min bug) ──
304
+ // A handback released while the parent is mid-turn on a long task is delivered
305
+ // into claude's single input queue and mints its adopting turn only when the
306
+ // parent turn ends. The flat 30 s reap could not tell "enqueued and waiting"
307
+ // from "bridge died, never enqueued" and finalized a false "handback never
308
+ // started — needs a nudge" card. With the queue-state gate (`isClaudeBusy`)
309
+ // the reap DEFERS while claude is busy and the late adoption finalizes the
310
+ // card honestly. This test FAILS on the pre-fix flat-30 s behaviour: without
311
+ // the gate the reap fires at 30 s, finalizes the orphan text, and consumes the
312
+ // entry so the later adoption returns null.
313
+ it('does NOT finalize a handback whose turn adopts late because the parent was busy', async () => {
314
+ let busy = false
315
+ const h = makeHarness({ isClaudeBusy: () => busy })
316
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatBusy', messageId: 700 }))
317
+ await h.sched.advance(700) // debounce: card painted, reap armed
318
+ const resolvedId = await h.openCard.mock.results[0]!.value
319
+ expect(h.records.size).toBe(1)
320
+
321
+ // The parent turn is now in flight (a long Wave-2 assembly). The handback is
322
+ // enqueued behind it.
323
+ busy = true
324
+
325
+ // Two full reap intervals elapse — well beyond the flat 30 s. The reap must
326
+ // DEFER each time: no false orphan card, entry still live, typing still lit.
327
+ await h.sched.advance(30_000)
328
+ await h.sched.advance(30_000)
329
+ expect(h.finalizedIds).toEqual([]) // NEVER posted the "never started" nudge
330
+ expect(h.signal.pendingCount()).toBe(1) // still queued, not reaped
331
+ expect(h.typing.activeCount()).toBe(1) // indicator kept while queued
332
+
333
+ // The parent turn finally ends and the handback mints its own turn: it
334
+ // adopts the SAME card (an edit, not a second send / not an orphan finalize).
335
+ busy = false
336
+ const turnId = deriveTurnId('chatBusy', null, 700)!
337
+ const adoption = h.signal.tryAdopt(turnId)
338
+ expect(adoption).not.toBeNull()
339
+ expect(adoption!.activityMessageId).toBe(resolvedId)
340
+ expect(h.finalizedIds).toEqual([]) // adopted, never finalized as orphan
341
+ expect(h.openCard).toHaveBeenCalledTimes(1) // no double-send
342
+ expect(h.signal.pendingCount()).toBe(0)
343
+ })
344
+
345
+ // The deferred reap is not a leak: once claude goes idle and the handback has
346
+ // STILL not been adopted (genuine bridge-death / dropped inbound), the re-armed
347
+ // reap finalizes honestly — the degenerate case the reap exists for.
348
+ it('finalizes a genuine orphan once claude goes idle, even after deferring while busy', async () => {
349
+ let busy = true
350
+ const h = makeHarness({ isClaudeBusy: () => busy })
351
+ h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatIdleOrphan', messageId: 710 }))
352
+ await h.sched.advance(700)
353
+ const resolvedId = await h.openCard.mock.results[0]!.value
354
+
355
+ // Busy: the first reap defers.
356
+ await h.sched.advance(30_000)
357
+ expect(h.finalizedIds).toEqual([])
358
+ expect(h.signal.pendingCount()).toBe(1)
359
+
360
+ // Claude goes idle but NO adopting turn ever mints (the inbound was dropped).
361
+ // The re-armed reap now finalizes the genuine orphan.
362
+ busy = false
363
+ await h.sched.advance(30_000)
364
+ expect(h.finalizedIds).toEqual([resolvedId])
365
+ expect(h.typing.activeCount()).toBe(0)
366
+ expect(h.records.size).toBe(0)
367
+ expect(h.signal.pendingCount()).toBe(0)
368
+ })
369
+
303
370
  it('identity race: a user inbound on the same topic never mis-adopts the handback, no double-send', async () => {
304
371
  const h = makeHarness()
305
372
  h.signal.noteHandbackRelease(handbackInbound({ chatId: 'chatE', messageId: 200 }))