switchroom 0.18.9 → 0.18.10

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 (62) hide show
  1. package/dist/agent-scheduler/index.js +1 -0
  2. package/dist/auth-broker/index.js +198 -13
  3. package/dist/cli/notion-write-pretool.mjs +1 -0
  4. package/dist/cli/switchroom.js +28 -4
  5. package/dist/host-control/main.js +3 -2
  6. package/dist/vault/approvals/kernel-server.js +2 -1
  7. package/dist/vault/broker/server.js +2 -1
  8. package/package.json +1 -1
  9. package/profiles/_base/start.sh.hbs +119 -37
  10. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  11. package/skills/dev-protocol/SKILL.md +131 -0
  12. package/telegram-plugin/README.md +2 -1
  13. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  14. package/telegram-plugin/admin-commands/index.ts +6 -1
  15. package/telegram-plugin/bridge/bridge.ts +23 -1
  16. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  17. package/telegram-plugin/chat-lock.ts +13 -0
  18. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  19. package/telegram-plugin/dist/gateway/gateway.js +1831 -263
  20. package/telegram-plugin/dist/server.js +29 -2
  21. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  22. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  23. package/telegram-plugin/gateway/effort-command.ts +47 -3
  24. package/telegram-plugin/gateway/gateway.ts +1435 -211
  25. package/telegram-plugin/gateway/model-command.ts +94 -8
  26. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  27. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  28. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  29. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  30. package/telegram-plugin/gateway/stop-command.ts +56 -0
  31. package/telegram-plugin/photo-precheck.ts +201 -0
  32. package/telegram-plugin/quota-watch.ts +141 -2
  33. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  34. package/telegram-plugin/registry/subagents.test.ts +67 -0
  35. package/telegram-plugin/retry-api-call.ts +31 -0
  36. package/telegram-plugin/subagent-watcher.ts +392 -1
  37. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  38. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  39. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  40. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  41. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  42. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  43. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  44. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  45. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  46. package/telegram-plugin/tests/model-command.test.ts +46 -3
  47. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  48. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  49. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  50. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  51. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  52. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  53. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  54. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  55. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  56. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  57. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  58. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  59. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  60. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  61. package/telegram-plugin/welcome-text.ts +4 -3
  62. package/telegram-plugin/worker-activity-feed.ts +27 -0
@@ -20887,6 +20887,7 @@ var init_schema = __esm(() => {
20887
20887
  uid: exports_external.number().int().nonnegative().optional().describe("Optional UID to chown the consumer socket to (defaults to 0 = root, " + "suitable for sibling containers running as root)."),
20888
20888
  mirror_dir: exports_external.string().optional().describe("Optional host-side directory path. When set, the broker actively " + "writes the consumer's effective-account `.credentials.json` mirror " + "here \u2014 in addition to serving creds on demand via `get-credentials`. " + "Use this to eliminate the pull-latency gap: without a mirror the " + "consumer only gets failover creds at its next scheduled re-fetch " + "(up to 30 min). With a mirror the broker pushes failover creds " + "immediately when it detects exhaustion (consumer-quota-sensor tick, " + "or a mark-exhausted RPC on the pinned account). The directory must " + "be accessible to the broker container (bind-mounted from the host) " + "and to the consumer container; the broker writes " + "`<mirror_dir>/.credentials.json` atomically. Chown is attempted to " + "`uid` (default 0) \u2014 swallowed when CAP_CHOWN is absent.")
20889
20889
  })).optional().describe("Non-agent peers that hold a broker socket (RFC H \u00a74.8). Each gets " + "its own `/run/switchroom/auth-broker/<name>/sock` chowned to its UID. " + "Consumers cannot be admins; a consumer name that collides with an " + "agent (whether that agent has `admin: true` or not) is a config " + "error caught at schema validation."),
20890
+ proactive_failover_pct: exports_external.number().min(1).max(99).optional().describe("Soft-avoid threshold (percent) for proactive auth failover (#3031). " + "When set (e.g. 95), an account whose fresh 7-day utilization is at/" + "above this value \u2014 or whose 5h utilization is at/above min(pct+3, " + "98) \u2014 enters the SOFT-AVOID preference tier: the broker prefers a " + "fully-eligible fallback account on the serving path, without ever " + "blocking the account (the hard wall stays 99.5). Enter/exit uses " + "hysteresis (exit below pct-5 or on window reset) so the preference " + "does not flap between probe ticks. Accounts serving past the wall " + "via `allow_overage_accounts` are never soft-avoided. UNSET (the " + "default) disables the tier entirely \u2014 behavior is identical to a " + "fleet without this field."),
20890
20891
  allow_overage_accounts: exports_external.array(exports_external.string().min(1)).optional().describe("Opt-in list of account labels (bare strings matching `auth.active` / " + "`auth.fallback_order` entries) that may be served PAST the weekly " + "utilization wall when Anthropic overage billing is available for the " + "account (`overageStatus === 'allowed'`). Overage is REAL MONEY \u2014 " + "default is empty (no account gets this). An account in this list is " + "only kept eligible when its fresh quota snapshot reports " + "`overageStatus: 'allowed'` AND `overageDisabledReason` is NOT " + "'out_of_credits' (i.e. the overage credit has not been exhausted). " + "As soon as `overageDisabledReason` becomes 'out_of_credits', the " + "account is blocked immediately regardless of this flag. Overage lifts " + "ONLY the utilization wall \u2014 it cannot lift an active exhaustion mark " + "written by a real 429 (`mark-exhausted`).")
20891
20892
  }).optional().describe("Switchroom-auth-broker configuration (RFC H). Fleet-wide active account, " + "fallback order, admin-agent ACL, and ephemeral-consumer surface. " + "Required from the v0.8+ schema onwards; pre-v0.8 fleets are migrated " + "in-place by `switchroom apply` (see src/auth/migrate-schema.ts)."),
20892
20893
  drive: GoogleWorkspaceConfigSchema.describe("RFC D legacy key \u2014 use `google_workspace:` instead. Optional Google " + "Workspace onboarding configuration. When set, supplies Google OAuth " + "client credentials, the approver allowlist for `switchroom drive " + "connect`, and the optional tier knob. Env vars " + "(SWITCHROOM_GOOGLE_CLIENT_ID, SWITCHROOM_GOOGLE_CLIENT_SECRET, " + "SWITCHROOM_APPROVER_USER_ID) take precedence over this block when " + "set, preserving back-compat with the env-only flow shipped in #766."),
@@ -27284,8 +27285,8 @@ import {
27284
27285
  mkdirSync as mkdirSync15,
27285
27286
  unlinkSync as unlinkSync7,
27286
27287
  fsyncSync,
27287
- openSync as openSync2,
27288
- closeSync as closeSync2,
27288
+ openSync as openSync3,
27289
+ closeSync as closeSync3,
27289
27290
  lstatSync,
27290
27291
  realpathSync
27291
27292
  } from "node:fs";
