switchroom 0.18.28 → 0.18.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -28638,6 +28638,7 @@ var init_redact = __esm(() => {
28638
28638
  // history.ts
28639
28639
  var exports_history = {};
28640
28640
  __export(exports_history, {
28641
+ verifyHistoryWritable: () => verifyHistoryWritable,
28641
28642
  recordReaction: () => recordReaction,
28642
28643
  recordOutbound: () => recordOutbound,
28643
28644
  recordInbound: () => recordInbound,
@@ -28676,6 +28677,15 @@ function loadDatabaseClass() {
28676
28677
  throw new Error(`history.ts requires Bun runtime (bun:sqlite). Caller: ${err.message}`);
28677
28678
  }
28678
28679
  }
28680
+ function warnHistory(msg) {
28681
+ try {
28682
+ process.stderr.write(`telegram history: ${msg}
28683
+ `);
28684
+ } catch {}
28685
+ }
28686
+ function isValidMessageId(id) {
28687
+ return typeof id === "number" && Number.isInteger(id) && id > 0;
28688
+ }
28679
28689
  function initHistory(stateDir, retentionDays = 30) {
28680
28690
  if (db != null)
28681
28691
  return;
@@ -28757,6 +28767,33 @@ function initHistory(stateDir, retentionDays = 30) {
28757
28767
  const cutoff = Math.floor(Date.now() / 1000) - retentionDays * 86400;
28758
28768
  db.prepare("DELETE FROM messages WHERE ts < ?").run(cutoff);
28759
28769
  }
28770
+ const check = verifyHistoryWritable();
28771
+ if (!check.ok) {
28772
+ warnHistory(`WRITER SELF-CHECK FAILED at boot (path=${path2}): ${check.error ?? "unknown"} \u2014 ` + `history recording is NOT durable; get_recent_messages recovery and the ` + `reply-backstop already-replied suppression will be blind. Investigate the ` + `DB path/mount/permissions before trusting delivery accounting.`);
28773
+ }
28774
+ }
28775
+ function verifyHistoryWritable() {
28776
+ if (db == null)
28777
+ return { ok: false, error: "initHistory() not called" };
28778
+ const SENTINEL_CHAT = "__history_selfcheck__";
28779
+ const sentinelId = Date.now();
28780
+ try {
28781
+ db.prepare("DELETE FROM messages WHERE chat_id = ?").run(SENTINEL_CHAT);
28782
+ db.prepare(`INSERT OR REPLACE INTO messages
28783
+ (chat_id, thread_id, message_id, role, ts, text)
28784
+ VALUES (?, NULL, ?, 'assistant', ?, ?)`).run(SENTINEL_CHAT, sentinelId, Math.floor(Date.now() / 1000), "selfcheck");
28785
+ const row = db.prepare("SELECT text FROM messages WHERE chat_id = ? AND message_id = ?").get(SENTINEL_CHAT, sentinelId);
28786
+ if (row?.text !== "selfcheck") {
28787
+ return { ok: false, error: "sentinel row not read back after insert" };
28788
+ }
28789
+ return { ok: true };
28790
+ } catch (err) {
28791
+ return { ok: false, error: err instanceof Error ? err.message : String(err) };
28792
+ } finally {
28793
+ try {
28794
+ db.prepare("DELETE FROM messages WHERE chat_id = ?").run(SENTINEL_CHAT);
28795
+ } catch {}
28796
+ }
28760
28797
  }
28761
28798
  function _resetForTests() {
28762
28799
  if (db != null) {
@@ -28815,6 +28852,10 @@ function requireDb() {
28815
28852
  function recordInbound(args) {
28816
28853
  if (args.message_id == null)
28817
28854
  return;
28855
+ if (!isValidMessageId(args.message_id)) {
28856
+ warnHistory(`recordInbound: dropping row with invalid message_id=${String(args.message_id)} ` + `(chat=${args.chat_id}) \u2014 a delivered inbound will be absent from history`);
28857
+ return;
28858
+ }
28818
28859
  const stmt = requireDb().prepare(`
28819
28860
  INSERT OR REPLACE INTO messages
28820
28861
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id, reply_to_message_id, reply_to_text, forwarded_from, forwarded_from_type, forwarded_from_id, forwarded_date, forwarded_message_id)
@@ -28826,23 +28867,38 @@ function recordOutbound(args) {
28826
28867
  if (args.message_ids.length === 0)
28827
28868
  return;
28828
28869
  const ts = args.ts ?? Math.floor(Date.now() / 1000);
28829
- const groupId = args.message_ids[0];
28870
+ const validRows = [];
28871
+ for (let i = 0;i < args.message_ids.length; i++) {
28872
+ const id = args.message_ids[i];
28873
+ if (!isValidMessageId(id)) {
28874
+ warnHistory(`recordOutbound: dropping chunk ${i} with invalid message_id=${String(id)} ` + `(chat=${args.chat_id}) \u2014 a delivered outbound will be absent from history, ` + `blinding the reply-backstop already-replied suppression`);
28875
+ continue;
28876
+ }
28877
+ validRows.push({
28878
+ id,
28879
+ text: redact(args.texts[i] ?? ""),
28880
+ attachKind: args.attachment_kinds?.[i] ?? null
28881
+ });
28882
+ }
28883
+ if (validRows.length === 0)
28884
+ return;
28885
+ const groupId = validRows[0].id;
28830
28886
  const stmt = requireDb().prepare(`
28831
28887
  INSERT OR REPLACE INTO messages
28832
28888
  (chat_id, thread_id, message_id, role, user, user_id, ts, text, attachment_kind, group_id)
28833
28889
  VALUES (?, ?, ?, 'assistant', NULL, NULL, ?, ?, ?, ?)
28834
28890
  `);
28835
- const tx = requireDb().transaction((rows2) => {
28836
- for (const [msgId, text4, attachKind] of rows2) {
28837
- stmt.run(args.chat_id, args.thread_id ?? null, msgId, ts, text4, attachKind, groupId);
28891
+ const tx = requireDb().transaction((rows) => {
28892
+ for (const r of rows) {
28893
+ stmt.run(args.chat_id, args.thread_id ?? null, r.id, ts, r.text, r.attachKind, groupId);
28838
28894
  }
28839
28895
  });
28840
- const rows = args.message_ids.map((id, i) => [
28841
- id,
28842
- redact(args.texts[i] ?? ""),
28843
- args.attachment_kinds?.[i] ?? null
28844
- ]);
28845
- tx(rows);
28896
+ try {
28897
+ tx(validRows);
28898
+ } catch (err) {
28899
+ warnHistory(`recordOutbound: INSERT failed (chat=${args.chat_id} ids=[${validRows.map((r) => r.id).join(",")}]): ` + `${err instanceof Error ? err.message : String(err)} \u2014 this outbound will be absent from history`);
28900
+ throw err;
28901
+ }
28846
28902
  }
28847
28903
  function recordEdit(args) {
28848
28904
  requireDb().prepare(`
@@ -39831,6 +39887,64 @@ function buildExtraAttachmentMeta(resolved) {
39831
39887
  return out;
39832
39888
  }
39833
39889
 
39890
+ // shared/local-time.ts
39891
+ function fmtLocalClock(ms, tz) {
39892
+ return new Intl.DateTimeFormat("en-US", {
39893
+ timeZone: tz,
39894
+ hour: "numeric",
39895
+ minute: "2-digit",
39896
+ hour12: true
39897
+ }).format(new Date(ms)).replace(/\s([AP])M$/, (_m, p) => `${p.toLowerCase()}m`);
39898
+ }
39899
+ function fmtLocalDate(ms, tz) {
39900
+ return new Intl.DateTimeFormat("en-GB", {
39901
+ timeZone: tz,
39902
+ day: "numeric",
39903
+ month: "short"
39904
+ }).format(new Date(ms));
39905
+ }
39906
+ function localDay(ms, tz) {
39907
+ return new Intl.DateTimeFormat("en-CA", {
39908
+ timeZone: tz,
39909
+ year: "numeric",
39910
+ month: "2-digit",
39911
+ day: "2-digit"
39912
+ }).format(new Date(ms));
39913
+ }
39914
+ function tzAbbrev(ms, tz) {
39915
+ const at = new Date(ms);
39916
+ for (const loc of ["en-US", "en-AU", "en-GB"]) {
39917
+ const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
39918
+ if (v && !/^(?:GMT|UTC)/i.test(v))
39919
+ return v;
39920
+ }
39921
+ return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
39922
+ }
39923
+ function resolveEnvTimezone(env = process.env) {
39924
+ return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? "UTC";
39925
+ }
39926
+ function fmtLocalStamp(ms, tz) {
39927
+ try {
39928
+ const at = new Date(ms);
39929
+ const weekday = new Intl.DateTimeFormat("en-US", {
39930
+ timeZone: tz,
39931
+ weekday: "long"
39932
+ }).format(at);
39933
+ const date = localDay(ms, tz);
39934
+ const time = new Intl.DateTimeFormat("en-US", {
39935
+ timeZone: tz,
39936
+ hour: "2-digit",
39937
+ minute: "2-digit",
39938
+ hour12: true
39939
+ }).format(at);
39940
+ return `${weekday} ${date} ${time} ${tzAbbrev(ms, tz)}`;
39941
+ } catch {
39942
+ if (tz !== "UTC")
39943
+ return fmtLocalStamp(ms, "UTC");
39944
+ return new Date(ms).toISOString();
39945
+ }
39946
+ }
39947
+
39834
39948
  // steering.ts
39835
39949
  function escapeXmlAttribute(s) {
39836
39950
  return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
@@ -39947,7 +40061,7 @@ function buildForwardOriginMeta(origins) {
39947
40061
  if (o.id != null)
39948
40062
  out[`forwarded_from_id${suffix}`] = String(o.id);
39949
40063
  if (o.date != null) {
39950
- out[`forwarded_date${suffix}`] = new Date(o.date * 1000).toISOString();
40064
+ out[`forwarded_date${suffix}`] = fmtLocalStamp(o.date * 1000, resolveEnvTimezone());
39951
40065
  }
39952
40066
  });
39953
40067
  return out;
@@ -39958,6 +40072,49 @@ function forwardOriginDateIso(o) {
39958
40072
  return new Date(o.date * 1000).toISOString();
39959
40073
  }
39960
40074
 
40075
+ // shared/local-time.ts
40076
+ function localDay2(ms, tz) {
40077
+ return new Intl.DateTimeFormat("en-CA", {
40078
+ timeZone: tz,
40079
+ year: "numeric",
40080
+ month: "2-digit",
40081
+ day: "2-digit"
40082
+ }).format(new Date(ms));
40083
+ }
40084
+ function tzAbbrev2(ms, tz) {
40085
+ const at = new Date(ms);
40086
+ for (const loc of ["en-US", "en-AU", "en-GB"]) {
40087
+ const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
40088
+ if (v && !/^(?:GMT|UTC)/i.test(v))
40089
+ return v;
40090
+ }
40091
+ return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
40092
+ }
40093
+ function resolveEnvTimezone2(env = process.env) {
40094
+ return env.SWITCHROOM_TIMEZONE ?? env.TZ ?? "UTC";
40095
+ }
40096
+ function fmtLocalStamp2(ms, tz) {
40097
+ try {
40098
+ const at = new Date(ms);
40099
+ const weekday = new Intl.DateTimeFormat("en-US", {
40100
+ timeZone: tz,
40101
+ weekday: "long"
40102
+ }).format(at);
40103
+ const date = localDay2(ms, tz);
40104
+ const time = new Intl.DateTimeFormat("en-US", {
40105
+ timeZone: tz,
40106
+ hour: "2-digit",
40107
+ minute: "2-digit",
40108
+ hour12: true
40109
+ }).format(at);
40110
+ return `${weekday} ${date} ${time} ${tzAbbrev2(ms, tz)}`;
40111
+ } catch {
40112
+ if (tz !== "UTC")
40113
+ return fmtLocalStamp2(ms, "UTC");
40114
+ return new Date(ms).toISOString();
40115
+ }
40116
+ }
40117
+
39961
40118
  // status-reactions.ts
39962
40119
  var TELEGRAM_REACTION_WHITELIST = new Set([
39963
40120
  "\uD83D\uDC4D",
@@ -57171,40 +57328,6 @@ function sendGateConfigFromEnv(env = process.env) {
57171
57328
  return out;
57172
57329
  }
57173
57330
 
57174
- // shared/local-time.ts
57175
- function fmtLocalClock(ms, tz) {
57176
- return new Intl.DateTimeFormat("en-US", {
57177
- timeZone: tz,
57178
- hour: "numeric",
57179
- minute: "2-digit",
57180
- hour12: true
57181
- }).format(new Date(ms)).replace(/\s([AP])M$/, (_m, p) => `${p.toLowerCase()}m`);
57182
- }
57183
- function fmtLocalDate(ms, tz) {
57184
- return new Intl.DateTimeFormat("en-GB", {
57185
- timeZone: tz,
57186
- day: "numeric",
57187
- month: "short"
57188
- }).format(new Date(ms));
57189
- }
57190
- function localDay(ms, tz) {
57191
- return new Intl.DateTimeFormat("en-CA", {
57192
- timeZone: tz,
57193
- year: "numeric",
57194
- month: "2-digit",
57195
- day: "2-digit"
57196
- }).format(new Date(ms));
57197
- }
57198
- function tzAbbrev(ms, tz) {
57199
- const at = new Date(ms);
57200
- for (const loc of ["en-US", "en-AU", "en-GB"]) {
57201
- const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
57202
- if (v && !/^(?:GMT|UTC)/i.test(v))
57203
- return v;
57204
- }
57205
- return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
57206
- }
57207
-
57208
57331
  // send-gate-observability.ts
57209
57332
  function formatStatsLine(stats) {
57210
57333
  const c = stats.global;
@@ -57239,7 +57362,7 @@ function createStatsLogger(config) {
57239
57362
  function isAlertableScope(scopeKey) {
57240
57363
  return scopeKey === "global" || scopeKey.startsWith("chat:") || scopeKey.startsWith("group:");
57241
57364
  }
57242
- function fmtLocalStamp(ms, tz, refMs) {
57365
+ function fmtLocalStamp3(ms, tz, refMs) {
57243
57366
  const clock = fmtLocalClock(ms, tz);
57244
57367
  const sameDay = refMs != null && localDay(ms, tz) === localDay(refMs, tz);
57245
57368
  const body = sameDay ? clock : `${fmtLocalDate(ms, tz)} ${clock}`;
@@ -57283,7 +57406,7 @@ function createFloodWindowObserver(config) {
57283
57406
  }
57284
57407
  function openAlertText(w, now) {
57285
57408
  const openFor = fmtDur(now - w.observedAt);
57286
- return `\u26a0\ufe0f Telegram flood ban active (scope \`${w.scopeKey}\`). ` + `Open for ${openFor}, expected to clear at ${fmtLocalStamp(w.untilTs, tz, now)}. ` + `Outbound to that scope is being suppressed.`;
57409
+ return `\u26a0\ufe0f Telegram flood ban active (scope \`${w.scopeKey}\`). ` + `Open for ${openFor}, expected to clear at ${fmtLocalStamp3(w.untilTs, tz, now)}. ` + `Outbound to that scope is being suppressed.`;
57287
57410
  }
57288
57411
  function closeAlertText(recs) {
57289
57412
  const observedAt = Math.min(...recs.map((r) => r.observedAt));
@@ -63314,6 +63437,7 @@ function endsWithSilentMarker(text4) {
63314
63437
  return false;
63315
63438
  return isSilentFlushMarker(lines[lines.length - 1]);
63316
63439
  }
63440
+ var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
63317
63441
  function selectFlushDeliveryText(blocks) {
63318
63442
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
63319
63443
  if (candidates.length === 0)
@@ -63328,8 +63452,18 @@ function selectFlushDeliveryText(blocks) {
63328
63452
  `);
63329
63453
  }
63330
63454
  var NARRATION_OPENER = /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i;
63455
+ var NARRATION_TRAILER = /(?:\.{3}|\u2026|:)\s*$/;
63456
+ function isTrailingNarrationLine(block) {
63457
+ const t = block.trim();
63458
+ if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS)
63459
+ return false;
63460
+ if (t.includes(`
63461
+ `))
63462
+ return false;
63463
+ return NARRATION_TRAILER.test(t);
63464
+ }
63331
63465
  function isNarrationBlock(block) {
63332
- return NARRATION_OPENER.test(block.trimStart());
63466
+ return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block);
63333
63467
  }
63334
63468
  function decideTurnFlush(input) {
63335
63469
  const flushEnabled = input.flushEnabled !== false;
@@ -69214,7 +69348,7 @@ function endsWithSilentMarker2(text4) {
69214
69348
  return false;
69215
69349
  return isSilentFlushMarker2(lines[lines.length - 1]);
69216
69350
  }
69217
- var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
69351
+ var FLUSH_SUBSTANTIVE_MIN_CHARS2 = 200;
69218
69352
  function selectFlushDeliveryText2(blocks) {
69219
69353
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
69220
69354
  if (candidates.length === 0)
@@ -69229,8 +69363,18 @@ function selectFlushDeliveryText2(blocks) {
69229
69363
  `);
69230
69364
  }
69231
69365
  var NARRATION_OPENER2 = /^(let me\b|lemme\b|i'?ll\b|i will\b|i am going to\b|i'?m going to\b|i'?m about to\b|going to\b|first,?\s+(?:let me|i'?ll|i will)\b|now,?\s+(?:let me|i'?ll|i will)\b|next,?\s+(?:let me|i'?ll|i will)\b|let'?s\b)/i;
69366
+ var NARRATION_TRAILER2 = /(?:\.{3}|\u2026|:)\s*$/;
69367
+ function isTrailingNarrationLine2(block) {
69368
+ const t = block.trim();
69369
+ if (t.length === 0 || t.length >= FLUSH_SUBSTANTIVE_MIN_CHARS2)
69370
+ return false;
69371
+ if (t.includes(`
69372
+ `))
69373
+ return false;
69374
+ return NARRATION_TRAILER2.test(t);
69375
+ }
69232
69376
  function isNarrationBlock2(block) {
69233
- return NARRATION_OPENER2.test(block.trimStart());
69377
+ return NARRATION_OPENER2.test(block.trimStart()) || isTrailingNarrationLine2(block);
69234
69378
  }
69235
69379
  function decideTurnFlush2(input) {
69236
69380
  const flushEnabled = input.flushEnabled !== false;
@@ -71165,16 +71309,6 @@ Allowed: \`${deps.escapeHtml(allow)}\``, { html: true });
71165
71309
  await deps.reply(ctx, finalText, { html: true, accent });
71166
71310
  }
71167
71311
 
71168
- // ../src/agents/model-picker.ts
71169
- function labelTag(label) {
71170
- let h = 2166136261;
71171
- for (const ch of label) {
71172
- h ^= ch.codePointAt(0);
71173
- h = Math.imul(h, 16777619) >>> 0;
71174
- }
71175
- return h.toString(16).padStart(8, "0");
71176
- }
71177
-
71178
71312
  // gateway/model-command.ts
71179
71313
  var MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable", "default"];
71180
71314
  var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/;
@@ -71184,12 +71318,6 @@ function isValidModelArg(arg) {
71184
71318
  function isSrModel(name) {
71185
71319
  return name.startsWith("sr-");
71186
71320
  }
71187
- function isClaudeModel(name) {
71188
- const lower = name.toLowerCase();
71189
- if (MODEL_ALIASES.includes(lower))
71190
- return true;
71191
- return lower.startsWith("claude-");
71192
- }
71193
71321
  function parseModelCommand(text4) {
71194
71322
  const m = text4.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
71195
71323
  if (!m)
@@ -71233,13 +71361,13 @@ function modelCommandReceiptLine(agent, parsed, busy) {
71233
71361
  const arg = parsed.kind === "set" ? parsed.model : parsed.kind === "show" ? "(show)" : "(help)";
71234
71362
  return `telegram gateway: gw /model received agent=${agent} kind=${parsed.kind} arg=${arg} busy=${busy}`;
71235
71363
  }
71236
- var PERSIST_NOTE = "_Session-only \u2014 this override lasts until the agent\u2019s next restart, then reverts to the configured `model:`. `/model default` clears it now. To change the default permanently, set `model:` in switchroom.yaml._";
71364
+ var PERSIST_NOTE = "_A `/model` switch relaunches the session (~30s) on the chosen model. Session-only \u2014 reverts to the configured `model:` on the next restart. `/model default` reverts now. Live scrollback is replaced by a fresh session; memory and the handoff briefing carry the context. To change the default permanently, set `model:` in switchroom.yaml._";
71237
71365
  function helpText2(deps, reason) {
71238
71366
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `\`${a}\``).join(" \u00b7 ");
71239
71367
  const lines = [];
71240
71368
  if (reason)
71241
71369
  lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
71242
- lines.push("**/model** \u2014 show or switch the Claude model", "`/model` \u2014 show the configured model", `\`/model <name>\` \u2014 switch the live session (${MODEL_ALIASES.map((a) => `\`${a}\``).join(" \u00b7 ")} or a full model id)`, `_OpenRouter shortcuts:_ ${srAliasExamples}`, "_OpenRouter (sr-\\*) switches restart the session (~30s); Claude switches apply instantly._", PERSIST_NOTE);
71370
+ lines.push("**/model** \u2014 show or switch the Claude model", "`/model` \u2014 show the configured model", `\`/model <name>\` \u2014 switch the live session (${MODEL_ALIASES.map((a) => `\`${a}\``).join(" \u00b7 ")} or a full model id)`, `_OpenRouter shortcuts:_ ${srAliasExamples}`, "_Every switch relaunches the session (~30s) on the chosen model \u2014 Claude and OpenRouter (sr-\\*) alike._", PERSIST_NOTE);
71243
71371
  return { text: lines.join(`
71244
71372
  `), html: true };
71245
71373
  }
@@ -71273,132 +71401,46 @@ async function handleModelCommand(parsed, deps) {
71273
71401
  html: true
71274
71402
  };
71275
71403
  }
71276
- const currentSession = deps.getActiveSessionModel();
71277
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
71278
- try {
71279
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`);
71280
- } catch (err) {
71281
- if (isRestartInFlight(err)) {
71282
- return {
71283
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71284
- html: true
71285
- };
71286
- }
71287
- const msg = err instanceof Error ? err.message : String(err);
71288
- return {
71289
- text: `\u274c Could not schedule restart: ${deps.escapeHtml(msg)}`,
71290
- html: true
71291
- };
71292
- }
71293
- return {
71294
- text: [
71295
- `Switching from \`${deps.escapeHtml(currentSession)}\` back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`,
71296
- PERSIST_NOTE
71297
- ].join(`
71298
- `),
71299
- html: true
71300
- };
71404
+ if (model.toLowerCase() === "default") {
71405
+ return scheduleDefaultRelaunchReply(deps, "user: /model default (revert relaunch)");
71301
71406
  }
71302
- if (isSrModel(model)) {
71303
- try {
71304
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`);
71305
- } catch (err) {
71306
- if (isRestartInFlight(err)) {
71307
- return {
71308
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71309
- html: true
71310
- };
71311
- }
71312
- const msg = err instanceof Error ? err.message : String(err);
71313
- return {
71314
- text: `\u274c Could not schedule model switch: ${deps.escapeHtml(msg)}`,
71315
- html: true
71316
- };
71317
- }
71407
+ return scheduleRelaunchReply(deps, model, `user: /model ${model} (session-only relaunch)`);
71408
+ }
71409
+ function switchingLine(deps, model) {
71410
+ const friendly = isSrModel(model) ? srFriendlyLabel(model) : model;
71411
+ return `\uD83D\uDD04 Switching to \`${deps.escapeHtml(friendly)}\` \u2014 relaunching the session (~30s).`;
71412
+ }
71413
+ function relaunchErrorReply(deps, model, err) {
71414
+ if (isRestartInFlight(err)) {
71318
71415
  return {
71319
- text: [
71320
- `Switching to \`${deps.escapeHtml(model)}\` \u2014 restarting session (~30s).`,
71321
- PERSIST_NOTE
71322
- ].join(`
71323
- `),
71416
+ text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71324
71417
  html: true
71325
71418
  };
71326
71419
  }
71327
- const verbHtml = `\`/model ${deps.escapeHtml(model)}\``;
71328
- let result;
71420
+ const msg = err instanceof Error ? err.message : String(err);
71421
+ return { text: `\u274c Could not schedule model switch: ${deps.escapeHtml(msg)}`, html: true };
71422
+ }
71423
+ async function scheduleRelaunchReply(deps, model, reason) {
71329
71424
  try {
71330
- result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
71331
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71332
- errorPattern: MODEL_SWITCH_ERROR_RE,
71333
- settleBeforeSendMs: 1500
71334
- });
71425
+ await deps.scheduleModelRelaunch(model, reason);
71335
71426
  } catch (err) {
71336
- const msg = err instanceof Error ? err.message : String(err);
71337
- return {
71338
- text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`,
71339
- html: true
71340
- };
71427
+ return relaunchErrorReply(deps, model, err);
71341
71428
  }
71342
- if (result.outcome === "ok" || result.outcome === "ok_no_output") {
71343
- const confirmation = result.outcome === "ok" ? modelSwitchConfirmationLine(result.output) : null;
71344
- if (confirmation) {
71345
- if (isKeptModelConfirmation(confirmation)) {
71346
- return {
71347
- text: [
71348
- `${verbHtml}`,
71349
- deps.preBlock(confirmation),
71350
- ...result.truncated ? ["_truncated_"] : [],
71351
- PERSIST_NOTE
71352
- ].join(`
71353
- `),
71354
- html: true
71355
- };
71356
- }
71357
- const confirmed = sessionModelFromConfirmation(confirmation) ?? model;
71358
- return {
71359
- text: [
71360
- `${verbHtml}`,
71361
- deps.preBlock(confirmation),
71362
- ...result.truncated ? ["_truncated_"] : [],
71363
- PERSIST_NOTE
71364
- ].join(`
71365
- `),
71366
- html: true,
71367
- selectedModel: confirmed
71368
- };
71369
- }
71370
- const errLine = result.outcome === "ok" ? modelSwitchErrorLine(result.output) : null;
71371
- if (errLine) {
71372
- return {
71373
- text: [
71374
- `\u274c ${verbHtml} \u2014 the switch did not take:`,
71375
- deps.preBlock(errLine),
71376
- "Check `/model` for a valid, available model."
71377
- ].join(`
71378
- `),
71379
- html: true
71380
- };
71381
- }
71382
- const optimisticLabel = optimisticModelRecordLabel(model);
71383
- return {
71384
- text: [
71385
- `${verbHtml} \u2014 sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
71386
- PERSIST_NOTE
71387
- ].join(`
71388
- `),
71389
- html: true,
71390
- selectedModel: optimisticLabel,
71391
- optimistic: true
71392
- };
71393
- }
71394
- if (result.errorCode === "session_missing") {
71395
- return {
71396
- text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove `experimental.legacy_pty: true` if set.",
71397
- html: true
71398
- };
71429
+ return { text: [switchingLine(deps, model), PERSIST_NOTE].join(`
71430
+ `), html: true };
71431
+ }
71432
+ async function scheduleDefaultRelaunchReply(deps, reason) {
71433
+ try {
71434
+ await deps.scheduleModelDefaultRelaunch(reason);
71435
+ } catch (err) {
71436
+ return relaunchErrorReply(deps, "default", err);
71399
71437
  }
71400
71438
  return {
71401
- text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
71439
+ text: [
71440
+ "\uD83D\uDD04 Reverting to the configured default model \u2014 relaunching the session (~30s).",
71441
+ PERSIST_NOTE
71442
+ ].join(`
71443
+ `),
71402
71444
  html: true
71403
71445
  };
71404
71446
  }
@@ -71451,15 +71493,6 @@ function expandSrAlias(arg) {
71451
71493
  function srFriendlyLabel(srName) {
71452
71494
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, "").replace(/-/g, " ");
71453
71495
  }
71454
- function optimisticModelRecordLabel(token) {
71455
- if (isSrModel(token))
71456
- return token;
71457
- const lower = token.toLowerCase();
71458
- if (MODEL_ALIASES.includes(lower)) {
71459
- return lower.charAt(0).toUpperCase() + lower.slice(1);
71460
- }
71461
- return token;
71462
- }
71463
71496
  function classifyDiscoveredOptions(options) {
71464
71497
  return {
71465
71498
  claude: options.filter((o) => !o.label.startsWith("sr-") && !o.label.includes("/") && (/^[A-Z]/.test(o.label) || o.label.startsWith("claude-"))),
@@ -71467,7 +71500,19 @@ function classifyDiscoveredOptions(options) {
71467
71500
  };
71468
71501
  }
71469
71502
  function modelSelectCallbackData(label) {
71470
- return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`;
71503
+ const token = canonicalClaudeToken(label);
71504
+ if (token)
71505
+ return `${MODEL_CALLBACK_SELECT}${token}`;
71506
+ if (/^default\b/i.test(label.trim()))
71507
+ return `${MODEL_CALLBACK_SELECT}default`;
71508
+ return MODEL_CALLBACK_SELECT;
71509
+ }
71510
+ function isRecognizedSwitchToken(token) {
71511
+ if (token.toLowerCase() === "default")
71512
+ return true;
71513
+ if (isSrModel(token))
71514
+ return true;
71515
+ return canonicalClaudeToken(token) !== null;
71471
71516
  }
71472
71517
  var BUSY_REFUSAL_TEXT = "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Your tap was not applied.";
71473
71518
  function isBusyRefusalText(text4) {
@@ -71617,75 +71662,28 @@ async function handleModelMenuCallback(data, deps) {
71617
71662
  if (data === MODEL_CALLBACK_PAGE_MAIN) {
71618
71663
  return { answer: "Back", reply: await buildModelMenu(deps, "main") };
71619
71664
  }
71620
- if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
71621
- const alias = data.slice(MODEL_CALLBACK_ALIAS.length);
71622
- if (!isValidModelArg(alias)) {
71623
- return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71624
- }
71625
- if (deps.isBusy()) {
71626
- return {
71627
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71628
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71629
- toastOnly: true,
71630
- busyRefusal: true
71631
- };
71632
- }
71633
- let aliasResult;
71634
- try {
71635
- aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
71636
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71637
- errorPattern: MODEL_SWITCH_ERROR_RE,
71638
- settleBeforeSendMs: 1500
71639
- });
71640
- } catch (err) {
71641
- const msg = err instanceof Error ? err.message : String(err);
71642
- return {
71643
- answer: "Switch failed",
71644
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`)
71645
- };
71646
- }
71647
- if (aliasResult.outcome === "ok" || aliasResult.outcome === "ok_no_output") {
71648
- const confirmation = aliasResult.outcome === "ok" ? modelSwitchConfirmationLine(aliasResult.output) : null;
71649
- if (confirmation) {
71650
- const kept = isKeptModelConfirmation(confirmation);
71651
- return {
71652
- answer: confirmation,
71653
- reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
71654
- ...kept ? {} : {
71655
- selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
71656
- selectedModelToken: alias
71657
- }
71658
- };
71659
- }
71660
- const aliasErr = aliasResult.outcome === "ok" ? modelSwitchErrorLine(aliasResult.output) : null;
71661
- if (aliasErr) {
71662
- return {
71663
- answer: "Switch failed",
71664
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`)
71665
- };
71666
- }
71667
- const optimisticLabel = optimisticModelRecordLabel(alias);
71668
- return {
71669
- answer: `Sent /model ${alias} \u2014 check /status`,
71670
- reply: await menuWithBannerStatic(deps, `Sent \`/model ${deps.escapeHtml(alias)}\` \u2014 couldn\u2019t read a confirmation line. \`/status\` will show the live model once it\u2019s confirmed.`),
71671
- selectedModel: optimisticLabel,
71672
- selectedModelToken: alias
71673
- };
71674
- }
71675
- return {
71676
- answer: "Switch failed",
71677
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed \u2014 agent may be mid-turn`)
71678
- };
71679
- }
71680
71665
  if (data === MODEL_CALLBACK_HEADER) {
71681
71666
  return { answer: "Tap a model in this section to switch", reply: { text: "", html: true }, toastOnly: true };
71682
71667
  }
71683
- if (data.startsWith(MODEL_CALLBACK_SR)) {
71684
- const srName = data.slice(MODEL_CALLBACK_SR.length);
71685
- const friendlyName = srFriendlyLabel(srName);
71686
- if (!isValidModelArg(srName)) {
71668
+ if (data.startsWith(MODEL_CALLBACK_ALIAS) || data.startsWith(MODEL_CALLBACK_SR) || data.startsWith(MODEL_CALLBACK_SELECT)) {
71669
+ let token;
71670
+ let label;
71671
+ if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
71672
+ token = data.slice(MODEL_CALLBACK_ALIAS.length);
71673
+ label = token;
71674
+ } else if (data.startsWith(MODEL_CALLBACK_SR)) {
71675
+ token = data.slice(MODEL_CALLBACK_SR.length);
71676
+ label = srFriendlyLabel(token);
71677
+ } else {
71678
+ token = data.slice(MODEL_CALLBACK_SELECT.length);
71679
+ label = token;
71680
+ }
71681
+ if (!isValidModelArg(token)) {
71687
71682
  return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71688
71683
  }
71684
+ if (!isRecognizedSwitchToken(token)) {
71685
+ return { answer: "Model list changed \u2014 menu refreshed", reply: await buildModelMenu(deps) };
71686
+ }
71689
71687
  if (deps.isBusy()) {
71690
71688
  return {
71691
71689
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
@@ -71694,87 +71692,38 @@ async function handleModelMenuCallback(data, deps) {
71694
71692
  busyRefusal: true
71695
71693
  };
71696
71694
  }
71697
- try {
71698
- await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
71699
- } catch (err) {
71700
- const msg = err instanceof Error ? err.message : String(err);
71695
+ return menuRelaunchOutcome(deps, token, label);
71696
+ }
71697
+ return { answer: "Unknown action", reply: await buildModelMenu(deps) };
71698
+ }
71699
+ async function menuRelaunchOutcome(deps, token, label) {
71700
+ const isDefault = token.toLowerCase() === "default";
71701
+ try {
71702
+ if (isDefault) {
71703
+ await deps.scheduleModelDefaultRelaunch("user: /model default (revert relaunch, menu)");
71704
+ } else {
71705
+ await deps.scheduleModelRelaunch(token, `user: /model ${token} (session-only relaunch, menu)`);
71706
+ }
71707
+ } catch (err) {
71708
+ if (isRestartInFlight(err)) {
71701
71709
  return {
71702
- answer: "Switch failed",
71703
- reply: await menuWithBannerStatic(deps, `\u274c Switch to **${deps.escapeHtml(friendlyName)}** failed: ${deps.escapeHtml(msg)}`)
71710
+ answer: "A restart is already in flight (~15s)",
71711
+ reply: await menuWithBannerStatic(deps, `\u23f3 A restart is already in flight \u2014 your switch to **${deps.escapeHtml(label)}** will apply as it completes (~15s).`)
71704
71712
  };
71705
71713
  }
71714
+ const msg = err instanceof Error ? err.message : String(err);
71706
71715
  return {
71707
- answer: `Switching to ${friendlyName} \u2014 restarting (~30s)`,
71708
- reply: await menuWithBannerStatic(deps, `\uD83D\uDD04 Switching session to **${deps.escapeHtml(friendlyName)}** \u2014 restarting (~30s).
71709
- ${PERSIST_NOTE}`),
71710
- selectedModel: srName
71711
- };
71712
- }
71713
- if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
71714
- return { answer: "Unknown action", reply: await buildModelMenu(deps) };
71715
- }
71716
- if (deps.isBusy()) {
71717
- return {
71718
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71719
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71720
- toastOnly: true,
71721
- busyRefusal: true
71722
- };
71723
- }
71724
- const tag = data.slice(MODEL_CALLBACK_SELECT.length);
71725
- const discovered = await deps.discover(deps.getAgentName());
71726
- if (!discovered.ok) {
71727
- return {
71728
- answer: "Picker unavailable",
71729
- reply: await menuWithBanner(deps, `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`)
71730
- };
71731
- }
71732
- const target = discovered.options.find((o) => labelTag(o.label) === tag);
71733
- if (!target) {
71734
- const fresh = await buildModelMenu(deps);
71735
- return { answer: "Model list changed \u2014 menu refreshed", reply: fresh };
71736
- }
71737
- const result = await deps.select(deps.getAgentName(), target.label);
71738
- if (!result.ok) {
71739
- return {
71740
- answer: "Switch failed \u2014 see the menu",
71741
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(target.label)}** failed: ${deps.escapeHtml(result.reason)}`)
71742
- };
71743
- }
71744
- if (isKeptModelConfirmation(result.confirmation)) {
71745
- return {
71746
- answer: deps.escapeHtml(result.confirmation),
71747
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`)
71716
+ answer: "Switch failed",
71717
+ reply: await menuWithBannerStatic(deps, `\u274c Switch to **${deps.escapeHtml(label)}** failed: ${deps.escapeHtml(msg)}`)
71748
71718
  };
71749
71719
  }
71750
- const token = canonicalClaudeToken(target.label);
71751
- const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined;
71752
- const clearedDefault = token == null && /^default\b/i.test(target.label.trim());
71720
+ const friendly = isDefault ? "the configured default" : label;
71753
71721
  return {
71754
- answer: deps.escapeHtml(result.confirmation),
71755
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`),
71756
- ...selectedModel ? { selectedModel } : {},
71757
- ...token ? { selectedModelToken: token } : {},
71758
- ...clearedDefault ? { clearedDefault: true } : {}
71722
+ answer: `Switching to ${isDefault ? "default" : label} \u2014 relaunching (~30s)`,
71723
+ reply: await menuWithBannerStatic(deps, `\uD83D\uDD04 Switching session to **${deps.escapeHtml(friendly)}** \u2014 relaunching (~30s).
71724
+ ${PERSIST_NOTE}`)
71759
71725
  };
71760
71726
  }
71761
- function isSrToClaudeTransition(prevModel, nextModel) {
71762
- return !!prevModel?.startsWith("sr-") && !nextModel.startsWith("sr-");
71763
- }
71764
- function modelSwitchConfirmationLine(output) {
71765
- const line = output.split(`
71766
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l));
71767
- return line && line.length > 0 ? line : null;
71768
- }
71769
- var MODEL_SWITCH_ERROR_RE = /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*(?:Error:\s*)?(?:Model(?:\s+'[^']+')?\s+not found|Invalid model|Unknown model|No such model|(?:[\w'\u2019.\-]+\s+){0,4}(?:(?:is |are )?(?:not available|unavailable|not enabled|not supported)|access denied|requires\b[^\n]{0,40}\b(?:subscription|plan)|no access)\b)/i;
71770
- function modelSwitchErrorLine(output) {
71771
- const line = output.split(`
71772
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_ERROR_RE.test(l));
71773
- return line && line.length > 0 ? line : null;
71774
- }
71775
- function isKeptModelConfirmation(confirmation) {
71776
- return /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*Kept model as\b/i.test(confirmation.trim());
71777
- }
71778
71727
  function canonicalClaudeToken(label) {
71779
71728
  const l = label.trim();
71780
71729
  if (l.toLowerCase().startsWith("claude-"))
@@ -71789,21 +71738,6 @@ function canonicalClaudeToken(label) {
71789
71738
  function isRestartInFlight(err) {
71790
71739
  return !!err && typeof err === "object" && err.code === "restart_in_flight";
71791
71740
  }
71792
- var MODEL_SWITCH_CONFIRMATION_PREFIX = /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*(?:Set model to|Switched to|Kept model as)\b/i;
71793
- function sessionModelFromConfirmation(confirmation) {
71794
- const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(confirmation.trim());
71795
- const name = m?.[1]?.trim();
71796
- return name && name.length > 0 ? name : null;
71797
- }
71798
- async function menuWithBanner(deps, banner) {
71799
- const fresh = await buildModelMenu(deps);
71800
- return {
71801
- text: [banner, "", fresh.text].join(`
71802
- `),
71803
- html: true,
71804
- ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
71805
- };
71806
- }
71807
71741
  async function menuWithBannerStatic(deps, banner) {
71808
71742
  const v1 = await handleModelCommand({ kind: "show" }, deps);
71809
71743
  return {
@@ -72215,70 +72149,6 @@ async function discoverModels(agentName3, opts = {}) {
72215
72149
  return { ok: true, options: parsed.options, currentLabel: current?.label ?? null, ...tail };
72216
72150
  });
72217
72151
  }
72218
- async function selectModel(agentName3, targetLabel, opts = {}) {
72219
- const io = makeIo(agentName3, opts);
72220
- if (!io.runner.hasSession(io.socket, io.session)) {
72221
- return { ok: false, reason: "tmux session not found" };
72222
- }
72223
- return withPaneLock(`${io.socket}:${io.session}`, async () => {
72224
- io.startedAt = Date.now();
72225
- let selected = false;
72226
- try {
72227
- const parsed = await openPickerWithRetry(io);
72228
- if (!parsed || !parsed.footerSeen) {
72229
- return { ok: false, reason: "picker did not render \u2014 agent may be mid-turn" };
72230
- }
72231
- const target = parsed.options.find((o) => o.label === targetLabel);
72232
- if (!target) {
72233
- const have = parsed.options.map((o) => o.label).join(", ");
72234
- return { ok: false, reason: `option "${targetLabel}" not offered (have: ${have})` };
72235
- }
72236
- if (parsed.cursorIndex < 0) {
72237
- return { ok: false, reason: "cursor marker not visible \u2014 refusing blind navigation" };
72238
- }
72239
- const delta = target.index - parsed.cursorIndex;
72240
- const key = delta > 0 ? "Down" : "Up";
72241
- for (let i = 0;i < Math.abs(delta); i++) {
72242
- if (expired(io))
72243
- return { ok: false, reason: "timed out navigating picker" };
72244
- sendKey(io, key);
72245
- await io.sleep(Math.min(io.stepMs, 250));
72246
- }
72247
- await io.sleep(io.stepMs);
72248
- const verifyPane = io.runner.capture(io.socket, io.session) ?? "";
72249
- const verify = parseModelPicker(verifyPane);
72250
- const atRow = verify?.options.find((o) => o.index === verify.cursorIndex);
72251
- if (!verify || !atRow || atRow.label !== targetLabel) {
72252
- return {
72253
- ok: false,
72254
- reason: `cursor verification failed (on "${atRow?.label ?? "?"}", wanted "${targetLabel}")`
72255
- };
72256
- }
72257
- sendLiteral(io, "s");
72258
- selected = true;
72259
- await io.sleep(io.stepMs);
72260
- const after = io.runner.capture(io.socket, io.session) ?? "";
72261
- const confirmation = extractConfirmation(after) ?? `Switched to ${targetLabel} (session)`;
72262
- return { ok: true, confirmation };
72263
- } catch (err) {
72264
- return { ok: false, reason: err instanceof Error ? err.message : String(err) };
72265
- } finally {
72266
- if (!selected) {
72267
- await dismissOrWarn(io, "select");
72268
- }
72269
- }
72270
- });
72271
- }
72272
- function extractConfirmation(pane) {
72273
- const lines = pane.split(`
72274
- `);
72275
- for (let i = lines.length - 1;i >= 0; i--) {
72276
- const t = lines[i].replace(/^\s*\u23bf\s*/, "").trim();
72277
- if (/^(Set model to|Kept model as|Switched to)/i.test(t))
72278
- return t;
72279
- }
72280
- return null;
72281
- }
72282
72152
 
72283
72153
  // ../src/agents/scaffold.ts
72284
72154
  import { join as join33, resolve as resolve6 } from "node:path";
@@ -77548,17 +77418,20 @@ function computeTurnStatus(turn) {
77548
77418
  return turn.finalAnswerDelivered ? "complete" : "no_reply";
77549
77419
  }
77550
77420
  }
77551
- function backstopSendOutcome(args) {
77421
+ function backstopSendOutcomeGated(args) {
77552
77422
  if (args.threw)
77553
77423
  return "failed";
77554
77424
  if (args.chunkCount === 0)
77555
77425
  return "failed";
77556
- if (args.sentCount < args.chunkCount)
77426
+ const freshCount = args.sentIds.filter((id) => args.cardMessageId == null || id !== args.cardMessageId).length;
77427
+ if (freshCount === 0)
77428
+ return "failed";
77429
+ if (freshCount < args.chunkCount)
77557
77430
  return "failed";
77558
77431
  return "delivered";
77559
77432
  }
77560
- function finalizeBackstopSend(turn, send) {
77561
- const outcome = backstopSendOutcome(send);
77433
+ function finalizeBackstopSendGated(turn, send) {
77434
+ const outcome = backstopSendOutcomeGated(send);
77562
77435
  turn.deliveryOutcome = outcome;
77563
77436
  return outcome;
77564
77437
  }
@@ -77573,6 +77446,120 @@ function buildTurnRecord(turn, endedAt) {
77573
77446
  };
77574
77447
  }
77575
77448
 
77449
+ // gateway/backstop-delivery.ts
77450
+ class BackstopDeliveryLedger {
77451
+ latched = new Set;
77452
+ chunks = new Map;
77453
+ pending = new Map;
77454
+ claim(turnId) {
77455
+ if (this.latched.has(turnId))
77456
+ return false;
77457
+ this.latched.add(turnId);
77458
+ return true;
77459
+ }
77460
+ release(turnId) {
77461
+ this.latched.delete(turnId);
77462
+ }
77463
+ markPending(turnId, index2) {
77464
+ let set = this.pending.get(turnId);
77465
+ if (set == null) {
77466
+ set = new Set;
77467
+ this.pending.set(turnId, set);
77468
+ }
77469
+ set.add(index2);
77470
+ }
77471
+ recordChunk(turnId, index2, messageIds) {
77472
+ let m = this.chunks.get(turnId);
77473
+ if (m == null) {
77474
+ m = new Map;
77475
+ this.chunks.set(turnId, m);
77476
+ }
77477
+ m.set(index2, messageIds.slice());
77478
+ this.pending.get(turnId)?.delete(index2);
77479
+ }
77480
+ hasChunk(turnId, index2) {
77481
+ return (this.chunks.get(turnId)?.get(index2)?.length ?? 0) > 0;
77482
+ }
77483
+ sentIds(turnId) {
77484
+ const m = this.chunks.get(turnId);
77485
+ if (m == null)
77486
+ return [];
77487
+ const out = [];
77488
+ for (const index2 of Array.from(m.keys()).sort((a, b) => a - b)) {
77489
+ out.push(...m.get(index2) ?? []);
77490
+ }
77491
+ return out;
77492
+ }
77493
+ entries(turnId) {
77494
+ const m = this.chunks.get(turnId);
77495
+ if (m == null)
77496
+ return [];
77497
+ return Array.from(m.keys()).sort((a, b) => a - b).map((index2) => ({ index: index2, messageIds: (m.get(index2) ?? []).slice() }));
77498
+ }
77499
+ unsentIndices(turnId, chunkCount) {
77500
+ const out = [];
77501
+ for (let i = 0;i < chunkCount; i++) {
77502
+ if (!this.hasChunk(turnId, i))
77503
+ out.push(i);
77504
+ }
77505
+ return out;
77506
+ }
77507
+ clear(turnId) {
77508
+ this.latched.delete(turnId);
77509
+ this.chunks.delete(turnId);
77510
+ this.pending.delete(turnId);
77511
+ }
77512
+ }
77513
+ function backstopReceiptIds(sentIds, cardMessageId) {
77514
+ return sentIds.filter((id) => cardMessageId == null || id !== cardMessageId);
77515
+ }
77516
+ async function runBackstopDelivery(ledger, turnId, chunks, cardMessageId, deps, maxAttempts = 3) {
77517
+ const stderr = deps.stderr ?? (() => {});
77518
+ const chunkCount = chunks.length;
77519
+ let attempts = 0;
77520
+ for (let attempt = 1;attempt <= Math.max(1, maxAttempts); attempt++) {
77521
+ attempts = attempt;
77522
+ const resume = ledger.unsentIndices(turnId, chunkCount);
77523
+ if (resume.length === 0)
77524
+ break;
77525
+ if (attempt > 1) {
77526
+ stderr(`telegram gateway: backstop delivery retry ${attempt}/${maxAttempts} \u2014 ` + `resuming at unsent chunk(s) [${resume.join(", ")}] for turn ${turnId}
77527
+ `);
77528
+ }
77529
+ let attemptThrew = false;
77530
+ try {
77531
+ for (const i of resume) {
77532
+ if (ledger.hasChunk(turnId, i))
77533
+ continue;
77534
+ ledger.markPending(turnId, i);
77535
+ const ids = await deps.sendChunk(i, chunks[i]);
77536
+ ledger.recordChunk(turnId, i, ids);
77537
+ }
77538
+ } catch (err) {
77539
+ attemptThrew = true;
77540
+ stderr(`telegram gateway: backstop delivery attempt ${attempt}/${maxAttempts} failed: ` + `${err instanceof Error ? err.message : String(err)}
77541
+ `);
77542
+ }
77543
+ if (!attemptThrew && ledger.unsentIndices(turnId, chunkCount).length === 0)
77544
+ break;
77545
+ }
77546
+ const sentIds = ledger.sentIds(turnId);
77547
+ const delivered = chunkCount > 0 && ledger.unsentIndices(turnId, chunkCount).length === 0 && backstopReceiptIds(sentIds, cardMessageId).length > 0;
77548
+ const exhausted = !delivered;
77549
+ if (deps.recordOutbound && sentIds.length > 0) {
77550
+ const texts = [];
77551
+ const ids = [];
77552
+ for (const { index: index2, messageIds } of ledger.entries(turnId)) {
77553
+ for (const id of messageIds) {
77554
+ ids.push(id);
77555
+ texts.push(chunks[index2] ?? "");
77556
+ }
77557
+ }
77558
+ deps.recordOutbound(ids, texts);
77559
+ }
77560
+ return { sentIds, chunkCount, delivered, attempts, exhausted };
77561
+ }
77562
+
77576
77563
  // gateway/inbound-delivery-confirm.ts
77577
77564
  function createDeliveryQueue() {
77578
77565
  return { pending: new Map };
@@ -84182,10 +84169,10 @@ function effectiveTurnAgeMs(markerAgeMs, turnStartedAt, now) {
84182
84169
  }
84183
84170
 
84184
84171
  // ../src/build-info.ts
84185
- var VERSION = "0.18.28";
84186
- var COMMIT_SHA = "ef0be9a2";
84187
- var COMMIT_DATE = "2026-07-16T14:42:35+10:00";
84188
- var LATEST_PR = 3274;
84172
+ var VERSION = "0.18.29";
84173
+ var COMMIT_SHA = "8f9b38b3";
84174
+ var COMMIT_DATE = "2026-07-16T21:47:27+10:00";
84175
+ var LATEST_PR = 3281;
84189
84176
  var COMMITS_AHEAD_OF_TAG = 0;
84190
84177
 
84191
84178
  // gateway/boot-version.ts
@@ -86743,6 +86730,73 @@ var deferredDoneReactions = new DeferredDoneReactions({
86743
86730
  });
86744
86731
  var outboundDedup = new OutboundDedupCache;
86745
86732
  var flushedTurnSupersede = new FlushedTurnSupersedeRegistry;
86733
+ var backstopDeliveryLedger = new BackstopDeliveryLedger;
86734
+ var BACKSTOP_DELIVERY_MAX_ATTEMPTS = (() => {
86735
+ const raw = Number(process.env.SWITCHROOM_BACKSTOP_DELIVERY_MAX_ATTEMPTS);
86736
+ return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 3;
86737
+ })();
86738
+ async function deliverAnswer(args) {
86739
+ const { chatId, turnId } = args;
86740
+ const rendered = addParagraphSpacers2(args.text);
86741
+ const chunks = splitMarkdownChunks2(rendered, RICH_MESSAGE_MAX_CHARS2);
86742
+ const deps = {
86743
+ sendRich: (opts, body, tid) => robustApiCall(() => bot.api.sendRichMessage(chatId, body, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical" }),
86744
+ sendLiteral: (opts, txt, tid) => robustApiCall(() => bot.api.sendMessage(chatId, txt, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical" }),
86745
+ sendLiteralRaw: (opts, txt) => bot.api.sendMessage(chatId, txt, opts),
86746
+ sendRichRaw: (opts, body) => bot.api.sendRichMessage(chatId, body, opts),
86747
+ editPreview: (mid, body, opts, tid) => robustApiCall(() => bot.api.editMessageText(chatId, mid, body, opts), { threadId: tid, chat_id: chatId, priorityClass: "critical", messageId: mid, editPayload: body }),
86748
+ richMessage: richMessage2,
86749
+ logOutbound,
86750
+ deleteStalePreview: async (id) => {
86751
+ await swallowingApiCall(() => bot.api.deleteMessage(chatId, id), { chat_id: chatId, verb: "deliverAnswer.deleteStalePreview" });
86752
+ },
86753
+ stderr: (s) => {
86754
+ process.stderr.write(s);
86755
+ }
86756
+ };
86757
+ let liveThreadId = args.threadId;
86758
+ const sendChunk = async (_chunkIndex, text5) => {
86759
+ const chunkIds = [];
86760
+ const res = await sendReplyChunks(deps, {
86761
+ chatId,
86762
+ chunks: [text5],
86763
+ literalText: false,
86764
+ suppressText: false,
86765
+ threadId: liveThreadId,
86766
+ previewMessageId: null,
86767
+ sentIds: chunkIds,
86768
+ buildSendOpts: (_i, _isLast, tid) => ({
86769
+ ...tid != null ? { message_thread_id: tid } : {},
86770
+ link_preview_options: { is_disabled: true }
86771
+ }),
86772
+ buildPreviewEditOpts: () => ({})
86773
+ });
86774
+ liveThreadId = res.threadId;
86775
+ return chunkIds;
86776
+ };
86777
+ const result = await runBackstopDelivery(backstopDeliveryLedger, turnId, chunks, args.cardMessageId, {
86778
+ sendChunk,
86779
+ recordOutbound: HISTORY_ENABLED ? (messageIds, texts) => {
86780
+ try {
86781
+ recordOutbound({
86782
+ chat_id: chatId,
86783
+ thread_id: args.threadId ?? null,
86784
+ message_ids: messageIds,
86785
+ texts
86786
+ });
86787
+ } catch {}
86788
+ } : undefined,
86789
+ stderr: (s) => {
86790
+ process.stderr.write(s);
86791
+ }
86792
+ }, BACKSTOP_DELIVERY_MAX_ATTEMPTS);
86793
+ return {
86794
+ sentIds: result.sentIds,
86795
+ chunkCount: result.chunkCount,
86796
+ delivered: result.delivered,
86797
+ exhausted: result.exhausted
86798
+ };
86799
+ }
86746
86800
  var chatAvailableReactions = new Map;
86747
86801
  var chatProbesInFlight = new Set;
86748
86802
  var activeTurnStartedAt = new Map;
@@ -87644,7 +87698,7 @@ function endCurrentTurnAtomic(turn, opts) {
87644
87698
  process.stderr.write(`telegram gateway: status-surface DEGRADED reason=${degraded.reason} turnId=${turn.turnId} chat=${turn.sessionChatId} thread=${turn.sessionThreadId ?? "-"} ${degraded.detail}
87645
87699
  `);
87646
87700
  }
87647
- if (OBLIGATION_LEDGER_ENABLED) {
87701
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
87648
87702
  if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === "close") {
87649
87703
  obligationLedger.close(turn.turnId);
87650
87704
  } else {
@@ -93156,7 +93210,7 @@ async function executeGetRecentMessages(args) {
93156
93210
  const rows = query({ chat_id, thread_id, limit, before_message_id });
93157
93211
  const summary = rows.map((r) => {
93158
93212
  const who = r.role === "user" ? r.user ?? "user" : "assistant";
93159
- const time = new Date(r.ts * 1000).toISOString();
93213
+ const time = fmtLocalStamp2(r.ts * 1000, resolveEnvTimezone2());
93160
93214
  const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : "";
93161
93215
  const replyCtx = r.reply_to_message_id != null ? ` \u21AA\uFE0F#${r.reply_to_message_id}${r.reply_to_text ? `:"${r.reply_to_text.slice(0, 60)}${r.reply_to_text.length > 60 ? "\u2026" : ""}"` : ""}` : "";
93162
93216
  const reactionTag = r.user_reaction ? ` [reaction: ${r.user_reaction}]` : "";
@@ -94288,16 +94342,15 @@ function handleSessionEvent(ev) {
94288
94342
  }
94289
94343
  turn.finalAnswerDelivered = true;
94290
94344
  turn.finalAnswerSubstantive = true;
94291
- if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
94292
- turn.answerDelivered = true;
94293
- }
94345
+ turn.answerDelivered = true;
94346
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId);
94294
94347
  const cardTakeover = progressDriver?.takeOverCard({
94295
94348
  chatId: backstopChatId,
94296
94349
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
94297
94350
  }) ?? { wasEmitted: false, turnKey: null };
94298
94351
  const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
94299
94352
  const backstopCardTurnKey = cardTakeover.turnKey;
94300
- const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true });
94353
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true });
94301
94354
  preambleSuppressor.dropNow();
94302
94355
  {
94303
94356
  const tKey = statusKey(chatId, threadId);
@@ -94316,80 +94369,47 @@ function handleSessionEvent(ev) {
94316
94369
  `);
94317
94370
  if (backstopTurnEndedAt != null) {
94318
94371
  turn.deliveryOutcome = "suppressed";
94372
+ if (OBLIGATION_LEDGER_ENABLED)
94373
+ obligationLedger.close(turn.turnId);
94319
94374
  emitTurnRecord(turn, backstopTurnEndedAt);
94320
94375
  }
94321
94376
  return;
94322
94377
  }
94323
94378
  } catch {}
94324
94379
  }
94380
+ if (!backstopLatchClaimed) {
94381
+ process.stderr.write(`telegram gateway: turn-flush skipped \u2014 turn ${turn.turnId} already claimed the delivery latch
94382
+ `);
94383
+ return;
94384
+ }
94325
94385
  process.stderr.write(`telegram gateway: turn-flush firing \u2014 ${capturedText.length} chars without reply tool (chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? "none"})
94326
94386
  `);
94327
- const sendOpts = {
94328
- ...backstopThreadId != null ? { message_thread_id: backstopThreadId } : {},
94329
- link_preview_options: { is_disabled: true }
94330
- };
94331
- const limit = RICH_MESSAGE_MAX_CHARS2;
94332
- let htmlChunks = [];
94333
- const sentIds = [];
94334
- let sendThrew = false;
94387
+ let sentIds = [];
94388
+ let chunkCount = 0;
94389
+ let delivered = false;
94335
94390
  try {
94336
- const renderedText = addParagraphSpacers2(capturedText);
94337
- htmlChunks = splitMarkdownChunks2(renderedText, limit);
94338
- let firstSendUsedEdit = false;
94339
- let liveThreadId = backstopThreadId;
94340
- if (backstopCardMessageId != null && htmlChunks.length > 0) {
94341
- try {
94342
- await robustApiCall(() => bot.api.editMessageText(backstopChatId, backstopCardMessageId, richMessage2(htmlChunks[0]), sendOpts), {
94343
- chat_id: backstopChatId,
94344
- verb: "turn-flush.editMessageText",
94345
- ...liveThreadId != null ? { threadId: liveThreadId } : {}
94346
- });
94347
- sentIds.push(backstopCardMessageId);
94348
- firstSendUsedEdit = true;
94349
- } catch (err) {
94350
- process.stderr.write(`telegram gateway: turn-flush card-takeover edit failed: ${err.message} \u2014 falling back to sendMessage
94351
- `);
94352
- if (err instanceof Error && err.message === "THREAD_NOT_FOUND") {
94353
- liveThreadId = undefined;
94354
- }
94355
- }
94356
- }
94357
- const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks;
94358
- for (const c of remainingChunks) {
94359
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
94360
- const opts = {
94361
- link_preview_options: { is_disabled: true },
94362
- ...tid != null ? { message_thread_id: tid } : {}
94363
- };
94364
- return bot.api.sendRichMessage(backstopChatId, richMessage2(c), opts);
94365
- }, {
94366
- threadId: liveThreadId,
94367
- chat_id: backstopChatId,
94368
- verb: "turn-flush.sendMessage"
94369
- });
94370
- if (liveThreadId != null) {
94371
- const sentMsg = sent;
94372
- if (sentMsg.message_thread_id == null)
94373
- liveThreadId = undefined;
94374
- }
94375
- sentIds.push(sent.message_id);
94376
- }
94377
- if (HISTORY_ENABLED && sentIds.length > 0) {
94378
- try {
94379
- recordOutbound({
94380
- chat_id: backstopChatId,
94381
- thread_id: backstopThreadId ?? null,
94382
- message_ids: sentIds,
94383
- texts: htmlChunks
94384
- });
94385
- } catch {}
94386
- }
94391
+ const delivery = await deliverAnswer({
94392
+ chatId: backstopChatId,
94393
+ threadId: backstopThreadId,
94394
+ text: capturedText,
94395
+ turnId: turn.turnId,
94396
+ cardMessageId: backstopCardMessageId
94397
+ });
94398
+ sentIds = delivery.sentIds;
94399
+ chunkCount = delivery.chunkCount;
94400
+ delivered = delivery.delivered;
94387
94401
  outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
94388
94402
  if (sentIds.length > 0) {
94389
94403
  flushedTurnSupersede.record(backstopChatId, backstopThreadId, { turnId: turn.turnId, messageIds: sentIds, text: capturedText }, Date.now());
94390
94404
  }
94391
- if (backstopCtrl)
94405
+ if (!delivered) {
94406
+ if (backstopCtrl)
94407
+ backstopCtrl.finalize("error");
94408
+ backstopDeliveryLedger.release(turn.turnId);
94409
+ turn.answerDelivered = false;
94410
+ } else if (backstopCtrl) {
94392
94411
  backstopCtrl.finalize("done");
94412
+ }
94393
94413
  if (backstopCardTurnKey != null) {
94394
94414
  completeProgressCardTurn?.({
94395
94415
  chatId: backstopChatId,
@@ -94400,21 +94420,33 @@ function handleSessionEvent(ev) {
94400
94420
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId);
94401
94421
  }
94402
94422
  } catch (err) {
94403
- sendThrew = true;
94404
- process.stderr.write(`telegram gateway: turn-flush send failed: ${err.message}
94423
+ process.stderr.write(`telegram gateway: turn-flush post-delivery bookkeeping failed: ${err.message}
94405
94424
  `);
94406
- turn.answerDelivered = false;
94407
- if (backstopCtrl)
94408
- backstopCtrl.finalize("error");
94425
+ if (!delivered) {
94426
+ turn.answerDelivered = false;
94427
+ backstopDeliveryLedger.release(turn.turnId);
94428
+ if (backstopCtrl)
94429
+ backstopCtrl.finalize("error");
94430
+ }
94409
94431
  } finally {
94410
94432
  if (backstopTurnEndedAt != null) {
94411
- finalizeBackstopSend(turn, {
94412
- threw: sendThrew,
94413
- sentCount: sentIds.length,
94414
- chunkCount: htmlChunks.length
94433
+ finalizeBackstopSendGated(turn, {
94434
+ threw: !delivered,
94435
+ sentIds,
94436
+ chunkCount,
94437
+ cardMessageId: backstopCardMessageId
94415
94438
  });
94439
+ if (OBLIGATION_LEDGER_ENABLED) {
94440
+ if (delivered) {
94441
+ obligationLedger.close(turn.turnId);
94442
+ } else {
94443
+ turn.finalAnswerDelivered = false;
94444
+ obligationLedger.noteTurnEnded(turn.turnId, Date.now());
94445
+ }
94446
+ }
94416
94447
  emitTurnRecord(turn, backstopTurnEndedAt);
94417
94448
  }
94449
+ backstopDeliveryLedger.clear(turn.turnId);
94418
94450
  }
94419
94451
  })();
94420
94452
  return;
@@ -95469,7 +95501,7 @@ ${preBlock(write.output)}`;
95469
95501
  ...msgId != null ? { message_id: String(msgId) } : {},
95470
95502
  user: displayUser,
95471
95503
  user_id: String(from.id),
95472
- ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
95504
+ ts: fmtLocalStamp2((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone2()),
95473
95505
  ...messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {},
95474
95506
  ...originTurnId != null ? { origin_turn_id: originTurnId } : {},
95475
95507
  ...topicScope != null ? { topic_scope: topicScope } : {},
@@ -96611,7 +96643,6 @@ function buildModelDeps(restartCtx) {
96611
96643
  return [];
96612
96644
  }
96613
96645
  },
96614
- select: (a, label) => selectModel(a, label),
96615
96646
  isBusy: () => currentTurn !== null,
96616
96647
  getAgentName: getMyAgentName,
96617
96648
  getQuotaBrief: async () => {
@@ -96630,14 +96661,12 @@ function buildModelDeps(restartCtx) {
96630
96661
  } catch {}
96631
96662
  return null;
96632
96663
  },
96633
- inject: injectSlashCommand,
96634
96664
  getConfiguredModel: () => {
96635
96665
  const data = switchroomExecJson(["agent", "list"]);
96636
96666
  return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
96637
96667
  },
96638
96668
  escapeHtml: escapeHtmlForTg2,
96639
96669
  preBlock,
96640
- getActiveSessionModel: () => sessionModelSource.getOverride(),
96641
96670
  scheduleRestart: async (reason) => {
96642
96671
  const name = getMyAgentName();
96643
96672
  const existing = readRestartMarker();
@@ -96680,6 +96709,30 @@ function buildModelDeps(restartCtx) {
96680
96709
  const prevFileRaw = readSessionModelFileRaw(agentDir);
96681
96710
  writeSessionModelFile(agentDir, model, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
96682
96711
  sessionModelSource.setOverride(model);
96712
+ process.stderr.write(`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=${model} reason=${JSON.stringify(reason)}
96713
+ `);
96714
+ clearPremiumRecoveryOnManualSwitch(model);
96715
+ try {
96716
+ await deps.scheduleRestart(reason);
96717
+ } catch (err) {
96718
+ if (err?.code !== "restart_in_flight") {
96719
+ restoreSessionModelFileRaw(agentDir, prevFileRaw);
96720
+ sessionModelSource.setOverride(prevOverride);
96721
+ }
96722
+ throw err;
96723
+ }
96724
+ },
96725
+ scheduleModelDefaultRelaunch: async (reason) => {
96726
+ const agentDir = resolveAgentDirFromEnv();
96727
+ if (!agentDir)
96728
+ throw new Error("agent dir unresolvable \u2014 cannot clear session-model file");
96729
+ const prevOverride = sessionModelSource.getOverride();
96730
+ const prevFileRaw = readSessionModelFileRaw(agentDir);
96731
+ clearSessionModelFile(agentDir);
96732
+ sessionModelSource.setOverride(null);
96733
+ process.stderr.write(`telegram gateway: gw /model relaunch scheduled agent=${getMyAgentName()} token=(default) reason=${JSON.stringify(reason)}
96734
+ `);
96735
+ clearPremiumRecoveryOnManualSwitch(null);
96683
96736
  try {
96684
96737
  await deps.scheduleRestart(reason);
96685
96738
  } catch (err) {
@@ -96704,60 +96757,6 @@ function modelMenuReplyMarkup(reply) {
96704
96757
  }
96705
96758
  return kb;
96706
96759
  }
96707
- function recordTypedModelSwitch(reply, requestedModelArg, _deps) {
96708
- const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null;
96709
- if (requested?.toLowerCase() === "default") {
96710
- const smDir = resolveAgentDirFromEnv();
96711
- if (smDir)
96712
- clearSessionModelFile(smDir);
96713
- if (reply.selectedModel)
96714
- sessionModelSource.setOverride(null);
96715
- return "";
96716
- }
96717
- if (!reply.selectedModel)
96718
- return "";
96719
- sessionModelSource.setOverride(reply.selectedModel);
96720
- clearPremiumRecoveryOnManualSwitch(reply.selectedModel);
96721
- return "";
96722
- }
96723
- function recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel) {
96724
- if (outcome.selectedModel) {
96725
- sessionModelSource.setOverride(outcome.selectedModel);
96726
- clearPremiumRecoveryOnManualSwitch(outcome.selectedModel);
96727
- }
96728
- if (outcome.clearedDefault) {
96729
- const smDir = resolveAgentDirFromEnv();
96730
- if (smDir)
96731
- clearSessionModelFile(smDir);
96732
- }
96733
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
96734
- const agentName3 = getMyAgentName();
96735
- const agentDir = resolveAgentDirFromEnv();
96736
- const token = outcome.selectedModelToken;
96737
- if (agentDir && token) {
96738
- try {
96739
- writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
96740
- sessionModelSource.setOverride(token);
96741
- } catch (e) {
96742
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
96743
- `);
96744
- }
96745
- } else if (agentDir) {
96746
- clearSessionModelFile(agentDir);
96747
- }
96748
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
96749
- stampUserRestartReason("user: sr-to-claude model switch (menu)");
96750
- if (turnInFlightForGate()) {
96751
- pendingRestarts.set(agentName3, Date.now());
96752
- } else {
96753
- sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
96754
- }
96755
- return {
96756
- restartNotice: `\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`
96757
- };
96758
- }
96759
- return {};
96760
- }
96761
96760
  async function editPendingCommandCard(chatId, messageId, text5) {
96762
96761
  try {
96763
96762
  await robustApiCall(() => lockedBot.api.editMessageText(chatId, messageId, richMessage2(hardenCardBreaks2(text5)), {
@@ -96779,14 +96778,11 @@ function enqueueSessionCommand(cmd) {
96779
96778
  async function applyQueuedModelCommand(cmd) {
96780
96779
  const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId });
96781
96780
  if (cmd.origin === "menu") {
96782
- const prevSessionModel = sessionModelSource.getOverride();
96783
96781
  const outcome = await handleModelMenuCallback(cmd.arg, deps);
96784
- const { restartNotice } = recordModelMenuSideEffects(outcome, deps, cmd.chatId, cmd.threadId, prevSessionModel);
96785
- return restartNotice ?? outcome.reply.text;
96782
+ return outcome.reply.text;
96786
96783
  }
96787
96784
  const reply = await handleModelCommand({ kind: "set", model: cmd.arg }, deps);
96788
- const warning = recordTypedModelSwitch(reply, cmd.arg, deps);
96789
- return reply.text + warning;
96785
+ return reply.text;
96790
96786
  }
96791
96787
  async function applyQueuedEffortCommand(cmd) {
96792
96788
  const deps = buildEffortDeps();
@@ -96924,8 +96920,7 @@ bot.command("model", async (ctx) => {
96924
96920
  return;
96925
96921
  }
96926
96922
  const reply = await handleModelCommand(parsed, deps);
96927
- const persistWarning = recordTypedModelSwitch(reply, parsed.kind === "set" ? parsed.model : null, deps);
96928
- await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html });
96923
+ await switchroomReply(ctx, reply.text, { html: reply.html });
96929
96924
  });
96930
96925
  var sessionEffortOverride = null;
96931
96926
  function buildEffortDeps() {
@@ -98979,31 +98974,10 @@ bot.on("callback_query:data", async (ctx) => {
98979
98974
  }
98980
98975
  const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN;
98981
98976
  await ctx.answerCallbackQuery({ text: isPageNav ? "Loading\u2026" : "Switching\u2026" }).catch(() => {});
98982
- if (data.startsWith(MODEL_CALLBACK_SR)) {
98983
- const srName = data.slice(MODEL_CALLBACK_SR.length);
98984
- const srLabel = escapeHtmlForTg2(srFriendlyLabel(srName));
98985
- if (!isValidModelArg(srName)) {
98986
- await ctx.editMessageText(richMessage2("\u274C Invalid model name"), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98987
- return;
98988
- }
98989
- await ctx.editMessageText(richMessage2(`\uD83D\uDD04 Switching session to **${srLabel}** \u2014 restarting (~30s). _Session-only; reverts to the configured default on the next restart._`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98990
- try {
98991
- await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
98992
- } catch (err) {
98993
- await ctx.editMessageText(richMessage2(`\u274C Could not switch to **${srLabel}**: ${escapeHtmlForTg2(err?.message ?? String(err))}`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98994
- }
98995
- return;
98996
- }
98997
98977
  try {
98998
- const prevSessionModel = sessionModelSource.getOverride();
98999
98978
  const outcome = await handleModelMenuCallback(data, modelDeps);
99000
98979
  if (outcome.toastOnly)
99001
98980
  return;
99002
- const { restartNotice } = recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel);
99003
- if (restartNotice) {
99004
- await ctx.editMessageText(richMessage2(restartNotice), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
99005
- return;
99006
- }
99007
98981
  await ctx.editMessageText(richMessage2(outcome.reply.text), {
99008
98982
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
99009
98983
  }).catch(() => {});
@@ -100708,6 +100682,8 @@ var didOneTimeSetup = false;
100708
100682
  `);
100709
100683
  }
100710
100684
  } catch {}
100685
+ let modelSwitchMarkerChat = null;
100686
+ let modelSwitchReason = null;
100711
100687
  try {
100712
100688
  const nowMs2 = Date.now();
100713
100689
  const marker = readRestartMarker();
@@ -100737,6 +100713,12 @@ var didOneTimeSetup = false;
100737
100713
  const ageSec = Math.max(1, Math.round(ageMs / 1000));
100738
100714
  process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 300000}
100739
100715
  `);
100716
+ if (ageMs < 300000) {
100717
+ modelSwitchMarkerChat = { chatId: marker.chat_id, threadId: marker.thread_id };
100718
+ if (typeof cleanMarker?.reason === "string" && cleanMarker.reason.startsWith("user: /model")) {
100719
+ modelSwitchReason = cleanMarker.reason;
100720
+ }
100721
+ }
100740
100722
  clearRestartMarker();
100741
100723
  }
100742
100724
  if (!isRealRestart) {
@@ -100762,7 +100744,11 @@ var didOneTimeSetup = false;
100762
100744
  firstSeenAt: new Date
100763
100745
  });
100764
100746
  }
100765
- if (target) {
100747
+ const suppressBootCardForModelSwitch = modelSwitchReason != null && modelSwitchMarkerChat != null;
100748
+ if (target && suppressBootCardForModelSwitch) {
100749
+ process.stderr.write(`telegram gateway: boot: suppressing generic boot card \u2014 /model switch apply-boot (reason=${JSON.stringify(modelSwitchReason)}); the confirmation card replaces it
100750
+ `);
100751
+ } else if (target) {
100766
100752
  const { chatId, threadId, ackMsgId } = target;
100767
100753
  const bootDedupe = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, "boot");
100768
100754
  if (bootDedupe.skip) {
@@ -100841,7 +100827,19 @@ var didOneTimeSetup = false;
100841
100827
  const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
100842
100828
  return resolveMainModel(raw ?? undefined);
100843
100829
  })();
100844
- sessionModelSource.setOverride(launched.length > 0 && launched !== configured ? launched : null);
100830
+ const isApplyBoot = launched.length > 0 && launched !== configured;
100831
+ sessionModelSource.setOverride(isApplyBoot ? launched : null);
100832
+ process.stderr.write(`telegram gateway: gw /model relaunch applied agent=${getMyAgentName()} launched=${launched || "(none)"} configured=${configured} override=${isApplyBoot ? "set" : "cleared"}
100833
+ `);
100834
+ if (modelSwitchReason != null && modelSwitchMarkerChat) {
100835
+ const chat = modelSwitchMarkerChat;
100836
+ const body = isApplyBoot ? `\u2705 Now running \`${launched}\` \u2014 session-only, reverts to the configured model on the next restart. Fresh session; memory and the handoff briefing carry the context.` : `\u2705 Now running \`${launched || configured}\` (the configured default) \u2014 fresh session; memory and the handoff briefing carry the context.`;
100837
+ lockedBot.api.sendMessage(chat.chatId, body, {
100838
+ parse_mode: "Markdown",
100839
+ ...chat.threadId != null ? { message_thread_id: chat.threadId } : {}
100840
+ }).catch((err) => process.stderr.write(`telegram gateway: model-switch confirmation send failed: ${err?.message ?? String(err)}
100841
+ `));
100842
+ }
100845
100843
  } catch {}
100846
100844
  }
100847
100845
  const activeEffortPath = join55(smAgentDir, ".active-session-effort");