switchroom 0.18.28 → 0.18.30

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 (48) hide show
  1. package/bin/handoff-briefing.sh +15 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -73
  4. package/dist/cli/autoaccept-poll.js +8 -3
  5. package/dist/cli/drive-write-pretool.mjs +8 -3
  6. package/dist/cli/ms-365-write-pretool.mjs +158 -11
  7. package/dist/cli/notion-write-pretool.mjs +103 -4
  8. package/dist/cli/switchroom.js +2712 -2219
  9. package/dist/host-control/main.js +110 -70
  10. package/dist/vault/approvals/kernel-server.js +116 -70
  11. package/dist/vault/broker/server.js +314 -202
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +105 -34
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +1128 -666
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  18. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  19. package/telegram-plugin/gateway/gateway.ts +656 -388
  20. package/telegram-plugin/gateway/model-command.ts +331 -602
  21. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  22. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  23. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  24. package/telegram-plugin/history.ts +153 -23
  25. package/telegram-plugin/llm-error-present.ts +24 -0
  26. package/telegram-plugin/model-unavailable.ts +55 -0
  27. package/telegram-plugin/operator-events.ts +113 -0
  28. package/telegram-plugin/pending-user-notice.ts +88 -0
  29. package/telegram-plugin/shared/local-time.ts +99 -0
  30. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  31. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  32. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  33. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  34. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +111 -60
  35. package/telegram-plugin/tests/history.test.ts +88 -0
  36. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  37. package/telegram-plugin/tests/local-time.test.ts +135 -0
  38. package/telegram-plugin/tests/model-command.test.ts +427 -1512
  39. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  40. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  41. package/telegram-plugin/tier-downgrade.ts +4 -3
  42. package/telegram-plugin/turn-flush-safety.ts +25 -1
  43. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  44. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  45. package/vendor/hindsight-memory/scripts/lib/content.py +93 -7
  46. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  47. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  48. package/vendor/hindsight-memory/tests/test_content.py +63 -7
@@ -102,6 +102,11 @@ import {
102
102
  forwardOriginDateIso,
103
103
  type ForwardOriginInfo,
104
104
  } from './forward-origin.js'
105
+ import {
106
+ installUpdateTap,
107
+ installUnhandledMessageCatchAll,
108
+ } from './unhandled-message.js'
109
+ import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
105
110
  import { StatusReactionController } from '../status-reactions.js'
106
111
  import { DeferredDoneReactions } from '../reaction-defer.js'
107
112
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
@@ -323,9 +328,12 @@ import { autoClassifyMidTurnInbound } from './auto-classify-mid-turn.js'
323
328
  import {
324
329
  renderOperatorEvent,
325
330
  shouldEmitOperatorEvent,
331
+ decideOperatorEventAudience,
332
+ renderUserFacingFailureNotice,
326
333
  type OperatorEvent,
327
334
  type OperatorEventKind,
328
335
  } from '../operator-events.js'
336
+ import { pendingUserNoticeGate } from '../pending-user-notice.js'
329
337
  import { recordOperatorEvent } from '../operator-events-history.js'
