switchroom 0.18.29 → 0.18.31

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 (40) hide show
  1. package/bin/handoff-briefing.sh +8 -2
  2. package/dist/agent-scheduler/index.js +111 -7
  3. package/dist/auth-broker/index.js +154 -16
  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 +2089 -1587
  9. package/dist/host-control/main.js +110 -13
  10. package/dist/vault/approvals/kernel-server.js +116 -13
  11. package/dist/vault/broker/server.js +314 -145
  12. package/package.json +3 -3
  13. package/profiles/_base/start.sh.hbs +172 -22
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +601 -104
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +280 -33
  18. package/telegram-plugin/gateway/model-command.ts +104 -0
  19. package/telegram-plugin/gateway/session-model-file.ts +40 -0
  20. package/telegram-plugin/gateway/turn-flush-suppression.ts +82 -0
  21. package/telegram-plugin/gateway/unhandled-message.ts +177 -0
  22. package/telegram-plugin/llm-error-present.ts +24 -0
  23. package/telegram-plugin/model-unavailable.ts +55 -0
  24. package/telegram-plugin/operator-events.ts +113 -0
  25. package/telegram-plugin/pending-user-notice.ts +88 -0
  26. package/telegram-plugin/shared/local-time.ts +43 -0
  27. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  28. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  29. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  30. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  31. package/telegram-plugin/tests/local-time.test.ts +68 -1
  32. package/telegram-plugin/tests/model-command.test.ts +133 -0
  33. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  34. package/telegram-plugin/tests/turn-flush-suppression.test.ts +90 -0
  35. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  36. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  37. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  38. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  39. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  40. package/vendor/hindsight-memory/tests/test_content.py +35 -0
@@ -17019,6 +17019,81 @@ function truncateDetailPreservingRequestId(detail, max) {
17019
17019
  return `${detail.slice(0, headBudget)}${suffix}`;
17020
17020
  }
17021
17021
 
17022
+ // quota-check.ts
17023
+ var init_quota_check = () => {};
17024
+
17025
+ // text-voice-scrub.ts
17026
+ var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
17027
+ var init_text_voice_scrub = __esm(() => {
17028
+ FENCE_PH = `${NULL}VS_FENCE`;
17029
+ INLINE_PH = `${NULL}VS_INLINE`;
17030
+ HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
17031
+ HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
17032
+ URL_PH = `${NULL}VS_URL`;
17033
+ });
17034
+
17035
+ // card-format.ts
17036
+ var init_card_format = __esm(() => {
17037
+ init_format();
17038
+ init_text_voice_scrub();
17039
+ });
17040
+
17041
+ // model-unavailable.ts
17042
+ function isTransientUpstreamSignal(text) {
17043
+ if (typeof text !== "string" || text.length === 0)
17044
+ return false;
17045
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17046
+ const lower = sample.toLowerCase();
17047
+ return transientUpstreamSignals.some((s) => lower.includes(s));
17048
+ }
17049
+ function isLitellmProxyLocal429(text) {
17050
+ if (typeof text !== "string" || text.length === 0)
17051
+ return false;
17052
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17053
+ const lower = sample.toLowerCase();
17054
+ if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17055
+ return true;
17056
+ return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17057
+ }
17058
+ function isLitellmProxyAuthMisconfig(text) {
17059
+ if (typeof text !== "string" || text.length === 0)
17060
+ return false;
17061
+ const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17062
+ const lower = sample.toLowerCase();
17063
+ if (lower.includes("x-api-key header is required"))
17064
+ return true;
17065
+ const isAuthErr = lower.includes("authentication_error") || lower.includes("authenticationerror");
17066
+ if (!isAuthErr)
17067
+ return false;
17068
+ return lower.includes("fallback") && lower.includes("x-api-key");
17069
+ }
17070
+ var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17071
+ var init_model_unavailable = __esm(() => {
17072
+ init_quota_check();
17073
+ init_card_format();
17074
+ transientUpstreamSignals = [
17075
+ "not your usage limit",
17076
+ "not your account",
17077
+ "not your account's",
17078
+ "temporarily limiting requests",
17079
+ "temporarily rate",
17080
+ "server is temporarily",
17081
+ "would exceed your account\u2019s rate limit",
17082
+ "would exceed your account's rate limit"
17083
+ ];
17084
+ litellmProxyLocal429Signals = [
17085
+ "deployment over user-defined ratelimit",
17086
+ "model rate limit exceeded. tpm limit",
17087
+ "model rate limit exceeded. rpm limit",
17088
+ "deployment over defined rpm limit",
17089
+ "no deployments available for selected model",
17090
+ "litellm rate limit handler",
17091
+ "crossed tpm / rpm",
17092
+ "max parallel request limit reached"
17093
+ ];
17094
+ litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17095
+ });
17096
+
17022
17097
  // operator-events.ts
