switchroom 0.18.29 → 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 (38) 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 +2074 -1585
  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 +73 -20
  14. package/telegram-plugin/dist/bridge/bridge.js +71 -47
  15. package/telegram-plugin/dist/gateway/gateway.js +560 -96
  16. package/telegram-plugin/dist/server.js +89 -64
  17. package/telegram-plugin/gateway/gateway.ts +212 -17
  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/unhandled-message.ts +177 -0
  21. package/telegram-plugin/llm-error-present.ts +24 -0
  22. package/telegram-plugin/model-unavailable.ts +55 -0
  23. package/telegram-plugin/operator-events.ts +113 -0
  24. package/telegram-plugin/pending-user-notice.ts +88 -0
  25. package/telegram-plugin/shared/local-time.ts +43 -0
  26. package/telegram-plugin/tests/catch-all-forwarded-history.test.ts +103 -0
  27. package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +264 -0
  28. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +26 -2
  29. package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +278 -0
  30. package/telegram-plugin/tests/local-time.test.ts +68 -1
  31. package/telegram-plugin/tests/model-command.test.ts +133 -0
  32. package/telegram-plugin/tests/session-model-file.test.ts +23 -0
  33. package/vendor/hindsight-memory/scripts/backfill_transcripts.py +399 -2
  34. package/vendor/hindsight-memory/scripts/lib/client.py +47 -0
  35. package/vendor/hindsight-memory/scripts/lib/content.py +53 -1
  36. package/vendor/hindsight-memory/scripts/lib/turnlog.py +450 -0
  37. package/vendor/hindsight-memory/scripts/tests/test_backfill_from_logs.py +467 -0
  38. 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
@@ -5341,6 +5358,13 @@ function endCurrentTurnAtomic(
5341
5358
  // wedging forever. No-op when this turn delivered, when nothing is
5342
5359
  // buffered, or when the serialize feature is off.
5343
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)
5344
5368
  return turnEndedAt
5345
5369
  }
5346
5370
 
@@ -8544,7 +8568,24 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8544
8568
  `telegram gateway: operator-event posting agent=${agent} kind=${kind} to ${access.allowFrom.length} chat(s)` +
8545
8569
  (opEventTopic != null ? ` topic=${opEventTopic}` : '') + '\n',
8546
8570
  )
8547
- 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) {
8548
8589
  // The resolved topic is valid ONLY in the agent's supergroup — attaching
8549
8590
  // it to an operator DM recipient yields 400 "message thread not found" and
8550
8591
  // the event silently fails to deliver (the marko #2096 class). Guard it:
@@ -8581,6 +8622,63 @@ function emitGatewayOperatorEvent(event: OperatorEvent): void {
8581
8622
  )
8582
8623
  })
8583
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
+ }
8584
8682
  }
8585
8683
 
