switchroom 0.14.48 → 0.14.49

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.
@@ -49462,8 +49462,8 @@ var {
49462
49462
  } = import__.default;
49463
49463
 
49464
49464
  // src/build-info.ts
49465
- var VERSION = "0.14.48";
49466
- var COMMIT_SHA = "a6517652";
49465
+ var VERSION = "0.14.49";
49466
+ var COMMIT_SHA = "df84be56";
49467
49467
 
49468
49468
  // src/cli/agent.ts
49469
49469
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.14.48",
3
+ "version": "0.14.49",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -29493,6 +29493,37 @@ function persistSnapshot(path, snapshot) {
29493
29493
  }
29494
29494
  var init_config_snapshot = () => {};
29495
29495
 
29496
+ // gateway/boot-card-msgid.ts
29497
+ import { readFileSync as readFileSync27, writeFileSync as writeFileSync17 } from "node:fs";
29498
+ function bootCardChatKey(chatId, threadId) {
29499
+ return `${chatId}:${threadId ?? ""}`;
29500
+ }
29501
+ function readStore(path) {
29502
+ try {
29503
+ const parsed = JSON.parse(readFileSync27(path, "utf8"));
29504
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
29505
+ return parsed;
29506
+ }
29507
+ } catch {}
29508
+ return {};
29509
+ }
29510
+ function loadBootCardMsgId(path, chatKey3) {
29511
+ const id = readStore(path)[chatKey3];
29512
+ return typeof id === "number" && Number.isFinite(id) && id > 0 ? id : null;
29513
+ }
29514
+ function saveBootCardMsgId(path, chatKey3, messageId) {
29515
+ if (!(Number.isFinite(messageId) && messageId > 0))
29516
+ return;
29517
+ try {
29518
+ const store2 = readStore(path);
29519
+ if (store2[chatKey3] === messageId)
29520
+ return;
29521
+ store2[chatKey3] = messageId;
29522
+ writeFileSync17(path, JSON.stringify(store2), "utf8");
29523
+ } catch {}
29524
+ }
29525
+ var init_boot_card_msgid = () => {};
29526
+
29496
29527
  // gateway/boot-card.ts
29497
29528
  var exports_boot_card = {};
29498
29529
  __export(exports_boot_card, {
@@ -29640,22 +29671,46 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
29640
29671
  ...opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {}
29641
29672
  });
29642
29673
  const silentBootCard = true;
29643
- let messageId;
29644
- try {
29645
- const sent = await bot.sendMessage(chatId, ackText, {
29646
- parse_mode: "HTML",
29647
- link_preview_options: { is_disabled: true },
29648
- ...threadId != null ? { message_thread_id: threadId } : {},
29649
- ...ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {},
29650
- ...silentBootCard ? { disable_notification: true } : {}
29651
- });
29652
- messageId = sent.message_id;
29653
- logger2(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? "-"} reason_detail=${opts.restartReasonDetail ?? "-"} silent=${silentBootCard}
29674
+ const chatKey3 = bootCardChatKey(chatId, threadId);
29675
+ const reuseId = ackMessageId == null && opts.bootCardStatePath != null && bot.editMessageTextStrict != null ? loadBootCardMsgId(opts.bootCardStatePath, chatKey3) : null;
29676
+ let messageId = -1;
29677
+ if (reuseId != null && bot.editMessageTextStrict != null) {
29678
+ try {
29679
+ const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
29680
+ parse_mode: "HTML",
29681
+ link_preview_options: { is_disabled: true },
29682
+ ...threadId != null ? { message_thread_id: threadId } : {}
29683
+ });
29684
+ if (outcome === "edited") {
29685
+ messageId = reuseId;
29686
+ logger2(`telegram gateway: boot-card: reused msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? "-"} reason_detail=${opts.restartReasonDetail ?? "-"} edit_in_place=true notify=none
29654
29687
  `);
29655
- } catch (err) {
29656
- logger2(`telegram gateway: boot-card: failed to post ack: ${err?.message ?? String(err)}
29688
+ }
29689
+ } catch (err) {
29690
+ logger2(`telegram gateway: boot-card: edit-in-place probe failed (${err?.message ?? String(err)}) \u2014 sending fresh
29657
29691
  `);
29658
- return { messageId: -1, complete: () => {} };
29692
+ }
29693
+ }
29694
+ if (messageId < 0) {
29695
+ try {
29696
+ const sent = await bot.sendMessage(chatId, ackText, {
29697
+ parse_mode: "HTML",
29698
+ link_preview_options: { is_disabled: true },
29699
+ ...threadId != null ? { message_thread_id: threadId } : {},
29700
+ ...ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {},
29701
+ ...silentBootCard ? { disable_notification: true } : {}
29702
+ });
29703
+ messageId = sent.message_id;
29704
+ logger2(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? "-"} reason_detail=${opts.restartReasonDetail ?? "-"} silent=${silentBootCard}
29705
+ `);
29706
+ } catch (err) {
29707
+ logger2(`telegram gateway: boot-card: failed to post ack: ${err?.message ?? String(err)}
29708
+ `);
29709
+ return { messageId: -1, complete: () => {} };
29710
+ }
29711
+ }
29712
+ if (opts.bootCardStatePath != null && messageId > 0) {
29713
+ saveBootCardMsgId(opts.bootCardStatePath, chatKey3, messageId);
29659
29714
  }
29660
29715
  const liveWindowMs = opts.agentLiveWindowMs ?? AGENT_LIVE_WINDOW_MS;
