switchroom 0.18.7 → 0.18.8

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 (54) hide show
  1. package/dist/cli/switchroom.js +905 -758
  2. package/dist/host-control/main.js +1 -1
  3. package/package.json +1 -1
  4. package/profiles/_base/start.sh.hbs +111 -34
  5. package/skills/switchroom-runtime/SKILL.md +2 -0
  6. package/telegram-plugin/dist/gateway/gateway.js +1403 -657
  7. package/telegram-plugin/flood-circuit-breaker.ts +123 -0
  8. package/telegram-plugin/gateway/activity-card-store.ts +63 -18
  9. package/telegram-plugin/gateway/boot-card.ts +27 -0
  10. package/telegram-plugin/gateway/busy-ack.ts +106 -0
  11. package/telegram-plugin/gateway/gateway.ts +564 -85
  12. package/telegram-plugin/gateway/mental-model-propose-diff.ts +61 -5
  13. package/telegram-plugin/gateway/model-command.ts +23 -11
  14. package/telegram-plugin/gateway/session-model-file.ts +198 -0
  15. package/telegram-plugin/gateway/status-pin-store.ts +82 -22
  16. package/telegram-plugin/gateway/worker-pin-reaper.ts +114 -0
  17. package/telegram-plugin/hooks/hooks.json +10 -10
  18. package/telegram-plugin/hooks/run-hook.sh +84 -0
  19. package/telegram-plugin/model-unavailable.ts +26 -0
  20. package/telegram-plugin/pty-partial-handler.ts +39 -0
  21. package/telegram-plugin/render/rich-render.ts +79 -1
  22. package/telegram-plugin/retry-api-call.ts +62 -0
  23. package/telegram-plugin/shared/bot-runtime.ts +8 -1
  24. package/telegram-plugin/silence-poke.ts +14 -0
  25. package/telegram-plugin/stream-controller.ts +156 -38
  26. package/telegram-plugin/tests/activity-card-store.test.ts +47 -2
  27. package/telegram-plugin/tests/approval-card-restart-outcome.test.ts +218 -0
  28. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +111 -0
  29. package/telegram-plugin/tests/busy-ack-wiring.test.ts +118 -0
  30. package/telegram-plugin/tests/busy-ack.test.ts +121 -0
  31. package/telegram-plugin/tests/flood-circuit-breaker.test.ts +74 -0
  32. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +177 -25
  33. package/telegram-plugin/tests/mental-model-name-entity-corruption.test.ts +119 -0
  34. package/telegram-plugin/tests/model-command.test.ts +2 -2
  35. package/telegram-plugin/tests/model-unavailable.test.ts +41 -0
  36. package/telegram-plugin/tests/pty-partial-handler.test.ts +56 -0
  37. package/telegram-plugin/tests/render/render-outbound-chunks.test.ts +98 -0
  38. package/telegram-plugin/tests/retry-api-call.test.ts +59 -0
  39. package/telegram-plugin/tests/run-hook-wrapper.test.ts +132 -0
  40. package/telegram-plugin/tests/session-model-file.test.ts +132 -0
  41. package/telegram-plugin/tests/slot-banner-boot-recovery.test.ts +3 -3
  42. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +3 -3
  43. package/telegram-plugin/tests/status-pin-store.test.ts +62 -6
  44. package/telegram-plugin/tests/stream-controller-chunk-cap.test.ts +122 -0
  45. package/telegram-plugin/tests/voice-send.test.ts +308 -0
  46. package/telegram-plugin/tests/worker-pin-reaper.test.ts +132 -0
  47. package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +118 -0
  48. package/telegram-plugin/uat/scenarios/jtbd-midflight-busy-ack-dm.test.ts +201 -0
  49. package/telegram-plugin/uat/scenarios/jtbd-worker-pin-lifecycle-dm.test.ts +208 -0
  50. package/telegram-plugin/uat/scenarios/vault-card-survives-gateway-restart-dm.test.ts +140 -0
  51. package/telegram-plugin/uat/scenarios/vault-deny-resumes-turn-dm.test.ts +84 -0
  52. package/telegram-plugin/uat/scenarios/vault-timeout-wakes-agent-dm.test.ts +91 -0
  53. package/telegram-plugin/voice-ondemand.ts +25 -1
  54. package/telegram-plugin/voice-send.ts +154 -0
@@ -41,6 +41,7 @@ import {
41
41
  resolveInterruptMaxWaitMs,
42
42
  resolveSafeBoundaryEnabled,
43
43
  } from './interrupt-defer.js'