330
338
  import {
331
339
  parseLlmError,
@@ -471,9 +479,9 @@ import {
471
479
  resolveStaleAwareBusy,
472
480
  modelCommandReceiptLine,
473
481
  handleModelCommand,
482
+ classifyModelSwitchConfirmation,
474
483
  buildModelMenu,
475
484
  handleModelMenuCallback,
476
- isSrToClaudeTransition,
477
485
  isValidModelArg,
478
486
  MODEL_CALLBACK_PREFIX,
479
487
  MODEL_CALLBACK_HEADER,
@@ -495,6 +503,7 @@ import {
495
503
  readSessionModelFileRaw,
496
504
  restoreSessionModelFileRaw,
497
505
  clearSessionModelFile,
506
+ consumeSessionModelCarrierOnHealthyBoot,
498
507
  readConfiguredDefaultModel,
499
508
  writeSessionEffortFile,
500
509
  clearSessionEffortFile,
@@ -505,7 +514,7 @@ import {
505
514
  import { runTierDowngrade } from './tier-downgrade-wiring.js'
506
515
  import { runPremiumRecoveryPing } from './premium-recovery-wiring.js'
507
516
  import { decidePremiumRecovery } from '../premium-recovery.js'
508
- import { discoverModels, selectModel } from '../../src/agents/model-picker.js'
517
+ import { discoverModels } from '../../src/agents/model-picker.js'
509
518
  import { resolveMainModel, SWITCHROOM_DEFAULT_THINKING_EFFORT } from '../../src/agents/scaffold.js'
510
519
  import {
511
520
  parseEffortCommand,
@@ -635,9 +644,13 @@ import { decideObligationTurnEnd } from './obligation-turn-end.js'
635
644
  import { maybeRotate } from './turns-jsonl-rotate.js'
636
645
  import {
637
646
  buildTurnRecord,
638
- finalizeBackstopSend,
647
+ finalizeBackstopSendGated,
639
648
  type DeliveryOutcome,
640
649
  } from './turn-record-status.js'
650
+ import {
651
+ BackstopDeliveryLedger,
652
+ runBackstopDelivery,
653
+ } from './backstop-delivery.js'
641
654
  import {
642
655
  createDeliveryQueue,
643
656
  trackDelivery,
@@ -1286,6 +1299,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
1286
1299
  const bot = new Bot(TOKEN)
1287
1300
  installTgPostLogger(bot)
1288
1301
 
1302
+ // ─── Diagnostic update tap (#3300) ────────────────────────────────────────
1303
+ // One compact line per received update, logged BEFORE any specific handler
1304
+ // runs, so a drop at the grammy-routing layer is always diagnosable from the
1305
+ // logs. Pass-through middleware — never consumes an update or alters routing.
1306
+ // Rate-limited inside installUpdateTap (per-minute cap + suppression summary
1307
+ // line so even a flood is never invisible).
1308
+ installUpdateTap(bot, line => process.stderr.write(line))
1309
+
1289
1310
  // ─── getUpdates heartbeat ─────────────────────────────────────────────────
1290
1311
  // Tracks the last time getUpdates completed (success OR error). Used by
1291
1312
  // the poll health check as a secondary stall signal: if getMe succeeds but
@@ -2338,6 +2359,150 @@ const outboundDedup = new OutboundDedupCache()
2338
2359
  // catches the containment case the exact-text `outboundDedup` misses (a
2339
2360
  // `narration\n\nanswer` flush never equals the clean `answer`-only reply).
2340
2361
  const flushedTurnSupersede = new FlushedTurnSupersedeRegistry()
2362
+ // #3276 — the turn-flush backstop's per-turn delivery latch + per-chunk
2363
+ // idempotency ledger. The latch (keyed on `turnId`) is the deterministic
2364
+ // arbiter of backstop-vs-reply; the chunk ledger lets a retry after a partial
2365
+ // send resume at the first unsent chunk instead of re-sending chunk 0.
2366
+ const backstopDeliveryLedger = new BackstopDeliveryLedger()
2367
+ // #3276 finding-1 — bounded in-turn retries for the backstop send before it
2368
+ // gives up and records `send_failed` (leaving the obligation open for the
2369
+ // liveness floor). Resumes mid-chunk each attempt, so chunk 0 is never
2370
+ // re-sent. Env-tunable for ops; default 3.
2371
+ const BACKSTOP_DELIVERY_MAX_ATTEMPTS = (() => {
2372
+ const raw = Number(process.env.SWITCHROOM_BACKSTOP_DELIVERY_MAX_ATTEMPTS)
2373
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3
2374
+ })()
2375
+
2376
+ /**
2377
+ * #3276 — the ONE delivery primitive the turn-flush backstop uses to put a
2378
+ * flushed answer into the chat. It routes through `sendReplyChunks` — the SAME
2379
+ * battle-tested send core `executeReply` uses (THREAD_NOT_FOUND fallback,
2380
+ * length re-split, parse-reject plaintext fallback) — and returns the REAL
2381
+ * fresh chat message ids.
2382
+ *
2383
+ * Deliberately NOT card-coupled: it never edits the progress card, so a
2384
+ * "delivery" can never be a card mutation that the marker-sweep GC's ~60-90s
2385
+ * later. The card is unpinned/collapsed by the caller as a purely cosmetic
2386
+ * follow-up (guard 4). `previewMessageId` is always null here.
2387
+ *
2388
+ * Idempotent (guard 6): each chunk is sent under `backstopDeliveryLedger`. A
2389
+ * chunk that already landed for this `turnId` is skipped, and a pending marker
2390
+ * is written before every wire call, so a retry after a partial send or a lost
2391
+ * ack resumes at the first unsent chunk and never re-sends chunk 0.
2392
+ *
2393
+ * `text` must already be fully normalized/redacted/scrubbed by the caller (the
2394
+ * turn-flush branch runs the exact reply-parity pipeline before calling this).
2395
+ */
2396
+ async function deliverAnswer(args: {
2397
+ chatId: string
2398
+ threadId: number | undefined
2399
+ text: string
2400
+ turnId: string
2401
+ cardMessageId: number | null
2402
+ }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
2403
+ const { chatId, turnId } = args
2404
+ // Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
2405
+ // executeReply does on the non-literal path (idempotent, one U+00A0 per gap).
2406
+ const rendered = addParagraphSpacers(args.text)
2407
+ const chunks = splitMarkdownChunks(rendered, RICH_MESSAGE_MAX_CHARS)
2408
+
2409
+ const deps: ReplyChunkSendDeps = {
2410
+ sendRich: (opts, body, tid) =>
2411
+ robustApiCall(
2412
+ // allow-raw-bot-api: deliverAnswer chunk-loop adapter — sendRichMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks' fallback ladder
2413
+ () => bot.api.sendRichMessage(chatId, body as never, opts as never),
2414
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical' },
2415
+ ),
2416
+ sendLiteral: (opts, txt, tid) =>
2417
+ robustApiCall(
2418
+ // allow-raw-bot-api: deliverAnswer chunk-loop adapter — literal sendMessage routed through robustApiCall; THREAD_NOT_FOUND handled by sendReplyChunks
2419
+ () => bot.api.sendMessage(chatId, txt, opts as never),
2420
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical' },
2421
+ ),
2422
+ // allow-raw-bot-api: literal last-resort fallback (parse-reject / length re-split); wrapping would re-enter the policy that just rejected the payload
2423
+ sendLiteralRaw: (opts, txt) => bot.api.sendMessage(chatId, txt, opts as never),
2424
+ // 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
2425
+ sendRichRaw: (opts, body) => bot.api.sendRichMessage(chatId, body as never, opts as never),
2426
+ editPreview: (mid, body, opts, tid) =>
2427
+ robustApiCall(
2428
+ // allow-raw-bot-api: preview edit-in-place routed through robustApiCall; thread fallback handled by sendReplyChunks
2429
+ () => bot.api.editMessageText(chatId, mid, body as never, opts as never),
2430
+ { threadId: tid, chat_id: chatId, priorityClass: 'critical', messageId: mid, editPayload: body },
2431
+ ),
2432
+ richMessage,
2433
+ logOutbound,
2434
+ // deliverAnswer never sets a previewMessageId, so this is never invoked;
2435
+ // provide a best-effort delete for interface completeness.
2436
+ deleteStalePreview: async (id: number): Promise<void> => {
2437
+ await swallowingApiCall(
2438
+ () => bot.api.deleteMessage(chatId, id),
2439
+ { chat_id: chatId, verb: 'deliverAnswer.deleteStalePreview' },
2440
+ )
2441
+ },
2442
+ stderr: (s: string) => { process.stderr.write(s) },
2443
+ }
2444
+
2445
+ // Send ONE chunk via the shared `sendReplyChunks` core. `liveThreadId`
2446
+ // threads the THREAD_NOT_FOUND fallback decision across chunks. Returns the
2447
+ // landed message id(s) (a length-resplit chunk may land >1); throws on an
2448
+ // unrecoverable send failure so the retry orchestrator can resume.
2449
+ let liveThreadId = args.threadId
2450
+ const sendChunk = async (_chunkIndex: number, text: string): Promise<number[]> => {
2451
+ const chunkIds: number[] = []
2452
+ const res = await sendReplyChunks(deps, {
2453
+ chatId,
2454
+ chunks: [text],
2455
+ literalText: false,
2456
+ suppressText: false,
2457
+ threadId: liveThreadId,
2458
+ previewMessageId: null,
2459
+ sentIds: chunkIds,
2460
+ buildSendOpts: (_i, _isLast, tid) => ({
2461
+ ...(tid != null ? { message_thread_id: tid } : {}),
2462
+ link_preview_options: { is_disabled: true },
2463
+ }),
2464
+ buildPreviewEditOpts: () => ({}),
2465
+ })
2466
+ liveThreadId = res.threadId
2467
+ return chunkIds
2468
+ }
2469
+
2470
+ // Bounded in-turn retry (finding-1 fix): resumes at the first unsent chunk on
2471
+ // each attempt via the ledger, so chunk 0 is delivered exactly once even
2472
+ // across retries. `delivered`/`exhausted` tell the caller whether the answer
2473
+ // actually reached the chat — the caller leaves the delivery obligation OPEN
2474
+ // on terminal failure so the liveness floor re-presents it.
2475
+ const result = await runBackstopDelivery(
2476
+ backstopDeliveryLedger,
2477
+ turnId,
2478
+ chunks,
2479
+ args.cardMessageId,
2480
+ {
2481
+ sendChunk,
2482
+ recordOutbound: HISTORY_ENABLED
2483
+ ? (messageIds, texts) => {
2484
+ try {
2485
+ recordOutbound({
2486
+ chat_id: chatId,
2487
+ thread_id: args.threadId ?? null,
2488
+ message_ids: messageIds,
2489
+ texts,
2490
+ })
2491
+ } catch { /* best-effort */ }
2492
+ }
2493
+ : undefined,
2494
+ stderr: (s: string) => { process.stderr.write(s) },
2495
+ },
2496
+ BACKSTOP_DELIVERY_MAX_ATTEMPTS,
2497
+ )
2498
+ return {
2499
+ sentIds: result.sentIds,
2500
+ chunkCount: result.chunkCount,
2501
+ delivered: result.delivered,
2502
+ exhausted: result.exhausted,
2503
+ }
2504
+ }
2505
+
2341
2506
  /**
2342
2507
  * Per-chat cache of `available_reactions` from `getChat`. Populated lazily —
2343
2508
  * the FIRST message in a chat creates a controller without the filter (null
@@ -5081,7 +5246,7 @@ function emitTurnRecord(turn: CurrentTurn, endedAt: number): void {
5081
5246
 
5082
5247
  function endCurrentTurnAtomic(
5083
5248
  turn: CurrentTurn,
5084
- opts?: { deferRecord?: boolean },
5249
+ opts?: { deferRecord?: boolean; deferObligationClose?: boolean },
5085
5250
  ): number | null {
5086
5251
  // PR-4e — keyed liveness + keyed clear (leak-close-at-origin). Flag-OFF: the
5087
5252
  // guard is `currentTurn === turn` and the clear nulls the singleton, verbatim.
@@ -5150,7 +5315,13 @@ function endCurrentTurnAtomic(
5150
5315
  // obligations are designed to catch ends via silence_fallback, NOT turn_end.
5151
5316
  // At turn_end with replyCalled=true the model explicitly signalled completion
5152
5317
  // AND replied, so the obligation is satisfied regardless of finalAnswerDelivered.
5153
- if (OBLIGATION_LEDGER_ENABLED) {
5318
+ // #3276 finding 1 — the turn-flush backstop passes `deferObligationClose` so
5319
+ // the obligation disposition reflects the REAL send outcome (resolved in its
5320
+ // async finally after the bounded retry), NOT the speculative fire-time
5321
+ // `finalAnswerDelivered=true`. Closing here would satisfy the obligation
5322
+ // before the send is known to have landed, re-introducing the silent-drop on
5323
+ // terminal failure. Every synchronous turn-end path is unchanged.
5324
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
5154
5325
  if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === 'close') {
5155
5326
  obligationLedger.close(turn.turnId)
5156
5327
  } else {
@@ -5187,6 +5358,13 @@ function endCurrentTurnAtomic(
5187
5358
  // wedging forever. No-op when this turn delivered, when nothing is
5188
5359
  // buffered, or when the serialize feature is off.
5189
5360
  armNoReplyDrainTimer(turn)
5361
+ // #3293 finding 1 — resolve any deferred non-operator failure notice against
5362
+ // this turn's outcome: replied → the turn recovered from the error line, the
5363
+ // gate drops the notice; reply-less → the turn genuinely died, the notice is
5364
+ // sent now. replyCalled covers the short-answer/#2624 shape where
5365
+ // finalAnswerDelivered stays false despite an explicit reply. No-op when
5366
+ // nothing is pending (the overwhelmingly common path).
5367
+ flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled)
5190
5368
  return turnEndedAt
5191
5369
  }
5192
5370
 
@@ -8390,7 +8568,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8390
8568
  `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
8391
8569
  (opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
8392
8570
  )
8393
- for (const chat_id of access.allowFrom) {
8571
+ // Ken's deterministic error-surfacing policy: an OPERATOR-ACTIONABLE fault
8572
+ // (credentials / credit / proxy-misconfig) must not reach non-operator users
8573
+ // as a raw or misleading card they can't act on. Split the audience — the
8574
+ // operator (allowlist head) gets the full card; every other allowlist chat
8575
+ // gets, at most, a brief plain-language "it's on our side" notice. Non-
8576
+ // actionable kinds keep their existing broadcast (all chats are operatorChats).
8577
+ const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
8578
+ kind,
8579
+ access.allowFrom,
8580
+ access.allowFrom[0],
8581
+ )
8582
+ if (userNoticeChats.length > 0) {
8583
+ process.stderr.write(
8584
+ `telegram gateway: operator-event operator-only routing agent=${agent} kind=${kind} operatorChats=${operatorChats.length} userNoticeChats=${userNoticeChats.length}\n`,
8585
+ )
8586
+ }
8587
+
8588
+ for (const chat_id of operatorChats) {
8394
8589
  // The resolved topic is valid ONLY in the agent's supergroup — attaching
8395
8590
  // it to an operator DM recipient yields 400 "message thread not found" and
8396
8591
  // the event silently fails to deliver (the marko #2096 class). Guard it:
@@ -8427,6 +8622,63 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8427
8622
  )
8428
8623
  })
8429
8624
  }
8625
+
8626
+ // Non-operator users: only the plain-language failure notice, and ONLY when
8627
+ // the turn genuinely dies (userNoticeChats is empty for every non-actionable
8628
+ // kind — see decideOperatorEventAudience). #3293 review finding 1: an error
8629
+ // line is NOT proof of turn failure — the LiteLLM fallback can 401 while a
8630
+ // retry / another deployment still serves the turn, and sending "couldn't
8631
+ // complete that" for a turn that completed is a false failure report. So the
8632
+ // notice is never sent here: it is SCHEDULED on the pendingUserNoticeGate and
8633
+ // resolved at the turn-end funnel (endCurrentTurnAtomic) — dropped when the
8634
+ // turn delivered a reply (recovered), sent when it ended reply-less (died).
8635
+ // Un-resolved notices expire after PENDING_USER_NOTICE_TTL_MS (bias to
8636
+ // silence over a false failure claim). Operator cards above stay immediate.
8637
+ if (userNoticeChats.length > 0) {
8638
+ pendingUserNoticeGate.schedule({
8639
+ chatIds: userNoticeChats,
8640
+ text: renderUserFacingFailureNotice(),
8641
+ agent,
8642
+ kind,
8643
+ atMs: Date.now(),
8644
+ })
8645
+ process.stderr.write(
8646
+ `telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length}\n`,
8647
+ )
8648
+ }
8649
+ }
8650
+
8651
+ /**
8652
+ * Turn-end resolution of deferred user failure notices (#3293 finding 1).
8653
+ * Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
8654
+ * passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
8655
+ * (the model explicitly replied → the turn recovered → notices are dropped by
8656
+ * the gate). Only a reply-less turn end flushes the pending notices to the
8657
+ * non-operator chats, so the user notice fires IFF the turn genuinely died.
8658
+ */
8659
+ function flushPendingUserFailureNotices(turnDeliveredReply: boolean): void {
8660
+ const notices = pendingUserNoticeGate.resolveTurnEnd(turnDeliveredReply)
8661
+ if (notices.length === 0) return
8662
+ const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
8663
+ const noticeSupergroup = resolveAgentSupergroupChatId()
8664
+ for (const notice of notices) {
8665
+ process.stderr.write(
8666
+ `telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
8667
+ )
8668
+ for (const chat_id of notice.chatIds) {
8669
+ const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
8670
+ const opts = {
8671
+ ...(thread != null ? { message_thread_id: thread } : {}),
8672
+ }
8673
+ // allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
8674
+ void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
8675
+ .catch(e => {
8676
+ process.stderr.write(
8677
+ `telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
8678
+ )
8679
+ })
8680
+ }
8681
+ }
8430
8682
  }
8431
8683
 
8432
8684
  /**
@@ -9524,6 +9776,18 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9524
9776
  process.stderr.write(
9525
9777
  `telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
9526
9778
  )
9779
+ // #3284 — this boot ACQUIRED the boot lock, so it is the surviving healthy
9780
+ // session. Consume the session-model carrier now (delete the carrier + the
9781
+ // bounded-retry attempt counter). start.sh no longer deletes the carrier
9782
+ // before apply: an apply-boot that wedged before reaching this point leaves
9783
+ // the carrier in place so the retry boot RE-APPLIES the intended model
9784
+ // instead of silently reverting to the configured default (the marko/klanker
9785
+ // boot.lock_stale_recovered_boot_mismatch revert). Best-effort, gated on
9786
+ // lock ownership so a LOSING double-boot never clears the winner's carrier.
9787
+ {
9788
+ const carrierAgentDir = resolveAgentDirFromEnv()
9789
+ if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9790
+ }
9527
9791
  // We WON the startup mutex — this gateway is the sole live owner of the
9528
9792
  // shared per-agent status-pin store, so it's now safe to clean up orphaned
9529
9793
  // pins from a prior (dead) session. Gated here (not at import time) so a
@@ -9547,6 +9811,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9547
9811
  // probe + 409-retry loop is still the liveness guard on this path. A
9548
9812
  // successful writePidFile here means no live holder was detected, so
9549
9813
  // running orphan cleanup is consistent with the pre-mutex behaviour.
9814
+ // #3284: same healthy-boot carrier consume as the mutex-acquired path —
9815
+ // a successful writePidFile means no live holder was detected, so this is
9816
+ // the surviving session.
9817
+ {
9818
+ const carrierAgentDir = resolveAgentDirFromEnv()
9819
+ if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9820
+ }
9550
9821
  // #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
9551
9822
  void runBootPinCleanupAndDmSweep()
9552
9823
  } catch (writeErr) {
@@ -16219,7 +16490,10 @@ async function executeGetRecentMessages(args: Record<string, unknown>): Promise<
16219
16490
  const summary = rows
16220
16491
  .map(r => {
16221
16492
  const who = r.role === 'user' ? r.user ?? 'user' : 'assistant'
16222
- const time = new Date(r.ts * 1000).toISOString()
16493
+ // Local am/pm wall-clock (NOT UTC ISO) — this buffer is read straight
16494
+ // into the model's context via get_recent_messages, so every timestamp
16495
+ // it shows must be local to avoid competing with the local-time hint.
16496
+ const time = fmtLocalStamp(r.ts * 1000, resolveEnvTimezone())
16223
16497
  const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : ''
16224
16498
  // Match server.ts get_recent_messages format exactly — both code paths
16225
16499
  // serve the same MCP tool, so the agent's parsing must not depend on
@@ -18855,14 +19129,28 @@ function handleSessionEvent(ev: SessionEvent): void {
18855
19129
  // post-fire pre-record window resolves this turn (via the unified owner
18856
19130
  // resolver, reading the atom preserved in `recentTurnsById`) and
18857
19131
  // suppresses itself against this latch — closing the residual race Part
18858
- // 1's supersede cannot reach. Scoped to a SUBSTANTIVE terminal answer
18859
- // (the same ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` floor the codebase uses to
18860
- // recognise a real answer) so a short flush never latches against a
18861
- // legitimate substantive reply. `capturedText` here is the selected,
19132
+ // 1's supersede cannot reach. `capturedText` here is the selected,
18862
19133
  // normalized flush delivery text.
18863
- if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
18864
- turn.answerDelivered = true
18865
- }
19134
+ //
19135
+ // #3276 guard 2/5 — the arm is now UNCONDITIONAL (dropped the former
19136
+ // ≥`FLUSH_SUBSTANTIVE_MIN_CHARS` gate). A short terminal answer ("yes,
19137
+ // done") is a genuine answer this backstop is about to deliver, so a
19138
+ // late reply carrying the same short answer MUST supersede/suppress
19139
+ // rather than post a duplicate — the real-id supersede recorded below
19140
+ // corrects it in place.
19141
+ //
19142
+ // TWO distinct arbiters set synchronously here, before any `await`:
19143
+ // (a) `turn.answerDelivered` — the backstop-vs-LATE-REPLY signal the
19144
+ // reply path already reads (`decideAnswerLatchSuppression` +
19145
+ // `flushedTurnSupersede`), exactly as on `main`.
19146
+ // (b) `backstopDeliveryLedger.claim` — the backstop-vs-BACKSTOP
19147
+ // double-fire latch: `claim` returning false means this turn
19148
+ // already fired a backstop (answer-ready quiescence, then the
19149
+ // turn-end backstop), so this fire is a no-op. It does NOT
19150
+ // arbitrate the late reply (that is (a)); it is redundant-but-
19151
+ // cheap with the `currentTurn == null` bail below.
19152
+ turn.answerDelivered = true
19153
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId)
18866
19154
 
18867
19155
  // #654 deterministic double-message fix. Hand off the pinned
18868
19156
  // progress card BEFORE state reset so the driver doesn't keep
@@ -18894,7 +19182,7 @@ function handleSessionEvent(ev: SessionEvent): void {
18894
19182
  // bookkeeping, purge) still runs synchronously here for the #1067 /
18895
19183
  // #1556 wedge-safety reasons. `backstopTurnEndedAt` is null iff the
18896
19184
  // atom was already torn down elsewhere (no record to emit).
18897
- const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true })
19185
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true })
18898
19186
  // #549 fix — turn-flush takes ownership of the captured-text
18899
19187
  // backup; reset the preamble buffer (its content is already in
18900
19188
  // the captured `capturedText`, which turn-flush is about to send).
@@ -18941,6 +19229,13 @@ function handleSessionEvent(ev: SessionEvent): void {
18941
19229
  // finalAnswerDelivered). Not a failure.
18942
19230
  if (backstopTurnEndedAt != null) {
18943
19231
  turn.deliveryOutcome = 'suppressed'
19232
+ // #3276 finding 1 — obligation close was DEFERRED out of
19233
+ // endCurrentTurnAtomic. The reply tool already delivered this
19234
+ // turn's answer (recentCount>0), so resolve it as satisfied
19235
+ // here (idempotent with the reply path's own close). Without
19236
+ // this the deferred obligation would linger OPEN and spuriously
19237
+ // re-present a turn that WAS answered.
19238
+ if (OBLIGATION_LEDGER_ENABLED) obligationLedger.close(turn.turnId)
18944
19239
  emitTurnRecord(turn, backstopTurnEndedAt)
18945
19240
  }
18946
19241
  return
@@ -18948,118 +19243,53 @@ function handleSessionEvent(ev: SessionEvent): void {
18948
19243
  } catch {}
18949
19244
  }
18950
19245
 
19246
+ // #3276 guard 5 — double-fire guard. If this turn already claimed the
19247
+ // delivery latch (a prior backstop fire — e.g. answer-ready quiescence
19248
+ // followed by the turn-end backstop for the same turn), do NOT deliver
19249
+ // again. The first fire owns delivery; this fire is a cosmetic no-op.
19250
+ if (!backstopLatchClaimed) {
19251
+ process.stderr.write(
19252
+ `telegram gateway: turn-flush skipped — turn ${turn.turnId} already claimed the delivery latch\n`,
19253
+ )
19254
+ return
19255
+ }
19256
+
18951
19257
  process.stderr.write(
18952
19258
  `telegram gateway: turn-flush firing — ${capturedText.length} chars without reply tool ` +
18953
19259
  `(chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? 'none'})\n`,
18954
19260
  )
18955
- const sendOpts = {
18956
- ...(backstopThreadId != null ? { message_thread_id: backstopThreadId } : {}),
18957
- link_preview_options: { is_disabled: true },
18958
- }
18959
- const limit = RICH_MESSAGE_MAX_CHARS
18960
- // PR B (Fix 1) send accounting is declared OUTSIDE the try so the
18961
- // single-record `finally` below can read it, and ALL setup (the chunk
18962
- // split) is pulled INSIDE the try so a throw there still lands in the
18963
- // finally (→ one honest `send_failed` row) rather than rejecting the
18964
- // IIFE with zero turns.jsonl rows written / an unhandled rejection.
18965
- let htmlChunks: string[] = []
18966
- const sentIds: number[] = []
18967
- // track whether the send threw so the deferred record reflects the
18968
- // real outcome (throw OR partial multi-chunk → send_failed).
18969
- let sendThrew = false
19261
+ // PR B (Fix 1) — send accounting declared OUTSIDE the try so the
19262
+ // single-record `finally` below reads it on EVERY in-process exit.
19263
+ // `delivered` is the RECEIPT-gated truth (>=1 fresh non-card id AND
19264
+ // all chunks landed), computed by the retry orchestrator — NOT a
19265
+ // blanket outer-catch flag, so a throw in the post-delivery
19266
+ // bookkeeping below (dedup / supersede record) can never demote a
19267
+ // genuinely delivered turn to `send_failed` (finding 4).
19268
+ let sentIds: number[] = []
19269
+ let chunkCount = 0
19270
+ let delivered = false
18970
19271
  try {
18971
- // #2798 / #2692 inject visible blank-line spacers into prose `\n\n`
18972
- // gaps before splitting, exactly as executeReply does. The rich GFM
18973
- // renderer collapses a bare `\n\n` gap TIGHT, so without this the
18974
- // paragraph boundaries from the '\n\n' block join (turn-flush-safety
18975
- // .ts) would still render jammed together. Mirrors reply's
18976
- // `addParagraphSpacers(text)` on the non-literal path (idempotent
18977
- // exactly one U+00A0 spacer per gap, never doubled).
18978
- const renderedText = addParagraphSpacers(capturedText)
18979
- htmlChunks = splitMarkdownChunks(renderedText, limit)
18980
- // #654 deterministic double-message fix. If the progress
18981
- // card is on screen (60s timer fired before turn_end), edit
18982
- // it in place with the first chunk of the answer instead of
18983
- // posting a fresh message — avoids the user seeing both the
18984
- // pinned card AND a separate text bubble for the same turn.
18985
- // Multi-chunk answers (>4000 chars) edit chunk[0] into the
18986
- // card and send chunks[1..] fresh. If the edit fails (e.g.
18987
- // user deleted the card, parse-mode conflict), fall back to
18988
- // a fresh send for chunk[0] — accepts a 2-message outcome
18989
- // for that edge case rather than dropping the answer.
18990
- // #1075: route turn-flush sends/edits through robustApiCall.
18991
- // Edit-in-place isn't thread-id-bearing on its own (the
18992
- // target message id implies a thread), so a stale-thread
18993
- // 400 just fails the edit and the loop falls back to fresh
18994
- // sendMessage. sendMessage IS thread-id-bearing — drop the
18995
- // thread on THREAD_NOT_FOUND so the captured prose still
18996
- // lands somewhere instead of being lost entirely.
18997
- let firstSendUsedEdit = false
18998
- let liveThreadId: number | undefined = backstopThreadId
18999
- if (backstopCardMessageId != null && htmlChunks.length > 0) {
19000
- try {
19001
- await robustApiCall(
19002
- () =>
19003
- bot.api.editMessageText(
19004
- backstopChatId,
19005
- backstopCardMessageId,
19006
- richMessage(htmlChunks[0]),
19007
- sendOpts,
19008
- ),
19009
- {
19010
- chat_id: backstopChatId,
19011
- verb: 'turn-flush.editMessageText',
19012
- ...(liveThreadId != null ? { threadId: liveThreadId } : {}),
19013
- },
19014
- )
19015
- sentIds.push(backstopCardMessageId)
19016
- firstSendUsedEdit = true
19017
- } catch (err) {
19018
- process.stderr.write(
19019
- `telegram gateway: turn-flush card-takeover edit failed: ${(err as Error).message} — falling back to sendMessage\n`,
19020
- )
19021
- if (err instanceof Error && err.message === 'THREAD_NOT_FOUND') {
19022
- liveThreadId = undefined
19023
- }
19024
- }
19025
- }
19026
- const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks
19027
- for (const c of remainingChunks) {
19028
- const sent = await retryWithThreadFallback(
19029
- robustApiCall,
19030
- (tid) => {
19031
- const opts = {
19032
- link_preview_options: { is_disabled: true },
19033
- ...(tid != null ? { message_thread_id: tid } : {}),
19034
- }
19035
- return bot.api.sendRichMessage(backstopChatId, richMessage(c), opts)
19036
- },
19037
- {
19038
- threadId: liveThreadId,
19039
- chat_id: backstopChatId,
19040
- verb: 'turn-flush.sendMessage',
19041
- },
19042
- )
19043
- if (liveThreadId != null) {
19044
- const sentMsg = sent as { message_thread_id?: number }
19045
- if (sentMsg.message_thread_id == null) liveThreadId = undefined
19046
- }
19047
- sentIds.push(sent.message_id)
19048
- }
19049
- if (HISTORY_ENABLED && sentIds.length > 0) {
19050
- try {
19051
- recordOutbound({
19052
- chat_id: backstopChatId,
19053
- thread_id: backstopThreadId ?? null,
19054
- message_ids: sentIds,
19055
- texts: htmlChunks,
19056
- })
19057
- } catch {}
19058
- }
19059
- // #546 dedup: record what turn-flush just sent so a
19060
- // late-arriving reply / stream_reply with the same
19061
- // content gets suppressed (claude-code retries the
19062
- // un-acked tool_call after a bridge reconnect).
19272
+ // #3276the ONE delivery primitive. deliverAnswer routes through
19273
+ // `sendReplyChunks` (the same send core executeReply uses) and posts
19274
+ // a FRESH chat message; it NEVER edits the progress card, so a
19275
+ // "delivery" can never be a card mutation the marker-sweep GC's
19276
+ // ~60-90s later. It returns the REAL fresh chat message ids and
19277
+ // retries mid-chunk (bounded) before giving up the per-chunk
19278
+ // ledger resumes at the first unsent chunk, never re-sending chunk 0
19279
+ // (guard 6).
19280
+ const delivery = await deliverAnswer({
19281
+ chatId: backstopChatId,
19282
+ threadId: backstopThreadId,
19283
+ text: capturedText,
19284
+ turnId: turn.turnId,
19285
+ cardMessageId: backstopCardMessageId,
19286
+ })
19287
+ sentIds = delivery.sentIds
19288
+ chunkCount = delivery.chunkCount
19289
+ delivered = delivery.delivered
19290
+
19291
+ // #546 dedup: record what turn-flush just sent so a late-arriving
19292
+ // reply / stream_reply with the same content gets suppressed.
19063
19293
  outboundDedup.record(
19064
19294
  backstopChatId,
19065
19295
  backstopThreadId,
@@ -19067,14 +19297,10 @@ function handleSessionEvent(ev: SessionEvent): void {
19067
19297
  Date.now(),
19068
19298
  currentTurn?.registryKey ?? null,
19069
19299
  )
19070
- // 2026-07 duplicate-reply fixrecord the flushed message id(s)
19071
- // keyed on THIS turn's `turnId` nonce so a late `reply` for the same
19072
- // turn supersedes them (delete + canonical resend) instead of
19073
- // shipping a duplicate. `turn` is the ending turn atom captured at
19074
- // the top of this branch (endCurrentTurnAtomic nulled currentTurn,
19075
- // but the captured `turn` still carries the honest turnId). Covers
19076
- // BOTH flush paths — answer-ready quiescence and the turn-end
19077
- // backstop both funnel through this single IIFE.
19300
+ // #3276 guard 3feed the REAL fresh chat ids into the supersede
19301
+ // record so a late `reply` for the same turn corrects them in place
19302
+ // (edit / delete+resend) instead of shipping a second bubble. These
19303
+ // are genuine chat message ids now, never a card-edit id.
19078
19304
  if (sentIds.length > 0) {
19079
19305
  flushedTurnSupersede.record(
19080
19306
  backstopChatId,
@@ -19083,13 +19309,25 @@ function handleSessionEvent(ev: SessionEvent): void {
19083
19309
  Date.now(),
19084
19310
  )
19085
19311
  }
19086
- // #1713: route the backstop terminal through finalize()
19087
- // single terminal path keeps the controller contract clean.
19088
- if (backstopCtrl) backstopCtrl.finalize('done')
19089
- // Unpin the card. completeTurn cleans up pinMgr's per-turn
19090
- // state and unpins via the API. If we didn't take over a
19091
- // turn (cardTakeover.turnKey == null), fall back to the
19092
- // legacy unpinForChat sweep.
19312
+ // #3276 guard 4 — collapse the taken-over card to a NON-answer
19313
+ // state. The answer flowed ONLY through deliverAnswer (a fresh
19314
+ // bubble); here we just unpin/complete the card so no orphaned
19315
+ // ⚙️ Working… lingers. The card carries NO answer text, so a
19316
+ // card-collapse failure and a fresh-send failure can never leave
19317
+ // BOTH an answer-card AND an answer-bubble visible.
19318
+ if (!delivered) {
19319
+ // Retries exhausted with nothing durable delivered — finalize the
19320
+ // reaction as error and reset the latch so a genuine late reply is
19321
+ // NOT suppressed.
19322
+ if (backstopCtrl) backstopCtrl.finalize('error')
19323
+ backstopDeliveryLedger.release(turn.turnId)
19324
+ turn.answerDelivered = false
19325
+ } else if (backstopCtrl) {
19326
+ backstopCtrl.finalize('done')
19327
+ }
19328
+ // Unpin the card either way (cosmetic). completeTurn cleans up
19329
+ // pinMgr's per-turn state and unpins; fall back to the legacy
19330
+ // unpinForChat sweep when we didn't take over a turn.
19093
19331
  if (backstopCardTurnKey != null) {
19094
19332
  completeProgressCardTurn?.({
19095
19333
  chatId: backstopChatId,
@@ -19100,49 +19338,50 @@ function handleSessionEvent(ev: SessionEvent): void {
19100
19338
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId)
19101
19339
  }
19102
19340
  } catch (err) {
19103
- sendThrew = true
19104
- process.stderr.write(`telegram gateway: turn-flush send failed: ${(err as Error).message}\n`)
19105
- // 2026-07 double-reply-on-DM fix (F1) the flush armed
19106
- // `answerDelivered` synchronously at FIRE time, but the send just
19107
- // FAILED. When nothing was delivered the supersede record was NOT
19108
- // written (gated on `sentIds.length > 0` above), so Part 1 can never
19109
- // fire for this turn — leaving the latch armed would make a genuine
19110
- // late reply suppress itself and the user would get ZERO messages
19111
- // (silent answer loss; on main that path still delivers the reply).
19112
- // Reset the latch so the late reply is NOT suppressed. On a PARTIAL
19113
- // send (sentIds > 0) the record WAS written, so Part 1's supersede
19114
- // deletes the partial message A and the reply delivers cleanly —
19115
- // resetting here is harmless in that case too.
19116
- // FOLLOW-UP (coordinator to file an issue): a reply suppressed
19117
- // synchronously DURING the in-flight send that then fails is not
19118
- // fully closable with a boolean latch — a residual micro-window the
19119
- // supersede+latch pair cannot eliminate. Not addressed in this PR.
19120
- turn.answerDelivered = false
19121
- // #1713: backstop send failed — finalize as error so the
19122
- // turn ends cleanly with 😱 rather than leaving it open.
19123
- if (backstopCtrl) backstopCtrl.finalize('error')
19341
+ // Only reachable via a throw in the post-delivery bookkeeping (the
19342
+ // delivery itself is retry-wrapped inside deliverAnswer and never
19343
+ // throws out). `delivered` already reflects the receipt-gated truth;
19344
+ // do NOT flip it here (finding 4). If nothing landed, reset the
19345
+ // latch so a genuine late reply is not suppressed.
19346
+ process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${(err as Error).message}\n`)
19347
+ if (!delivered) {
19348
+ turn.answerDelivered = false
19349
+ backstopDeliveryLedger.release(turn.turnId)
19350
+ if (backstopCtrl) backstopCtrl.finalize('error')
19351
+ }
19124
19352
  } finally {
19125
- // PR B (Fix 1)SINGLE-RECORD GUARANTEE. The send has now RESOLVED
19126
- // (or thrown); this finally runs on EVERY in-process exit of the send
19127
- // block clean send, throw in setup/split, throw mid-send — so
19128
- // exactly one turns.jsonl row is written, with the outcome reflecting
19129
- // what actually happened. A throw or a partial multi-chunk delivery
19130
- // (sentIds < htmlChunks) or an un-run/empty split → 'failed' → status
19131
- // `send_failed`; a full delivery 'delivered' `complete`. This
19132
- // replaces the false `complete` the old synchronous write produced
19133
- // for a flood-dropped / errored answer the user never received, and
19134
- // restores the old code's one-row-per-turn guarantee (the pre-finally
19135
- // shape wrote ZERO rows if setup threw). The suppressed-reply branch
19136
- // above already emitted its record and `return`ed BEFORE reaching
19137
- // this try, so it can never double-write with this finally.
19353
+ // #3276 guard 7 + finding 1 — honest record AND honest recovery.
19354
+ // Status is derived from the RECEIPT gate: `complete` IFF a fresh
19355
+ // non-card id landed for every chunk; otherwise `send_failed`.
19356
+ //
19357
+ // The delivery-obligation close was DEFERRED out of
19358
+ // endCurrentTurnAtomic (deferObligationClose) so it reflects the
19359
+ // REAL send outcome here, not the speculative fire-time flag:
19360
+ // delivered → close the obligation (answered).
19361
+ // NOT deliv. leave it OPEN + noteTurnEnded, so the ~150s
19362
+ // liveness floor re-presents the answer instead of
19363
+ // the old silent `send_failed` drop.
19138
19364
  if (backstopTurnEndedAt != null) {
19139
- finalizeBackstopSend(turn, {
19140
- threw: sendThrew,
19141
- sentCount: sentIds.length,
19142
- chunkCount: htmlChunks.length,
19365
+ finalizeBackstopSendGated(turn, {
19366
+ threw: !delivered,
19367
+ sentIds,
19368
+ chunkCount,
19369
+ cardMessageId: backstopCardMessageId,
19143
19370
  })
19371
+ if (OBLIGATION_LEDGER_ENABLED) {
19372
+ if (delivered) {
19373
+ obligationLedger.close(turn.turnId)
19374
+ } else {
19375
+ // Terminal fail — do NOT mark the obligation satisfied.
19376
+ turn.finalAnswerDelivered = false
19377
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now())
19378
+ }
19379
+ }
19144
19380
  emitTurnRecord(turn, backstopTurnEndedAt)
19145
19381
  }
19382
+ // GC the ledger only now — after success OR retry exhaustion — so a
19383
+ // resume could always read prior progress up to this point.
19384
+ backstopDeliveryLedger.clear(turn.turnId)
19146
19385
  }
19147
19386
  // #2094 cosmetic: the trailing `finally { purgeReactionTracking() }`
19148
19387
  // was removed. endCurrentTurnAtomic already ran the canonical purge
@@ -21215,7 +21454,11 @@ async function handleInbound(
21215
21454
  ...(msgId != null ? { message_id: String(msgId) } : {}),
21216
21455
  user: displayUser,
21217
21456
  user_id: String(from.id),
21218
- ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
21457
+ // Model-facing `ts="…"` on the inbound <channel> tag. Rendered as the
21458
+ // agent's LOCAL am/pm wall-clock (NOT UTC ISO) so the model never reads
21459
+ // a competing UTC "now" — the numeric epoch survives on InboundMessage.ts
21460
+ // (above) and in the SQLite history for any machine consumer.
21461
+ ts: fmtLocalStamp((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone()),
21219
21462
  ...(messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {}),
21220
21463
  // Component 3 — origin turn id. The model is told to pass this back
21221
21464
  // as origin_turn_id on the reply so the answer routes to the topic
@@ -22764,9 +23007,14 @@ async function runSwitchroomCommand(
22764
23007
  // commands) preserve in-place reply behavior. /logs /audit
22765
23008
  // /upgradestatus /memory pass 'heavy' to route to admin alias.
22766
23009
  classification: 'query' | 'mutation' | 'heavy' = 'query',
23010
+ // Optional DISPLAY-ONLY transform applied to the stripped command output
23011
+ // before formatting (never mutates the underlying stored logs). /logs uses
23012
+ // this to render leading UTC ISO docker-log timestamps in local am/pm.
23013
+ transformOutput?: (raw: string) => string,
22767
23014
  ): Promise<void> {
22768
23015
  try {
22769
- const output = stripAnsi(switchroomExec(args))
23016
+ const stripped = stripAnsi(switchroomExec(args))
23017
+ const output = transformOutput ? transformOutput(stripped) : stripped
22770
23018
  const formatted = formatSwitchroomOutput(output)
22771
23019
  if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
22772
23020
  else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
@@ -23316,7 +23564,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23316
23564
  return []
23317
23565
  }
23318
23566
  },
23319
- select: (a, label) => selectModel(a, label),
23320
23567
  isBusy: () => currentTurn !== null,
23321
23568
  getAgentName: getMyAgentName,
23322
23569
  getQuotaBrief: async () => {
@@ -23335,7 +23582,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23335
23582
  } catch { /* quota is garnish — never block the menu on it */ }
23336
23583
  return null
23337
23584
  },
23338
- inject: injectSlashCommandImpl,
23339
23585
  getConfiguredModel: () => {
23340
23586
  type AgentListResp = { agents: Array<{ name: string; model?: string | null }> }
23341
23587
  const data = switchroomExecJson<AgentListResp>(['agent', 'list'])
@@ -23343,7 +23589,6 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23343
23589
  },
23344
23590
  escapeHtml: escapeHtmlForTg,
23345
23591
  preBlock,
23346
- getActiveSessionModel: () => sessionModelSource.getOverride(),
23347
23592
  /**
23348
23593
  * Graceful restart for sr-* → Claude model switch. Same mechanism as
23349
23594
  * the /restart command: writes a restart marker (so the post-restart
@@ -23429,6 +23674,18 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23429
23674
  resolveMainModel(deps.getConfiguredModel() ?? undefined),
23430
23675
  )
23431
23676
  sessionModelSource.setOverride(model)
23677
+ // Diagnosability (rev 5): the applied-model is now always greppable at the
23678
+ // relaunch boundary — `grep 'gw /model relaunch scheduled'` — closing the
23679
+ // gap the debug worker flagged (the retired inject path logged nothing).
23680
+ process.stderr.write(
23681
+ `telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=${model} reason=${JSON.stringify(reason)}\n`,
23682
+ )
23683
+ // A manual /model switch clears any pending premium-recovery marker so the
23684
+ // "available again" ping can't still fire after the operator switched away
23685
+ // themselves. Rev 5: this moves here (from the deleted recordTypedModelSwitch /
23686
+ // recordModelMenuSideEffects) so it fires for EVERY switch path uniformly —
23687
+ // both R5 sites collapse to this single call.
23688
+ clearPremiumRecoveryOnManualSwitch(model)
23432
23689
  try {
23433
23690
  await deps.scheduleRestart(reason)
23434
23691
  } catch (err) {
@@ -23445,6 +23702,36 @@ function buildModelDeps(restartCtx?: ModelDepsRestartContext): ModelMenuDeps & M
23445
23702
  throw err
23446
23703
  }
23447
23704
  },
23705
+ /**
23706
+ * `/model default` (rev 5): CLEAR the consume-once carrier + the in-memory
23707
+ * override, then relaunch so the LIVE session reverts to the configured
23708
+ * default. Mirrors scheduleModelRelaunch's rollback discipline (G1): on a
23709
+ * `restart_in_flight` throw keep the cleared state (the in-flight boot has no
23710
+ * carrier and reverts anyway); on any other dispatch failure restore the
23711
+ * prior carrier + override so a failed default-revert doesn't strand the
23712
+ * session in a half-cleared state.
23713
+ */
23714
+ scheduleModelDefaultRelaunch: async (reason: string) => {
23715
+ const agentDir = resolveAgentDirFromEnv()
23716
+ if (!agentDir) throw new Error('agent dir unresolvable — cannot clear session-model file')
23717
+ const prevOverride = sessionModelSource.getOverride()
23718
+ const prevFileRaw = readSessionModelFileRaw(agentDir)
23719
+ clearSessionModelFile(agentDir)
23720
+ sessionModelSource.setOverride(null)
23721
+ process.stderr.write(
23722
+ `telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=(default) reason=${JSON.stringify(reason)}\n`,
23723
+ )
23724
+ clearPremiumRecoveryOnManualSwitch(null)
23725
+ try {
23726
+ await deps.scheduleRestart(reason)
23727
+ } catch (err) {
23728
+ if ((err as { code?: string })?.code !== 'restart_in_flight') {
23729
+ restoreSessionModelFileRaw(agentDir, prevFileRaw)
23730
+ sessionModelSource.setOverride(prevOverride)
23731
+ }
23732
+ throw err
23733
+ }
23734
+ },
23448
23735
  }