8586
8684
  /**
@@ -9678,6 +9776,18 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9678
9776
  process.stderr.write(
9679
9777
  `telegram gateway: wrote PID file ${GATEWAY_PID_PATH} pid=${process.pid} startedAt=${GATEWAY_STARTED_AT_MS}\n`,
9680
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
+ }
9681
9791
  // We WON the startup mutex — this gateway is the sole live owner of the
9682
9792
  // shared per-agent status-pin store, so it's now safe to clean up orphaned
9683
9793
  // pins from a prior (dead) session. Gated here (not at import time) so a
@@ -9701,6 +9811,13 @@ function ensureIssuesCard(chatId: string, threadId: number | undefined): void {
9701
9811
  // probe + 409-retry loop is still the liveness guard on this path. A
9702
9812
  // successful writePidFile here means no live holder was detected, so
9703
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
+ }
9704
9821
  // #3026: same sequenced cleanup + DM stale-pin sweep as the mutex path.
9705
9822
  void runBootPinCleanupAndDmSweep()
9706
9823
  } catch (writeErr) {
@@ -22890,9 +23007,14 @@ async function runSwitchroomCommand(
22890
23007
  // commands) preserve in-place reply behavior. /logs /audit
22891
23008
  // /upgradestatus /memory pass 'heavy' to route to admin alias.
22892
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,
22893
23014
  ): Promise<void> {
22894
23015
  try {
22895
- const output = stripAnsi(switchroomExec(args))
23016
+ const stripped = stripAnsi(switchroomExec(args))
23017
+ const output = transformOutput ? transformOutput(stripped) : stripped
22896
23018
  const formatted = formatSwitchroomOutput(output)
22897
23019
  if (formatted) { await switchroomReply(ctx, preBlock(formatted), { html: true, classification }) }
22898
23020
  else { await switchroomReply(ctx, `${label}: done (no output)`, { classification }) }
@@ -26961,7 +27083,23 @@ bot.command('logs', async ctx => {
26961
27083
  const lines = linesArg ? parseInt(linesArg, 10) : 20
26962
27084
  const lineCount = isNaN(lines) || lines < 1 ? 20 : Math.min(lines, 200)
26963
27085
  // PR5 — heavy-output → admin alias in supergroup mode (CPO #4).
26964
- 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
+ )
26965
27103
  })
26966
27104
 
26967
27105
  bot.command('memory', async ctx => {
@@ -29292,6 +29430,34 @@ bot.on('message:pinned_message', async ctx => {
29292
29430
  }
29293
29431
  })
29294
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
+
29295
29461
  // ─── Reaction-trigger runtime state (#1074) ──────────────────────────────
29296
29462
  //
29297
29463
  // Bot-message reactions in the configured allowlist trigger a synthetic
@@ -30737,20 +30903,49 @@ void (async () => {
30737
30903
  // this is the single card the operator sees for the switch.
30738
30904
  if (modelSwitchReason != null && modelSwitchMarkerChat) {
30739
30905
  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
- ),
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`,
30753
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
+ }
30754
30949
  }
30755
30950
  } catch { /* leave override as-is on a bad read */ }
30756
30951
  }
@@ -84,6 +84,110 @@ export function isClaudeModel(name: string): boolean {
84
84
  return lower.startsWith('claude-')
85
85
  }
86
86
 
