switchroom 0.17.10 → 0.18.3

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 (61) hide show
  1. package/bin/workspace-dynamic-hook.sh +12 -13
  2. package/dist/agent-scheduler/index.js +27 -1
  3. package/dist/auth-broker/index.js +6161 -151
  4. package/dist/cli/notion-write-pretool.mjs +29 -2
  5. package/dist/cli/switchroom.js +578 -454
  6. package/dist/host-control/main.js +6182 -172
  7. package/dist/vault/approvals/kernel-server.js +5891 -164
  8. package/dist/vault/broker/server.js +6597 -881
  9. package/package.json +1 -1
  10. package/profiles/_base/settings.json.hbs +2 -2
  11. package/profiles/_base/start.sh.hbs +170 -21
  12. package/profiles/coding/CLAUDE.md.hbs +1 -1
  13. package/profiles/default/CLAUDE.md +2 -2
  14. package/profiles/default/CLAUDE.md.hbs +2 -2
  15. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  16. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  17. package/telegram-plugin/auth-snapshot-format.ts +22 -24
  18. package/telegram-plugin/context-exhaustion.ts +124 -0
  19. package/telegram-plugin/dist/gateway/gateway.js +24086 -8727
  20. package/telegram-plugin/gateway/activity-card-store.ts +76 -0
  21. package/telegram-plugin/gateway/gateway.ts +480 -85
  22. package/telegram-plugin/gateway/inbound-delivery-gate.ts +26 -0
  23. package/telegram-plugin/gateway/model-command.ts +70 -10
  24. package/telegram-plugin/package.json +6 -0
  25. package/telegram-plugin/quota-watch.ts +4 -6
  26. package/telegram-plugin/registry/turns-schema.test.ts +97 -0
  27. package/telegram-plugin/registry/turns-schema.ts +78 -0
  28. package/telegram-plugin/render/ir.ts +209 -0
  29. package/telegram-plugin/render/parse.ts +363 -0
  30. package/telegram-plugin/render/render.ts +440 -0
  31. package/telegram-plugin/render/rich-render.ts +72 -0
  32. package/telegram-plugin/stream-controller.ts +14 -3
  33. package/telegram-plugin/tests/activity-card-store.test.ts +94 -0
  34. package/telegram-plugin/tests/auth-command-format2.test.ts +1 -1
  35. package/telegram-plugin/tests/auth-snapshot-format.test.ts +30 -16
  36. package/telegram-plugin/tests/claude-code-event-contract.test.ts +48 -0
  37. package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +11 -0
  38. package/telegram-plugin/tests/feed-survival.test.ts +39 -0
  39. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +81 -0
  40. package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +82 -0
  41. package/telegram-plugin/tests/liveness-tracker.test.ts +228 -0
  42. package/telegram-plugin/tests/model-command.test.ts +193 -16
  43. package/telegram-plugin/tests/narrative-render.test.ts +125 -0
  44. package/telegram-plugin/tests/orphaned-reply-rearm.test.ts +123 -163
  45. package/telegram-plugin/tests/quota-watch.test.ts +1 -4
  46. package/telegram-plugin/tests/rapid-fire-delivery-ordering.test.ts +149 -0
  47. package/telegram-plugin/tests/render/parse-torture.test.ts +136 -0
  48. package/telegram-plugin/tests/render/parse.test.ts +393 -0
  49. package/telegram-plugin/tests/render/render.test.ts +436 -0
  50. package/telegram-plugin/tests/render/rich-render.test.ts +85 -0
  51. package/telegram-plugin/tests/telegram-activity-visibility-integration.test.ts +155 -1
  52. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +98 -3
  53. package/telegram-plugin/turn-liveness-floor.ts +35 -1
  54. package/telegram-plugin/uat/scenarios/jtbd-rich-formatting-render-dm.test.ts +99 -7
  55. package/telegram-plugin/worktree-watch-cwds.ts +92 -17
  56. package/vendor/hindsight-memory/scripts/lib/client.py +11 -1
  57. package/vendor/hindsight-memory/scripts/lib/config.py +9 -2
  58. package/vendor/hindsight-memory/scripts/recall.py +64 -6
  59. package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +1 -0
  60. package/vendor/hindsight-memory/tests/test_client.py +43 -0
  61. package/vendor/hindsight-memory/tests/test_recall_precision.py +114 -0
@@ -294,6 +294,7 @@ import {
294
294
  shouldArmOrphanedReplyTimeout,
295
295
  ORPHANED_REPLY_TIMEOUT_MS,
296
296
  ORPHANED_REPLY_MAX_REARMS,
297
+ LivenessTracker,
297
298
  } from '../context-exhaustion.js'