23449
23736
  return deps
23450
23737
  }
@@ -23459,125 +23746,14 @@ function modelMenuReplyMarkup(reply: ModelMenuReply): InlineKeyboard | undefined
23459
23746
  return kb
23460
23747
  }
23461
23748
 
23462
- /**
23463
- * Record a POSITIVELY-CONFIRMED typed `/model` switch: set the in-memory
23464
- * override so `/status` reflects the live model. Shared by the live
23465
- * `bot.command('model')` handler and the deferred (queued mid-turn) apply so
23466
- * both record identically. Returns a warning suffix to append to the reply
23467
- * body (currently always empty kept for a stable signature).
23468
- *
23469
- * Session-scoped (rev 4): a live Claude `/model` switch writes NO
23470
- * `.session-model` carrier — it applies in-session and the explicit
23471
- * `claude --model <configured>` flag reverts it on the next boot, so it lasts
23472
- * exactly until the next restart with no durable state. (sr-* switches never
23473
- * reach here — they go through scheduleModelRelaunch, which owns the
23474
- * consume-once carrier.) The `/status` honesty invariant lives here: only
23475
- * `reply.selectedModel` records; an unverified inject records nothing.
23476
- * `/model default` clears any in-memory override and any leftover carrier.
23477
- */
23478
- function recordTypedModelSwitch(
23479
- reply: { text: string; selectedModel?: string },
23480
- requestedModelArg: string | null,
23481
- _deps: ModelCommandDeps,
23482
- ): string {
23483
- const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null
23484
- if (requested?.toLowerCase() === 'default') {
23485
- const smDir = resolveAgentDirFromEnv()
23486
- if (smDir) clearSessionModelFile(smDir)
23487
- if (reply.selectedModel) sessionModelSource.setOverride(null)
23488
- return ''
23489
- }
23490
- if (!reply.selectedModel) return ''
23491
- sessionModelSource.setOverride(reply.selectedModel)
23492
- // A manual /model apply to the dropped premium clears any pending recovery
23493
- // marker so the "available again" ping can't still fire after the user has
23494
- // already switched back themselves.
23495
- clearPremiumRecoveryOnManualSwitch(reply.selectedModel)
23496
- return ''
23497
- }
23498
-
23499
- /**
23500
- * Record a model-MENU callback outcome (set the live in-memory override, clear
23501
- * a leftover carrier on a Default tap) and drive an sr-*→Claude graceful
23502
- * restart when the tap crosses that boundary — the only menu path that writes a
23503
- * consume-once `.session-model` carrier (a live Claude tap writes none, rev 4).
23504
- * Extracted from the live `mdl:*` dispatcher so the deferred (queued mid-turn)
23505
- * apply records + restarts identically. Does NOT edit any Telegram message —
23506
- * callers own the card edit. Returns a restart notice when a session restart
23507
- * was scheduled (the card should then drop its keyboard).
23508
- */
23509
- function recordModelMenuSideEffects(
23510
- outcome: Awaited<ReturnType<typeof handleModelMenuCallback>>,
23511
- modelDeps: ModelCommandDeps,
23512
- cbChatId: string,
23513
- cbThreadId: number | undefined,
23514
- prevSessionModel: string | null,
23515
- ): { restartNotice?: string } {
23516
- // Record a successful session switch so /status reflects what's actually
23517
- // running. Session-scoped (rev 4): a live Claude menu tap writes NO
23518
- // `.session-model` carrier — it applies in-session (native picker) and
23519
- // reverts on the next boot. Only the sr→Claude transition below (which
23520
- // relaunches) writes the consume-once carrier. A confirmed "Default
23521
- // (recommended)" selection clears any leftover carrier.
23522
- if (outcome.selectedModel) {
23523
- sessionModelSource.setOverride(outcome.selectedModel)
23524
- // Clear a pending premium-recovery marker when the tap re-selects the
23525
- // dropped premium (menu OR the recovery ping's own switch-back button):
23526
- // no stale "available again" ping once we're back on it. Idempotent — the
23527
- // ping-send path already consumed the marker, so this is a no-op there.
23528
- clearPremiumRecoveryOnManualSwitch(outcome.selectedModel)
23529
- }
23530
- if (outcome.clearedDefault) {
23531
- const smDir = resolveAgentDirFromEnv()
23532
- if (smDir) clearSessionModelFile(smDir)
23533
- }
23534
-
23535
- // sr-* → Claude transition: the picker-select only changes the session model
23536
- // label, but the sr-* LiteLLM routing context persists until the session is
23537
- // torn down — a graceful restart (same mechanism as /restart) is required.
23538
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
23539
- const agentName = getMyAgentName()
23540
- // Carry the requested Claude model across the restart via the SAME
23541
- // consume-once `.session-model` carrier a Claude → sr-* switch uses —
23542
- // otherwise this transition's apply-relaunch boots the CONFIGURED default
23543
- // and the tapped model is silently dropped. Applied on that one boot,
23544
- // then reverts on the next restart (rev 4).
23545
- const agentDir = resolveAgentDirFromEnv()
23546
- const token = outcome.selectedModelToken
23547
- if (agentDir && token) {
23548
- try {
23549
- writeSessionModelFile(
23550
- agentDir,
23551
- token,
23552
- readConfiguredDefaultModel(agentDir) ??
23553
- resolveMainModel(modelDeps.getConfiguredModel() ?? undefined),
23554
- )
23555
- sessionModelSource.setOverride(token)
23556
- } catch (e) {
23557
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${(e as Error)?.message ?? String(e)}\n`)
23558
- }
23559
- } else if (agentDir) {
23560
- // Default-row tap while on sr-*: the restart must land on the configured
23561
- // default — a stale sticky override would resurrect the old model.
23562
- clearSessionModelFile(agentDir)
23563
- }
23564
- // Write the restart marker so the post-restart boot card edits into this chat.
23565
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() })
23566
- stampUserRestartReason('user: sr-to-claude model switch (menu)')
23567
- if (turnInFlightForGate()) {
23568
- // Defer restart until the in-flight turn completes (same gate as /restart).
23569
- pendingRestarts.set(agentName, Date.now())
23570
- } else {
23571
- void sweepBeforeSelfRestart().finally(() =>
23572
- triggerSelfRestart(agentName, 'sr-to-claude-model-switch', 1500),
23573
- )
23574
- }
23575
- return {
23576
- restartNotice: `🔄 Switching from **${escapeHtmlForTg(prevSessionModel!)}** back to Claude — restarting session cleanly. Claude will be ready in ~30s.`,
23577
- }
23578
- }
23579
- return {}
23580
- }
23749
+ // Rev 5 (deterministic switch): `recordTypedModelSwitch` and
23750
+ // `recordModelMenuSideEffects` are RETIRED. Every switch now routes through
23751
+ // `scheduleModelRelaunch` / `scheduleModelDefaultRelaunch` inside the handlers,
23752
+ // which own the carrier + in-memory override writes and the premium-recovery
23753
+ // clear. The sr-*→Claude special case is gone: because EVERY switch relaunches,
23754
+ // the sr-* LiteLLM routing is always torn down cleanly by the boot, so there is
23755
+ // no distinct transition to detect. The ACTUAL running model is reconciled at
23756
+ // boot from `.active-session-model` (never from a scraped `selectedModel`).
23581
23757
 
23582
23758
  // ─── Mid-turn ack-queue-apply-confirm for /model + /effort (#3017) ──────────
23583
23759
  //
@@ -23636,24 +23812,17 @@ function enqueueSessionCommand(cmd: PendingSessionCommand): void {
23636
23812
  async function applyQueuedModelCommand(cmd: PendingSessionCommand): Promise<string> {
23637
23813
  const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId })
23638
23814
  if (cmd.origin === 'menu') {
23639
- // Menu SELECT (mdl:s:<tag>) — replay the callback handler (discovery is
23640
- // idle-safe now) and record via the shared side-effects helper.
23641
- const prevSessionModel = sessionModelSource.getOverride()
23815
+ // Menu SELECT (mdl:s:<token>) — replay the callback handler, which now
23816
+ // relaunches through the carrier itself (rev 5: no side-effects helper, no
23817
+ // scrape). The relaunch writes its own restart marker so the post-boot card
23818
+ // lands in this chat.
23642
23819
  const outcome = await handleModelMenuCallback(cmd.arg, deps)
23643
- const { restartNotice } = recordModelMenuSideEffects(
23644
- outcome,
23645
- deps,
23646
- cmd.chatId,
23647
- cmd.threadId,
23648
- prevSessionModel,
23649
- )
23650
- return restartNotice ?? outcome.reply.text
23820
+ return outcome.reply.text
23651
23821
  }
23652
23822
  // Typed (and alias/sr menu taps converted to typed at enqueue): run the real
23653
- // handler + shared recording.
23823
+ // handler, which relaunches through the carrier and owns all side effects.
23654
23824
  const reply = await handleModelCommand({ kind: 'set', model: cmd.arg }, deps)
23655
- const warning = recordTypedModelSwitch(reply, cmd.arg, deps)
23656
- return reply.text + warning
23825
+ return reply.text
23657
23826
  }
23658
23827
 
23659
23828
  /** Apply a queued typed/menu effort command at idle; return the reply body. */
@@ -23872,17 +24041,12 @@ bot.command('model', async ctx => {
23872
24041
  })
23873
24042
  return
23874
24043
  }
24044
+ // Rev 5: the handler relaunches through the carrier and owns every side effect
24045
+ // (override write, carrier, premium-recovery clear). There is no post-hoc
24046
+ // recording — /status is reconciled at boot from `.active-session-model`, so
24047
+ // an unapplied switch can never be optimistically recorded here.
23875
24048
  const reply = await handleModelCommand(parsed, deps)
23876
- // Record a POSITIVELY-CONFIRMED typed switch so /status reflects what's
23877
- // actually running (shared with the deferred/menu paths). The sr-*/relaunch
23878
- // paths already set the override inside scheduleModelRelaunch; an unverified
23879
- // switch carries no selectedModel so /status is never lied to.
23880
- const persistWarning = recordTypedModelSwitch(
23881
- reply,
23882
- parsed.kind === 'set' ? parsed.model : null,
23883
- deps,
23884
- )
23885
- await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html })
24049
+ await switchroomReply(ctx, reply.text, { html: reply.html })
23886
24050
  })
23887
24051
 
23888
24052
  // `/effort` — show or switch the reasoning effort for the live session.
@@ -26919,7 +27083,23 @@ bot.command('logs', async ctx => {
26919
27083
  const lines = linesArg ? parseInt(linesArg, 10) : 20
26920
27084
  const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
26921
27085
  // PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
26922
- await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`, 'heavy')
27086
+ // #tz-fix audit (MEDIUM gap 2): `--timestamps` makes docker prefix every
27087
+ // line with its own UTC ISO-8601-Z stamp — the only deterministic per-line
27088
+ // timestamp (raw app lines often carry no stamp, or a local-zone one that
27089
+ // must NOT be re-shifted). We then render that stamp in local am/pm at
27090
+ // DISPLAY time so `/logs` doesn't surface UTC (competing with the
27091
+ // local-time hint). The zone is the GATEWAY/operator zone
27092
+ // (resolveEnvTimezone of this process), not the target agent's zone —
27093
+ // intended: /logs is an operator surface. Stored logs are untouched —
27094
+ // the transform runs only on the text sent to chat.
27095
+ const tz = resolveEnvTimezone()
27096
+ await runSwitchroomCommand(
27097
+ ctx,
27098
+ ['agent', 'logs', name, '--lines', String(lineCount), '--timestamps'],
27099
+ `logs ${name}`,
27100
+ 'heavy',
27101
+ (raw) => renderLogTimestampsLocal(raw, tz),
27102
+ )
26923
27103
  })
