switchroom 0.18.27 → 0.18.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/handoff-briefing.sh +8 -1
  2. package/dist/auth-broker/index.js +0 -57
  3. package/dist/cli/switchroom.js +501 -497
  4. package/dist/host-control/main.js +1 -58
  5. package/dist/vault/approvals/kernel-server.js +0 -57
  6. package/dist/vault/broker/server.js +0 -57
  7. package/package.json +1 -1
  8. package/profiles/_base/start.sh.hbs +37 -19
  9. package/telegram-plugin/dist/gateway/gateway.js +655 -587
  10. package/telegram-plugin/gateway/backstop-delivery.ts +272 -0
  11. package/telegram-plugin/gateway/forward-origin.ts +9 -1
  12. package/telegram-plugin/gateway/gateway.ts +511 -397
  13. package/telegram-plugin/gateway/model-command.ts +227 -602
  14. package/telegram-plugin/gateway/turn-record-status.ts +45 -0
  15. package/telegram-plugin/gateway/worker-pin-reaper.ts +54 -0
  16. package/telegram-plugin/history.ts +153 -23
  17. package/telegram-plugin/shared/local-time.ts +56 -0
  18. package/telegram-plugin/tests/backstop-delivery.test.ts +250 -0
  19. package/telegram-plugin/tests/forward-origin.test.ts +30 -3
  20. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +86 -59
  21. package/telegram-plugin/tests/history.test.ts +88 -0
  22. package/telegram-plugin/tests/local-time.test.ts +68 -0
  23. package/telegram-plugin/tests/model-command.test.ts +317 -1535
  24. package/telegram-plugin/tests/turn-flush-safety.test.ts +34 -0
  25. package/telegram-plugin/tests/worker-feed-migration-eviction.test.ts +140 -0
  26. package/telegram-plugin/tests/worker-pin-reaper.test.ts +78 -0
  27. package/telegram-plugin/tier-downgrade.ts +4 -3
  28. package/telegram-plugin/turn-flush-safety.ts +25 -1
  29. package/telegram-plugin/worker-activity-feed.ts +78 -5
  30. package/vendor/hindsight-memory/scripts/lib/content.py +40 -6
  31. package/vendor/hindsight-memory/tests/test_content.py +28 -7
@@ -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",
@@ -41118,6 +41275,12 @@ function createWorkerActivityFeed(opts) {
41118
41275
  agentIndex.delete(agentId);
41119
41276
  maybeDeleteGroup(g);
41120
41277
  }
41278
+ function evictRowFromGroup(g, agentId) {
41279
+ g.workers.delete(agentId);
41280
+ if (agentIndex.get(agentId) === g.feedKey)
41281
+ agentIndex.delete(agentId);
41282
+ maybeDeleteGroup(g);
41283
+ }
41121
41284
  function maybeDeleteGroup(g) {
41122
41285
  if (g.workers.size === 0 && g.pendingFinalize.size === 0)
41123
41286
  groups.delete(g.feedKey);
@@ -41309,7 +41472,7 @@ function createWorkerActivityFeed(opts) {
41309
41472
  if (row.finished)
41310
41473
  staleFinished.push({ g, agentId: row.agentId, reason });
41311
41474
  else
41312
- staleAgentIds.push({ agentId: row.agentId, reason });
41475
+ staleAgentIds.push({ g, agentId: row.agentId, reason });
41313
41476
  }
41314
41477
  }