298
299
  import {
299
300
  decideTurnFlush,
@@ -346,6 +347,7 @@ import {
346
347
  buildModelMenu,
347
348
  handleModelMenuCallback,
348
349
  isSrToClaudeTransition,
350
+ isValidModelArg,
349
351
  MODEL_CALLBACK_PREFIX,
350
352
  MODEL_CALLBACK_HEADER,
351
353
  MODEL_CALLBACK_SR,
@@ -357,6 +359,7 @@ import {
357
359
  type ModelMenuReply,
358
360
  } from './model-command.js'
359
361
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
362
+ import { resolveMainModel } from '../../src/agents/scaffold.js'
360
363
  import {
361
364
  parseEffortCommand,
362
365
  handleEffortCommand,
@@ -419,6 +422,7 @@ import {
419
422
  writeActivityCardRecord,
420
423
  clearActivityCardRecord,
421
424
  runActivityCardBootReaper,
425
+ runActivityCardMidSessionReaper,
422
426
  restartOrphanCardFinalizeText,
423
427
  type ActivityCardStoreFsSeam,
424
428
  } from './activity-card-store.js'
@@ -427,7 +431,7 @@ import { shouldSuppressRepresent } from './represent-guard.js'
427
431
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
428
432
  import { createInboundSpool } from './inbound-spool.js'
429
433
  import { purgeStaleTurnsForChat } from './turn-state-purge.js'
430
- import { decideInboundDelivery } from './inbound-delivery-gate.js'
434
+ import { decideInboundDelivery, reserveInboundDelivery } from './inbound-delivery-gate.js'
431
435
  import { mayDrainBufferedInbound, shouldArmNoReplyDrain } from './serialize-drain-gate.js'
432
436
  import { decideFeedReopen } from './feed-reopen-gate.js'
433
437
  import {
@@ -662,6 +666,7 @@ import {
662
666
  findRecentTurnsForChat,
663
667
  getTurnByKey,
664
668
  markTurnResumed,
669
+ reapStaleOpenTurns,
665
670
  } from '../registry/turns-schema.js'
666
671
  import {
667
672
  buildResumeInterruptedInbound,
@@ -1949,6 +1954,25 @@ const obligationEscalateInFlight = new Set<string>()
1949
1954
  // drain on the bare turn-end signal.
1950
1955
  const SERIALIZE_UNTIL_REPLIED_ENABLED =
1951
1956
  process.env.SWITCHROOM_SERIALIZE_UNTIL_REPLIED !== '0'
1957
+ // #2917 — rapid-fire per-chat FIFO. The #1556 delivery gate documents its
1958
+ // `turnInFlight` input as a LIVE read ("evaluated at delivery time — not a
1959
+ // receipt-time snapshot"), but handleInbound passes a receipt-time snapshot
1960
+ // (`turnInFlightAtReceipt`) taken at function entry. Under rapid-fire two
1961
+ // same-chat inbounds each snapshot "idle" during the other's async lead-in
1962
+ // (attachment download, composer-clear), both pass the gate, and both are
1963
+ // delivered — so replies come back reordered (observed 1,2,4,5,3,7,8,6). The
1964
+ // fix reads the gate LIVE off `claudeBusyKeys` (which does NOT yet contain
1965
+ // THIS inbound's own key — that is only marked at delivery — so it can't
1966
+ // self-block) AND reserves the key synchronously BEFORE the composer-clear
1967
+ // await, so a concurrent same-chat inbound observes the reservation and
1968
+ // buffers behind it, preserving FIFO. Nothing is dropped: a buffered inbound
1969
+ // drains on turn-complete / idle exactly as before. Kill switch (=0) restores
1970
+ // the receipt-snapshot behaviour. Only the non-cutover path is affected — the
1971
+ // delivery-machine cutover keeps its own at-receipt machine snapshot (reading
1972
+ // the machine live WOULD self-block, since its inbound event already advanced
1973
+ // it for this key).
1974
+ const SERIALIZE_INBOUND_DELIVERY_ENABLED =
1975
+ process.env.SWITCHROOM_SERIALIZE_INBOUND_DELIVERY !== '0'
1952
1976
  // Component 2 (bounded no-reply escape hatch). A turn that legitimately
1953
1977
  // ends with NO reply (handback ack, NO_REPLY marker, silent-end) sets
1954
1978
  // finalAnswerDelivered=false and would block the serialize gate forever.
@@ -2348,13 +2372,15 @@ type CurrentTurn = {
2348
2372
  silentAnchorText: string
2349
2373
  capturedText: string[]
2350
2374
  orphanedReplyTimeoutId: ReturnType<typeof setTimeout> | null
2351
- // How many times the orphaned-reply backstop timer has been re-armed
2352
- // mid-tool-call instead of firing a synthetic turn_end. Bounded so a
2353
- // genuinely wedged single long-running tool still surfaces: the cap is
2354
- // ORPHANED_REPLY_MAX_REARMS (20 × 30 s = 10 min of genuine tool activity).
2355
- // Reset to 0 on a fresh enqueue; NOT reset on text/tool_label re-arms —
2356
- // only a new turn resets the budget.
2357
- orphanedReplyRearmCount: number
2375
+ // Per-turn liveness tracker for the orphaned-reply backstop. Owns
2376
+ // `lastStreamEventAt` (stamped on ANY genuine stream event so a model
2377
+ // reasoning pause keeps the turn "recently streaming" and re-arms the fuse
2378
+ // instead of firing) and the rearm counter (bounded by
2379
+ // ORPHANED_REPLY_MAX_REARMS so a genuinely wedged single tool that never
2380
+ // streams still surfaces after the cap). The counter is zeroed on every
2381
+ // genuine stream event, so the cap only bites CONSECUTIVE silent expiries.
2382
+ // Fresh instance per enqueue (below). See context-exhaustion.ts.
2383
+ liveness: LivenessTracker
2358
2384
  // Component 3 (turn-origin reply routing). A stable per-turn identity,
2359
2385
  // `${registryKey-or-chatKey}#${startedAt}`, assigned when the turn
2360
2386
  // starts and stamped into the inbound meta (`origin_turn_id`) so a reply
@@ -6230,6 +6256,127 @@ async function activityCardBootReaper(): Promise<void> {
6230
6256
  )
6231
6257
  }
6232
6258
  }
6259
+ // ─── Mid-session stale-card reaper (#2918) ──────────────────────────────────
6260
+ // The boot reapers (markOrphanedWithTimeoutClassification + activityCardBoot-
6261
+ // Reaper) run ONCE at startup. A turn whose owning SDK subprocess dies
6262
+ // mid-session (SIGKILL / OOM / crash) without a clean end leaves its turns-DB
6263
+ // row `ended_at IS NULL` and its activity card frozen on "working…" until the
6264
+ // NEXT gateway boot — often many hours. This periodic sweep, running inside the
6265
+ // live gateway, finalizes those orphans without waiting for a restart.
6266
+ //
6267
+ // LIVENESS (the critical correctness guard): a turn/card is reaped ONLY when no
6268
+ // live in-flight turn owns it. The live set is derived from `currentTurnMap`
6269
+ // (per-topic byKey) PLUS the singleton `currentTurn` mirror, so it is correct
6270
+ // whether or not the per-topic-isolation flag is on. A healthy long-running
6271
+ // turn is always in that set and is never swept. A TTL gate is a secondary
6272
+ // guard against the just-started-not-yet-tracked race.
6273
+ //
6274
+ // Kill switch: set SWITCHROOM_MID_SESSION_CARD_REAPER=0 to disable (clean
6275
+ // revert, mirrors #2919). TTL/interval overridable via env for tuning.
6276
+ const MID_SESSION_CARD_REAPER_ENABLED =
6277
+ process.env.SWITCHROOM_MID_SESSION_CARD_REAPER !== '0'
6278
+ const MID_SESSION_CARD_REAPER_TTL_MS = (() => {
6279
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_TTL_MS)
6280
+ return Number.isFinite(v) && v > 0 ? v : 15 * 60_000 // 15 min
6281
+ })()
6282
+ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
6283
+ const v = Number(process.env.SWITCHROOM_MID_SESSION_CARD_REAPER_INTERVAL_MS)
6284
+ return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
6285
+ })()
6286
+
6287
+ // Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
6288
+ function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
6289
+ const registryKeys = new Set<string>()
6290
+ const topicKeys = new Set<string>()
6291
+ const add = (t: CurrentTurn | null | undefined): void => {
6292
+ if (t == null) return
6293
+ if (t.registryKey != null && t.registryKey.length > 0) registryKeys.add(t.registryKey)
6294
+ topicKeys.add(statusKey(t.sessionChatId, t.sessionThreadId))
6295
+ }
6296
+ for (const t of currentTurnMap.byKey.values()) add(t)
6297
+ add(currentTurn) // flag-OFF store + most-recent mirror
6298
+ return { registryKeys, topicKeys }
6299
+ }
6300
+
6301
+ async function runMidSessionCardReaper(): Promise<void> {
6302
+ if (!MID_SESSION_CARD_REAPER_ENABLED) return
6303
+ const now = Date.now()
6304
+ const { registryKeys, topicKeys } = liveTurnKeySets()
6305
+
6306
+ // 1) Stamp ownerless open turns-DB rows (the durable spinner signal).
6307
+ if (turnsDb != null) {
6308
+ try {
6309
+ const { reaped, reapedTurnKeys } = reapStaleOpenTurns(turnsDb, {
6310
+ activeTurnKeys: registryKeys,
6311
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6312
+ now,
6313
+ })
6314
+ if (reaped > 0) {
6315
+ process.stderr.write(
6316
+ `telegram gateway: mid-session reaper stamped ${reaped} orphaned turn(s) ` +
6317
+ `as 'restart' (${reapedTurnKeys.join(',')})\n`,
6318
+ )
6319
+ }
6320
+ } catch (err) {
6321
+ process.stderr.write(
6322
+ `telegram gateway: mid-session turn reaper error: ${(err as Error).message}\n`,
6323
+ )
6324
+ }
6325
+ }
6326
+
6327
+ // 2) Finalize the leftover visible activity card(s) for those dead turns.
6328
+ if (activityCardPersistEnabled) {
6329
+ try {
6330
+ const { finalized, vanished, total } = await runActivityCardMidSessionReaper({
6331
+ path: ACTIVITY_CARD_STORE_PATH,
6332
+ fs: activityCardStoreFs,
6333
+ isLive: (record) => topicKeys.has(record.turnKey),
6334
+ ttlMs: MID_SESSION_CARD_REAPER_TTL_MS,
6335
+ now,
6336
+ finalizeCard: (record) =>
6337
+ robustApiCall(
6338
+ () =>
6339
+ lockedBot.api.editMessageText(
6340
+ record.chatId,
6341
+ record.activityMessageId,
6342
+ richMessage(restartOrphanCardFinalizeText(record.startedAt)),
6343
+ {},
6344
+ ),
6345
+ {
6346
+ chat_id: record.chatId,
6347
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6348
+ verb: 'activity-card.mid-session-reap-finalize',
6349
+ },
6350
+ ),
6351
+ unpinCard: (record) =>
6352
+ robustApiCall(
6353
+ () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
6354
+ {
6355
+ chat_id: record.chatId,
6356
+ ...(record.threadId != null ? { threadId: record.threadId } : {}),
6357
+ verb: 'activity-card.mid-session-reap-unpin',
6358
+ },
6359
+ ),
6360
+ })
6361
+ if (total > 0) {
6362
+ process.stderr.write(
6363
+ `telegram gateway: activity-card: mid-session finalized ${finalized}/${total} ` +
6364
+ `(vanished ${vanished}/${total}) orphaned card(s) (at-most-once)\n`,
6365
+ )
6366
+ }
6367
+ } catch (err) {
6368
+ process.stderr.write(
6369
+ `telegram gateway: mid-session card reaper error: ${(err as Error).message}\n`,
6370
+ )
6371
+ }
6372
+ }
6373
+ }
6374
+
6375
+ const midSessionCardReaper = setInterval(() => {
6376
+ void runMidSessionCardReaper()
6377
+ }, MID_SESSION_CARD_REAPER_INTERVAL_MS)
6378
+ midSessionCardReaper.unref()
6379
+
6233
6380
  // NOTE: statusPinBootCleanup() is deliberately NOT invoked here at import time.