44
+ import { shouldPostBusyAck, formatBusyAckText, BUSY_ACK_STEP_AGE_THRESHOLD_MS } from './busy-ack.js'
44
45
  import {
45
46
  resolveStickerSendArgs,
46
47
  resolveGifSendArgs,
@@ -62,6 +63,7 @@ import {
62
63
  buildListenKeyboard,
63
64
  mayInjectListenButton,
64
65
  } from '../voice-ondemand.js'
66
+ import { sendVoiceReusingFileId } from '../voice-send.js'
65
67
  import {
66
68
  PreSynthQueue,
67
69
  sweepVoiceCacheDir,
@@ -175,6 +177,7 @@ import {
175
177
  isMessageTooLongError,
176
178
  } from '../retry-api-call.js'
177
179
  import { installTgPostLogger, withTgPostTags } from '../shared/bot-runtime.js'
180
+ import { floodStatePath, makeFloodWaitRecorder } from '../flood-circuit-breaker.js'
178
181
  import { buildAttachmentPath, assertInsideInbox } from '../attachment-path.js'
179
182
  import { logStreamingEvent } from '../streaming-metrics.js'
180
183
  import * as signalTracker from '../turn-signal-tracker.js'
@@ -375,10 +378,22 @@ import {
375
378
  MODEL_CALLBACK_PAGE_EXTERNAL,
376
379
  MODEL_CALLBACK_PAGE_MAIN,
377
380
  srFriendlyLabel,
381
+ expandSrAlias,
382
+ isSrModel,
378
383
  type ModelMenuDeps,
379
384
  type ModelCommandDeps,
380
385
  type ModelMenuReply,
381
386
  } from './model-command.js'
387
+ import {
388
+ writeSessionModelFile,
389
+ readSessionModelFileRaw,
390
+ restoreSessionModelFileRaw,
391
+ clearSessionModelFile,
392
+ readConfiguredDefaultModel,
393
+ writeRelaunchModelIntent,
394
+ clearRelaunchModelIntent,
395
+ intentForRestartReason,
396
+ } from './session-model-file.js'
382
397
  import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
383
398
  import { resolveMainModel } from '../../src/agents/scaffold.js'
384
399
  import {
@@ -447,6 +462,10 @@ import {
447
462
  restartOrphanCardFinalizeText,
448
463
  type ActivityCardStoreFsSeam,
449
464
  } from './activity-card-store.js'
465
+ import {
466
+ decideWorkerPinReaps,
467
+ WORKER_PIN_TTL_MS_DEFAULT,
468
+ } from './worker-pin-reaper.js'
450
469
  import { driveEscalation } from './escalation-drive.js'
451
470
  import { shouldSuppressRepresent } from './represent-guard.js'
452
471
  import { shouldDeferEscalationForBridge } from './escalation-bridge-gate.js'
@@ -908,6 +927,16 @@ function triggerSelfRestart(
908
927
  )
909
928
  return false
910
929
  }
930
+ // Session-model stickiness (reference/rfcs/session-model-stickiness.md):
931
+ // boot default is REVERT, so every switchroom-managed bounce must stamp
932
+ // its intent BEFORE the SIGTERM is even scheduled (write-before-kill
933
+ // invariant — pinned by gateway-session-model-relaunch.test.ts). The
934
+ // per-reason table classifies recovery/model-switch bounces as "keep"
935
+ // and the deliberate inline restart button as "revert".
936
+ {
937
+ const smDir = resolveAgentDirFromEnv()
938
+ if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
939
+ }
911
940
  process.stderr.write(
912
941
  `telegram gateway: restart-via-SIGTERM-PID1 agent=${targetAgent} reason=${reason} (docker)\n`,
913
942
  )
@@ -919,6 +948,10 @@ function triggerSelfRestart(
919
948
  return true
920
949
  }
921
950
  // Legacy systemd path.
951
+ if (targetAgent === selfAgent) {
952
+ const smDir = resolveAgentDirFromEnv()
953
+ if (smDir) writeRelaunchModelIntent(smDir, intentForRestartReason(reason), reason)
954
+ }
922
955
  process.stderr.write(
923
956
  `telegram gateway: restart-via-systemctl agent=${targetAgent} reason=${reason}\n`,
924
957
  )
@@ -1874,7 +1907,22 @@ const activeReactionMsgIds = new Map<string, { chatId: string; messageId: number
1874
1907
  // by chatKey(chat_id, bufferedThread). Delete-on-answer: never a dangling
1875
1908
  // placeholder. Reaped on answer (executeReply/stream), on turn-flush, and
1876
1909
  // in purgeReactionTracking cleanup so an abnormal turn-end can't strand it.
1877
- const queuedStatusMsgIds = new Map<string, { chatId: string; threadId: number; messageId: number }>()
1910
+ // #2995: threadId is null for DM cards the mid-flight busy-ack extends
1911
+ // this lifecycle to DMs and same-topic surfaces (the original component-5
1912
+ // card was cross-topic only).
1913
+ const queuedStatusMsgIds = new Map<string, { chatId: string; threadId: number | null; messageId: number }>()
1914
+ // #2995 mid-flight busy-ack dedupe: statusKeys that already got a busy-ack
1915
+ // card during the CURRENT turn — at most one card per turn per chat/topic
1916
+ // even after the card itself is reaped. The ending turn's key is deleted in
1917
+ // purgeReactionTracking (per-key, not a global clear — a purge for topic A
1918
+ // must not reset topic B's dedupe).
1919
+ const busyAckPostedKeys = new Set<string>()
1920
+ // #2995 deferred re-check: a ping that arrives while the blocking step is
1921
+ // still YOUNG (< threshold) must not be silently forgotten — the step may
1922
+ // run for minutes more (the original #2995 silence). One timer per key,
1923
+ // armed for (threshold − stepAge); it re-evaluates live state when it
1924
+ // fires and is cancelled by purgeReactionTracking when the turn ends.
1925
+ const busyAckRecheckTimers = new Map<string, ReturnType<typeof setTimeout>>()
1878
1926
  // Reactions whose terminal 👍 is deferred because a background sub-agent
1879
1927
  // worker was still running when the parent's `turn_end` fired. Painting 👍
1880
1928
  // then would read as "done / nothing happening" while the worker keeps
@@ -2310,6 +2358,16 @@ const TOPIC_FRAMING_ENABLED =
2310
2358
  // → no placeholder (the 👀 ack reaction still fires). Delete-on-answer.
2311
2359
  const QUEUED_STATUS_UX_ENABLED =
2312
2360
  process.env.SWITCHROOM_QUEUED_STATUS_UX !== '0'
2361
+ // #2995 mid-flight busy ack. When a mid-turn inbound is buffered (or lands
2362
+ // as a steer) while the running turn sits inside ONE long tool call (step
2363
+ // age past the threshold in busy-ack.ts), post a silent deterministic
2364
+ // "⏳ Queued — currently inside `<tool>` …" card into the inbound's own
2365
+ // chat/topic — model-free, zero tokens. Reuses the queuedStatusMsgIds
2366
+ // edit/delete lifecycle (delete-on-answer, reap on abnormal turn-end),
2367
+ // extending it to DMs and same-topic surfaces. At most one card per turn
2368
+ // per chat/topic. Kill switch off (=0) → legacy silence (👀 only).
2369
+ const MIDFLIGHT_BUSY_ACK_ENABLED =
2370
+ process.env.SWITCHROOM_MIDFLIGHT_BUSY_ACK !== '0'
2313
2371
  // Feed-reopen-after-ack. When a tool label arrives for a turn already
2314
2372
  // marked finalAnswerDelivered, the model is still WORKING — so the earlier
2315
2373
  // "final" reply was an interim ACK (an ack-first reply pings or runs ≥200
@@ -3472,7 +3530,9 @@ function postQueuedStatus(chatId: string, bufferedThread: number, inFlightThread
3472
3530
  */
3473
3531
  function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
3474
3532
  if (!QUEUED_STATUS_UX_ENABLED) return
3475
- if (thread == null) return
3533
+ // #2995: thread == null is a DM (or General-topic) card — the busy-ack
3534
+ // extended this lifecycle to DMs, so promote those too (statusKey keys
3535
+ // a null thread identically at post and promote time).
3476
3536
  const key = statusKey(chatId, thread)
3477
3537
  const entry = queuedStatusMsgIds.get(key)
3478
3538
  if (entry == null) return
@@ -3483,7 +3543,7 @@ function promoteQueuedStatus(chatId: string, thread: number | undefined): void {
3483
3543
  void swallowingApiCall(
3484
3544
  () =>
3485
3545
  bot.api.editMessageText(chatId, entry.messageId, '✍️ On it — replying now.', {}),
3486
- { chat_id: chatId, verb: 'queued-status.promote', threadId: thread },
3546
+ { chat_id: chatId, verb: 'queued-status.promote', ...(thread != null ? { threadId: thread } : {}) },
3487
3547
  )
3488
3548
  }
3489
3549
 
@@ -3503,6 +3563,115 @@ function reapQueuedStatus(chatId: string, thread: number | undefined): void {
3503
3563
  )
3504
3564
  }
3505
3565
 
3566
+ /**
3567
+ * #2995 — mid-flight busy ack (decision + dispatch). Called when a mid-turn
3568
+ * inbound was buffered (`buffer-until-idle`) or delivered as a steer. If the
3569
+ * running turn is sitting inside one LONG tool step (see busy-ack.ts for the
3570
+ * threshold rationale), post a silent deterministic card into the inbound's
3571
+ * own chat/topic naming the blocking activity — so a quick question asked
3572
+ * behind a `--watch`-style blocking call doesn't read as ignored for
3573
+ * minutes. Model-free, zero tokens. The card is stored in
3574
+ * `queuedStatusMsgIds`, so it inherits the component-5 lifecycle: promoted
3575
+ * ("On it") when the buffered turn starts, deleted when the answer lands or
3576
+ * the turn ends. Dedupe is per turn per chat/topic via `busyAckPostedKeys`.
3577
+ */
3578
+ function maybePostBusyAck(
3579
+ gateDecision: 'buffer-until-idle' | 'steer',
3580
+ chatId: string,
3581
+ threadId: number | undefined,
3582
+ ): void {
3583
+ if (!MIDFLIGHT_BUSY_ACK_ENABLED) return
3584
+ const key = statusKey(chatId, threadId)
3585
+ const inFlight = currentTurn
3586
+ const inFlightKey =
3587
+ inFlight != null ? statusKey(inFlight.sessionChatId, inFlight.sessionThreadId) : key
3588
+ const now = Date.now()
3589
+ const step = silencePoke.longestInFlightTool(inFlightKey, now)
3590
+ const midToolCall = toolFlightTracker.isMidToolCall()
3591
+ const stepAgeMs = step?.durationMs ?? null
3592
+ const alreadyAcked = queuedStatusMsgIds.has(key) || busyAckPostedKeys.has(key)
3593
+ const fire = shouldPostBusyAck({ gateDecision, midToolCall, stepAgeMs, alreadyAcked })
3594
+ if (!fire) {
3595
+ // Deferred re-check: the ONLY miss reason we re-arm for is a step that
3596
+ // is genuinely open but still young — it may run for minutes more, and
3597
+ // a one-shot evaluation would re-create the original #2995 silence. Arm
3598
+ // one timer for (threshold − stepAge); everything is re-read live at
3599
+ // fire time, and the timer is pinned to THIS turn (turnId guard) plus
3600
+ // cancelled outright by purgeReactionTracking when the turn ends.
3601
+ if (
3602
+ midToolCall &&
3603
+ !alreadyAcked &&
3604
+ stepAgeMs != null &&
3605
+ stepAgeMs < BUSY_ACK_STEP_AGE_THRESHOLD_MS &&
3606
+ !busyAckRecheckTimers.has(key)
3607
+ ) {
3608
+ const turnIdAtSchedule = inFlight?.turnId ?? null
3609
+ const delayMs = BUSY_ACK_STEP_AGE_THRESHOLD_MS - stepAgeMs + 250
3610
+ busyAckRecheckTimers.set(key, setTimeout(() => {
3611
+ busyAckRecheckTimers.delete(key)
3612
+ // The ping is only still waiting if the SAME turn is still running
3613
+ // (a new/ended turn means the buffered inbound is being handled —
3614
+ // a "Queued" card then would be a lie).
3615
+ if (turnIdAtSchedule == null || currentTurn?.turnId !== turnIdAtSchedule) return
3616
+ maybePostBusyAck(gateDecision, chatId, threadId)
3617
+ }, delayMs))
3618
+ }
3619
+ return
3620
+ }
3621
+ const pendingRecheck = busyAckRecheckTimers.get(key)
3622
+ if (pendingRecheck != null) {
3623
+ clearTimeout(pendingRecheck)
3624
+ busyAckRecheckTimers.delete(key)
3625
+ }
3626
+ busyAckPostedKeys.add(key)
3627
+ const text = formatBusyAckText({
3628
+ gateDecision,
3629
+ toolName: step?.name ?? null,
3630
+ toolLabel: step?.label ?? null,
3631
+ })
3632
+ process.stderr.write(
3633
+ `telegram gateway: mid-flight busy ack chat=${chatId} thread=${threadId ?? '-'} ` +
3634
+ `decision=${gateDecision} step=${step?.name ?? '-'} step_age_ms=${step?.durationMs ?? '-'}\n`,
3635
+ )
3636
+ postBusyAck(chatId, threadId, text)
3637
+ }
3638
+
3639
+ /**
3640
+ * #2995 — send the busy-ack card. Mirrors `postQueuedStatus` (idempotent
3641
+ * key, silent send, post-race orphan cleanup) but is thread-OPTIONAL so it
3642
+ * covers DMs and same-topic queues — the surfaces the cross-topic queued
3643
+ * status deliberately suppressed. Shares `queuedStatusMsgIds`, so
3644
+ * promote/reap clean it up exactly like the queued-status placeholder.
3645
+ */
3646
+ function postBusyAck(chatId: string, threadId: number | undefined, text: string): void {
3647
+ const key = statusKey(chatId, threadId)
3648
+ if (queuedStatusMsgIds.has(key)) return
3649
+ void (async () => {
3650
+ const sent = await swallowingApiCall(
3651
+ () =>
3652
+ // Deterministic busy-ack placeholder, not the user's answer —
3653
+ // silent by contract (no device ping for a status card).
3654
+ bot.api.sendMessage(chatId, text, {
3655
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
3656
+ disable_notification: true,
3657
+ }),
3658
+ { chat_id: chatId, verb: 'busy-ack.post', ...(threadId != null ? { threadId } : {}) },
3659
+ )
3660
+ const messageId = (sent as { message_id?: number } | undefined)?.message_id
3661
+ if (typeof messageId !== 'number') return
3662
+ // Re-check after the await (same race pattern as postQueuedStatus):
3663
+ // if a placeholder landed for this key in the gap, delete this orphan.
3664
+ if (queuedStatusMsgIds.has(key)) {
3665
+ void swallowingApiCall(
3666
+ () => bot.api.deleteMessage(chatId, messageId),
3667
+ { chat_id: chatId, verb: 'busy-ack.post-race-cleanup', ...(threadId != null ? { threadId } : {}) },
3668
+ )
3669
+ return
3670
+ }
3671
+ queuedStatusMsgIds.set(key, { chatId, threadId: threadId ?? null, messageId })
3672
+ })()
3673
+ }
3674
+
3506
3675
  // Problem B — deferred safe-boundary interrupt.
3507
3676
  //
3508
3677
  // `toolFlightTracker` mirrors the session-event stream to know whether a
@@ -3836,6 +4005,17 @@ function purgeReactionTracking(key: string, endingTurn?: CurrentTurn): void {
3836
4005
  const pqThread = pqThreadPart === '_' || pqThreadPart === '' ? null : Number(pqThreadPart)
3837
4006
  reapQueuedStatus(pqChatId, Number.isFinite(pqThread) ? (pqThread as number) : undefined)
3838
4007
  }
4008
+ // #2995 — reset the ending turn's OWN busy-ack dedupe + cancel its pending
4009
+ // deferred re-check. Per-key, not a global clear: a purge for topic A must
4010
+ // not reset topic B's dedupe or kill B's armed re-check. A buffered topic's
4011
+ // entry clears when ITS turn eventually runs and ends (this same path), and
4012
+ // the re-check timer self-guards on turnId anyway (defense-in-depth).
4013
+ busyAckPostedKeys.delete(key)
4014
+ const busyAckRecheck = busyAckRecheckTimers.get(key)
4015
+ if (busyAckRecheck != null) {
4016
+ clearTimeout(busyAckRecheck)
4017
+ busyAckRecheckTimers.delete(key)
4018
+ }
3839
4019
  // PR3b: clear the parallel-turns fleet-gate entry. Symmetric with
3840
4020
  // the markClaudeBusyForInbound on the delivery path. Safe no-op
3841
4021
  // when the key was never marked (synthetic purge from a sweep).
@@ -4887,8 +5067,16 @@ const typingWrapper = createTypingWrapper({
4887
5067
  // ─── Robust API call wrapper ──────────────────────────────────────────────
4888
5068
  // Extracted to telegram-plugin/retry-api-call.ts so it's unit-testable in
4889
5069
  // isolation; the gateway just composes the pure policy with its own logger.
5070
+ // #2923: the shared flood-wait marker. Every observed 429 retry_after window
5071
+ // is persisted here via onFloodWait, and both boot-card callsites consult the
5072
+ // SAME file to suppress a restart card while a per-bot flood ban is open (so a
5073
+ // restart doesn't post into the window and extend the ban). Falls back to a
5074
+ // no-op recorder when TELEGRAM_STATE_DIR is unset (dev/one-shot contexts).
5075
+ // STATE_DIR always resolves (env or a ~/.claude fallback), so this is live.
5076
+ const FLOOD_STATE_PATH = floodStatePath(STATE_DIR)
4890
5077
  const robustApiCall = createRetryApiCall({
4891
5078
  log: (line) => process.stderr.write(line),
5079
+ onFloodWait: makeFloodWaitRecorder(FLOOD_STATE_PATH),
4892
5080
  })
4893
5081
 
4894
5082
  // Fire-and-forget wrapper for outbound surfaces that previously had
@@ -6804,6 +6992,10 @@ const statusPinState = new Map<string, PinState>()
6804
6992
  // owned pins without threading the chat id through every call site. Written on
6805
6993
  // every desired-pinned reconcile, cleared alongside the state on unpin.
6806
6994
  const statusPinChatIds = new Map<string, string>()
6995
+ // Companion registry: pinKey → wall-clock ms the claim was FIRST taken (a
6996
+ // re-pin of the same key keeps the original timestamp). Feeds the TTL gate of
6997
+ // the mid-session `wk:` pin reaper (#3001); cleared alongside the state.
6998
+ const statusPinPinnedAt = new Map<string, number>()
6807
6999
 
6808
7000
  // Durable snapshot of the pin claim set on the persistent per-agent volume
6809
7001
  // (STATE_DIR = /state/agent/telegram in prod). Closes the crash hole: the
@@ -6855,6 +7047,22 @@ const BANNER_PIN_KEY = 'banner:owner'
6855
7047
  // run → no-op). The boot-cleanup gate below is widened to cover this.
6856
7048
  const bannerPinPersistEnabled = !STATIC
6857
7049
 
7050
+ // `pin_message` MCP-tool pin registration (#3001). Tool pins ride the same
7051
+ // shared status-pins.json store under `tool:<chatId>:<messageId>` keys, but
7052
+ // with a TTL row (`expiresAt`): a tool pin is a deliberate agent action with
7053
+ // no "work finished" event, so a restart does NOT reset it — boot cleanup
7054
+ // keeps unexpired tool rows and only unpins them once the TTL lapses (the
7055
+ // backstop against agent-pinned messages accumulating forever). Independent
7056
+ // of PIN_STATUS_WHILE_WORKING (it gates the auto status pin, not the tool).
7057
+ const toolPinPersistEnabled = !STATIC
7058
+ // 7 days: generous — an agent-pinned message the operator still cares about
7059
+ // after a week has usually been re-pinned or acted on; anything older is the
7060
+ // stale-pin long tail this issue exists to clear. Override for tuning.
7061
+ const TOOL_PIN_TTL_MS = (() => {
7062
+ const v = Number(process.env.SWITCHROOM_TOOL_PIN_TTL_MS)
7063
+ return Number.isFinite(v) && v > 0 ? v : 7 * 24 * 60 * 60_000
7064
+ })()
7065
+
6858
7066
  // Persist (or drop) the slot-banner's pin row into the shared store. Routes
6859
7067
  // through mutateStatusPinRow: a read-modify-write for ONLY the banner:owner key,
6860
7068
  // serialised on the store's per-path lock, so the live fg:/wk: status-pin rows
@@ -6905,9 +7113,9 @@ async function statusPinBootCleanup(): Promise<void> {
6905
7113
  // map-backed status pins (statusPinPersistEnabled) or the slot banner
6906
7114
  // (bannerPinPersistEnabled). A single cleanup drains ALL orphaned rows —
6907
7115
  // status pins AND banner alike — since they share status-pins.json.
6908
- if (!statusPinPersistEnabled && !bannerPinPersistEnabled) return
7116
+ if (!statusPinPersistEnabled && !bannerPinPersistEnabled && !toolPinPersistEnabled) return
6909
7117
  const api = statusPinApi()
6910
- const { cleared, total } = await runStatusPinBootCleanup({
7118
+ const { cleared, retained, kept, total } = await runStatusPinBootCleanup({
6911
7119
  path: STATUS_PIN_STORE_PATH,
6912
7120
  fs: statusPinStoreFs,
6913
7121
  unpin: (chatId, messageId) => api.unpinChatMessage(chatId, messageId),
@@ -6915,7 +7123,8 @@ async function statusPinBootCleanup(): Promise<void> {
6915
7123
  if (total > 0) {
6916
7124
  process.stderr.write(
6917
7125
  `telegram gateway: status-pin: cleared ${cleared}/${total} ` +
6918
- `orphaned pin(s) from a prior session\n`,
7126
+ `orphaned pin(s) from a prior session ` +
7127
+ `(retained ${retained} for retry, kept ${kept} unexpired tool pin(s))\n`,
6919
7128
  )
6920
7129
  }
6921
7130
  }
@@ -7016,6 +7225,25 @@ const MID_SESSION_CARD_REAPER_INTERVAL_MS = (() => {
7016
7225
  return Number.isFinite(v) && v > 0 ? v : 5 * 60_000 // 5 min
7017
7226
  })()
7018
7227
 
7228
+ // ─── Mid-session stale worker-pin reaper (#3001) ─────────────────────────────
7229
+ // The `wk:<agentId>` pin is normally dropped by the worker's completion
7230
+ // handler, but a missed onFinish (watcher crash / SDK SIGKILL / dropped JSONL
7231
+ // tail) used to leave the pin glued to the chat until the NEXT gateway boot.
7232
+ // This sweep (piggybacking on the mid-session reaper interval) unpins a
7233
+ // claimed worker pin when the registry says its worker is TERMINAL, or when
7234
+ // the pin has been held past a TTL. Pure decision in worker-pin-reaper.ts;
7235
+ // each reap executes through reconcileStatusPin so the in-memory claim and
7236
+ // the durable store row clear together.
7237
+ //
7238
+ // Kill switch: SWITCHROOM_WORKER_PIN_REAPER=0 disables (clean revert, mirrors
7239
+ // SWITCHROOM_MID_SESSION_CARD_REAPER). TTL overridable via env for tuning.
7240
+ const WORKER_PIN_REAPER_ENABLED =
7241
+ process.env.SWITCHROOM_WORKER_PIN_REAPER !== '0'
7242
+ const WORKER_PIN_REAPER_TTL_MS = (() => {
7243
+ const v = Number(process.env.SWITCHROOM_WORKER_PIN_REAPER_TTL_MS)
7244
+ return Number.isFinite(v) && v > 0 ? v : WORKER_PIN_TTL_MS_DEFAULT // 6 h
7245
+ })()
7246
+
7019
7247
  // Snapshot the turn_keys / topic keys owned by a live in-flight turn right now.
7020
7248
  function liveTurnKeySets(): { registryKeys: Set<string>; topicKeys: Set<string> } {
7021
7249
  const registryKeys = new Set<string>()
@@ -7080,15 +7308,27 @@ async function runMidSessionCardReaper(): Promise<void> {
7080
7308
  verb: 'activity-card.mid-session-reap-finalize',
7081
7309
  },
7082
7310
  ),
7083
- unpinCard: (record) =>
7084
- robustApiCall(
7311
+ // #3001: route through reconcileStatusPin when the pin is a live
7312
+ // in-memory claim, so the claim AND the durable status-pins.json row
7313
+ // clear together (the raw-API unpin left both behind — the boot sweep
7314
+ // then re-unpinned an already-unpinned message and the service-message
7315
+ // handler kept a phantom claim). Falls back to the raw unpin when no
7316
+ // claim is tracked (e.g. claim already dropped out-of-band).
7317
+ unpinCard: async (record) => {
7318
+ const pinKey = `fg:${record.turnKey}`
7319
+ if (statusPinState.has(pinKey)) {
7320
+ await reconcileStatusPin(pinKey, record.chatId, { pinned: false })
7321
+ return true
7322
+ }
7323
+ return robustApiCall(
7085
7324
  () => lockedBot.api.unpinChatMessage(record.chatId, record.activityMessageId),
7086
7325
  {
7087
7326
  chat_id: record.chatId,
7088
7327
  ...(record.threadId != null ? { threadId: record.threadId } : {}),
7089
7328
  verb: 'activity-card.mid-session-reap-unpin',
7090
7329
  },
7091
- ),
7330
+ )
7331
+ },
7092
7332
  })
7093
7333
  if (total > 0) {
7094
7334
  process.stderr.write(
@@ -7102,6 +7342,55 @@ async function runMidSessionCardReaper(): Promise<void> {
7102
7342
  )
7103
7343
  }
7104
7344
  }
7345
+
7346
+ // 3) Reap stale `wk:` worker pins (#3001): a claimed worker pin whose worker
7347
+ // is terminal in the registry (missed onFinish) or whose claim outlived
7348
+ // the TTL. Executes through reconcileStatusPin so the in-memory claim and
7349
+ // the durable store row clear together.
7350
+ if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
7351
+ try {
7352
+ const candidates = [...statusPinState.keys()]
7353
+ .filter((k) => k.startsWith('wk:'))
7354
+ .map((k) => ({
7355
+ pinKey: k,
7356
+ chatId: statusPinChatIds.get(k) ?? '',
7357
+ // A missing timestamp (should not happen — set on every claim)
7358
+ // degrades to "claimed just now": terminality can still reap it,
7359
+ // the TTL gate never can. Conservative, never a spurious unpin.
7360
+ pinnedAt: statusPinPinnedAt.get(k) ?? now,
7361
+ }))
7362
+ const reaps = decideWorkerPinReaps({
7363
+ pins: candidates,
7364
+ statusOf: (agentId) => {
7365
+ if (turnsDb == null) return 'unknown'
7366
+ try {
7367
+ const row = getSubagentByJsonlId(turnsDb, agentId)
7368
+ if (row == null) return 'unknown'
7369
+ if (row.status === 'completed' || row.status === 'failed') return 'terminal'
7370
+ // A live 'running' row exempts the pin from the TTL (no churn on
7371
+ // healthy long workers); 'stalled' degrades to 'unknown' → TTL.
7372
+ if (row.status === 'running') return 'running'
7373
+ return 'unknown'
7374
+ } catch {
7375
+ return 'unknown' // DB hiccup degrades to "keep until TTL"
7376
+ }
7377
+ },
7378
+ ttlMs: WORKER_PIN_REAPER_TTL_MS,
7379
+ now,
7380
+ })
7381
+ for (const reap of reaps) {
7382
+ process.stderr.write(
7383
+ `telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
7384
+ `(chat=${reap.chatId} reason=${reap.reason})\n`,
7385
+ )
7386
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
7387
+ }
7388
+ } catch (err) {
7389
+ process.stderr.write(
7390
+ `telegram gateway: worker-pin reaper error: ${(err as Error).message}\n`,
7391
+ )
7392
+ }
7393
+ }
7105
7394
  }
7106
7395
 
7107
7396
  const midSessionCardReaper = setInterval(() => {
@@ -7189,9 +7478,11 @@ async function reconcileStatusPinInner(
7189
7478
  if (next == null) {
7190
7479
  statusPinState.delete(pinKey)
7191
7480
  statusPinChatIds.delete(pinKey)
7481
+ statusPinPinnedAt.delete(pinKey)
7192
7482
  } else {
7193
7483
  statusPinState.set(pinKey, next)
7194
7484
  statusPinChatIds.set(pinKey, chatId)
7485
+ if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
7195
7486
  }
7196
7487
  return
7197
7488
  }
@@ -7211,9 +7502,11 @@ async function reconcileStatusPinInner(
7211
7502
  if (next == null) {
7212
7503
  statusPinState.delete(pinKey)
7213
7504
  statusPinChatIds.delete(pinKey)
7505
+ statusPinPinnedAt.delete(pinKey)
7214
7506
  } else {
7215
7507
  statusPinState.set(pinKey, next)
7216
7508
  statusPinChatIds.set(pinKey, chatId)
7509
+ if (!statusPinPinnedAt.has(pinKey)) statusPinPinnedAt.set(pinKey, Date.now())
7217
7510
  }
7218
7511
  }
7219
7512
 
@@ -7256,7 +7549,7 @@ async function unpinAllStatusPins(): Promise<void> {
7256
7549
  if (st == null) continue
7257
7550
  // Recover the chat id from the state map's companion key registry.
7258
7551
  const chatId = statusPinChatIds.get(key)
7259
- if (chatId == null) { statusPinState.delete(key); continue }
7552
+ if (chatId == null) { statusPinState.delete(key); statusPinPinnedAt.delete(key); continue }
7260
7553
  await reconcileStatusPin(key, chatId, { pinned: false })
7261
7554
  }
7262
7555
  }
@@ -8861,6 +9154,7 @@ const ipcServer: IpcServer = createIpcServer({
8861
9154
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
8862
9155
  configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
8863
9156
  bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
9157
+ floodStatePath: FLOOD_STATE_PATH,
8864
9158
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
8865
9159
  }, ackMsgId).then(handle => {
8866
9160
  activeBootCard = handle
@@ -10455,6 +10749,15 @@ const voicePreSynthQueue = new PreSynthQueue({
10455
10749
  }
10456
10750
  const filePath = writeVoiceCacheFile(VOICE_CACHE_DIR, job.token, result.audio)
10457
10751
  voiceOnDemandCache.setFilePath(job.token, filePath)
10752
+ // TODO(first-tap-instant): pre-mint the Telegram file_id here so even the
10753
+ // FIRST user tap sends by id (no upload). Would require uploading this ogg
10754
+ // once to the bot's OWN hidden storage/log chat and calling
10755
+ // voiceOnDemandCache.setTelegramFileId(job.token, msg.voice.file_id).
10756
+ // Deferred: no storage-chat id exists in config/env today, and the product
10757
+ // invariant forbids sending audio to the USER's chat without a tap — so a
10758
+ // dedicated bot-storage chat id must be added first. Until then, the first
10759
+ // tap uploads (via the disk fast-path) and captures the id for reuse; only
10760
+ // the second+ taps are instant.
10458
10761
  process.stderr.write(
10459
10762
  `telegram gateway: voice-presynth: cached ${result.audio.length} bytes at ${filePath} ` +
10460
10763
  `(${result.durationMs}ms, backlog=${voicePreSynthQueue.size})\n`,
@@ -13262,10 +13565,26 @@ async function executePinMessage(args: Record<string, unknown>): Promise<unknown
13262
13565
  // errors are retried. THREAD_NOT_FOUND on a stale topic surfaces to the
13263
13566
  // agent as a tool-error — pinning a vanished message is genuinely a
13264
13567
  // failure the agent should see.
13568
+ const pinMsgId = Number(args.message_id)
13265
13569
  await robustApiCall(
13266
- () => lockedBot.api.pinChatMessage(pinChatId, Number(args.message_id)),
13570
+ () => lockedBot.api.pinChatMessage(pinChatId, pinMsgId),
13267
13571
  { chat_id: pinChatId, verb: 'pin_message' },
13268
13572
  )
13573
+ // #3001: register the tool pin in the shared status-pin store under a
13574
+ // `tool:` key so it is no longer fire-and-forget. Unlike work-scoped
13575
+ // fg:/wk: rows a tool pin has no "work finished" event, so a restart does
13576
+ // NOT reset it — boot cleanup keeps the row until its TTL, then unpins the
13577
+ // (likely long-forgotten) message. Best-effort fire-and-forget: a store
13578
+ // failure must never fail the tool call the pin already landed for.
13579
+ if (toolPinPersistEnabled) {
13580
+ const toolPinKey = `tool:${pinChatId}:${pinMsgId}`
13581
+ void mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, toolPinKey, {
13582
+ pinKey: toolPinKey,
13583
+ chatId: pinChatId,
13584
+ messageId: pinMsgId,
13585
+ expiresAt: Date.now() + TOOL_PIN_TTL_MS,
13586
+ })
13587
+ }
13269
13588
  return { content: [{ type: 'text', text: `pinned message ${args.message_id}` }] }
13270
13589
  }
13271
13590
 
@@ -17778,13 +18097,23 @@ async function handleInbound(
17778
18097
  // know they're queued. Suppressed for DMs (no topics) and same-topic
17779
18098
  // queues (the in-flight turn's own card already covers them).
17780
18099
  const inFlightThread = currentTurn?.sessionThreadId
17781
- if (
18100
+ const crossTopicQueuedCard =
17782
18101
  QUEUED_STATUS_UX_ENABLED &&
17783
18102
  !isDmChatId(chat_id) &&
17784
18103
  messageThreadId != null &&
17785
18104
  messageThreadId !== inFlightThread
17786
- ) {
18105
+ if (crossTopicQueuedCard) {
17787
18106
  postQueuedStatus(chat_id, messageThreadId, inFlightThread)
18107
+ } else {
18108
+ // #2995 mid-flight busy ack — ONLY the surfaces the cross-topic card
18109
+ // suppresses (DMs, same-topic). Mutually exclusive with
18110
+ // postQueuedStatus above: both record into queuedStatusMsgIds only
18111
+ // AFTER their sendMessage awaits resolve, so calling both here would
18112
+ // race past each other's has(key) check and double-card the topic.
18113
+ // When the running turn is inside one LONG tool step, a buffered
18114
+ // quick question would otherwise wait minutes with only a 👀; post a
18115
+ // silent deterministic "Queued — currently inside <tool>" card.
18116
+ maybePostBusyAck('buffer-until-idle', chat_id, messageThreadId ?? undefined)
17788
18117
  }
17789
18118
  return
17790
18119
  }
@@ -17830,6 +18159,15 @@ async function handleInbound(
17830
18159
 
17831
18160
  const delivered = ipcServer.sendToAgent(selfAgent, inboundMsg)
17832
18161
  if (delivered) {
18162
+ // #2995 — a steer delivered mid-turn while the turn is stuck inside one
18163
+ // long tool step gets a deterministic ack (worded as a steer, not
18164
+ // "Queued" — classification visibility): the model can't narrate the
18165
+ // steer until the blocking call returns. Posted only AFTER the bridge
18166
+ // dispatch succeeded — a "Steer noted" card for a send that missed
18167
+ // (bridge offline → buffered/dropped below) would be untrue.
18168
+ if (isSteering) {
18169
+ maybePostBusyAck('steer', chat_id, messageThreadId ?? undefined)
18170
+ }
17833
18171
  // Reuse the key reserved synchronously above (#2917) when present, else
17834
18172
  // mark now — markClaudeBusyForInbound is idempotent (lockstep re-stamp),
17835
18173
  // so a re-mark is safe and returns the same chat key.
@@ -19527,6 +19865,15 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
19527
19865
  })
19528
19866
  }
19529
19867
  stampUserRestartReason(reason)
19868
+ // Model-switch restarts are switchroom-managed relaunches: the session
19869
+ // override (written by the caller before this dispatch) must survive
19870
+ // the bounce, so stamp keep-intent BEFORE dispatch (boot default is
19871
+ // revert). hostd shells through `switchroom agent restart`, which
19872
+ // deliberately writes no intent of its own.
19873
+ {
19874
+ const smDir = resolveAgentDirFromEnv()
19875
+ if (smDir) writeRelaunchModelIntent(smDir, 'keep', reason)
19876
+ }
19530
19877
  await sweepBeforeSelfRestart()
19531
19878
  const hostdResp = await tryHostdDispatch(name, {
19532
19879
  v: 1,
@@ -19546,43 +19893,58 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
19546
19893
  )
19547
19894
  return
19548
19895
  }
19549
- // hostd is configured but returned an error/denied result.
19896
+ // hostd is configured but returned an error/denied result. No restart
19897
+ // is coming, so the keep-intent stamped above must not linger — a
19898
+ // crash within its 10-min freshness window would wrongly KEEP.
19550
19899
  if (hostdResp.result !== 'started' && hostdResp.result !== 'completed') {
19551
19900
  clearRestartMarker()
19901
+ {
19902
+ const smDir = resolveAgentDirFromEnv()
19903
+ if (smDir) clearRelaunchModelIntent(smDir)
19904
+ }
19552
19905
  throw new Error(
19553
19906
  `hostd restart failed (result=${hostdResp.result}): ${hostdResp.error ?? '(no details)'}`,
19554
19907
  )
19555
19908
  }
19556
19909
  },
19557
19910
  /**
19558
- * Session-only switch TO an sr-* (LiteLLM/OpenRouter) model. claude's
19559
- * native `/model` picker rejects unknown sr-* ids, so we can't inject.
19560
- * Write the token to the `.session-model-override` carrier file (start.sh
19561
- * consumes it on the next boot and launches `claude --model <token>`), set
19562
- * the in-memory session-model so /status stays honest across the restart
19563
- * window, then run the SAME restart dispatch as scheduleRestart above.
19911
+ * Switch TO a model that needs a relaunch (sr-* LiteLLM/OpenRouter ids,
19912
+ * which claude's native `/model` picker rejects, and the sr-to-claude
19913
+ * direction). Write the DURABLE `.session-model` override (start.sh
19914
+ * applies it on every keep-relaunch boot and launches `claude --model
19915
+ * <token>`), set the in-memory session-model so /status stays honest
19916
+ * across the restart window, then run the SAME restart dispatch as
19917
+ * scheduleRestart above — which stamps the keep-intent this boot needs.
19564
19918
  */
19565
19919
  scheduleModelRelaunch: async (model: string, reason: string) => {
19566
19920
  const agentDir = resolveAgentDirFromEnv()
19567
- if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model carrier')
19568
- // Carrier: single line, token + newline, no quoting (start.sh strips
19569
- // whitespace and shape-gates). One-shot — consumed on the next boot.
19921
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot write session-model file')
19570
19922
  const prevOverride = sessionModelSource.getOverride()
19571
- writeFileSync(join(agentDir, '.session-model-override'), `${model}\n`, 'utf8')
19923
+ const prevFileRaw = readSessionModelFileRaw(agentDir)
19924
+ writeSessionModelFile(
19925
+ agentDir,
19926
+ model,
19927
+ readConfiguredDefaultModel(agentDir) ??
19928
+ resolveMainModel(deps.getConfiguredModel() ?? undefined),
19929
+ )
19572
19930
  sessionModelSource.setOverride(model)
19573
19931
  try {
19574
19932
  await deps.scheduleRestart(reason)
19575
19933
  } catch (err) {
19576
- const carrierPath = join(agentDir, '.session-model-override')
19577
- // A restart already in flight OWNS the carrier we just wrote — it will
19578
- // consume our token at boot, so the switch is queued, not lost: keep the
19579
- // carrier + override and let the caller tell the operator "~15s". Any
19580
- // OTHER dispatch failure means no restart is coming, so roll BOTH back —
19581
- // a lingering carrier/override would lie to /status and mis-launch the
19582
- // NEXT ordinary restart.
19934
+ // A restart already in flight OWNS the override we just wrote — its
19935
+ // boot (stamped keep by the in-flight path's own intent, last-writer-
19936
+ // wins) will apply our token, so the switch is queued, not lost: keep
19937
+ // the file + override and let the caller tell the operator "~15s".
19938
+ // Any OTHER dispatch failure means no restart is coming, so roll BOTH
19939
+ // back — a lingering file/override would lie to /status and
19940
+ // mis-launch the NEXT relaunch. The keep-intent goes with them
19941
+ // (belt-and-braces: scheduleRestart's failure branch clears it too):
19942
+ // a fresh keep on disk with no restart coming would wrongly KEEP
19943
+ // across a crash inside its 10-min window.
19583
19944
  if ((err as { code?: string })?.code !== 'restart_in_flight') {
19584
- try { rmSync(carrierPath, { force: true }) } catch { /* best-effort */ }
19945
+ restoreSessionModelFileRaw(agentDir, prevFileRaw)
19585
19946
  sessionModelSource.setOverride(prevOverride)
19947
+ clearRelaunchModelIntent(agentDir)
19586
19948
  }
19587
19949
  throw err
19588
19950
  }
@@ -19620,10 +19982,44 @@ bot.command('model', async ctx => {
19620
19982
  // set on the confirmed inject path; the sr-*/relaunch paths already set the
19621
19983
  // override inside scheduleModelRelaunch, and an unverified switch carries no
19622
19984
  // selectedModel so /status is never lied to.
19623
- if (reply.selectedModel) {
19985
+ const requested = parsed.kind === 'set' ? expandSrAlias(parsed.model) : null
19986
+ let persistWarning = ''
19987
+ if (requested?.toLowerCase() === 'default') {
19988
+ // `/model default` clears the sticky file even WITHOUT a positive
19989
+ // confirmation: claude's arg-form switch can be silent, and a surviving
19990
+ // sticky file would resurrect the old model on the next keep-relaunch.
19991
+ // Clearing is idempotent; the in-memory override change stays gated on a
19992
+ // confirmed switch so an unverified inject never lies to /status.
19993
+ const smDir = resolveAgentDirFromEnv()
19994
+ if (smDir) clearSessionModelFile(smDir)
19995
+ if (reply.selectedModel) sessionModelSource.setOverride(null)
19996
+ } else if (reply.selectedModel) {
19624
19997
  sessionModelSource.setOverride(reply.selectedModel)
19998
+ // Durable stickiness: persist the REQUESTED canonical token — never
19999
+ // the confirmation's display label ("Opus 4.8"), which `claude
20000
+ // --model` would reject on the next boot. sr-* switches never reach
20001
+ // here (they go through scheduleModelRelaunch, which persists).
20002
+ const smDir = resolveAgentDirFromEnv()
20003
+ if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
20004
+ try {
20005
+ writeSessionModelFile(
20006
+ smDir,
20007
+ requested,
20008
+ readConfiguredDefaultModel(smDir) ??
20009
+ resolveMainModel(deps.getConfiguredModel() ?? undefined),
20010
+ )
20011
+ } catch (err) {
20012
+ // The reply body already promises stickiness — never let the promise
20013
+ // and the disk disagree silently.
20014
+ persistWarning =
20015
+ '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
20016
+ process.stderr.write(
20017
+ `telegram gateway: session-model persist failed (typed /model): ${(err as Error)?.message ?? String(err)}\n`,
20018
+ )
20019
+ }
20020
+ }
19625
20021
  }
19626
- await switchroomReply(ctx, reply.text, { html: reply.html })
20022
+ await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
19627
20023
  })
19628
20024
 
19629
20025
  // `/effort` — show or switch the reasoning effort for the live session.
@@ -19734,6 +20130,13 @@ bot.command('restart', async ctx => {
19734
20130
  // greeting card shows "Restarted user: /restart from chat" instead
19735
20131
  // of whatever reason the downstream CLI would default to.
19736
20132
  stampUserRestartReason('user: /restart from chat')
20133
+ // /restart is a DELIBERATE restart: the session model reverts to the
20134
+ // configured default. Absence of intent would revert anyway (boot
20135
+ // default) — the explicit stamp is reason-honesty for the boot notice.
20136
+ {
20137
+ const smDir = resolveAgentDirFromEnv()
20138
+ if (smDir) writeRelaunchModelIntent(smDir, 'revert', 'user: /restart from chat')
20139
+ }
19737
20140
  await sweepBeforeSelfRestart()
19738
20141
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
19739
20142
  v: 1,
@@ -19892,6 +20295,12 @@ async function handleNewCommand(ctx: Context): Promise<void> {
19892
20295
  // Stamp user attribution so the next greeting shows "Restarted user:
19893
20296
  // /new" / "user: /reset" rather than the downstream CLI default.
19894
20297
  stampUserRestartReason(`user: /${kind} from chat`)
20298
+ // /new and /reset start a fresh CONVERSATION, not a fresh model choice:
20299
+ // the sticky session-model override KEEPS across them (contract row 7).
20300
+ // Boot default is revert, so the keep-intent must land before dispatch.
20301
+ if (agentDir != null) {
20302
+ writeRelaunchModelIntent(agentDir, 'keep', `user: /${kind} from chat`)
20303
+ }
19895
20304
  await sweepBeforeSelfRestart()
19896
20305
  const hostdResp = await tryHostdDispatch(getMyAgentName(), {
19897
20306
  v: 1,
@@ -24727,7 +25136,7 @@ bot.on('callback_query:data', async ctx => {
24727
25136
  // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
24728
25137
  // Parity with the text `/model sr-*` path — claude's native picker rejects
24729
25138
  // unknown sr-* ids, so an in-place inject can't set them. Carry the token
24730
- // across a graceful restart (the `.session-model-override` carrier) and
25139
+ // across a graceful restart (the durable `.session-model` override) and
24731
25140
  // relaunch `claude --model sr-*`. Session-only; reverts to the configured
24732
25141
  // default on the next restart. The sr-* → Claude direction is handled below
24733
25142
  // via the SELECT/alias outcome + isSrToClaudeTransition.
@@ -24763,10 +25172,35 @@ bot.on('callback_query:data', async ctx => {
24763
25172
  const prevSessionModel = sessionModelSource.getOverride()
24764
25173
  const outcome = await handleModelMenuCallback(data, modelDeps)
24765
25174
  // Record a successful session switch so /status reflects what's
24766
- // actually running. In-memory only → clears when the gateway (and thus
24767
- // claude's session) restarts, exactly matching the session-only scope.
25175
+ // actually running, and persist the STICKY override
25176
+ // (reference/rfcs/session-model-stickiness.md): the canonical token
25177
+ // (never the display label) goes to the durable `.session-model`; a
25178
+ // confirmed "Default (recommended)" selection clears it instead.
24768
25179
  if (outcome.selectedModel) {
24769
25180
  sessionModelSource.setOverride(outcome.selectedModel)
25181
+ const smDir = resolveAgentDirFromEnv()
25182
+ if (smDir && outcome.selectedModelToken) {
25183
+ try {
25184
+ writeSessionModelFile(
25185
+ smDir,
25186
+ outcome.selectedModelToken,
25187
+ readConfiguredDefaultModel(smDir) ??
25188
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
25189
+ )
25190
+ } catch (err) {
25191
+ // The banner already promises stickiness — surface the failure on
25192
+ // the same card instead of only stderr.
25193
+ outcome.reply.text +=
25194
+ '\n⚠️ Couldn’t persist the sticky override — the switch is live now but won’t survive a relaunch.'
25195
+ process.stderr.write(
25196
+ `telegram gateway: session-model persist failed (menu): ${(err as Error)?.message ?? String(err)}\n`,
25197
+ )
25198
+ }
25199
+ }
25200
+ }
25201
+ if (outcome.clearedDefault) {
25202
+ const smDir = resolveAgentDirFromEnv()
25203
+ if (smDir) clearSessionModelFile(smDir)
24770
25204
  }
24771
25205
  // toastOnly: leave the menu untouched — but only if we haven't already
24772
25206
  // cleared its buttons with the interim sr-* edit. If we have, fall
@@ -24789,22 +25223,33 @@ bot.on('callback_query:data', async ctx => {
24789
25223
  )
24790
25224
  .catch(() => {})
24791
25225
  // Carry the requested Claude model across the restart via the SAME
24792
- // `.session-model-override` carrier a Claude → sr-* switch uses — otherwise
24793
- // boot launches the CONFIGURED default and the tapped model is silently
24794
- // dropped. `selectedModelToken` is a real `claude --model` token (alias or
24795
- // full claude-* id); a "Default"-row tap yields no token → boot the
24796
- // configured default (correct). start.sh's LiteLLM-down guard only drops
24797
- // sr-* overrides, so a Claude token is never dropped.
25226
+ // durable `.session-model` override a Claude → sr-* switch uses —
25227
+ // otherwise boot launches the CONFIGURED default and the tapped model
25228
+ // is silently dropped. `selectedModelToken` is a real `claude --model`
25229
+ // token (alias or full claude-* id); a "Default"-row tap yields no
25230
+ // token clear the override and boot the configured default
25231
+ // (correct). start.sh's LiteLLM-down guard only skips sr-* overrides,
25232
+ // so a Claude token is never dropped.
24798
25233
  {
24799
25234
  const agentDir = resolveAgentDirFromEnv()
24800
25235
  const token = outcome.selectedModelToken
24801
25236
  if (agentDir && token) {
24802
25237
  try {
24803
- writeFileSync(join(agentDir, '.session-model-override'), `${token}\n`, 'utf8')
25238
+ writeSessionModelFile(
25239
+ agentDir,
25240
+ token,
25241
+ readConfiguredDefaultModel(agentDir) ??
25242
+ resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
25243
+ )
24804
25244
  sessionModelSource.setOverride(token)
24805
25245
  } catch (e) {
24806
- process.stderr.write(`telegram gateway: sr-to-claude carrier write failed: ${(e as Error)?.message ?? String(e)}\n`)
25246
+ process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
24807
25247
  }
25248
+ } else if (agentDir) {
25249
+ // Default-row tap while on sr-*: the restart must land on the
25250
+ // configured default — a stale sticky override would resurrect
25251
+ // the old model on the next keep-relaunch.
25252
+ clearSessionModelFile(agentDir)
24808
25253
  }
24809
25254
  }
24810
25255
  // Write the restart marker so the post-restart boot card edits into this chat.
@@ -25165,22 +25610,36 @@ bot.on('callback_query:data', async ctx => {
25165
25610
  }
25166
25611
  return undefined
25167
25612
  })()
25168
- // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written
25169
- // by the pre-synth queue at reply time). If it's on disk, attach it
25613
+ const tok = token as string
25614
+ const sendOpts = {
25615
+ ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
25616
+ ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
25617
+ } as never
25618
+ const sendVerbOpts = {
25619
+ chat_id: cbChatId,
25620
+ verb: 'voice-ondemand.sendVoice',
25621
+ ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
25622
+ }
25623
+
25624
+ // #2763 attach-on-tap: prefer the eagerly pre-synthesized file (written by
25625
+ // the pre-synth queue at reply time). If it's on disk, attach it
25170
25626
  // immediately — no GPU wait. Missing/unreadable file (expired + swept,
25171
- // crash, pre-feature entry, kill-switched gateway) falls back
25172
- // transparently to the lazy synth path below.
25173
- let audio: Uint8Array | null = null
25174
- if (entry.filePath != null) {
25175
- try {
25176
- audio = readFileSync(entry.filePath)
25177
- } catch {
25178
- audio = null // swept/missing — lazy fallback
25627
+ // crash, pre-feature entry, kill-switched gateway) falls back transparently
25628
+ // to the lazy synth path. Only invoked when there's no reusable file_id (or
25629
+ // a stored id was rejected as stale) — see sendVoiceReusingFileId.
25630
+ const loadAudio = async (): Promise<Uint8Array | null> => {
25631
+ let audio: Uint8Array | null = null
25632
+ if (entry.filePath != null) {
25633
+ try {
25634
+ audio = readFileSync(entry.filePath)
25635
+ } catch {
25636
+ audio = null // swept/missing — lazy fallback
25637
+ }
25638
+ }
25639
+ if (audio != null) {
25640
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
25641
+ return audio
25179
25642
  }
25180
- }
25181
- if (audio != null) {
25182
- await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
25183
- } else {
25184
25643
  await ctx.answerCallbackQuery({ text: '🔊 Synthesizing…' }).catch(() => {})
25185
25644
  // Local sidecar (kokoro) synthesis — same helper the immediate voice-out
25186
25645
  // path uses. On-demand is a local-engine feature; the cache is only
@@ -25190,7 +25649,7 @@ bot.on('callback_query:data', async ctx => {
25190
25649
  await ctx
25191
25650
  .answerCallbackQuery({ text: 'Voice sidecar unavailable — try again later.' })
25192
25651
  .catch(() => {})
25193
- return
25652
+ return null
25194
25653
  }
25195
25654
  const result = await synthesizeViaSidecar({
25196
25655
  token: sidecarToken,
@@ -25209,33 +25668,52 @@ bot.on('callback_query:data', async ctx => {
25209
25668
  await ctx
25210
25669
  .answerCallbackQuery({ text: `Voice failed: ${result.reason}` })
25211
25670
  .catch(() => {})
25212
- return
25671
+ return null
25672
+ }
25673
+ return result.audio
25674
+ }
25675
+
25676
+ // Fast path: if we already captured a reusable file_id, ack instantly and
25677
+ // send BY the id — no disk read, no re-upload. First tap (no id yet) and a
25678
+ // stale-id fallback both go through loadAudio + InputFile below and refresh
25679
+ // the stored id from the returned message.
25680
+ if (entry.telegramFileId != null) {
25681
+ await ctx.answerCallbackQuery({ text: '🔊' }).catch(() => {})
25682
+ }
25683
+ const sendResult = await sendVoiceReusingFileId({
25684
+ fileId: entry.telegramFileId ?? null,
25685
+ sendByFileId: (fid) =>
25686
+ robustApiCall(
25687
+ // allow-raw-bot-api: reuse Telegram's file_id — no re-upload.
25688
+ () => bot.api.sendVoice(cbChatId, fid, sendOpts),
25689
+ sendVerbOpts,
25690
+ ),
25691
+ loadAudio,
25692
+ sendByUpload: (audioOut) =>
25693
+ robustApiCall(
25694
+ // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
25695
+ () => bot.api.sendVoice(cbChatId, new InputFile(Buffer.from(audioOut)), sendOpts),
25696
+ sendVerbOpts,
25697
+ ),
25698
+ onFileId: (fid) => voiceOnDemandCache.setTelegramFileId(tok, fid),
25699
+ log: (line) => process.stderr.write(line),
25700
+ })
25701
+
25702
+ if (!sendResult.ok) {
25703
+ // no-audio already surfaced a toast inside loadAudio; send-failed is
25704
+ // logged non-fatally — the '🔊 Listen' keyboard is left intact so the
25705
+ // user can retry (only a successful send strips it below).
25706
+ if (sendResult.reason === 'send-failed') {
25707
+ const err = sendResult.error
25708
+ const msg = err instanceof Error ? err.message : String(err)
25709
+ process.stderr.write(
25710
+ `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
25711
+ )
25213
25712
  }
25214
- audio = result.audio
25713
+ return
25215
25714
  }
25216
- // Rebind as const so the closure below narrows to non-null (TS doesn't
25217
- // narrow a captured `let` inside an arrow function).
25218
- const audioOut: Uint8Array = audio
25715
+
25219
25716
  try {
25220
- // Native voice note (NOT a document), quote-replying the button's
25221
- // message.
25222
- await robustApiCall(
25223
- () =>
25224
- bot.api.sendVoice(
25225
- cbChatId,
25226
- // allow-raw-bot-api: single native voice-note send for an on-demand Listen tap.
25227
- new InputFile(Buffer.from(audioOut)),
25228
- {
25229
- ...(cbMessageId != null ? { reply_parameters: { message_id: cbMessageId } } : {}),
25230
- ...(cbThreadId != null ? { message_thread_id: cbThreadId } : {}),
25231
- } as never,
25232
- ),
25233
- {
25234
- chat_id: cbChatId,
25235
- verb: 'voice-ondemand.sendVoice',
25236
- ...(cbThreadId != null ? { threadId: cbThreadId } : {}),
25237
- },
25238
- )
25239
25717
  // Single-use on SUCCESS: strip the '🔊 Listen' keyboard so the button
25240
25718
  // can't be re-tapped now that the audio has been delivered. Mirrors the
25241
25719
  // agent-button single_use strip (keyboardIsSingleUse) house style.
@@ -25259,7 +25737,7 @@ bot.on('callback_query:data', async ctx => {
25259
25737
  } catch (err) {
25260
25738
  const msg = err instanceof Error ? err.message : String(err)
25261
25739
  process.stderr.write(
25262
- `telegram gateway: voice-out on-demand: sendVoice failed (non-fatal): ${msg}\n`,
25740
+ `telegram gateway: voice-out on-demand: strip-listen-keyboard failed (non-fatal): ${msg}\n`,
25263
25741
  )
25264
25742
  }
25265
25743
  return
@@ -27809,6 +28287,7 @@ void (async () => {
27809
28287
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
27810
28288
  configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
27811
28289
  bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
28290
+ floodStatePath: FLOOD_STATE_PATH,
27812
28291
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
27813
28292
  }, ackMsgId)
27814
28293
  activeBootCard = handle