41315
41478
  for (const { g, agentId, reason } of staleFinished) {
@@ -41323,15 +41486,22 @@ function createWorkerActivityFeed(opts) {
41323
41486
  removeWorker(g, agentId);
41324
41487
  syncPin(g);
41325
41488
  }
41326
- for (const { agentId, reason } of staleAgentIds) {
41327
- const row = groupOfAgent(agentId)?.workers.get(agentId);
41489
+ for (const { g, agentId, reason } of staleAgentIds) {
41490
+ const row = g.workers.get(agentId);
41328
41491
  if (reason === "absolute") {
41329
41492
  const age = Math.floor((now - (row?.createdAtMs ?? now)) / 1000);
41330
41493
  log(`worker-feed: ABSOLUTE cap reap agent=${agentId} \u2014 row age ${age}s (>= ${Math.floor(absoluteRowLifetimeCapMs / 1000)}s); force-terminating immortal row (survives lastUpdateAt reset)`);
41331
41494
  } else {
41332
41495
  log(`worker-feed: TTL reap agent=${agentId} \u2014 no update in ${Math.floor((now - (row?.lastUpdateAt ?? now)) / 1000)}s (>= ${Math.floor(staleWorkerTtlMs / 1000)}s); force-terminating leaked row`);
41333
41496
  }
41334
- terminateWorker(agentId);
41497
+ if (groupOfAgent(agentId) === g) {
41498
+ terminateWorker(agentId);
41499
+ } else {
41500
+ log(`worker-feed: force-evicting leaked row agent=${agentId} feed=${g.feedKey} \u2014 agentIndex desynced (points elsewhere); removing directly`);
41501
+ markFinalized(agentId);
41502
+ evictRowFromGroup(g, agentId);
41503
+ syncPin(g);
41504
+ }
41335
41505
  }
41336
41506
  for (const g of [...groups.values()]) {
41337
41507
  if (g.pendingFinalize.size > 0 && now >= g.cooldownUntil) {
@@ -41378,6 +41548,14 @@ function createWorkerActivityFeed(opts) {
41378
41548
  }
41379
41549
  }
41380
41550
  heartbeatTimer = setIntervalFn(heartbeatTick, heartbeatTickMs);
41551
+ opts.exposeTestControls?.({
41552
+ repointAgentIndex: (agentId, feedKey) => {
41553
+ if (feedKey == null)
41554
+ agentIndex.delete(agentId);
41555
+ else
41556
+ agentIndex.set(agentId, feedKey);
41557
+ }
41558
+ });
41381
41559
  return {
41382
41560
  has(agentId) {
41383
41561
  const g = groupOfAgent(agentId);
@@ -41405,6 +41583,14 @@ function createWorkerActivityFeed(opts) {
41405
41583
  if (existingRow?.finished === true)
41406
41584
  return Promise.resolve();
41407
41585
  const feedKey = feedKeyOf(chatId, threadId);
41586
+ const priorFeedKey = agentIndex.get(agentId);
41587
+ if (priorFeedKey != null && priorFeedKey !== feedKey) {
41588
+ const priorGroup = groups.get(priorFeedKey);
41589
+ if (priorGroup != null) {
41590
+ evictRowFromGroup(priorGroup, agentId);
41591
+ syncPin(priorGroup);
41592
+ }
41593
+ }
41408
41594
  let g = groups.get(feedKey);
41409
41595
  if (g == null) {
41410
41596
  g = {
@@ -57142,40 +57328,6 @@ function sendGateConfigFromEnv(env = process.env) {
57142
57328
  return out;
57143
57329
  }
57144
57330
 
57145
- // shared/local-time.ts
57146
- function fmtLocalClock(ms, tz) {
57147
- return new Intl.DateTimeFormat("en-US", {
57148
- timeZone: tz,
57149
- hour: "numeric",
57150
- minute: "2-digit",
57151
- hour12: true
57152
- }).format(new Date(ms)).replace(/\s([AP])M$/, (_m, p) => `${p.toLowerCase()}m`);
57153
- }
57154
- function fmtLocalDate(ms, tz) {
57155
- return new Intl.DateTimeFormat("en-GB", {
57156
- timeZone: tz,
57157
- day: "numeric",
57158
- month: "short"
57159
- }).format(new Date(ms));
57160
- }
57161
- function localDay(ms, tz) {
57162
- return new Intl.DateTimeFormat("en-CA", {
57163
- timeZone: tz,
57164
- year: "numeric",
57165
- month: "2-digit",
57166
- day: "2-digit"
57167
- }).format(new Date(ms));
57168
- }
57169
- function tzAbbrev(ms, tz) {
57170
- const at = new Date(ms);
57171
- for (const loc of ["en-US", "en-AU", "en-GB"]) {
57172
- const v = new Intl.DateTimeFormat(loc, { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value;
57173
- if (v && !/^(?:GMT|UTC)/i.test(v))
57174
- return v;
57175
- }
57176
- return new Intl.DateTimeFormat("en-US", { timeZone: tz, timeZoneName: "short" }).formatToParts(at).find((p) => p.type === "timeZoneName")?.value ?? tz;
57177
- }
57178
-
57179
57331
  // send-gate-observability.ts
57180
57332
  function formatStatsLine(stats) {
57181
57333
  const c = stats.global;
@@ -57210,7 +57362,7 @@ function createStatsLogger(config) {
57210
57362
  function isAlertableScope(scopeKey) {
57211
57363
  return scopeKey === "global" || scopeKey.startsWith("chat:") || scopeKey.startsWith("group:");
57212
57364
  }
57213
- function fmtLocalStamp(ms, tz, refMs) {
57365
+ function fmtLocalStamp3(ms, tz, refMs) {
57214
57366
  const clock = fmtLocalClock(ms, tz);
57215
57367
  const sameDay = refMs != null && localDay(ms, tz) === localDay(refMs, tz);
57216
57368
  const body = sameDay ? clock : `${fmtLocalDate(ms, tz)} ${clock}`;
@@ -57254,7 +57406,7 @@ function createFloodWindowObserver(config) {
57254
57406
  }
57255
57407
  function openAlertText(w, now) {
57256
57408
  const openFor = fmtDur(now - w.observedAt);
57257
- 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.`;
57258
57410
  }
57259
57411
  function closeAlertText(recs) {
57260
57412
  const observedAt = Math.min(...recs.map((r) => r.observedAt));
@@ -63285,6 +63437,7 @@ function endsWithSilentMarker(text4) {
63285
63437
  return false;
63286
63438
  return isSilentFlushMarker(lines[lines.length - 1]);
63287
63439
  }
63440
+ var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
63288
63441
  function selectFlushDeliveryText(blocks) {
63289
63442
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
63290
63443
  if (candidates.length === 0)
@@ -63299,8 +63452,18 @@ function selectFlushDeliveryText(blocks) {
63299
63452
  `);
63300
63453
  }
63301
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
+ }
63302
63465
  function isNarrationBlock(block) {
63303
- return NARRATION_OPENER.test(block.trimStart());
63466
+ return NARRATION_OPENER.test(block.trimStart()) || isTrailingNarrationLine(block);
63304
63467
  }
63305
63468
  function decideTurnFlush(input) {
63306
63469
  const flushEnabled = input.flushEnabled !== false;
@@ -69185,7 +69348,7 @@ function endsWithSilentMarker2(text4) {
69185
69348
  return false;
69186
69349
  return isSilentFlushMarker2(lines[lines.length - 1]);
69187
69350
  }
69188
- var FLUSH_SUBSTANTIVE_MIN_CHARS = 200;
69351
+ var FLUSH_SUBSTANTIVE_MIN_CHARS2 = 200;
69189
69352
  function selectFlushDeliveryText2(blocks) {
69190
69353
  const candidates = blocks.map((b) => b.trim()).filter((b) => b.length > 0);
69191
69354
  if (candidates.length === 0)
@@ -69200,8 +69363,18 @@ function selectFlushDeliveryText2(blocks) {
69200
69363
  `);
69201
69364
  }
69202
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
+ }
69203
69376
  function isNarrationBlock2(block) {
69204
- return NARRATION_OPENER2.test(block.trimStart());
69377
+ return NARRATION_OPENER2.test(block.trimStart()) || isTrailingNarrationLine2(block);
69205
69378
  }
69206
69379
  function decideTurnFlush2(input) {
69207
69380
  const flushEnabled = input.flushEnabled !== false;
@@ -71136,16 +71309,6 @@ Allowed: \`${deps.escapeHtml(allow)}\``, { html: true });
71136
71309
  await deps.reply(ctx, finalText, { html: true, accent });
71137
71310
  }
71138
71311
 
71139
- // ../src/agents/model-picker.ts
71140
- function labelTag(label) {
71141
- let h = 2166136261;
71142
- for (const ch of label) {
71143
- h ^= ch.codePointAt(0);
71144
- h = Math.imul(h, 16777619) >>> 0;
71145
- }
71146
- return h.toString(16).padStart(8, "0");
71147
- }
71148
-
71149
71312
  // gateway/model-command.ts
71150
71313
  var MODEL_ALIASES = ["opus", "sonnet", "haiku", "fable", "default"];
71151
71314
  var MODEL_ARG_RE = /^[A-Za-z0-9][A-Za-z0-9._/\[\]-]{0,99}$/;
@@ -71155,12 +71318,6 @@ function isValidModelArg(arg) {
71155
71318
  function isSrModel(name) {
71156
71319
  return name.startsWith("sr-");
71157
71320
  }
71158
- function isClaudeModel(name) {
71159
- const lower = name.toLowerCase();
71160
- if (MODEL_ALIASES.includes(lower))
71161
- return true;
71162
- return lower.startsWith("claude-");
71163
- }
71164
71321
  function parseModelCommand(text4) {
71165
71322
  const m = text4.match(/^\/model(?:@[A-Za-z0-9_]+)?(?:\s+([\s\S]*))?$/);
71166
71323
  if (!m)
@@ -71204,13 +71361,13 @@ function modelCommandReceiptLine(agent, parsed, busy) {
71204
71361
  const arg = parsed.kind === "set" ? parsed.model : parsed.kind === "show" ? "(show)" : "(help)";
71205
71362
  return `telegram gateway: gw /model received agent=${agent} kind=${parsed.kind} arg=${arg} busy=${busy}`;
71206
71363
  }
71207
- 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._";
71208
71365
  function helpText2(deps, reason) {
71209
71366
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `\`${a}\``).join(" \u00b7 ");
71210
71367
  const lines = [];
71211
71368
  if (reason)
71212
71369
  lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
71213
- 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);
71214
71371
  return { text: lines.join(`
71215
71372
  `), html: true };
71216
71373
  }
@@ -71244,132 +71401,46 @@ async function handleModelCommand(parsed, deps) {
71244
71401
  html: true
71245
71402
  };
71246
71403
  }
71247
- const currentSession = deps.getActiveSessionModel();
71248
- if (currentSession !== null && isSrModel(currentSession) && isClaudeModel(model)) {
71249
- try {
71250
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (sr-to-claude restart)`);
71251
- } catch (err) {
71252
- if (isRestartInFlight(err)) {
71253
- return {
71254
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71255
- html: true
71256
- };
71257
- }
71258
- const msg = err instanceof Error ? err.message : String(err);
71259
- return {
71260
- text: `\u274c Could not schedule restart: ${deps.escapeHtml(msg)}`,
71261
- html: true
71262
- };
71263
- }
71264
- return {
71265
- text: [
71266
- `Switching from \`${deps.escapeHtml(currentSession)}\` back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`,
71267
- PERSIST_NOTE
71268
- ].join(`
71269
- `),
71270
- html: true
71271
- };
71404
+ if (model.toLowerCase() === "default") {
71405
+ return scheduleDefaultRelaunchReply(deps, "user: /model default (revert relaunch)");
71272
71406
  }
71273
- if (isSrModel(model)) {
71274
- try {
71275
- await deps.scheduleModelRelaunch(model, `user: /model ${model} (session-only relaunch)`);
71276
- } catch (err) {
71277
- if (isRestartInFlight(err)) {
71278
- return {
71279
- text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71280
- html: true
71281
- };
71282
- }
71283
- const msg = err instanceof Error ? err.message : String(err);
71284
- return {
71285
- text: `\u274c Could not schedule model switch: ${deps.escapeHtml(msg)}`,
71286
- html: true
71287
- };
71288
- }
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)) {
71289
71415
  return {
71290
- text: [
71291
- `Switching to \`${deps.escapeHtml(model)}\` \u2014 restarting session (~30s).`,
71292
- PERSIST_NOTE
71293
- ].join(`
71294
- `),
71416
+ text: `\u23f3 A restart is already in flight \u2014 your switch to \`${deps.escapeHtml(model)}\` will apply as it completes (~15s).`,
71295
71417
  html: true
71296
71418
  };
71297
71419
  }
71298
- const verbHtml = `\`/model ${deps.escapeHtml(model)}\``;
71299
- 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) {
71300
71424
  try {
71301
- result = await deps.inject(deps.getAgentName(), `/model ${model}`, {
71302
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71303
- errorPattern: MODEL_SWITCH_ERROR_RE,
71304
- settleBeforeSendMs: 1500
71305
- });
71425
+ await deps.scheduleModelRelaunch(model, reason);
71306
71426
  } catch (err) {
71307
- const msg = err instanceof Error ? err.message : String(err);
71308
- return {
71309
- text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`,
71310
- html: true
71311
- };
71427
+ return relaunchErrorReply(deps, model, err);
71312
71428
  }
71313
- if (result.outcome === "ok" || result.outcome === "ok_no_output") {
71314
- const confirmation = result.outcome === "ok" ? modelSwitchConfirmationLine(result.output) : null;
71315
- if (confirmation) {
71316
- if (isKeptModelConfirmation(confirmation)) {
71317
- return {
71318
- text: [
71319
- `${verbHtml}`,
71320
- deps.preBlock(confirmation),
71321
- ...result.truncated ? ["_truncated_"] : [],
71322
- PERSIST_NOTE
71323
- ].join(`
71324
- `),
71325
- html: true
71326
- };
71327
- }
71328
- const confirmed = sessionModelFromConfirmation(confirmation) ?? model;
71329
- return {
71330
- text: [
71331
- `${verbHtml}`,
71332
- deps.preBlock(confirmation),
71333
- ...result.truncated ? ["_truncated_"] : [],
71334
- PERSIST_NOTE
71335
- ].join(`
71336
- `),
71337
- html: true,
71338
- selectedModel: confirmed
71339
- };
71340
- }
71341
- const errLine = result.outcome === "ok" ? modelSwitchErrorLine(result.output) : null;
71342
- if (errLine) {
71343
- return {
71344
- text: [
71345
- `\u274c ${verbHtml} \u2014 the switch did not take:`,
71346
- deps.preBlock(errLine),
71347
- "Check `/model` for a valid, available model."
71348
- ].join(`
71349
- `),
71350
- html: true
71351
- };
71352
- }
71353
- const optimisticLabel = optimisticModelRecordLabel(model);
71354
- return {
71355
- text: [
71356
- `${verbHtml} \u2014 sent, but couldn't read a confirmation line. \`/status\` will show the live model once it's confirmed.`,
71357
- PERSIST_NOTE
71358
- ].join(`
71359
- `),
71360
- html: true,
71361
- selectedModel: optimisticLabel,
71362
- optimistic: true
71363
- };
71364
- }
71365
- if (result.errorCode === "session_missing") {
71366
- return {
71367
- 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.",
71368
- html: true
71369
- };
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);
71370
71437
  }
71371
71438
  return {
71372
- 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
+ `),
71373
71444
  html: true
71374
71445
  };
71375
71446
  }
@@ -71422,15 +71493,6 @@ function expandSrAlias(arg) {
71422
71493
  function srFriendlyLabel(srName) {
71423
71494
  return SR_MODEL_LABELS[srName] ?? srName.replace(/^sr-/, "").replace(/-/g, " ");
71424
71495
  }
71425
- function optimisticModelRecordLabel(token) {
71426
- if (isSrModel(token))
71427
- return token;
71428
- const lower = token.toLowerCase();
71429
- if (MODEL_ALIASES.includes(lower)) {
71430
- return lower.charAt(0).toUpperCase() + lower.slice(1);
71431
- }
71432
- return token;
71433
- }
71434
71496
  function classifyDiscoveredOptions(options) {
71435
71497
  return {
71436
71498
  claude: options.filter((o) => !o.label.startsWith("sr-") && !o.label.includes("/") && (/^[A-Z]/.test(o.label) || o.label.startsWith("claude-"))),
@@ -71438,7 +71500,19 @@ function classifyDiscoveredOptions(options) {
71438
71500
  };
71439
71501
  }
71440
71502
  function modelSelectCallbackData(label) {
71441
- 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;
71442
71516
  }
71443
71517
  var BUSY_REFUSAL_TEXT = "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Your tap was not applied.";
71444
71518
  function isBusyRefusalText(text4) {
@@ -71588,75 +71662,28 @@ async function handleModelMenuCallback(data, deps) {
71588
71662
  if (data === MODEL_CALLBACK_PAGE_MAIN) {
71589
71663
  return { answer: "Back", reply: await buildModelMenu(deps, "main") };
71590
71664
  }
71591
- if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
71592
- const alias = data.slice(MODEL_CALLBACK_ALIAS.length);
71593
- if (!isValidModelArg(alias)) {
71594
- return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71595
- }
71596
- if (deps.isBusy()) {
71597
- return {
71598
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71599
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71600
- toastOnly: true,
71601
- busyRefusal: true
71602
- };
71603
- }
71604
- let aliasResult;
71605
- try {
71606
- aliasResult = await deps.inject(deps.getAgentName(), `/model ${alias}`, {
71607
- successPattern: MODEL_SWITCH_CONFIRMATION_PREFIX,
71608
- errorPattern: MODEL_SWITCH_ERROR_RE,
71609
- settleBeforeSendMs: 1500
71610
- });
71611
- } catch (err) {
71612
- const msg = err instanceof Error ? err.message : String(err);
71613
- return {
71614
- answer: "Switch failed",
71615
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed: ${deps.escapeHtml(msg)}`)
71616
- };
71617
- }
71618
- if (aliasResult.outcome === "ok" || aliasResult.outcome === "ok_no_output") {
71619
- const confirmation = aliasResult.outcome === "ok" ? modelSwitchConfirmationLine(aliasResult.output) : null;
71620
- if (confirmation) {
71621
- const kept = isKeptModelConfirmation(confirmation);
71622
- return {
71623
- answer: confirmation,
71624
- reply: await menuWithBannerStatic(deps, `\u2705 ${deps.escapeHtml(confirmation)}`),
71625
- ...kept ? {} : {
71626
- selectedModel: sessionModelFromConfirmation(confirmation) ?? optimisticModelRecordLabel(alias),
71627
- selectedModelToken: alias
71628
- }
71629
- };
71630
- }
71631
- const aliasErr = aliasResult.outcome === "ok" ? modelSwitchErrorLine(aliasResult.output) : null;
71632
- if (aliasErr) {
71633
- return {
71634
- answer: "Switch failed",
71635
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** did not take: ${deps.escapeHtml(aliasErr)}`)
71636
- };
71637
- }
71638
- const optimisticLabel = optimisticModelRecordLabel(alias);
71639
- return {
71640
- answer: `Sent /model ${alias} \u2014 check /status`,
71641
- 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.`),
71642
- selectedModel: optimisticLabel,
71643
- selectedModelToken: alias
71644
- };
71645
- }
71646
- return {
71647
- answer: "Switch failed",
71648
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(alias)}** failed \u2014 agent may be mid-turn`)
71649
- };
71650
- }
71651
71665
  if (data === MODEL_CALLBACK_HEADER) {
71652
71666
  return { answer: "Tap a model in this section to switch", reply: { text: "", html: true }, toastOnly: true };
71653
71667
  }
71654
- if (data.startsWith(MODEL_CALLBACK_SR)) {
71655
- const srName = data.slice(MODEL_CALLBACK_SR.length);
71656
- const friendlyName = srFriendlyLabel(srName);
71657
- 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)) {
71658
71682
  return { answer: "Invalid model name", reply: await buildModelMenu(deps) };
71659
71683
  }
71684
+ if (!isRecognizedSwitchToken(token)) {
71685
+ return { answer: "Model list changed \u2014 menu refreshed", reply: await buildModelMenu(deps) };
71686
+ }
71660
71687
  if (deps.isBusy()) {
71661
71688
  return {
71662
71689
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
@@ -71665,87 +71692,38 @@ async function handleModelMenuCallback(data, deps) {
71665
71692
  busyRefusal: true
71666
71693
  };
71667
71694
  }
71668
- try {
71669
- await deps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
71670
- } catch (err) {
71671
- 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)) {
71672
71709
  return {
71673
- answer: "Switch failed",
71674
- 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).`)
71675
71712
  };
71676
71713
  }
71714
+ const msg = err instanceof Error ? err.message : String(err);
71677
71715
  return {
71678
- answer: `Switching to ${friendlyName} \u2014 restarting (~30s)`,
71679
- reply: await menuWithBannerStatic(deps, `\uD83D\uDD04 Switching session to **${deps.escapeHtml(friendlyName)}** \u2014 restarting (~30s).
71680
- ${PERSIST_NOTE}`),
71681
- selectedModel: srName
71682
- };
71683
- }
71684
- if (!data.startsWith(MODEL_CALLBACK_SELECT)) {
71685
- return { answer: "Unknown action", reply: await buildModelMenu(deps) };
71686
- }
71687
- if (deps.isBusy()) {
71688
- return {
71689
- answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
71690
- reply: { text: BUSY_REFUSAL_TEXT, html: true },
71691
- toastOnly: true,
71692
- busyRefusal: true
71693
- };
71694
- }
71695
- const tag = data.slice(MODEL_CALLBACK_SELECT.length);
71696
- const discovered = await deps.discover(deps.getAgentName());
71697
- if (!discovered.ok) {
71698
- return {
71699
- answer: "Picker unavailable",
71700
- reply: await menuWithBanner(deps, `\u274c Could not open the model picker: ${deps.escapeHtml(discovered.reason)}`)
71701
- };
71702
- }
71703
- const target = discovered.options.find((o) => labelTag(o.label) === tag);
71704
- if (!target) {
71705
- const fresh = await buildModelMenu(deps);
71706
- return { answer: "Model list changed \u2014 menu refreshed", reply: fresh };
71707
- }
71708
- const result = await deps.select(deps.getAgentName(), target.label);
71709
- if (!result.ok) {
71710
- return {
71711
- answer: "Switch failed \u2014 see the menu",
71712
- reply: await menuWithBanner(deps, `\u274c Switch to **${deps.escapeHtml(target.label)}** failed: ${deps.escapeHtml(result.reason)}`)
71713
- };
71714
- }
71715
- if (isKeptModelConfirmation(result.confirmation)) {
71716
- return {
71717
- answer: deps.escapeHtml(result.confirmation),
71718
- 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)}`)
71719
71718
  };
71720
71719
  }
71721
- const token = canonicalClaudeToken(target.label);
71722
- const selectedModel = sessionModelFromConfirmation(result.confirmation) ?? token ?? undefined;
71723
- const clearedDefault = token == null && /^default\b/i.test(target.label.trim());
71720
+ const friendly = isDefault ? "the configured default" : label;
71724
71721
  return {
71725
- answer: deps.escapeHtml(result.confirmation),
71726
- reply: await menuWithBanner(deps, `\u2705 ${deps.escapeHtml(result.confirmation)}`),
71727
- ...selectedModel ? { selectedModel } : {},
71728
- ...token ? { selectedModelToken: token } : {},
71729
- ...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}`)
71730
71725
  };
71731
71726
  }
71732
- function isSrToClaudeTransition(prevModel, nextModel) {
71733
- return !!prevModel?.startsWith("sr-") && !nextModel.startsWith("sr-");
71734
- }
71735
- function modelSwitchConfirmationLine(output) {
71736
- const line = output.split(`
71737
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_CONFIRMATION_PREFIX.test(l));
71738
- return line && line.length > 0 ? line : null;
71739
- }
71740
- 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;
71741
- function modelSwitchErrorLine(output) {
71742
- const line = output.split(`
71743
- `).map((l) => l.trim()).find((l) => MODEL_SWITCH_ERROR_RE.test(l));
71744
- return line && line.length > 0 ? line : null;
71745
- }
71746
- function isKeptModelConfirmation(confirmation) {
71747
- return /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*Kept model as\b/i.test(confirmation.trim());
71748
- }
71749
71727
  function canonicalClaudeToken(label) {
71750
71728
  const l = label.trim();
71751
71729
  if (l.toLowerCase().startsWith("claude-"))
@@ -71760,21 +71738,6 @@ function canonicalClaudeToken(label) {
71760
71738
  function isRestartInFlight(err) {
71761
71739
  return !!err && typeof err === "object" && err.code === "restart_in_flight";
71762
71740
  }
71763
- var MODEL_SWITCH_CONFIRMATION_PREFIX = /^\s*[\u23fa\u25cf\u2022>\u23bf-]?\s*(?:Set model to|Switched to|Kept model as)\b/i;
71764
- function sessionModelFromConfirmation(confirmation) {
71765
- const m = /(?:Set model to|Switched to)\s+(.+?)(?:\s+for (?:this|the) session|\s+and saved\b|\s*\(|\s*$)/i.exec(confirmation.trim());
71766
- const name = m?.[1]?.trim();
71767
- return name && name.length > 0 ? name : null;
71768
- }
71769
- async function menuWithBanner(deps, banner) {
71770
- const fresh = await buildModelMenu(deps);
71771
- return {
71772
- text: [banner, "", fresh.text].join(`
71773
- `),
71774
- html: true,
71775
- ...fresh.keyboard ? { keyboard: fresh.keyboard } : {}
71776
- };
71777
- }
71778
71741
  async function menuWithBannerStatic(deps, banner) {
71779
71742
  const v1 = await handleModelCommand({ kind: "show" }, deps);
71780
71743
  return {
@@ -72186,70 +72149,6 @@ async function discoverModels(agentName3, opts = {}) {
72186
72149
  return { ok: true, options: parsed.options, currentLabel: current?.label ?? null, ...tail };
72187
72150
  });
72188
72151
  }
72189
- async function selectModel(agentName3, targetLabel, opts = {}) {
72190
- const io = makeIo(agentName3, opts);
72191
- if (!io.runner.hasSession(io.socket, io.session)) {
72192
- return { ok: false, reason: "tmux session not found" };
72193
- }
72194
- return withPaneLock(`${io.socket}:${io.session}`, async () => {
72195
- io.startedAt = Date.now();
72196
- let selected = false;
72197
- try {
72198
- const parsed = await openPickerWithRetry(io);
72199
- if (!parsed || !parsed.footerSeen) {
72200
- return { ok: false, reason: "picker did not render \u2014 agent may be mid-turn" };
72201
- }
72202
- const target = parsed.options.find((o) => o.label === targetLabel);
72203
- if (!target) {
72204
- const have = parsed.options.map((o) => o.label).join(", ");
72205
- return { ok: false, reason: `option "${targetLabel}" not offered (have: ${have})` };
72206
- }
72207
- if (parsed.cursorIndex < 0) {
72208
- return { ok: false, reason: "cursor marker not visible \u2014 refusing blind navigation" };
72209
- }
72210
- const delta = target.index - parsed.cursorIndex;
72211
- const key = delta > 0 ? "Down" : "Up";
72212
- for (let i = 0;i < Math.abs(delta); i++) {
72213
- if (expired(io))
72214
- return { ok: false, reason: "timed out navigating picker" };
72215
- sendKey(io, key);
72216
- await io.sleep(Math.min(io.stepMs, 250));
72217
- }
72218
- await io.sleep(io.stepMs);
72219
- const verifyPane = io.runner.capture(io.socket, io.session) ?? "";
72220
- const verify = parseModelPicker(verifyPane);
72221
- const atRow = verify?.options.find((o) => o.index === verify.cursorIndex);
72222
- if (!verify || !atRow || atRow.label !== targetLabel) {
72223
- return {
72224
- ok: false,
72225
- reason: `cursor verification failed (on "${atRow?.label ?? "?"}", wanted "${targetLabel}")`
72226
- };
72227
- }
72228
- sendLiteral(io, "s");
72229
- selected = true;
72230
- await io.sleep(io.stepMs);
72231
- const after = io.runner.capture(io.socket, io.session) ?? "";
72232
- const confirmation = extractConfirmation(after) ?? `Switched to ${targetLabel} (session)`;
72233
- return { ok: true, confirmation };
72234
- } catch (err) {
72235
- return { ok: false, reason: err instanceof Error ? err.message : String(err) };
72236
- } finally {
72237
- if (!selected) {
72238
- await dismissOrWarn(io, "select");
72239
- }
72240
- }
72241
- });
72242
- }
72243
- function extractConfirmation(pane) {
72244
- const lines = pane.split(`
72245
- `);
72246
- for (let i = lines.length - 1;i >= 0; i--) {
72247
- const t = lines[i].replace(/^\s*\u23bf\s*/, "").trim();
72248
- if (/^(Set model to|Kept model as|Switched to)/i.test(t))
72249
- return t;
72250
- }
72251
- return null;
72252
- }
72253
72152
 
72254
72153
  // ../src/agents/scaffold.ts
72255
72154
  import { join as join33, resolve as resolve6 } from "node:path";
@@ -76759,6 +76658,28 @@ function workerAgentIdOfPinKey(pinKey) {
76759
76658
  const agentId = pinKey.slice(WORKER_PIN_KEY_PREFIX.length);
76760
76659
  return agentId.length > 0 ? agentId : null;
76761
76660
  }
76661
+ function storeOnlyWorkerPinCandidates(args) {
76662
+ const out = [];
76663
+ for (const r of args.rows) {
76664
+ if (workerAgentIdOfPinKey(r.pinKey) == null)
76665
+ continue;
76666
+ if (r.pending)
76667
+ continue;
76668
+ if (r.expiresAt != null)
76669
+ continue;
76670
+ if (r.chatId.length === 0)
76671
+ continue;
76672
+ if (args.inMemoryPinKeys.has(r.pinKey))
76673
+ continue;
76674
+ out.push({
76675
+ pinKey: r.pinKey,
76676
+ chatId: r.chatId,
76677
+ pinnedAt: args.now,
76678
+ messageId: r.messageId
76679
+ });
76680
+ }
76681
+ return out;
76682
+ }
76762
76683
  function decideWorkerPinReaps(args) {
76763
76684
  const reaps = [];
76764
76685
  for (const pin of args.pins) {
@@ -77497,17 +77418,20 @@ function computeTurnStatus(turn) {
77497
77418
  return turn.finalAnswerDelivered ? "complete" : "no_reply";
77498
77419
  }
77499
77420
  }
77500
- function backstopSendOutcome(args) {
77421
+ function backstopSendOutcomeGated(args) {
77501
77422
  if (args.threw)
77502
77423
  return "failed";
77503
77424
  if (args.chunkCount === 0)
77504
77425
  return "failed";
77505
- 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)
77506
77430
  return "failed";
77507
77431
  return "delivered";
77508
77432
  }
77509
- function finalizeBackstopSend(turn, send) {
77510
- const outcome = backstopSendOutcome(send);
77433
+ function finalizeBackstopSendGated(turn, send) {
77434
+ const outcome = backstopSendOutcomeGated(send);
77511
77435
  turn.deliveryOutcome = outcome;
77512
77436
  return outcome;
77513
77437
  }
@@ -77522,6 +77446,120 @@ function buildTurnRecord(turn, endedAt) {
77522
77446
  };
77523
77447
  }
77524
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
+
77525
77563
  // gateway/inbound-delivery-confirm.ts
77526
77564
  function createDeliveryQueue() {
77527
77565
  return { pending: new Map };
@@ -84131,10 +84169,10 @@ function effectiveTurnAgeMs(markerAgeMs, turnStartedAt, now) {
84131
84169
  }
84132
84170
 
84133
84171
  // ../src/build-info.ts
84134
- var VERSION = "0.18.27";
84135
- var COMMIT_SHA = "93871829";
84136
- var COMMIT_DATE = "2026-07-16T01:31:20Z";
84137
- var LATEST_PR = 3273;
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;
84138
84176
  var COMMITS_AHEAD_OF_TAG = 0;
84139
84177
 
84140
84178
  // gateway/boot-version.ts
@@ -86692,6 +86730,73 @@ var deferredDoneReactions = new DeferredDoneReactions({
86692
86730
  });
86693
86731
  var outboundDedup = new OutboundDedupCache;
86694
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
+ }
86695
86800
  var chatAvailableReactions = new Map;
86696
86801
  var chatProbesInFlight = new Set;
86697
86802
  var activeTurnStartedAt = new Map;
@@ -87593,7 +87698,7 @@ function endCurrentTurnAtomic(turn, opts) {
87593
87698
  process.stderr.write(`telegram gateway: status-surface DEGRADED reason=${degraded.reason} turnId=${turn.turnId} chat=${turn.sessionChatId} thread=${turn.sessionThreadId ?? "-"} ${degraded.detail}
87594
87699
  `);
87595
87700
  }
87596
- if (OBLIGATION_LEDGER_ENABLED) {
87701
+ if (OBLIGATION_LEDGER_ENABLED && opts?.deferObligationClose !== true) {
87597
87702
  if (decideObligationTurnEnd(turn.finalAnswerDelivered, turn.replyCalled) === "close") {
87598
87703
  obligationLedger.close(turn.turnId);
87599
87704
  } else {
@@ -89325,11 +89430,19 @@ async function runMidSessionCardReaper() {
89325
89430
  }
89326
89431
  if (WORKER_PIN_REAPER_ENABLED && PIN_STATUS_WHILE_WORKING) {
89327
89432
  try {
89328
- const candidates = [...statusPinState.keys()].filter((k) => k.startsWith("wk:")).map((k) => ({
89433
+ const inMemoryKeys = new Set([...statusPinState.keys()].filter((k) => k.startsWith("wk:")));
89434
+ const inMemoryCandidates = [...inMemoryKeys].map((k) => ({
89329
89435
  pinKey: k,
89330
89436
  chatId: statusPinChatIds.get(k) ?? "",
89331
89437
  pinnedAt: statusPinPinnedAt.get(k) ?? now
89332
89438
  }));
89439
+ const storeOnlyCandidates = statusPinPersistEnabled ? storeOnlyWorkerPinCandidates({
89440
+ rows: loadStatusPins(STATUS_PIN_STORE_PATH, statusPinStoreFs),
89441
+ inMemoryPinKeys: inMemoryKeys,
89442
+ now
89443
+ }) : [];
89444
+ const storeOnlyKeys = new Set(storeOnlyCandidates.map((c) => c.pinKey));
89445
+ const candidates = [...inMemoryCandidates, ...storeOnlyCandidates];
89333
89446
  const reaps = decideWorkerPinReaps({
89334
89447
  pins: candidates,
89335
89448
  statusOf: (agentId) => {
@@ -89356,9 +89469,20 @@ async function runMidSessionCardReaper() {
89356
89469
  now
89357
89470
  });
89358
89471
  for (const reap of reaps) {
89359
- process.stderr.write(`telegram gateway: worker-pin reaper unpinning ${reap.pinKey} (chat=${reap.chatId} reason=${reap.reason})
89472
+ const storeOnly = storeOnlyKeys.has(reap.pinKey);
89473
+ process.stderr.write(`telegram gateway: worker-pin reaper unpinning ${reap.pinKey} (chat=${reap.chatId} reason=${reap.reason}${storeOnly ? " source=store-orphan" : ""})
89474
+ `);
89475
+ if (storeOnly && reap.messageId != null) {
89476
+ try {
89477
+ await statusPinApi().unpinChatMessage(reap.chatId, reap.messageId);
89478
+ } catch (err) {
89479
+ process.stderr.write(`telegram gateway: worker-pin reaper store-orphan unpin failed (${reap.pinKey} chat=${reap.chatId} msg=${reap.messageId}): ${err.message}
89360
89480
  `);
89361
- await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false });
89481
+ }
89482
+ await mutateStatusPinRow(STATUS_PIN_STORE_PATH, statusPinStoreFs, reap.pinKey, null);
89483
+ } else {
89484
+ await reconcileStatusPin(reap.pinKey, reap.chatId, { pinned: false });
89485
+ }
89362
89486
  }
89363
89487
  } catch (err) {
89364
89488
  process.stderr.write(`telegram gateway: worker-pin reaper error: ${err.message}
@@ -93086,7 +93210,7 @@ async function executeGetRecentMessages(args) {
93086
93210
  const rows = query({ chat_id, thread_id, limit, before_message_id });
93087
93211
  const summary = rows.map((r) => {
93088
93212
  const who = r.role === "user" ? r.user ?? "user" : "assistant";
93089
- const time = new Date(r.ts * 1000).toISOString();
93213
+ const time = fmtLocalStamp2(r.ts * 1000, resolveEnvTimezone2());
93090
93214
  const attach = r.attachment_kind ? ` [${r.attachment_kind}]` : "";
93091
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" : ""}"` : ""}` : "";
93092
93216
  const reactionTag = r.user_reaction ? ` [reaction: ${r.user_reaction}]` : "";
@@ -94218,16 +94342,15 @@ function handleSessionEvent(ev) {
94218
94342
  }
94219
94343
  turn.finalAnswerDelivered = true;
94220
94344
  turn.finalAnswerSubstantive = true;
94221
- if (capturedText.trim().length >= FLUSH_SUBSTANTIVE_MIN_CHARS) {
94222
- turn.answerDelivered = true;
94223
- }
94345
+ turn.answerDelivered = true;
94346
+ const backstopLatchClaimed = backstopDeliveryLedger.claim(turn.turnId);
94224
94347
  const cardTakeover = progressDriver?.takeOverCard({
94225
94348
  chatId: backstopChatId,
94226
94349
  threadId: backstopThreadId != null ? String(backstopThreadId) : undefined
94227
94350
  }) ?? { wasEmitted: false, turnKey: null };
94228
94351
  const backstopCardMessageId = cardTakeover.wasEmitted && cardTakeover.turnKey != null ? getPinnedProgressCardMessageId?.(cardTakeover.turnKey) ?? null : null;
94229
94352
  const backstopCardTurnKey = cardTakeover.turnKey;
94230
- const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true });
94353
+ const backstopTurnEndedAt = endCurrentTurnAtomic(turn, { deferRecord: true, deferObligationClose: true });
94231
94354
  preambleSuppressor.dropNow();
94232
94355
  {
94233
94356
  const tKey = statusKey(chatId, threadId);
@@ -94246,80 +94369,47 @@ function handleSessionEvent(ev) {
94246
94369
  `);
94247
94370
  if (backstopTurnEndedAt != null) {
94248
94371
  turn.deliveryOutcome = "suppressed";
94372
+ if (OBLIGATION_LEDGER_ENABLED)
94373
+ obligationLedger.close(turn.turnId);
94249
94374
  emitTurnRecord(turn, backstopTurnEndedAt);
94250
94375
  }
94251
94376
  return;
94252
94377
  }
94253
94378
  } catch {}
94254
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
+ }
94255
94385
  process.stderr.write(`telegram gateway: turn-flush firing \u2014 ${capturedText.length} chars without reply tool (chat=${backstopChatId} cardMsgId=${backstopCardMessageId ?? "none"})
94256
94386
  `);
94257
- const sendOpts = {
94258
- ...backstopThreadId != null ? { message_thread_id: backstopThreadId } : {},
94259
- link_preview_options: { is_disabled: true }
94260
- };
94261
- const limit = RICH_MESSAGE_MAX_CHARS2;
94262
- let htmlChunks = [];
94263
- const sentIds = [];
94264
- let sendThrew = false;
94387
+ let sentIds = [];
94388
+ let chunkCount = 0;
94389
+ let delivered = false;
94265
94390
  try {
94266
- const renderedText = addParagraphSpacers2(capturedText);
94267
- htmlChunks = splitMarkdownChunks2(renderedText, limit);
94268
- let firstSendUsedEdit = false;
94269
- let liveThreadId = backstopThreadId;
94270
- if (backstopCardMessageId != null && htmlChunks.length > 0) {
94271
- try {
94272
- await robustApiCall(() => bot.api.editMessageText(backstopChatId, backstopCardMessageId, richMessage2(htmlChunks[0]), sendOpts), {
94273
- chat_id: backstopChatId,
94274
- verb: "turn-flush.editMessageText",
94275
- ...liveThreadId != null ? { threadId: liveThreadId } : {}
94276
- });
94277
- sentIds.push(backstopCardMessageId);
94278
- firstSendUsedEdit = true;
94279
- } catch (err) {
94280
- process.stderr.write(`telegram gateway: turn-flush card-takeover edit failed: ${err.message} \u2014 falling back to sendMessage
94281
- `);
94282
- if (err instanceof Error && err.message === "THREAD_NOT_FOUND") {
94283
- liveThreadId = undefined;
94284
- }
94285
- }
94286
- }
94287
- const remainingChunks = firstSendUsedEdit ? htmlChunks.slice(1) : htmlChunks;
94288
- for (const c of remainingChunks) {
94289
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
94290
- const opts = {
94291
- link_preview_options: { is_disabled: true },
94292
- ...tid != null ? { message_thread_id: tid } : {}
94293
- };
94294
- return bot.api.sendRichMessage(backstopChatId, richMessage2(c), opts);
94295
- }, {
94296
- threadId: liveThreadId,
94297
- chat_id: backstopChatId,
94298
- verb: "turn-flush.sendMessage"
94299
- });
94300
- if (liveThreadId != null) {
94301
- const sentMsg = sent;
94302
- if (sentMsg.message_thread_id == null)
94303
- liveThreadId = undefined;
94304
- }
94305
- sentIds.push(sent.message_id);
94306
- }
94307
- if (HISTORY_ENABLED && sentIds.length > 0) {
94308
- try {
94309
- recordOutbound({
94310
- chat_id: backstopChatId,
94311
- thread_id: backstopThreadId ?? null,
94312
- message_ids: sentIds,
94313
- texts: htmlChunks
94314
- });
94315
- } catch {}
94316
- }
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;
94317
94401
  outboundDedup.record(backstopChatId, backstopThreadId, capturedText, Date.now(), currentTurn?.registryKey ?? null);
94318
94402
  if (sentIds.length > 0) {
94319
94403
  flushedTurnSupersede.record(backstopChatId, backstopThreadId, { turnId: turn.turnId, messageIds: sentIds, text: capturedText }, Date.now());
94320
94404
  }
94321
- 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) {
94322
94411
  backstopCtrl.finalize("done");
94412
+ }
94323
94413
  if (backstopCardTurnKey != null) {
94324
94414
  completeProgressCardTurn?.({
94325
94415
  chatId: backstopChatId,
@@ -94330,21 +94420,33 @@ function handleSessionEvent(ev) {
94330
94420
  unpinProgressCardForChat?.(backstopChatId, backstopThreadId);
94331
94421
  }
94332
94422
  } catch (err) {
94333
- sendThrew = true;
94334
- 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}
94335
94424
  `);
94336
- turn.answerDelivered = false;
94337
- if (backstopCtrl)
94338
- backstopCtrl.finalize("error");
94425
+ if (!delivered) {
94426
+ turn.answerDelivered = false;
94427
+ backstopDeliveryLedger.release(turn.turnId);
94428
+ if (backstopCtrl)
94429
+ backstopCtrl.finalize("error");
94430
+ }
94339
94431
  } finally {
94340
94432
  if (backstopTurnEndedAt != null) {
94341
- finalizeBackstopSend(turn, {
94342
- threw: sendThrew,
94343
- sentCount: sentIds.length,
94344
- chunkCount: htmlChunks.length
94433
+ finalizeBackstopSendGated(turn, {
94434
+ threw: !delivered,
94435
+ sentIds,
94436
+ chunkCount,
94437
+ cardMessageId: backstopCardMessageId
94345
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
+ }
94346
94447
  emitTurnRecord(turn, backstopTurnEndedAt);
94347
94448
  }
94449
+ backstopDeliveryLedger.clear(turn.turnId);
94348
94450
  }
94349
94451
  })();
94350
94452
  return;
@@ -95399,7 +95501,7 @@ ${preBlock(write.output)}`;
95399
95501
  ...msgId != null ? { message_id: String(msgId) } : {},
95400
95502
  user: displayUser,
95401
95503
  user_id: String(from.id),
95402
- ts: new Date((ctx.message?.date ?? 0) * 1000).toISOString(),
95504
+ ts: fmtLocalStamp2((ctx.message?.date ?? 0) * 1000, resolveEnvTimezone2()),
95403
95505
  ...messageThreadId != null ? { message_thread_id: String(messageThreadId) } : {},
95404
95506
  ...originTurnId != null ? { origin_turn_id: originTurnId } : {},
95405
95507
  ...topicScope != null ? { topic_scope: topicScope } : {},
@@ -96541,7 +96643,6 @@ function buildModelDeps(restartCtx) {
96541
96643
  return [];
96542
96644
  }
96543
96645
  },
96544
- select: (a, label) => selectModel(a, label),
96545
96646
  isBusy: () => currentTurn !== null,
96546
96647
  getAgentName: getMyAgentName,
96547
96648
  getQuotaBrief: async () => {
@@ -96560,14 +96661,12 @@ function buildModelDeps(restartCtx) {
96560
96661
  } catch {}
96561
96662
  return null;
96562
96663
  },
96563
- inject: injectSlashCommand,
96564
96664
  getConfiguredModel: () => {
96565
96665
  const data = switchroomExecJson(["agent", "list"]);
96566
96666
  return data?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
96567
96667
  },
96568
96668
  escapeHtml: escapeHtmlForTg2,
96569
96669
  preBlock,
96570
- getActiveSessionModel: () => sessionModelSource.getOverride(),
96571
96670
  scheduleRestart: async (reason) => {
96572
96671
  const name = getMyAgentName();
96573
96672
  const existing = readRestartMarker();
@@ -96610,6 +96709,30 @@ function buildModelDeps(restartCtx) {
96610
96709
  const prevFileRaw = readSessionModelFileRaw(agentDir);
96611
96710
  writeSessionModelFile(agentDir, model, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
96612
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);
96613
96736
  try {
96614
96737
  await deps.scheduleRestart(reason);
96615
96738
  } catch (err) {
@@ -96634,60 +96757,6 @@ function modelMenuReplyMarkup(reply) {
96634
96757
  }
96635
96758
  return kb;
96636
96759
  }
96637
- function recordTypedModelSwitch(reply, requestedModelArg, _deps) {
96638
- const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null;
96639
- if (requested?.toLowerCase() === "default") {
96640
- const smDir = resolveAgentDirFromEnv();
96641
- if (smDir)
96642
- clearSessionModelFile(smDir);
96643
- if (reply.selectedModel)
96644
- sessionModelSource.setOverride(null);
96645
- return "";
96646
- }
96647
- if (!reply.selectedModel)
96648
- return "";
96649
- sessionModelSource.setOverride(reply.selectedModel);
96650
- clearPremiumRecoveryOnManualSwitch(reply.selectedModel);
96651
- return "";
96652
- }
96653
- function recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel) {
96654
- if (outcome.selectedModel) {
96655
- sessionModelSource.setOverride(outcome.selectedModel);
96656
- clearPremiumRecoveryOnManualSwitch(outcome.selectedModel);
96657
- }
96658
- if (outcome.clearedDefault) {
96659
- const smDir = resolveAgentDirFromEnv();
96660
- if (smDir)
96661
- clearSessionModelFile(smDir);
96662
- }
96663
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
96664
- const agentName3 = getMyAgentName();
96665
- const agentDir = resolveAgentDirFromEnv();
96666
- const token = outcome.selectedModelToken;
96667
- if (agentDir && token) {
96668
- try {
96669
- writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
96670
- sessionModelSource.setOverride(token);
96671
- } catch (e) {
96672
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
96673
- `);
96674
- }
96675
- } else if (agentDir) {
96676
- clearSessionModelFile(agentDir);
96677
- }
96678
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
96679
- stampUserRestartReason("user: sr-to-claude model switch (menu)");
96680
- if (turnInFlightForGate()) {
96681
- pendingRestarts.set(agentName3, Date.now());
96682
- } else {
96683
- sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
96684
- }
96685
- return {
96686
- restartNotice: `\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`
96687
- };
96688
- }
96689
- return {};
96690
- }
96691
96760
  async function editPendingCommandCard(chatId, messageId, text5) {
96692
96761
  try {
96693
96762
  await robustApiCall(() => lockedBot.api.editMessageText(chatId, messageId, richMessage2(hardenCardBreaks2(text5)), {
@@ -96709,14 +96778,11 @@ function enqueueSessionCommand(cmd) {
96709
96778
  async function applyQueuedModelCommand(cmd) {
96710
96779
  const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId });
96711
96780
  if (cmd.origin === "menu") {
96712
- const prevSessionModel = sessionModelSource.getOverride();
96713
96781
  const outcome = await handleModelMenuCallback(cmd.arg, deps);
96714
- const { restartNotice } = recordModelMenuSideEffects(outcome, deps, cmd.chatId, cmd.threadId, prevSessionModel);
96715
- return restartNotice ?? outcome.reply.text;
96782
+ return outcome.reply.text;
96716
96783
  }
96717
96784
  const reply = await handleModelCommand({ kind: "set", model: cmd.arg }, deps);
96718
- const warning = recordTypedModelSwitch(reply, cmd.arg, deps);
96719
- return reply.text + warning;
96785
+ return reply.text;
96720
96786
  }
96721
96787
  async function applyQueuedEffortCommand(cmd) {
96722
96788
  const deps = buildEffortDeps();
@@ -96854,8 +96920,7 @@ bot.command("model", async (ctx) => {
96854
96920
  return;
96855
96921
  }
96856
96922
  const reply = await handleModelCommand(parsed, deps);
96857
- const persistWarning = recordTypedModelSwitch(reply, parsed.kind === "set" ? parsed.model : null, deps);
96858
- await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html });
96923
+ await switchroomReply(ctx, reply.text, { html: reply.html });
96859
96924
  });
96860
96925
  var sessionEffortOverride = null;
96861
96926
  function buildEffortDeps() {
@@ -98909,31 +98974,10 @@ bot.on("callback_query:data", async (ctx) => {
98909
98974
  }
98910
98975
  const isPageNav = data === MODEL_CALLBACK_PAGE_EXTERNAL || data === MODEL_CALLBACK_PAGE_MAIN;
98911
98976
  await ctx.answerCallbackQuery({ text: isPageNav ? "Loading\u2026" : "Switching\u2026" }).catch(() => {});
98912
- if (data.startsWith(MODEL_CALLBACK_SR)) {
98913
- const srName = data.slice(MODEL_CALLBACK_SR.length);
98914
- const srLabel = escapeHtmlForTg2(srFriendlyLabel(srName));
98915
- if (!isValidModelArg(srName)) {
98916
- await ctx.editMessageText(richMessage2("\u274C Invalid model name"), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98917
- return;
98918
- }
98919
- 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(() => {});
98920
- try {
98921
- await modelDeps.scheduleModelRelaunch(srName, `user: /model ${srName} (session-only relaunch, menu)`);
98922
- } catch (err) {
98923
- await ctx.editMessageText(richMessage2(`\u274C Could not switch to **${srLabel}**: ${escapeHtmlForTg2(err?.message ?? String(err))}`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98924
- }
98925
- return;
98926
- }
98927
98977
  try {
98928
- const prevSessionModel = sessionModelSource.getOverride();
98929
98978
  const outcome = await handleModelMenuCallback(data, modelDeps);
98930
98979
  if (outcome.toastOnly)
98931
98980
  return;
98932
- const { restartNotice } = recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel);
98933
- if (restartNotice) {
98934
- await ctx.editMessageText(richMessage2(restartNotice), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
98935
- return;
98936
- }
98937
98981
  await ctx.editMessageText(richMessage2(outcome.reply.text), {
98938
98982
  reply_markup: modelMenuReplyMarkup(outcome.reply) ?? { inline_keyboard: [] }
98939
98983
  }).catch(() => {});
@@ -100638,6 +100682,8 @@ var didOneTimeSetup = false;
100638
100682
  `);
100639
100683
  }
100640
100684
  } catch {}
100685
+ let modelSwitchMarkerChat = null;
100686
+ let modelSwitchReason = null;
100641
100687
  try {
100642
100688
  const nowMs2 = Date.now();
100643
100689
  const marker = readRestartMarker();
@@ -100667,6 +100713,12 @@ var didOneTimeSetup = false;
100667
100713
  const ageSec = Math.max(1, Math.round(ageMs / 1000));
100668
100714
  process.stderr.write(`telegram gateway: boot: restart-marker present, chat_id=${marker.chat_id} age=${ageSec}s within5min=${ageMs < 300000}
100669
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
+ }
100670
100722
  clearRestartMarker();
100671
100723
  }
100672
100724
  if (!isRealRestart) {
@@ -100692,7 +100744,11 @@ var didOneTimeSetup = false;
100692
100744
  firstSeenAt: new Date
100693
100745
  });
100694
100746
  }
100695
- 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) {
100696
100752
  const { chatId, threadId, ackMsgId } = target;
100697
100753
  const bootDedupe = shouldSkipDuplicateBootCard({ activeBootCard, bootCardPending }, "boot");
100698
100754
  if (bootDedupe.skip) {
@@ -100771,7 +100827,19 @@ var didOneTimeSetup = false;
100771
100827
  const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
100772
100828
  return resolveMainModel(raw ?? undefined);
100773
100829
  })();
100774
- 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
+ }
100775
100843
  } catch {}
100776
100844
  }
100777
100845
  const activeEffortPath = join55(smAgentDir, ".active-session-effort");