switchroom 0.18.27 → 0.18.29

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 (31) hide show
  1. package/bin/handoff-briefing.sh +8 -1
  2. package/dist/auth-broker/index.js +0 -57
  3. package/dist/cli/switchroom.js +501 -497
  4. package/dist/host-control/main.js +1 -58
  5. package/dist/vault/approvals/kernel-server.js +0 -57
  6. package/dist/vault/broker/server.js +0 -57
  7. package/package.json +1 -1
  8. package/profiles/_base/start.sh.hbs +37 -19
  9. package/telegram-plugin/dist/gateway/gateway.js +655 -587
  10. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  11. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  12. package/telegram-plugin/gateway/gateway.ts +511 -397
  13. package/telegram-plugin/gateway/model-command.ts +227 -602
  14. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  15. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  16. package/telegram-plugin/history.ts +153 -23
  17. package/telegram-plugin/shared/local-time.ts +56 -0
  18. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  19. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  20. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
  21. package/telegram-plugin/tests/history.test.ts +88 -0
  22. package/telegram-plugin/tests/local-time.test.ts +68 -0
  23. package/telegram-plugin/tests/model-command.test.ts +317 -1535
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  25. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  26. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  27. package/telegram-plugin/tier-downgrade.ts +4 -3
  28. package/telegram-plugin/turn-flush-safety.ts +25 -1
  29. package/telegram-plugin/worker-activity-feed.ts +78 -5
  30. package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
  31. package/vendor/hindsight-memory/tests/test_content.py +28 -7
@@ -102,6 +102,7 @@ import {
102
102
  forwardOriginDateIso,
103
103
  type ForwardOriginInfo,
104
104
  } from './forward-origin.js'
105
+ import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
105
106
  import { StatusReactionController } from '../status-reactions.js'
106
107
  import { DeferredDoneReactions } from '../reaction-defer.js'