17023
17098
  function classifyClaudeError(raw) {
17024
17099
  try {
@@ -17036,6 +17111,12 @@ function classifyInner(raw) {
17036
17111
  const message = extractString(obj, "message") ?? extractString(getNestedObj(obj, "error"), "message") ?? (typeof raw === "string" ? raw : "") ?? "";
17037
17112
  const status = extractNumber(obj, "status") ?? extractNumber(obj, "statusCode") ?? extractNumber(obj, "status_code") ?? null;
17038
17113
  const sdkCode = extractString(obj, "error_code") ?? "";
17114
+ if (isLitellmProxyAuthMisconfig(`${errorType}
17115
+ ${errorCode}
17116
+ ${sdkCode}
17117
+ ${message}`)) {
17118
+ return "proxy-misconfig";
17119
+ }
17039
17120
  if (errorType === "authentication_error" || errorCode === "authentication_error" || sdkCode === "authentication_error" || message.toLowerCase().includes("authentication_error")) {
17040
17121
  const msg = message.toLowerCase();
17041
17122
  if (msg.includes("expired") || msg.includes("refresh")) {
@@ -17081,74 +17162,18 @@ function getNestedObj(obj, key) {
17081
17162
  const v = obj[key];
17082
17163
  return typeof v === "object" && v != null ? v : {};
17083
17164
  }
17084
- var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap;
17165
+ var DEFAULT_OPERATOR_EVENT_COOLDOWN_MS, cooldownMap, OPERATOR_ACTIONABLE_KINDS;
17085
17166
  var init_operator_events = __esm(() => {
17086
17167
  init_format();
17168
+ init_model_unavailable();
17087
17169
  DEFAULT_OPERATOR_EVENT_COOLDOWN_MS = 5 * 60000;
17088
17170
  cooldownMap = new Map;
17089
- });
17090
-
17091
- // quota-check.ts
17092
- var init_quota_check = () => {};
17093
-
17094
- // text-voice-scrub.ts
17095
- var NULL = "\x00", FENCE_PH, INLINE_PH, HTML_CODE_PH, HTML_PRE_PH, URL_PH;
17096
- var init_text_voice_scrub = __esm(() => {
17097
- FENCE_PH = `${NULL}VS_FENCE`;
17098
- INLINE_PH = `${NULL}VS_INLINE`;
17099
- HTML_CODE_PH = `${NULL}VS_HTMLCODE`;
17100
- HTML_PRE_PH = `${NULL}VS_HTMLPRE`;
17101
- URL_PH = `${NULL}VS_URL`;
17102
- });
17103
-
17104
- // card-format.ts
17105
- var init_card_format = __esm(() => {
17106
- init_format();
17107
- init_text_voice_scrub();
17108
- });
17109
-
17110
- // model-unavailable.ts
17111
- function isTransientUpstreamSignal(text) {
17112
- if (typeof text !== "string" || text.length === 0)
17113
- return false;
17114
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17115
- const lower = sample.toLowerCase();
17116
- return transientUpstreamSignals.some((s) => lower.includes(s));
17117
- }
17118
- function isLitellmProxyLocal429(text) {
17119
- if (typeof text !== "string" || text.length === 0)
17120
- return false;
17121
- const sample = text.length > 16384 ? text.slice(0, 16384) : text;
17122
- const lower = sample.toLowerCase();
17123
- if (litellmProxyLocal429Signals.some((s) => lower.includes(s)))
17124
- return true;
17125
- return litellmV3LimiterSignalPair.every((s) => lower.includes(s));
17126
- }
17127
- var transientUpstreamSignals, litellmProxyLocal429Signals, litellmV3LimiterSignalPair;
17128
- var init_model_unavailable = __esm(() => {
17129
- init_quota_check();
17130
- init_card_format();
17131
- transientUpstreamSignals = [
17132
- "not your usage limit",
17133
- "not your account",
17134
- "not your account's",
17135
- "temporarily limiting requests",
17136
- "temporarily rate",
17137
- "server is temporarily",
17138
- "would exceed your account\u2019s rate limit",
17139
- "would exceed your account's rate limit"
17140
- ];
17141
- litellmProxyLocal429Signals = [
17142
- "deployment over user-defined ratelimit",
17143
- "model rate limit exceeded. tpm limit",
17144
- "model rate limit exceeded. rpm limit",
17145
- "deployment over defined rpm limit",
17146
- "no deployments available for selected model",
17147
- "litellm rate limit handler",
17148
- "crossed tpm / rpm",
17149
- "max parallel request limit reached"
17150
- ];
17151
- litellmV3LimiterSignalPair = ["rate limit exceeded for ", "limit type:"];
17171
+ OPERATOR_ACTIONABLE_KINDS = new Set([
17172
+ "credentials-expired",
17173
+ "credentials-invalid",
17174
+ "credit-exhausted",
17175
+ "proxy-misconfig"
17176
+ ]);
17152
17177
  });
17153
17178
 
17154
17179
  // tool-label-sidecar.ts
@@ -102,7 +102,11 @@ import {
102
102
  forwardOriginDateIso,
103
103
  type ForwardOriginInfo,
104
104
  } from './forward-origin.js'
105
- import { fmtLocalStamp, resolveEnvTimezone } from '../shared/local-time.js'
105
+ import {
106
+ installUpdateTap,
107
+ installUnhandledMessageCatchAll,
108
+ } from './unhandled-message.js'
109
+ import { fmtLocalStamp, resolveEnvTimezone, renderLogTimestampsLocal } from '../shared/local-time.js'
106
110
  import { StatusReactionController } from '../status-reactions.js'
107
111
  import { DeferredDoneReactions } from '../reaction-defer.js'
108
112
  import { createWorkerActivityFeed, isWorkerActivityFeedEnabled } from '../worker-activity-feed.js'
@@ -324,9 +328,12 @@ import { autoClassifyMidTurnInbound } from './auto-classify-mid-turn.js'
324
328
  import {
325
329
  renderOperatorEvent,
326
330
  shouldEmitOperatorEvent,
331
+ decideOperatorEventAudience,
332
+ renderUserFacingFailureNotice,
327
333
  type OperatorEvent,
328
334
  type OperatorEventKind,
329
335
  } from '../operator-events.js'
336
+ import { pendingUserNoticeGate } from '../pending-user-notice.js'
330
337
  import { recordOperatorEvent } from '../operator-events-history.js'
331
338
  import {
332
339
  parseLlmError,
@@ -472,6 +479,7 @@ import {
472
479
  resolveStaleAwareBusy,
473
480
  modelCommandReceiptLine,
474
481
  handleModelCommand,
482
+ classifyModelSwitchConfirmation,
475
483
  buildModelMenu,
476
484
  handleModelMenuCallback,
477
485
  isValidModelArg,
@@ -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,
@@ -1290,6 +1299,14 @@ const AGENT_ADMIN = process.env.SWITCHROOM_AGENT_ADMIN === 'true'
1290
1299
  const bot = new Bot(TOKEN)
1291
1300
  installTgPostLogger(bot)
1292
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
+
1293
1310
  // ─── getUpdates heartbeat ─────────────────────────────────────────────────
1294
1311
  // Tracks the last time getUpdates completed (success OR error). Used by
1295
1312
  // the poll health check as a secondary stall signal: if getMe succeeds but
@@ -2382,6 +2399,13 @@ async function deliverAnswer(args: {
2382
2399
  text: string
2383
2400
  turnId: string
2384
2401
  cardMessageId: number | null
2402
+ /** S4 (fable red-team 2026-07-17) — the inbound message this turn answers
2403
+ * (`turn.sourceMessageId`). When present, the FIRST chunk is sent as a
2404
+ * native quote-reply so the flushed answer anchors to the user's question,
2405
+ * matching `executeReply`'s default. `allow_sending_without_reply` keeps
2406
+ * the send alive if the user deleted their message. Null for synthesized
2407
+ * turns (cron/handback) — those send bare, as before. */
2408
+ replyToMessageId: number | null
2385
2409
  }): Promise<{ sentIds: number[]; chunkCount: number; delivered: boolean; exhausted: boolean }> {
2386
2410
  const { chatId, turnId } = args
2387
2411
  // Inject visible blank-line spacers into `\n\n` gaps, then split — exactly as
@@ -2430,8 +2454,17 @@ async function deliverAnswer(args: {
2430
2454
  // landed message id(s) (a length-resplit chunk may land >1); throws on an
2431
2455
  // unrecoverable send failure so the retry orchestrator can resume.
2432
2456
  let liveThreadId = args.threadId
2433
- const sendChunk = async (_chunkIndex: number, text: string): Promise<number[]> => {
2457
+ const sendChunk = async (chunkIndex: number, text: string): Promise<number[]> => {
2434
2458
  const chunkIds: number[] = []
2459
+ // S4 — quote-anchor the FIRST chunk only (chunkIndex is the OVERALL chunk
2460
+ // index across the retry orchestrator; each sendReplyChunks call here
2461
+ // carries exactly one chunk, so the inner index is always 0). Mirrors
2462
+ // executeReply's first-chunk-only default. The ledger resumes retries at
2463
+ // the first UNSENT chunk, so chunk 0 keeps its anchor across retries.
2464
+ const anchor =
2465
+ chunkIndex === 0 && args.replyToMessageId != null
2466
+ ? { reply_parameters: { message_id: args.replyToMessageId, allow_sending_without_reply: true } }
2467
+ : {}
2435
2468
  const res = await sendReplyChunks(deps, {
2436
2469
  chatId,
2437
2470
  chunks: [text],
@@ -2441,6 +2474,7 @@ async function deliverAnswer(args: {
2441
2474
  previewMessageId: null,
2442
2475
  sentIds: chunkIds,
2443
2476
  buildSendOpts: (_i, _isLast, tid) => ({
2477
+ ...anchor,
2444
2478
  ...(tid != null ? { message_thread_id: tid } : {}),
2445
2479
  link_preview_options: { is_disabled: true },
2446
2480
  }),
@@ -5341,6 +5375,13 @@ function endCurrentTurnAtomic(
5341
5375
  // wedging forever. No-op when this turn delivered, when nothing is
5342
5376
  // buffered, or when the serialize feature is off.
5343
5377
  armNoReplyDrainTimer(turn)
5378
+ // #3293 finding 1 — resolve any deferred non-operator failure notice against
5379
+ // this turn's outcome: replied → the turn recovered from the error line, the
5380
+ // gate drops the notice; reply-less → the turn genuinely died, the notice is
5381
+ // sent now. replyCalled covers the short-answer/#2624 shape where
5382
+ // finalAnswerDelivered stays false despite an explicit reply. No-op when
5383
+ // nothing is pending (the overwhelmingly common path).
5384
+ flushPendingUserFailureNotices(turn.finalAnswerDelivered || turn.replyCalled)
5344
5385
  return turnEndedAt
5345
5386
  }
5346
5387
 
@@ -8544,7 +8585,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8544
8585
  `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
8545
8586
  (opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
8546
8587
  )
8547
- for (const chat_id of access.allowFrom) {
8588
+ // Ken's deterministic error-surfacing policy: an OPERATOR-ACTIONABLE fault
8589
+ // (credentials / credit / proxy-misconfig) must not reach non-operator users
8590
+ // as a raw or misleading card they can't act on. Split the audience — the
8591
+ // operator (allowlist head) gets the full card; every other allowlist chat
8592
+ // gets, at most, a brief plain-language "it's on our side" notice. Non-
8593
+ // actionable kinds keep their existing broadcast (all chats are operatorChats).
8594
+ const { operatorChats, userNoticeChats } = decideOperatorEventAudience(
8595
+ kind,
8596
+ access.allowFrom,
8597
+ access.allowFrom[0],
8598
+ )
8599
+ if (userNoticeChats.length > 0) {
8600
+ process.stderr.write(
8601
+ `telegram gateway: operator-event operator-only routing agent=${agent} kind=${kind} operatorChats=${operatorChats.length} userNoticeChats=${userNoticeChats.length}\n`,
8602
+ )
8603
+ }
8604
+
8605
+ for (const chat_id of operatorChats) {
8548
8606
  // The resolved topic is valid ONLY in the agent's supergroup — attaching
8549
8607
  // it to an operator DM recipient yields 400 "message thread not found" and
8550
8608
  // the event silently fails to deliver (the marko #2096 class). Guard it:
@@ -8581,6 +8639,63 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8581
8639
  )
8582
8640
  })
8583
8641
  }
8642
+
8643
+ // Non-operator users: only the plain-language failure notice, and ONLY when
8644
+ // the turn genuinely dies (userNoticeChats is empty for every non-actionable
8645
+ // kind — see decideOperatorEventAudience). #3293 review finding 1: an error
8646
+ // line is NOT proof of turn failure — the LiteLLM fallback can 401 while a
8647
+ // retry / another deployment still serves the turn, and sending "couldn't
8648
+ // complete that" for a turn that completed is a false failure report. So the
8649
+ // notice is never sent here: it is SCHEDULED on the pendingUserNoticeGate and
8650
+ // resolved at the turn-end funnel (endCurrentTurnAtomic) — dropped when the
8651
+ // turn delivered a reply (recovered), sent when it ended reply-less (died).
8652
+ // Un-resolved notices expire after PENDING_USER_NOTICE_TTL_MS (bias to
8653
+ // silence over a false failure claim). Operator cards above stay immediate.
8654
+ if (userNoticeChats.length > 0) {
8655
+ pendingUserNoticeGate.schedule({
8656
+ chatIds: userNoticeChats,
8657
+ text: renderUserFacingFailureNotice(),
8658
+ agent,
8659
+ kind,
8660
+ atMs: Date.now(),
8661
+ })
8662
+ process.stderr.write(
8663
+ `telegram gateway: operator-event user-notice deferred to turn-end agent=${agent} kind=${kind} chats=${userNoticeChats.length}\n`,
8664
+ )
8665
+ }
8666
+ }
8667
+
8668
+ /**
8669
+ * Turn-end resolution of deferred user failure notices (#3293 finding 1).
8670
+ * Called from `endCurrentTurnAtomic` — the ONE funnel every turn-end path
8671
+ * passes through. `turnDeliveredReply` is `finalAnswerDelivered || replyCalled`
8672
+ * (the model explicitly replied → the turn recovered → notices are dropped by
8673
+ * the gate). Only a reply-less turn end flushes the pending notices to the
8674
+ * non-operator chats, so the user notice fires IFF the turn genuinely died.
8675
+ */
8676
+ function flushPendingUserFailureNotices(turnDeliveredReply: boolean): void {
8677
+ const notices = pendingUserNoticeGate.resolveTurnEnd(turnDeliveredReply)
8678
+ if (notices.length === 0) return
8679
+ const noticeTopic = resolveAgentOutboundTopic({ kind: 'compact-watchdog' })
8680
+ const noticeSupergroup = resolveAgentSupergroupChatId()
8681
+ for (const notice of notices) {
8682
+ process.stderr.write(
8683
+ `telegram gateway: user-notice flush (turn died reply-less) agent=${notice.agent} kind=${notice.kind} chats=${notice.chatIds.length}\n`,
8684
+ )
8685
+ for (const chat_id of notice.chatIds) {
8686
+ const thread = topicForRecipient({ recipientChatId: chat_id, resolvedTopic: noticeTopic, supergroupChatId: noticeSupergroup })
8687
+ const opts = {
8688
+ ...(thread != null ? { message_thread_id: thread } : {}),
8689
+ }
8690
+ // allow-raw-bot-api: deferred user-notice flush loop; topic-aware opts
8691
+ void bot.api.sendRichMessage(chat_id, richMessage(notice.text), opts as never)
8692
+ .catch(e => {
8693
+ process.stderr.write(
8694
+ `telegram gateway: user-notice send to ${chat_id} failed agent=${notice.agent} kind=${notice.kind}: ${e}\n`,
8695
+ )
8696
+ })
8697
+ }
8698
+ }
8584
8699
  }
8585
8700
 
8586
8701
  /**
@@ -9678,6 +9793,18 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9678
9793
  process.stderr.write(
9679
9794
  `telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
9680
9795
  )
9796
+ // #3284 — this boot ACQUIRED the boot lock, so it is the surviving healthy
9797
+ // session. Consume the session-model carrier now (delete the carrier + the
9798
+ // bounded-retry attempt counter). start.sh no longer deletes the carrier
9799
+ // before apply: an apply-boot that wedged before reaching this point leaves
9800
+ // the carrier in place so the retry boot RE-APPLIES the intended model
9801
+ // instead of silently reverting to the configured default (the marko/klanker
9802
+ // boot.lock_stale_recovered_boot_mismatch revert). Best-effort, gated on
9803
+ // lock ownership so a LOSING double-boot never clears the winner's carrier.
9804
+ {
9805
+ const carrierAgentDir = resolveAgentDirFromEnv()
9806
+ if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9807
+ }
9681
9808
  // We WON the startup mutex — this gateway is the sole live owner of the
9682
9809
  // shared per-agent status-pin store, so it's now safe to clean up orphaned
9683
9810
  // pins from a prior (dead) session. Gated here (not at import time) so a
@@ -9701,6 +9828,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9701
9828
  // probe + 409-retry loop is still the liveness guard on this path. A
9702
9829
  // successful writePidFile here means no live holder was detected, so
9703
9830
  // running orphan cleanup is consistent with the pre-mutex behaviour.
9831
+ // #3284: same healthy-boot carrier consume as the mutex-acquired path —
9832
+ // a successful writePidFile means no live holder was detected, so this is
9833
+ // the surviving session.
9834
+ {
9835
+ const carrierAgentDir = resolveAgentDirFromEnv()
9836
+ if (carrierAgentDir != null) consumeSessionModelCarrierOnHealthyBoot(carrierAgentDir)
9837
+ }
9704
9838
  // #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
9705
9839
  void runBootPinCleanupAndDmSweep()
9706
9840
  } catch (writeErr) {
@@ -17980,6 +18114,16 @@ function handleSessionEvent(ev: SessionEvent): void {
17980
18114
  // — read `turn` once, don't re-read currentTurn after any await.
17981
18115
  const turn = currentTurn
17982
18116
  if (turn == null) return
18117
+ // S2 fix (fable red-team 2026-07-17) — a thinking block means the model
18118
+ // is still working, not quiescent. Without this, "prose → >1s thinking
18119
+ // pause → trailing NO_REPLY" let the answer-ready quiescence timer fire
18120
+ // mid-pause and deliver a turn the model was about to mark silent
18121
+ // (#2053 in miniature). Re-arm (not just clear): `reset()` re-verifies
18122
+ // via `decideTurnFlush` and pushes the debounce out by a fresh window,
18123
+ // so the trailing sentinel gets to land before any fire; if no further
18124
+ // text arrives, the flush still fires one window after the LAST
18125
+ // thinking event — the fast path is deferred, never lost.
18126
+ resetAnswerReadyFlushTimeout()
17983
18127
  const ctrl = activeStatusReactions.get(statusKey(turn.sessionChatId, turn.sessionThreadId))
17984
18128
  if (ctrl) ctrl.setThinking()
17985
18129
  return
@@ -19091,10 +19235,28 @@ function handleSessionEvent(ev: SessionEvent): void {
19091
19235
  await new Promise<void>(resolve => setTimeout(resolve, 500))
19092
19236
  if (HISTORY_ENABLED) {
19093
19237
  try {
19094
- const { getRecentOutboundCount } = await import('../history.js')
19095
- const recentCount = getRecentOutboundCount(backstopChatId, 2)
19096
- if (recentCount > 0) {
19097
- process.stderr.write(`telegram gateway: turn-flush suppressed — reply tool sent ${recentCount} message(s) within 2s\n`)
19238
+ // S1 fix (fable red-team 2026-07-17) — the old predicate here,
19239
+ // `getRecentOutboundCount(chatId, 2) > 0`, counted ANY assistant
19240
+ // row in the WHOLE chat: a worker `progress_update`, a command
19241
+ // ack / restart notice, or a reply in a DIFFERENT forum topic all
19242
+ // suppressed the flush and (worse) CLOSED the obligation below —
19243
+ // silently dropping the user's real answer. The scoped predicate
19244
+ // (same thread, length ≥ min(answerLength, 200)) lives in
19245
+ // `turn-flush-suppression.ts`; `hasOutboundDeliveredSince` is the
19246
+ // durable oracle whose thread/length semantics history tests pin.
19247
+ const { hasOutboundDeliveredSince } = await import('../history.js')
19248
+ const { shouldSuppressTurnFlush } = await import('./turn-flush-suppression.js')
19249
+ const suppress = shouldSuppressTurnFlush(
19250
+ { hasSubstantiveOutbound: hasOutboundDeliveredSince },
19251
+ {
19252
+ chatId: backstopChatId,
19253
+ threadId: backstopThreadId ?? null,
19254
+ answerLength: capturedText.length,
19255
+ nowMs: Date.now(),
19256
+ },
19257
+ )
19258
+ if (suppress) {
19259
+ process.stderr.write(`telegram gateway: turn-flush suppressed — a substantive same-thread outbound landed within 2s\n`)
19098
19260
  // Do NOT finalize the status reaction here. As of #1713
19099
19261
  // the reaction is only finalized by the `turn_end` IPC
19100
19262
  // handler — mid-turn delivery proofs (local history,
@@ -19106,19 +19268,23 @@ function handleSessionEvent(ev: SessionEvent): void {
19106
19268
  // here re-fired on an already-cleared key WITHOUT `endingTurn`,
19107
19269
  // emitting an inconsistent shadow trace. Removed.
19108
19270
  //
19109
- // PR B — the reply tool already delivered this turn's answer, so
19110
- // the flush was legitimately suppressed. Emit the deferred record
19111
- // as 'suppressed' (→ `complete`, since the reply path set
19112
- // finalAnswerDelivered). Not a failure.
19271
+ // PR B — a substantive same-thread outbound just delivered this
19272
+ // turn's answer, so the flush was legitimately suppressed. Emit
19273
+ // the deferred record as 'suppressed'. Not a failure.
19113
19274
  if (backstopTurnEndedAt != null) {
19114
19275
  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)
19276
+ // S1 fix — do NOT close the obligation here. When the recent
19277
+ // outbound is genuinely this turn's answer (a raced reply /
19278
+ // stream materialization), the reply path closes its own
19279
+ // obligation idempotently; when the suppression is a false
19280
+ // positive (a long same-thread non-answer inside the 2s
19281
+ // window — the residual the scoped predicate can't
19282
+ // discriminate), closing here made the drop PERMANENT.
19283
+ // Leaving it open lets the obligation sweep arbitrate: it
19284
+ // stands down silently if a substantive outbound answered
19285
+ // the user, and re-presents otherwise. `noteTurnEnded` arms
19286
+ // the liveness floor exactly as the send-failed path does.
19287
+ if (OBLIGATION_LEDGER_ENABLED) obligationLedger.noteTurnEnded(turn.turnId, Date.now())
19122
19288
  emitTurnRecord(turn, backstopTurnEndedAt)
19123
19289
  }
19124
19290
  return
@@ -19166,6 +19332,9 @@ function handleSessionEvent(ev: SessionEvent): void {
19166
19332
  text: capturedText,
19167
19333
  turnId: turn.turnId,
19168
19334
  cardMessageId: backstopCardMessageId,
19335
+ // S4 — anchor the flushed answer to the inbound it answers
19336
+ // (null for synthesized turns, which send bare as before).
19337
+ replyToMessageId: turn.sourceMessageId,
19169
19338
  })
19170
19339
  sentIds = delivery.sentIds
19171
19340
  chunkCount = delivery.chunkCount
@@ -22890,9 +23059,14 @@ async function runSwitchroomCommand(
22890
23059
  // commands) preserve in-place reply behavior. /logs /audit
22891
23060
  // /upgradestatus /memory pass 'heavy' to route to admin alias.
22892
23061
  classification: 'query' | 'mutation' | 'heavy' = 'query',
23062
+ // Optional DISPLAY-ONLY transform applied to the stripped command output
23063
+ // before formatting (never mutates the underlying stored logs). /logs uses
23064
+ // this to render leading UTC ISO docker-log timestamps in local am/pm.
23065
+ transformOutput?: (raw: string) => string,
22893
23066
  ): Promise<void> {
22894
23067
  try {
22895
- const output = stripAnsi(switchroomExec(args))
23068
+ const stripped = stripAnsi(switchroomExec(args))
23069
+ const output = transformOutput ? transformOutput(stripped) : stripped
22896
23070
  const formatted = formatSwitchroomOutput(output)
22897
23071
  if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
22898
23072
  else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
@@ -26961,7 +27135,23 @@ bot.command('logs', async ctx => {
26961
27135
  const lines = linesArg ? parseInt(linesArg, 10) : 20
26962
27136
  const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
26963
27137
  // PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
26964
- await runSwitchroomCommand(ctx, ['agent', 'logs', name, '--lines', String(lineCount)], `logs ${name}`, 'heavy')
27138
+ // #tz-fix audit (MEDIUM gap 2): `--timestamps` makes docker prefix every
27139
+ // line with its own UTC ISO-8601-Z stamp — the only deterministic per-line
27140
+ // timestamp (raw app lines often carry no stamp, or a local-zone one that
27141
+ // must NOT be re-shifted). We then render that stamp in local am/pm at
27142
+ // DISPLAY time so `/logs` doesn't surface UTC (competing with the
27143
+ // local-time hint). The zone is the GATEWAY/operator zone
27144
+ // (resolveEnvTimezone of this process), not the target agent's zone —
27145
+ // intended: /logs is an operator surface. Stored logs are untouched —
27146
+ // the transform runs only on the text sent to chat.
27147
+ const tz = resolveEnvTimezone()
27148
+ await runSwitchroomCommand(
27149
+ ctx,
27150
+ ['agent', 'logs', name, '--lines', String(lineCount), '--timestamps'],
27151
+ `logs ${name}`,
27152
+ 'heavy',
27153
+ (raw) => renderLogTimestampsLocal(raw, tz),
27154
+ )
26965
27155
  })
26966
27156
 
26967
27157
  bot.command('memory', async ctx => {
@@ -29292,6 +29482,34 @@ bot.on('message:pinned_message', async ctx => {
29292
29482
  }
29293
29483
  })
29294
29484
 
29485
+ // ─── Terminal catch-all for unhandled message content types (#3300) ───────
29486
+ //
29487
+ // MUST stay registered LAST among the `message`/`message:*` handlers.
29488
+ //
29489
+ // grammy's `bot.on('message:<type>')` is filtering middleware: internally
29490
+ // `on → filter(pred, handler) → branch(pred, handler, pass)`. When the filter
29491
+ // matches, grammy runs the leaf handler, which never calls `next()`, so the
29492
+ // chain STOPS — a specific `message:text`/`:photo`/… match above consumes the
29493
+ // update and this catch-all never sees it (specific handler always wins; no
29494
+ // "already handled" guard is needed, the ordering is the guarantee). When NO
29495
+ // specific `message:*` filter matches, grammy ^1.44 would otherwise SILENTLY
29496
+ // drop the update — no log, no ack, no history row (the zero-observability
29497
+ // failure class behind the 2026-07-16 dropped-message incident: message_id
29498
+ // 19090 allocated in the DM with no gateway trace at all). This handler
29499
+ // closes the class: every inbound `message` is either delivered as a turn or
29500
+ // explicitly logged (known-noise service messages → log-only, no turn — see
29501
+ // SERVICE_NOISE_KEYS), so nothing is silently dropped at this layer again.
29502
+ //
29503
+ // Access gating is NOT re-implemented here — routing through
29504
+ // handleInboundCoalesced (the exact path `message:text` uses) applies the
29505
+ // same gate()/allowFrom checks, and parseForwardOrigin runs inside it so
29506
+ // forwarded-message provenance is preserved.
29507
+ installUnhandledMessageCatchAll(
29508
+ bot,
29509
+ (ctx, text) => handleInboundCoalesced(ctx, text, undefined),
29510
+ line => process.stderr.write(line),
29511
+ )
29512
+
29295
29513
  // ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
29296
29514
  //
29297
29515
  // Bot-message reactions in the configured allowlist trigger a synthetic
@@ -30737,20 +30955,49 @@ void (async () => {
30737
30955
  // this is the single card the operator sees for the switch.
30738
30956
  if (modelSwitchReason != null && modelSwitchMarkerChat) {
30739
30957
  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
- ),
30958
+ // Derive the confirmation from the DETERMINISTIC post-boot
30959
+ // signals. A non-default switch that reverted to the configured
30960
+ // default (a wedged/consumed apply-boot — the silent-revert bug)
30961
+ // must WARN, not print a misleading green "✅ Now running
30962
+ // <default>" card. `applied` / `default` keep the honest green
30963
+ // card (N4: the default/revert case still confirms).
30964
+ const confirmation = classifyModelSwitchConfirmation({
30965
+ reason: modelSwitchReason,
30966
+ launched,
30967
+ configured,
30968
+ })
30969
+ // LOW-2 dedup: the config-default-changed / proxy-down revert
30970
+ // paths in start.sh write a TAILORED `.session-model-alert`
30971
+ // (relayed to operators below) that already explains why the
30972
+ // switch didn't apply and how to re-issue it. Suppress the
30973
+ // generic not-applied card when such an alert is present for
30974
+ // this boot so the operator isn't double-warned — the alert is
30975
+ // the more specific message. The not-applied card still fires
30976
+ // for the plain wedge/revert case (no alert on disk).
30977
+ const hasSessionModelAlert = existsSync(join(smAgentDir, '.session-model-alert'))
30978
+ if (confirmation.kind === 'not-applied' && hasSessionModelAlert) {
30979
+ process.stderr.write(
30980
+ `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`,
30753
30981
  )
30982
+ } else {
30983
+ const body =
30984
+ confirmation.kind === 'applied'
30985
+ ? `✅ 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.`
30986
+ : confirmation.kind === 'not-applied'
30987
+ ? `⚠️ 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.`
30988
+ : `✅ Now running \`${confirmation.launched}\` (the configured default) — fresh session; memory and the handoff briefing carry the context.`
30989
+ // allow-raw-bot-api: one-shot boot confirmation, same shape as the session-model alert relay below
30990
+ void lockedBot.api
30991
+ .sendMessage(chat.chatId, body, {
30992
+ parse_mode: 'Markdown',
30993
+ ...(chat.threadId != null ? { message_thread_id: chat.threadId } : {}),
30994
+ })
30995
+ .catch((err: unknown) =>
30996
+ process.stderr.write(
30997
+ `telegram gateway: model-switch confirmation send failed: ${(err as Error)?.message ?? String(err)}\n`,
30998
+ ),
30999
+ )
31000
+ }
30754
31001
  }
30755
31002
  } catch { /* leave override as-is on a bad read */ }
30756
31003
  }