6234
6381
  // The status-pin store is a SHARED per-agent file, and cleanup issues real
6235
6382
  // unpinChatMessage calls. On a double-boot the losing gateway must NOT touch
@@ -6596,6 +6743,11 @@ function parsePositiveMsEnv(name: string, fallbackMs: number): number {
6596
6743
  const n = Number(raw)
6597
6744
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallbackMs
6598
6745
  }
6746
+ // Orphaned-reply "recently streaming" window (thinking-pause fix). If a genuine
6747
+ // stream event landed within this window, the fuse re-arms instead of firing —
6748
+ // so a long model reasoning pause (which emits no text/tool events) is
6749
+ // survivable while a genuine multi-minute hang still surfaces. Default 120 s.
6750
+ const ORPHANED_REPLY_STREAM_WINDOW_MS = parsePositiveMsEnv('SWITCHROOM_ORPHANED_REPLY_STREAM_WINDOW_MS', 120_000)
6599
6751
  const SILENCE_FALLBACK_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_MS', 300_000)
6600
6752
  const SILENCE_FALLBACK_HARD_MS = parsePositiveMsEnv('SWITCHROOM_SILENCE_FALLBACK_HARD_MS', 900_000)
6601
6753
  // #2527 — mid-turn liveness floor threshold (default 45s). Still gates the
@@ -12411,20 +12563,32 @@ function resetOrphanedReplyTimeout(): void {
12411
12563
  }
12412
12564
  return false
12413
12565
  })()