107
108
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
@@ -473,7 +474,6 @@ import {
473
474
  handleModelCommand,
474
475
  buildModelMenu,
475
476
  handleModelMenuCallback,
476
- isSrToClaudeTransition,
477
477
  isValidModelArg,
478
478
  MODEL_CALLBACK_PREFIX,
479
479
  MODEL_CALLBACK_HEADER,
@@ -505,7 +505,7 @@ import {
505
505
  import { runTierDowngrade } from './tier-downgrade-wiring.js'
506
506
  import { runPremiumRecoveryPing } from './premium-recovery-wiring.js'
507
507
  import { decidePremiumRecovery } from '../premium-recovery.js'
508
- import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
508
+ import { discoverModels } from '../../src/agents/model-picker.js'
509
509
  import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
510
510
  import {
511
511
  parseEffortCommand,
@@ -604,6 +604,7 @@ import {
604
604
  } from './queued-card-store.js'
605
605
  import {
606
606
  decideWorkerPinReaps,
607
+ storeOnlyWorkerPinCandidates,
607
608
  WORKER_PIN_TTL_MS_DEFAULT,
608
609
  } from './worker-pin-reaper.js'
609
610
  import { driveEscalation } from './escalation-drive.js'
@@ -634,9 +635,13 @@ import { decideObligationTurnEnd } from './obligation-turn-end.js'
634
635
  import { maybeRotate } from './turns-jsonl-rotate.js'
635
636
  import {
636
637
  buildTurnRecord,
637
- finalizeBackstopSend,
638
+ finalizeBackstopSendGated,
638
639
  type DeliveryOutcome,
639
640
  } from './turn-record-status.js'
641
+ import {
642
+ BackstopDeliveryLedger,
643
+ runBackstopDelivery,
644
+ } from './backstop-delivery.js'
640
645
  import {
641
646
  createDeliveryQueue,
642
647
  trackDelivery,
@@ -2337,6 +2342,150 @@ const outboundDedup = new OutboundDedupCache()
2337
2342
  // catches the containment case the exact-text `outboundDedup` misses (a
2338
2343
  // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2339
2344
  const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2345
+ // #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
2346
+ // idempotency ledger. The latch (keyed on `turnId`) is the deterministic
2347
+ // arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
2348
+ // send resume at the first unsent chunk instead of re-sending chunk 0.
2349
+ const backstopDeliveryLedger = new BackstopDeliveryLedger()
2350
+ // #3276 finding-1 — bounded in-turn retries for the backstop send before it
2351
+ // gives up and records `send_failed` (leaving the obligation open for the
2352
+ // liveness floor). Resumes mid-chunk each attempt, so chunk 0 is never
2353
+ // re-sent. Env-tunable for ops; default 3.
2354
+ const BACKSTOP_DELIVERY_MAX_ATTEMPTS = (() => {
2355
+ const raw = Number(process.env.SWITCHROOM_BACKSTOP_DELIVERY_MAX_ATTEMPTS)
2356
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3
2357
+ })()
2358
+
2359
+ /**
2360
+ * #3276 — the ONE delivery primitive the turn-flush backstop uses to put a
2361
+ * flushed answer into the chat. It routes through `sendReplyChunks` — the SAME
2362
+ * battle-tested send core `executeReply` uses (THREAD_NOT_FOUND fallback,
2363
+ * length re-split, parse-reject plaintext fallback) — and returns the REAL
2364
+ * fresh chat message ids.
2365
+ *
2366
+ * Deliberately NOT card-coupled: it never edits the progress card, so a
2367
+ * "delivery" can never be a card mutation that the marker-sweep GC's ~60-90s
2368
+ * later. The card is unpinned/collapsed by the caller as a purely cosmetic
2369
+ * follow-up (guard 4). `previewMessageId` is always null here.
2370
+ *
2371
+ * Idempotent (guard 6): each chunk is sent under `backstopDeliveryLedger`. A
2372
+ * chunk that already landed for this `turnId` is skipped, and a pending marker
2373
+ * is written before every wire call, so a retry after a partial send or a lost
2374
+ * ack resumes at the first unsent chunk and never re-sends chunk 0.
2375
+ *
2376
+ * `text` must already be fully normalized/redacted/scrubbed by the caller (the
2377
+ * turn-flush branch runs the exact reply-parity pipeline before calling this).
2378
+ */
2379
+ async function deliverAnswer(args: {
2380
+ chatId: string
2381
+ threadId: number | undefined
2382
+ text: string
2383
+ turnId: string
2384
+ cardMessageId: number | null
2385
+ }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
2386
+ const { chatId, turnId } = args
2387
+ // Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
2388
+ // executeReply does on the non-literal path (idempotent, one U+00A0 per gap).
2389
+ const rendered = addParagraphSpacers(args.text)
2390
+ const chunks = splitMarkdownChunks(rendered, RICH_MESSAGE_MAX_CHARS)
2391
+
2392
+ const deps: ReplyChunkSendDeps = {
2393
+ sendRich: (opts, body, tid) =>
2394
+ robustApiCall(
2395
+ // allow-raw-bot-api: deliverAnswer chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
2396
+ () => bot.api.sendRichMessage(chatId, body as never, opts as never),
2397
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical' },
2398
+ ),
2399
+ sendLiteral: (opts, txt, tid) =>
2400
+ robustApiCall(
2401
+ // allow-raw-bot-api: deliverAnswer chunk-loop adapter — literal sendMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
2402
+ () => bot.api.sendMessage(chatId, txt, opts as never),
2403
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical' },
2404
+ ),
2405
+ // allow-raw-bot-api: literal last-resort fallback (parse-reject / length re-split); wrapping would re-enter the policy that just rejected the payload
2406
+ sendLiteralRaw: (opts, txt) => bot.api.sendMessage(chatId, txt, opts as never),
2407
+ // allow-raw-bot-api: rich length-error re-split last resort; wrapping would re-enter the chunk-loop classification on an already-classified length failure
2408
+ sendRichRaw: (opts, body) => bot.api.sendRichMessage(chatId, body as never, opts as never),
2409
+ editPreview: (mid, body, opts, tid) =>
2410
+ robustApiCall(
2411
+ // allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
2412
+ () => bot.api.editMessageText(chatId, mid, body as never, opts as never),
2413
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical', messageId: mid, editPayload: body },
2414
+ ),
2415
+ richMessage,
2416
+ logOutbound,
2417
+ // deliverAnswer never sets a previewMessageId, so this is never invoked;
2418
+ // provide a best-effort delete for interface completeness.
2419
+ deleteStalePreview: async (id: number): Promise<void> => {
2420
+ await swallowingApiCall(
2421
+ () => bot.api.deleteMessage(chatId, id),
2422
+ { chat_id: chatId, verb: 'deliverAnswer.deleteStalePreview' },
2423
+ )
2424
+ },
2425
+ stderr: (s: string) => { process.stderr.write(s) },
2426
+ }
2427
+
2428
+ // Send ONE chunk via the shared `sendReplyChunks` core. `liveThreadId`
2429
+ // threads the THREAD_NOT_FOUND fallback decision across chunks. Returns the
2430
+ // landed message id(s) (a length-resplit chunk may land >1); throws on an
2431
+ // unrecoverable send failure so the retry orchestrator can resume.
2432
+ let liveThreadId = args.threadId
2433
+ const sendChunk = async (_chunkIndex: number, text: string): Promise<number[]> => {
2434
+ const chunkIds: number[] = []
2435
+ const res = await sendReplyChunks(deps, {
2436
+ chatId,
2437
+ chunks: [text],
2438
+ literalText: false,
2439
+ suppressText: false,
2440
+ threadId: liveThreadId,
2441
+ previewMessageId: null,
2442
+ sentIds: chunkIds,
2443
+ buildSendOpts: (_i, _isLast, tid) => ({
2444
+ ...(tid != null ? { message_thread_id: tid } : {}),
2445
+ link_preview_options: { is_disabled: true },
2446
+ }),
2447
+ buildPreviewEditOpts: () => ({}),
2448
+ })
2449
+ liveThreadId = res.threadId
2450
+ return chunkIds
2451
+ }
2452
+
2453
+ // Bounded in-turn retry (finding-1 fix): resumes at the first unsent chunk on
2454
+ // each attempt via the ledger, so chunk 0 is delivered exactly once even
2455
+ // across retries. `delivered`/`exhausted` tell the caller whether the answer
2456
+ // actually reached the chat — the caller leaves the delivery obligation OPEN
2457
+ // on terminal failure so the liveness floor re-presents it.
2458
+ const result = await runBackstopDelivery(
2459
+ backstopDeliveryLedger,
2460
+ turnId,
2461
+ chunks,
2462
+ args.cardMessageId,
2463
+ {
2464
+ sendChunk,
2465
+ recordOutbound: HISTORY_ENABLED
2466
+ ? (messageIds, texts) => {
2467
+ try {
2468
+ recordOutbound({
2469
+ chat_id: chatId,
2470
+ thread_id: args.threadId ?? null,
2471
+ message_ids: messageIds,
2472
+ texts,
2473
+ })
2474
+ } catch { /* best-effort */ }
2475
+ }
2476
+ : undefined,
2477
+ stderr: (s: string) => { process.stderr.write(s) },
2478
+ },
2479
+ BACKSTOP_DELIVERY_MAX_ATTEMPTS,
2480
+ )
2481
+ return {
2482
+ sentIds: result.sentIds,
2483
+ chunkCount: result.chunkCount,
2484
+ delivered: result.delivered,
2485
+ exhausted: result.exhausted,
2486
+ }
2487
+ }
2488
+
2340
2489
  /**
2341
2490
  * Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
2342
2491
  * the FIRST message in a chat creates a controller without the filter (null
@@ -5080,7 +5229,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5080
5229
 
5081
5230
  function endCurrentTurnAtomic(
5082
5231
  turn: CurrentTurn,
5083
- opts?: { deferRecord?: boolean },
5232
+ opts?: { deferRecord?: boolean; deferObligationClose?: boolean },
5084
5233
  ): number | null {
5085
5234
  // PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
5086
5235
  // guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
@@ -5149,7 +5298,13 @@ function endCurrentTurnAtomic(
5149
5298
  // obligations are designed to catch ends via silence_fallback, NOT turn_end.
5150
5299
  // At turn_end with replyCalled=true the model explicitly signalled completion
5151
5300
  // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
5152
- if (OBLIGATION_LEDGER_ENABLED) {
5301
+ // #3276 finding 1 — the turn-flush backstop passes `deferObligationClose` so
5302
+ // the obligation disposition reflects the REAL send outcome (resolved in its
5303
+ // async finally after the bounded retry), NOT the speculative fire-time
5304
+ // `finalAnswerDelivered=true`. Closing here would satisfy the obligation
5305
+ // before the send is known to have landed, re-introducing the silent-drop on
5306
+ // terminal failure. Every synchronous turn-end path is unchanged.
5307
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
5153
5308
  if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
5154
5309
  obligationLedger.close(turn.turnId)
5155
5310
  } else {
@@ -9023,16 +9178,32 @@ async function runMidSessionCardReaper(): Promise<void> {
9023
9178
  // the durable store row clear together.
9024
9179
  if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
9025
9180
  try {
9026
- const candidates = [...statusPinState.keys()]
9027
- .filter((k) => k.startsWith('wk:'))
9028
- .map((k) => ({
9029
- pinKey: k,
9030
- chatId: statusPinChatIds.get(k) ?? '',
9031
- // A missing timestamp (should not happen — set on every claim)
9032
- // degrades to "claimed just now": terminality can still reap it,
9033
- // the TTL gate never can. Conservative, never a spurious unpin.
9034
- pinnedAt: statusPinPinnedAt.get(k) ?? now,
9035
- }))
9181
+ const inMemoryKeys = new Set(
9182
+ [...statusPinState.keys()].filter((k) => k.startsWith('wk:')),
9183
+ )
9184
+ const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
9185
+ pinKey: k,
9186
+ chatId: statusPinChatIds.get(k) ?? '',
9187
+ // A missing timestamp (should not happen — set on every claim)
9188
+ // degrades to "claimed just now": terminality can still reap it,
9189
+ // the TTL gate never can. Conservative, never a spurious unpin.
9190
+ pinnedAt: statusPinPinnedAt.get(k) ?? now,
9191
+ }))
9192
+ // #3001 durable group net: fold in `wk:` rows that live in the DURABLE
9193
+ // store but have NO in-memory claim — the divergence window where the
9194
+ // claim was lost but the Telegram pin + store row survive. These would
9195
+ // otherwise linger until the next boot cleanup; the reconciling sweep
9196
+ // recovers them mid-session too, group-safely (per-message unpin of a
9197
+ // bot-tracked row, NEVER an unpin-all, NEVER an untracked/human pin).
9198
+ const storeOnlyCandidates = statusPinPersistEnabled
9199
+ ? storeOnlyWorkerPinCandidates({
9200
+ rows: loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
9201
+ inMemoryPinKeys: inMemoryKeys,
9202
+ now,
9203
+ })
9204
+ : []
9205
+ const storeOnlyKeys = new Set(storeOnlyCandidates.map((c) => c.pinKey))
9206
+ const candidates = [...inMemoryCandidates, ...storeOnlyCandidates]
9036
9207
  const reaps = decideWorkerPinReaps({
9037
9208
  pins: candidates,
9038
9209
  statusOf: (agentId) => {
@@ -9064,11 +9235,35 @@ async function runMidSessionCardReaper(): Promise<void> {
9064
9235
  now,
9065
9236
  })
9066
9237
  for (const reap of reaps) {
9238
+ const storeOnly = storeOnlyKeys.has(reap.pinKey)
9067
9239
  process.stderr.write(
9068
9240
  `telegram gateway: worker-pin reaper unpinning ${reap.pinKey} ` +
9069
- `(chat=${reap.chatId} reason=${reap.reason})\n`,
9241
+ `(chat=${reap.chatId} reason=${reap.reason}` +
9242
+ `${storeOnly ? ' source=store-orphan' : ''})\n`,
9070
9243
  )
9071
- await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
9244
+ if (storeOnly && reap.messageId != null) {
9245
+ // No in-memory claim to reconcile: unpin the exact tracked message
9246
+ // (per-message — group-safe) and drop the store row directly. Both
9247
+ // are best-effort/idempotent; a failed unpin still drops the row so
9248
+ // the next boot's cleanup is the final backstop.
9249
+ try {
9250
+ await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId)
9251
+ } catch (err) {
9252
+ process.stderr.write(
9253
+ `telegram gateway: worker-pin reaper store-orphan unpin failed ` +
9254
+ `(${reap.pinKey} chat=${reap.chatId} msg=${reap.messageId}): ` +
9255
+ `${(err as Error).message}\n`,
9256
+ )
9257
+ }
9258
+ await mutateStatusPinRow(
9259
+ STATUS_PIN_STORE_PATH,
9260
+ statusPinStoreFs,
9261
+ reap.pinKey,
9262
+ null,
9263
+ )
9264
+ } else {
9265
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false })
9266
+ }
9072
9267
  }
9073
9268
  } catch (err) {
9074
9269
  process.stderr.write(
@@ -16178,7 +16373,10 @@ async function executeGetRecentMessages(args: Record<string, unknown>): Promise<
16178
16373
  const summary = rows
16179
16374
  .map(r => {
16180
16375
  const who = r.role === 'user' ? r.user ?? 'user' : 'assistant'
16181
- const time = new Date(r.ts * 1000).toISOString()
16376
+ // Local am/pm wall-clock (NOT UTC ISO) — this buffer is read straight
16377
+ // into the model's context via get_recent_messages, so every timestamp
16378
+ // it shows must be local to avoid competing with the local-time hint.
16379
+ const time = fmtLocalStamp(r.ts * 1000, resolveEnvTimezone())
16182
16380
  const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : ''
16183
16381
  // Match server.ts get_recent_messages format exactly — both code paths
16184
16382
  // serve the same MCP tool, so the agent's parsing must not depend on
@@ -18814,14 +19012,28 @@ function handleSessionEvent(ev: SessionEvent): void {
18814
19012
  // post-fire pre-record window resolves this turn (via the unified owner
18815
19013
  // resolver, reading the atom preserved in `recentTurnsById`) and
18816
19014
  // suppresses itself against this latch — closing the residual race Part
18817
- // 1's supersede cannot reach. Scoped to a SUBSTANTIVE terminal answer
18818
- // (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
18819
- // recognise a real answer) so a short flush never latches against a
18820
- // legitimate substantive reply. `capturedText` here is the selected,
19015
+ // 1's supersede cannot reach. `capturedText` here is the selected,
18821
19016
  // normalized flush delivery text.
18822
- if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
18823
- turn.answerDelivered = true
18824
- }
19017
+ //
19018
+ // #3276 guard 2/5 — the arm is now UNCONDITIONAL (dropped the former
19019
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` gate). A short terminal answer ("yes,
19020
+ // done") is a genuine answer this backstop is about to deliver, so a
19021
+ // late reply carrying the same short answer MUST supersede/suppress
19022
+ // rather than post a duplicate — the real-id supersede recorded below
19023
+ // corrects it in place.
19024
+ //
19025
+ // TWO distinct arbiters set synchronously here, before any `await`:
19026
+ // (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
19027
+ // reply path already reads (`decideAnswerLatchSuppression` +
19028
+ // `flushedTurnSupersede`), exactly as on `main`.
19029
+ // (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
19030
+ // double-fire latch: `claim` returning false means this turn
19031
+ // already fired a backstop (answer-ready quiescence, then the
19032
+ // turn-end backstop), so this fire is a no-op. It does NOT
19033
+ // arbitrate the late reply (that is (a)); it is redundant-but-
19034
+ // cheap with the `currentTurn == null` bail below.
19035
+ turn.answerDelivered = true
19036
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
18825
19037
 
18826
19038
  // #654 deterministic double-message fix. Hand off the pinned
18827
19039
  // progress card BEFORE state reset so the driver doesn't keep
@@ -18853,7 +19065,7 @@ function handleSessionEvent(ev: SessionEvent): void {
18853
19065
  // bookkeeping, purge) still runs synchronously here for the #1067 /
18854
19066
  // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
18855
19067
  // atom was already torn down elsewhere (no record to emit).
18856
- const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true })
19068
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
18857
19069
  // #549 fix — turn-flush takes ownership of the captured-text
18858
19070
  // backup; reset the preamble buffer (its content is already in
18859
19071
  // the captured `capturedText`, which turn-flush is about to send).
@@ -18900,6 +19112,13 @@ function handleSessionEvent(ev: SessionEvent): void {
18900
19112
  // finalAnswerDelivered). Not a failure.
18901
19113
  if (backstopTurnEndedAt != null) {
18902
19114
  turn.deliveryOutcome = 'suppressed'
19115
+ // #3276 finding 1 — obligation close was DEFERRED out of
19116
+ // endCurrentTurnAtomic. The reply tool already delivered this
19117
+ // turn's answer (recentCount>0), so resolve it as satisfied
19118
+ // here (idempotent with the reply path's own close). Without
19119
+ // this the deferred obligation would linger OPEN and spuriously
19120
+ // re-present a turn that WAS answered.
19121
+ if (OBLIGATION_LEDGER_ENABLED) obligationLedger.close(turn.turnId)
18903
19122
  emitTurnRecord(turn, backstopTurnEndedAt)
18904
19123
  }
18905
19124
  return
@@ -18907,118 +19126,53 @@ function handleSessionEvent(ev: SessionEvent): void {
18907
19126
  } catch {}
18908
19127
  }
18909
19128
 
19129
+ // #3276 guard 5 — double-fire guard. If this turn already claimed the
19130
+ // delivery latch (a prior backstop fire — e.g. answer-ready quiescence
19131
+ // followed by the turn-end backstop for the same turn), do NOT deliver
19132
+ // again. The first fire owns delivery; this fire is a cosmetic no-op.
19133
+ if (!backstopLatchClaimed) {
19134
+ process.stderr.write(
19135
+ `telegram gateway: turn-flush skipped — turn ${turn.turnId} already claimed the delivery latch\n`,
19136
+ )
19137
+ return
19138
+ }
19139
+
18910
19140
  process.stderr.write(
18911
19141
  `telegram gateway: turn-flush firing — ${capturedText.length} chars without reply tool ` +
18912
19142
  `(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
18913
19143
  )
18914
- const sendOpts = {
18915
- ...(backstopThreadId != null ? { message_thread_id: backstopThreadId } : {}),
18916
- link_preview_options: { is_disabled: true },
18917
- }
18918
- const limit = RICH_MESSAGE_MAX_CHARS
18919
- // PR B (Fix 1) — send accounting is declared OUTSIDE the try so the
18920
- // single-record `finally` below can read it, and ALL setup (the chunk
18921
- // split) is pulled INSIDE the try so a throw there still lands in the
18922
- // finally (→ one honest `send_failed` row) rather than rejecting the
18923
- // IIFE with zero turns.jsonl rows written / an unhandled rejection.
18924
- let htmlChunks: string[] = []
18925
- const sentIds: number[] = []
18926
- // track whether the send threw so the deferred record reflects the
18927
- // real outcome (throw OR partial multi-chunk → send_failed).
18928
- let sendThrew = false
19144
+ // PR B (Fix 1) — send accounting declared OUTSIDE the try so the
19145
+ // single-record `finally` below reads it on EVERY in-process exit.
19146
+ // `delivered` is the RECEIPT-gated truth (>=1 fresh non-card id AND
19147
+ // all chunks landed), computed by the retry orchestrator — NOT a
19148
+ // blanket outer-catch flag, so a throw in the post-delivery
19149
+ // bookkeeping below (dedup / supersede record) can never demote a
19150
+ // genuinely delivered turn to `send_failed` (finding 4).
19151
+ let sentIds: number[] = []
19152
+ let chunkCount = 0
19153
+ let delivered = false
18929
19154
  try {
18930
- // #2798 / #2692 — inject visible blank-line spacers into prose `\n\n`
18931
- // gaps before splitting, exactly as executeReply does. The rich GFM
18932
- // renderer collapses a bare `\n\n` gap TIGHT, so without this the
18933
- // paragraph boundaries from the '\n\n' block join (turn-flush-safety
18934
- // .ts) would still render jammed together. Mirrors reply's
18935
- // `addParagraphSpacers(text)` on the non-literal path (idempotent —
18936
- // exactly one U+00A0 spacer per gap, never doubled).
18937
- const renderedText = addParagraphSpacers(capturedText)
18938
- htmlChunks = splitMarkdownChunks(renderedText, limit)
18939
- // #654 deterministic double-message fix. If the progress
18940
- // card is on screen (60s timer fired before turn_end), edit
18941
- // it in place with the first chunk of the answer instead of
18942
- // posting a fresh message — avoids the user seeing both the
18943
- // pinned card AND a separate text bubble for the same turn.
18944
- // Multi-chunk answers (>4000 chars) edit chunk[0] into the
18945
- // card and send chunks[1..] fresh. If the edit fails (e.g.
18946
- // user deleted the card, parse-mode conflict), fall back to
18947
- // a fresh send for chunk[0] — accepts a 2-message outcome
18948
- // for that edge case rather than dropping the answer.
18949
- // #1075: route turn-flush sends/edits through robustApiCall.
18950
- // Edit-in-place isn't thread-id-bearing on its own (the
18951
- // target message id implies a thread), so a stale-thread
18952
- // 400 just fails the edit and the loop falls back to fresh
18953
- // sendMessage. sendMessage IS thread-id-bearing — drop the
18954
- // thread on THREAD_NOT_FOUND so the captured prose still
18955
- // lands somewhere instead of being lost entirely.
18956
- let firstSendUsedEdit = false
18957
- let liveThreadId: number | undefined = backstopThreadId
18958
- if (backstopCardMessageId != null && htmlChunks.length > 0) {
18959
- try {
18960
- await robustApiCall(
18961
- () =>
18962
- bot.api.editMessageText(
18963
- backstopChatId,
18964
- backstopCardMessageId,
18965
- richMessage(htmlChunks[0]),
18966
- sendOpts,
18967
- ),
18968
- {
18969
- chat_id: backstopChatId,
18970
- verb: 'turn-flush.editMessageText',
18971
- ...(liveThreadId != null ? { threadId: liveThreadId } : {}),
18972
- },
18973
- )
18974
- sentIds.push(backstopCardMessageId)
18975
- firstSendUsedEdit = true
18976
- } catch (err) {
18977
- process.stderr.write(
18978
- `telegram gateway: turn-flush card-takeover edit failed: ${(err as Error).message} — falling back to sendMessage\n`,
18979
- )
18980
- if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
18981
- liveThreadId = undefined
18982
- }
18983
- }
18984
- }
18985
- const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks
18986
- for (const c of remainingChunks) {
18987
- const sent = await retryWithThreadFallback(
18988
- robustApiCall,
18989
- (tid) => {
18990
- const opts = {
18991
- link_preview_options: { is_disabled: true },
18992
- ...(tid != null ? { message_thread_id: tid } : {}),
18993
- }
18994
- return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
18995
- },
18996
- {
18997
- threadId: liveThreadId,
18998
- chat_id: backstopChatId,
18999
- verb: 'turn-flush.sendMessage',
19000
- },
19001
- )
19002
- if (liveThreadId != null) {
19003
- const sentMsg = sent as { message_thread_id?: number }
19004
- if (sentMsg.message_thread_id == null) liveThreadId = undefined
19005
- }
19006
- sentIds.push(sent.message_id)
19007
- }
19008
- if (HISTORY_ENABLED && sentIds.length > 0) {
19009
- try {
19010
- recordOutbound({
19011
- chat_id: backstopChatId,
19012
- thread_id: backstopThreadId ?? null,
19013
- message_ids: sentIds,
19014
- texts: htmlChunks,
19015
- })
19016
- } catch {}
19017
- }
19018
- // #546 dedup: record what turn-flush just sent so a
19019
- // late-arriving reply / stream_reply with the same
19020
- // content gets suppressed (claude-code retries the
19021
- // un-acked tool_call after a bridge reconnect).
19155
+ // #3276 — the ONE delivery primitive. deliverAnswer routes through
19156
+ // `sendReplyChunks` (the same send core executeReply uses) and posts
19157
+ // a FRESH chat message; it NEVER edits the progress card, so a
19158
+ // "delivery" can never be a card mutation the marker-sweep GC's
19159
+ // ~60-90s later. It returns the REAL fresh chat message ids and
19160
+ // retries mid-chunk (bounded) before giving up — the per-chunk
19161
+ // ledger resumes at the first unsent chunk, never re-sending chunk 0
19162
+ // (guard 6).
19163
+ const delivery = await deliverAnswer({
19164
+ chatId: backstopChatId,
19165
+ threadId: backstopThreadId,
19166
+ text: capturedText,
19167
+ turnId: turn.turnId,
19168
+ cardMessageId: backstopCardMessageId,
19169
+ })
19170
+ sentIds = delivery.sentIds
19171
+ chunkCount = delivery.chunkCount
19172
+ delivered = delivery.delivered
19173
+
19174
+ // #546 dedup: record what turn-flush just sent so a late-arriving
19175
+ // reply / stream_reply with the same content gets suppressed.
19022
19176
  outboundDedup.record(
19023
19177
  backstopChatId,
19024
19178
  backstopThreadId,
@@ -19026,14 +19180,10 @@ function handleSessionEvent(ev: SessionEvent): void {
19026
19180
  Date.now(),
19027
19181
  currentTurn?.registryKey ?? null,
19028
19182
  )
19029
- // 2026-07 duplicate-reply fix — record the flushed message id(s)
19030
- // keyed on THIS turn's `turnId` nonce so a late `reply` for the same
19031
- // turn supersedes them (delete + canonical resend) instead of
19032
- // shipping a duplicate. `turn` is the ending turn atom captured at
19033
- // the top of this branch (endCurrentTurnAtomic nulled currentTurn,
19034
- // but the captured `turn` still carries the honest turnId). Covers
19035
- // BOTH flush paths — answer-ready quiescence and the turn-end
19036
- // backstop both funnel through this single IIFE.
19183
+ // #3276 guard 3 — feed the REAL fresh chat ids into the supersede
19184
+ // record so a late `reply` for the same turn corrects them in place
19185
+ // (edit / delete+resend) instead of shipping a second bubble. These
19186
+ // are genuine chat message ids now, never a card-edit id.
19037
19187
  if (sentIds.length > 0) {
19038
19188
  flushedTurnSupersede.record(
19039
19189
  backstopChatId,
@@ -19042,13 +19192,25 @@ function handleSessionEvent(ev: SessionEvent): void {
19042
19192
  Date.now(),
19043
19193
  )
19044
19194
  }
19045
- // #1713: route the backstop terminal through finalize() —
19046
- // single terminal path keeps the controller contract clean.
19047
- if (backstopCtrl) backstopCtrl.finalize('done')
19048
- // Unpin the card. completeTurn cleans up pinMgr's per-turn
19049
- // state and unpins via the API. If we didn't take over a
19050
- // turn (cardTakeover.turnKey == null), fall back to the
19051
- // legacy unpinForChat sweep.
19195
+ // #3276 guard 4 — collapse the taken-over card to a NON-answer
19196
+ // state. The answer flowed ONLY through deliverAnswer (a fresh
19197
+ // bubble); here we just unpin/complete the card so no orphaned
19198
+ // ⚙️ Working… lingers. The card carries NO answer text, so a
19199
+ // card-collapse failure and a fresh-send failure can never leave
19200
+ // BOTH an answer-card AND an answer-bubble visible.
19201
+ if (!delivered) {
19202
+ // Retries exhausted with nothing durable delivered — finalize the
19203
+ // reaction as error and reset the latch so a genuine late reply is
19204
+ // NOT suppressed.
19205
+ if (backstopCtrl) backstopCtrl.finalize('error')
19206
+ backstopDeliveryLedger.release(turn.turnId)
19207
+ turn.answerDelivered = false
19208
+ } else if (backstopCtrl) {
19209
+ backstopCtrl.finalize('done')
19210
+ }
19211
+ // Unpin the card either way (cosmetic). completeTurn cleans up
19212
+ // pinMgr's per-turn state and unpins; fall back to the legacy
19213
+ // unpinForChat sweep when we didn't take over a turn.
19052
19214
  if (backstopCardTurnKey != null) {
19053
19215
  completeProgressCardTurn?.({
19054
19216
  chatId: backstopChatId,
@@ -19059,49 +19221,50 @@ function handleSessionEvent(ev: SessionEvent): void {
19059
19221
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
19060
19222
  }
19061
19223
  } catch (err) {
19062
- sendThrew = true
19063
- process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
19064
- // 2026-07 double-reply-on-DM fix (F1) — the flush armed
19065
- // `answerDelivered` synchronously at FIRE time, but the send just
19066
- // FAILED. When nothing was delivered the supersede record was NOT
19067
- // written (gated on `sentIds.length > 0` above), so Part 1 can never
19068
- // fire for this turn — leaving the latch armed would make a genuine
19069
- // late reply suppress itself and the user would get ZERO messages
19070
- // (silent answer loss; on main that path still delivers the reply).
19071
- // Reset the latch so the late reply is NOT suppressed. On a PARTIAL
19072
- // send (sentIds > 0) the record WAS written, so Part 1's supersede
19073
- // deletes the partial message A and the reply delivers cleanly —
19074
- // resetting here is harmless in that case too.
19075
- // FOLLOW-UP (coordinator to file an issue): a reply suppressed
19076
- // synchronously DURING the in-flight send that then fails is not
19077
- // fully closable with a boolean latch — a residual micro-window the
19078
- // supersede+latch pair cannot eliminate. Not addressed in this PR.
19079
- turn.answerDelivered = false
19080
- // #1713: backstop send failed — finalize as error so the
19081
- // turn ends cleanly with 😱 rather than leaving it open.
19082
- if (backstopCtrl) backstopCtrl.finalize('error')
19224
+ // Only reachable via a throw in the post-delivery bookkeeping (the
19225
+ // delivery itself is retry-wrapped inside deliverAnswer and never
19226
+ // throws out). `delivered` already reflects the receipt-gated truth;
19227
+ // do NOT flip it here (finding 4). If nothing landed, reset the
19228
+ // latch so a genuine late reply is not suppressed.
19229
+ process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
19230
+ if (!delivered) {
19231
+ turn.answerDelivered = false
19232
+ backstopDeliveryLedger.release(turn.turnId)
19233
+ if (backstopCtrl) backstopCtrl.finalize('error')
19234
+ }
19083
19235
  } finally {
19084
- // PR B (Fix 1) — SINGLE-RECORD GUARANTEE. The send has now RESOLVED
19085
- // (or thrown); this finally runs on EVERY in-process exit of the send
19086
- // block — clean send, throw in setup/split, throw mid-send — so
19087
- // exactly one turns.jsonl row is written, with the outcome reflecting
19088
- // what actually happened. A throw or a partial multi-chunk delivery
19089
- // (sentIds < htmlChunks) or an un-run/empty split → 'failed' → status
19090
- // `send_failed`; a full delivery → 'delivered' → `complete`. This
19091
- // replaces the false `complete` the old synchronous write produced
19092
- // for a flood-dropped / errored answer the user never received, and
19093
- // restores the old code's one-row-per-turn guarantee (the pre-finally
19094
- // shape wrote ZERO rows if setup threw). The suppressed-reply branch
19095
- // above already emitted its record and `return`ed BEFORE reaching
19096
- // this try, so it can never double-write with this finally.
19236
+ // #3276 guard 7 + finding 1 — honest record AND honest recovery.
19237
+ // Status is derived from the RECEIPT gate: `complete` IFF a fresh
19238
+ // non-card id landed for every chunk; otherwise `send_failed`.
19239
+ //
19240
+ // The delivery-obligation close was DEFERRED out of
19241
+ // endCurrentTurnAtomic (deferObligationClose) so it reflects the
19242
+ // REAL send outcome here, not the speculative fire-time flag:
19243
+ // delivered → close the obligation (answered).
19244
+ // NOT deliv. → leave it OPEN + noteTurnEnded, so the ~150s
19245
+ // liveness floor re-presents the answer instead of
19246
+ // the old silent `send_failed` drop.
19097
19247
  if (backstopTurnEndedAt != null) {
19098
- finalizeBackstopSend(turn, {
19099
- threw: sendThrew,
19100
- sentCount: sentIds.length,
19101
- chunkCount: htmlChunks.length,
19248
+ finalizeBackstopSendGated(turn, {
19249
+ threw: !delivered,
19250
+ sentIds,
19251
+ chunkCount,
19252
+ cardMessageId: backstopCardMessageId,
19102
19253
  })
19254
+ if (OBLIGATION_LEDGER_ENABLED) {
19255
+ if (delivered) {
19256
+ obligationLedger.close(turn.turnId)
19257
+ } else {
19258
+ // Terminal fail — do NOT mark the obligation satisfied.
19259
+ turn.finalAnswerDelivered = false
19260
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now())
19261
+ }
19262
+ }
19103
19263
  emitTurnRecord(turn, backstopTurnEndedAt)
19104
19264
  }
19265
+ // GC the ledger only now — after success OR retry exhaustion — so a
19266
+ // resume could always read prior progress up to this point.
19267
+ backstopDeliveryLedger.clear(turn.turnId)
19105
19268
  }
19106
19269
  // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
19107
19270
  // was removed. endCurrentTurnAtomic already ran the canonical purge
@@ -21174,7 +21337,11 @@ async function handleInbound(
21174
21337
  ...(msgId != null ? { message_id: String(msgId) } : {}),
21175
21338
  user: displayUser,
21176
21339
  user_id: String(from.id),
21177
- ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
21340
+ // Model-facing `ts="…"` on the inbound <channel> tag. Rendered as the
21341
+ // agent's LOCAL am/pm wall-clock (NOT UTC ISO) so the model never reads
21342
+ // a competing UTC "now" — the numeric epoch survives on InboundMessage.ts
21343
+ // (above) and in the SQLite history for any machine consumer.
21344
+ ts: fmtLocalStamp((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone()),
21178
21345
  ...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
21179
21346
  // Component 3 — origin turn id. The model is told to pass this back
21180
21347
  // as origin_turn_id on the reply so the answer routes to the topic
@@ -23275,7 +23442,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23275
23442
  return []
23276
23443
  }
23277
23444
  },
23278
- select: (a, label) => selectModel(a, label),
23279
23445
  isBusy: () => currentTurn !== null,
23280
23446
  getAgentName: getMyAgentName,
23281
23447
  getQuotaBrief: async () => {
@@ -23294,7 +23460,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23294
23460
  } catch { /* quota is garnish — never block the menu on it */ }
23295
23461
  return null
23296
23462
  },
23297
- inject: injectSlashCommandImpl,
23298
23463
  getConfiguredModel: () => {
23299
23464
  type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
23300
23465
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
@@ -23302,7 +23467,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23302
23467
  },
23303
23468
  escapeHtml: escapeHtmlForTg,
23304
23469
  preBlock,
23305
- getActiveSessionModel: () => sessionModelSource.getOverride(),
23306
23470
  /**
23307
23471
  * Graceful restart for sr-* → Claude model switch. Same mechanism as
23308
23472
  * the /restart command: writes a restart marker (so the post-restart
@@ -23388,6 +23552,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23388
23552
  resolveMainModel(deps.getConfiguredModel() ?? undefined),
23389
23553
  )
23390
23554
  sessionModelSource.setOverride(model)
23555
+ // Diagnosability (rev 5): the applied-model is now always greppable at the
23556
+ // relaunch boundary — `grep 'gw /model relaunch scheduled'` — closing the
23557
+ // gap the debug worker flagged (the retired inject path logged nothing).
23558
+ process.stderr.write(
23559
+ `telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=${model} reason=${JSON.stringify(reason)}\n`,
23560
+ )
23561
+ // A manual /model switch clears any pending premium-recovery marker so the
23562
+ // "available again" ping can't still fire after the operator switched away
23563
+ // themselves. Rev 5: this moves here (from the deleted recordTypedModelSwitch /
23564
+ // recordModelMenuSideEffects) so it fires for EVERY switch path uniformly —
23565
+ // both R5 sites collapse to this single call.
23566
+ clearPremiumRecoveryOnManualSwitch(model)
23391
23567
  try {
23392
23568
  await deps.scheduleRestart(reason)
23393
23569
  } catch (err) {
@@ -23404,6 +23580,36 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23404
23580
  throw err
23405
23581
  }
23406
23582
  },
23583
+ /**
23584
+ * `/model default` (rev 5): CLEAR the consume-once carrier + the in-memory
23585
+ * override, then relaunch so the LIVE session reverts to the configured
23586
+ * default. Mirrors scheduleModelRelaunch's rollback discipline (G1): on a
23587
+ * `restart_in_flight` throw keep the cleared state (the in-flight boot has no
23588
+ * carrier and reverts anyway); on any other dispatch failure restore the
23589
+ * prior carrier + override so a failed default-revert doesn't strand the
23590
+ * session in a half-cleared state.
23591
+ */
23592
+ scheduleModelDefaultRelaunch: async (reason: string) => {
23593
+ const agentDir = resolveAgentDirFromEnv()
23594
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot clear session-model file')
23595
+ const prevOverride = sessionModelSource.getOverride()
23596
+ const prevFileRaw = readSessionModelFileRaw(agentDir)
23597
+ clearSessionModelFile(agentDir)
23598
+ sessionModelSource.setOverride(null)
23599
+ process.stderr.write(
23600
+ `telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=(default) reason=${JSON.stringify(reason)}\n`,
23601
+ )
23602
+ clearPremiumRecoveryOnManualSwitch(null)
23603
+ try {
23604
+ await deps.scheduleRestart(reason)
23605
+ } catch (err) {
23606
+ if ((err as { code?: string })?.code !== 'restart_in_flight') {
23607
+ restoreSessionModelFileRaw(agentDir, prevFileRaw)
23608
+ sessionModelSource.setOverride(prevOverride)
23609
+ }
23610
+ throw err
23611
+ }
23612
+ },
23407
23613
  }
23408
23614
  return deps
23409
23615
  }