26924
27104
 
26925
27105
  bot.command('memory', async ctx => {
@@ -27374,51 +27554,15 @@ bot.on('callback_query:data', async ctx => {
27374
27554
  // rather than the switch-oriented "Switching…".
27375
27555
  const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN
27376
27556
  await ctx.answerCallbackQuery({ text: isPageNav ? 'Loading…' : 'Switching…' }).catch(() => {})
27377
- // sr-* inject waits for claude to respond (can take 10-30s). Edit the
27378
- // menu immediately to show a "working on it" state so the operator isn't
27379
- // left looking at a stale menu with no feedback. The final edit (✅/❌)
27380
- // replaces this once the inject returns.
27381
- // NOTE: this await yields the event loop, so a new inbound turn could
27382
- // start between here and handleModelMenuCallback's inner isBusy() check.
27383
- // We track whether we applied the interim edit so we can skip the
27384
- // toastOnly short-circuit if we did a toastOnly return after the interim
27385
- // edit would leave the menu stuck button-less.
27386
- // sr-* TARGET tap: switch TO a non-Claude (LiteLLM/OpenRouter) model.
27387
- // Parity with the text `/model sr-*` path — claude's native picker rejects
27388
- // unknown sr-* ids, so an in-place inject can't set them. Carry the token
27389
- // across a graceful restart (the consume-once `.session-model` carrier) and
27390
- // relaunch `claude --model sr-*`. Session-only; reverts to the configured
27391
- // default on the next restart. The sr-* → Claude direction is handled below
27392
- // via the SELECT/alias outcome + isSrToClaudeTransition.
27393
- if (data.startsWith(MODEL_CALLBACK_SR)) {
27394
- const srName = data.slice(MODEL_CALLBACK_SR.length)
27395
- const srLabel = escapeHtmlForTg(srFriendlyLabel(srName))
27396
- if (!isValidModelArg(srName)) {
27397
- await ctx
27398
- .editMessageText(richMessage('❌ Invalid model name'), { reply_markup: { inline_keyboard: [] } })
27399
- .catch(() => {})
27400
- return
27401
- }
27402
- await ctx
27403
- .editMessageText(
27404
- richMessage(`🔄 Switching session to **${srLabel}** — restarting (~30s). _Session-only; reverts to the configured default on the next restart._`),
27405
- { reply_markup: { inline_keyboard: [] } },
27406
- )
27407
- .catch(() => {})
27408
- try {
27409
- await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`)
27410
- } catch (err) {
27411
- await ctx
27412
- .editMessageText(
27413
- richMessage(`❌ Could not switch to **${srLabel}**: ${escapeHtmlForTg((err as Error)?.message ?? String(err))}`),
27414
- { reply_markup: { inline_keyboard: [] } },
27415
- )
27416
- .catch(() => {})
27417
- }
27418
- return
27419
- }
27557
+ // Rev 5 (deterministic switch): EVERY switch tap Fable/alias (`mdl:alias:`),
27558
+ // sr-* target (`mdl:sr:`), and picker SELECT (`mdl:s:<token>`) goes through
27559
+ // the single handler, which relaunches through the consume-once carrier
27560
+ // (`scheduleModelRelaunch` / `scheduleModelDefaultRelaunch`). No inject, no
27561
+ // cursor-nav, no scrape, and no post-hoc `recordModelMenuSideEffects`: the
27562
+ // relaunch owns the override + carrier writes, and `.active-session-model`
27563
+ // reconciles /status at boot. The handler writes its own restart marker so
27564
+ // the post-boot card lands in this chat.
27420
27565
  try {
27421
- const prevSessionModel = sessionModelSource.getOverride()
27422
27566
  const outcome = await handleModelMenuCallback(data, modelDeps)
27423
27567
  // toastOnly: leave the menu untouched — a mid-turn refusal keeps its
27424
27568
  // buttons so the operator can tap again. (In the enqueue world this
@@ -27426,23 +27570,6 @@ bot.on('callback_query:data', async ctx => {
27426
27570
  // before ever calling the handler — but retained for callers that skip
27427
27571
  // the dispatcher gate.)
27428
27572
  if (outcome.toastOnly) return
27429
- // Record the switch (persist/clear sticky override) + drive an sr-*→Claude
27430
- // graceful restart when needed. Shared with the deferred (queued) apply so
27431
- // both surfaces record identically. Returns a restart notice when a
27432
- // session restart was scheduled.
27433
- const { restartNotice } = recordModelMenuSideEffects(
27434
- outcome,
27435
- modelDeps,
27436
- cbChatId,
27437
- cbThreadId,
27438
- prevSessionModel,
27439
- )
27440
- if (restartNotice) {
27441
- await ctx
27442
- .editMessageText(richMessage(restartNotice), { reply_markup: { inline_keyboard: [] } })
27443
- .catch(() => {})
27444
- return
27445
- }
27446
27573
  await ctx
27447
27574
  .editMessageText(richMessage(outcome.reply.text), {
27448
27575
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] },
@@ -29303,6 +29430,34 @@ bot.on('message:pinned_message', async ctx => {
29303
29430
  }
29304
29431
  })
29305
29432
 
29433
+ // ─── Terminal catch-all for unhandled message content types (#3300) ───────
29434
+ //
29435
+ // MUST stay registered LAST among the `message`/`message:*` handlers.
29436
+ //
29437
+ // grammy's `bot.on('message:<type>')` is filtering middleware: internally
29438
+ // `on → filter(pred, handler) → branch(pred, handler, pass)`. When the filter
29439
+ // matches, grammy runs the leaf handler, which never calls `next()`, so the
29440
+ // chain STOPS — a specific `message:text`/`:photo`/… match above consumes the
29441
+ // update and this catch-all never sees it (specific handler always wins; no
29442
+ // "already handled" guard is needed, the ordering is the guarantee). When NO
29443
+ // specific `message:*` filter matches, grammy ^1.44 would otherwise SILENTLY
29444
+ // drop the update — no log, no ack, no history row (the zero-observability
29445
+ // failure class behind the 2026-07-16 dropped-message incident: message_id
29446
+ // 19090 allocated in the DM with no gateway trace at all). This handler
29447
+ // closes the class: every inbound `message` is either delivered as a turn or
29448
+ // explicitly logged (known-noise service messages → log-only, no turn — see
29449
+ // SERVICE_NOISE_KEYS), so nothing is silently dropped at this layer again.
29450
+ //
29451
+ // Access gating is NOT re-implemented here — routing through
29452
+ // handleInboundCoalesced (the exact path `message:text` uses) applies the
29453
+ // same gate()/allowFrom checks, and parseForwardOrigin runs inside it so
29454
+ // forwarded-message provenance is preserved.
29455
+ installUnhandledMessageCatchAll(
29456
+ bot,
29457
+ (ctx, text) => handleInboundCoalesced(ctx, text, undefined),
29458
+ line => process.stderr.write(line),
29459
+ )
29460
+
29306
29461
  // ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
29307
29462
  //
29308
29463
  // Bot-message reactions in the configured allowlist trigger a synthetic
@@ -30473,6 +30628,18 @@ void (async () => {
30473
30628
  void sweepableIds
30474
30629
  } catch {}
30475
30630
 
30631
+ // Rev 5: capture the restart-marker chat BEFORE the boot-card block
30632
+ // clears it, so the session-model re-hydration block below can send the
30633
+ // switch-confirmation ("✅ Now running X") to the chat that initiated the
30634
+ // switch. A non-`/model` restart leaves these unused.
30635
+ let modelSwitchMarkerChat: { chatId: string; threadId: number | null } | null = null
30636
+ // The DETERMINISTIC "this boot was a /model switch" signal: the reason
30637
+ // stampUserRestartReason() wrote to the clean-shutdown marker. Precise
30638
+ // (distinguishes a model-switch relaunch from any other restart) and works
30639
+ // even when launched === configured (a `/model default` / switch-to-default
30640
+ // apply-boot) — that is how N4 (confirm the default case too) is closed.
30641
+ let modelSwitchReason: string | null = null
30642
+
30476
30643
  // Boot card — always post on every gateway start with the restart reason.
30477
30644
  // Gated on session marker so a grammY poll-restart (same process, no
30478
30645
  // actual restart) does NOT re-post. See session-marker.ts for the
@@ -30534,6 +30701,18 @@ void (async () => {
30534
30701
  const ageMs = nowMs - marker.ts
30535
30702
  const ageSec = Math.max(1, Math.round(ageMs / 1000))
30536
30703
  process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 5 * 60_000}\n`)
30704
+ // Stash the chat for the model-switch confirmation (rev 5) before the
30705
+ // marker is cleared. Bounded to a recent marker (<5min) so a stale
30706
+ // marker can't misdirect a confirmation. Pair it with the /model
30707
+ // switch reason (from the clean-shutdown marker) so the confirmation
30708
+ // fires ONLY on a genuine /model relaunch (not a plain /restart that
30709
+ // also wrote a marker chat).
30710
+ if (ageMs < 5 * 60_000) {
30711
+ modelSwitchMarkerChat = { chatId: marker.chat_id, threadId: marker.thread_id }
30712
+ if (typeof cleanMarker?.reason === 'string' && cleanMarker.reason.startsWith('user: /model')) {
30713
+ modelSwitchReason = cleanMarker.reason
30714
+ }
30715
+ }
30537
30716
  clearRestartMarker()
30538
30717
  }
30539
30718
 
@@ -30570,7 +30749,21 @@ void (async () => {
30570
30749
  })
30571
30750
  }
30572
30751
 
30573
- if (target) {
30752
+ // N3 (dedup to ONE card per switch): a `/model` apply-boot already
30753
+ // sends the `✅ Now running X` confirmation from the re-hydration block
30754
+ // below (into the same chat). Suppress the generic restart boot card
30755
+ // here so a deliberate switch yields exactly one card — the
30756
+ // confirmation, which carries the operative "here's your model" info.
30757
+ // Only when we KNOW it was a /model switch (reason) AND we will send the
30758
+ // confirmation (marker chat captured); otherwise the boot card fires
30759
+ // normally. Version/quota remain available via /status.
30760
+ const suppressBootCardForModelSwitch =
30761
+ modelSwitchReason != null && modelSwitchMarkerChat != null
30762
+ if (target && suppressBootCardForModelSwitch) {
30763
+ process.stderr.write(
30764
+ `telegram gateway: boot: suppressing generic boot card — /model switch apply-boot (reason=${JSON.stringify(modelSwitchReason)}); the confirmation card replaces it\n`,
30765
+ )
30766
+ } else if (target) {
30574
30767
  const { chatId, threadId, ackMsgId } = target
30575
30768
  // First-write-wins dedupe: if bridge-reconnect already
30576
30769
  // claimed (its IPC client connected before this IIFE
@@ -30676,9 +30869,84 @@ void (async () => {
30676
30869
  // a phantom session override.
30677
30870
  return resolveMainModel(raw ?? undefined)
30678
30871
  })()
30679
- sessionModelSource.setOverride(
30680
- launched.length > 0 && launched !== configured ? launched : null,
30872
+ // `launched !== configured` is the DETERMINISTIC "a model switch
30873
+ // landed" signal (F1): the carrier is consume-once, so a launched
30874
+ // model that differs from the configured default only ever
30875
+ // happens on a genuine apply-boot. Seed the in-memory override
30876
+ // from it — this is the ONLY success reporting, sourced from the
30877
+ // real post-boot signal (`.active-session-model`), never from a
30878
+ // scraped pane or an optimistic record.
30879
+ const isApplyBoot = launched.length > 0 && launched !== configured
30880
+ sessionModelSource.setOverride(isApplyBoot ? launched : null)
30881
+ // Diagnosability (rev 5): the applied model is now always
30882
+ // greppable — `grep 'gw /model relaunch applied'`. F4 note:
30883
+ // `launched` is the REQUESTED token start.sh wrote before `exec
30884
+ // claude` (it is NOT a post-launch confirmation). If a shape-valid
30885
+ // but unknown Claude id was requested, `--fallback-model` may mask
30886
+ // it: claude serves a fallback while this records the requested
30887
+ // token. That divergence is NOT a persistent lie — the transcript's
30888
+ // `message.model` (noteTranscriptModel) reclaims the source from
30889
+ // this override on the first assistant line, correcting /status to
30890
+ // the model actually serving calls. The pre-first-assistant window
30891
+ // is the only optimistic window (G2), and it is bounded and
30892
+ // self-healing; it is documented, not silently asserted as success.
30893
+ process.stderr.write(
30894
+ `telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || '(none)'} configured=${configured} override=${isApplyBoot ? 'set' : 'cleared'}\n`,
30681
30895
  )
30896
+ // Switch-confirmation (F1 / PLAN §4 step 2): on a /model apply-boot
30897
+ // with a known initiating chat, send ONE confirmation built from the
30898
+ // ACTUAL launched model — never optimistic. Keyed on the DETERMINISTIC
30899
+ // /model switch reason (from the clean-shutdown marker), not on
30900
+ // `launched !== configured`, so it ALSO fires when a switch landed on
30901
+ // the configured default (`/model default`, or `/model <configured>`)
30902
+ // — N4. The generic boot card is suppressed for this boot (N3), so
30903
+ // this is the single card the operator sees for the switch.
30904
+ if (modelSwitchReason != null && modelSwitchMarkerChat) {
30905
+ const chat = modelSwitchMarkerChat
30906
+ // Derive the confirmation from the DETERMINISTIC post-boot
30907
+ // signals. A non-default switch that reverted to the configured
30908
+ // default (a wedged/consumed apply-boot — the silent-revert bug)
30909
+ // must WARN, not print a misleading green "✅ Now running
30910
+ // <default>" card. `applied` / `default` keep the honest green
30911
+ // card (N4: the default/revert case still confirms).
30912
+ const confirmation = classifyModelSwitchConfirmation({
30913
+ reason: modelSwitchReason,
30914
+ launched,
30915
+ configured,
30916
+ })
30917
+ // LOW-2 dedup: the config-default-changed / proxy-down revert
30918
+ // paths in start.sh write a TAILORED `.session-model-alert`
30919
+ // (relayed to operators below) that already explains why the
30920
+ // switch didn't apply and how to re-issue it. Suppress the
30921
+ // generic not-applied card when such an alert is present for
30922
+ // this boot so the operator isn't double-warned — the alert is
30923
+ // the more specific message. The not-applied card still fires
30924
+ // for the plain wedge/revert case (no alert on disk).
30925
+ const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
30926
+ if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
30927
+ process.stderr.write(
30928
+ `telegram gateway: gw /model relaunch applied — suppressing not-applied confirmation (a .session-model-alert is present and will be relayed) agent=${getMyAgentName()} target=${confirmation.target}\n`,
30929
+ )
30930
+ } else {
30931
+ const body =
30932
+ confirmation.kind === 'applied'
30933
+ ? `✅ Now running \`${confirmation.launched}\` — session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.`
30934
+ : confirmation.kind === 'not-applied'
30935
+ ? `⚠️ Your switch to \`${confirmation.target}\` didn't apply — the agent reverted to \`${confirmation.revertedTo}\` (the apply-boot didn't complete). Re-issue \`/model ${confirmation.target}\` to try again.`
30936
+ : `✅ Now running \`${confirmation.launched}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
30937
+ // allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
30938
+ void lockedBot.api
30939
+ .sendMessage(chat.chatId, body, {
30940
+ parse_mode: 'Markdown',
30941
+ ...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
30942
+ })
30943
+ .catch((err: unknown) =>
30944
+ process.stderr.write(
30945
+ `telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
30946
+ ),
30947
+ )
30948
+ }
30949
+ }
30682
30950
  } catch { /* leave override as-is on a bad read */ }
30683
30951
  }
30684
30952