12414
- if (working || humanWaiting) {
12415
- const underCap = t.orphanedReplyRearmCount < ORPHANED_REPLY_MAX_REARMS
12416
- if (humanWaiting || underCap) {
12417
- t.orphanedReplyRearmCount++
12418
- process.stderr.write(
12419
- `telegram gateway: orphaned-reply fuse expired — re-arming` +
12420
- ` (rearm ${t.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
12421
- ` in_flight=${toolFlightTracker.inFlightCount()},` +
12422
- ` human_wait=${humanWaiting},` +
12423
- ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
12424
- )
12425
- resetOrphanedReplyTimeout()
12426
- return
12427
- }
12566
+ // Route the rearm decision through the per-turn LivenessTracker. It
12567
+ // rearms when working OR recently-streaming (thinking-pause survival)
12568
+ // OR human-waiting; working/recently-streaming rearms count against
12569
+ // ORPHANED_REPLY_MAX_REARMS while human-wait rearms stay uncapped.
12570
+ const now = Date.now()
12571
+ const recentlyStreaming = t.liveness.recentlyStreaming(now, ORPHANED_REPLY_STREAM_WINDOW_MS)
12572
+ const decision = t.liveness.decideOnExpiry({
12573
+ working,
12574
+ humanWaiting,
12575
+ now,
12576
+ windowMs: ORPHANED_REPLY_STREAM_WINDOW_MS,
12577
+ maxRearms: ORPHANED_REPLY_MAX_REARMS,
12578
+ })
12579
+ if (decision.rearm) {
12580
+ process.stderr.write(
12581
+ `telegram gateway: orphaned-reply fuse expired — re-arming` +
12582
+ ` (rearm ${t.liveness.orphanedReplyRearmCount}/${ORPHANED_REPLY_MAX_REARMS},` +
12583
+ ` in_flight=${toolFlightTracker.inFlightCount()},` +
12584
+ ` human_wait=${humanWaiting},` +
12585
+ ` recently_streaming=${recentlyStreaming},` +
12586
+ ` bg_work=${pendingProgress.hasPendingAsyncDispatch(turnKey)})\n`,
12587
+ )
12588
+ resetOrphanedReplyTimeout()
12589
+ return
12590
+ }
12591
+ if (decision.countsAgainstCap) {
12428
12592
  process.stderr.write(
12429
12593
  `telegram gateway: orphaned-reply rearm cap reached (${ORPHANED_REPLY_MAX_REARMS}) — forcing backstop despite working state\n`,
12430
12594
  )
@@ -12900,11 +13064,19 @@ function feedHeartbeatTick(): void {
12900
13064
  // durable record once it completes.
12901
13065
  // - 'emit' → genuine in-flight post-answer activity; render the card below.
12902
13066
  const subagentAt = turn.subagentActivityAt
13067
+ // Fix 3 (sub-agent-delegation freeze): a foreground `Task`/`Agent` still
13068
+ // tracked in `turn.foregroundSubAgents` is POSITIVE evidence the worker
13069
+ // has not reported finished — see the doc comment on
13070
+ // `PostAnswerLivenessInput.stillDispatched` in turn-liveness-floor.ts for
13071
+ // why this must bypass the staleness cap rather than let a single long
13072
+ // silent step freeze the card mid-delegation.
13073
+ const stillDispatched = turn.foregroundSubAgents.size > 0
12903
13074
  const livenessVerdict = evaluatePostAnswerLiveness({
12904
13075
  subagentActivityAt: subagentAt,
12905
13076
  finalAnswerDeliveredAt: turn.finalAnswerDeliveredAt,
12906
13077
  now: Date.now(),
12907
13078
  staleCapMs: POST_ANSWER_LIVENESS_STALE_MS,
13079
+ stillDispatched,
12908
13080
  })
12909
13081
  if (livenessVerdict !== 'emit' || subagentAt == null) return // idle gap or stale worker → stay silent (the `== null` also narrows subagentAt for the elapsed below)
12910
13082
  // A background worker is genuinely active after the answer. Open or maintain
@@ -13278,6 +13450,22 @@ function surfaceConsolidationLegibility(
13278
13450
  }
13279
13451
 
13280
13452
  function handleSessionEvent(ev: SessionEvent): void {
13453
+ // Per-turn liveness stamp (orphaned-reply thinking-pause fix). Stamp
13454
+ // lastStreamEventAt AND reset the rearm counter on ANY genuine stream event,
13455
+ // under ONE shared predicate: a live turn is present and this is not the
13456
+ // synthetic durationMs===-1 turn_end (the fire callback's own re-dispatch).
13457
+ // The counter reset MUST live here at the dispatcher — NOT inside
13458
+ // resetOrphanedReplyTimeout() (which is called from the fire callback one
13459
+ // line after the counter increments) and NOT tied to a single case (e.g.
13460
+ // tool_result does not call resetOrphanedReplyTimeout). onStreamEvent applies
13461
+ // the `!(turn_end && -1)` half of the predicate internally.
13462
+ {
13463
+ const liveTurn = currentTurn
13464
+ if (liveTurn != null) {
13465
+ const durationMs = ev.kind === 'turn_end' ? ev.durationMs : undefined
13466
+ liveTurn.liveness.onStreamEvent(ev.kind, durationMs, Date.now())
13467
+ }
13468
+ }
13281
13469
  switch (ev.kind) {
13282
13470
  case 'enqueue': {
13283
13471
  // Drain any orphaned typing-wrap entries left over from a crashed
@@ -13314,6 +13502,14 @@ function handleSessionEvent(ev: SessionEvent): void {
13314
13502
  prior.answerStream.stop()
13315
13503
  prior.answerStream = null
13316
13504
  }
13505
+ // Bounded-leak hardening (A5): clear the prior turn's orphaned-reply
13506
+ // fuse before it is superseded. The fire callback re-reads currentTurn
13507
+ // and no-ops on a stale turn, but proactively clearing the timer avoids
13508
+ // a bounded pile-up of dangling timers across rapid steer/queue turns.
13509
+ if (prior?.orphanedReplyTimeoutId != null) {
13510
+ clearTimeout(prior.orphanedReplyTimeoutId)
13511
+ prior.orphanedReplyTimeoutId = null
13512
+ }
13317
13513
  // #1067: swap the entire turn atom in one assignment. Every
13318
13514
  // handler captures `const turn = currentTurn` at entry, so a
13319
13515
  // captured-then-awaited read can't reattribute to the new turn.
@@ -13373,7 +13569,9 @@ function handleSessionEvent(ev: SessionEvent): void {
13373
13569
  silentAnchorText: '',
13374
13570
  capturedText: [],
13375
13571
  orphanedReplyTimeoutId: null,
13376
- orphanedReplyRearmCount: 0,
13572
+ // Fresh liveness tracker: lastStreamEventAt seeded to the turn start
13573
+ // so a turn that never streams still trips the fuse after windowMs.
13574
+ liveness: new LivenessTracker(startedAt),
13377
13575
  turnId,
13378
13576
  registryKey: null,
13379
13577
  noReplyDrainTimer: null,
@@ -14043,10 +14241,25 @@ function handleSessionEvent(ev: SessionEvent): void {
14043
14241
  if (ev.durationMs === -1) {
14044
14242
  const turn = currentTurn
14045
14243
  const key = turn != null ? statusKey(turn.sessionChatId, turn.sessionThreadId) : ''
14046
- if (isLegitimatelyWorking(key)) {
14244
+ // Widened to also suppress while the turn is RECENTLY STREAMING — a
14245
+ // model reasoning pause emits no tool/text events (so
14246
+ // isLegitimatelyWorking is false) but a genuine stream landed within
14247
+ // the window, so the turn is alive and must not be torn down.
14248
+ // ACCEPTED TRADE-OFF (F3): the context-exhaustion recovery latency via
14249
+ // THIS backstop path grows from ~30 s to ~120-150 s, because the
14250
+ // "Prompt is too long" marker is itself a genuine `text` event that
14251
+ // stamps recentlyStreaming. This is acceptable — the primary
14252
+ // context-exhaustion teardown is the immediate `endCurrentTurnAtomic`
14253
+ // in `case 'text'` (isContextExhaustionText) above; this backstop only
14254
+ // matters if that path is missed, and it still COMPLETES once the
14255
+ // window lapses. Recovery is delayed, never suppressed forever.
14256
+ const recentlyStreaming =
14257
+ turn != null && turn.liveness.recentlyStreaming(Date.now(), ORPHANED_REPLY_STREAM_WINDOW_MS)
14258
+ if (isLegitimatelyWorking(key) || recentlyStreaming) {
14047
14259
  process.stderr.write(
14048
14260
  `telegram gateway: synthetic turn_end suppressed — legitimately working` +
14049
14261
  ` (in_flight=${toolFlightTracker.inFlightCount()},` +
14262
+ ` recently_streaming=${recentlyStreaming},` +
14050
14263
  ` bg_work=${turn != null ? pendingProgress.hasPendingAsyncDispatch(key) : false})\n`,
14051
14264
  )
14052
14265
  return
@@ -15340,30 +15553,32 @@ async function handleInbound(
15340
15553
  // network RTT) but not a user-perceived end-to-end measurement.
15341
15554
  const inboundReceivedAt = Date.now()
15342
15555
 
15343
- // Phase 2b shadow: inbound arrival. Emit BEFORE the snapshot/gate
15344
- // logic so the machine sees the event at the same point in time the
15345
- // imperative code would. The machine internally handles fresh-turn
15346
- // vs mid-turn — its decision will be visible in the gw-trace shadow
15347
- // line emitted to stderr.
15348
- const _shadowKey = statusKey(ctx.chat?.id != null ? String(ctx.chat.id) : '0', ctx.message?.message_thread_id) as _ChatKey
15349
- // PR3b-cutover: snapshot the machine's in-turn state BEFORE the
15350
- // inbound event advances it. A fresh-turn inbound transitions the
15351
- // machine idle→in_turn; reading after the emit would see THIS
15352
- // message's own just-started turn and self-block it (the same
15353
- // self-block hazard the claudeBusyKeys snapshot below guards). When
15354
- // the kill-switch is off this is null and the gate uses the legacy
15556
+ // PR3b-cutover: snapshot the machine's in-turn state AT RECEIPT, before
15557
+ // this handler emits the `inbound` event (that emit is DEFERRED below to
15558
+ // the delivery-commit point — search DEFERRED_INBOUND_EMIT). A fresh-turn
15559
+ // inbound transitions the machine idle→in_turn; reading after the emit
15560
+ // would see THIS message's own just-started turn and self-block it (the
15561
+ // same self-block hazard the claudeBusyKeys snapshot below guards). Null
15562
+ // when the kill-switch is off, in which case the gate uses the legacy
15355
15563
  // claudeBusyKeys read.
15564
+ //
15565
+ // WHY THE INBOUND EMIT IS DEFERRED (overlord /usage wedge, 2026-07-08):
15566
+ // the `inbound` event drives the now-AUTHORITATIVE turn-in-flight gate
15567
+ // (turnInFlightForGate → isMachineInTurn). Emitting it HERE — at handler
15568
+ // entry, before the intercept gauntlet below (permission-reply, /auth
15569
+ // paste-back, interrupt-empty, secret-detect drop, …) — drove the machine
15570
+ // into `bridge_alive_in_turn` for messages that then EARLY-RETURN as an
15571
+ // intercept and never become a turn. No delivery, no claudeBusyKeys mark,
15572
+ // and critically no `turnEnd` ever fires, so the machine held the gate
15573
+ // closed until the 5-min TTL tick force-cleared it — buffering every
15574
+ // subsequent inbound (including /usage) the whole time. That is exactly
15575
+ // the dangerous `machine_over_holds` divergence gate-parity-probe.ts
15576
+ // flags. The imperative claudeBusyKeys tracker got it right (never marked
15577
+ // busy for the intercepted message); the machine was mis-fed. Fix: emit
15578
+ // `inbound` only once the message clears every intercept and reaches the
15579
+ // deliver-or-buffer decision, keeping the machine in lockstep with the
15580
+ // imperative delivery lifecycle it models.
15356
15581
  const machineInTurnAtReceipt = isDeliveryCutoverEnabled() ? isMachineInTurn() : null
15357
- shadowEmit({
15358
- kind: 'inbound',
15359
- key: _shadowKey,
15360
- msg: {
15361
- msgId: ctx.message?.message_id ?? 0,
15362
- isSteering: false, // refined in PR 3 — for now shadow conservatively classifies as non-steering
15363
- payload: null,
15364
- },
15365
- at: Date.now(),
15366
- })
15367
15582
 
15368
15583
  // #1556 self-blocking fix (v0.12.22): snapshot the live turn-state
15369
15584
  // BEFORE the fresh-turn branch (line ~7357) sets activeTurnStartedAt
@@ -16560,6 +16775,29 @@ async function handleInbound(
16560
16775
  return
16561
16776
  }
16562
16777
 
16778
+ // DEFERRED_INBOUND_EMIT — drive the delivery state machine's `inbound`
16779
+ // event HERE, not at handler entry. Every intercept/early-return above
16780
+ // (permission-reply, /auth paste-back, interrupt-empty, secret-detect
16781
+ // drop, drop/pair) has been passed, so any message reaching this point is
16782
+ // a genuine turn the imperative code is about to deliver or buffer. That
16783
+ // keeps the machine's authoritative turn-in-flight state in lockstep with
16784
+ // the imperative delivery it models and can never be advanced into
16785
+ // `bridge_alive_in_turn` by a message that never becomes a turn — the
16786
+ // overlord /usage wedge of 2026-07-08 (see machineInTurnAtReceipt above).
16787
+ // isSteering is now the real classification (computed at ~line 16289), so
16788
+ // the machine correctly distinguishes a mid-turn steer (delivered, no new
16789
+ // turn) from a fresh turn.
16790
+ shadowEmit({
16791
+ kind: 'inbound',
16792
+ key: statusKey(chat_id, messageThreadId) as _ChatKey,
16793
+ msg: {
16794
+ msgId: msgId ?? 0,
16795
+ isSteering,
16796
+ payload: null,
16797
+ },
16798
+ at: Date.now(),
16799
+ })
16800
+
16563
16801
  // PR2 obligation-ledger OPEN — BEFORE the buffer-until-idle / deliver split so
16564
16802
  // a mid-turn cross-topic inbound (the 715 case) is tracked whether it is
16565
16803
  // buffered or delivered now. Idempotent + gated; no-op when the flag is off.
@@ -16569,21 +16807,32 @@ async function handleInbound(
16569
16807
  effectiveText,
16570
16808
  })
16571
16809
 
16572
- if (
16573
- decideInboundDelivery({
16574
- turnInFlight: turnInFlightAtReceipt,
16575
- isSteering,
16576
- // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
16577
- // must bypass the "buffer-until-turn-complete" gate because the
16578
- // SIGINT'd turn often doesn't emit turn_complete, leaving the
16579
- // body stranded in pendingInboundBuffer indefinitely. The
16580
- // `interrupt` const is computed at the start of handleInbound
16581
- // (line ~7606) and remains in scope here. When the user fires
16582
- // `!`-with-body, this delivers the body as a fresh inbound to
16583
- // the freshly-killed bridge.
16584
- isInterrupt: interrupt.isInterrupt,
16585
- }) === 'buffer-until-idle'
16586
- ) {
16810
+ // #2917: read the gate LIVE (not the receipt snapshot) on the default path
16811
+ // so a sibling same-chat inbound delivered during THIS handler's async
16812
+ // lead-in is observed here. Reading `claudeBusyKeys` live is self-block-safe:
16813
+ // THIS inbound's own key is only added at delivery (below), never by the
16814
+ // fresh-turn init bundle — so the live read never sees its own key. The
16815
+ // delivery-machine cutover keeps its at-receipt machine snapshot (a live
16816
+ // machine read WOULD self-block, since the inbound event already advanced
16817
+ // the machine for this key). Kill switch (=0) restores the pure snapshot.
16818
+ const gateTurnInFlight =
16819
+ SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null
16820
+ ? claudeBusyKeys.size > 0
16821
+ : turnInFlightAtReceipt
16822
+ const deliveryGate = reserveInboundDelivery({
16823
+ turnInFlight: gateTurnInFlight,
16824
+ isSteering,
16825
+ // Interrupt-marker carve-out (2026-05-24): the `!`-prefixed body
16826
+ // must bypass the "buffer-until-turn-complete" gate because the
16827
+ // SIGINT'd turn often doesn't emit turn_complete, leaving the
16828
+ // body stranded in pendingInboundBuffer indefinitely. The
16829
+ // `interrupt` const is computed at the start of handleInbound
16830
+ // (line ~7606) and remains in scope here. When the user fires
16831
+ // `!`-with-body, this delivers the body as a fresh inbound to
16832
+ // the freshly-killed bridge.
16833
+ isInterrupt: interrupt.isInterrupt,
16834
+ })
16835
+ if (deliveryGate.decision === 'buffer-until-idle') {
16587
16836
  pendingInboundBuffer.push(selfAgent, inboundMsg)
16588
16837
  process.stderr.write(
16589
16838
  `telegram gateway: inbound held mid-turn agent=${selfAgent} ` +
@@ -16607,6 +16856,18 @@ async function handleInbound(
16607
16856
  return
16608
16857
  }
16609
16858
 
16859
+ // #2917: reserve the chat's busy key SYNCHRONOUSLY here — before the
16860
+ // composer-clear await below — so a concurrent same-chat inbound reaching
16861
+ // the LIVE gate above observes this in-flight delivery and buffers behind it
16862
+ // (per-chat FIFO). Without this, both handlers pass the gate during each
16863
+ // other's async lead-in and race to the bridge, reordering the replies.
16864
+ // Only fresh-turn deliveries reserve (steering/interrupt amend a running
16865
+ // turn and must not). Released below if the send misses (bridge offline).
16866
+ let reservedBusyKey: string | null = null
16867
+ if (deliveryGate.reserve && SERIALIZE_INBOUND_DELIVERY_ENABLED && machineInTurnAtReceipt == null) {
16868
+ reservedBusyKey = markClaudeBusyForInbound(inboundMsg)
16869
+ }
16870
+
16610
16871
  // Pre-send composer clear (the marko wedge). The inbound is about to be
16611
16872
  // delivered as an MCP `notifications/claude/channel` notification, which
16612
16873
  // the unmodified CLI appends into its composer and auto-submits ONLY when
@@ -16636,7 +16897,10 @@ async function handleInbound(
16636
16897
 
16637
16898
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
16638
16899
  if (delivered) {
16639
- const busyKey = markClaudeBusyForInbound(inboundMsg)
16900
+ // Reuse the key reserved synchronously above (#2917) when present, else
16901
+ // mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
16902
+ // so a re-mark is safe and returns the same chat key.
16903
+ const busyKey = reservedBusyKey ?? markClaudeBusyForInbound(inboundMsg)
16640
16904
  // Track until claude acks via `enqueue` (the marko drop-wedge): if no ack
16641
16905
  // lands, the message stranded in the composer and the sweep re-delivers
16642
16906
  // it. Track ONLY messages that produce an `enqueue` to ack against —
@@ -16658,6 +16922,15 @@ async function handleInbound(
16658
16922
  }
16659
16923
  }
16660
16924
  if (!delivered) {
16925
+ // #2917: the synchronous reservation assumed delivery; the send missed
16926
+ // (bridge offline), so release it in lockstep — otherwise the orphaned
16927
+ // busy key would gate every subsequent inbound into the buffer until the
16928
+ // orphan reaper clears it. The message itself is buffered below, so FIFO
16929
+ // is still preserved (it drains in order on the next bridge register).
16930
+ if (reservedBusyKey != null) {
16931
+ claudeBusyKeys.delete(reservedBusyKey)
16932
+ claudeBusyKeySince.delete(reservedBusyKey)
16933
+ }
16661
16934
  // Only persist fresh user turns to the durable spool. Steering / `!`
16662
16935
  // interrupt / empty bodies are mid-turn amendments or no-ops that would
16663
16936
  // arrive orphaned if replayed as a fresh turn after a restart — drop them
@@ -18223,7 +18496,7 @@ interface ModelDepsRestartContext {
18223
18496
  }
18224
18497
 
18225
18498
  function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & ModelCommandDeps {
18226
- return {
18499
+ const deps: ModelMenuDeps & ModelCommandDeps = {
18227
18500
  discover: (a) => discoverModels(a),
18228
18501
  discoverSrModels: async () => {
18229
18502
  // /model/info lives on the ROOT proxy (model-mapped surface), NOT the
@@ -18329,7 +18602,25 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
18329
18602
  )
18330
18603
  }
18331
18604
  },
18605
+ /**
18606
+ * Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
18607
+ * native `/model` picker rejects unknown sr-* ids, so we can't inject.
18608
+ * Write the token to the `.session-model-override` carrier file (start.sh
18609
+ * consumes it on the next boot and launches `claude --model <token>`), set
18610
+ * the in-memory session-model so /status stays honest across the restart
18611
+ * window, then run the SAME restart dispatch as scheduleRestart above.
18612
+ */
18613
+ scheduleModelRelaunch: async (model: string, reason: string) => {
18614
+ const agentDir = resolveAgentDirFromEnv()
18615
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
18616
+ // Carrier: single line, token + newline, no quoting (start.sh strips
18617
+ // whitespace and shape-gates). One-shot — consumed on the next boot.
18618
+ writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
18619
+ activeSessionModelOverride = model
18620
+ await deps.scheduleRestart(reason)
18621
+ },
18332
18622
  }
18623
+ return deps
18333
18624
  }
18334
18625
 
18335
18626
  function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined {
@@ -19804,25 +20095,21 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
19804
20095
  }
19805
20096
  } catch (err) {
19806
20097
  process.stderr.write(`telegram gateway: quota-watch: probe for crossing accounts failed: ${err}\n`)
19807
- if (!tuning.sendOnProbeFail) {
19808
- // A quota notification must never carry numbers we could not verify
19809
- // live. Leave the crossing accounts' state untouched — the
19810
- // transition re-evaluates (and re-probes) on the next 15-min tick.
19811
- // Persist any reconciles already applied, then bail.
19812
- if (reconciledCount > 0) {
19813
- try {
19814
- saveQuotaWatchState(stateDir, mutatedState)
19815
- } catch (saveErr) {
19816
- process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
19817
- }
20098
+ // A quota notification must never carry numbers we could not verify
20099
+ // live. Leave the crossing accounts' state untouched — the
20100
+ // transition re-evaluates (and re-probes) on the next 15-min tick.
20101
+ // Persist any reconciles already applied, then bail.
20102
+ if (reconciledCount > 0) {
20103
+ try {
20104
+ saveQuotaWatchState(stateDir, mutatedState)
20105
+ } catch (saveErr) {
20106
+ process.stderr.write(`telegram gateway: quota-watch state persist failed: ${saveErr}\n`)
19818
20107
  }
19819
- process.stderr.write(
19820
- `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
19821
- )
19822
- return
19823
20108
  }
19824
- // Legacy (SWITCHROOM_QUOTA_WATCH_SEND_ON_PROBE_FAIL=1): fall through
19825
- // and send from cached data.
20109
+ process.stderr.write(
20110
+ `telegram gateway: quota-watch: deferring ${pendingTransitions.length} notification(s) until probe succeeds\n`,
20111
+ )
20112
+ return
19826
20113
  }