@@ -27371,10 +27372,10 @@ var init_vault = __esm(() => {
27371
27372
  // ../src/vault/resolver.ts
27372
27373
  import {
27373
27374
  chmodSync as chmodSync2,
27374
- closeSync as closeSync3,
27375
+ closeSync as closeSync4,
27375
27376
  mkdirSync as mkdirSync16,
27376
27377
  mkdtempSync,
27377
- openSync as openSync3,
27378
+ openSync as openSync4,
27378
27379
  rmSync,
27379
27380
  statSync as statSync5,
27380
27381
  writeSync as writeSync3
@@ -27442,11 +27443,11 @@ function materializationRoot() {
27442
27443
  }
27443
27444
  function writeFileExclusive(filePath, content3) {
27444
27445
  const buf = typeof content3 === "string" ? Buffer.from(content3, "utf8") : content3;
27445
- const fd = openSync3(filePath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
27446
+ const fd = openSync4(filePath, fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL, 384);
27446
27447
  try {
27447
27448
  writeSync3(fd, buf);
27448
27449
  } finally {
27449
- closeSync3(fd);
27450
+ closeSync4(fd);
27450
27451
  }
27451
27452
  }
27452
27453
  function materializeFilesEntry(key, files) {
@@ -37415,7 +37416,7 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
37415
37416
  return { messageId: -1, complete: () => {} };
37416
37417
  }
37417
37418
  }
37418
- const ackText = renderBootCard({
37419
+ const ackText2 = renderBootCard({
37419
37420
  agentName: opts.agentName,
37420
37421
  agentSlug: opts.agentSlug,
37421
37422
  version: opts.version,
@@ -37429,7 +37430,7 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
37429
37430
  let messageId = -1;
37430
37431
  if (reuseId != null && bot.editMessageTextStrict != null) {
37431
37432
  try {
37432
- const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText, {
37433
+ const outcome = await bot.editMessageTextStrict(chatId, reuseId, ackText2, {
37433
37434
  link_preview_options: { is_disabled: true },
37434
37435
  ...threadId != null ? { message_thread_id: threadId } : {}
37435
37436
  });
@@ -37445,7 +37446,7 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
37445
37446
  }
37446
37447
  if (messageId < 0) {
37447
37448
  try {
37448
- const sent = await bot.sendMessage(chatId, ackText, {
37449
+ const sent = await bot.sendMessage(chatId, ackText2, {
37449
37450
  link_preview_options: { is_disabled: true },
37450
37451
  ...threadId != null ? { message_thread_id: threadId } : {},
37451
37452
  ...ackMessageId != null ? { reply_parameters: { message_id: ackMessageId } } : {},
@@ -37544,7 +37545,7 @@ async function startBootCard(chatId, threadId, bot, opts, ackMessageId, log) {
37544
37545
  ...opts.updateOutcomeLine ? { updateOutcomeLine: opts.updateOutcomeLine } : {},
37545
37546
  ...configChanges.length > 0 ? { configChanges } : {}
37546
37547
  });
37547
- if (currentText !== ackText) {
37548
+ if (currentText !== ackText2) {
37548
37549
  try {
37549
37550
  await bot.editMessageText(chatId, messageId, currentText, {
37550
37551
  link_preview_options: { is_disabled: true },
@@ -38164,7 +38165,7 @@ __export(exports_materialize_bot_token, {
38164
38165
  materializeBotToken: () => materializeBotToken,
38165
38166
  BotTokenMaterializeError: () => BotTokenMaterializeError
38166
38167
  });
38167
- import { existsSync as existsSync48 } from "node:fs";
38168
+ import { existsSync as existsSync49 } from "node:fs";
38168
38169
  function pickConfiguredToken(config, agentName3) {
38169
38170
  if (agentName3) {
38170
38171
  const agent = config.agents?.[agentName3];
@@ -38178,7 +38179,7 @@ function tryDirectVaultRead3(ref, config, passphrase) {
38178
38179
  if (!passphrase)
38179
38180
  return null;
38180
38181
  const vaultPath = resolvePath(config.vault?.path ?? "~/.switchroom/vault.enc");
38181
- if (!existsSync48(vaultPath))
38182
+ if (!existsSync49(vaultPath))
38182
38183
  return null;
38183
38184
  try {
38184
38185
  const secrets = openVault(passphrase, vaultPath);
@@ -38270,7 +38271,7 @@ __export(exports_tmux, {
38270
38271
  captureAgentPane: () => captureAgentPane
38271
38272
  });
38272
38273
  import { execFileSync as execFileSync7 } from "node:child_process";
38273
- import { mkdirSync as mkdirSync38, readdirSync as readdirSync10, statSync as statSync15, unlinkSync as unlinkSync19, writeFileSync as writeFileSync40 } from "node:fs";
38274
+ import { mkdirSync as mkdirSync38, readdirSync as readdirSync11, statSync as statSync15, unlinkSync as unlinkSync20, writeFileSync as writeFileSync41 } from "node:fs";
38274
38275
  import { resolve as resolve10 } from "node:path";
38275
38276
  function captureAgentPane(opts) {
38276
38277
  const { agentName: agentName3, agentDir, reason } = opts;
@@ -38316,7 +38317,7 @@ function captureAgentPane(opts) {
38316
38317
  ` + `
38317
38318
  `;
38318
38319
  try {
38319
- writeFileSync40(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
38320
+ writeFileSync41(outPath, Buffer.concat([Buffer.from(header, "utf8"), body]), {
38320
38321
  mode: 420
38321
38322
  });
38322
38323
  } catch (err) {
@@ -38386,7 +38387,7 @@ function sleepSync2(ms) {
38386
38387
  function pruneOldReports(dir, retain) {
38387
38388
  let entries;
38388
38389
  try {
38389
- entries = readdirSync10(dir);
38390
+ entries = readdirSync11(dir);
38390
38391
  } catch {
38391
38392
  return;
38392
38393
  }
@@ -38400,7 +38401,7 @@ function pruneOldReports(dir, retain) {
38400
38401
  }).sort((a, b) => b.mtimeMs - a.mtimeMs);
38401
38402
  for (const stale of files.slice(retain)) {
38402
38403
  try {
38403
- unlinkSync19(stale.full);
38404
+ unlinkSync20(stale.full);
38404
38405
  } catch {}
38405
38406
  }
38406
38407
  }
@@ -39020,19 +39021,19 @@ var import_runner3 = __toESM(require_mod3(), 1);
39020
39021
  import { randomBytes as randomBytes9, createHash as createHash3 } from "crypto";
39021
39022
  import { execFileSync as execFileSync8, execSync as execSync2, spawn } from "child_process";
39022
39023
  import {
39023
- readFileSync as readFileSync51,
39024
- writeFileSync as writeFileSync41,
39024
+ readFileSync as readFileSync53,
39025
+ writeFileSync as writeFileSync42,
39025
39026
  mkdirSync as mkdirSync39,
39026
- readdirSync as readdirSync11,
39027
+ readdirSync as readdirSync12,
39027
39028
  rmSync as rmSync5,
39028
39029
  statSync as statSync16,
39029
- renameSync as renameSync16,
39030
+ renameSync as renameSync17,
39030
39031
  realpathSync as realpathSync4,
39031
39032
  chmodSync as chmodSync8,
39032
- openSync as openSync10,
39033
- closeSync as closeSync10,
39034
- existsSync as existsSync49,
39035
- unlinkSync as unlinkSync20,
39033
+ openSync as openSync11,
39034
+ closeSync as closeSync11,
39035
+ existsSync as existsSync50,
39036
+ unlinkSync as unlinkSync21,
39036
39037
  appendFileSync as appendFileSync6
39037
39038
  } from "fs";
39038
39039
  import { homedir as homedir17 } from "os";
@@ -39326,6 +39327,22 @@ function resolveSafeBoundaryEnabled(configured) {
39326
39327
  return configured !== false;
39327
39328
  }
39328
39329
 
39330
+ // gateway/stop-command.ts
39331
+ function parseStopKeyword(text) {
39332
+ return /^stop[.!]?$/i.test(text.trim());
39333
+ }
39334
+ function buildStopReply(turnInFlight, queuedSessionCmds) {
39335
+ if (!turnInFlight) {
39336
+ return { text: "Nothing running \u2014 no in-flight turn to cancel." };
39337
+ }
39338
+ const lines = ["\u23f9 Turn cancelled."];
39339
+ if (queuedSessionCmds.length > 0) {
39340
+ lines.push(`Queued command${queuedSessionCmds.length > 1 ? "s" : ""} (${queuedSessionCmds.join(", ")}) survive${queuedSessionCmds.length > 1 ? "" : "s"} and will apply once the session is idle.`);
39341
+ }
39342
+ return { text: lines.join(`
39343
+ `) };
39344
+ }
39345
+
39329
39346
  // gateway/busy-ack.ts
39330
39347
  var BUSY_ACK_STEP_AGE_THRESHOLD_MS = 12000;
39331
39348
  function shouldPostBusyAck(input) {
@@ -42199,6 +42216,17 @@ function createWorkerActivityFeed(opts) {
42199
42216
  markFinalized(agentId);
42200
42217
  handles.delete(agentId);
42201
42218
  },
42219
+ resurrect(agentId) {
42220
+ const wasFinalized = finalized.delete(agentId);
42221
+ const h = handles.get(agentId);
42222
+ if (h != null) {
42223
+ h.finished = false;
42224
+ h.pendingFinish = null;
42225
+ }
42226
+ if (wasFinalized || h != null) {
42227
+ log(`worker-feed: resurrect agent=${agentId} \u2014 cleared finalized gate; card will repaint on next running cue`);
42228
+ }
42229
+ },
42202
42230
  heartbeatTick,
42203
42231
  stop() {
42204
42232
  if (heartbeatTimer != null) {
@@ -42568,6 +42596,17 @@ function buildTimedOutCardEdits(cardText, cards) {
42568
42596
  stripKeyboard: true
42569
42597
  }));
42570
42598
  }
42599
+ var CANCELLED_FOOTER = `
42600
+
42601
+ \u23f9 Cancelled \u2014 the turn was stopped`;
42602
+ function buildCancelledCardEdits(cardText, cards) {
42603
+ return cards.map(({ chatId, messageId }) => ({
42604
+ chatId,
42605
+ messageId,
42606
+ text: `${cardText}${CANCELLED_FOOTER}`,
42607
+ stripKeyboard: true
42608
+ }));
42609
+ }
42571
42610
  var STALE_TAP_NOTICE = "This request already resolved (timed out) \u2014 ask again to act.";
42572
42611
 
42573
42612
  // shared/bot-runtime.ts
@@ -56238,6 +56277,7 @@ function createChatLock() {
56238
56277
  if (chains.get(key) === tracked)
56239
56278
  chains.delete(key);
56240
56279
  });
56280
+ tracked.catch(() => {});
56241
56281
  chains.set(key, tracked);
56242
56282
  return next;
56243
56283
  }
@@ -56378,6 +56418,146 @@ async function retryWithThreadFallback2(retry, send, opts) {
56378
56418
  throw err;
56379
56419
  }
56380
56420
  }
56421
+ function isPhotoDimensionRejectError(err) {
56422
+ if (!(err instanceof import_grammy6.GrammyError) || err.error_code !== 400)
56423
+ return false;
56424
+ const d = (err.description || "").toLowerCase();
56425
+ return d.includes("photo_invalid_dimensions") || d.includes("photo_save_file_invalid") || d.includes("photo dimensions") || d.includes("image_process_failed") || d.includes("photo is too big") || d.includes("too big for a photo") || d.includes("image is too big") || d.includes("file is too big");
56426
+ }
56427
+
56428
+ // photo-precheck.ts
56429
+ import { closeSync as closeSync2, fstatSync, openSync as openSync2, readSync } from "node:fs";
56430
+ var PHOTO_MAX_DIMENSION_SUM = 1e4;
56431
+ var PHOTO_MAX_ASPECT_RATIO = 10;
56432
+ var PHOTO_MAX_BYTES = 10 * 1024 * 1024;
56433
+ function pngDimensions(buf) {
56434
+ if (buf.length < 24)
56435
+ return null;
56436
+ if (buf.readUInt32BE(0) !== 2303741511 || buf.readUInt32BE(4) !== 218765834)
56437
+ return null;
56438
+ if (buf.toString("ascii", 12, 16) !== "IHDR")
56439
+ return null;
56440
+ return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
56441
+ }
56442
+ function jpegDimensions(buf) {
56443
+ if (buf.length < 4 || buf[0] !== 255 || buf[1] !== 216)
56444
+ return null;
56445
+ let off = 2;
56446
+ while (off + 9 < buf.length) {
56447
+ if (buf[off] !== 255) {
56448
+ off++;
56449
+ continue;
56450
+ }
56451
+ const marker = buf[off + 1];
56452
+ if (marker === 216 || marker >= 208 && marker <= 215 || marker === 1 || marker === 255) {
56453
+ off += 2;
56454
+ continue;
56455
+ }
56456
+ const len = buf.readUInt16BE(off + 2);
56457
+ if (marker >= 192 && marker <= 207 && marker !== 196 && marker !== 200 && marker !== 204) {
56458
+ return { height: buf.readUInt16BE(off + 5), width: buf.readUInt16BE(off + 7) };
56459
+ }
56460
+ if (len < 2)
56461
+ return null;
56462
+ off += 2 + len;
56463
+ }
56464
+ return null;
56465
+ }
56466
+ function gifDimensions(buf) {
56467
+ if (buf.length < 10)
56468
+ return null;
56469
+ const sig = buf.toString("ascii", 0, 6);
56470
+ if (sig !== "GIF87a" && sig !== "GIF89a")
56471
+ return null;
56472
+ return { width: buf.readUInt16LE(6), height: buf.readUInt16LE(8) };
56473
+ }
56474
+ function webpDimensions(buf) {
56475
+ if (buf.length < 30)
56476
+ return null;
56477
+ if (buf.toString("ascii", 0, 4) !== "RIFF" || buf.toString("ascii", 8, 12) !== "WEBP")
56478
+ return null;
56479
+ const chunk = buf.toString("ascii", 12, 16);
56480
+ if (chunk === "VP8X") {
56481
+ const width = 1 + (buf[24] | buf[25] << 8 | buf[26] << 16);
56482
+ const height = 1 + (buf[27] | buf[28] << 8 | buf[29] << 16);
56483
+ return { width, height };
56484
+ }
56485
+ if (chunk === "VP8L") {
56486
+ if (buf[20] !== 47)
56487
+ return null;
56488
+ const b0 = buf[21], b1 = buf[22], b2 = buf[23], b3 = buf[24];
56489
+ const width = 1 + ((b1 & 63) << 8 | b0);
56490
+ const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
56491
+ return { width, height };
56492
+ }
56493
+ if (chunk === "VP8 ") {
56494
+ if (buf[23] !== 157 || buf[24] !== 1 || buf[25] !== 42)
56495
+ return null;
56496
+ const width = buf.readUInt16LE(26) & 16383;
56497
+ const height = buf.readUInt16LE(28) & 16383;
56498
+ return { width, height };
56499
+ }
56500
+ return null;
56501
+ }
56502
+ function probeImageDimensions(buf) {
56503
+ return pngDimensions(buf) ?? jpegDimensions(buf) ?? gifDimensions(buf) ?? webpDimensions(buf);
56504
+ }
56505
+ var HEADER_PROBE_BYTES = 32;
56506
+ var JPEG_PROBE_BYTES = 64 * 1024;
56507
+ function probeImageFile(path2) {
56508
+ let fd;
56509
+ try {
56510
+ fd = openSync2(path2, "r");
56511
+ const bytes = fstatSync(fd).size;
56512
+ const headBuf = Buffer.alloc(HEADER_PROBE_BYTES);
56513
+ const headRead = readSync(fd, headBuf, 0, HEADER_PROBE_BYTES, 0);
56514
+ let probe = headBuf.subarray(0, headRead);
56515
+ if (headRead >= 2 && probe[0] === 255 && probe[1] === 216) {
56516
+ const jpegBuf = Buffer.alloc(Math.min(JPEG_PROBE_BYTES, bytes));
56517
+ const jpegRead = readSync(fd, jpegBuf, 0, jpegBuf.length, 0);
56518
+ probe = jpegBuf.subarray(0, jpegRead);
56519
+ }
56520
+ const dims = probeImageDimensions(probe);
56521
+ if (!dims || dims.width <= 0 || dims.height <= 0)
56522
+ return null;
56523
+ return { ...dims, bytes };
56524
+ } catch {
56525
+ return null;
56526
+ } finally {
56527
+ if (fd !== undefined) {
56528
+ try {
56529
+ closeSync2(fd);
56530
+ } catch {}
56531
+ }
56532
+ }
56533
+ }
56534
+ function photoSendViolation(probe) {
56535
+ const { width, height, bytes } = probe;
56536
+ if (width + height > PHOTO_MAX_DIMENSION_SUM) {
56537
+ return `dimensions ${width}x${height} exceed width+height<=${PHOTO_MAX_DIMENSION_SUM}`;
56538
+ }
56539
+ const ratio = Math.max(width, height) / Math.min(width, height);
56540
+ if (ratio > PHOTO_MAX_ASPECT_RATIO) {
56541
+ return `aspect ratio ${ratio.toFixed(1)}:1 (${width}x${height}) exceeds ${PHOTO_MAX_ASPECT_RATIO}:1 photo bound`;
56542
+ }
56543
+ if (bytes > PHOTO_MAX_BYTES) {
56544
+ return `file size ${(bytes / (1024 * 1024)).toFixed(1)}MB exceeds ${PHOTO_MAX_BYTES / (1024 * 1024)}MB photo ceiling`;
56545
+ }
56546
+ return null;
56547
+ }
56548
+ function rerouteResultSuffix(reroutes) {
56549
+ if (reroutes.length === 0)
56550
+ return "";
56551
+ const details = reroutes.map((r) => `${r.path.split("/").pop() ?? r.path}: ${r.reason}`).join("; ");
56552
+ return ` (${reroutes.length} file(s) sent as document, not inline photo: ${details})`;
56553
+ }
56554
+ function classifyPhotoFile(path2) {
56555
+ const probe = probeImageFile(path2);
56556
+ if (!probe)
56557
+ return { route: "photo" };
56558
+ const violation = photoSendViolation(probe);
56559
+ return violation ? { route: "document", reason: violation } : { route: "photo" };
56560
+ }
56381
56561
 
56382
56562
  // shared/bot-runtime.ts
56383
56563
  var import_grammy7 = __toESM(require_mod2(), 1);
@@ -67772,7 +67952,7 @@ function parseModelCommand(text4) {
67772
67952
  }
67773
67953
  return { kind: "set", model: arg };
67774
67954
  }
67775
- var PERSIST_NOTE = "_Sticky across switchroom-managed relaunches (`/new`, watchdog recovery); reverts on `/restart`, agent restart, crash, or external container restart. `/model default` clears it. To persist, set `model:` in switchroom.yaml._";
67955
+ var PERSIST_NOTE = "_Sticky \u2014 persists across restarts, deploys, and crashes until `/model default` clears it (or the configured `model:` in switchroom.yaml changes, which resets it and notifies you). To change the default, set `model:` in switchroom.yaml._";
67776
67956
  function helpText2(deps, reason) {
67777
67957
  const srAliasExamples = Object.keys(SR_MODEL_ALIASES).map((a) => `\`${a}\``).join(" \u00b7 ");
67778
67958
  const lines = [];
@@ -67808,7 +67988,7 @@ async function handleModelCommand(parsed, deps) {
67808
67988
  const model = expandSrAlias(parsed.model);
67809
67989
  if (deps.isBusy()) {
67810
67990
  return {
67811
- text: "\u23f3 The agent is mid-turn \u2014 a model switch needs an idle session. Try again in a moment.",
67991
+ text: "\u23f3 The agent is mid-turn \u2014 a model switch needs an idle session. The switch was not applied.",
67812
67992
  html: true
67813
67993
  };
67814
67994
  }
@@ -67967,6 +68147,13 @@ var SR_MODEL_ALIASES = {
67967
68147
  glm: "sr-glm-5",
67968
68148
  codex: "sr-codex-5.5"
67969
68149
  };
68150
+ function isOfflineTrustedModelToken(token) {
68151
+ if (MODEL_ALIASES.includes(token))
68152
+ return true;
68153
+ if (token in SR_MODEL_ALIASES)
68154
+ return true;
68155
+ return Object.values(SR_MODEL_ALIASES).includes(token);
68156
+ }
67970
68157
  function expandSrAlias(arg) {
67971
68158
  return SR_MODEL_ALIASES[arg.toLowerCase()] ?? arg;
67972
68159
  }
@@ -67982,10 +68169,49 @@ function classifyDiscoveredOptions(options) {
67982
68169
  function modelSelectCallbackData(label) {
67983
68170
  return `${MODEL_CALLBACK_SELECT}${labelTag(label)}`;
67984
68171
  }
67985
- function busyReply(deps) {
68172
+ var BUSY_REFUSAL_TEXT = "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Your tap was not applied.";
68173
+ function isBusyRefusalText(text4) {
68174
+ return text4.includes("The agent is mid-turn");
68175
+ }
68176
+ function busyStaticMenu(deps, page) {
68177
+ const externalNames = externalModelNames([]);
68178
+ if (page === "external") {
68179
+ return {
68180
+ text: [
68181
+ `**Model \u2014 ${deps.escapeHtml(deps.getAgentName())}** \u00b7 \uD83C\uDF10 External`,
68182
+ "_Agent is mid-turn \u2014 taps are queued and apply the moment the turn ends._",
68183
+ "These models are **billed separately** via OpenRouter. Tap one to switch the **live session**:",
68184
+ PERSIST_NOTE
68185
+ ].join(`
68186
+ `),
68187
+ html: true,
68188
+ keyboard: externalPageKeyboard(externalNames)
68189
+ };
68190
+ }
68191
+ const rows = [];
68192
+ for (const alias of MODEL_ALIASES) {
68193
+ if (alias === "default")
68194
+ continue;
68195
+ rows.push([{
68196
+ text: alias.charAt(0).toUpperCase() + alias.slice(1),
68197
+ callback_data: `${MODEL_CALLBACK_ALIAS}${alias}`
68198
+ }]);
68199
+ }
68200
+ rows.push([{ text: "Default (configured)", callback_data: `${MODEL_CALLBACK_ALIAS}default` }]);
68201
+ if (externalNames.length > 0) {
68202
+ rows.push([{ text: "\uD83C\uDF10 External models \u25b8", callback_data: MODEL_CALLBACK_PAGE_EXTERNAL }]);
68203
+ }
68204
+ rows.push([{ text: "\uD83D\uDD04 Refresh", callback_data: MODEL_CALLBACK_REFRESH }]);
67986
68205
  return {
67987
- text: "\u23f3 The agent is mid-turn \u2014 the model picker needs an idle prompt. Try again in a moment.",
67988
- html: true
68206
+ text: [
68207
+ `**Model \u2014 ${deps.escapeHtml(deps.getAgentName())}**`,
68208
+ "_Agent is mid-turn, so the live picker can\u2019t be read right now \u2014 this is the quick list. A tap is queued and applies the moment the turn ends._",
68209
+ "Tap a model to switch the **live session**:",
68210
+ PERSIST_NOTE
68211
+ ].join(`
68212
+ `),
68213
+ html: true,
68214
+ keyboard: rows
67989
68215
  };
67990
68216
  }
67991
68217
  function headerRow(label) {
@@ -68034,7 +68260,7 @@ function externalPageKeyboard(externalNames) {
68034
68260
  }
68035
68261
  async function buildModelMenu(deps, page = "main") {
68036
68262
  if (deps.isBusy())
68037
- return busyReply(deps);
68263
+ return busyStaticMenu(deps, page);
68038
68264
  const [discovered, quota, srNames] = await Promise.all([
68039
68265
  deps.discover(deps.getAgentName()),
68040
68266
  deps.getQuotaBrief().catch(() => null),
@@ -68099,8 +68325,9 @@ async function handleModelMenuCallback(data, deps) {
68099
68325
  if (deps.isBusy()) {
68100
68326
  return {
68101
68327
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
68102
- reply: busyReply(deps),
68103
- toastOnly: true
68328
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
68329
+ toastOnly: true,
68330
+ busyRefusal: true
68104
68331
  };
68105
68332
  }
68106
68333
  let aliasResult;
@@ -68142,8 +68369,9 @@ async function handleModelMenuCallback(data, deps) {
68142
68369
  if (deps.isBusy()) {
68143
68370
  return {
68144
68371
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
68145
- reply: busyReply(deps),
68146
- toastOnly: true
68372
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
68373
+ toastOnly: true,
68374
+ busyRefusal: true
68147
68375
  };
68148
68376
  }
68149
68377
  try {
@@ -68168,8 +68396,9 @@ ${PERSIST_NOTE}`),
68168
68396
  if (deps.isBusy()) {
68169
68397
  return {
68170
68398
  answer: "\u23f3 Agent is mid-turn \u2014 tap again when it\u2019s idle",
68171
- reply: busyReply(deps),
68172
- toastOnly: true
68399
+ reply: { text: BUSY_REFUSAL_TEXT, html: true },
68400
+ toastOnly: true,
68401
+ busyRefusal: true
68173
68402
  };
68174
68403
  }
68175
68404
  const tag = data.slice(MODEL_CALLBACK_SELECT.length);
@@ -68278,9 +68507,8 @@ function isValidModelArg2(arg) {
68278
68507
  var SESSION_MODEL_FILE = ".session-model";
68279
68508
  var RELAUNCH_MODEL_INTENT_FILE = ".relaunch-model-intent";
68280
68509
  var CONFIGURED_DEFAULT_MODEL_FILE = ".configured-default-model";
68281
- var REVERT_RESTART_REASONS = new Set(["inline-button-restart"]);
68282
- function intentForRestartReason(reason) {
68283
- return REVERT_RESTART_REASONS.has(reason) ? "revert" : "keep";
68510
+ function intentForRestartReason(_reason) {
68511
+ return "keep";
68284
68512
  }
68285
68513
  function atomicWrite(path2, content3) {
68286
68514
  const tmp = `${path2}.tmp-${process.pid}-${Date.now()}`;
@@ -68295,6 +68523,17 @@ function serializeSessionModel(rec) {
68295
68523
  })}
68296
68524
  `;
68297
68525
  }
68526
+ function parseSessionModel(text4) {
68527
+ try {
68528
+ const raw = JSON.parse(text4);
68529
+ if (typeof raw.model !== "string" || typeof raw.configuredDefaultAtWrite !== "string" || typeof raw.ts !== "number" || !isValidModelArg2(raw.model)) {
68530
+ return null;
68531
+ }
68532
+ return { model: raw.model, configuredDefaultAtWrite: raw.configuredDefaultAtWrite, ts: raw.ts };
68533
+ } catch {
68534
+ return null;
68535
+ }
68536
+ }
68298
68537
  function writeSessionModelFile(agentDir, model, configuredDefaultAtWrite) {
68299
68538
  if (!isValidModelArg2(model)) {
68300
68539
  throw new Error(`refusing to persist non-canonical session model token: ${JSON.stringify(model)}`);
@@ -68308,9 +68547,15 @@ function readSessionModelFileRaw(agentDir) {
68308
68547
  return null;
68309
68548
  }
68310
68549
  }
68550
+ function readSessionModelFile(agentDir) {
68551
+ const raw = readSessionModelFileRaw(agentDir);
68552
+ return raw == null ? null : parseSessionModel(raw);
68553
+ }
68311
68554
  function clearSessionModelFile(agentDir) {
68312
68555
  try {
68313
68556
  rmSync4(join28(agentDir, SESSION_MODEL_FILE), { force: true });
68557
+ rmSync4(join28(agentDir, ".session-model-kept-notified"), { force: true });
68558
+ rmSync4(join28(agentDir, ".session-model-boot-attempts"), { force: true });
68314
68559
  } catch {}
68315
68560
  }
68316
68561
  function restoreSessionModelFileRaw(agentDir, raw) {
@@ -68331,6 +68576,26 @@ function writeRelaunchModelIntent(agentDir, intent, reason) {
68331
68576
  `);
68332
68577
  }
68333
68578
  }
68579
+ var GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX = "gateway-shutdown:";
68580
+ function readRelaunchModelIntent(agentDir) {
68581
+ try {
68582
+ const raw = readFileSync24(join28(agentDir, RELAUNCH_MODEL_INTENT_FILE), "utf8");
68583
+ const parsed = JSON.parse(raw);
68584
+ if (parsed.intent !== "keep" && parsed.intent !== "revert" || typeof parsed.reason !== "string" || typeof parsed.ts !== "number") {
68585
+ return null;
68586
+ }
68587
+ return { intent: parsed.intent, reason: parsed.reason, ts: parsed.ts };
68588
+ } catch {
68589
+ return null;
68590
+ }
68591
+ }
68592
+ function clearStaleGatewayShutdownIntent(agentDir) {
68593
+ const rec = readRelaunchModelIntent(agentDir);
68594
+ if (rec == null || !rec.reason.startsWith(GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX))
68595
+ return false;
68596
+ clearRelaunchModelIntent(agentDir);
68597
+ return true;
68598
+ }
68334
68599
  function clearRelaunchModelIntent(agentDir) {
68335
68600
  try {
68336
68601
  rmSync4(join28(agentDir, RELAUNCH_MODEL_INTENT_FILE), { force: true });
@@ -68344,6 +68609,38 @@ function readConfiguredDefaultModel(agentDir) {
68344
68609
  return null;
68345
68610
  }
68346
68611
  }
68612
+ var SESSION_EFFORT_FILE = ".session-effort";
68613
+ var EFFORT_LEVEL_RE = /^(low|medium|high|xhigh|max)$/;
68614
+ function parseSessionEffort(text4) {
68615
+ try {
68616
+ const raw = JSON.parse(text4);
68617
+ if (typeof raw.level !== "string" || typeof raw.configuredDefaultAtWrite !== "string" || typeof raw.ts !== "number" || !EFFORT_LEVEL_RE.test(raw.level)) {
68618
+ return null;
68619
+ }
68620
+ return { level: raw.level, configuredDefaultAtWrite: raw.configuredDefaultAtWrite, ts: raw.ts };
68621
+ } catch {
68622
+ return null;
68623
+ }
68624
+ }
68625
+ function writeSessionEffortFile(agentDir, level, configuredDefaultAtWrite) {
68626
+ if (!EFFORT_LEVEL_RE.test(level)) {
68627
+ throw new Error(`refusing to persist non-allowlisted effort level: ${JSON.stringify(level)}`);
68628
+ }
68629
+ atomicWrite(join28(agentDir, SESSION_EFFORT_FILE), `${JSON.stringify({ level, configuredDefaultAtWrite: configuredDefaultAtWrite ?? "", ts: Date.now() })}
68630
+ `);
68631
+ }
68632
+ function readSessionEffortFile(agentDir) {
68633
+ try {
68634
+ return parseSessionEffort(readFileSync24(join28(agentDir, SESSION_EFFORT_FILE), "utf8"));
68635
+ } catch {
68636
+ return null;
68637
+ }
68638
+ }
68639
+ function clearSessionEffortFile(agentDir) {
68640
+ try {
68641
+ rmSync4(join28(agentDir, SESSION_EFFORT_FILE), { force: true });
68642
+ } catch {}
68643
+ }
68347
68644
 
68348
68645
  // ../src/agents/model-picker.ts
68349
68646
  var HEADER_RE = /Select model/;
@@ -68608,7 +68905,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
68608
68905
  return typeof value === "number" && Number.isFinite(value);
68609
68906
  });
68610
68907
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
68611
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline"];
68908
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
68612
68909
  for (const name of SHARED_FRAGMENTS) {
68613
68910
  const fragPath = join30(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
68614
68911
  if (existsSync26(fragPath)) {
@@ -68662,6 +68959,7 @@ init_paths();
68662
68959
  // ../src/agents/scaffold.ts
68663
68960
  var REPO_ROOT = resolve6(import.meta.dirname, "../..");
68664
68961
  var SWITCHROOM_DEFAULT_MAIN_MODEL = "claude-sonnet-5";
68962
+ var SWITCHROOM_DEFAULT_THINKING_EFFORT = "low";
68665
68963
  function resolveMainModel(model) {
68666
68964
  if (model === undefined || model === "default")
68667
68965
  return SWITCHROOM_DEFAULT_MAIN_MODEL;
@@ -68696,31 +68994,56 @@ function parseEffortCommand(text4) {
68696
68994
  const arg = parts[0];
68697
68995
  if (arg.toLowerCase() === "help")
68698
68996
  return { kind: "help" };
68997
+ if (arg.toLowerCase() === "default")
68998
+ return { kind: "default" };
68699
68999
  if (!isValidEffortArg(arg)) {
68700
69000
  return { kind: "help", reason: `not a valid effort level: ${arg}` };
68701
69001
  }
68702
69002
  return { kind: "set", level: arg.toLowerCase() };
68703
69003
  }
68704
- var PERSIST_NOTE2 = "_Session-only \u2014 reverts to the configured default on restart. To change the default, set `thinking_effort:` in switchroom.yaml and restart._";
69004
+ var PERSIST_NOTE2 = "_Sticky \u2014 persists across restarts and deploys until `/effort default` clears it. To change the configured default, set `thinking_effort:` in switchroom.yaml._";
68705
69005
  var LEVELS_INLINE = EFFORT_LEVELS.map((l) => `\`${l}\``).join(" \u00b7 ");
68706
69006
  function helpText3(deps, reason) {
68707
69007
  const lines = [];
68708
69008
  if (reason)
68709
69009
  lines.push(`\u26a0\ufe0f ${deps.escapeHtml(reason)}`);
68710
- lines.push("**/effort** \u2014 show or switch the reasoning effort (faster\u2192smarter)", "`/effort` \u2014 show the configured effort + a tap menu", `\`/effort <level>\` \u2014 switch the live session (${LEVELS_INLINE})`, PERSIST_NOTE2);
69010
+ lines.push("**/effort** \u2014 show or switch the reasoning effort (faster\u2192smarter)", "`/effort` \u2014 show the configured effort + a tap menu", `\`/effort <level>\` \u2014 switch the live session (${LEVELS_INLINE})`, "`/effort default` \u2014 clear the override, back to the configured default", PERSIST_NOTE2);
68711
69011
  return { text: lines.join(`
68712
69012
  `), html: true };
68713
69013
  }
68714
69014
  async function handleEffortCommand(parsed, deps) {
68715
69015
  if (parsed.kind === "help")
68716
69016
  return helpText3(deps, parsed.reason);
69017
+ if (parsed.kind === "default") {
69018
+ const configured = deps.getConfiguredEffort();
69019
+ const restoreLevel = configured && isValidEffortArg(configured) ? configured.toLowerCase() : "low";
69020
+ let restored = null;
69021
+ try {
69022
+ restored = await deps.applyEffort(deps.getAgentName(), restoreLevel);
69023
+ } catch {
69024
+ restored = null;
69025
+ }
69026
+ deps.clearSessionEffort?.();
69027
+ const cfgHtml = `\`${deps.escapeHtml(restoreLevel)}\``;
69028
+ if (restored?.ok) {
69029
+ return {
69030
+ text: `\u2705 Effort override cleared \u2014 back on the configured default ${cfgHtml}. It will also boot on ${cfgHtml} from now on.`,
69031
+ html: true
69032
+ };
69033
+ }
69034
+ return {
69035
+ text: `\u2705 Effort override cleared \u2014 the agent boots on the configured default ${cfgHtml} from now on. (Couldn't switch the live session right now; it may already be on ${cfgHtml}, or will be after the next restart.)`,
69036
+ html: true
69037
+ };
69038
+ }
68717
69039
  if (parsed.kind === "show") {
68718
69040
  const configured = deps.getConfiguredEffort();
68719
69041
  const shown = configured && configured.length > 0 ? configured : "low";
69042
+ const override = deps.getSessionEffort?.() ?? null;
68720
69043
  return {
68721
69044
  text: [
68722
69045
  `**Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}**`,
68723
- `Configured default: \`${deps.escapeHtml(shown)}\``,
69046
+ `Configured default: \`${deps.escapeHtml(shown)}\`${override ? ` \u00b7 session override: \`${deps.escapeHtml(override)}\`` : ""}`,
68724
69047
  `Switch the live session: ${EFFORT_LEVELS.map((l) => `\`/effort ${l}\``).join(" \u00b7 ")}`,
68725
69048
  PERSIST_NOTE2
68726
69049
  ].join(`
@@ -68776,7 +69099,7 @@ function menuKeyboard(highlight) {
68776
69099
  }
68777
69100
  function buildEffortMenu(deps, highlight) {
68778
69101
  const configured = deps.getConfiguredEffort() || "low";
68779
- const live = highlight ?? configured;
69102
+ const live = highlight ?? deps.getSessionEffort?.() ?? configured;
68780
69103
  return {
68781
69104
  text: [
68782
69105
  `**Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}**`,
@@ -68823,6 +69146,122 @@ ${menu.text}` },
68823
69146
  };
68824
69147
  }
68825
69148
 
69149
+ // gateway/pending-session-command.ts
69150
+ function createPendingSessionCommandSlots() {
69151
+ const slots = new Map;
69152
+ return {
69153
+ get: (kind) => slots.get(kind) ?? null,
69154
+ set(cmd) {
69155
+ const displaced = slots.get(cmd.kind) ?? null;
69156
+ slots.delete(cmd.kind);
69157
+ slots.set(cmd.kind, cmd);
69158
+ return displaced;
69159
+ },
69160
+ take(kind) {
69161
+ const cur = slots.get(kind) ?? null;
69162
+ slots.delete(kind);
69163
+ return cur;
69164
+ },
69165
+ takeAll() {
69166
+ const all2 = [...slots.values()];
69167
+ slots.clear();
69168
+ return all2;
69169
+ },
69170
+ list: () => [...slots.values()],
69171
+ clear() {
69172
+ slots.clear();
69173
+ },
69174
+ get size() {
69175
+ return slots.size;
69176
+ }
69177
+ };
69178
+ }
69179
+ async function drainTakenCommands(taken, io) {
69180
+ for (let i = 0;i < taken.length; i++) {
69181
+ const cmd = taken[i];
69182
+ if (io.restartPending()) {
69183
+ await io.editCard(cmd, io.resolveForRestartText(cmd));
69184
+ continue;
69185
+ }
69186
+ if (io.turnInFlight()) {
69187
+ for (const rest of taken.slice(i))
69188
+ io.reEnqueue(rest);
69189
+ return;
69190
+ }
69191
+ let body;
69192
+ try {
69193
+ body = await io.apply(cmd);
69194
+ } catch (err) {
69195
+ await io.editCard(cmd, io.failureText(cmd, err));
69196
+ continue;
69197
+ }
69198
+ if (io.isBusyRefusal(body)) {
69199
+ for (const rest of taken.slice(i))
69200
+ io.reEnqueue(rest);
69201
+ return;
69202
+ }
69203
+ await io.editCard(cmd, body);
69204
+ }
69205
+ }
69206
+ function drainCapDecision(queued, now, capMs, turnInFlight) {
69207
+ const overdue = queued.some((c) => now - c.requestedAt > capMs);
69208
+ if (!overdue)
69209
+ return "wait";
69210
+ return turnInFlight ? "defer-turn-in-flight" : "force";
69211
+ }
69212
+ var EFFORT_MENU_SELECT_PREFIX = "eff:s:";
69213
+ function classifyShutdownPersist(cmd) {
69214
+ if (cmd.kind === "effort") {
69215
+ const level = cmd.origin === "menu" && cmd.arg.startsWith(EFFORT_MENU_SELECT_PREFIX) ? cmd.arg.slice(EFFORT_MENU_SELECT_PREFIX.length) : cmd.arg;
69216
+ if (level === "default")
69217
+ return { persist: "clear-effort", arg: level };
69218
+ return { persist: "effort", arg: level };
69219
+ }
69220
+ if (cmd.origin === "menu")
69221
+ return { persist: null, arg: cmd.arg };
69222
+ if (cmd.arg === "default")
69223
+ return { persist: "clear-model", arg: cmd.arg };
69224
+ return { persist: "model", arg: cmd.arg };
69225
+ }
69226
+ function persistedText(cmd, escapeHtml5) {
69227
+ const noun = KIND_NOUN[cmd.kind];
69228
+ const isClear = cmd.arg === "default" || cmd.targetLabel === "default";
69229
+ if (isClear) {
69230
+ return `\uD83D\uDCBE The session is restarting \u2014 your ${noun} override was cleared as requested; the agent boots on the configured default.`;
69231
+ }
69232
+ return `\uD83D\uDCBE The session is restarting \u2014 your \`${escapeHtml5(cmd.targetLabel)}\` ${noun} choice is saved and applies as the agent boots.`;
69233
+ }
69234
+ function shutdownResolutionActions(slots, escapeHtml5) {
69235
+ return slots.takeAll().map((cmd) => resolveForRestart(cmd, escapeHtml5));
69236
+ }
69237
+ function resolveForRestart(cmd, escapeHtml5) {
69238
+ const { persist, arg } = classifyShutdownPersist(cmd);
69239
+ return {
69240
+ cmd,
69241
+ persist,
69242
+ arg,
69243
+ persistedText: persistedText(cmd, escapeHtml5),
69244
+ reissueText: restartSupersededText(cmd, escapeHtml5)
69245
+ };
69246
+ }
69247
+ var KIND_NOUN = {
69248
+ model: "model",
69249
+ effort: "effort"
69250
+ };
69251
+ var KIND_VERB = {
69252
+ model: "switch to",
69253
+ effort: "set effort to"
69254
+ };
69255
+ function ackText(kind, targetLabel, escapeHtml5) {
69256
+ return `\uD83D\uDCE5 Agent is mid-turn \u2014 I'll ${KIND_VERB[kind]} \`${escapeHtml5(targetLabel)}\` the moment this turn finishes.`;
69257
+ }
69258
+ function supersededText(displaced, next, escapeHtml5) {
69259
+ return `\u21a9\ufe0f Superseded \u2014 queued \`${escapeHtml5(next.targetLabel)}\` instead (your earlier \`${escapeHtml5(displaced.targetLabel)}\` ${KIND_NOUN[displaced.kind]} request was replaced before it applied).`;
69260
+ }
69261
+ function restartSupersededText(cmd, escapeHtml5) {
69262
+ return `\u21a9\ufe0f The session is restarting before your \`${escapeHtml5(cmd.targetLabel)}\` ${KIND_NOUN[cmd.kind]} change could apply \u2014 re-issue \`/${cmd.kind} ${escapeHtml5(cmd.targetLabel)}\` once the agent is back.`;
69263
+ }
69264
+
68826
69265
  // welcome-text.ts
68827
69266
  init_demo_mask();
68828
69267
  init_card_format();
@@ -68866,11 +69305,12 @@ function switchroomHelpText(agentName3) {
68866
69305
  `\`/deny [id]\` \u2014 deny pending tool permission`,
68867
69306
  `\`/pending\` \u2014 list pending permission prompts`,
68868
69307
  `\`/interrupt [name]\` \u2014 interrupt an agent turn`,
69308
+ `\`/stop\` \u2014 cancel my in-flight turn (bare "stop" works too)`,
68869
69309
  ``,
68870
69310
  `**Agents**`,
68871
69311
  `\`/agents\` \u2014 list all agents`,
68872
69312
  `\`/agentstart [name]\` \u2014 start an agent`,
68873
- `\`/stop [name]\` \u2014 stop an agent`,
69313
+ `\`/agentstop [name]\` \u2014 stop an agent's container`,
68874
69314
  `\`/logs [name] [lines]\` \u2014 show agent logs`,
68875
69315
  `\`/memory <query>\` \u2014 search agent memory`,
68876
69316
  ``,
@@ -74127,10 +74567,10 @@ function skillProposalKeyboard(id) {
74127
74567
 
74128
74568
  // ../src/self-improve/skill-proposals.ts
74129
74569
  import {
74130
- closeSync as closeSync4,
74570
+ closeSync as closeSync5,
74131
74571
  existsSync as existsSync33,
74132
74572
  mkdirSync as mkdirSync26,
74133
- openSync as openSync4,
74573
+ openSync as openSync5,
74134
74574
  readFileSync as readFileSync30,
74135
74575
  writeSync as writeSync4
74136
74576
  } from "node:fs";
@@ -74152,12 +74592,12 @@ function ensureDir3(stateDir) {
74152
74592
  }
74153
74593
  }
74154
74594
  function appendLine2(path2, obj) {
74155
- const fd = openSync4(path2, "a");
74595
+ const fd = openSync5(path2, "a");
74156
74596
  try {
74157
74597
  writeSync4(fd, JSON.stringify(obj) + `
74158
74598
  `);
74159
74599
  } finally {
74160
- closeSync4(fd);
74600
+ closeSync5(fd);
74161
74601
  }
74162
74602
  }
74163
74603
  function readLines2(path2, isValid2) {
@@ -75582,7 +76022,7 @@ var ADMIN_COMMAND_NAMES = new Set([
75582
76022
  "agents",
75583
76023
  "logs",
75584
76024
  "restart",
75585
- "stop",
76025
+ "agentstop",
75586
76026
  "agentstart",
75587
76027
  "update",
75588
76028
  "reconcile",
@@ -75629,10 +76069,10 @@ function classifyAdminGate(text4, myAgentName) {
75629
76069
  // subagent-watcher.ts
75630
76070
  import {
75631
76071
  existsSync as existsSync35,
75632
- openSync as openSync6,
75633
- readSync,
76072
+ openSync as openSync7,
76073
+ readSync as readSync2,
75634
76074
  statSync as statSync10,
75635
- closeSync as closeSync6,
76075
+ closeSync as closeSync7,
75636
76076
  watch,
75637
76077
  readdirSync as readdirSync6,
75638
76078
  readFileSync as readFileSync36
@@ -76056,12 +76496,13 @@ function recordSubagentStall(db2, args) {
76056
76496
  }
76057
76497
  function reapStuckRunningRows(db2, args) {
76058
76498
  const cutoff = args.now - args.ttlMs;
76059
- const candidates = db2.prepare(`
76060
- SELECT id FROM subagents
76499
+ const rows = db2.prepare(`
76500
+ SELECT id, jsonl_agent_id FROM subagents
76061
76501
  WHERE status = 'running'
76062
76502
  AND background = 1
76063
76503
  AND COALESCE(last_activity_at, started_at) < ?
76064
76504
  `).all(cutoff);
76505
+ const candidates = args.isLive ? rows.filter((r) => !args.isLive(r.jsonl_agent_id)) : rows;
76065
76506
  for (const row of candidates) {
76066
76507
  recordSubagentStall(db2, {
76067
76508
  id: row.id,
@@ -76116,10 +76557,10 @@ function recordNestedSubagentDispatch(db2, args) {
76116
76557
 
76117
76558
  // gateway/turn-active-marker.ts
76118
76559
  import {
76119
- closeSync as closeSync5,
76560
+ closeSync as closeSync6,
76120
76561
  existsSync as existsSync34,
76121
76562
  mkdirSync as mkdirSync27,
76122
- openSync as openSync5,
76563
+ openSync as openSync6,
76123
76564
  readFileSync as readFileSync35,
76124
76565
  statSync as statSync9,
76125
76566
  unlinkSync as unlinkSync15,
@@ -76137,8 +76578,8 @@ function touchTurnActiveMarker(stateDir) {
76137
76578
  utimesSync(path2, now, now);
76138
76579
  } catch {
76139
76580
  try {
76140
- const fd = openSync5(path2, "r+");
76141
- closeSync5(fd);
76581
+ const fd = openSync6(path2, "r+");
76582
+ closeSync6(fd);
76142
76583
  } catch {}
76143
76584
  }
76144
76585
  }
@@ -76148,7 +76589,10 @@ var DEFAULT_RESCAN_MS = 1000;
76148
76589
  var DEFAULT_STALL_THRESHOLD_MS = 60000;
76149
76590
  var DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300000;
76150
76591
  var DEFAULT_SILENT_STALL_TERMINAL_MS = 300000;
76592
+ var DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60000;
76151
76593
  var LONG_RUNNING_TOOLS = new Set(["Bash"]);
76594
+ var MAX_RESURRECTIONS = 1;
76595
+ var FALSE_FINISH_TRACKER_CAP = 512;
76152
76596
  function isLongRunningTool(name) {
76153
76597
  return name != null && LONG_RUNNING_TOOLS.has(name);
76154
76598
  }
@@ -76403,6 +76847,9 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76403
76847
  entry.lastReplyText = ev.input.text;
76404
76848
  }
76405
76849
  entry.toolCount++;
76850
+ if (ev.toolUseId != null && ev.toolUseId !== "") {
76851
+ entry.inflightToolUseIds.add(ev.toolUseId);
76852
+ }
76406
76853
  entry.lastTool = {
76407
76854
  name: ev.toolName,
76408
76855
  sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {})
@@ -76431,6 +76878,9 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76431
76878
  }
76432
76879
  }
76433
76880
  } else if (ev.kind === "sub_agent_nested_spawn") {
76881
+ if (ev.toolUseId != null && ev.toolUseId.length > 0) {
76882
+ entry.inflightToolUseIds.add(ev.toolUseId);
76883
+ }
76434
76884
  if (db2 != null && ev.toolUseId != null && ev.toolUseId.length > 0) {
76435
76885
  try {
76436
76886
  const nestedInput = ev.input ?? {};
@@ -76454,7 +76904,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76454
76904
  fireNarrativeProgress();
76455
76905
  }
76456
76906
  entry.pendingNarrative = { text: ev.text };
76907
+ } else if (ev.kind === "sub_agent_tool_result") {
76908
+ if (ev.toolUseId != null && ev.toolUseId !== "") {
76909
+ entry.inflightToolUseIds.delete(ev.toolUseId);
76910
+ }
76457
76911
  } else if (ev.kind === "sub_agent_turn_end") {
76912
+ entry.inflightToolUseIds.clear();
76458
76913
  resolvePendingSubNarrative(null, undefined);
76459
76914
  if (entry.state === "running") {
76460
76915
  entry.state = "done";
@@ -76495,6 +76950,7 @@ function startSubagentWatcher(config) {
76495
76950
  const stallThresholdMs = config.stallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_MS") ?? DEFAULT_STALL_THRESHOLD_MS;
76496
76951
  const silentSynthesisStallThresholdMs = config.silentSynthesisStallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS") ?? DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS;
76497
76952
  const silentStallTerminalMs = config.silentStallTerminalMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS") ?? DEFAULT_SILENT_STALL_TERMINAL_MS;
76953
+ const inflightTerminalCapMs = config.inflightTerminalCapMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS") ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS;
76498
76954
  const inflightPromoteMaxAgeMs = config.inflightPromoteMaxAgeMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS") ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS;
76499
76955
  const terminatedAgentIdsCap = config.terminatedAgentIdsCap ?? TERMINATED_AGENT_IDS_CAP;
76500
76956
  const bootPromoteEnabled = config.bootPromoteEnabled ?? process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== "0";
@@ -76523,9 +76979,9 @@ function startSubagentWatcher(config) {
76523
76979
  existsSync: existsSync35,
76524
76980
  readdirSync: readdirSync6,
76525
76981
  statSync: statSync10,
76526
- openSync: openSync6,
76527
- closeSync: closeSync6,
76528
- readSync,
76982
+ openSync: openSync7,
76983
+ closeSync: closeSync7,
76984
+ readSync: readSync2,
76529
76985
  watch
76530
76986
  };
76531
76987
  const registry = new Map;
@@ -76535,6 +76991,7 @@ function startSubagentWatcher(config) {
76535
76991
  const pendingCloses = new Map;
76536
76992
  const historicalFiles = new Set;
76537
76993
  const terminatedAgentIds = new Set;
76994
+ const falseFinishTracker = new Map;
76538
76995
  let bootScanInProgress = true;
76539
76996
  let stopped = false;
76540
76997
  function registerAgent(filePath, agentId) {
@@ -76559,6 +77016,7 @@ function startSubagentWatcher(config) {
76559
77016
  lastResultText: "",
76560
77017
  lastProgressBucketIdx: null,
76561
77018
  lastTool: null,
77019
+ inflightToolUseIds: new Set,
76562
77020
  historical: isHistorical
76563
77021
  };
76564
77022
  registry.set(agentId, entry);
@@ -76739,6 +77197,93 @@ function startSubagentWatcher(config) {
76739
77197
  terminatedAgentIds.add(agentId);
76740
77198
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`);
76741
77199
  }
77200
+ function recordFalseFinish(agentId, filePath, n) {
77201
+ let size = 0;
77202
+ try {
77203
+ size = fs2.statSync(filePath).size;
77204
+ } catch {}
77205
+ const prior = falseFinishTracker.get(agentId);
77206
+ if (prior == null && falseFinishTracker.size >= FALSE_FINISH_TRACKER_CAP) {
77207
+ const oldest = falseFinishTracker.keys().next().value;
77208
+ if (oldest != null)
77209
+ falseFinishTracker.delete(oldest);
77210
+ }
77211
+ falseFinishTracker.set(agentId, {
77212
+ filePath,
77213
+ synthesisedAt: n,
77214
+ sizeAtSynthesis: size,
77215
+ resurrectionCount: prior?.resurrectionCount ?? 0,
77216
+ resurrectedForThisFinish: false,
77217
+ lost: prior?.lost ?? false
77218
+ });
77219
+ }
77220
+ function resurrectAgent(agentId, filePath) {
77221
+ const pc = pendingCloses.get(agentId);
77222
+ if (pc != null) {
77223
+ clearT(pc);
77224
+ pendingCloses.delete(agentId);
77225
+ }
77226
+ terminatedAgentIds.delete(agentId);
77227
+ historicalFiles.delete(filePath);
77228
+ const existing = registry.get(agentId);
77229
+ if (existing != null) {
77230
+ existing.state = "running";
77231
+ existing.historical = false;
77232
+ existing.stallNotified = false;
77233
+ existing.stalledAt = null;
77234
+ existing.stallTerminalSynthesised = false;
77235
+ existing.completionNotified = false;
77236
+ existing.errored = false;
77237
+ existing.errorDetail = undefined;
77238
+ existing.lastActivityAt = nowFn();
77239
+ knownFiles.add(filePath);
77240
+ return;
77241
+ }
77242
+ knownFiles.add(filePath);
77243
+ registerAgent(filePath, agentId);
77244
+ }
77245
+ function checkResurrections() {
77246
+ const n = nowFn();
77247
+ for (const [agentId, rec] of falseFinishTracker) {
77248
+ if (rec.lost)
77249
+ continue;
77250
+ if (rec.resurrectedForThisFinish)
77251
+ continue;
77252
+ let size;
77253
+ try {
77254
+ size = fs2.statSync(rec.filePath).size;
77255
+ } catch {
77256
+ continue;
77257
+ }
77258
+ if (size <= rec.sizeAtSynthesis)
77259
+ continue;
77260
+ const entry = registry.get(agentId);
77261
+ const description = entry?.description ?? "sub-agent";
77262
+ if (rec.resurrectionCount >= MAX_RESURRECTIONS) {
77263
+ rec.lost = true;
77264
+ log?.(`subagent-watcher: worker ${agentId} FALSELY finalised again after a prior resurrection (resurrectionCount=${rec.resurrectionCount} >= ${MAX_RESURRECTIONS}) \u2014 NAMED AS LOST, not resurrecting again (bounded resurrection chain, issue #3023)`);
77265
+ if (config.onWorkerLost != null) {
77266
+ try {
77267
+ config.onWorkerLost(agentId, description);
77268
+ } catch (cbErr) {
77269
+ log?.(`subagent-watcher: onWorkerLost callback error ${agentId}: ${cbErr.message}`);
77270
+ }
77271
+ }
77272
+ continue;
77273
+ }
77274
+ rec.resurrectionCount++;
77275
+ rec.resurrectedForThisFinish = true;
77276
+ log?.(`subagent-watcher: RESURRECTING worker ${agentId} \u2014 JSONL resumed growing (${rec.sizeAtSynthesis} \u2192 ${size} bytes) after a false terminal synthesis; the worker is still alive, reviving its card (resurrection #${rec.resurrectionCount}, issue #3023)`);
77277
+ if (config.onResurrect != null) {
77278
+ try {
77279
+ config.onResurrect(agentId, description);
77280
+ } catch (cbErr) {
77281
+ log?.(`subagent-watcher: onResurrect callback error ${agentId}: ${cbErr.message}`);
77282
+ }
77283
+ }
77284
+ resurrectAgent(agentId, rec.filePath);
77285
+ }
77286
+ }
76742
77287
  function checkStalls() {
76743
77288
  const n = nowFn();
76744
77289
  for (const entry of registry.values()) {
@@ -76786,11 +77331,20 @@ function startSubagentWatcher(config) {
76786
77331
  continue;
76787
77332
  if (n - entry.stalledAt < silentStallTerminalMs)
76788
77333
  continue;
77334
+ if (entry.inflightToolUseIds.size > 0) {
77335
+ const totalIdleMs = n - entry.lastActivityAt;
77336
+ if (totalIdleMs < inflightTerminalCapMs) {
77337
+ log?.(`subagent-watcher: silent-stall terminal synthesis deferred for ${entry.agentId} \u2014 ${entry.inflightToolUseIds.size} tool call(s) still in flight, ${Math.floor(totalIdleMs / 1000)}s idle < ${Math.floor(inflightTerminalCapMs / 1000)}s cap (long-running tool, not a dead worker)`);
77338
+ continue;
77339
+ }
77340
+ log?.(`subagent-watcher: in-flight deferral cap reached for ${entry.agentId} (${Math.floor(totalIdleMs / 1000)}s idle >= ${Math.floor(inflightTerminalCapMs / 1000)}s cap with ${entry.inflightToolUseIds.size} tool call(s) still unresolved) \u2014 treating as died-mid-tool, proceeding with terminal synthesis`);
77341
+ }
76789
77342
  entry.stallTerminalSynthesised = true;
76790
77343
  entry.state = "done";
76791
77344
  const postStallSec = Math.floor((n - entry.stalledAt) / 1000);
76792
77345
  const totalIdleSec = Math.floor((n - entry.lastActivityAt) / 1000);
76793
77346
  log?.(`subagent-watcher: silent-stall terminal synthesis for ${entry.agentId} (stalled ${postStallSec}s post-notify, ${totalIdleSec}s total idle) \u2014 bg worker JSONL lacks turn_end; synthesising sub_agent_turn_end so deferred-completion gate releases`);
77347
+ recordFalseFinish(entry.agentId, entry.filePath, n);
76794
77348
  if (db2 != null) {
76795
77349
  try {
76796
77350
  const rowRef = db2.prepare("SELECT id FROM subagents WHERE jsonl_agent_id = ?").get(entry.agentId);
@@ -76947,6 +77501,7 @@ function startSubagentWatcher(config) {
76947
77501
  maybySendStateTransition(agentId);
76948
77502
  }
76949
77503
  checkStalls();
77504
+ checkResurrections();
76950
77505
  }
76951
77506
  rescanSubagentDirs();
76952
77507
  bootScanInProgress = false;
@@ -76954,7 +77509,16 @@ function startSubagentWatcher(config) {
76954
77509
  if (db2 == null)
76955
77510
  return;
76956
77511
  try {
76957
- const result = reapStuckRunningRows(db2, { ttlMs: reaperTtlMs, now: nowFn() });
77512
+ const result = reapStuckRunningRows(db2, {
77513
+ ttlMs: reaperTtlMs,
77514
+ now: nowFn(),
77515
+ isLive: (jsonlAgentId) => {
77516
+ if (jsonlAgentId == null)
77517
+ return false;
77518
+ const entry = registry.get(jsonlAgentId);
77519
+ return entry != null && entry.state === "running" && !entry.historical;
77520
+ }
77521
+ });
76958
77522
  if (result.reaped > 0) {
76959
77523
  log?.(`subagent-watcher: reaper transitioned ${result.reaped} stuck-running row(s) to stalled (ttl=${Math.round(reaperTtlMs / 60000)}min)`);
76960
77524
  }
@@ -77125,7 +77689,7 @@ function determineRestartReason(opts) {
77125
77689
  init_boot_card();
77126
77690
 
77127
77691
  // gateway/update-announce.ts
77128
- import { existsSync as existsSync41, mkdirSync as mkdirSync32, openSync as openSync7, closeSync as closeSync7, readFileSync as readFileSync43 } from "node:fs";
77692
+ import { existsSync as existsSync41, mkdirSync as mkdirSync32, openSync as openSync8, closeSync as closeSync8, readFileSync as readFileSync43 } from "node:fs";
77129
77693
  import { join as join44 } from "node:path";
77130
77694
  import { homedir as homedir15 } from "node:os";
77131
77695
 
@@ -77326,8 +77890,8 @@ function claimUpdateAnnouncement(requestId, opts = {}) {
77326
77890
  const safeId = requestId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 200);
77327
77891
  const path2 = join44(dir, safeId);
77328
77892
  try {
77329
- const fd = openSync7(path2, "wx");
77330
- closeSync7(fd);
77893
+ const fd = openSync8(path2, "wx");
77894
+ closeSync8(fd);
77331
77895
  return true;
77332
77896
  } catch {
77333
77897
  return false;
@@ -77550,10 +78114,10 @@ import { join as join46 } from "node:path";
77550
78114
 
77551
78115
  // ../src/issues/store.ts
77552
78116
  import {
77553
- closeSync as closeSync8,
78117
+ closeSync as closeSync9,
77554
78118
  existsSync as existsSync42,
77555
78119
  mkdirSync as mkdirSync33,
77556
- openSync as openSync8,
78120
+ openSync as openSync9,
77557
78121
  readdirSync as readdirSync9,
77558
78122
  readFileSync as readFileSync45,
77559
78123
  renameSync as renameSync15,
@@ -77675,7 +78239,7 @@ function withLock(stateDir, fn) {
77675
78239
  let fd = null;
77676
78240
  while (fd === null) {
77677
78241
  try {
77678
- fd = openSync8(lockPath, "wx");
78242
+ fd = openSync9(lockPath, "wx");
77679
78243
  try {
77680
78244
  writeSync5(fd, String(process.pid));
77681
78245
  } catch {}
@@ -77695,7 +78259,7 @@ function withLock(stateDir, fn) {
77695
78259
  return fn();
77696
78260
  } finally {
77697
78261
  try {
77698
- closeSync8(fd);
78262
+ closeSync9(fd);
77699
78263
  } catch {}
77700
78264
  try {
77701
78265
  unlinkSync17(lockPath);
@@ -79117,6 +79681,40 @@ function resolveQuotaWatchTuning(env) {
79117
79681
  function buildQuotaClaimKey(accountLabel, transition2, chatId) {
79118
79682
  return `quota-watch:${accountLabel}:${transition2}:${chatId}`;
79119
79683
  }
79684
+ var FLEET_ROLL_ANNOUNCE_KEY = "__fleet_roll_announce__";
79685
+ var FLEET_ROLL_MAX_AGE_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
79686
+ var QUOTA_WATCH_ROLL_DEDUP_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
79687
+ function evaluateFleetRollAnnounce(args) {
79688
+ const { roll, prev, now } = args;
79689
+ const maxAgeMs = args.maxAgeMs ?? FLEET_ROLL_MAX_AGE_MS;
79690
+ if (!roll)
79691
+ return { kind: "skip", reason: "no-roll" };
79692
+ if (prev.lastNotifiedAt >= roll.at)
79693
+ return { kind: "skip", reason: "already-announced" };
79694
+ if (now - roll.at > maxAgeMs)
79695
+ return { kind: "skip", reason: "stale-roll" };
79696
+ return {
79697
+ kind: "notify",
79698
+ message: buildFleetRollMessage(roll, now),
79699
+ newState: { lastNotifiedHealth: "healthy", lastNotifiedAt: roll.at }
79700
+ };
79701
+ }
79702
+ function buildFleetRollMessage(roll, now) {
79703
+ const winLabel = roll.window === "5h" ? "5-hour window" : roll.window === "7d" ? "7-day window" : "quota window";
79704
+ const pctPart = typeof roll.pct === "number" ? ` at ${fmtPct(roll.pct)}` : "";
79705
+ const resetPart = typeof roll.exhausted_until === "number" && roll.exhausted_until > now ? ` (resets ${formatRelative(new Date(roll.exhausted_until), new Date(now))})` : "";
79706
+ const softAvoid = roll.reason === "soft-avoid";
79707
+ const causeLine = softAvoid ? `Proactive switch \u2014 \`${codeSpanSafe(roll.from)}\` is approaching its limits (${winLabel}${pctPart})${resetPart}, so the fleet moved early instead of hitting the wall.` : `${winLabel}${pctPart} on \`${codeSpanSafe(roll.from)}\`${resetPart}.`;
79708
+ return [
79709
+ `\uD83D\uDD01 **Switched fleet to \`${codeSpanSafe(roll.to)}\`**`,
79710
+ ``,
79711
+ causeLine,
79712
+ `Work continues uninterrupted \u2014 agents and scheduled jobs now serve from \`${codeSpanSafe(roll.to)}\`.`,
79713
+ ``,
79714
+ `_Automatic broker failover${softAvoid ? " (proactive, before exhaustion)" : ""}. Run /auth for fleet status; \`/auth use ${codeSpanSafe(roll.from)}\` to switch back once it ${softAvoid ? "has headroom again" : "refills"}._`
79715
+ ].join(`
79716
+ `);
79717
+ }
79120
79718
  function isLiveCorroboration(entry) {
79121
79719
  return entry?.result.ok === true && entry.served === "live";
79122
79720
  }
@@ -79142,6 +79740,16 @@ function evaluateQuotaWatchAccount(args) {
79142
79740
  lastNotifiedHealth: "throttling",
79143
79741
  lastNotifiedAt: now
79144
79742
  };
79743
+ const lastRoll = args.lastRoll;
79744
+ if (lastRoll && lastRoll.from === label && now - lastRoll.at <= QUOTA_WATCH_ROLL_DEDUP_MS && prev.lastNotifiedAt < lastRoll.at) {
79745
+ return {
79746
+ kind: "reconcile",
79747
+ accountLabel: label,
79748
+ newAccountState: newState,
79749
+ transition: "entered-throttling",
79750
+ reason: "roll-announced"
79751
+ };
79752
+ }
79145
79753
  return {
79146
79754
  kind: "notify",
79147
79755
  accountLabel: label,
@@ -79255,7 +79863,7 @@ function buildThrottlingMessage(agentName3, snap) {
79255
79863
  const activeNote = snap.isActive ? "" : `
79256
79864
  This is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
79257
79865
  const altNote = snap.isActive ? `
79258
- Consider \`/auth use <other-account>\` if you have a healthier account, or wait for the ${winLabel} window to refill${resetStr}.` : "";
79866
+ No action needed: if usage keeps climbing, the fleet will prefer another account once this reaches the failover threshold (you'll get a switch announcement). Or switch early with \`/auth use <other-account>\`, or wait for the ${winLabel} window to refill${resetStr}.` : "";
79259
79867
  return [
79260
79868
  `\uD83D\uDFE1 **Quota approaching limit** \u2014 \`${codeSpanSafe(snap.label)}\``,
79261
79869
  ``,
@@ -79313,15 +79921,50 @@ function patchQuotaWatchState(current, accountLabel, accountState) {
79313
79921
  return { ...current, [accountLabel]: accountState };
79314
79922
  }
79315
79923
 
79924
+ // fallback-card-collapse.ts
79925
+ var CARD_COLLAPSE_MAX_AGE_MS = 5 * 60000;
79926
+ function createModelUnavailableCardRegistry(maxAgeMs = CARD_COLLAPSE_MAX_AGE_MS) {
79927
+ const cards = new Map;
79928
+ return {
79929
+ record(chatId, rec) {
79930
+ cards.set(chatId, rec);
79931
+ },
79932
+ take(chatId, now) {
79933
+ const rec = cards.get(chatId);
79934
+ if (!rec)
79935
+ return null;
79936
+ cards.delete(chatId);
79937
+ if (!rec.promisedFallback)
79938
+ return null;
79939
+ if (now - rec.atMs > maxAgeMs)
79940
+ return null;
79941
+ return rec;
79942
+ },
79943
+ size() {
79944
+ return cards.size;
79945
+ }
79946
+ };
79947
+ }
79948
+ function decideAnnouncementDelivery(outcomeKind, card) {
79949
+ if (outcomeKind !== "switched")
79950
+ return "send";
79951
+ return card !== null ? "edit" : "send";
79952
+ }
79953
+ function foldAnnouncementIntoCard(cardText, announcement) {
79954
+ return `${cardText}
79955
+
79956
+ ${announcement}`;
79957
+ }
79958
+
79316
79959
  // gateway/gateway.ts
79317
79960
  init_auth_snapshot_format2();
79318
79961
 
79319
79962
  // gateway/turn-active-marker.ts
79320
79963
  import {
79321
- closeSync as closeSync9,
79964
+ closeSync as closeSync10,
79322
79965
  existsSync as existsSync46,
79323
79966
  mkdirSync as mkdirSync36,
79324
- openSync as openSync9,
79967
+ openSync as openSync10,
79325
79968
  readFileSync as readFileSync49,
79326
79969
  statSync as statSync13,
79327
79970
  unlinkSync as unlinkSync18,
@@ -79346,8 +79989,8 @@ function touchTurnActiveMarker2(stateDir) {
79346
79989
  utimesSync2(path2, now, now);
79347
79990
  } catch {
79348
79991
  try {
79349
- const fd = openSync9(path2, "r+");
79350
- closeSync9(fd);
79992
+ const fd = openSync10(path2, "r+");
79993
+ closeSync10(fd);
79351
79994
  } catch {}
79352
79995
  }
79353
79996
  }
@@ -79398,10 +80041,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
79398
80041
  }
79399
80042
 
79400
80043
  // ../src/build-info.ts
79401
- var VERSION = "0.18.9";
79402
- var COMMIT_SHA = "436289c9";
79403
- var COMMIT_DATE = "2026-07-10T22:33:10Z";
79404
- var LATEST_PR = 3015;
80044
+ var VERSION = "0.18.10";
80045
+ var COMMIT_SHA = "1f778844";
80046
+ var COMMIT_DATE = "2026-07-11T13:26:53+10:00";
80047
+ var LATEST_PR = 3044;
79405
80048
  var COMMITS_AHEAD_OF_TAG = 0;
79406
80049
 
79407
80050
  // gateway/boot-version.ts
@@ -80616,6 +81259,13 @@ When the restart hit, ${count} sub-agent${count === 1 ? " was" : "s were"} still
80616
81259
  ` + lines.join(`
80617
81260
  `) + overflow + closing;
80618
81261
  }
81262
+ function renderRestartCauseBlock(cause) {
81263
+ if (!cause || cause.note.trim().length === 0)
81264
+ return "";
81265
+ return `
81266
+
81267
+ Why this restart happened: ${cause.note.trim()}`;
81268
+ }
80619
81269
  var RESUME_SYNTHETIC_PROMPT_PREFIX = "You just restarted.";
80620
81270
  function isResumeSyntheticTurn(turn) {
80621
81271
  const p = turn.user_prompt_preview;
@@ -80648,6 +81298,8 @@ function buildResumeInterruptedInbound(ctx) {
80648
81298
  };
80649
81299
  if (ctx.turn.user_prompt_preview)
80650
81300
  meta.original_prompt = ctx.turn.user_prompt_preview;
81301
+ if (ctx.restartCause)
81302
+ meta.restart_cause = ctx.restartCause.reason;
80651
81303
  return {
80652
81304
  type: "inbound",
80653
81305
  chatId: ctx.turn.chat_id,
@@ -80656,7 +81308,7 @@ function buildResumeInterruptedInbound(ctx) {
80656
81308
  user: "switchroom",
80657
81309
  userId: 0,
80658
81310
  ts,
80659
- text: `You just restarted. Your previous turn was interrupted ${elapsed} ago, ` + `before it finished \u2014 it was cut off by a restart, not completed.` + promptClause(ctx.turn) + ` That quoted text is only the first ~200 characters of the original ` + `request, and you've lost your in-memory context across the restart \u2014 so ` + `BEFORE you continue, call get_recent_messages for this chat to read your ` + `full original message and the surrounding conversation, so you resume the ` + `COMPLETE task (including any instructions in the tail of a long request), ` + `not just the truncated preview. Then pick that work back up and carry it ` + `through to completion. In your first message, briefly let the user know ` + `you're resuming what was interrupted (mention roughly how long ago in ` + `plain language) so they're not left wondering \u2014 then carry on with the ` + `actual task. Do not ask whether to resume; just resume. If even after ` + `reading the recent messages you genuinely can't tell what the work was, ` + `say so and ask.` + renderInterruptedSubagentsBlock(ctx.subagents),
81311
+ text: `You just restarted. Your previous turn was interrupted ${elapsed} ago, ` + `before it finished \u2014 it was cut off by a restart, not completed.` + promptClause(ctx.turn) + ` That quoted text is only the first ~200 characters of the original ` + `request, and you've lost your in-memory context across the restart \u2014 so ` + `BEFORE you continue, call get_recent_messages for this chat to read your ` + `full original message and the surrounding conversation, so you resume the ` + `COMPLETE task (including any instructions in the tail of a long request), ` + `not just the truncated preview. Then pick that work back up and carry it ` + `through to completion. In your first message, briefly let the user know ` + `you're resuming what was interrupted (mention roughly how long ago in ` + `plain language) so they're not left wondering \u2014 then carry on with the ` + `actual task. Do not ask whether to resume; just resume. If even after ` + `reading the recent messages you genuinely can't tell what the work was, ` + `say so and ask.` + renderInterruptedSubagentsBlock(ctx.subagents) + renderRestartCauseBlock(ctx.restartCause),
80660
81312
  meta
80661
81313
  };
80662
81314
  }
@@ -80679,6 +81331,8 @@ function buildResumeWatchdogReportInbound(ctx) {
80679
81331
  meta.tool_call_count = String(ctx.turn.tool_call_count);
80680
81332
  if (ctx.turn.user_prompt_preview)
80681
81333
  meta.original_prompt = ctx.turn.user_prompt_preview;
81334
+ if (ctx.restartCause)
81335
+ meta.restart_cause = ctx.restartCause.reason;
80682
81336
  return {
80683
81337
  type: "inbound",
80684
81338
  chatId: ctx.turn.chat_id,
@@ -80687,7 +81341,7 @@ function buildResumeWatchdogReportInbound(ctx) {
80687
81341
  user: "switchroom",
80688
81342
  userId: 0,
80689
81343
  ts,
80690
- text: `You just restarted. Your previous turn (started ${since} ago) was ` + `killed by the hang-watchdog: it made no observable progress for ${idle} ` + `and the watchdog restarts a turn that goes that long without activity.` + toolClause + promptClause(ctx.turn) + ` Do NOT silently resume it \u2014 it may hang again the same way. Instead, ` + `tell the user plainly what happened: that your last turn was killed ` + `after ${idle} of no progress, and roughly what it was doing. Then ask ` + `whether they want you to retry it or take a different angle. Report ` + `only the honest cause \u2014 no observable progress for that long \u2014 don't ` + `speculate about a deeper root cause you can't see.` + renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
81344
+ text: `You just restarted. Your previous turn (started ${since} ago) was ` + `killed by the hang-watchdog: it made no observable progress for ${idle} ` + `and the watchdog restarts a turn that goes that long without activity.` + toolClause + promptClause(ctx.turn) + ` Do NOT silently resume it \u2014 it may hang again the same way. Instead, ` + `tell the user plainly what happened: that your last turn was killed ` + `after ${idle} of no progress, and roughly what it was doing. Then ask ` + `whether they want you to retry it or take a different angle. Report ` + `only the honest cause \u2014 no observable progress for that long \u2014 don't ` + `speculate about a deeper root cause you can't see.` + renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) + renderRestartCauseBlock(ctx.restartCause),
80691
81345
  meta
80692
81346
  };
80693
81347
  }
@@ -80716,6 +81370,8 @@ function buildResumeDeferredReportInbound(ctx) {
80716
81370
  };
80717
81371
  if (ctx.turn.user_prompt_preview)
80718
81372
  meta.original_prompt = ctx.turn.user_prompt_preview;
81373
+ if (ctx.restartCause)
81374
+ meta.restart_cause = ctx.restartCause.reason;
80719
81375
  const cause = ctx.reason === "loop-guard" ? `Your previous turn was ALREADY a resume of earlier interrupted work, ` + `and it was interrupted again by another restart ${elapsed} ago. To ` + `avoid an endless restart\u2192resume loop (and re-running work that may ` + `have already partly executed), the framework has stopped ` + `auto-resuming this chain.` : `Your previous turn was interrupted ${elapsed} ago by a deliberate ` + `restart, before it finished. This agent is configured NOT to ` + `auto-resume work across deliberate restarts (boot_resume: never), so ` + `it was not replayed automatically.`;
80720
81376
  return {
80721
81377
  type: "inbound",
@@ -80725,7 +81381,7 @@ function buildResumeDeferredReportInbound(ctx) {
80725
81381
  user: "switchroom",
80726
81382
  userId: 0,
80727
81383
  ts,
80728
- text: `${RESUME_SYNTHETIC_PROMPT_PREFIX} ${cause}` + promptClause(ctx.turn) + ` Do NOT silently resume it. Instead, briefly tell the user what was in ` + `flight (call get_recent_messages for this chat if you need the full ` + `original request \u2014 the quoted preview is only the first ~200 ` + `characters), then ask whether they want you to pick it back up or drop ` + `it. If you genuinely can't tell what the work was, say so and ask.` + renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }),
81384
+ text: `${RESUME_SYNTHETIC_PROMPT_PREFIX} ${cause}` + promptClause(ctx.turn) + ` Do NOT silently resume it. Instead, briefly tell the user what was in ` + `flight (call get_recent_messages for this chat if you need the full ` + `original request \u2014 the quoted preview is only the first ~200 ` + `characters), then ask whether they want you to pick it back up or drop ` + `it. If you genuinely can't tell what the work was, say so and ask.` + renderInterruptedSubagentsBlock(ctx.subagents, { assertive: false }) + renderRestartCauseBlock(ctx.restartCause),
80729
81385
  meta
80730
81386
  };
80731
81387
  }
@@ -80745,6 +81401,282 @@ function selectResumeBuilder(endedVia, opts) {
80745
81401
  return kind;
80746
81402
  }
80747
81403
 
81404
+ // gateway/bridge-dead-watchdog.ts
81405
+ import { readFileSync as readFileSync51, writeFileSync as writeFileSync40, renameSync as renameSync16, unlinkSync as unlinkSync19 } from "node:fs";
81406
+
81407
+ // gateway/cron-session.ts
81408
+ var CRON_IDENTITY_SUFFIX2 = "-cron";
81409
+ function isCronIdentity2(name) {
81410
+ return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX2);
81411
+ }
81412
+
81413
+ // gateway/bridge-dead-watchdog.ts
81414
+ var BRIDGE_DEAD_RESTART_REASON = "bridge-dead-resume";
81415
+ var DEFAULT_BRIDGE_DEAD_GRACE_MS = 90000;
81416
+ var ESCALATION_MARKER_MAX_AGE_MS = 10 * 60000;
81417
+ var MAX_CONSECUTIVE_ESCALATIONS = 2;
81418
+ var CRASH_LOG_FRESH_WINDOW_MS = 15 * 60000;
81419
+ var CRASH_LOG_TAIL_LINES = 3;
81420
+ function decideBridgeDeadEscalation(s) {
81421
+ if (s.bridgeRegistered)
81422
+ return { action: "skip", why: "bridge-registered" };
81423
+ if (s.shuttingDown)
81424
+ return { action: "skip", why: "shutting-down" };
81425
+ if (s.alreadyEscalated)
81426
+ return { action: "skip", why: "already-escalated" };
81427
+ if (!s.bridgeEverRegistered && s.priorStreak >= s.maxConsecutive) {
81428
+ return { action: "skip", why: "streak-capped" };
81429
+ }
81430
+ if (!s.sessionAlive)
81431
+ return { action: "retry", why: "session-not-alive" };
81432
+ return { action: "escalate" };
81433
+ }
81434
+ function readFreshCrashLogTail(path2, opts = {}) {
81435
+ const nowMs2 = opts.nowMs ?? Date.now();
81436
+ const freshWindowMs = opts.freshWindowMs ?? CRASH_LOG_FRESH_WINDOW_MS;
81437
+ const maxLines = opts.maxLines ?? CRASH_LOG_TAIL_LINES;
81438
+ const readFile = opts.readFile ?? ((p) => readFileSync51(p, "utf8"));
81439
+ let raw;
81440
+ try {
81441
+ raw = readFile(path2);
81442
+ } catch {
81443
+ return null;
81444
+ }
81445
+ const fresh = raw.split(`
81446
+ `).filter((line) => {
81447
+ const m = line.match(/^(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)\s/);
81448
+ if (!m)
81449
+ return false;
81450
+ const t = Date.parse(m[1]);
81451
+ return Number.isFinite(t) && nowMs2 - t >= 0 && nowMs2 - t <= freshWindowMs;
81452
+ }).slice(-maxLines);
81453
+ if (fresh.length === 0)
81454
+ return null;
81455
+ return fresh.map((l) => l.slice(0, 500)).join(" || ");
81456
+ }
81457
+ function writeBridgeDeadEscalationMarker(path2, marker) {
81458
+ const tmp = `${path2}.tmp-${process.pid}-${Date.now()}`;
81459
+ writeFileSync40(tmp, JSON.stringify(marker), "utf8");
81460
+ renameSync16(tmp, path2);
81461
+ }
81462
+ function consumeBridgeDeadEscalationMarker(path2, nowMs2 = Date.now(), maxAgeMs = ESCALATION_MARKER_MAX_AGE_MS) {
81463
+ let marker = null;
81464
+ try {
81465
+ const parsed = JSON.parse(readFileSync51(path2, "utf8"));
81466
+ if (typeof parsed.ts === "number" && Number.isFinite(parsed.ts) && typeof parsed.reason === "string") {
81467
+ const age = nowMs2 - parsed.ts;
81468
+ if (age >= 0 && age < maxAgeMs) {
81469
+ marker = { ts: parsed.ts, reason: parsed.reason };
81470
+ marker.count = typeof parsed.count === "number" && Number.isFinite(parsed.count) && parsed.count >= 1 ? Math.floor(parsed.count) : 1;
81471
+ if (typeof parsed.crashTail === "string")
81472
+ marker.crashTail = parsed.crashTail;
81473
+ }
81474
+ }
81475
+ } catch {}
81476
+ try {
81477
+ unlinkSync19(path2);
81478
+ } catch {}
81479
+ return marker;
81480
+ }
81481
+ function buildBridgeDeadIdleNoticeInbound(args) {
81482
+ const ts = args.nowMs ?? Date.now();
81483
+ return {
81484
+ type: "inbound",
81485
+ chatId: args.chatId,
81486
+ messageId: ts,
81487
+ user: "switchroom",
81488
+ userId: 0,
81489
+ ts,
81490
+ text: `You just restarted. The framework itself triggered this restart: your Telegram ` + `MCP bridge process had died (chat tools were unavailable \u2014 you could not send ` + `replies), so the container was bounced to restore the chat surface. No work was ` + `in flight when it happened. Briefly let the user know the messaging bridge died ` + `and the framework restarted you to fix it \u2014 one short message, no drama. This was ` + `NOT an operator-initiated restart and NOT a hang-watchdog kill; report only that ` + `honest cause.`,
81491
+ meta: {
81492
+ source: "bridge_dead_restart",
81493
+ chat_id: args.chatId,
81494
+ message_id: String(ts),
81495
+ restart_cause: args.marker.reason
81496
+ }
81497
+ };
81498
+ }
81499
+ function isRealBridgeIdentity(agentName3) {
81500
+ return agentName3 != null && agentName3.length > 0 && !isCronIdentity2(agentName3);
81501
+ }
81502
+ function createBridgeDeadWatchdog(opts) {
81503
+ const setTimer = opts.setTimer ?? ((fn, ms) => {
81504
+ const t = setTimeout(fn, ms);
81505
+ t.unref?.();
81506
+ return t;
81507
+ });
81508
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
81509
+ const nowMs2 = opts.nowMs ?? (() => Date.now());
81510
+ const writeMarker = opts.writeMarker ?? writeBridgeDeadEscalationMarker;
81511
+ const readCrashTail = opts.readCrashTail ?? ((p, t) => readFreshCrashLogTail(p, { nowMs: t }));
81512
+ const priorStreak = opts.priorStreak ?? 0;
81513
+ const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS;
81514
+ let timer3 = null;
81515
+ let bridgeRegistered = false;
81516
+ let bridgeEverRegistered = false;
81517
+ let escalated = false;
81518
+ const cancel = () => {
81519
+ if (timer3 != null) {
81520
+ clearTimer(timer3);
81521
+ timer3 = null;
81522
+ }
81523
+ };
81524
+ const effectiveGraceMs = () => !bridgeEverRegistered && priorStreak >= 1 ? opts.graceMs * 2 : opts.graceMs;
81525
+ const armInternal = () => {
81526
+ if (escalated)
81527
+ return;
81528
+ if (!bridgeEverRegistered && priorStreak >= maxConsecutive) {
81529
+ opts.log(`telegram gateway: [bridge-dead-watchdog] STANDING DOWN \u2014 ${priorStreak} consecutive ` + `bridge-dead escalations already restarted this container without the bridge coming ` + `back (cap=${maxConsecutive}). The bridge is failing deterministically; a further ` + `restart will not fix it. Investigate the bridge (plugin bundle, .mcp.json, ` + `STATE_DIR/bridge-crash.log). Manual recovery: fix the cause, then restart the ` + `container. SWITCHROOM_BRIDGE_DEAD_ESCALATION=0 disables this watchdog entirely.`);
81530
+ return;
81531
+ }
81532
+ cancel();
81533
+ timer3 = setTimer(() => {
81534
+ timer3 = null;
81535
+ check();
81536
+ }, effectiveGraceMs());
81537
+ };
81538
+ const check = () => {
81539
+ const decision = decideBridgeDeadEscalation({
81540
+ bridgeRegistered,
81541
+ bridgeEverRegistered,
81542
+ sessionAlive: opts.isSessionAlive(),
81543
+ shuttingDown: opts.isShuttingDown(),
81544
+ alreadyEscalated: escalated,
81545
+ priorStreak,
81546
+ maxConsecutive
81547
+ });
81548
+ if (decision.action === "retry") {
81549
+ opts.log(`telegram gateway: [bridge-dead-watchdog] no bridge registered after ${effectiveGraceMs()}ms ` + `but claude session not found \u2014 re-arming (no escalation)`);
81550
+ armInternal();
81551
+ return decision;
81552
+ }
81553
+ if (decision.action === "skip") {
81554
+ if (decision.why !== "bridge-registered") {
81555
+ opts.log(`telegram gateway: [bridge-dead-watchdog] bridge missing but skipping escalation (${decision.why})`);
81556
+ }
81557
+ return decision;
81558
+ }
81559
+ escalated = true;
81560
+ const t = nowMs2();
81561
+ const crashTail = (() => {
81562
+ try {
81563
+ return readCrashTail(opts.crashLogPath, t);
81564
+ } catch {
81565
+ return null;
81566
+ }
81567
+ })();
81568
+ const streakCount = bridgeEverRegistered ? 1 : priorStreak + 1;
81569
+ try {
81570
+ const marker = {
81571
+ ts: t,
81572
+ reason: BRIDGE_DEAD_RESTART_REASON,
81573
+ count: streakCount
81574
+ };
81575
+ if (crashTail != null)
81576
+ marker.crashTail = crashTail;
81577
+ writeMarker(opts.markerPath, marker);
81578
+ } catch (err) {
81579
+ opts.log(`telegram gateway: [bridge-dead-watchdog] escalation marker write failed: ${err.message}`);
81580
+ }
81581
+ opts.log(`telegram gateway: [bridge-dead-watchdog] ESCALATING reason=${BRIDGE_DEAD_RESTART_REASON} ` + `consecutive=${streakCount}/${maxConsecutive} \u2014 ` + `no MCP bridge registered within ${effectiveGraceMs()}ms grace window while the claude session is alive ` + `(Claude Code never respawns a dead MCP server; bouncing the container to restore the chat surface). ` + (crashTail != null ? `bridge-crash.log tail: ${crashTail}` : `no fresh bridge-crash.log entries`));
81582
+ const fired = opts.escalate(BRIDGE_DEAD_RESTART_REASON);
81583
+ if (!fired) {
81584
+ opts.log(`telegram gateway: [bridge-dead-watchdog] escalate() reported failure \u2014 ` + `restart not dispatched (fuse stays blown; no retry this boot)`);
81585
+ }
81586
+ return decision;
81587
+ };
81588
+ return {
81589
+ arm: armInternal,
81590
+ noteBridgeRegistered: (agentName3) => {
81591
+ if (!isRealBridgeIdentity(agentName3))
81592
+ return;
81593
+ bridgeRegistered = true;
81594
+ bridgeEverRegistered = true;
81595
+ cancel();
81596
+ },
81597
+ noteBridgeDisconnected: (agentName3) => {
81598
+ if (!isRealBridgeIdentity(agentName3))
81599
+ return;
81600
+ bridgeRegistered = false;
81601
+ armInternal();
81602
+ },
81603
+ check,
81604
+ stop: cancel,
81605
+ hasEscalated: () => escalated
81606
+ };
81607
+ }
81608
+
81609
+ // gateway/boot-probes.ts
81610
+ import { readFileSync as readFileSync52, readdirSync as readdirSync10, existsSync as existsSync48 } from "fs";
81611
+ init_quota_cache();
81612
+ init_quota_check();
81613
+ import { execFile as execFileCb2 } from "child_process";
81614
+ import { promisify as promisify2 } from "util";
81615
+ var execFile2 = promisify2(execFileCb2);
81616
+ var realProcFs2 = {
81617
+ readdir: (p) => readdirSync10(p),
81618
+ readFile: (p) => readFileSync52(p, "utf-8")
81619
+ };
81620
+ function findAgentProcessInContainer2(fs3 = realProcFs2) {
81621
+ let entries;
81622
+ try {
81623
+ entries = fs3.readdir("/proc");
81624
+ } catch {
81625
+ return null;
81626
+ }
81627
+ const candidates = [];
81628
+ for (const entry of entries) {
81629
+ if (!/^\d+$/.test(entry))
81630
+ continue;
81631
+ const pid = Number(entry);
81632
+ if (!Number.isFinite(pid) || pid <= 0)
81633
+ continue;
81634
+ if (pid === process.pid)
81635
+ continue;
81636
+ let comm = "";
81637
+ try {
81638
+ comm = fs3.readFile(`/proc/${pid}/comm`).trim();
81639
+ } catch {
81640
+ continue;
81641
+ }
81642
+ let rssKb = 0;
81643
+ try {
81644
+ const status = fs3.readFile(`/proc/${pid}/status`);
81645
+ const m = status.match(/^VmRSS:\s+(\d+)/m);
81646
+ if (m)
81647
+ rssKb = parseInt(m[1], 10) || 0;
81648
+ } catch {
81649
+ continue;
81650
+ }
81651
+ let starttime = 0;
81652
+ try {
81653
+ const stat = fs3.readFile(`/proc/${pid}/stat`);
81654
+ const close = stat.lastIndexOf(")");
81655
+ const tail = close >= 0 ? stat.slice(close + 2) : stat;
81656
+ const fields = tail.trim().split(/\s+/);
81657
+ const st = Number(fields[19]);
81658
+ if (Number.isFinite(st) && st > 0)
81659
+ starttime = st;
81660
+ } catch {
81661
+ continue;
81662
+ }
81663
+ candidates.push({ pid, rssKb, comm, starttime });
81664
+ }
81665
+ if (candidates.length === 0)
81666
+ return null;
81667
+ const isAgent = (c) => c.comm === "claude";
81668
+ const isWrapper = (c) => c.comm === "tmux" || c.comm.startsWith("tmux:") || c.comm === "expect" || c.comm === "script" || c.comm === "bash" || c.comm === "sh" || c.comm === "tini" || c.comm === "sleep";
81669
+ const claudeMatches = candidates.filter(isAgent);
81670
+ if (claudeMatches.length > 0) {
81671
+ claudeMatches.sort((a, b) => b.rssKb - a.rssKb);
81672
+ return claudeMatches[0];
81673
+ }
81674
+ const nodeMatches = candidates.filter((c) => c.comm === "node" && !isWrapper(c)).sort((a, b) => b.rssKb - a.rssKb);
81675
+ if (nodeMatches.length > 0)
81676
+ return nodeMatches[0];
81677
+ return null;
81678
+ }
81679
+
80748
81680
  // registry/subagents-schema.ts
80749
81681
  var SUBAGENTS_SCHEMA_SQL = `
80750
81682
  CREATE TABLE IF NOT EXISTS subagents (
@@ -80900,7 +81832,7 @@ function alwaysAllowDrainDeps() {
80900
81832
  return {
80901
81833
  readConfigText: () => {
80902
81834
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
80903
- return readFileSync51(cfgPath, "utf8");
81835
+ return readFileSync53(cfgPath, "utf8");
80904
81836
  },
80905
81837
  resolveAllowList: (_configText, agentName3) => {
80906
81838
  const cfg = loadConfig2();
@@ -81011,6 +81943,13 @@ function triggerSelfRestart(targetAgent, reason, delayMs = 300) {
81011
81943
  return false;
81012
81944
  }
81013
81945
  }
81946
+ {
81947
+ const bootSmDir = resolveAgentDirFromEnv();
81948
+ if (bootSmDir != null && clearStaleGatewayShutdownIntent(bootSmDir)) {
81949
+ process.stderr.write(`telegram gateway: cleared stale gateway-shutdown relaunch-model intent (previous bounce was gateway-only \u2014 container never restarted)
81950
+ `);
81951
+ }
81952
+ }
81014
81953
  var cachedClaudeCliVersion = undefined;
81015
81954
  function getClaudeCliVersion() {
81016
81955
  if (cachedClaudeCliVersion !== undefined)
@@ -81040,7 +81979,7 @@ function formatBootVersion() {
81040
81979
  }
81041
81980
  try {
81042
81981
  chmodSync8(ENV_FILE, 384);
81043
- for (const line of readFileSync51(ENV_FILE, "utf8").split(`
81982
+ for (const line of readFileSync53(ENV_FILE, "utf8").split(`
81044
81983
  `)) {
81045
81984
  const m = line.match(/^(\w+)=(.*)$/);
81046
81985
  if (m && process.env[m[1]] === undefined)
@@ -81105,7 +82044,7 @@ bot.api.config.use(async (prev, method, payload, signal) => {
81105
82044
  });
81106
82045
  var GRAMMY_VERSION = (() => {
81107
82046
  try {
81108
- const raw = readFileSync51(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
82047
+ const raw = readFileSync53(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
81109
82048
  return JSON.parse(raw).version ?? "unknown";
81110
82049
  } catch {
81111
82050
  return "unknown";
@@ -81191,7 +82130,7 @@ function assertSendable(f) {
81191
82130
  }
81192
82131
  function readAccessFile() {
81193
82132
  try {
81194
- const raw = readFileSync51(ACCESS_FILE, "utf8");
82133
+ const raw = readFileSync53(ACCESS_FILE, "utf8");
81195
82134
  const parsed = JSON.parse(raw);
81196
82135
  const allowFrom = validateStringArray("allowFrom", parsed.allowFrom ?? []);
81197
82136
  const groups = {};
@@ -81229,7 +82168,7 @@ function readAccessFile() {
81229
82168
  if (err.code === "ENOENT")
81230
82169
  return defaultAccess();
81231
82170
  try {
81232
- renameSync16(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
82171
+ renameSync17(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
81233
82172
  } catch {}
81234
82173
  process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.
81235
82174
  `);
@@ -81251,7 +82190,7 @@ function loadAccess() {
81251
82190
  }
81252
82191
  function readPeopleFile() {
81253
82192
  try {
81254
- const raw = readFileSync51(PEOPLE_FILE, "utf8");
82193
+ const raw = readFileSync53(PEOPLE_FILE, "utf8");
81255
82194
  const parsed = JSON.parse(raw);
81256
82195
  if (!Array.isArray(parsed.entries))
81257
82196
  return [];
@@ -81275,9 +82214,9 @@ function saveAccess(a) {
81275
82214
  return;
81276
82215
  mkdirSync39(STATE_DIR, { recursive: true, mode: 448 });
81277
82216
  const tmp = ACCESS_FILE + ".tmp";
81278
- writeFileSync41(tmp, JSON.stringify(a, null, 2) + `
82217
+ writeFileSync42(tmp, JSON.stringify(a, null, 2) + `
81279
82218
  `, { mode: 384 });
81280
- renameSync16(tmp, ACCESS_FILE);
82219
+ renameSync17(tmp, ACCESS_FILE);
81281
82220
  }
81282
82221
  function pruneExpired(a) {
81283
82222
  const now = Date.now();
@@ -81304,6 +82243,7 @@ if (HISTORY_ENABLED) {
81304
82243
  }
81305
82244
  var turnsDb = null;
81306
82245
  var bootResumeInbound = null;
82246
+ var bridgeDeadPriorStreak = 0;
81307
82247
  try {
81308
82248
  const agentDir = STATE_DIR.endsWith("/telegram") ? STATE_DIR.slice(0, -"/telegram".length) : STATE_DIR;
81309
82249
  turnsDb = openTurnsDb(agentDir);
@@ -81312,11 +82252,11 @@ try {
81312
82252
  let markerAgeMs = null;
81313
82253
  try {
81314
82254
  const markerPath = join53(STATE_DIR, TURN_ACTIVE_MARKER_FILE2);
81315
- if (existsSync49(markerPath)) {
82255
+ if (existsSync50(markerPath)) {
81316
82256
  const st = statSync16(markerPath);
81317
82257
  markerAgeMs = Date.now() - st.mtimeMs;
81318
82258
  try {
81319
- const payload = JSON.parse(readFileSync51(markerPath, "utf8"));
82259
+ const payload = JSON.parse(readFileSync53(markerPath, "utf8"));
81320
82260
  if (typeof payload.turnKey === "string" && payload.turnKey.length > 0) {
81321
82261
  markerTurnKey = payload.turnKey;
81322
82262
  }
@@ -81339,6 +82279,16 @@ try {
81339
82279
  process.stderr.write(`telegram gateway: turn-registry initialized at ${join53(agentDir, "telegram", "registry.db")}
81340
82280
  `);
81341
82281
  }
82282
+ const bridgeDeadMarker = consumeBridgeDeadEscalationMarker(join53(STATE_DIR, "bridge-dead-escalation.json"));
82283
+ if (bridgeDeadMarker != null) {
82284
+ bridgeDeadPriorStreak = bridgeDeadMarker.count ?? 1;
82285
+ process.stderr.write(`telegram gateway: boot: prior restart was a bridge-dead escalation (reason=${bridgeDeadMarker.reason}, consecutive=${bridgeDeadPriorStreak}${bridgeDeadMarker.crashTail ? `, crashTail=${bridgeDeadMarker.crashTail}` : ""})
82286
+ `);
82287
+ }
82288
+ const bridgeDeadRestartCause = bridgeDeadMarker != null ? {
82289
+ reason: bridgeDeadMarker.reason,
82290
+ note: "The framework itself triggered this restart: your Telegram MCP bridge process had died " + "(chat tools were unavailable \u2014 you could not send replies), so the container was bounced " + "to restore the chat surface. This was NOT an operator-initiated restart and NOT a hang-watchdog kill."
82291
+ } : undefined;
81342
82292
  const pending2 = findLatestTurnIfInterrupted(turnsDb);
81343
82293
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
81344
82294
  if (pending2 != null && selfAgent) {
@@ -81370,7 +82320,11 @@ try {
81370
82320
  if (bootResumeKind === "resume") {
81371
82321
  bootResumeInbound = {
81372
82322
  agent: selfAgent,
81373
- msg: buildResumeInterruptedInbound({ turn: pending2, subagents: interruptedSubagents })
82323
+ msg: buildResumeInterruptedInbound({
82324
+ turn: pending2,
82325
+ subagents: interruptedSubagents,
82326
+ restartCause: bridgeDeadRestartCause
82327
+ })
81374
82328
  };
81375
82329
  } else if (bootResumeKind === "report") {
81376
82330
  let idleMs = pending2.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null;
@@ -81385,7 +82339,12 @@ try {
81385
82339
  idleMs = Math.max(0, Date.now() - pending2.started_at);
81386
82340
  bootResumeInbound = {
81387
82341
  agent: selfAgent,
81388
- msg: buildResumeWatchdogReportInbound({ turn: pending2, idleMs, subagents: interruptedSubagents })
82342
+ msg: buildResumeWatchdogReportInbound({
82343
+ turn: pending2,
82344
+ idleMs,
82345
+ subagents: interruptedSubagents,
82346
+ restartCause: bridgeDeadRestartCause
82347
+ })
81389
82348
  };
81390
82349
  } else if (bootResumeKind === "defer-loop" || bootResumeKind === "defer-suppressed") {
81391
82350
  bootResumeInbound = {
@@ -81393,7 +82352,8 @@ try {
81393
82352
  msg: buildResumeDeferredReportInbound({
81394
82353
  turn: pending2,
81395
82354
  reason: bootResumeKind === "defer-loop" ? "loop-guard" : "clean-restart-suppressed",
81396
- subagents: interruptedSubagents
82355
+ subagents: interruptedSubagents,
82356
+ restartCause: bridgeDeadRestartCause
81397
82357
  })
81398
82358
  };
81399
82359
  }
@@ -81406,6 +82366,29 @@ try {
81406
82366
  }
81407
82367
  if (bootResumeInbound != null) {
81408
82368
  process.stderr.write(`telegram gateway: boot-resume queued kind=${bootResumeKind} mode=${bootResumeMode} turnKey=${pending2.turn_key} endedVia=${pending2.ended_via ?? "open"} chat=${pending2.chat_id}
82369
+ `);
82370
+ }
82371
+ }
82372
+ if (bridgeDeadMarker != null && bootResumeInbound == null && selfAgent) {
82373
+ const idleNoticeChat = (() => {
82374
+ try {
82375
+ return loadAccess().allowFrom[0] ?? null;
82376
+ } catch {
82377
+ return null;
82378
+ }
82379
+ })();
82380
+ if (idleNoticeChat != null) {
82381
+ bootResumeInbound = {
82382
+ agent: selfAgent,
82383
+ msg: buildBridgeDeadIdleNoticeInbound({
82384
+ chatId: String(idleNoticeChat),
82385
+ marker: bridgeDeadMarker
82386
+ })
82387
+ };
82388
+ process.stderr.write(`telegram gateway: boot: bridge-dead idle notice queued chat=${idleNoticeChat} (no turn was in flight)
82389
+ `);
82390
+ } else {
82391
+ process.stderr.write(`telegram gateway: boot: bridge-dead idle notice skipped \u2014 no allowFrom chat to surface it in
81409
82392
  `);
81410
82393
  }
81411
82394
  }
@@ -81423,13 +82406,13 @@ try {
81423
82406
  pending2.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending2.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`
81424
82407
  ];
81425
82408
  const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`;
81426
- writeFileSync41(pendingEnvTmp, lines.join(`
82409
+ writeFileSync42(pendingEnvTmp, lines.join(`
81427
82410
  `) + `
81428
82411
  `, { mode: 384 });
81429
- renameSync16(pendingEnvTmp, pendingEnvPath);
82412
+ renameSync17(pendingEnvTmp, pendingEnvPath);
81430
82413
  process.stderr.write(`telegram gateway: pending-turn env written to ${pendingEnvPath} turnKey=${pending2.turn_key} endedVia=${pending2.ended_via ?? "open"}
81431
82414
  `);
81432
- } else if (existsSync49(pendingEnvPath)) {
82415
+ } else if (existsSync50(pendingEnvPath)) {
81433
82416
  rmSync5(pendingEnvPath, { force: true });
81434
82417
  process.stderr.write(`telegram gateway: pending-turn env cleared (clean previous shutdown)
81435
82418
  `);
@@ -81520,7 +82503,7 @@ if (!STATIC) {
81520
82503
  function checkApprovals() {
81521
82504
  let files;
81522
82505
  try {
81523
- files = readdirSync11(APPROVED_DIR);
82506
+ files = readdirSync12(APPROVED_DIR);
81524
82507
  } catch {
81525
82508
  return;
81526
82509
  }
@@ -81631,10 +82614,10 @@ function noteAgentOutputAt(key, ts) {
81631
82614
  }
81632
82615
  var OBLIGATION_STORE_PATH = join53(STATE_DIR, "obligations.json");
81633
82616
  var obligationStoreFs = {
81634
- readFileSync: (p) => readFileSync51(p, "utf8"),
81635
- writeFileSync: (p, d) => writeFileSync41(p, d),
81636
- renameSync: (a, b) => renameSync16(a, b),
81637
- existsSync: (p) => existsSync49(p)
82617
+ readFileSync: (p) => readFileSync53(p, "utf8"),
82618
+ writeFileSync: (p, d) => writeFileSync42(p, d),
82619
+ renameSync: (a, b) => renameSync17(a, b),
82620
+ existsSync: (p) => existsSync50(p)
81638
82621
  };
81639
82622
  var obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX, {
81640
82623
  onChange: STATIC || !OBLIGATION_LEDGER_ENABLED ? undefined : (snapshot) => persistObligations(OBLIGATION_STORE_PATH, obligationStoreFs, snapshot)
@@ -81741,6 +82724,8 @@ async function deliverButtonTapInbound(agent, inbound) {
81741
82724
  return "buffered-bridge-offline";
81742
82725
  }
81743
82726
  var pendingRestarts = new Map;
82727
+ var pendingSessionCommand = createPendingSessionCommandSlots();
82728
+ var PENDING_CMD_DRAIN_CAP_MS = 60000;
81744
82729
  var lastSessionActiveFile = null;
81745
82730
  var compactState = initialCompactState();
81746
82731
  var compactDispatching = false;
@@ -82072,6 +83057,119 @@ async function fireDeferredInterrupt(reason) {
82072
83057
  `);
82073
83058
  }
82074
83059
  }
83060
+ var haltBoundaryWaiters = new Set;
83061
+ function notifyHaltBoundaryWaiters() {
83062
+ if (haltBoundaryWaiters.size === 0)
83063
+ return;
83064
+ for (const check of [...haltBoundaryWaiters])
83065
+ check();
83066
+ }
83067
+ function waitForSafeBoundary(maxWaitMs) {
83068
+ if (!toolFlightTracker.isMidToolCall())
83069
+ return Promise.resolve("boundary");
83070
+ return new Promise((resolve11) => {
83071
+ let done = false;
83072
+ const finish = (reason) => {
83073
+ if (done)
83074
+ return;
83075
+ done = true;
83076
+ clearTimeout(timer3);
83077
+ haltBoundaryWaiters.delete(check);
83078
+ resolve11(reason);
83079
+ };
83080
+ const timer3 = setTimeout(() => finish("timeout"), maxWaitMs);
83081
+ const check = () => {
83082
+ if (!toolFlightTracker.isMidToolCall())
83083
+ finish("boundary");
83084
+ };
83085
+ haltBoundaryWaiters.add(check);
83086
+ });
83087
+ }
83088
+ function cancelPendingPermissionsForHalt(origin) {
83089
+ if (pendingPermissions.size === 0)
83090
+ return;
83091
+ for (const [requestId, details] of pendingPermissions) {
83092
+ dispatchPermissionVerdict({
83093
+ type: "permission",
83094
+ requestId,
83095
+ behavior: "deny",
83096
+ message: "Cancelled \u2014 the operator stopped this turn. Do not retry."
83097
+ });
83098
+ stripCancelledPermissionCards(details.card_text, details.cards);
83099
+ pendingPermissions.delete(requestId);
83100
+ permCardStore.remove(requestId);
83101
+ process.stderr.write(`telegram gateway: halt-now cancelled pending permission origin=${origin} request=${requestId} tool=${details.tool_name}
83102
+ `);
83103
+ }
83104
+ }
83105
+ async function stripCancelledPermissionCards(cardText, cards) {
83106
+ for (const edit of buildCancelledCardEdits(cardText, cards)) {
83107
+ await swallowingApiCall(() => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage2(edit.text), {}), { chat_id: edit.chatId, verb: "permission_halt.strip" });
83108
+ }
83109
+ }
83110
+ async function executeHaltNow(origin) {
83111
+ const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
83112
+ if (!agentName3)
83113
+ return;
83114
+ const haltTarget = currentTurn;
83115
+ const haltTargetKey = haltTarget?.registryKey ?? null;
83116
+ const access = loadAccess();
83117
+ const timing = decideInterruptTiming({
83118
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
83119
+ midToolCall: toolFlightTracker.isMidToolCall()
83120
+ });
83121
+ const startedAt = Date.now();
83122
+ if (timing === "defer") {
83123
+ const maxWaitMs = resolveInterruptMaxWaitMs(access.interruptMaxWaitMs);
83124
+ const reason = await waitForSafeBoundary(maxWaitMs);
83125
+ process.stderr.write(`telegram gateway: halt-now boundary-wait origin=${origin} reason=${reason} waited_ms=${Date.now() - startedAt} in_flight=${toolFlightTracker.inFlightCount()}
83126
+ `);
83127
+ }
83128
+ if (currentTurn !== haltTarget) {
83129
+ process.stderr.write(`telegram gateway: halt-now skipped \u2014 turn changed during boundary wait ` + `origin=${origin} target_key=${haltTargetKey ?? "none"} current_key=${currentTurn?.registryKey ?? "none"}
83130
+ `);
83131
+ return;
83132
+ }
83133
+ try {
83134
+ const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
83135
+ const r = sendAgentInterrupt2({ agentName: agentName3 });
83136
+ if ("ok" in r) {
83137
+ process.stderr.write(`telegram gateway: halt-now SIGINT delivered via tmux send-keys origin=${origin} agent=${agentName3}
83138
+ `);
83139
+ } else {
83140
+ process.stderr.write(`telegram gateway: halt-now SIGINT via tmux failed origin=${origin} agent=${agentName3}: ${r.error}
83141
+ `);
83142
+ }
83143
+ } catch (err) {
83144
+ process.stderr.write(`telegram gateway: halt-now SIGINT failed origin=${origin}: ${err.message}
83145
+ `);
83146
+ }
83147
+ cancelInterruptedObligation();
83148
+ cancelPendingPermissionsForHalt(origin);
83149
+ const turn = currentTurn;
83150
+ if (turn != null) {
83151
+ if (turnsDb != null && turn.registryKey != null) {
83152
+ const _turnKey = turn.registryKey;
83153
+ try {
83154
+ recordTurnEnd(turnsDb, {
83155
+ turnKey: _turnKey,
83156
+ endedVia: "stop",
83157
+ lastAssistantMsgId: turn.lastAssistantMsgId,
83158
+ lastAssistantDone: turn.lastAssistantDone,
83159
+ toolCallCount: turn.toolCallCount
83160
+ });
83161
+ } catch (err) {
83162
+ process.stderr.write(`telegram gateway: recordTurnEnd(halt) failed turnKey=${_turnKey}: ${err.message}
83163
+ `);
83164
+ }
83165
+ }
83166
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId);
83167
+ endCurrentTurnAtomic(turn);
83168
+ releaseTurnBufferGate(key, turn);
83169
+ }
83170
+ if (!turnInFlightForGate())
83171
+ drainPendingSessionCommand();
83172
+ }
82075
83173
  var preambleSuppressor = new PreambleSuppressor({
82076
83174
  emitAnswer: (cumulative) => {
82077
83175
  const stream = currentTurn?.answerStream ?? null;
@@ -82194,6 +83292,7 @@ function purgeReactionTracking(key, endingTurn) {
82194
83292
  }
82195
83293
  drainBufferedIfAllowed(endingTurn, "turn-complete");
82196
83294
  if (!turnInFlightForGate()) {
83295
+ drainPendingSessionCommand();
82197
83296
  if (pendingRestarts.size > 0) {
82198
83297
  for (const [agentName3, _timestamp] of pendingRestarts.entries()) {
82199
83298
  triggerSelfRestart(agentName3, "turn-complete-pending-restart");
@@ -82258,7 +83357,7 @@ function emitTurnRecord(turn, endedAt) {
82258
83357
  return;
82259
83358
  }
82260
83359
  },
82261
- rename: (from, to) => renameSync16(from, to)
83360
+ rename: (from, to) => renameSync17(from, to)
82262
83361
  });
82263
83362
  appendFileSync6(turnsPath, rec);
82264
83363
  } catch {}
@@ -83357,6 +84456,17 @@ var pendingStateReaper = setInterval(() => {
83357
84456
  triggerSelfRestart(agentName3, "restart-drain-cap-forced", 100);
83358
84457
  }
83359
84458
  }
84459
+ const queuedCmds = pendingSessionCommand.list();
84460
+ const cmdDecision = drainCapDecision(queuedCmds, now, PENDING_CMD_DRAIN_CAP_MS, turnInFlightForGate());
84461
+ if (cmdDecision === "defer-turn-in-flight") {
84462
+ process.stderr.write(`telegram gateway: [pending-cmd-drain] deferred reason=turn-in-flight queued=${queuedCmds.map((c) => c.kind).join(",")}
84463
+ `);
84464
+ } else if (cmdDecision === "force") {
84465
+ const oldest = Math.min(...queuedCmds.map((c) => c.requestedAt));
84466
+ process.stderr.write(`telegram gateway: [pending-cmd-drain] forcing queued=${queuedCmds.map((c) => `${c.kind}:${c.targetLabel}`).join(",")} waited=${Math.round((now - oldest) / 1000)}s threshold=${Math.round(PENDING_CMD_DRAIN_CAP_MS / 1000)}s
84467
+ `);
84468
+ drainPendingSessionCommand();
84469
+ }
83360
84470
  }, 60000);
83361
84471
  pendingStateReaper.unref();
83362
84472
  function looksLikeAuthCode(text5) {
@@ -83410,6 +84520,7 @@ function emitGatewayOperatorEvent(event) {
83410
84520
  const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event);
83411
84521
  let renderedText;
83412
84522
  let renderedKeyboard;
84523
+ let cardPromisedFallback = false;
83413
84524
  if (modelUnavailable) {
83414
84525
  const isAutoKind = modelUnavailable.kind === "quota_exhausted";
83415
84526
  const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback();
@@ -83419,6 +84530,7 @@ function emitGatewayOperatorEvent(event) {
83419
84530
  autoFallbackInFlight: willActuallyFire
83420
84531
  });
83421
84532
  renderedKeyboard = undefined;
84533
+ cardPromisedFallback = willActuallyFire;
83422
84534
  if (willActuallyFire) {
83423
84535
  const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime());
83424
84536
  fireFleetAutoFallback(agent, untilMs);
@@ -83450,7 +84562,16 @@ function emitGatewayOperatorEvent(event) {
83450
84562
  ...renderedKeyboard ? { reply_markup: renderedKeyboard } : {},
83451
84563
  ...opEventThread != null ? { message_thread_id: opEventThread } : {}
83452
84564
  };
83453
- bot.api.sendRichMessage(chat_id, richMessage2(renderedText), opts).catch((e) => {
84565
+ bot.api.sendRichMessage(chat_id, richMessage2(renderedText), opts).then((sent) => {
84566
+ if (cardPromisedFallback) {
84567
+ modelUnavailableCardRegistry.record(String(chat_id), {
84568
+ messageId: sent.message_id,
84569
+ text: renderedText,
84570
+ atMs: Date.now(),
84571
+ promisedFallback: true
84572
+ });
84573
+ }
84574
+ }).catch((e) => {
83454
84575
  process.stderr.write(`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}
83455
84576
  `);
83456
84577
  });
@@ -83512,18 +84633,18 @@ var statusPinChatIds = new Map;
83512
84633
  var statusPinPinnedAt = new Map;
83513
84634
  var STATUS_PIN_STORE_PATH = join53(STATE_DIR, "status-pins.json");
83514
84635
  var statusPinStoreFs = {
83515
- readFileSync: (p) => readFileSync51(p, "utf8"),
83516
- writeFileSync: (p, d) => writeFileSync41(p, d),
83517
- renameSync: (a, b) => renameSync16(a, b),
83518
- existsSync: (p) => existsSync49(p)
84636
+ readFileSync: (p) => readFileSync53(p, "utf8"),
84637
+ writeFileSync: (p, d) => writeFileSync42(p, d),
84638
+ renameSync: (a, b) => renameSync17(a, b),
84639
+ existsSync: (p) => existsSync50(p)
83519
84640
  };
83520
84641
  var statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING;
83521
84642
  var ACTIVITY_CARD_STORE_PATH = join53(STATE_DIR, "activity-cards-pending.json");
83522
84643
  var activityCardStoreFs = {
83523
- readFileSync: (p) => readFileSync51(p, "utf8"),
83524
- writeFileSync: (p, d) => writeFileSync41(p, d),
83525
- renameSync: (a, b) => renameSync16(a, b),
83526
- existsSync: (p) => existsSync49(p)
84644
+ readFileSync: (p) => readFileSync53(p, "utf8"),
84645
+ writeFileSync: (p, d) => writeFileSync42(p, d),
84646
+ renameSync: (a, b) => renameSync17(a, b),
84647
+ existsSync: (p) => existsSync50(p)
83527
84648
  };
83528
84649
  var activityCardPersistEnabled = !STATIC;
83529
84650
  var bannerPinPersistEnabled = !STATIC;
@@ -84167,10 +85288,10 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
84167
85288
  path: join53(STATE_DIR, "inbound-spool.jsonl"),
84168
85289
  fs: {
84169
85290
  appendFileSync: (p, d) => appendFileSync6(p, d),
84170
- readFileSync: (p) => readFileSync51(p, "utf8"),
84171
- writeFileSync: (p, d) => writeFileSync41(p, d),
84172
- renameSync: (a, b) => renameSync16(a, b),
84173
- existsSync: (p) => existsSync49(p),
85291
+ readFileSync: (p) => readFileSync53(p, "utf8"),
85292
+ writeFileSync: (p, d) => writeFileSync42(p, d),
85293
+ renameSync: (a, b) => renameSync17(a, b),
85294
+ existsSync: (p) => existsSync50(p),
84174
85295
  statSizeSync: (p) => statSync16(p).size
84175
85296
  },
84176
85297
  onDegraded: (info) => {
@@ -84351,6 +85472,27 @@ async function stripStalePermissionCard(card) {
84351
85472
  `);
84352
85473
  }
84353
85474
  }
85475
+ var BRIDGE_DEAD_ESCALATION_ENABLED = process.env.SWITCHROOM_BRIDGE_DEAD_ESCALATION !== "0";
85476
+ var BRIDGE_DEAD_GRACE_MS = (() => {
85477
+ const v = Number(process.env.SWITCHROOM_BRIDGE_DEAD_GRACE_MS);
85478
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_BRIDGE_DEAD_GRACE_MS;
85479
+ })();
85480
+ var bridgeDeadWatchdog = createBridgeDeadWatchdog({
85481
+ graceMs: BRIDGE_DEAD_GRACE_MS,
85482
+ isSessionAlive: () => findAgentProcessInContainer2()?.comm === "claude",
85483
+ isShuttingDown: () => shuttingDown,
85484
+ escalate: (reason) => triggerSelfRestart(process.env.SWITCHROOM_AGENT_NAME ?? "", reason, 1500),
85485
+ crashLogPath: join53(STATE_DIR, "bridge-crash.log"),
85486
+ markerPath: join53(STATE_DIR, "bridge-dead-escalation.json"),
85487
+ log: (line) => process.stderr.write(`${line}
85488
+ `),
85489
+ priorStreak: bridgeDeadPriorStreak
85490
+ });
85491
+ if (BRIDGE_DEAD_ESCALATION_ENABLED) {
85492
+ bridgeDeadWatchdog.arm();
85493
+ process.stderr.write(`telegram gateway: [bridge-dead-watchdog] armed (grace=${BRIDGE_DEAD_GRACE_MS}ms)
85494
+ `);
85495
+ }
84354
85496
  var ipcServer = createIpcServer({
84355
85497
  socketPath: SOCKET_PATH,
84356
85498
  onClientRegistered(client3) {
@@ -84367,6 +85509,7 @@ var ipcServer = createIpcServer({
84367
85509
  return;
84368
85510
  }
84369
85511
  const bridgeUpEffects = client3.agentName != null ? shadowEmit({ kind: "bridgeUp", at: Date.now() }) : [];
85512
+ bridgeDeadWatchdog.noteBridgeRegistered(client3.agentName);
84370
85513
  client3.send({ type: "status", status: "agent_connected" });
84371
85514
  if (client3.agentName != null) {
84372
85515
  if (isDispatchEnabled()) {
@@ -84498,6 +85641,8 @@ var ipcServer = createIpcServer({
84498
85641
  process.stderr.write(`telegram gateway: bridge disconnected \u2014 agent=${client3.agentName}
84499
85642
  `);
84500
85643
  shadowEmit({ kind: "bridgeDown", at: Date.now() });
85644
+ if (BRIDGE_DEAD_ESCALATION_ENABLED)
85645
+ bridgeDeadWatchdog.noteBridgeDisconnected(client3.agentName);
84501
85646
  }
84502
85647
  flushOnAgentDisconnect({
84503
85648
  agentName: client3.agentName,
@@ -84552,6 +85697,7 @@ var ipcServer = createIpcServer({
84552
85697
  if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
84553
85698
  fireDeferredInterrupt("boundary");
84554
85699
  }
85700
+ notifyHaltBoundaryWaiters();
84555
85701
  if (currentTurn != null) {
84556
85702
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId);
84557
85703
  if (ev.kind === "thinking") {
@@ -85948,20 +87094,58 @@ ${url}`;
85948
87094
  turn2.silentAnchorText = effectiveText;
85949
87095
  }
85950
87096
  }
85951
- const allPhotos = files.length >= 2 && files.length <= 10 && files.every((f) => PHOTO_EXTS.has(extname(f).toLowerCase()));
87097
+ const photoPrecheck = new Map;
87098
+ const documentReroutes = [];
87099
+ const sentAsDocument = new Set;
87100
+ for (const f of files) {
87101
+ if (!PHOTO_EXTS.has(extname(f).toLowerCase()))
87102
+ continue;
87103
+ const cls = classifyPhotoFile(f);
87104
+ photoPrecheck.set(f, cls);
87105
+ if (cls.route === "document") {
87106
+ documentReroutes.push({ path: f, reason: cls.reason });
87107
+ sentAsDocument.add(f);
87108
+ process.stderr.write(`telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})
87109
+ `);
87110
+ }
87111
+ }
87112
+ const sendableAsPhoto = (f) => PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== "document";
87113
+ const allPhotos = files.length >= 2 && files.length <= 10 && files.every(sendableAsPhoto);
85952
87114
  const replyParams = reply_to != null && replyMode !== "off" ? { reply_parameters: { message_id: reply_to } } : {};
87115
+ const sendAsDocument = (f) => retryWithThreadFallback2(robustApiCall, (tid) => {
87116
+ const baseOpts = {
87117
+ ...replyParams,
87118
+ ...tid != null ? { message_thread_id: tid } : {}
87119
+ };
87120
+ return lockedBot.api.sendDocument(chat_id, new import_grammy13.InputFile(f), baseOpts);
87121
+ }, { threadId, chat_id, verb: "sendDocument" });
85953
87122
  if (allPhotos) {
85954
87123
  const media = files.map((f) => ({
85955
87124
  type: "photo",
85956
87125
  media: new import_grammy13.InputFile(f)
85957
87126
  }));
85958
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
85959
- const baseOpts = {
85960
- ...replyParams,
85961
- ...tid != null ? { message_thread_id: tid } : {}
85962
- };
85963
- return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts);
85964
- }, { threadId, chat_id, verb: "sendMediaGroup" });
87127
+ let sent;
87128
+ try {
87129
+ sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
87130
+ const baseOpts = {
87131
+ ...replyParams,
87132
+ ...tid != null ? { message_thread_id: tid } : {}
87133
+ };
87134
+ return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts);
87135
+ }, { threadId, chat_id, verb: "sendMediaGroup" });
87136
+ } catch (err) {
87137
+ if (!isPhotoDimensionRejectError(err))
87138
+ throw err;
87139
+ process.stderr.write(`telegram gateway: sendMediaGroup rejected the photo album (${err instanceof Error ? err.message : String(err)}); falling back to per-file sendDocument
87140
+ `);
87141
+ sent = [];
87142
+ const albumReason = "Telegram rejected the photo album; whole album re-sent as documents";
87143
+ for (const f of files) {
87144
+ sent.push(await sendAsDocument(f));
87145
+ sentAsDocument.add(f);
87146
+ documentReroutes.push({ path: f, reason: albumReason });
87147
+ }
87148
+ }
85965
87149
  if (threadId != null) {
85966
87150
  const first = sent[0];
85967
87151
  if (first && first.message_thread_id == null)
@@ -85971,23 +87155,33 @@ ${url}`;
85971
87155
  sentIds.push(m.message_id);
85972
87156
  } else {
85973
87157
  for (const f of files) {
85974
- const ext = extname(f).toLowerCase();
85975
87158
  const input = new import_grammy13.InputFile(f);
85976
- const isPhoto = PHOTO_EXTS.has(ext);
85977
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
85978
- const baseOpts = {
85979
- ...replyParams,
85980
- ...tid != null ? { message_thread_id: tid } : {}
85981
- };
85982
- return isPhoto ? lockedBot.api.sendPhoto(chat_id, input, baseOpts) : lockedBot.api.sendDocument(chat_id, input, baseOpts);
85983
- }, { threadId, chat_id, verb: isPhoto ? "sendPhoto" : "sendDocument" });
87159
+ const isPhoto = sendableAsPhoto(f);
87160
+ let sent;
87161
+ try {
87162
+ sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
87163
+ const baseOpts = {
87164
+ ...replyParams,
87165
+ ...tid != null ? { message_thread_id: tid } : {}
87166
+ };
87167
+ return isPhoto ? lockedBot.api.sendPhoto(chat_id, input, baseOpts) : lockedBot.api.sendDocument(chat_id, input, baseOpts);
87168
+ }, { threadId, chat_id, verb: isPhoto ? "sendPhoto" : "sendDocument" });
87169
+ } catch (err) {
87170
+ if (!(isPhoto && isPhotoDimensionRejectError(err)))
87171
+ throw err;
87172
+ process.stderr.write(`telegram gateway: sendPhoto rejected ${f} (${err instanceof Error ? err.message : String(err)}); falling back to sendDocument
87173
+ `);
87174
+ sent = await sendAsDocument(f);
87175
+ sentAsDocument.add(f);
87176
+ documentReroutes.push({ path: f, reason: "Telegram rejected it as a photo; re-sent as document" });
87177
+ }
85984
87178
  if (threadId != null && sent.message_thread_id == null) {
85985
87179
  threadId = undefined;
85986
87180
  }
85987
87181
  sentIds.push(sent.message_id);
85988
87182
  }
85989
87183
  }
85990
- const result = sentIds.length === 1 ? `sent (id: ${sentIds[0]})` : `sent ${sentIds.length} parts (ids: ${sentIds.join(", ")})`;
87184
+ const result = (sentIds.length === 1 ? `sent (id: ${sentIds[0]})` : `sent ${sentIds.length} parts (ids: ${sentIds.join(", ")})`) + rerouteResultSuffix(documentReroutes);
85991
87185
  if (HISTORY_ENABLED && sentIds.length > 0) {
85992
87186
  try {
85993
87187
  const fileCount = files.length;
@@ -85999,9 +87193,11 @@ ${url}`;
85999
87193
  attachKinds.push(null);
86000
87194
  }
86001
87195
  for (let i = 0;i < fileCount; i++) {
86002
- const ext = extname(files[i] ?? "").toLowerCase();
86003
- texts.push(`(${PHOTO_EXTS.has(ext) ? "photo" : "document"}: ${files[i]})`);
86004
- attachKinds.push(PHOTO_EXTS.has(ext) ? "photo" : "document");
87196
+ const f = files[i] ?? "";
87197
+ const ext = extname(f).toLowerCase();
87198
+ const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? "photo" : "document";
87199
+ texts.push(`(${kind}: ${f})`);
87200
+ attachKinds.push(kind);
86005
87201
  }
86006
87202
  recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds });
86007
87203
  } catch (err) {
@@ -86262,8 +87458,8 @@ async function publishToTelegraph(text5, shortName, authorName) {
86262
87458
  const accountPath = join53(STATE_DIR, "telegraph-account.json");
86263
87459
  let account = null;
86264
87460
  try {
86265
- if (existsSync49(accountPath)) {
86266
- const raw = readFileSync51(accountPath, "utf-8");
87461
+ if (existsSync50(accountPath)) {
87462
+ const raw = readFileSync53(accountPath, "utf-8");
86267
87463
  const parsed = JSON.parse(raw);
86268
87464
  if (parsed.shortName && parsed.accessToken) {
86269
87465
  account = parsed;
@@ -86283,7 +87479,7 @@ async function publishToTelegraph(text5, shortName, authorName) {
86283
87479
  account = created.value;
86284
87480
  try {
86285
87481
  mkdirSync39(STATE_DIR, { recursive: true, mode: 448 });
86286
- writeFileSync41(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
87482
+ writeFileSync42(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
86287
87483
  } catch (err) {
86288
87484
  process.stderr.write(`telegram gateway: telegraph cache write failed: ${err.message}
86289
87485
  `);
@@ -86723,7 +87919,7 @@ async function executeVaultRequestAccess(args) {
86723
87919
  }
86724
87920
  function readLiveSwitchroomConfigText() {
86725
87921
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? findConfigFile2();
86726
- return readFileSync51(cfgPath, "utf8");
87922
+ return readFileSync53(cfgPath, "utf8");
86727
87923
  }
86728
87924
  var MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
86729
87925
  function buildMentalModelProposeKeyboard(stageId) {
@@ -86894,7 +88090,7 @@ async function executeDownloadAttachment(args) {
86894
88090
  });
86895
88091
  mkdirSync39(INBOX_DIR, { recursive: true, mode: 448 });
86896
88092
  assertInsideInbox(INBOX_DIR, dlPath);
86897
- writeFileSync41(dlPath, buf, { mode: 384 });
88093
+ writeFileSync42(dlPath, buf, { mode: 384 });
86898
88094
  return { content: [{ type: "text", text: dlPath }] };
86899
88095
  }
86900
88096
  async function executeEditMessage(args) {
@@ -88401,6 +89597,9 @@ async function handleInboundCoalesced(ctx, text5, downloadImage, attachment) {
88401
89597
  if (parseInterruptMarker(text5).isInterrupt) {
88402
89598
  return handleInbound(ctx, text5, downloadImage, attachment);
88403
89599
  }
89600
+ if (parseStopKeyword(text5) && downloadImage == null && attachment == null) {
89601
+ return handleInbound(ctx, text5, downloadImage, attachment);
89602
+ }
88404
89603
  const hasAttachment = downloadImage != null || attachment != null;
88405
89604
  const maxAttachments = coalesceMaxAttachments();
88406
89605
  if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
@@ -88509,6 +89708,27 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88509
89708
  process.stderr.write(`telegram gateway: inbound classifier error: ${err.message}
88510
89709
  `);
88511
89710
  }
89711
+ if (parseStopKeyword(text5) && downloadImage == null && attachment == null && (extraAttachments == null || extraAttachments.length === 0)) {
89712
+ const queuedLabels = pendingSessionCommand.list().map((c) => `/${c.kind} ${c.targetLabel}`);
89713
+ const inFlight = turnInFlightForGate();
89714
+ process.stderr.write(`telegram gateway: stop-keyword received chat_id=${chat_id} in_flight_turn=${inFlight} queued_cmds=${queuedLabels.length}
89715
+ `);
89716
+ if (inFlight) {
89717
+ if (msgId != null) {
89718
+ bot.api.setMessageReaction(chat_id, msgId, [
89719
+ { type: "emoji", emoji: "\u26A1" }
89720
+ ]).catch(() => {});
89721
+ }
89722
+ await executeHaltNow("stop-keyword");
89723
+ }
89724
+ const stopReply = buildStopReply(inFlight, queuedLabels);
89725
+ await swallowingApiCall(() => bot.api.sendMessage(chat_id, stopReply.text, messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
89726
+ chat_id,
89727
+ verb: "stop-keyword-reply",
89728
+ ...messageThreadId != null ? { threadId: messageThreadId } : {}
89729
+ });
89730
+ return;
89731
+ }
88512
89732
  const interrupt = parseInterruptMarker(text5);
88513
89733
  let deferInterrupt = false;
88514
89734
  if (interrupt.isInterrupt) {
@@ -88525,6 +89745,15 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88525
89745
  { type: "emoji", emoji: "\u26A1" }
88526
89746
  ]).catch(() => {});
88527
89747
  }
89748
+ if (interrupt.emptyBody) {
89749
+ await executeHaltNow("bang-empty");
89750
+ await swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u26A1 Interrupted. Send your replacement instruction now.", messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
89751
+ chat_id,
89752
+ verb: "interrupt-empty-body",
89753
+ ...messageThreadId != null ? { threadId: messageThreadId } : {}
89754
+ });
89755
+ return;
89756
+ }
88528
89757
  if (agentName3 && !deferInterrupt) {
88529
89758
  try {
88530
89759
  const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
@@ -88542,14 +89771,6 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88542
89771
  }
88543
89772
  cancelInterruptedObligation();
88544
89773
  }
88545
- if (interrupt.emptyBody) {
88546
- await swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u26A1 Interrupted. Send your replacement instruction now.", messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
88547
- chat_id,
88548
- verb: "interrupt-empty-body",
88549
- ...messageThreadId != null ? { threadId: messageThreadId } : {}
88550
- });
88551
- return;
88552
- }
88553
89774
  text5 = interrupt.body;
88554
89775
  }
88555
89776
  if (STATUS_QUERY_RE.test(text5)) {
@@ -89420,7 +90641,7 @@ function writeRestartMarker(marker) {
89420
90641
  if (!p)
89421
90642
  return;
89422
90643
  try {
89423
- writeFileSync41(p, JSON.stringify(marker));
90644
+ writeFileSync42(p, JSON.stringify(marker));
89424
90645
  lastPlannedRestartAt = Date.now();
89425
90646
  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}
89426
90647
  `);
@@ -89439,7 +90660,7 @@ function readRestartMarker() {
89439
90660
  if (!p)
89440
90661
  return null;
89441
90662
  try {
89442
- return JSON.parse(readFileSync51(p, "utf8"));
90663
+ return JSON.parse(readFileSync53(p, "utf8"));
89443
90664
  } catch {
89444
90665
  return null;
89445
90666
  }
@@ -89588,7 +90809,7 @@ var _dockerReachable;
89588
90809
  function isDockerReachable() {
89589
90810
  if (_dockerReachable !== undefined)
89590
90811
  return _dockerReachable;
89591
- if (!existsSync49("/var/run/docker.sock")) {
90812
+ if (!existsSync50("/var/run/docker.sock")) {
89592
90813
  _dockerReachable = false;
89593
90814
  return _dockerReachable;
89594
90815
  }
@@ -89609,8 +90830,8 @@ function spawnSwitchroomDetached(args, onFailure) {
89609
90830
  let outFd = null;
89610
90831
  try {
89611
90832
  mkdirSync39(STATE_DIR, { recursive: true });
89612
- outFd = openSync10(logPath, "a");
89613
- writeFileSync41(logPath, `
90833
+ outFd = openSync11(logPath, "a");
90834
+ writeFileSync42(logPath, `
89614
90835
  [${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(" ")}
89615
90836
  `, { flag: "a" });
89616
90837
  } catch {}
@@ -89624,7 +90845,7 @@ function spawnSwitchroomDetached(args, onFailure) {
89624
90845
  });
89625
90846
  if (outFd != null) {
89626
90847
  try {
89627
- closeSync10(outFd);
90848
+ closeSync11(outFd);
89628
90849
  } catch {}
89629
90850
  }
89630
90851
  if (onFailure) {
@@ -89636,7 +90857,7 @@ function spawnSwitchroomDetached(args, onFailure) {
89636
90857
  return;
89637
90858
  let tail = "";
89638
90859
  try {
89639
- const full = readFileSync51(logPath, "utf8");
90860
+ const full = readFileSync53(logPath, "utf8");
89640
90861
  tail = full.split(`
89641
90862
  `).slice(-30).join(`
89642
90863
  `).trim();
@@ -90004,9 +91225,9 @@ bot.use(async (ctx, next) => {
90004
91225
  function readRecentDenialsForAgent(agentName3, windowMs, limit) {
90005
91226
  try {
90006
91227
  const auditPath = join53(homedir17(), ".switchroom", "vault-audit.log");
90007
- if (!existsSync49(auditPath))
91228
+ if (!existsSync50(auditPath))
90008
91229
  return [];
90009
- const raw = readFileSync51(auditPath, "utf8");
91230
+ const raw = readFileSync53(auditPath, "utf8");
90010
91231
  return recentDenialsFromAuditLog(raw, { agentName: agentName3, windowMs, limit });
90011
91232
  } catch {
90012
91233
  return [];
@@ -90057,7 +91278,7 @@ async function buildAgentMetadata(agentName3) {
90057
91278
  try {
90058
91279
  const agentDir = resolveAgentDirFromEnv();
90059
91280
  if (agentDir) {
90060
- const raw = readFileSync51(join53(agentDir, ".claude", ".claude.json"), "utf8");
91281
+ const raw = readFileSync53(join53(agentDir, ".claude", ".claude.json"), "utf8");
90061
91282
  claudeJson = JSON.parse(raw);
90062
91283
  }
90063
91284
  } catch {}
@@ -90327,6 +91548,192 @@ function modelMenuReplyMarkup(reply) {
90327
91548
  }
90328
91549
  return kb;
90329
91550
  }
91551
+ function recordTypedModelSwitch(reply, requestedModelArg, deps) {
91552
+ const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null;
91553
+ if (requested?.toLowerCase() === "default") {
91554
+ const smDir2 = resolveAgentDirFromEnv();
91555
+ if (smDir2)
91556
+ clearSessionModelFile(smDir2);
91557
+ if (reply.selectedModel)
91558
+ sessionModelSource.setOverride(null);
91559
+ return "";
91560
+ }
91561
+ if (!reply.selectedModel)
91562
+ return "";
91563
+ sessionModelSource.setOverride(reply.selectedModel);
91564
+ const smDir = resolveAgentDirFromEnv();
91565
+ if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
91566
+ try {
91567
+ writeSessionModelFile(smDir, requested, readConfiguredDefaultModel(smDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
91568
+ } catch (err) {
91569
+ process.stderr.write(`telegram gateway: session-model persist failed (typed /model): ${err?.message ?? String(err)}
91570
+ `);
91571
+ return `
91572
+ \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
91573
+ }
91574
+ }
91575
+ return "";
91576
+ }
91577
+ function recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel) {
91578
+ if (outcome.selectedModel) {
91579
+ sessionModelSource.setOverride(outcome.selectedModel);
91580
+ const smDir = resolveAgentDirFromEnv();
91581
+ if (smDir && outcome.selectedModelToken) {
91582
+ try {
91583
+ writeSessionModelFile(smDir, outcome.selectedModelToken, readConfiguredDefaultModel(smDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
91584
+ } catch (err) {
91585
+ outcome.reply.text += `
91586
+ \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
91587
+ process.stderr.write(`telegram gateway: session-model persist failed (menu): ${err?.message ?? String(err)}
91588
+ `);
91589
+ }
91590
+ }
91591
+ }
91592
+ if (outcome.clearedDefault) {
91593
+ const smDir = resolveAgentDirFromEnv();
91594
+ if (smDir)
91595
+ clearSessionModelFile(smDir);
91596
+ }
91597
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
91598
+ const agentName3 = getMyAgentName();
91599
+ const agentDir = resolveAgentDirFromEnv();
91600
+ const token = outcome.selectedModelToken;
91601
+ if (agentDir && token) {
91602
+ try {
91603
+ writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
91604
+ sessionModelSource.setOverride(token);
91605
+ } catch (e) {
91606
+ process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
91607
+ `);
91608
+ }
91609
+ } else if (agentDir) {
91610
+ clearSessionModelFile(agentDir);
91611
+ }
91612
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
91613
+ stampUserRestartReason("user: sr-to-claude model switch (menu)");
91614
+ if (turnInFlightForGate()) {
91615
+ pendingRestarts.set(agentName3, Date.now());
91616
+ } else {
91617
+ sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
91618
+ }
91619
+ return {
91620
+ restartNotice: `\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`
91621
+ };
91622
+ }
91623
+ return {};
91624
+ }
91625
+ async function editPendingCommandCard(chatId, messageId, text5) {
91626
+ try {
91627
+ await robustApiCall(() => lockedBot.api.editMessageText(chatId, messageId, richMessage2(hardenCardBreaks2(text5)), {
91628
+ reply_markup: { inline_keyboard: [] }
91629
+ }), { chat_id: chatId, verb: "pending-cmd-card" });
91630
+ } catch (err) {
91631
+ process.stderr.write(`telegram gateway: pending-command card edit failed chat=${chatId} msg=${messageId}: ${err?.message ?? String(err)}
91632
+ `);
91633
+ }
91634
+ }
91635
+ function enqueueSessionCommand(cmd) {
91636
+ const displaced = pendingSessionCommand.set(cmd);
91637
+ if (displaced) {
91638
+ editPendingCommandCard(displaced.ackChatId, displaced.ackMessageId, supersededText(displaced, cmd, escapeHtmlForTg2));
91639
+ }
91640
+ if (!turnInFlightForGate())
91641
+ drainPendingSessionCommand();
91642
+ }
91643
+ async function applyQueuedModelCommand(cmd) {
91644
+ const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId });
91645
+ if (cmd.origin === "menu") {
91646
+ const prevSessionModel = sessionModelSource.getOverride();
91647
+ const outcome = await handleModelMenuCallback(cmd.arg, deps);
91648
+ const { restartNotice } = recordModelMenuSideEffects(outcome, deps, cmd.chatId, cmd.threadId, prevSessionModel);
91649
+ return restartNotice ?? outcome.reply.text;
91650
+ }
91651
+ const reply = await handleModelCommand({ kind: "set", model: cmd.arg }, deps);
91652
+ const warning = recordTypedModelSwitch(reply, cmd.arg, deps);
91653
+ return reply.text + warning;
91654
+ }
91655
+ async function applyQueuedEffortCommand(cmd) {
91656
+ const deps = buildEffortDeps();
91657
+ if (cmd.origin === "menu") {
91658
+ const outcome = await handleEffortMenuCallback(cmd.arg, deps);
91659
+ return outcome.reply.text;
91660
+ }
91661
+ const parsed = cmd.arg === "default" ? { kind: "default" } : { kind: "set", level: cmd.arg };
91662
+ const reply = await handleEffortCommand(parsed, deps);
91663
+ return reply.text;
91664
+ }
91665
+ function reEnqueueUnlessSuperseded(cmd) {
91666
+ const newer = pendingSessionCommand.get(cmd.kind);
91667
+ if (newer != null) {
91668
+ editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, supersededText(cmd, newer, escapeHtmlForTg2));
91669
+ return;
91670
+ }
91671
+ pendingSessionCommand.set(cmd);
91672
+ }
91673
+ function persistQueuedCommandForRestart(action) {
91674
+ if (action.persist == null)
91675
+ return action.reissueText;
91676
+ const agentDir = resolveAgentDirFromEnv();
91677
+ if (agentDir == null)
91678
+ return action.reissueText;
91679
+ try {
91680
+ switch (action.persist) {
91681
+ case "model": {
91682
+ if (!isOfflineTrustedModelToken(action.arg)) {
91683
+ return `\u21A9\uFE0F Couldn\u2019t verify \`${escapeHtmlForTg2(action.cmd.targetLabel || action.arg)}\` as a known model without the live session \u2014 it was NOT saved. Re-issue \`/model ${escapeHtmlForTg2(action.arg)}\` once the agent is back.`;
91684
+ }
91685
+ const configured = readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined);
91686
+ writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured);
91687
+ writeRelaunchModelIntent(agentDir, "keep", "queued /model carried across restart");
91688
+ break;
91689
+ }
91690
+ case "clear-model":
91691
+ clearSessionModelFile(agentDir);
91692
+ break;
91693
+ case "effort":
91694
+ writeSessionEffortFile(agentDir, action.arg, getConfiguredEffortForPersist());
91695
+ break;
91696
+ case "clear-effort":
91697
+ clearSessionEffortFile(agentDir);
91698
+ break;
91699
+ }
91700
+ return action.persistedText;
91701
+ } catch (err) {
91702
+ process.stderr.write(`telegram gateway: queued-command persist-for-restart failed kind=${action.cmd.kind} arg=${action.arg}: ${err?.message ?? String(err)}
91703
+ `);
91704
+ return action.reissueText;
91705
+ }
91706
+ }
91707
+ function getConfiguredEffortForPersist() {
91708
+ try {
91709
+ const data = switchroomExecJson(["agent", "list"]);
91710
+ return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT;
91711
+ } catch {
91712
+ return SWITCHROOM_DEFAULT_THINKING_EFFORT;
91713
+ }
91714
+ }
91715
+ var pendingCmdDraining = false;
91716
+ async function drainPendingSessionCommand() {
91717
+ if (pendingCmdDraining)
91718
+ return;
91719
+ if (pendingSessionCommand.size === 0)
91720
+ return;
91721
+ pendingCmdDraining = true;
91722
+ try {
91723
+ await drainTakenCommands(pendingSessionCommand.takeAll(), {
91724
+ restartPending: () => pendingRestarts.size > 0,
91725
+ turnInFlight: () => turnInFlightForGate(),
91726
+ apply: (cmd) => cmd.kind === "model" ? applyQueuedModelCommand(cmd) : applyQueuedEffortCommand(cmd),
91727
+ isBusyRefusal: isBusyRefusalText,
91728
+ resolveForRestartText: (cmd) => persistQueuedCommandForRestart(resolveForRestart(cmd, escapeHtmlForTg2)),
91729
+ editCard: (cmd, text5) => editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, text5),
91730
+ reEnqueue: reEnqueueUnlessSuperseded,
91731
+ failureText: (cmd, err) => `\u274C Couldn\u2019t apply the queued ${cmd.kind} switch to \`${escapeHtmlForTg2(cmd.targetLabel)}\`: ${escapeHtmlForTg2(err?.message ?? String(err))}`
91732
+ });
91733
+ } finally {
91734
+ pendingCmdDraining = false;
91735
+ }
91736
+ }
90330
91737
  bot.command("model", async (ctx) => {
90331
91738
  if (!isAuthorizedSender(ctx))
90332
91739
  return;
@@ -90340,38 +91747,53 @@ bot.command("model", async (ctx) => {
90340
91747
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) });
90341
91748
  return;
90342
91749
  }
90343
- const reply = await handleModelCommand(parsed, deps);
90344
- const requested = parsed.kind === "set" ? expandSrAlias(parsed.model) : null;
90345
- let persistWarning = "";
90346
- if (requested?.toLowerCase() === "default") {
90347
- const smDir = resolveAgentDirFromEnv();
90348
- if (smDir)
90349
- clearSessionModelFile(smDir);
90350
- if (reply.selectedModel)
90351
- sessionModelSource.setOverride(null);
90352
- } else if (reply.selectedModel) {
90353
- sessionModelSource.setOverride(reply.selectedModel);
90354
- const smDir = resolveAgentDirFromEnv();
90355
- if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
90356
- try {
90357
- writeSessionModelFile(smDir, requested, readConfiguredDefaultModel(smDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
90358
- } catch (err) {
90359
- persistWarning = `
90360
- \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
90361
- process.stderr.write(`telegram gateway: session-model persist failed (typed /model): ${err?.message ?? String(err)}
90362
- `);
90363
- }
90364
- }
91750
+ if (parsed.kind === "set" && deps.isBusy()) {
91751
+ const target = expandSrAlias(parsed.model);
91752
+ const sent = await ctx.replyWithRichMessage(richMessage2(hardenCardBreaks2(ackText("model", target, escapeHtmlForTg2))), threadId != null ? { message_thread_id: threadId } : {});
91753
+ enqueueSessionCommand({
91754
+ kind: "model",
91755
+ origin: "typed",
91756
+ arg: target,
91757
+ targetLabel: target,
91758
+ chatId,
91759
+ threadId,
91760
+ ackChatId: chatId,
91761
+ ackMessageId: sent.message_id,
91762
+ requestedAt: Date.now()
91763
+ });
91764
+ return;
90365
91765
  }
91766
+ const reply = await handleModelCommand(parsed, deps);
91767
+ const persistWarning = recordTypedModelSwitch(reply, parsed.kind === "set" ? parsed.model : null, deps);
90366
91768
  await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html });
90367
91769
  });
90368
91770
  function buildEffortDeps() {
90369
91771
  return {
90370
- applyEffort: (agent, level) => applyEffort(agent, level),
91772
+ applyEffort: async (agent, level) => {
91773
+ const result = await applyEffort(agent, level);
91774
+ if (result.ok) {
91775
+ const agentDir = resolveAgentDirFromEnv();
91776
+ if (agentDir) {
91777
+ try {
91778
+ writeSessionEffortFile(agentDir, level, getConfiguredEffortForPersist());
91779
+ } catch (err) {
91780
+ process.stderr.write(`telegram gateway: session-effort persist failed level=${level}: ${err?.message ?? String(err)}
91781
+ `);
91782
+ }
91783
+ }
91784
+ }
91785
+ return result;
91786
+ },
90371
91787
  getAgentName: getMyAgentName,
90372
- getConfiguredEffort: () => {
90373
- const data = switchroomExecJson(["agent", "list"]);
90374
- return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
91788
+ getConfiguredEffort: () => getConfiguredEffortForPersist(),
91789
+ clearSessionEffort: () => {
91790
+ const agentDir = resolveAgentDirFromEnv();
91791
+ if (agentDir)
91792
+ clearSessionEffortFile(agentDir);
91793
+ },
91794
+ getSessionEffort: () => {
91795
+ const agentDir = resolveAgentDirFromEnv();
91796
+ return agentDir ? readSessionEffortFile(agentDir)?.level ?? null : null;
90375
91797
  },
90376
91798
  escapeHtml: escapeHtmlForTg2
90377
91799
  };
@@ -90398,6 +91820,24 @@ bot.command("effort", async (ctx) => {
90398
91820
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) });
90399
91821
  return;
90400
91822
  }
91823
+ if ((parsed.kind === "set" || parsed.kind === "default") && currentTurn !== null) {
91824
+ const requestedLevel = parsed.kind === "set" ? parsed.level : "default";
91825
+ const chatId = String(ctx.chat.id);
91826
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
91827
+ const sent = await ctx.replyWithRichMessage(richMessage2(hardenCardBreaks2(ackText("effort", requestedLevel, escapeHtmlForTg2))), threadId != null ? { message_thread_id: threadId } : {});
91828
+ enqueueSessionCommand({
91829
+ kind: "effort",
91830
+ origin: "typed",
91831
+ arg: requestedLevel,
91832
+ targetLabel: requestedLevel,
91833
+ chatId,
91834
+ threadId,
91835
+ ackChatId: chatId,
91836
+ ackMessageId: sent.message_id,
91837
+ requestedAt: Date.now()
91838
+ });
91839
+ return;
91840
+ }
90401
91841
  const reply = await handleEffortCommand(parsed, deps);
90402
91842
  await switchroomReply(ctx, reply.text, { html: reply.html });
90403
91843
  });
@@ -90413,7 +91853,7 @@ bot.command("agentstart", async (ctx) => {
90413
91853
  }
90414
91854
  await dispatchShortVerbViaHostd(ctx, { v: 1, op: "agent_start", request_id: hostdRequestId2("gw-start"), args: { name } }, `start ${name}`, ["agent", "start", name]);
90415
91855
  });
90416
- bot.command("stop", async (ctx) => {
91856
+ bot.command("agentstop", async (ctx) => {
90417
91857
  if (!isAuthorizedSender(ctx))
90418
91858
  return;
90419
91859
  const name = ctx.match?.trim() || getMyAgentName();
@@ -90425,6 +91865,23 @@ bot.command("stop", async (ctx) => {
90425
91865
  }
90426
91866
  await dispatchShortVerbViaHostd(ctx, { v: 1, op: "agent_stop", request_id: hostdRequestId2("gw-stop"), args: { name } }, `stop ${name}`, ["agent", "stop", name]);
90427
91867
  });
91868
+ bot.command("stop", async (ctx) => {
91869
+ if (!isAuthorizedSender(ctx))
91870
+ return;
91871
+ const arg = ctx.match?.trim();
91872
+ if (arg) {
91873
+ await switchroomReply(ctx, `/stop takes no argument \u2014 it cancels MY in-flight turn. ` + `To stop a container, use /agentstop ${arg}. Nothing was stopped.`);
91874
+ return;
91875
+ }
91876
+ const queuedLabels = pendingSessionCommand.list().map((c) => `/${c.kind} ${c.targetLabel}`);
91877
+ const inFlight = turnInFlightForGate();
91878
+ if (!inFlight) {
91879
+ await switchroomReply(ctx, buildStopReply(false, queuedLabels).text);
91880
+ return;
91881
+ }
91882
+ await executeHaltNow("stop-command");
91883
+ await switchroomReply(ctx, buildStopReply(true, queuedLabels).text);
91884
+ });
90428
91885
  bot.command("restart", async (ctx) => {
90429
91886
  if (!isAuthorizedSender(ctx))
90430
91887
  return;
@@ -90443,10 +91900,10 @@ bot.command("restart", async (ctx) => {
90443
91900
  }
90444
91901
  const chatId = String(ctx.chat.id);
90445
91902
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90446
- const ackText = restartAckText(name);
91903
+ const ackText2 = restartAckText(name);
90447
91904
  let ackId = null;
90448
91905
  try {
90449
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
91906
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90450
91907
  disable_notification: true,
90451
91908
  ...tid != null ? { message_thread_id: tid } : {}
90452
91909
  }), { threadId, chat_id: chatId, verb: "restart.ack" });
@@ -90462,7 +91919,7 @@ bot.command("restart", async (ctx) => {
90462
91919
  {
90463
91920
  const smDir = resolveAgentDirFromEnv();
90464
91921
  if (smDir)
90465
- writeRelaunchModelIntent(smDir, "revert", "user: /restart from chat");
91922
+ writeRelaunchModelIntent(smDir, "keep", "user: /restart from chat");
90466
91923
  }
90467
91924
  await sweepBeforeSelfRestart();
90468
91925
  const hostdResp = await tryHostdDispatch2(getMyAgentName(), {
@@ -90496,8 +91953,8 @@ function flushAgentHandoff(agentDir) {
90496
91953
  for (const fname of [".handoff.md", ".handoff-topic"]) {
90497
91954
  const p = join53(agentDir, fname);
90498
91955
  try {
90499
- if (existsSync49(p)) {
90500
- unlinkSync20(p);
91956
+ if (existsSync50(p)) {
91957
+ unlinkSync21(p);
90501
91958
  removed++;
90502
91959
  }
90503
91960
  } catch (err) {
@@ -90535,24 +91992,24 @@ async function handleNewCommand(ctx) {
90535
91992
  const flushed = agentDir != null ? flushAgentHandoff(agentDir) : 0;
90536
91993
  const chatId = String(ctx.chat.id);
90537
91994
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90538
- const ackText = newSessionAckText(name, flushed > 0);
91995
+ const ackText2 = newSessionAckText(name, flushed > 0);
90539
91996
  let ackId = null;
90540
91997
  try {
90541
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
91998
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90542
91999
  disable_notification: true,
90543
92000
  ...tid != null ? { message_thread_id: tid } : {}
90544
92001
  }), { threadId, chat_id: chatId, verb: "new-or-reset.ack" });
90545
92002
  ackId = sent.message_id;
90546
92003
  if (HISTORY_ENABLED) {
90547
92004
  try {
90548
- recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: [sent.message_id], texts: [ackText], attachment_kinds: [] });
92005
+ recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: [sent.message_id], texts: [ackText2], attachment_kinds: [] });
90549
92006
  } catch {}
90550
92007
  }
90551
92008
  } catch {}
90552
92009
  writeRestartMarker({ chat_id: chatId, thread_id: threadId ?? null, ack_message_id: ackId, ts: Date.now() });
90553
92010
  if (agentDir != null) {
90554
92011
  try {
90555
- writeFileSync41(join53(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
92012
+ writeFileSync42(join53(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
90556
92013
  `, "utf8");
90557
92014
  } catch (err) {
90558
92015
  process.stderr.write(`telegram gateway: failed to write force-fresh marker: ${err}
@@ -90628,7 +92085,7 @@ Either run \`switchroom update\` from the host shell, or set \`host_control.enab
90628
92085
  }
90629
92086
  const chatId = String(ctx.chat.id);
90630
92087
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90631
- const ackText = `\uD83D\uDE80 **update started** \u2014 running ${[
92088
+ const ackText2 = `\uD83D\uDE80 **update started** \u2014 running ${[
90632
92089
  "`switchroom update`",
90633
92090
  ...passthrough.map((t) => `\`${t}\``)
90634
92091
  ].join(" ")}
@@ -90636,7 +92093,7 @@ Either run \`switchroom update\` from the host shell, or set \`host_control.enab
90636
92093
  The gateway will restart as part of the recreate step; watch for the post-restart greeting card to confirm completion.`;
90637
92094
  let ackId = null;
90638
92095
  try {
90639
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
92096
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90640
92097
  disable_notification: true,
90641
92098
  ...tid != null ? { message_thread_id: tid } : {}
90642
92099
  }), { threadId, chat_id: chatId, verb: "update.ack" });
@@ -90919,16 +92376,16 @@ bot.command("interrupt", async (ctx) => {
90919
92376
  await runSwitchroomCommand(ctx, ["agent", "interrupt", name], `interrupt ${name}`);
90920
92377
  });
90921
92378
  var lockoutOps = {
90922
- readFileSync: (p, enc) => readFileSync51(p, enc),
90923
- writeFileSync: (p, data, opts) => writeFileSync41(p, data, opts),
90924
- existsSync: (p) => existsSync49(p),
92379
+ readFileSync: (p, enc) => readFileSync53(p, enc),
92380
+ writeFileSync: (p, data, opts) => writeFileSync42(p, data, opts),
92381
+ existsSync: (p) => existsSync50(p),
90925
92382
  mkdirSync: (p, opts) => mkdirSync39(p, opts),
90926
92383
  joinPath: (...parts) => join53(...parts)
90927
92384
  };
90928
92385
  var FLEET_FALLBACK_DEDUP_MS = 30000;
90929
92386
  function isAuthBrokerSocketReachable() {
90930
92387
  try {
90931
- return existsSync49(resolveAuthBrokerSocketPath2());
92388
+ return existsSync50(resolveAuthBrokerSocketPath2());
90932
92389
  } catch {
90933
92390
  return false;
90934
92391
  }
@@ -90937,6 +92394,7 @@ var fleetFallbackGate = createFleetFallbackGate({
90937
92394
  dedupMs: FLEET_FALLBACK_DEDUP_MS,
90938
92395
  brokerReachable: isAuthBrokerSocketReachable
90939
92396
  });
92397
+ var modelUnavailableCardRegistry = createModelUnavailableCardRegistry();
90940
92398
  var fleetFallbackResumeGate = createFleetFallbackResumeGate({
90941
92399
  maxAgeMs: (() => {
90942
92400
  const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
@@ -91024,6 +92482,16 @@ async function doFireFleetAutoFallback(triggerAgent, untilMs) {
91024
92482
  return outcome.kind === "switched";
91025
92483
  const opts = { disable_notification: true };
91026
92484
  for (const chat_id of access.allowFrom) {
92485
+ const card = modelUnavailableCardRegistry.take(String(chat_id), Date.now());
92486
+ if (decideAnnouncementDelivery(outcome.kind, card) === "edit" && card) {
92487
+ try {
92488
+ await bot.api.editMessageText(chat_id, card.messageId, richMessage2(foldAnnouncementIntoCard(card.text, outcome.announcement)), {});
92489
+ continue;
92490
+ } catch (err) {
92491
+ process.stderr.write(`telegram gateway: [fleet-fallback] card edit failed chat=${chat_id} \u2014 sending announcement separately: ${err?.message ?? err}
92492
+ `);
92493
+ }
92494
+ }
91027
92495
  swallowingApiCall(() => bot.api.sendRichMessage(chat_id, richMessage2(outcome.announcement), opts), { chat_id, verb: "fleet-fallback:notify" });
91028
92496
  }
91029
92497
  if (outcome.kind === "switched") {
@@ -91144,6 +92612,38 @@ async function runQuotaWatch(opts = {}) {
91144
92612
  `);
91145
92613
  }
91146
92614
  process.stderr.write(`telegram gateway: quota-watch: fleet all-exhausted ${fleetDecision.transition}
92615
+ `);
92616
+ }
92617
+ }
92618
+ {
92619
+ const rollPrev = watchState[FLEET_ROLL_ANNOUNCE_KEY] ?? emptyAccountState();
92620
+ const rollDecision = evaluateFleetRollAnnounce({
92621
+ roll: listStateData.last_fleet_roll ?? null,
92622
+ prev: rollPrev,
92623
+ now
92624
+ });
92625
+ if (rollDecision.kind === "notify") {
92626
+ for (const chat_id of access.allowFrom) {
92627
+ if (tuning.fleetDedup) {
92628
+ const granted = await claimQuotaNotification(brokerClient, buildQuotaClaimKey(FLEET_ROLL_ANNOUNCE_KEY, String(listStateData.last_fleet_roll.at), chat_id));
92629
+ if (!granted) {
92630
+ process.stderr.write(`telegram gateway: quota-watch: fleet-roll claim denied chat=${chat_id} \u2014 another agent notified
92631
+ `);
92632
+ continue;
92633
+ }
92634
+ }
92635
+ await swallowingApiCall(() => bot.api.sendRichMessage(chat_id, richMessage2(rollDecision.message), {
92636
+ disable_notification: true
92637
+ }), { chat_id, verb: "quota-watch.fleet-roll" });
92638
+ }
92639
+ watchState = patchQuotaWatchState(watchState, FLEET_ROLL_ANNOUNCE_KEY, rollDecision.newState);
92640
+ try {
92641
+ saveQuotaWatchState(stateDir, watchState);
92642
+ } catch (err) {
92643
+ process.stderr.write(`telegram gateway: quota-watch: fleet-roll state save failed: ${err}
92644
+ `);
92645
+ }
92646
+ process.stderr.write(`telegram gateway: quota-watch: announced broker fleet roll ${listStateData.last_fleet_roll.from} \u2192 ${listStateData.last_fleet_roll.to}
91147
92647
  `);
91148
92648
  }
91149
92649
  }
@@ -91151,9 +92651,10 @@ async function runQuotaWatch(opts = {}) {
91151
92651
  const labelToSnapIndex = new Map(snapshots.map((s, i) => [s.label, i]));
91152
92652
  let reconciledCount = 0;
91153
92653
  let mutatedState = watchState;
92654
+ const lastRoll = listStateData.last_fleet_roll ?? null;
91154
92655
  for (const snap of snapshots) {
91155
92656
  const prev = watchState[snap.label] ?? emptyAccountState();
91156
- const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now, bootTick, tuning });
92657
+ const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now, bootTick, tuning, lastRoll });
91157
92658
  if (decision.kind === "reconcile") {
91158
92659
  mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState);
91159
92660
  reconciledCount++;
@@ -91209,7 +92710,7 @@ async function runQuotaWatch(opts = {}) {
91209
92710
  if (isLiveCorroboration(freshEntry) && freshEntry.result.ok && snapIndex >= 0) {
91210
92711
  const enrichedSnap = { ...snapshots[snapIndex], quota: freshEntry.result.data, capturedAtMs: undefined };
91211
92712
  const prev = watchState[accountLabel] ?? emptyAccountState();
91212
- const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now, bootTick, tuning });
92713
+ const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now, bootTick, tuning, lastRoll });
91213
92714
  if (re.kind === "notify" && re.transition === decision.transition) {
91214
92715
  enrichedDecision = re;
91215
92716
  } else if (re.kind === "reconcile") {
@@ -92045,6 +93546,28 @@ bot.on("callback_query:data", async (ctx) => {
92045
93546
  await ctx.answerCallbackQuery({ text: "Not authorized." });
92046
93547
  return;
92047
93548
  }
93549
+ const effLevel = data.startsWith("eff:s:") ? data.slice("eff:s:".length) : null;
93550
+ const effCardMsgId = ctx.callbackQuery?.message?.message_id;
93551
+ if (effLevel != null && currentTurn !== null && effCardMsgId != null) {
93552
+ await ctx.answerCallbackQuery({ text: "\uD83D\uDCE5 Queued \u2014 applies when the turn ends" }).catch(() => {});
93553
+ const effChatId = String(ctx.chat?.id ?? "");
93554
+ const effThreadId = resolveThreadId(effChatId, ctx.callbackQuery?.message?.message_thread_id);
93555
+ await ctx.editMessageText(richMessage2(hardenCardBreaks2(ackText("effort", effLevel, escapeHtmlForTg2))), {
93556
+ reply_markup: { inline_keyboard: [] }
93557
+ }).catch(() => {});
93558
+ enqueueSessionCommand({
93559
+ kind: "effort",
93560
+ origin: "menu",
93561
+ arg: data,
93562
+ targetLabel: effLevel,
93563
+ chatId: effChatId,
93564
+ threadId: effThreadId,
93565
+ ackChatId: effChatId,
93566
+ ackMessageId: effCardMsgId,
93567
+ requestedAt: Date.now()
93568
+ });
93569
+ return;
93570
+ }
92048
93571
  await ctx.answerCallbackQuery({ text: "Setting effort\u2026" }).catch(() => {});
92049
93572
  try {
92050
93573
  const outcome = await handleEffortMenuCallback(data, buildEffortDeps());
@@ -92075,6 +93598,46 @@ bot.on("callback_query:data", async (ctx) => {
92075
93598
  const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id);
92076
93599
  const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId });
92077
93600
  if (modelDeps.isBusy()) {
93601
+ const cardMsgId = ctx.callbackQuery?.message?.message_id;
93602
+ let queueArg = null;
93603
+ let queueOrigin = "menu";
93604
+ let queueLabel = "the selected model";
93605
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
93606
+ const srName = data.slice(MODEL_CALLBACK_SR.length);
93607
+ if (isValidModelArg(srName)) {
93608
+ queueArg = srName;
93609
+ queueOrigin = "typed";
93610
+ queueLabel = srFriendlyLabel(srName);
93611
+ }
93612
+ } else if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
93613
+ const alias = data.slice(MODEL_CALLBACK_ALIAS.length);
93614
+ if (isValidModelArg(alias)) {
93615
+ queueArg = alias;
93616
+ queueOrigin = "typed";
93617
+ queueLabel = alias;
93618
+ }
93619
+ } else if (data.startsWith("mdl:s:")) {
93620
+ queueArg = data;
93621
+ queueOrigin = "menu";
93622
+ }
93623
+ if (queueArg != null && cardMsgId != null) {
93624
+ await ctx.answerCallbackQuery({ text: "\uD83D\uDCE5 Queued \u2014 applies when the turn ends" }).catch(() => {});
93625
+ await ctx.editMessageText(richMessage2(hardenCardBreaks2(ackText("model", queueLabel, escapeHtmlForTg2))), {
93626
+ reply_markup: { inline_keyboard: [] }
93627
+ }).catch(() => {});
93628
+ enqueueSessionCommand({
93629
+ kind: "model",
93630
+ origin: queueOrigin,
93631
+ arg: queueArg,
93632
+ targetLabel: queueLabel,
93633
+ chatId: cbChatId,
93634
+ threadId: cbThreadId,
93635
+ ackChatId: cbChatId,
93636
+ ackMessageId: cardMsgId,
93637
+ requestedAt: Date.now()
93638
+ });
93639
+ return;
93640
+ }
92078
93641
  await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
92079
93642
  return;
92080
93643
  }
@@ -92099,56 +93662,14 @@ bot.on("callback_query:data", async (ctx) => {
92099
93662
  }
92100
93663
  return;
92101
93664
  }
92102
- const didInterimSrEdit = false;
92103
93665
  try {
92104
93666
  const prevSessionModel = sessionModelSource.getOverride();
92105
93667
  const outcome = await handleModelMenuCallback(data, modelDeps);
92106
- if (outcome.selectedModel) {
92107
- sessionModelSource.setOverride(outcome.selectedModel);
92108
- const smDir = resolveAgentDirFromEnv();
92109
- if (smDir && outcome.selectedModelToken) {
92110
- try {
92111
- writeSessionModelFile(smDir, outcome.selectedModelToken, readConfiguredDefaultModel(smDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
92112
- } catch (err) {
92113
- outcome.reply.text += `
92114
- \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
92115
- process.stderr.write(`telegram gateway: session-model persist failed (menu): ${err?.message ?? String(err)}
92116
- `);
92117
- }
92118
- }
92119
- }
92120
- if (outcome.clearedDefault) {
92121
- const smDir = resolveAgentDirFromEnv();
92122
- if (smDir)
92123
- clearSessionModelFile(smDir);
92124
- }
92125
- if (outcome.toastOnly && !didInterimSrEdit)
93668
+ if (outcome.toastOnly)
92126
93669
  return;
92127
- if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
92128
- const agentName3 = getMyAgentName();
92129
- await ctx.editMessageText(richMessage2(`\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
92130
- {
92131
- const agentDir = resolveAgentDirFromEnv();
92132
- const token = outcome.selectedModelToken;
92133
- if (agentDir && token) {
92134
- try {
92135
- writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
92136
- sessionModelSource.setOverride(token);
92137
- } catch (e) {
92138
- process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
92139
- `);
92140
- }
92141
- } else if (agentDir) {
92142
- clearSessionModelFile(agentDir);
92143
- }
92144
- }
92145
- writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
92146
- stampUserRestartReason("user: sr-to-claude model switch (menu)");
92147
- if (turnInFlightForGate()) {
92148
- pendingRestarts.set(agentName3, Date.now());
92149
- } else {
92150
- sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
92151
- }
93670
+ const { restartNotice } = recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel);
93671
+ if (restartNotice) {
93672
+ await ctx.editMessageText(richMessage2(restartNotice), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
92152
93673
  return;
92153
93674
  }
92154
93675
  await ctx.editMessageText(richMessage2(outcome.reply.text), {
@@ -92365,7 +93886,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
92365
93886
  let audio = null;
92366
93887
  if (entry.filePath != null) {
92367
93888
  try {
92368
- audio = readFileSync51(entry.filePath);
93889
+ audio = readFileSync53(entry.filePath);
92369
93890
  } catch {
92370
93891
  audio = null;
92371
93892
  }
@@ -92443,8 +93964,8 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
92443
93964
  const cbMessageId = ctx.callbackQuery?.message?.message_id;
92444
93965
  const metaForMessage = cbMessageId != null ? agentButtonMeta.get(`${cbChatId}:${cbMessageId}`) : undefined;
92445
93966
  const tapMeta = metaForMessage?.get(agentCb.raw);
92446
- const ackText2 = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
92447
- await ctx.answerCallbackQuery({ text: ackText2 }).catch(() => {});
93967
+ const ackText3 = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
93968
+ await ctx.answerCallbackQuery({ text: ackText3 }).catch(() => {});
92448
93969
  const buttonText = (() => {
92449
93970
  const msg2 = ctx.callbackQuery?.message;
92450
93971
  const kb = msg2 && "reply_markup" in msg2 ? msg2.reply_markup : undefined;
@@ -92596,7 +94117,7 @@ ${interimLabel}` : interimLabel
92596
94117
  const unifiedDiff = (() => {
92597
94118
  try {
92598
94119
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
92599
- const raw = readFileSync51(cfgPath, "utf8");
94120
+ const raw = readFileSync53(cfgPath, "utf8");
92600
94121
  return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
92601
94122
  } catch (err) {
92602
94123
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
@@ -92732,7 +94253,7 @@ ${editLabel}` : editLabel), {
92732
94253
  }
92733
94254
  const scopedMins = Math.max(1, Math.round(scopedTtl / 60000));
92734
94255
  const windowGranted = timeBox != null && grantAgent !== "";
92735
- const ackText = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
94256
+ const ackText2 = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
92736
94257
  const htmlLabel = behavior === "deny" ? "\u274C **Denied**" : windowGranted ? `\u2705 **Allowed \u2014 won't ask again about ${escapeHtmlForTg2(timeBox.breadth)} for ${scopedMins} min**` : "\u2705 **Allowed once**";
92737
94258
  const msg = ctx.callbackQuery?.message;
92738
94259
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg2(msg.text) : "";
@@ -92745,7 +94266,7 @@ ${editLabel}` : editLabel), {
92745
94266
 
92746
94267
  ${resumeLine}` : htmlLabel;
92747
94268
  await finalizeCallback2(ctx, {
92748
- ackText: ackText.slice(0, 200),
94269
+ ackText: ackText2.slice(0, 200),
92749
94270
  newText: baseText ? `${baseText}
92750
94271
 
92751
94272
  ${labelWithResume}` : labelWithResume,
@@ -92787,7 +94308,7 @@ bot.on("message:photo", async (ctx) => {
92787
94308
  });
92788
94309
  mkdirSync39(INBOX_DIR, { recursive: true, mode: 448 });
92789
94310
  assertInsideInbox(INBOX_DIR, dlPath);
92790
- writeFileSync41(dlPath, buf, { mode: 384 });
94311
+ writeFileSync42(dlPath, buf, { mode: 384 });
92791
94312
  return dlPath;
92792
94313
  } catch (err) {
92793
94314
  const msg = err instanceof Error ? err.message : "unknown error";
@@ -93487,6 +95008,7 @@ bot.catch((err) => {
93487
95008
  `);
93488
95009
  });
93489
95010
  var SHUTDOWN_DRAIN_BUDGET_MS = 35000;
95011
+ var SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS = 1500;
93490
95012
  function countInFlight() {
93491
95013
  return pendingPermissions.size + pendingVaultOps.size + inboundCoalescer.size() + pendingReauthFlows.size;
93492
95014
  }
@@ -93498,6 +95020,7 @@ async function shutdown(signal) {
93498
95020
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME ?? "-";
93499
95021
  process.stderr.write(`telegram gateway: shutting down
93500
95022
  `);
95023
+ bridgeDeadWatchdog.stop();
93501
95024
  const isOsSignal = signal === "SIGTERM" || signal === "SIGINT";
93502
95025
  if (isOsSignal) {
93503
95026
  try {
@@ -93508,12 +95031,43 @@ async function shutdown(signal) {
93508
95031
  `);
93509
95032
  } catch (err) {
93510
95033
  process.stderr.write(`telegram gateway: shutdown.clean_marker_write_failed err=${err.message}
95034
+ `);
95035
+ }
95036
+ try {
95037
+ const smDir = resolveAgentDirFromEnv();
95038
+ if (smDir != null) {
95039
+ const hasOverride = readSessionModelFile(smDir) != null;
95040
+ const intentAlreadyStamped = existsSync50(join53(smDir, RELAUNCH_MODEL_INTENT_FILE));
95041
+ if (hasOverride && !intentAlreadyStamped) {
95042
+ writeRelaunchModelIntent(smDir, "keep", `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown (deploy/rolling restart) \u2014 preserving user-chosen session model`);
95043
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamped signal=${signal}
95044
+ `);
95045
+ }
95046
+ }
95047
+ } catch (err) {
95048
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamp_failed err=${err.message}
93511
95049
  `);
93512
95050
  }
93513
95051
  } else {
93514
95052
  process.stderr.write(`telegram gateway: shutdown.clean_marker_skipped signal=${signal} (crash path \u2014 banner will fire on next boot)
93515
95053
  `);
93516
95054
  }
95055
+ const orphanedCmdActions = shutdownResolutionActions(pendingSessionCommand, escapeHtmlForTg2);
95056
+ const orphanedCmdEdits = orphanedCmdActions.map((a) => ({
95057
+ chatId: a.cmd.ackChatId,
95058
+ messageId: a.cmd.ackMessageId,
95059
+ text: persistQueuedCommandForRestart(a)
95060
+ }));
95061
+ if (orphanedCmdEdits.length > 0) {
95062
+ process.stderr.write(`telegram gateway: shutdown.pending_cmd_resolved count=${orphanedCmdEdits.length}
95063
+ `);
95064
+ await Promise.race([
95065
+ Promise.allSettled(orphanedCmdEdits.map((e) => editPendingCommandCard(e.chatId, e.messageId, e.text))),
95066
+ new Promise((resolve11) => {
95067
+ setTimeout(resolve11, SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS).unref();
95068
+ })
95069
+ ]);
95070
+ }
93517
95071
  const shutdownTurn = currentTurn;
93518
95072
  if (turnsDb != null && shutdownTurn?.registryKey != null) {
93519
95073
  const wasScheduledRestart = pendingRestarts.size > 0;
@@ -93927,9 +95481,9 @@ var didOneTimeSetup = false;
93927
95481
  const smAgentDir = resolveAgentDirFromEnv();
93928
95482
  if (smAgentDir) {
93929
95483
  const activePath = join53(smAgentDir, ".active-session-model");
93930
- if (existsSync49(activePath)) {
95484
+ if (existsSync50(activePath)) {
93931
95485
  try {
93932
- const launched = readFileSync51(activePath, "utf8").trim();
95486
+ const launched = readFileSync53(activePath, "utf8").trim();
93933
95487
  const configured = (() => {
93934
95488
  const d = switchroomExecJson(["agent", "list"]);
93935
95489
  const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
@@ -93939,15 +95493,15 @@ var didOneTimeSetup = false;
93939
95493
  } catch {}
93940
95494
  }
93941
95495
  const alertPath = join53(smAgentDir, ".session-model-alert");
93942
- if (existsSync49(alertPath)) {
95496
+ if (existsSync50(alertPath)) {
93943
95497
  let alertText = null;
93944
95498
  try {
93945
- alertText = readFileSync51(alertPath, "utf8").trim();
95499
+ alertText = readFileSync53(alertPath, "utf8").trim();
93946
95500
  } catch {
93947
95501
  alertText = null;
93948
95502
  }
93949
95503
  try {
93950
- unlinkSync20(alertPath);
95504
+ unlinkSync21(alertPath);
93951
95505
  } catch {}
93952
95506
  if (alertText && alertText.length > 0) {
93953
95507
  const operators = loadAccess().allowFrom;
@@ -94067,6 +95621,20 @@ var didOneTimeSetup = false;
94067
95621
  parentStateDir: STATE_DIR,
94068
95622
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
94069
95623
  `),
95624
+ onResurrect: (agentId, _description) => {
95625
+ try {
95626
+ workerActivityFeed?.resurrect(agentId);
95627
+ } catch (err) {
95628
+ process.stderr.write(`telegram gateway: worker resurrect error agent=${agentId}: ${err.message}
95629
+ `);
95630
+ }
95631
+ process.stderr.write(`telegram gateway: worker ${agentId} card RESURRECTED \u2014 false terminal finish reversed, worker resumed (issue #3023)
95632
+ `);
95633
+ },
95634
+ onWorkerLost: (agentId, _description) => {
95635
+ process.stderr.write(`telegram gateway: worker ${agentId} NAMED AS LOST \u2014 falsely finalised twice, resurrection chain bound reached (issue #3023)
95636
+ `);
95637
+ },
94070
95638
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
94071
95639
  deferredDoneReactions.promote();
94072
95640
  let fleetChatId = "";