29661
29716
  setTimeoutFn(() => {
@@ -29822,6 +29877,7 @@ var init_boot_card = __esm(() => {
29822
29877
  init_boot_probes();
29823
29878
  init_boot_issue_cache();
29824
29879
  init_config_snapshot();
29880
+ init_boot_card_msgid();
29825
29881
  init_loader();
29826
29882
  init_merge();
29827
29883
  DOT = {
@@ -30211,8 +30267,8 @@ var init_flock = () => {};
30211
30267
  // ../src/vault/vault.ts
30212
30268
  import { randomBytes as randomBytes5, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
30213
30269
  import {
30214
- readFileSync as readFileSync34,
30215
- writeFileSync as writeFileSync22,
30270
+ readFileSync as readFileSync35,
30271
+ writeFileSync as writeFileSync23,
30216
30272
  existsSync as existsSync36,
30217
30273
  renameSync as renameSync11,
30218
30274
  mkdirSync as mkdirSync23,
@@ -30256,7 +30312,7 @@ function openVault(passphrase, vaultPath) {
30256
30312
  }
30257
30313
  let vaultFile;
30258
30314
  try {
30259
- vaultFile = JSON.parse(readFileSync34(vaultPath, "utf8"));
30315
+ vaultFile = JSON.parse(readFileSync35(vaultPath, "utf8"));
30260
30316
  } catch {
30261
30317
  throw new VaultError(`Failed to read vault file: ${vaultPath}`);
30262
30318
  }
@@ -30637,7 +30693,7 @@ __export(exports_tmux, {
30637
30693
  captureAgentPane: () => captureAgentPane
30638
30694
  });
30639
30695
  import { execFileSync as execFileSync4 } from "node:child_process";
30640
- import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync13, writeFileSync as writeFileSync23 } from "node:fs";
30696
+ import { mkdirSync as mkdirSync25, readdirSync as readdirSync6, statSync as statSync12, unlinkSync as unlinkSync13, writeFileSync as writeFileSync24 } from "node:fs";
30641
30697
  import { resolve as resolve7 } from "node:path";
30642
30698
  function captureAgentPane(opts) {
30643
30699
  const { agentName: agentName3, agentDir, reason } = opts;
@@ -30683,7 +30739,7 @@ function captureAgentPane(opts) {
30683
30739
  ` + `
30684
30740
  `;
30685
30741
  try {
30686
- writeFileSync23(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
30742
+ writeFileSync24(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
30687
30743
  mode: 420
30688
30744
  });
30689
30745
  } catch (err) {
@@ -31259,8 +31315,8 @@ var import_runner2 = __toESM(require_mod3(), 1);
31259
31315
  import { randomBytes as randomBytes6 } from "crypto";
31260
31316
  import { execFileSync as execFileSync5, execSync as execSync2, spawn as spawn2 } from "child_process";
31261
31317
  import {
31262
- readFileSync as readFileSync35,
31263
- writeFileSync as writeFileSync24,
31318
+ readFileSync as readFileSync36,
31319
+ writeFileSync as writeFileSync25,
31264
31320
  mkdirSync as mkdirSync26,
31265
31321
  readdirSync as readdirSync7,
31266
31322
  rmSync as rmSync4,
@@ -50439,7 +50495,7 @@ function determineRestartReason(opts) {
50439
50495
  init_boot_card();
50440
50496
 
50441
50497
  // gateway/update-announce.ts
50442
- import { existsSync as existsSync29, mkdirSync as mkdirSync17, openSync as openSync3, closeSync as closeSync3, readFileSync as readFileSync27 } from "node:fs";
50498
+ import { existsSync as existsSync29, mkdirSync as mkdirSync17, openSync as openSync3, closeSync as closeSync3, readFileSync as readFileSync28 } from "node:fs";
50443
50499
  import { join as join26 } from "node:path";
50444
50500
  import { homedir as homedir12 } from "node:os";
50445
50501
 
@@ -50554,7 +50610,7 @@ var DEFAULT_LOOKBACK_MS = 10 * 60 * 1000;
50554
50610
  function readLastTerminalUpdateAudit(opts = {}) {
50555
50611
  const path = opts.auditLogPath ?? defaultAuditLogPath();
50556
50612
  const exists = opts.exists ?? existsSync29;
50557
- const readFile = opts.readFile ?? ((p) => readFileSync27(p, "utf-8"));
50613
+ const readFile = opts.readFile ?? ((p) => readFileSync28(p, "utf-8"));
50558
50614
  if (!exists(path))
50559
50615
  return null;
50560
50616
  let raw;
@@ -50642,7 +50698,7 @@ function maybeRenderUpdateAnnouncement(opts = {}) {
50642
50698
  }
50643
50699
 
50644
50700
  // issues-card.ts
50645
- import { readFileSync as readFileSync28, writeFileSync as writeFileSync17 } from "node:fs";
50701
+ import { readFileSync as readFileSync29, writeFileSync as writeFileSync18 } from "node:fs";
50646
50702
  var SEVERITY_EMOJI = {
50647
50703
  info: "\u2139\ufe0f",
50648
50704
  warn: "\u26a0\ufe0f",
@@ -50734,7 +50790,7 @@ function extractRetryAfterSecs2(err) {
50734
50790
  var COOLDOWN_JITTER_MS2 = 500;
50735
50791
  function readPersistedMessageId(path, log) {
50736
50792
  try {
50737
- const raw = readFileSync28(path, "utf8");
50793
+ const raw = readFileSync29(path, "utf8");
50738
50794
  const parsed = JSON.parse(raw);
50739
50795
  const v = parsed.messageId;
50740
50796
  if (typeof v === "number" && Number.isInteger(v) && v > 0)
@@ -50750,7 +50806,7 @@ function readPersistedMessageId(path, log) {
50750
50806
  }
50751
50807
  function writePersistedMessageId(path, messageId, log) {
50752
50808
  try {
50753
- writeFileSync17(path, JSON.stringify({ messageId }) + `
50809
+ writeFileSync18(path, JSON.stringify({ messageId }) + `
50754
50810
  `, { mode: 384 });
50755
50811
  } catch (err) {
50756
50812
  log(`issues-card: persist write failed (${err.message})`);
@@ -50853,11 +50909,11 @@ import {
50853
50909
  mkdirSync as mkdirSync18,
50854
50910
  openSync as openSync4,
50855
50911
  readdirSync as readdirSync5,
50856
- readFileSync as readFileSync29,
50912
+ readFileSync as readFileSync30,
50857
50913
  renameSync as renameSync10,
50858
50914
  statSync as statSync7,
50859
50915
  unlinkSync as unlinkSync10,
50860
- writeFileSync as writeFileSync18,
50916
+ writeFileSync as writeFileSync19,
50861
50917
  writeSync
50862
50918
  } from "node:fs";
50863
50919
  import { join as join27 } from "node:path";
@@ -50884,7 +50940,7 @@ function readAll(stateDir) {
50884
50940
  return [];
50885
50941
  let raw;
50886
50942
  try {
50887
- raw = readFileSync29(path, "utf-8");
50943
+ raw = readFileSync30(path, "utf-8");
50888
50944
  } catch {
50889
50945
  return [];
50890
50946
  }
@@ -50940,7 +50996,7 @@ function writeAll(stateDir, events) {
50940
50996
  const body = events.length === 0 ? "" : events.map((e) => JSON.stringify(e)).join(`
50941
50997
  `) + `
50942
50998
  `;
50943
- writeFileSync18(tmp, body, "utf-8");
50999
+ writeFileSync19(tmp, body, "utf-8");
50944
51000
  renameSync10(tmp, path);
50945
51001
  }
50946
51002
  var ORPHAN_TMP_TTL_MS = 60000;
@@ -51003,7 +51059,7 @@ function withLock(stateDir, fn) {
51003
51059
  function tryStealStaleLock(lockPath) {
51004
51060
  let pidStr;
51005
51061
  try {
51006
- pidStr = readFileSync29(lockPath, "utf-8").trim();
51062
+ pidStr = readFileSync30(lockPath, "utf-8").trim();
51007
51063
  } catch {
51008
51064
  return true;
51009
51065
  }
@@ -51781,7 +51837,7 @@ function extractFlowItems(line) {
51781
51837
  }
51782
51838
 
51783
51839
  // credits-watch.ts
51784
- import { readFileSync as readFileSync30, writeFileSync as writeFileSync19, existsSync as existsSync32, mkdirSync as mkdirSync19 } from "fs";
51840
+ import { readFileSync as readFileSync31, writeFileSync as writeFileSync20, existsSync as existsSync32, mkdirSync as mkdirSync19 } from "fs";
51785
51841
  import { join as join29 } from "path";
51786
51842
  var STATE_FILE = "credits-watch.json";
51787
51843
  var FATAL_REASONS = new Set([
@@ -51799,7 +51855,7 @@ function readClaudeJsonOverage(claudeConfigDir) {
51799
51855
  return null;
51800
51856
  let raw;
51801
51857
  try {
51802
- raw = readFileSync30(path, "utf-8");
51858
+ raw = readFileSync31(path, "utf-8");
51803
51859
  } catch {
51804
51860
  return null;
51805
51861
  }
@@ -51883,7 +51939,7 @@ function loadCreditState(stateDir) {
51883
51939
  if (!existsSync32(path))
51884
51940
  return emptyCreditState();
51885
51941
  try {
51886
- const raw = readFileSync30(path, "utf-8");
51942
+ const raw = readFileSync31(path, "utf-8");
51887
51943
  const parsed = JSON.parse(raw);
51888
51944
  if (parsed && typeof parsed === "object" && (parsed.lastNotifiedReason === null || typeof parsed.lastNotifiedReason === "string") && typeof parsed.lastNotifiedAt === "number" && Number.isFinite(parsed.lastNotifiedAt)) {
51889
51945
  return {
@@ -51897,12 +51953,12 @@ function loadCreditState(stateDir) {
51897
51953
  function saveCreditState(stateDir, state4) {
51898
51954
  mkdirSync19(stateDir, { recursive: true });
51899
51955
  const path = join29(stateDir, STATE_FILE);
51900
- writeFileSync19(path, JSON.stringify(state4, null, 2) + `
51956
+ writeFileSync20(path, JSON.stringify(state4, null, 2) + `
51901
51957
  `, { mode: 384 });
51902
51958
  }
51903
51959
 
51904
51960
  // quota-watch.ts
51905
- import { readFileSync as readFileSync31, writeFileSync as writeFileSync20, existsSync as existsSync33, mkdirSync as mkdirSync20 } from "fs";
51961
+ import { readFileSync as readFileSync32, writeFileSync as writeFileSync21, existsSync as existsSync33, mkdirSync as mkdirSync20 } from "fs";
51906
51962
  import { join as join30 } from "path";
51907
51963
  var STATE_FILE2 = "quota-watch.json";
51908
51964
  function emptyQuotaWatchState() {
@@ -51997,7 +52053,7 @@ function loadQuotaWatchState(stateDir) {
51997
52053
  if (!existsSync33(path))
51998
52054
  return emptyQuotaWatchState();
51999
52055
  try {
52000
- const raw = readFileSync31(path, "utf-8");
52056
+ const raw = readFileSync32(path, "utf-8");
52001
52057
  const parsed = JSON.parse(raw);
52002
52058
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
52003
52059
  return emptyQuotaWatchState();
@@ -52016,7 +52072,7 @@ function loadQuotaWatchState(stateDir) {
52016
52072
  function saveQuotaWatchState(stateDir, state4) {
52017
52073
  mkdirSync20(stateDir, { recursive: true });
52018
52074
  const path = join30(stateDir, STATE_FILE2);
52019
- writeFileSync20(path, JSON.stringify(state4, null, 2) + `
52075
+ writeFileSync21(path, JSON.stringify(state4, null, 2) + `
52020
52076
  `, { mode: 384 });
52021
52077
  }
52022
52078
  function patchQuotaWatchState(current, accountLabel, accountState) {
@@ -52032,18 +52088,18 @@ import {
52032
52088
  existsSync as existsSync34,
52033
52089
  mkdirSync as mkdirSync21,
52034
52090
  openSync as openSync5,
52035
- readFileSync as readFileSync32,
52091
+ readFileSync as readFileSync33,
52036
52092
  statSync as statSync9,
52037
52093
  unlinkSync as unlinkSync11,
52038
52094
  utimesSync as utimesSync2,
52039
- writeFileSync as writeFileSync21
52095
+ writeFileSync as writeFileSync22
52040
52096
  } from "node:fs";
52041
52097
  import { join as join31 } from "node:path";
52042
52098
  var TURN_ACTIVE_MARKER_FILE2 = "turn-active.json";
52043
52099
  function writeTurnActiveMarker(stateDir, marker) {
52044
52100
  try {
52045
52101
  mkdirSync21(stateDir, { recursive: true });
52046
- writeFileSync21(join31(stateDir, TURN_ACTIVE_MARKER_FILE2), JSON.stringify(marker, null, 2) + `
52102
+ writeFileSync22(join31(stateDir, TURN_ACTIVE_MARKER_FILE2), JSON.stringify(marker, null, 2) + `
52047
52103
  `, { mode: 384 });
52048
52104
  } catch {}
52049
52105
  }
@@ -52080,7 +52136,7 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52080
52136
  return false;
52081
52137
  let payload = null;
52082
52138
  try {
52083
- payload = readFileSync32(path, "utf8");
52139
+ payload = readFileSync33(path, "utf8");
52084
52140
  } catch {}
52085
52141
  unlinkSync11(path);
52086
52142
  if (opts.onRemove) {
@@ -52099,10 +52155,10 @@ function sweepStaleTurnActiveMarker(stateDir, opts) {
52099
52155
  }
52100
52156
 
52101
52157
  // ../src/build-info.ts
52102
- var VERSION = "0.14.48";
52103
- var COMMIT_SHA = "a6517652";
52104
- var COMMIT_DATE = "2026-06-03T07:57:29Z";
52105
- var LATEST_PR = 2120;
52158
+ var VERSION = "0.14.49";
52159
+ var COMMIT_SHA = "df84be56";
52160
+ var COMMIT_DATE = "2026-06-03T09:45:46Z";
52161
+ var LATEST_PR = 2123;
52106
52162
  var COMMITS_AHEAD_OF_TAG = 0;
52107
52163
 
52108
52164
  // gateway/boot-version.ts
@@ -52849,7 +52905,7 @@ function formatBootVersion() {
52849
52905
  }
52850
52906
  try {
52851
52907
  chmodSync6(ENV_FILE, 384);
52852
- for (const line of readFileSync35(ENV_FILE, "utf8").split(`
52908
+ for (const line of readFileSync36(ENV_FILE, "utf8").split(`
52853
52909
  `)) {
52854
52910
  const m = line.match(/^(\w+)=(.*)$/);
52855
52911
  if (m && process.env[m[1]] === undefined)
@@ -52902,7 +52958,7 @@ installTgPostLogger(bot);
52902
52958
  var _rawSendMessageDraft = bot.api.raw.sendMessageDraft;
52903
52959
  var GRAMMY_VERSION = (() => {
52904
52960
  try {
52905
- const raw = readFileSync35(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
52961
+ const raw = readFileSync36(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
52906
52962
  return JSON.parse(raw).version ?? "unknown";
52907
52963
  } catch {
52908
52964
  return "unknown";
@@ -52994,7 +53050,7 @@ function assertSendable(f) {
52994
53050
  }
52995
53051
  function readAccessFile() {
52996
53052
  try {
52997
- const raw = readFileSync35(ACCESS_FILE, "utf8");
53053
+ const raw = readFileSync36(ACCESS_FILE, "utf8");
52998
53054
  const parsed = JSON.parse(raw);
52999
53055
  const allowFrom = validateStringArray("allowFrom", parsed.allowFrom ?? []);
53000
53056
  const groups = {};
@@ -53064,7 +53120,7 @@ function saveAccess(a) {
53064
53120
  return;
53065
53121
  mkdirSync26(STATE_DIR, { recursive: true, mode: 448 });
53066
53122
  const tmp = ACCESS_FILE + ".tmp";
53067
- writeFileSync24(tmp, JSON.stringify(a, null, 2) + `
53123
+ writeFileSync25(tmp, JSON.stringify(a, null, 2) + `
53068
53124
  `, { mode: 384 });
53069
53125
  renameSync12(tmp, ACCESS_FILE);
53070
53126
  }
@@ -53105,7 +53161,7 @@ try {
53105
53161
  const st = statSync13(markerPath);
53106
53162
  markerAgeMs = Date.now() - st.mtimeMs;
53107
53163
  try {
53108
- const payload = JSON.parse(readFileSync35(markerPath, "utf8"));
53164
+ const payload = JSON.parse(readFileSync36(markerPath, "utf8"));
53109
53165
  if (typeof payload.turnKey === "string" && payload.turnKey.length > 0) {
53110
53166
  markerTurnKey = payload.turnKey;
53111
53167
  }
@@ -53176,7 +53232,7 @@ try {
53176
53232
  pending2.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending2.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`
53177
53233
  ];
53178
53234
  const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`;
53179
- writeFileSync24(pendingEnvTmp, lines.join(`
53235
+ writeFileSync25(pendingEnvTmp, lines.join(`
53180
53236
  `) + `
53181
53237
  `, { mode: 384 });
53182
53238
  renameSync12(pendingEnvTmp, pendingEnvPath);
@@ -53798,7 +53854,18 @@ function wrapBootCardApi(threadId) {
53798
53854
  const sent = await robustApiCall(() => lockedBot.api.sendMessage(cid, text, sendOpts), opts(cid));
53799
53855
  return sent;
53800
53856
  },
53801
- editMessageText: (cid, mid, text, editOpts) => robustApiCall(() => lockedBot.api.editMessageText(cid, mid, text, editOpts), opts(cid))
53857
+ editMessageText: (cid, mid, text, editOpts) => robustApiCall(() => lockedBot.api.editMessageText(cid, mid, text, editOpts), opts(cid)),
53858
+ editMessageTextStrict: async (cid, mid, text, editOpts) => {
53859
+ try {
53860
+ await lockedBot.api.editMessageText(cid, mid, text, editOpts);
53861
+ return "edited";
53862
+ } catch (err) {
53863
+ const desc = err instanceof import_grammy9.GrammyError ? err.description : err instanceof Error ? err.message : String(err);
53864
+ if (typeof desc === "string" && desc.toLowerCase().includes("not modified"))
53865
+ return "edited";
53866
+ return "gone";
53867
+ }
53868
+ }
53802
53869
  };
53803
53870
  }
53804
53871
  function wrapIssuesCardApi(threadId) {
@@ -54496,8 +54563,8 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
54496
54563
  path: join35(STATE_DIR, "inbound-spool.jsonl"),
54497
54564
  fs: {
54498
54565
  appendFileSync: (p, d) => appendFileSync5(p, d),
54499
- readFileSync: (p) => readFileSync35(p, "utf8"),
54500
- writeFileSync: (p, d) => writeFileSync24(p, d),
54566
+ readFileSync: (p) => readFileSync36(p, "utf8"),
54567
+ writeFileSync: (p, d) => writeFileSync25(p, d),
54501
54568
  renameSync: (a, b) => renameSync12(a, b),
54502
54569
  existsSync: (p) => existsSync38(p),
54503
54570
  statSizeSync: (p) => statSync13(p).size
@@ -54642,6 +54709,7 @@ var ipcServer = createIpcServer({
54642
54709
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === "1",
54643
54710
  dockerMode: process.env.SWITCHROOM_RUNTIME === "docker",
54644
54711
  configSnapshotPath: join35(resolvedAgentDirForCard, ".config-snapshot.json"),
54712
+ bootCardStatePath: join35(resolvedAgentDirForCard, ".boot-card-msgid.json"),
54645
54713
  ...updateOutcomeLine ? { updateOutcomeLine } : {}
54646
54714
  }, ackMsgId).then((handle) => {
54647
54715
  activeBootCard = handle;
@@ -56081,7 +56149,7 @@ async function publishToTelegraph(text, shortName, authorName) {
56081
56149
  let account = null;
56082
56150
  try {
56083
56151
  if (existsSync38(accountPath)) {
56084
- const raw = readFileSync35(accountPath, "utf-8");
56152
+ const raw = readFileSync36(accountPath, "utf-8");
56085
56153
  const parsed = JSON.parse(raw);
56086
56154
  if (parsed.shortName && parsed.accessToken) {
56087
56155
  account = parsed;
@@ -56101,7 +56169,7 @@ async function publishToTelegraph(text, shortName, authorName) {
56101
56169
  account = created.value;
56102
56170
  try {
56103
56171
  mkdirSync26(STATE_DIR, { recursive: true, mode: 448 });
56104
- writeFileSync24(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
56172
+ writeFileSync25(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
56105
56173
  } catch (err) {
56106
56174
  process.stderr.write(`telegram gateway: telegraph cache write failed: ${err.message}
56107
56175
  `);
@@ -56575,7 +56643,7 @@ async function executeDownloadAttachment(args) {
56575
56643
  });
56576
56644
  mkdirSync26(INBOX_DIR, { recursive: true, mode: 448 });
56577
56645
  assertInsideInbox(INBOX_DIR, dlPath);
56578
- writeFileSync24(dlPath, buf, { mode: 384 });
56646
+ writeFileSync25(dlPath, buf, { mode: 384 });
56579
56647
  return { content: [{ type: "text", text: dlPath }] };
56580
56648
  }
56581
56649
  async function executeEditMessage(args) {
@@ -58464,7 +58532,7 @@ function writeRestartMarker(marker) {
58464
58532
  if (!p)
58465
58533
  return;
58466
58534
  try {
58467
- writeFileSync24(p, JSON.stringify(marker));
58535
+ writeFileSync25(p, JSON.stringify(marker));
58468
58536
  lastPlannedRestartAt = Date.now();
58469
58537
  process.stderr.write(`telegram gateway: restart-marker: write chat_id=${marker.chat_id} thread_id=${marker.thread_id ?? "-"} ack=${marker.ack_message_id ?? "-"} path=${p}
58470
58538
  `);
@@ -58483,7 +58551,7 @@ function readRestartMarker() {
58483
58551
  if (!p)
58484
58552
  return null;
58485
58553
  try {
58486
- return JSON.parse(readFileSync35(p, "utf8"));
58554
+ return JSON.parse(readFileSync36(p, "utf8"));
58487
58555
  } catch {
58488
58556
  return null;
58489
58557
  }
@@ -58620,7 +58688,7 @@ function spawnSwitchroomDetached(args, onFailure) {
58620
58688
  try {
58621
58689
  mkdirSync26(STATE_DIR, { recursive: true });
58622
58690
  outFd = openSync8(logPath, "a");
58623
- writeFileSync24(logPath, `
58691
+ writeFileSync25(logPath, `
58624
58692
  [${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(" ")}
58625
58693
  `, { flag: "a" });
58626
58694
  } catch {}
@@ -58646,7 +58714,7 @@ function spawnSwitchroomDetached(args, onFailure) {
58646
58714
  return;
58647
58715
  let tail = "";
58648
58716
  try {
58649
- const full = readFileSync35(logPath, "utf8");
58717
+ const full = readFileSync36(logPath, "utf8");
58650
58718
  tail = full.split(`
58651
58719
  `).slice(-30).join(`
58652
58720
  `).trim();
@@ -58991,7 +59059,7 @@ function readRecentDenialsForAgent(agentName3, windowMs, limit) {
58991
59059
  const auditPath = join35(homedir14(), ".switchroom", "vault-audit.log");
58992
59060
  if (!existsSync38(auditPath))
58993
59061
  return [];
58994
- const raw = readFileSync35(auditPath, "utf8");
59062
+ const raw = readFileSync36(auditPath, "utf8");
58995
59063
  return recentDenialsFromAuditLog(raw, { agentName: agentName3, windowMs, limit });
58996
59064
  } catch {
58997
59065
  return [];
@@ -59042,7 +59110,7 @@ async function buildAgentMetadata(agentName3) {
59042
59110
  try {
59043
59111
  const agentDir = resolveAgentDirFromEnv();
59044
59112
  if (agentDir) {
59045
- const raw = readFileSync35(join35(agentDir, ".claude", ".claude.json"), "utf8");
59113
+ const raw = readFileSync36(join35(agentDir, ".claude", ".claude.json"), "utf8");
59046
59114
  claudeJson = JSON.parse(raw);
59047
59115
  }
59048
59116
  } catch {}
@@ -59314,7 +59382,7 @@ async function handleNewOrResetCommand(ctx, kind) {
59314
59382
  writeRestartMarker({ chat_id: chatId, thread_id: threadId ?? null, ack_message_id: ackId, ts: Date.now() });
59315
59383
  if (agentDir != null) {
59316
59384
  try {
59317
- writeFileSync24(join35(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
59385
+ writeFileSync25(join35(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
59318
59386
  `, "utf8");
59319
59387
  } catch (err) {
59320
59388
  process.stderr.write(`telegram gateway: failed to write force-fresh marker: ${err}
@@ -59678,8 +59746,8 @@ bot.command("interrupt", async (ctx) => {
59678
59746
  await runSwitchroomCommand(ctx, ["agent", "interrupt", name], `interrupt ${name}`);
59679
59747
  });
59680
59748
  var lockoutOps = {
59681
- readFileSync: (p, enc) => readFileSync35(p, enc),
59682
- writeFileSync: (p, data, opts) => writeFileSync24(p, data, opts),
59749
+ readFileSync: (p, enc) => readFileSync36(p, enc),
59750
+ writeFileSync: (p, data, opts) => writeFileSync25(p, data, opts),
59683
59751
  existsSync: (p) => existsSync38(p),
59684
59752
  mkdirSync: (p, opts) => mkdirSync26(p, opts),
59685
59753
  joinPath: (...parts) => join35(...parts)
@@ -60054,7 +60122,7 @@ async function handleVaultRecentDenialCallback(ctx, data) {
60054
60122
  const tokenPath = join35(homedir14(), ".switchroom", "agents", agentName3, ".vault-token");
60055
60123
  try {
60056
60124
  mkdirSync26(join35(homedir14(), ".switchroom", "agents", agentName3), { recursive: true });
60057
- writeFileSync24(tokenPath, token, { mode: 384 });
60125
+ writeFileSync25(tokenPath, token, { mode: 384 });
60058
60126
  } catch (err) {
60059
60127
  await switchroomReply(ctx, `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ${escapeHtmlForTg(String(err))}
60060
60128
  <i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(agentName3)} --keys ${escapeHtmlForTg(keyName)} --duration 30d</code> on the host.</i>`, { html: true });
@@ -60133,7 +60201,7 @@ async function performVaultAccessApproval(ctx, pending2, stageId, senderId, atte
60133
60201
  const tokenPath = join35(homedir14(), ".switchroom", "agents", pending2.agent, ".vault-token");
60134
60202
  try {
60135
60203
  mkdirSync26(join35(homedir14(), ".switchroom", "agents", pending2.agent), { recursive: true });
60136
- writeFileSync24(tokenPath, token, { mode: 384 });
60204
+ writeFileSync25(tokenPath, token, { mode: 384 });
60137
60205
  } catch (err) {
60138
60206
  await switchroomReply(ctx, `<b>Grant created (${escapeHtmlForTg(id)}) but token write failed:</b> ${escapeHtmlForTg(String(err))}
60139
60207
  <i>Recover with: <code>switchroom vault grant ${escapeHtmlForTg(pending2.agent)} --keys ${escapeHtmlForTg(pending2.key)} --duration ${Math.round(pending2.ttl_seconds / 86400)}d</code> on the host.</i>`, { html: true });
@@ -60637,7 +60705,7 @@ async function executeGrantWizard(ctx, chatId, state4) {
60637
60705
  const tokenPath = join35(homedir14(), ".switchroom", "agents", state4.agent, ".vault-token");
60638
60706
  try {
60639
60707
  mkdirSync26(join35(homedir14(), ".switchroom", "agents", state4.agent), { recursive: true });
60640
- writeFileSync24(tokenPath, token, { mode: 384 });
60708
+ writeFileSync25(tokenPath, token, { mode: 384 });
60641
60709
  } catch (err) {
60642
60710
  await switchroomReply(ctx, `<b>Grant created but token write failed:</b> ${escapeHtmlForTg(String(err))}`, { html: true });
60643
60711
  return;
@@ -61933,7 +62001,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
61933
62001
  const unifiedDiff = (() => {
61934
62002
  try {
61935
62003
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
61936
- const raw = readFileSync35(cfgPath, "utf8");
62004
+ const raw = readFileSync36(cfgPath, "utf8");
61937
62005
  return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
61938
62006
  } catch (err) {
61939
62007
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
@@ -62081,7 +62149,7 @@ bot.on("message:photo", async (ctx) => {
62081
62149
  });
62082
62150
  mkdirSync26(INBOX_DIR, { recursive: true, mode: 448 });
62083
62151
  assertInsideInbox(INBOX_DIR, dlPath);
62084
- writeFileSync24(dlPath, buf, { mode: 384 });
62152
+ writeFileSync25(dlPath, buf, { mode: 384 });
62085
62153
  return dlPath;
62086
62154
  } catch (err) {
62087
62155
  const msg = err instanceof Error ? err.message : "unknown error";
@@ -62122,7 +62190,7 @@ async function maybeTranscribeVoice(fileId, mimeType, language) {
62122
62190
  try {
62123
62191
  const path = __require("path").join(__require("os").homedir(), ".switchroom", "openai-api-key");
62124
62192
  if (existsSync38(path)) {
62125
- apiKey = readFileSync35(path, "utf-8").trim();
62193
+ apiKey = readFileSync36(path, "utf-8").trim();
62126
62194
  }
62127
62195
  } catch (err) {
62128
62196
  process.stderr.write(`telegram gateway: voice-in: failed to read api key: ${err.message}
@@ -62992,6 +63060,7 @@ var didOneTimeSetup = false;
62992
63060
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === "1",
62993
63061
  dockerMode: process.env.SWITCHROOM_RUNTIME === "docker",
62994
63062
  configSnapshotPath: join35(resolvedAgentDirForBootCard, ".config-snapshot.json"),
63063
+ bootCardStatePath: join35(resolvedAgentDirForBootCard, ".boot-card-msgid.json"),
62995
63064
  ...updateOutcomeLine ? { updateOutcomeLine } : {}
62996
63065
  }, ackMsgId);
62997
63066
  activeBootCard = handle;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Cross-reboot persistence for the boot card's Telegram message id.
3
+ *
4
+ * Why: a freshly *sent* Telegram message always bumps the chat's unread
5
+ * badge — `disable_notification: true` removes the sound/banner but not the
6
+ * badge (there is no Bot API flag for that). To make routine reboots produce
7
+ * ZERO notification (operator request, 2026-06-03), the gateway reuses the
8
+ * PRIOR boot card's message and EDITS it in place instead of sending a new
9
+ * one — and edits never touch the badge.
10
+ *
11
+ * That requires remembering the last boot card's `message_id` across gateway
12
+ * restarts, keyed by the chat (+ forum topic) it lives in. This module is the
13
+ * tiny JSON store for that, mirroring `config-snapshot.ts` /
14
+ * `boot-issue-cache.ts`: one file under the agent's (bind-mounted, reboot-
15
+ * surviving) state dir, read once on boot, written once after the id is
16
+ * established. All failures are non-fatal — a missing/corrupt file just means
17
+ * "no prior card", so the boot path falls back to a fresh (silent) send.
18
+ */
19
+
20
+ import { readFileSync, writeFileSync } from 'node:fs'
21
+
22
+ /** Stable key for a boot-card target: chat id + optional forum topic. A DM
23
+ * agent always boots to the same `<chatId>:` key; a supergroup agent keys by
24
+ * `<chatId>:<threadId>` so a topic change starts a fresh card. */
25
+ export function bootCardChatKey(chatId: string, threadId: number | undefined): string {
26
+ return `${chatId}:${threadId ?? ''}`
27
+ }
28
+
29
+ type Store = Record<string, number>
30
+
31
+ function readStore(path: string): Store {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown
34
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
35
+ return parsed as Store
36
+ }
37
+ } catch {
38
+ /* missing / corrupt → treat as empty */
39
+ }
40
+ return {}
41
+ }
42
+
43
+ /** The persisted message id for this chat+topic, or null when there's no
44
+ * prior boot card to reuse (first boot, corrupt file, different chat). */
45
+ export function loadBootCardMsgId(
46
+ path: string,
47
+ chatKey: string,
48
+ ): number | null {
49
+ const id = readStore(path)[chatKey]
50
+ return typeof id === 'number' && Number.isFinite(id) && id > 0 ? id : null
51
+ }
52
+
53
+ /** Record the current boot card's message id for this chat+topic. Merges into
54
+ * the existing store (other chats' ids survive). Non-fatal on write failure —
55
+ * the worst case is the next reboot sends a fresh card (one badge). */
56
+ export function saveBootCardMsgId(
57
+ path: string,
58
+ chatKey: string,
59
+ messageId: number,
60
+ ): void {
61
+ if (!(Number.isFinite(messageId) && messageId > 0)) return
62
+ try {
63
+ const store = readStore(path)
64
+ if (store[chatKey] === messageId) return // idempotent — no rewrite
65
+ store[chatKey] = messageId
66
+ writeFileSync(path, JSON.stringify(store), 'utf8')
67
+ } catch {
68
+ /* non-fatal */
69
+ }
70
+ }
@@ -67,6 +67,7 @@ import {
67
67
  type ConfigDiff,
68
68
  } from './config-snapshot.js'
69
69
  import { join } from 'path'
70
+ import { bootCardChatKey, loadBootCardMsgId, saveBootCardMsgId } from './boot-card-msgid.js'
70
71
  import { loadConfig as _loadSwitchroomConfig } from '../../src/config/loader.js'
71
72
  import { resolveAgentConfig as _resolveAgentConfig } from '../../src/config/merge.js'
72
73
 
@@ -134,6 +135,21 @@ export interface BotApiForBootCard {
134
135
  text: string,
135
136
  opts?: Record<string, unknown>,
136
137
  ): Promise<unknown>
138
+ /**
139
+ * Like `editMessageText`, but reports whether the target message still
140
+ * exists rather than swallowing a "message to edit not found" the way the
141
+ * shared retry policy does (retry-api-call.ts) — the boot path needs to
142
+ * know so it can fall back to a fresh send when the prior card was deleted.
143
+ * `'edited'` = the edit landed (or content was identical → message exists);
144
+ * `'gone'` = the message is missing (or any other error → send fresh).
145
+ * Optional so existing callers/tests without it fall back to always-send.
146
+ */
147
+ editMessageTextStrict?(
148
+ chatId: string,
149
+ messageId: number,
150
+ text: string,
151
+ opts?: Record<string, unknown>,
152
+ ): Promise<'edited' | 'gone'>
137
153
  }
138
154
 
139
155
  export interface BootCardHandle {
@@ -568,6 +584,17 @@ export interface RunProbesOpts {
568
584
  * resolve the default memory collection label.
569
585
  */
570
586
  configSnapshotPath?: string
587
+ /**
588
+ * Cross-reboot store for the boot card's Telegram message id (JSON,
589
+ * typically `<agentDir>/.boot-card-msgid.json`). When set AND the bot
590
+ * supports `editMessageTextStrict`, a routine reboot (no `ackMessageId`)
591
+ * EDITS the prior boot card in place instead of sending a new one — edits
592
+ * never bump the unread badge, so reboots produce zero notification
593
+ * (operator request 2026-06-03). Falls back to a fresh silent send when
594
+ * there's no prior card or it was deleted. Omit to keep the always-send
595
+ * behaviour.
596
+ */
597
+ bootCardStatePath?: string
571
598
  }
572
599
 
573
600
  /** Run all six probes concurrently with their own per-probe timeouts.
@@ -641,20 +668,60 @@ export async function startBootCard(
641
668
  // the chat is where you look, and nothing here warrants a push.
642
669
  const silentBootCard = true
643
670
 
644
- let messageId: number
645
- try {
646
- const sent = await bot.sendMessage(chatId, ackText, {
647
- parse_mode: 'HTML',
648
- link_preview_options: { is_disabled: true },
649
- ...(threadId != null ? { message_thread_id: threadId } : {}),
650
- ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
651
- ...(silentBootCard ? { disable_notification: true } : {}),
652
- })
653
- messageId = sent.message_id
654
- logger(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} silent=${silentBootCard}\n`)
655
- } catch (err: unknown) {
656
- logger(`telegram gateway: boot-card: failed to post ack: ${(err as Error)?.message ?? String(err)}\n`)
657
- return { messageId: -1, complete: () => {} }
671
+ // Edit-in-place to produce ZERO notification (operator request 2026-06-03).
672
+ // A sent message always bumps the unread badge — `disable_notification`
673
+ // only kills the sound/banner. So for a ROUTINE reboot (no `ackMessageId`:
674
+ // operator update / cli rollout / crash / fresh) we EDIT the prior boot
675
+ // card in place — edits never touch the badge — instead of sending a new
676
+ // one. We only do this when the bot can tell us the prior message still
677
+ // exists (`editMessageTextStrict`); if it's gone, or this is a
678
+ // Telegram-initiated `/restart` (ackMessageId set the operator asked and
679
+ // is watching, and the card should reply to their command), we fall back to
680
+ // a fresh silent send.
681
+ const chatKey = bootCardChatKey(chatId, threadId)
682
+ const reuseId =
683
+ ackMessageId == null && opts.bootCardStatePath != null && bot.editMessageTextStrict != null
684
+ ? loadBootCardMsgId(opts.bootCardStatePath, chatKey)
685
+ : null
686
+
687
+ let messageId = -1
688
+ if (reuseId != null && bot.editMessageTextStrict != null) {
689
+ try {
690
+ const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
691
+ parse_mode: 'HTML',
692
+ link_preview_options: { is_disabled: true },
693
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
694
+ })
695
+ if (outcome === 'edited') {
696
+ messageId = reuseId
697
+ logger(`telegram gateway: boot-card: reused msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} edit_in_place=true notify=none\n`)
698
+ }
699
+ } catch (err: unknown) {
700
+ logger(`telegram gateway: boot-card: edit-in-place probe failed (${(err as Error)?.message ?? String(err)}) — sending fresh\n`)
701
+ }
702
+ }
703
+
704
+ if (messageId < 0) {
705
+ try {
706
+ const sent = await bot.sendMessage(chatId, ackText, {
707
+ parse_mode: 'HTML',
708
+ link_preview_options: { is_disabled: true },
709
+ ...(threadId != null ? { message_thread_id: threadId } : {}),
710
+ ...(ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {}),
711
+ ...(silentBootCard ? { disable_notification: true } : {}),
712
+ })
713
+ messageId = sent.message_id
714
+ logger(`telegram gateway: boot-card: posted msgId=${messageId} chatId=${chatId} reason=${opts.restartReason ?? '-'} reason_detail=${opts.restartReasonDetail ?? '-'} silent=${silentBootCard}\n`)
715
+ } catch (err: unknown) {
716
+ logger(`telegram gateway: boot-card: failed to post ack: ${(err as Error)?.message ?? String(err)}\n`)
717
+ return { messageId: -1, complete: () => {} }
718
+ }
719
+ }
720
+
721
+ // Remember this card's id so the NEXT reboot can edit it in place (no
722
+ // notification). Idempotent on reuse; non-fatal on write failure.
723
+ if (opts.bootCardStatePath != null && messageId > 0) {
724
+ saveBootCardMsgId(opts.bootCardStatePath, chatKey, messageId)
658
725
  }
659
726
 
660
727
  // Determine the live window for agent-service status updates. Callers
@@ -9,7 +9,7 @@
9
9
  * is connected, inbound LLM messages get a "⏳ Agent is restarting…" reply.
10
10
  */
11
11
 
12
- import { Bot, GrammyError, InlineKeyboard, InputFile, type Context } from 'grammy'
12
+ import { Bot, GrammyError, InlineKeyboard, InputFile, type Context, type Api } from 'grammy'
13
13
  import { run, type RunnerHandle } from '@grammyjs/runner'
14
14
  import type { ReactionTypeEmoji } from 'grammy/types'
15
15
  import { randomBytes } from 'crypto'
@@ -2603,6 +2603,26 @@ function wrapBootCardApi(
2603
2603
  ),
2604
2604
  opts(cid),
2605
2605
  ) as Promise<unknown>,
2606
+ // Strict edit for the boot-card edit-in-place probe: distinguishes
2607
+ // "message gone" (→ 'gone', caller sends fresh) from a landed/identical
2608
+ // edit (→ 'edited'). robustApiCall SWALLOWS "message to edit not found"
2609
+ // to undefined (retry-api-call.ts), so this can't go through it — a
2610
+ // deliberate single-attempt raw edit that classifies the error itself.
2611
+ editMessageTextStrict: async (cid, mid, text, editOpts) => {
2612
+ type EditOpts = Parameters<Api['editMessageText']>[3]
2613
+ try {
2614
+ // allow-raw-bot-api: boot-card edit-in-place probe — must detect a deleted target, which the shared retry policy swallows.
2615
+ await lockedBot.api.editMessageText(cid, mid, text, editOpts as EditOpts)
2616
+ return 'edited'
2617
+ } catch (err) {
2618
+ const desc =
2619
+ err instanceof GrammyError ? err.description : err instanceof Error ? err.message : String(err)
2620
+ // Content identical → message still exists; reuse it.
2621
+ if (typeof desc === 'string' && desc.toLowerCase().includes('not modified')) return 'edited'
2622
+ // Not found, or any other error → fall back to a fresh silent send.
2623
+ return 'gone'
2624
+ }
2625
+ },
2606
2626
  }
2607
2627
  }
2608
2628
 
@@ -4578,6 +4598,7 @@ const ipcServer: IpcServer = createIpcServer({
4578
4598
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
4579
4599
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
4580
4600
  configSnapshotPath: join(resolvedAgentDirForCard, '.config-snapshot.json'),
4601
+ bootCardStatePath: join(resolvedAgentDirForCard, '.boot-card-msgid.json'),
4581
4602
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
4582
4603
  }, ackMsgId).then(handle => {
4583
4604
  activeBootCard = handle
@@ -18469,6 +18490,7 @@ void (async () => {
18469
18490
  tmuxSupervisor: process.env.SWITCHROOM_TMUX_SUPERVISOR === '1',
18470
18491
  dockerMode: process.env.SWITCHROOM_RUNTIME === 'docker',
18471
18492
  configSnapshotPath: join(resolvedAgentDirForBootCard, '.config-snapshot.json'),
18493
+ bootCardStatePath: join(resolvedAgentDirForBootCard, '.boot-card-msgid.json'),
18472
18494
  ...(updateOutcomeLine ? { updateOutcomeLine } : {}),
18473
18495
  }, ackMsgId)
18474
18496
  activeBootCard = handle
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Edit-in-place boot card (zero-notification reboots, operator request
3
+ * 2026-06-03). A routine reboot must EDIT the prior boot card rather than
4
+ * send a new one — a sent message bumps the unread badge even with
5
+ * `disable_notification: true`; an edit never does.
6
+ *
7
+ * Pins the startBootCard contract:
8
+ * - first boot (no persisted id) → SEND + persist the id
9
+ * - next routine boot (persisted id exists) → EDIT in place, NO send
10
+ * - persisted id but message deleted ('gone') → fall back to SEND
11
+ * - Telegram-initiated /restart (ackMessageId set) → SEND fresh (replies to
12
+ * the operator's command; they asked and are watching)
13
+ * - no bootCardStatePath → always SEND (back-compat)
14
+ *
15
+ * State is an isolated mkdtemp file — NEVER ~/.switchroom (test discipline).
16
+ */
17
+
18
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
19
+ import { mkdtempSync, rmSync } from 'node:fs'
20
+ import { tmpdir } from 'node:os'
21
+ import { join } from 'node:path'
22
+ import { startBootCard } from '../gateway/boot-card.js'
23
+ import type { BotApiForBootCard } from '../gateway/boot-card.js'
24
+
25
+ let dir: string
26
+ let statePath: string
27
+
28
+ beforeEach(() => {
29
+ dir = mkdtempSync(join(tmpdir(), 'boot-card-eip-'))
30
+ statePath = join(dir, '.boot-card-msgid.json')
31
+ })
32
+ afterEach(() => {
33
+ rmSync(dir, { recursive: true, force: true })
34
+ })
35
+
36
+ /** Capturing bot with a configurable strict-edit outcome. */
37
+ function makeBot(strictOutcome: 'edited' | 'gone' | null) {
38
+ const sends: Array<{ chatId: string; opts: Record<string, unknown> }> = []
39
+ const strictEdits: Array<{ chatId: string; messageId: number }> = []
40
+ let nextId = 1000
41
+ const bot: BotApiForBootCard = {
42
+ sendMessage: async (chatId, _text, opts) => {
43
+ sends.push({ chatId, opts: opts ?? {} })
44
+ return { message_id: ++nextId }
45
+ },
46
+ editMessageText: async () => ({}),
47
+ ...(strictOutcome != null
48
+ ? {
49
+ editMessageTextStrict: async (chatId: string, messageId: number) => {
50
+ strictEdits.push({ chatId, messageId })
51
+ return strictOutcome
52
+ },
53
+ }
54
+ : {}),
55
+ }
56
+ return { bot, sends, strictEdits }
57
+ }
58
+
59
+ function mkOpts(overrides: Record<string, unknown> = {}) {
60
+ return {
61
+ agentName: 'TestAgent',
62
+ agentSlug: 'test-agent',
63
+ version: 'v0.0.0-test',
64
+ agentDir: dir,
65
+ gatewayInfo: { pid: 1, startedAtMs: Date.now() },
66
+ restartReason: 'graceful' as const,
67
+ agentLiveWindowMs: 0, // disable the live loop — we assert the initial post/edit only
68
+ settleWindowMs: 1_000_000,
69
+ bootCardStatePath: statePath,
70
+ ...overrides,
71
+ }
72
+ }
73
+
74
+ describe('boot card — edit-in-place (zero-notification reboots)', () => {
75
+ it('first boot with no persisted id → sends, and persists the id', async () => {
76
+ const { bot, sends, strictEdits } = makeBot('edited')
77
+ await startBootCard('chat1', undefined, bot, mkOpts())
78
+ expect(sends).toHaveLength(1) // first boot must send (one badge, ever)
79
+ expect(strictEdits).toHaveLength(0)
80
+ expect(sends[0]!.opts.disable_notification).toBe(true) // still silent
81
+ })
82
+
83
+ it('second routine boot → edits the prior card in place, NO new send', async () => {
84
+ // Boot 1 sends + persists.
85
+ const first = makeBot('edited')
86
+ await startBootCard('chat1', undefined, first.bot, mkOpts())
87
+ expect(first.sends).toHaveLength(1)
88
+
89
+ // Boot 2 (same state file) → reuse via strict edit, no send.
90
+ const second = makeBot('edited')
91
+ await startBootCard('chat1', undefined, second.bot, mkOpts())
92
+ expect(second.sends).toHaveLength(0) // ← zero notification: no new message
93
+ expect(second.strictEdits).toHaveLength(1)
94
+ // Boot 1's send returned message_id 1001 (nextId starts at 1000, ++ first);
95
+ // boot 2 must edit exactly that persisted id.
96
+ expect(second.strictEdits[0]!.messageId).toBe(1001)
97
+ })
98
+
99
+ it('persisted id but message was deleted (gone) → falls back to a fresh send', async () => {
100
+ const first = makeBot('edited')
101
+ await startBootCard('chat1', undefined, first.bot, mkOpts())
102
+
103
+ const second = makeBot('gone')
104
+ await startBootCard('chat1', undefined, second.bot, mkOpts())
105
+ expect(second.strictEdits).toHaveLength(1) // probed the old id
106
+ expect(second.sends).toHaveLength(1) // and fell back to a fresh send
107
+ expect(second.sends[0]!.opts.disable_notification).toBe(true)
108
+ })
109
+
110
+ it('Telegram-initiated /restart (ackMessageId set) → sends fresh, never edits', async () => {
111
+ // Seed a persisted id from a routine boot.
112
+ const seed = makeBot('edited')
113
+ await startBootCard('chat1', undefined, seed.bot, mkOpts())
114
+
115
+ // A /restart passes ackMessageId → must reply with a fresh card, not edit.
116
+ const restart = makeBot('edited')
117
+ await startBootCard('chat1', undefined, restart.bot, mkOpts(), 555)
118
+ expect(restart.strictEdits).toHaveLength(0) // no reuse on user-initiated restart
119
+ expect(restart.sends).toHaveLength(1)
120
+ expect(restart.sends[0]!.opts.reply_parameters).toEqual({ message_id: 555 })
121
+ })
122
+
123
+ it('no bootCardStatePath → always sends (back-compat, unchanged)', async () => {
124
+ const { bot, sends, strictEdits } = makeBot('edited')
125
+ await startBootCard('chat1', undefined, bot, mkOpts({ bootCardStatePath: undefined }))
126
+ expect(sends).toHaveLength(1)
127
+ expect(strictEdits).toHaveLength(0)
128
+ })
129
+
130
+ it('bot without editMessageTextStrict → always sends (graceful degrade)', async () => {
131
+ const { bot, sends } = makeBot(null) // no strict method
132
+ await startBootCard('chat1', undefined, bot, mkOpts())
133
+ const second = makeBot(null)
134
+ await startBootCard('chat1', undefined, second.bot, mkOpts())
135
+ // Both boots send — no strict method means no in-place reuse path.
136
+ expect(sends).toHaveLength(1)
137
+ expect(second.sends).toHaveLength(1)
138
+ })
139
+ })
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Unit tests for the cross-reboot boot-card message-id store
3
+ * (gateway/boot-card-msgid.ts) — the persistence that lets a routine reboot
4
+ * EDIT the prior boot card in place (zero notification) instead of sending a
5
+ * new one.
6
+ *
7
+ * All I/O is to an isolated mkdtemp dir — NEVER ~/.switchroom (test discipline).
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
11
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from 'node:fs'
12
+ import { tmpdir } from 'node:os'
13
+ import { join } from 'node:path'
14
+ import {
15
+ bootCardChatKey,
16
+ loadBootCardMsgId,
17
+ saveBootCardMsgId,
18
+ } from '../gateway/boot-card-msgid.js'
19
+
20
+ let dir: string
21
+ let path: string
22
+
23
+ beforeEach(() => {
24
+ dir = mkdtempSync(join(tmpdir(), 'boot-card-msgid-'))
25
+ path = join(dir, '.boot-card-msgid.json')
26
+ })
27
+ afterEach(() => {
28
+ rmSync(dir, { recursive: true, force: true })
29
+ })
30
+
31
+ describe('bootCardChatKey', () => {
32
+ it('keys a DM (no topic) distinctly from a supergroup topic', () => {
33
+ expect(bootCardChatKey('12345', undefined)).toBe('12345:')
34
+ expect(bootCardChatKey('-1001234567890', 4)).toBe('-1001234567890:4')
35
+ // Different topics in the same supergroup are distinct cards.
36
+ expect(bootCardChatKey('-100', 3)).not.toBe(bootCardChatKey('-100', 4))
37
+ })
38
+ })
39
+
40
+ describe('loadBootCardMsgId / saveBootCardMsgId', () => {
41
+ it('returns null when the file does not exist (first boot)', () => {
42
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
43
+ })
44
+
45
+ it('round-trips a saved id', () => {
46
+ saveBootCardMsgId(path, 'dm:', 353)
47
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(353)
48
+ })
49
+
50
+ it('keeps ids for different chats independent', () => {
51
+ saveBootCardMsgId(path, 'dm:', 100)
52
+ saveBootCardMsgId(path, '-100:4', 200)
53
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(100)
54
+ expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
55
+ // Updating one leaves the other intact.
56
+ saveBootCardMsgId(path, 'dm:', 101)
57
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(101)
58
+ expect(loadBootCardMsgId(path, '-100:4')).toBe(200)
59
+ })
60
+
61
+ it('returns null for an unknown chat key', () => {
62
+ saveBootCardMsgId(path, 'dm:', 1)
63
+ expect(loadBootCardMsgId(path, 'other:')).toBeNull()
64
+ })
65
+
66
+ it('rejects non-positive / non-finite ids on save and load', () => {
67
+ saveBootCardMsgId(path, 'dm:', 0)
68
+ saveBootCardMsgId(path, 'dm:', -5)
69
+ saveBootCardMsgId(path, 'dm:', Number.NaN)
70
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
71
+ })
72
+
73
+ it('treats a corrupt file as empty (falls back to fresh send)', () => {
74
+ writeFileSync(path, 'not json{', 'utf8')
75
+ expect(loadBootCardMsgId(path, 'dm:')).toBeNull()
76
+ // And a subsequent save still works (overwrites the garbage).
77
+ saveBootCardMsgId(path, 'dm:', 7)
78
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(7)
79
+ })
80
+
81
+ it('does not rewrite the file when the id is unchanged (idempotent)', () => {
82
+ saveBootCardMsgId(path, 'dm:', 42)
83
+ expect(existsSync(path)).toBe(true)
84
+ // Saving the same value again is a no-op; the value is still readable.
85
+ saveBootCardMsgId(path, 'dm:', 42)
86
+ expect(loadBootCardMsgId(path, 'dm:')).toBe(42)
87
+ })
88
+ })