@@ -23418,125 +23624,14 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
23418
23624
  return kb
23419
23625
  }
23420
23626
 
23421
- /**
23422
- * Record a POSITIVELY-CONFIRMED typed `/model` switch: set the in-memory
23423
- * override so `/status` reflects the live model. Shared by the live
23424
- * `bot.command('model')` handler and the deferred (queued mid-turn) apply so
23425
- * both record identically. Returns a warning suffix to append to the reply
23426
- * body (currently always empty — kept for a stable signature).
23427
- *
23428
- * Session-scoped (rev 4): a live Claude `/model` switch writes NO
23429
- * `.session-model` carrier — it applies in-session and the explicit
23430
- * `claude --model <configured>` flag reverts it on the next boot, so it lasts
23431
- * exactly until the next restart with no durable state. (sr-* switches never
23432
- * reach here — they go through scheduleModelRelaunch, which owns the
23433
- * consume-once carrier.) The `/status` honesty invariant lives here: only
23434
- * `reply.selectedModel` records; an unverified inject records nothing.
23435
- * `/model default` clears any in-memory override and any leftover carrier.
23436
- */
23437
- function recordTypedModelSwitch(
23438
- reply: { text: string; selectedModel?: string },
23439
- requestedModelArg: string | null,
23440
- _deps: ModelCommandDeps,
23441
- ): string {
23442
- const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
23443
- if (requested?.toLowerCase() === 'default') {
23444
- const smDir = resolveAgentDirFromEnv()
23445
- if (smDir) clearSessionModelFile(smDir)
23446
- if (reply.selectedModel) sessionModelSource.setOverride(null)
23447
- return ''
23448
- }
23449
- if (!reply.selectedModel) return ''
23450
- sessionModelSource.setOverride(reply.selectedModel)
23451
- // A manual /model apply to the dropped premium clears any pending recovery
23452
- // marker so the "available again" ping can't still fire after the user has
23453
- // already switched back themselves.
23454
- clearPremiumRecoveryOnManualSwitch(reply.selectedModel)
23455
- return ''
23456
- }
23457
-
23458
- /**
23459
- * Record a model-MENU callback outcome (set the live in-memory override, clear
23460
- * a leftover carrier on a Default tap) and drive an sr-*→Claude graceful
23461
- * restart when the tap crosses that boundary — the only menu path that writes a
23462
- * consume-once `.session-model` carrier (a live Claude tap writes none, rev 4).
23463
- * Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
23464
- * apply records + restarts identically. Does NOT edit any Telegram message —
23465
- * callers own the card edit. Returns a restart notice when a session restart
23466
- * was scheduled (the card should then drop its keyboard).
23467
- */
23468
- function recordModelMenuSideEffects(
23469
- outcome: Awaited<ReturnType<typeof handleModelMenuCallback>>,
23470
- modelDeps: ModelCommandDeps,
23471
- cbChatId: string,
23472
- cbThreadId: number | undefined,
23473
- prevSessionModel: string | null,
23474
- ): { restartNotice?: string } {
23475
- // Record a successful session switch so /status reflects what's actually
23476
- // running. Session-scoped (rev 4): a live Claude menu tap writes NO
23477
- // `.session-model` carrier — it applies in-session (native picker) and
23478
- // reverts on the next boot. Only the sr→Claude transition below (which
23479
- // relaunches) writes the consume-once carrier. A confirmed "Default
23480
- // (recommended)" selection clears any leftover carrier.
23481
- if (outcome.selectedModel) {
23482
- sessionModelSource.setOverride(outcome.selectedModel)
23483
- // Clear a pending premium-recovery marker when the tap re-selects the
23484
- // dropped premium (menu OR the recovery ping's own switch-back button):
23485
- // no stale "available again" ping once we're back on it. Idempotent — the
23486
- // ping-send path already consumed the marker, so this is a no-op there.
23487
- clearPremiumRecoveryOnManualSwitch(outcome.selectedModel)
23488
- }
23489
- if (outcome.clearedDefault) {
23490
- const smDir = resolveAgentDirFromEnv()
23491
- if (smDir) clearSessionModelFile(smDir)
23492
- }
23493
-
23494
- // sr-* → Claude transition: the picker-select only changes the session model
23495
- // label, but the sr-* LiteLLM routing context persists until the session is
23496
- // torn down — a graceful restart (same mechanism as /restart) is required.
23497
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
23498
- const agentName = getMyAgentName()
23499
- // Carry the requested Claude model across the restart via the SAME
23500
- // consume-once `.session-model` carrier a Claude → sr-* switch uses —
23501
- // otherwise this transition's apply-relaunch boots the CONFIGURED default
23502
- // and the tapped model is silently dropped. Applied on that one boot,
23503
- // then reverts on the next restart (rev 4).
23504
- const agentDir = resolveAgentDirFromEnv()
23505
- const token = outcome.selectedModelToken
23506
- if (agentDir && token) {
23507
- try {
23508
- writeSessionModelFile(
23509
- agentDir,
23510
- token,
23511
- readConfiguredDefaultModel(agentDir) ??
23512
- resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
23513
- )
23514
- sessionModelSource.setOverride(token)
23515
- } catch (e) {
23516
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
23517
- }
23518
- } else if (agentDir) {
23519
- // Default-row tap while on sr-*: the restart must land on the configured
23520
- // default — a stale sticky override would resurrect the old model.
23521
- clearSessionModelFile(agentDir)
23522
- }
23523
- // Write the restart marker so the post-restart boot card edits into this chat.
23524
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
23525
- stampUserRestartReason('user: sr-to-claude model switch (menu)')
23526
- if (turnInFlightForGate()) {
23527
- // Defer restart until the in-flight turn completes (same gate as /restart).
23528
- pendingRestarts.set(agentName, Date.now())
23529
- } else {
23530
- void sweepBeforeSelfRestart().finally(() =>
23531
- triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
23532
- )
23533
- }
23534
- return {
23535
- restartNotice: `🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
23536
- }
23537
- }
23538
- return {}
23539
- }
23627
+ // Rev 5 (deterministic switch): `recordTypedModelSwitch` and
23628
+ // `recordModelMenuSideEffects` are RETIRED. Every switch now routes through
23629
+ // `scheduleModelRelaunch` / `scheduleModelDefaultRelaunch` inside the handlers,
23630
+ // which own the carrier + in-memory override writes and the premium-recovery
23631
+ // clear. The sr-*→Claude special case is gone: because EVERY switch relaunches,
23632
+ // the sr-* LiteLLM routing is always torn down cleanly by the boot, so there is
23633
+ // no distinct transition to detect. The ACTUAL running model is reconciled at
23634
+ // boot from `.active-session-model` (never from a scraped `selectedModel`).
23540
23635
 
23541
23636
  // ─── Mid-turn ack-queue-apply-confirm for /model + /effort (#3017) ──────────
23542
23637
  //
@@ -23595,24 +23690,17 @@ function enqueueSessionCommand(cmd: PendingSessionCommand): void {
23595
23690
  async function applyQueuedModelCommand(cmd: PendingSessionCommand): Promise<string> {
23596
23691
  const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId })
23597
23692
  if (cmd.origin === 'menu') {
23598
- // Menu SELECT (mdl:s:<tag>) — replay the callback handler (discovery is
23599
- // idle-safe now) and record via the shared side-effects helper.
23600
- const prevSessionModel = sessionModelSource.getOverride()
23693
+ // Menu SELECT (mdl:s:<token>) — replay the callback handler, which now
23694
+ // relaunches through the carrier itself (rev 5: no side-effects helper, no
23695
+ // scrape). The relaunch writes its own restart marker so the post-boot card
23696
+ // lands in this chat.
23601
23697
  const outcome = await handleModelMenuCallback(cmd.arg, deps)
23602
- const { restartNotice } = recordModelMenuSideEffects(
23603
- outcome,
23604
- deps,
23605
- cmd.chatId,
23606
- cmd.threadId,
23607
- prevSessionModel,
23608
- )
23609
- return restartNotice ?? outcome.reply.text
23698
+ return outcome.reply.text
23610
23699
  }
23611
23700
  // Typed (and alias/sr menu taps converted to typed at enqueue): run the real
23612
- // handler + shared recording.
23701
+ // handler, which relaunches through the carrier and owns all side effects.
23613
23702
  const reply = await handleModelCommand({ kind: 'set', model: cmd.arg }, deps)
23614
- const warning = recordTypedModelSwitch(reply, cmd.arg, deps)
23615
- return reply.text + warning
23703
+ return reply.text
23616
23704
  }
23617
23705
 
23618
23706
  /** Apply a queued typed/menu effort command at idle; return the reply body. */
@@ -23831,17 +23919,12 @@ bot.command('model', async ctx => {
23831
23919
  })
23832
23920
  return
23833
23921
  }
23922
+ // Rev 5: the handler relaunches through the carrier and owns every side effect
23923
+ // (override write, carrier, premium-recovery clear). There is no post-hoc
23924
+ // recording — /status is reconciled at boot from `.active-session-model`, so
23925
+ // an unapplied switch can never be optimistically recorded here.
23834
23926
  const reply = await handleModelCommand(parsed, deps)
23835
- // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
23836
- // actually running (shared with the deferred/menu paths). The sr-*/relaunch
23837
- // paths already set the override inside scheduleModelRelaunch; an unverified
23838
- // switch carries no selectedModel so /status is never lied to.
23839
- const persistWarning = recordTypedModelSwitch(
23840
- reply,
23841
- parsed.kind === 'set' ? parsed.model : null,
23842
- deps,
23843
- )
23844
- await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
23927
+ await switchroomReply(ctx, reply.text, { html: reply.html })
23845
23928
  })
23846
23929
 
23847
23930
  // `/effort` — show or switch the reasoning effort for the live session.
@@ -27333,51 +27416,15 @@ bot.on('callback_query:data', async ctx => {
27333
27416
  // rather than the switch-oriented "Switching…".
27334
27417
  const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
27335
27418
  await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
27336
- // sr-* inject waits for claude to respond (can take 10-30s). Edit the
27337
- // menu immediately to show a "working on it" state so the operator isn't
27338
- // left looking at a stale menu with no feedback. The final edit (✅/❌)
27339
- // replaces this once the inject returns.
27340
- // NOTE: this await yields the event loop, so a new inbound turn could
27341
- // start between here and handleModelMenuCallback's inner isBusy() check.
27342
- // We track whether we applied the interim edit so we can skip the
27343
- // toastOnly short-circuit if we did — a toastOnly return after the interim
27344
- // edit would leave the menu stuck button-less.
27345
- // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
27346
- // Parity with the text `/model sr-*` path — claude's native picker rejects
27347
- // unknown sr-* ids, so an in-place inject can't set them. Carry the token
27348
- // across a graceful restart (the consume-once `.session-model` carrier) and
27349
- // relaunch `claude --model sr-*`. Session-only; reverts to the configured
27350
- // default on the next restart. The sr-* → Claude direction is handled below
27351
- // via the SELECT/alias outcome + isSrToClaudeTransition.
27352
- if (data.startsWith(MODEL_CALLBACK_SR)) {
27353
- const srName = data.slice(MODEL_CALLBACK_SR.length)
27354
- const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
27355
- if (!isValidModelArg(srName)) {
27356
- await ctx
27357
- .editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
27358
- .catch(() => {})
27359
- return
27360
- }
27361
- await ctx
27362
- .editMessageText(
27363
- richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
27364
- { reply_markup: { inline_keyboard: [] } },
27365
- )
27366
- .catch(() => {})
27367
- try {
27368
- await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
27369
- } catch (err) {
27370
- await ctx
27371
- .editMessageText(
27372
- richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
27373
- { reply_markup: { inline_keyboard: [] } },
27374
- )
27375
- .catch(() => {})
27376
- }
27377
- return
27378
- }
27419
+ // Rev 5 (deterministic switch): EVERY switch tap — Fable/alias (`mdl:alias:`),
27420
+ // sr-* target (`mdl:sr:`), and picker SELECT (`mdl:s:<token>`) — goes through
27421
+ // the single handler, which relaunches through the consume-once carrier
27422
+ // (`scheduleModelRelaunch` / `scheduleModelDefaultRelaunch`). No inject, no
27423
+ // cursor-nav, no scrape, and no post-hoc `recordModelMenuSideEffects`: the
27424
+ // relaunch owns the override + carrier writes, and `.active-session-model`
27425
+ // reconciles /status at boot. The handler writes its own restart marker so
27426
+ // the post-boot card lands in this chat.
27379
27427
  try {
27380
- const prevSessionModel = sessionModelSource.getOverride()
27381
27428
  const outcome = await handleModelMenuCallback(data, modelDeps)
27382
27429
  // toastOnly: leave the menu untouched — a mid-turn refusal keeps its
27383
27430
  // buttons so the operator can tap again. (In the enqueue world this
@@ -27385,23 +27432,6 @@ bot.on('callback_query:data', async ctx => {
27385
27432
  // before ever calling the handler — but retained for callers that skip
27386
27433
  // the dispatcher gate.)
27387
27434
  if (outcome.toastOnly) return
27388
- // Record the switch (persist/clear sticky override) + drive an sr-*→Claude
27389
- // graceful restart when needed. Shared with the deferred (queued) apply so
27390
- // both surfaces record identically. Returns a restart notice when a
27391
- // session restart was scheduled.
27392
- const { restartNotice } = recordModelMenuSideEffects(
27393
- outcome,
27394
- modelDeps,
27395
- cbChatId,
27396
- cbThreadId,
27397
- prevSessionModel,
27398
- )
27399
- if (restartNotice) {
27400
- await ctx
27401
- .editMessageText(richMessage(restartNotice), { reply_markup: { inline_keyboard: [] } })
27402
- .catch(() => {})
27403
- return
27404
- }
27405
27435
  await ctx
27406
27436
  .editMessageText(richMessage(outcome.reply.text), {
27407
27437
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
@@ -30432,6 +30462,18 @@ void (async () => {
30432
30462
  void sweepableIds
30433
30463
  } catch {}
30434
30464
 
30465
+ // Rev 5: capture the restart-marker chat BEFORE the boot-card block
30466
+ // clears it, so the session-model re-hydration block below can send the
30467
+ // switch-confirmation ("✅ Now running X") to the chat that initiated the
30468
+ // switch. A non-`/model` restart leaves these unused.
30469
+ let modelSwitchMarkerChat: { chatId: string; threadId: number | null } | null = null
30470
+ // The DETERMINISTIC "this boot was a /model switch" signal: the reason
30471
+ // stampUserRestartReason() wrote to the clean-shutdown marker. Precise
30472
+ // (distinguishes a model-switch relaunch from any other restart) and works
30473
+ // even when launched === configured (a `/model default` / switch-to-default
30474
+ // apply-boot) — that is how N4 (confirm the default case too) is closed.
30475
+ let modelSwitchReason: string | null = null
30476
+
30435
30477
  // Boot card — always post on every gateway start with the restart reason.
30436
30478
  // Gated on session marker so a grammY poll-restart (same process, no
30437
30479
  // actual restart) does NOT re-post. See session-marker.ts for the
@@ -30493,6 +30535,18 @@ void (async () => {
30493
30535
  const ageMs = nowMs - marker.ts
30494
30536
  const ageSec = Math.max(1, Math.round(ageMs / 1000))
30495
30537
  process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 5 * 60_000}\n`)
30538
+ // Stash the chat for the model-switch confirmation (rev 5) before the
30539
+ // marker is cleared. Bounded to a recent marker (<5min) so a stale
30540
+ // marker can't misdirect a confirmation. Pair it with the /model
30541
+ // switch reason (from the clean-shutdown marker) so the confirmation
30542
+ // fires ONLY on a genuine /model relaunch (not a plain /restart that
30543
+ // also wrote a marker chat).
30544
+ if (ageMs < 5 * 60_000) {
30545
+ modelSwitchMarkerChat = { chatId: marker.chat_id, threadId: marker.thread_id }
30546
+ if (typeof cleanMarker?.reason === 'string' && cleanMarker.reason.startsWith('user: /model')) {
30547
+ modelSwitchReason = cleanMarker.reason
30548
+ }
30549
+ }
30496
30550
  clearRestartMarker()
30497
30551
  }