19827
20114
 
19828
20115
  // Build final notifications, enriching the snapshot with fresh probe
@@ -19872,7 +20159,7 @@ async function runQuotaWatch(opts: { bootTick?: boolean } = {}): Promise<void> {
19872
20159
  // State normalised by the time of the probe — don't notify.
19873
20160
  continue
19874
20161
  }
19875
- } else if (!tuning.sendOnProbeFail) {
20162
+ } else {
19876
20163
  // No verified fresh data for this account (per-account probe failure
19877
20164
  // or label missing from the batch result). Same rule as the batch
19878
20165
  // throw above: never send unverified numbers. State untouched —
@@ -23385,14 +23672,41 @@ bot.on('callback_query:data', async ctx => {
23385
23672
  // We track whether we applied the interim edit so we can skip the
23386
23673
  // toastOnly short-circuit if we did — a toastOnly return after the interim
23387
23674
  // edit would leave the menu stuck button-less.
23388
- let didInterimSrEdit = false
23675
+ // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
23676
+ // Parity with the text `/model sr-*` path — claude's native picker rejects
23677
+ // unknown sr-* ids, so an in-place inject can't set them. Carry the token
23678
+ // across a graceful restart (the `.session-model-override` carrier) and
23679
+ // relaunch `claude --model sr-*`. Session-only; reverts to the configured
23680
+ // default on the next restart. The sr-* → Claude direction is handled below
23681
+ // via the SELECT/alias outcome + isSrToClaudeTransition.
23389
23682
  if (data.startsWith(MODEL_CALLBACK_SR)) {
23390
- const srLabel = escapeHtmlForTg(srFriendlyLabel(data.slice(MODEL_CALLBACK_SR.length)))
23683
+ const srName = data.slice(MODEL_CALLBACK_SR.length)
23684
+ const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
23685
+ if (!isValidModelArg(srName)) {
23686
+ await ctx
23687
+ .editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
23688
+ .catch(() => {})
23689
+ return
23690
+ }
23391
23691
  await ctx
23392
- .editMessageText(richMessage(`⏳ Switching session to **${srLabel}**…`), { reply_markup: { inline_keyboard: [] } })
23692
+ .editMessageText(
23693
+ richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
23694
+ { reply_markup: { inline_keyboard: [] } },
23695
+ )
23393
23696
  .catch(() => {})
23394
- didInterimSrEdit = true
23697
+ try {
23698
+ await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
23699
+ } catch (err) {
23700
+ await ctx
23701
+ .editMessageText(
23702
+ richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
23703
+ { reply_markup: { inline_keyboard: [] } },
23704
+ )
23705
+ .catch(() => {})
23706
+ }
23707
+ return
23395
23708
  }
23709
+ const didInterimSrEdit = false
23396
23710
  try {
23397
23711
  const prevSessionModel = activeSessionModelOverride
23398
23712
  const outcome = await handleModelMenuCallback(data, modelDeps)
@@ -26376,6 +26690,73 @@ void (async () => {
26376
26690
  }
26377
26691
  } catch {}
26378
26692
 
26693
+ // ─── Session-model re-hydration + LiteLLM-down alert (session relaunch) ───
26694
+ //
26695
+ // start.sh writes the EFFECTIVE launched model to `.active-session-model`
26696
+ // on every boot (the model actually passed to `claude --model`). Re-hydrate
26697
+ // the in-memory session-model override from it so `/status` and the welcome
26698
+ // card stay honest after a session-relaunch restart. Only treat it as an
26699
+ // override when it differs from the configured/default model — a plain boot
26700
+ // on the configured model leaves the override null.
26701
+ //
26702
+ // Also consume the `.session-model-alert` sentinel: start.sh drops it when
26703
+ // it had to DROP an sr-* override because LiteLLM was unreachable at boot
26704
+ // (booting on the configured default instead of 4xx-ing against Anthropic).
26705
+ // We turn it into a loud Telegram message to the operator, then delete it.
26706
+ try {
26707
+ const smAgentDir = resolveAgentDirFromEnv()
26708
+ if (smAgentDir) {
26709
+ const activePath = join(smAgentDir, '.active-session-model')
26710
+ if (existsSync(activePath)) {
26711
+ try {
26712
+ const launched = readFileSync(activePath, 'utf8').trim()
26713
+ const configured = (() => {
26714
+ type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
26715
+ const d = switchroomExecJson<AgentListResp>(['agent', 'list'])
26716
+ const raw = d?.agents?.find(a => a.name === getMyAgentName())?.model ?? null
26717
+ // Resolve through the SAME resolver start.sh's scaffold uses, so an
26718
+ // unset (`null`) or `model: "default"` config value maps to the
26719
+ // switchroom default model id — matching the EFFECTIVE model start.sh
26720
+ // wrote to `.active-session-model`. Comparing the raw (unresolved)
26721
+ // value would flag every ordinary restart of a default-model agent as
26722
+ // a phantom session override.
26723
+ return resolveMainModel(raw ?? undefined)
26724
+ })()
26725
+ activeSessionModelOverride =
26726
+ launched.length > 0 && launched !== configured ? launched : null
26727
+ } catch { /* leave override as-is on a bad read */ }
26728
+ }
26729
+
26730
+ const alertPath = join(smAgentDir, '.session-model-alert')
26731
+ if (existsSync(alertPath)) {
26732
+ let alertText: string | null = null
26733
+ try {
26734
+ alertText = readFileSync(alertPath, 'utf8').trim()
26735
+ } catch { alertText = null }
26736
+ try { unlinkSync(alertPath) } catch { /* best-effort */ }
26737
+ if (alertText && alertText.length > 0) {
26738
+ // Notify EVERY operator, not just allowFrom[0]. Each send is wrapped
26739
+ // in its own catch so one operator's failure (blocked bot, bad chat
26740
+ // id) never stops the rest, and the outer boot flow never crashes.
26741
+ const operators = loadAccess().allowFrom
26742
+ for (const operator of operators) {
26743
+ if (!operator) continue
26744
+ void lockedBot.api
26745
+ .sendMessage(operator, `⚠️ ${alertText}`)
26746
+ .catch((err: unknown) =>
26747
+ process.stderr.write(
26748
+ `telegram gateway: session-model alert send failed for ${operator}: ${(err as Error)?.message ?? String(err)}\n`,
26749
+ ),
26750
+ )
26751
+ }
26752
+ process.stderr.write(`telegram gateway: session-model: LiteLLM-down override drop — ${alertText}\n`)
26753
+ }
26754
+ }
26755
+ }
26756
+ } catch (err) {
26757
+ process.stderr.write(`telegram gateway: session-model re-hydration failed: ${(err as Error)?.message ?? String(err)}\n`)
26758
+ }
26759
+
26379
26760
  // Credit-exhaustion watcher (#348). Reads `<agentDir>/.claude/.claude.json`
26380
26761
  // for `cachedExtraUsageDisabledReason`. Fires a Telegram notification
26381
26762
  // on transition into / out of fatal billing states (out_of_credits,
@@ -26593,6 +26974,20 @@ void (async () => {
26593
26974
  ownedWorktreeCwds({
26594
26975
  self: process.env.SWITCHROOM_AGENT_NAME,
26595
26976
  listRecords: listWorktreeRecords,
26977
+ // Durable, non-env identity fallback (#1116 / #2893): when
26978
+ // SWITCHROOM_AGENT_NAME is somehow unset, derive this
26979
+ // agent's own identity from its own directory so worktree
26980
+ // ownership still resolves (env is only the fast path).
26981
+ // `watcherAgentDir` is guaranteed non-null in this branch
26982
+ // (the whole watcher is gated on it above). Kill-switch
26983
+ // SWITCHROOM_WORKTREE_IDENTITY_FALLBACK=0 restores the
26984
+ // pre-fix env-only behaviour.
26985
+ agentDir:
26986
+ process.env.SWITCHROOM_WORKTREE_IDENTITY_FALLBACK === '0'
26987
+ ? undefined
26988
+ : watcherAgentDir,
26989
+ log: (msg) =>
26990
+ process.stderr.write(`telegram gateway: ${msg}\n`),
26596
26991
  }),
26597
26992
  // Bug 0 fix: previously omitted, leaving the watcher unable to
26598
26993
  // write liveness/stall/turn_end updates to the registry DB.