87
+ /**
88
+ * The outcome of a `/model` apply-boot, derived purely from the DETERMINISTIC
89
+ * post-boot signals (never optimistic):
90
+ *
91
+ * - `reason` — the clean-shutdown marker reason that keyed this boot as a
92
+ * `/model` apply-boot, e.g.
93
+ * `user: /model fable (session-only relaunch, menu)`.
94
+ * - `launched` — the contents of `.active-session-model`: the model start.sh
95
+ * actually passed to `claude --model` this boot.
96
+ * - `configured` — the resolved configured default model.
97
+ *
98
+ * Three outcomes:
99
+ * - `applied` — the launched model differs from the configured default, so
100
+ * the switch landed (a session-only override).
101
+ * - `default` — the operator asked for the configured default (`/model
102
+ * default`, or `/model <configured>`) and got it.
103
+ * - `not-applied` — the operator asked for a NON-default model, but the boot
104
+ * came back on the configured default: the switch SILENTLY
105
+ * failed to apply. The consume-once carrier was consumed by a
106
+ * boot that never launched the target (e.g. a wedged apply-boot
107
+ * that hit boot.lock_stale_recovered_boot_mismatch and reverted
108
+ * to the default). This is the case that previously emitted a
109
+ * MISLEADING green "✅ Now running <default> (the configured
110
+ * default)" card with no signal that the requested switch was
111
+ * lost. It self-corrects across boots: a later boot that
112
+ * genuinely launches the target reports `applied`.
113
+ */
114
+ export type ModelSwitchConfirmation =
115
+ | { kind: 'applied'; launched: string }
116
+ | { kind: 'default'; launched: string }
117
+ | { kind: 'not-applied'; target: string; revertedTo: string }
118
+
119
+ /**
120
+ * Extract the requested `/model <target>` token from a clean-shutdown reason
121
+ * (e.g. `user: /model fable (session-only relaunch, menu)` → `fable`). Returns
122
+ * null when the reason carries no `/model <token>` (a non-switch reason).
123
+ */
124
+ export function parseModelSwitchTarget(reason: string): string | null {
125
+ const m = reason.match(/\/model\s+(\S+)/)
126
+ return m ? m[1] : null
127
+ }
128
+
129
+ /**
130
+ * Reduce a model token to a comparable FAMILY key so an alias and its resolved
131
+ * full id compare equal (`sonnet` ≡ `claude-sonnet-5` ≡ `claude-sonnet-5-<date>`).
132
+ *
133
+ * This is the normalization the silent-revert classifier needs: `target` from
134
+ * the clean-shutdown reason is the RAW user token (an alias like `sonnet`),
135
+ * while `.active-session-model` / the configured default may be the RESOLVED
136
+ * full id start.sh wrote on a revert-with-alert path (config-default-changed at
137
+ * start.sh.hbs:1228, proxy-down at :1234). A naive string compare would flag
138
+ * `/model sonnet` on a `claude-sonnet-5`-default agent as "didn't apply" even
139
+ * though sonnet IS the default.
140
+ *
141
+ * NB `resolveMainModel` (scaffold.ts:1358) is NOT sufficient here — it only
142
+ * remaps the `default` alias / unset to the switchroom default; it passes
143
+ * `sonnet`/`opus`/etc. through unchanged. The alias↔full-id equivalence is a
144
+ * FAMILY reduction (same rule model-label.ts uses one-way), done here:
145
+ * - `claude-<family>-…` → `<family>` (e.g. `claude-sonnet-5` → `sonnet`)
146
+ * - a bare alias / any other token → itself, lowercased (`sonnet`, `sr-glm-5`)
147
+ * sr-* ids never carry a `claude-` prefix, so they stay verbatim — correct,
148
+ * since the reason token and `.active-session-model` both hold the same
149
+ * already-expanded sr-* id and compare equal directly.
150
+ */
151
+ export function modelFamilyToken(token: string): string {
152
+ const t = token.trim().toLowerCase()
153
+ if (t.startsWith('claude-')) {
154
+ const family = t.slice('claude-'.length).split('-').filter((p) => p.length > 0)[0]
155
+ return family ?? t
156
+ }
157
+ return t
158
+ }
159
+
160
+ /**
161
+ * Classify a `/model` apply-boot outcome from the post-boot signals. Pure so the
162
+ * confirmation-card decision is unit-testable without booting the gateway. The
163
+ * `not-applied` branch is the fix for the silent-revert bug: a non-default switch
164
+ * that reverted to the configured default must warn, not print a green ✅.
165
+ */
166
+ export function classifyModelSwitchConfirmation(input: {
167
+ reason: string
168
+ launched: string
169
+ configured: string
170
+ }): ModelSwitchConfirmation {
171
+ const { reason, launched, configured } = input
172
+ const isApplyBoot = launched.length > 0 && launched !== configured
173
+ if (isApplyBoot) return { kind: 'applied', launched }
174
+ // launched === configured (or empty): either an intended default/revert, or a
175
+ // NON-default switch that silently reverted to the configured default.
176
+ const target = parseModelSwitchTarget(reason)
177
+ const revertedTo = launched.length > 0 ? launched : configured
178
+ // Family-normalize both sides so an alias target (`sonnet`) matches its
179
+ // resolved full-id revert (`claude-sonnet-5`) — otherwise a `/model sonnet`
180
+ // that reverted to the sonnet default would emit a WRONG "didn't apply" card.
181
+ if (
182
+ target != null &&
183
+ target.toLowerCase() !== 'default' &&
184
+ modelFamilyToken(target) !== modelFamilyToken(revertedTo)
185
+ ) {
186
+ return { kind: 'not-applied', target, revertedTo }
187
+ }
188
+ return { kind: 'default', launched: revertedTo }
189
+ }
190
+
87
191
  export type ParsedModelCommand =
88
192
  | { kind: 'show' }
89
193
  | { kind: 'set'; model: string }