30498
30552
 
@@ -30529,7 +30583,21 @@ void (async () => {
30529
30583
  })
30530
30584
  }
30531
30585
 
30532
- if (target) {
30586
+ // N3 (dedup to ONE card per switch): a `/model` apply-boot already
30587
+ // sends the `✅ Now running X` confirmation from the re-hydration block
30588
+ // below (into the same chat). Suppress the generic restart boot card
30589
+ // here so a deliberate switch yields exactly one card — the
30590
+ // confirmation, which carries the operative "here's your model" info.
30591
+ // Only when we KNOW it was a /model switch (reason) AND we will send the
30592
+ // confirmation (marker chat captured); otherwise the boot card fires
30593
+ // normally. Version/quota remain available via /status.
30594
+ const suppressBootCardForModelSwitch =
30595
+ modelSwitchReason != null && modelSwitchMarkerChat != null
30596
+ if (target && suppressBootCardForModelSwitch) {
30597
+ process.stderr.write(
30598
+ `telegram gateway: boot: suppressing generic boot card — /model switch apply-boot (reason=${JSON.stringify(modelSwitchReason)}); the confirmation card replaces it\n`,
30599
+ )
30600
+ } else if (target) {
30533
30601
  const { chatId, threadId, ackMsgId } = target
30534
30602
  // First-write-wins dedupe: if bridge-reconnect already
30535
30603
  // claimed (its IPC client connected before this IIFE
@@ -30635,9 +30703,55 @@ void (async () => {
30635
30703
  // a phantom session override.
30636
30704
  return resolveMainModel(raw ?? undefined)
30637
30705
  })()
30638
- sessionModelSource.setOverride(
30639
- launched.length > 0 && launched !== configured ? launched : null,
30706
+ // `launched !== configured` is the DETERMINISTIC "a model switch
30707
+ // landed" signal (F1): the carrier is consume-once, so a launched
30708
+ // model that differs from the configured default only ever
30709
+ // happens on a genuine apply-boot. Seed the in-memory override
30710
+ // from it — this is the ONLY success reporting, sourced from the
30711
+ // real post-boot signal (`.active-session-model`), never from a
30712
+ // scraped pane or an optimistic record.
30713
+ const isApplyBoot = launched.length > 0 && launched !== configured
30714
+ sessionModelSource.setOverride(isApplyBoot ? launched : null)
30715
+ // Diagnosability (rev 5): the applied model is now always
30716
+ // greppable — `grep 'gw /model relaunch applied'`. F4 note:
30717
+ // `launched` is the REQUESTED token start.sh wrote before `exec
30718
+ // claude` (it is NOT a post-launch confirmation). If a shape-valid
30719
+ // but unknown Claude id was requested, `--fallback-model` may mask
30720
+ // it: claude serves a fallback while this records the requested
30721
+ // token. That divergence is NOT a persistent lie — the transcript's
30722
+ // `message.model` (noteTranscriptModel) reclaims the source from
30723
+ // this override on the first assistant line, correcting /status to
30724
+ // the model actually serving calls. The pre-first-assistant window
30725
+ // is the only optimistic window (G2), and it is bounded and
30726
+ // self-healing; it is documented, not silently asserted as success.
30727
+ process.stderr.write(
30728
+ `telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || '(none)'} configured=${configured} override=${isApplyBoot ? 'set' : 'cleared'}\n`,
30640
30729
  )
30730
+ // Switch-confirmation (F1 / PLAN §4 step 2): on a /model apply-boot
30731
+ // with a known initiating chat, send ONE confirmation built from the
30732
+ // ACTUAL launched model — never optimistic. Keyed on the DETERMINISTIC
30733
+ // /model switch reason (from the clean-shutdown marker), not on
30734
+ // `launched !== configured`, so it ALSO fires when a switch landed on
30735
+ // the configured default (`/model default`, or `/model <configured>`)
30736
+ // — N4. The generic boot card is suppressed for this boot (N3), so
30737
+ // this is the single card the operator sees for the switch.
30738
+ if (modelSwitchReason != null && modelSwitchMarkerChat) {
30739
+ const chat = modelSwitchMarkerChat
30740
+ const body = isApplyBoot
30741
+ ? `✅ Now running \`${launched}\` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
30742
+ : `✅ Now running \`${launched || configured}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
30743
+ // allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
30744
+ void lockedBot.api
30745
+ .sendMessage(chat.chatId, body, {
30746
+ parse_mode: 'Markdown',
30747
+ ...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
30748
+ })
30749
+ .catch((err: unknown) =>
30750
+ process.stderr.write(
30751
+ `telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
30752
+ ),
30753
+ )
30754
+ }
30641
30755
  } catch { /* leave override as-is on a bad read */ }
30642
30756
  }
30643
30757