switchroom 0.18.9 → 0.18.11

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 (65) 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 +214 -83
  5. package/dist/cli/ui/apple-touch-icon.png +0 -0
  6. package/dist/cli/ui/favicon-32.png +0 -0
  7. package/dist/cli/ui/favicon.ico +0 -0
  8. package/dist/host-control/main.js +760 -257
  9. package/dist/vault/approvals/kernel-server.js +3 -1
  10. package/dist/vault/broker/server.js +3 -1
  11. package/package.json +1 -1
  12. package/profiles/_base/start.sh.hbs +119 -37
  13. package/profiles/_shared/dev-protocol.md.hbs +42 -0
  14. package/skills/dev-protocol/SKILL.md +131 -0
  15. package/telegram-plugin/README.md +2 -1
  16. package/telegram-plugin/admin-commands/dispatch.test.ts +40 -2
  17. package/telegram-plugin/admin-commands/index.ts +6 -1
  18. package/telegram-plugin/bridge/bridge.ts +23 -1
  19. package/telegram-plugin/bridge/crash-breadcrumb.ts +42 -0
  20. package/telegram-plugin/chat-lock.ts +13 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +24 -1
  22. package/telegram-plugin/dist/gateway/gateway.js +1832 -263
  23. package/telegram-plugin/dist/server.js +29 -2
  24. package/telegram-plugin/fallback-card-collapse.ts +131 -0
  25. package/telegram-plugin/gateway/bridge-dead-watchdog.ts +546 -0
  26. package/telegram-plugin/gateway/effort-command.ts +47 -3
  27. package/telegram-plugin/gateway/gateway.ts +1435 -211
  28. package/telegram-plugin/gateway/model-command.ts +94 -8
  29. package/telegram-plugin/gateway/pending-session-command.ts +365 -0
  30. package/telegram-plugin/gateway/permission-timeout.ts +25 -0
  31. package/telegram-plugin/gateway/resume-inbound-builder.ts +23 -3
  32. package/telegram-plugin/gateway/session-model-file.ts +166 -23
  33. package/telegram-plugin/gateway/stop-command.ts +56 -0
  34. package/telegram-plugin/photo-precheck.ts +201 -0
  35. package/telegram-plugin/quota-watch.ts +141 -2
  36. package/telegram-plugin/registry/subagents-schema.ts +26 -3
  37. package/telegram-plugin/registry/subagents.test.ts +67 -0
  38. package/telegram-plugin/retry-api-call.ts +31 -0
  39. package/telegram-plugin/subagent-watcher.ts +392 -1
  40. package/telegram-plugin/tests/bridge-dead-watchdog.test.ts +576 -0
  41. package/telegram-plugin/tests/buffer-gate-broadened.test.ts +11 -5
  42. package/telegram-plugin/tests/chat-lock-unhandled-rejection.test.ts +101 -0
  43. package/telegram-plugin/tests/crash-breadcrumb.test.ts +57 -0
  44. package/telegram-plugin/tests/effort-command.test.ts +59 -2
  45. package/telegram-plugin/tests/fallback-card-collapse.test.ts +104 -0
  46. package/telegram-plugin/tests/gateway-pending-command-wiring.test.ts +124 -0
  47. package/telegram-plugin/tests/gateway-secret-detect.test.ts +7 -1
  48. package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +19 -11
  49. package/telegram-plugin/tests/model-command.test.ts +46 -3
  50. package/telegram-plugin/tests/pending-session-command.test.ts +322 -0
  51. package/telegram-plugin/tests/permission-timeout.test.ts +26 -0
  52. package/telegram-plugin/tests/permission-verdict-resume-guard.test.ts +16 -0
  53. package/telegram-plugin/tests/photo-dimension-fallback.test.ts +129 -0
  54. package/telegram-plugin/tests/photo-precheck.test.ts +240 -0
  55. package/telegram-plugin/tests/photo-reroute-wiring.test.ts +85 -0
  56. package/telegram-plugin/tests/quota-watch.test.ts +225 -0
  57. package/telegram-plugin/tests/session-model-file.test.ts +101 -2
  58. package/telegram-plugin/tests/stop-command.test.ts +234 -0
  59. package/telegram-plugin/tests/subagent-watcher-env-thresholds.test.ts +27 -9
  60. package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +398 -0
  61. package/telegram-plugin/tests/subagent-watcher-stall-terminal.test.ts +172 -0
  62. package/telegram-plugin/tests/worker-activity-feed.test.ts +37 -0
  63. package/telegram-plugin/tests/worker-visibility-prose-silent-harness.test.ts +18 -4
  64. package/telegram-plugin/welcome-text.ts +4 -3
  65. 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/;
@@ -68581,6 +68878,7 @@ import { homedir as homedir9 } from "node:os";
68581
68878
  init_loader();
68582
68879
 
68583
68880
  // ../src/cli/agent-config.ts
68881
+ init_loader();
68584
68882
  init_merge();
68585
68883
 
68586
68884
  // ../src/vault/broker/acl.ts
@@ -68608,7 +68906,7 @@ import_handlebars.default.registerHelper("isNumber", (value) => {
68608
68906
  return typeof value === "number" && Number.isFinite(value);
68609
68907
  });
68610
68908
  var SHARED_FRAGMENTS_DIR = resolve5(PROFILES_ROOT, "_shared");
68611
- var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline"];
68909
+ var SHARED_FRAGMENTS = ["vault-protocol", "agent-self-service", "execution-discipline", "reply-discipline", "dev-protocol"];
68612
68910
  for (const name of SHARED_FRAGMENTS) {
68613
68911
  const fragPath = join30(SHARED_FRAGMENTS_DIR, `${name}.md.hbs`);
68614
68912
  if (existsSync26(fragPath)) {
@@ -68662,6 +68960,7 @@ init_paths();
68662
68960
  // ../src/agents/scaffold.ts
68663
68961
  var REPO_ROOT = resolve6(import.meta.dirname, "../..");
68664
68962
  var SWITCHROOM_DEFAULT_MAIN_MODEL = "claude-sonnet-5";
68963
+ var SWITCHROOM_DEFAULT_THINKING_EFFORT = "low";
68665
68964
  function resolveMainModel(model) {
68666
68965
  if (model === undefined || model === "default")
68667
68966
  return SWITCHROOM_DEFAULT_MAIN_MODEL;
@@ -68696,31 +68995,56 @@ function parseEffortCommand(text4) {
68696
68995
  const arg = parts[0];
68697
68996
  if (arg.toLowerCase() === "help")
68698
68997
  return { kind: "help" };
68998
+ if (arg.toLowerCase() === "default")
68999
+ return { kind: "default" };
68699
69000
  if (!isValidEffortArg(arg)) {
68700
69001
  return { kind: "help", reason: `not a valid effort level: ${arg}` };
68701
69002
  }
68702
69003
  return { kind: "set", level: arg.toLowerCase() };
68703
69004
  }
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._";
69005
+ 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
69006
  var LEVELS_INLINE = EFFORT_LEVELS.map((l) => `\`${l}\``).join(" \u00b7 ");
68706
69007
  function helpText3(deps, reason) {
68707
69008
  const lines = [];
68708
69009
  if (reason)
68709
69010
  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);
69011
+ 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
69012
  return { text: lines.join(`
68712
69013
  `), html: true };
68713
69014
  }
68714
69015
  async function handleEffortCommand(parsed, deps) {
68715
69016
  if (parsed.kind === "help")
68716
69017
  return helpText3(deps, parsed.reason);
69018
+ if (parsed.kind === "default") {
69019
+ const configured = deps.getConfiguredEffort();
69020
+ const restoreLevel = configured && isValidEffortArg(configured) ? configured.toLowerCase() : "low";
69021
+ let restored = null;
69022
+ try {
69023
+ restored = await deps.applyEffort(deps.getAgentName(), restoreLevel);
69024
+ } catch {
69025
+ restored = null;
69026
+ }
69027
+ deps.clearSessionEffort?.();
69028
+ const cfgHtml = `\`${deps.escapeHtml(restoreLevel)}\``;
69029
+ if (restored?.ok) {
69030
+ return {
69031
+ text: `\u2705 Effort override cleared \u2014 back on the configured default ${cfgHtml}. It will also boot on ${cfgHtml} from now on.`,
69032
+ html: true
69033
+ };
69034
+ }
69035
+ return {
69036
+ 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.)`,
69037
+ html: true
69038
+ };
69039
+ }
68717
69040
  if (parsed.kind === "show") {
68718
69041
  const configured = deps.getConfiguredEffort();
68719
69042
  const shown = configured && configured.length > 0 ? configured : "low";
69043
+ const override = deps.getSessionEffort?.() ?? null;
68720
69044
  return {
68721
69045
  text: [
68722
69046
  `**Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}**`,
68723
- `Configured default: \`${deps.escapeHtml(shown)}\``,
69047
+ `Configured default: \`${deps.escapeHtml(shown)}\`${override ? ` \u00b7 session override: \`${deps.escapeHtml(override)}\`` : ""}`,
68724
69048
  `Switch the live session: ${EFFORT_LEVELS.map((l) => `\`/effort ${l}\``).join(" \u00b7 ")}`,
68725
69049
  PERSIST_NOTE2
68726
69050
  ].join(`
@@ -68776,7 +69100,7 @@ function menuKeyboard(highlight) {
68776
69100
  }
68777
69101
  function buildEffortMenu(deps, highlight) {
68778
69102
  const configured = deps.getConfiguredEffort() || "low";
68779
- const live = highlight ?? configured;
69103
+ const live = highlight ?? deps.getSessionEffort?.() ?? configured;
68780
69104
  return {
68781
69105
  text: [
68782
69106
  `**Effort \u2014 ${deps.escapeHtml(deps.getAgentName())}**`,
@@ -68823,6 +69147,122 @@ ${menu.text}` },
68823
69147
  };
68824
69148
  }
68825
69149
 
69150
+ // gateway/pending-session-command.ts
69151
+ function createPendingSessionCommandSlots() {
69152
+ const slots = new Map;
69153
+ return {
69154
+ get: (kind) => slots.get(kind) ?? null,
69155
+ set(cmd) {
69156
+ const displaced = slots.get(cmd.kind) ?? null;
69157
+ slots.delete(cmd.kind);
69158
+ slots.set(cmd.kind, cmd);
69159
+ return displaced;
69160
+ },
69161
+ take(kind) {
69162
+ const cur = slots.get(kind) ?? null;
69163
+ slots.delete(kind);
69164
+ return cur;
69165
+ },
69166
+ takeAll() {
69167
+ const all2 = [...slots.values()];
69168
+ slots.clear();
69169
+ return all2;
69170
+ },
69171
+ list: () => [...slots.values()],
69172
+ clear() {
69173
+ slots.clear();
69174
+ },
69175
+ get size() {
69176
+ return slots.size;
69177
+ }
69178
+ };
69179
+ }
69180
+ async function drainTakenCommands(taken, io) {
69181
+ for (let i = 0;i < taken.length; i++) {
69182
+ const cmd = taken[i];
69183
+ if (io.restartPending()) {
69184
+ await io.editCard(cmd, io.resolveForRestartText(cmd));
69185
+ continue;
69186
+ }
69187
+ if (io.turnInFlight()) {
69188
+ for (const rest of taken.slice(i))
69189
+ io.reEnqueue(rest);
69190
+ return;
69191
+ }
69192
+ let body;
69193
+ try {
69194
+ body = await io.apply(cmd);
69195
+ } catch (err) {
69196
+ await io.editCard(cmd, io.failureText(cmd, err));
69197
+ continue;
69198
+ }
69199
+ if (io.isBusyRefusal(body)) {
69200
+ for (const rest of taken.slice(i))
69201
+ io.reEnqueue(rest);
69202
+ return;
69203
+ }
69204
+ await io.editCard(cmd, body);
69205
+ }
69206
+ }
69207
+ function drainCapDecision(queued, now, capMs, turnInFlight) {
69208
+ const overdue = queued.some((c) => now - c.requestedAt > capMs);
69209
+ if (!overdue)
69210
+ return "wait";
69211
+ return turnInFlight ? "defer-turn-in-flight" : "force";
69212
+ }
69213
+ var EFFORT_MENU_SELECT_PREFIX = "eff:s:";
69214
+ function classifyShutdownPersist(cmd) {
69215
+ if (cmd.kind === "effort") {
69216
+ const level = cmd.origin === "menu" && cmd.arg.startsWith(EFFORT_MENU_SELECT_PREFIX) ? cmd.arg.slice(EFFORT_MENU_SELECT_PREFIX.length) : cmd.arg;
69217
+ if (level === "default")
69218
+ return { persist: "clear-effort", arg: level };
69219
+ return { persist: "effort", arg: level };
69220
+ }
69221
+ if (cmd.origin === "menu")
69222
+ return { persist: null, arg: cmd.arg };
69223
+ if (cmd.arg === "default")
69224
+ return { persist: "clear-model", arg: cmd.arg };
69225
+ return { persist: "model", arg: cmd.arg };
69226
+ }
69227
+ function persistedText(cmd, escapeHtml5) {
69228
+ const noun = KIND_NOUN[cmd.kind];
69229
+ const isClear = cmd.arg === "default" || cmd.targetLabel === "default";
69230
+ if (isClear) {
69231
+ return `\uD83D\uDCBE The session is restarting \u2014 your ${noun} override was cleared as requested; the agent boots on the configured default.`;
69232
+ }
69233
+ return `\uD83D\uDCBE The session is restarting \u2014 your \`${escapeHtml5(cmd.targetLabel)}\` ${noun} choice is saved and applies as the agent boots.`;
69234
+ }
69235
+ function shutdownResolutionActions(slots, escapeHtml5) {
69236
+ return slots.takeAll().map((cmd) => resolveForRestart(cmd, escapeHtml5));
69237
+ }
69238
+ function resolveForRestart(cmd, escapeHtml5) {
69239
+ const { persist, arg } = classifyShutdownPersist(cmd);
69240
+ return {
69241
+ cmd,
69242
+ persist,
69243
+ arg,
69244
+ persistedText: persistedText(cmd, escapeHtml5),
69245
+ reissueText: restartSupersededText(cmd, escapeHtml5)
69246
+ };
69247
+ }
69248
+ var KIND_NOUN = {
69249
+ model: "model",
69250
+ effort: "effort"
69251
+ };
69252
+ var KIND_VERB = {
69253
+ model: "switch to",
69254
+ effort: "set effort to"
69255
+ };
69256
+ function ackText(kind, targetLabel, escapeHtml5) {
69257
+ return `\uD83D\uDCE5 Agent is mid-turn \u2014 I'll ${KIND_VERB[kind]} \`${escapeHtml5(targetLabel)}\` the moment this turn finishes.`;
69258
+ }
69259
+ function supersededText(displaced, next, escapeHtml5) {
69260
+ 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).`;
69261
+ }
69262
+ function restartSupersededText(cmd, escapeHtml5) {
69263
+ 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.`;
69264
+ }
69265
+
68826
69266
  // welcome-text.ts
68827
69267
  init_demo_mask();
68828
69268
  init_card_format();
@@ -68866,11 +69306,12 @@ function switchroomHelpText(agentName3) {
68866
69306
  `\`/deny [id]\` \u2014 deny pending tool permission`,
68867
69307
  `\`/pending\` \u2014 list pending permission prompts`,
68868
69308
  `\`/interrupt [name]\` \u2014 interrupt an agent turn`,
69309
+ `\`/stop\` \u2014 cancel my in-flight turn (bare "stop" works too)`,
68869
69310
  ``,
68870
69311
  `**Agents**`,
68871
69312
  `\`/agents\` \u2014 list all agents`,
68872
69313
  `\`/agentstart [name]\` \u2014 start an agent`,
68873
- `\`/stop [name]\` \u2014 stop an agent`,
69314
+ `\`/agentstop [name]\` \u2014 stop an agent's container`,
68874
69315
  `\`/logs [name] [lines]\` \u2014 show agent logs`,
68875
69316
  `\`/memory <query>\` \u2014 search agent memory`,
68876
69317
  ``,
@@ -74127,10 +74568,10 @@ function skillProposalKeyboard(id) {
74127
74568
 
74128
74569
  // ../src/self-improve/skill-proposals.ts
74129
74570
  import {
74130
- closeSync as closeSync4,
74571
+ closeSync as closeSync5,
74131
74572
  existsSync as existsSync33,
74132
74573
  mkdirSync as mkdirSync26,
74133
- openSync as openSync4,
74574
+ openSync as openSync5,
74134
74575
  readFileSync as readFileSync30,
74135
74576
  writeSync as writeSync4
74136
74577
  } from "node:fs";
@@ -74152,12 +74593,12 @@ function ensureDir3(stateDir) {
74152
74593
  }
74153
74594
  }
74154
74595
  function appendLine2(path2, obj) {
74155
- const fd = openSync4(path2, "a");
74596
+ const fd = openSync5(path2, "a");
74156
74597
  try {
74157
74598
  writeSync4(fd, JSON.stringify(obj) + `
74158
74599
  `);
74159
74600
  } finally {
74160
- closeSync4(fd);
74601
+ closeSync5(fd);
74161
74602
  }
74162
74603
  }
74163
74604
  function readLines2(path2, isValid2) {
@@ -75582,7 +76023,7 @@ var ADMIN_COMMAND_NAMES = new Set([
75582
76023
  "agents",
75583
76024
  "logs",
75584
76025
  "restart",
75585
- "stop",
76026
+ "agentstop",
75586
76027
  "agentstart",
75587
76028
  "update",
75588
76029
  "reconcile",
@@ -75629,10 +76070,10 @@ function classifyAdminGate(text4, myAgentName) {
75629
76070
  // subagent-watcher.ts
75630
76071
  import {
75631
76072
  existsSync as existsSync35,
75632
- openSync as openSync6,
75633
- readSync,
76073
+ openSync as openSync7,
76074
+ readSync as readSync2,
75634
76075
  statSync as statSync10,
75635
- closeSync as closeSync6,
76076
+ closeSync as closeSync7,
75636
76077
  watch,
75637
76078
  readdirSync as readdirSync6,
75638
76079
  readFileSync as readFileSync36
@@ -76056,12 +76497,13 @@ function recordSubagentStall(db2, args) {
76056
76497
  }
76057
76498
  function reapStuckRunningRows(db2, args) {
76058
76499
  const cutoff = args.now - args.ttlMs;
76059
- const candidates = db2.prepare(`
76060
- SELECT id FROM subagents
76500
+ const rows = db2.prepare(`
76501
+ SELECT id, jsonl_agent_id FROM subagents
76061
76502
  WHERE status = 'running'
76062
76503
  AND background = 1
76063
76504
  AND COALESCE(last_activity_at, started_at) < ?
76064
76505
  `).all(cutoff);
76506
+ const candidates = args.isLive ? rows.filter((r) => !args.isLive(r.jsonl_agent_id)) : rows;
76065
76507
  for (const row of candidates) {
76066
76508
  recordSubagentStall(db2, {
76067
76509
  id: row.id,
@@ -76116,10 +76558,10 @@ function recordNestedSubagentDispatch(db2, args) {
76116
76558
 
76117
76559
  // gateway/turn-active-marker.ts
76118
76560
  import {
76119
- closeSync as closeSync5,
76561
+ closeSync as closeSync6,
76120
76562
  existsSync as existsSync34,
76121
76563
  mkdirSync as mkdirSync27,
76122
- openSync as openSync5,
76564
+ openSync as openSync6,
76123
76565
  readFileSync as readFileSync35,
76124
76566
  statSync as statSync9,
76125
76567
  unlinkSync as unlinkSync15,
@@ -76137,8 +76579,8 @@ function touchTurnActiveMarker(stateDir) {
76137
76579
  utimesSync(path2, now, now);
76138
76580
  } catch {
76139
76581
  try {
76140
- const fd = openSync5(path2, "r+");
76141
- closeSync5(fd);
76582
+ const fd = openSync6(path2, "r+");
76583
+ closeSync6(fd);
76142
76584
  } catch {}
76143
76585
  }
76144
76586
  }
@@ -76148,7 +76590,10 @@ var DEFAULT_RESCAN_MS = 1000;
76148
76590
  var DEFAULT_STALL_THRESHOLD_MS = 60000;
76149
76591
  var DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS = 300000;
76150
76592
  var DEFAULT_SILENT_STALL_TERMINAL_MS = 300000;
76593
+ var DEFAULT_INFLIGHT_TERMINAL_CAP_MS = 45 * 60000;
76151
76594
  var LONG_RUNNING_TOOLS = new Set(["Bash"]);
76595
+ var MAX_RESURRECTIONS = 1;
76596
+ var FALSE_FINISH_TRACKER_CAP = 512;
76152
76597
  function isLongRunningTool(name) {
76153
76598
  return name != null && LONG_RUNNING_TOOLS.has(name);
76154
76599
  }
@@ -76403,6 +76848,9 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76403
76848
  entry.lastReplyText = ev.input.text;
76404
76849
  }
76405
76850
  entry.toolCount++;
76851
+ if (ev.toolUseId != null && ev.toolUseId !== "") {
76852
+ entry.inflightToolUseIds.add(ev.toolUseId);
76853
+ }
76406
76854
  entry.lastTool = {
76407
76855
  name: ev.toolName,
76408
76856
  sanitisedArg: sanitiseToolArg(ev.toolName, ev.input ?? {})
@@ -76431,6 +76879,9 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76431
76879
  }
76432
76880
  }
76433
76881
  } else if (ev.kind === "sub_agent_nested_spawn") {
76882
+ if (ev.toolUseId != null && ev.toolUseId.length > 0) {
76883
+ entry.inflightToolUseIds.add(ev.toolUseId);
76884
+ }
76434
76885
  if (db2 != null && ev.toolUseId != null && ev.toolUseId.length > 0) {
76435
76886
  try {
76436
76887
  const nestedInput = ev.input ?? {};
@@ -76454,7 +76905,12 @@ function readSubTail(entry, tail, now, onDescriptionUpdate, fs2, log, db2, paren
76454
76905
  fireNarrativeProgress();
76455
76906
  }
76456
76907
  entry.pendingNarrative = { text: ev.text };
76908
+ } else if (ev.kind === "sub_agent_tool_result") {
76909
+ if (ev.toolUseId != null && ev.toolUseId !== "") {
76910
+ entry.inflightToolUseIds.delete(ev.toolUseId);
76911
+ }
76457
76912
  } else if (ev.kind === "sub_agent_turn_end") {
76913
+ entry.inflightToolUseIds.clear();
76458
76914
  resolvePendingSubNarrative(null, undefined);
76459
76915
  if (entry.state === "running") {
76460
76916
  entry.state = "done";
@@ -76495,6 +76951,7 @@ function startSubagentWatcher(config) {
76495
76951
  const stallThresholdMs = config.stallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_MS") ?? DEFAULT_STALL_THRESHOLD_MS;
76496
76952
  const silentSynthesisStallThresholdMs = config.silentSynthesisStallThresholdMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_SILENT_SYNTH_STALL_MS") ?? DEFAULT_SILENT_SYNTHESIS_STALL_THRESHOLD_MS;
76497
76953
  const silentStallTerminalMs = config.silentStallTerminalMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_STALL_TERMINAL_MS") ?? DEFAULT_SILENT_STALL_TERMINAL_MS;
76954
+ const inflightTerminalCapMs = config.inflightTerminalCapMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_TERMINAL_CAP_MS") ?? DEFAULT_INFLIGHT_TERMINAL_CAP_MS;
76498
76955
  const inflightPromoteMaxAgeMs = config.inflightPromoteMaxAgeMs ?? parseEnvMs("SWITCHROOM_SUBAGENT_INFLIGHT_MAX_AGE_MS") ?? DEFAULT_INFLIGHT_PROMOTE_MAX_AGE_MS;
76499
76956
  const terminatedAgentIdsCap = config.terminatedAgentIdsCap ?? TERMINATED_AGENT_IDS_CAP;
76500
76957
  const bootPromoteEnabled = config.bootPromoteEnabled ?? process.env.SWITCHROOM_SUBAGENT_BOOT_PROMOTE !== "0";
@@ -76523,9 +76980,9 @@ function startSubagentWatcher(config) {
76523
76980
  existsSync: existsSync35,
76524
76981
  readdirSync: readdirSync6,
76525
76982
  statSync: statSync10,
76526
- openSync: openSync6,
76527
- closeSync: closeSync6,
76528
- readSync,
76983
+ openSync: openSync7,
76984
+ closeSync: closeSync7,
76985
+ readSync: readSync2,
76529
76986
  watch
76530
76987
  };
76531
76988
  const registry = new Map;
@@ -76535,6 +76992,7 @@ function startSubagentWatcher(config) {
76535
76992
  const pendingCloses = new Map;
76536
76993
  const historicalFiles = new Set;
76537
76994
  const terminatedAgentIds = new Set;
76995
+ const falseFinishTracker = new Map;
76538
76996
  let bootScanInProgress = true;
76539
76997
  let stopped = false;
76540
76998
  function registerAgent(filePath, agentId) {
@@ -76559,6 +77017,7 @@ function startSubagentWatcher(config) {
76559
77017
  lastResultText: "",
76560
77018
  lastProgressBucketIdx: null,
76561
77019
  lastTool: null,
77020
+ inflightToolUseIds: new Set,
76562
77021
  historical: isHistorical
76563
77022
  };
76564
77023
  registry.set(agentId, entry);
@@ -76739,6 +77198,93 @@ function startSubagentWatcher(config) {
76739
77198
  terminatedAgentIds.add(agentId);
76740
77199
  log?.(`subagent-watcher: cleaned up terminal agent ${agentId}`);
76741
77200
  }
77201
+ function recordFalseFinish(agentId, filePath, n) {
77202
+ let size = 0;
77203
+ try {
77204
+ size = fs2.statSync(filePath).size;
77205
+ } catch {}
77206
+ const prior = falseFinishTracker.get(agentId);
77207
+ if (prior == null && falseFinishTracker.size >= FALSE_FINISH_TRACKER_CAP) {
77208
+ const oldest = falseFinishTracker.keys().next().value;
77209
+ if (oldest != null)
77210
+ falseFinishTracker.delete(oldest);
77211
+ }
77212
+ falseFinishTracker.set(agentId, {
77213
+ filePath,
77214
+ synthesisedAt: n,
77215
+ sizeAtSynthesis: size,
77216
+ resurrectionCount: prior?.resurrectionCount ?? 0,
77217
+ resurrectedForThisFinish: false,
77218
+ lost: prior?.lost ?? false
77219
+ });
77220
+ }
77221
+ function resurrectAgent(agentId, filePath) {
77222
+ const pc = pendingCloses.get(agentId);
77223
+ if (pc != null) {
77224
+ clearT(pc);
77225
+ pendingCloses.delete(agentId);
77226
+ }
77227
+ terminatedAgentIds.delete(agentId);
77228
+ historicalFiles.delete(filePath);
77229
+ const existing = registry.get(agentId);
77230
+ if (existing != null) {
77231
+ existing.state = "running";
77232
+ existing.historical = false;
77233
+ existing.stallNotified = false;
77234
+ existing.stalledAt = null;
77235
+ existing.stallTerminalSynthesised = false;
77236
+ existing.completionNotified = false;
77237
+ existing.errored = false;
77238
+ existing.errorDetail = undefined;
77239
+ existing.lastActivityAt = nowFn();
77240
+ knownFiles.add(filePath);
77241
+ return;
77242
+ }
77243
+ knownFiles.add(filePath);
77244
+ registerAgent(filePath, agentId);
77245
+ }
77246
+ function checkResurrections() {
77247
+ const n = nowFn();
77248
+ for (const [agentId, rec] of falseFinishTracker) {
77249
+ if (rec.lost)
77250
+ continue;
77251
+ if (rec.resurrectedForThisFinish)
77252
+ continue;
77253
+ let size;
77254
+ try {
77255
+ size = fs2.statSync(rec.filePath).size;
77256
+ } catch {
77257
+ continue;
77258
+ }
77259
+ if (size <= rec.sizeAtSynthesis)
77260
+ continue;
77261
+ const entry = registry.get(agentId);
77262
+ const description = entry?.description ?? "sub-agent";
77263
+ if (rec.resurrectionCount >= MAX_RESURRECTIONS) {
77264
+ rec.lost = true;
77265
+ 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)`);
77266
+ if (config.onWorkerLost != null) {
77267
+ try {
77268
+ config.onWorkerLost(agentId, description);
77269
+ } catch (cbErr) {
77270
+ log?.(`subagent-watcher: onWorkerLost callback error ${agentId}: ${cbErr.message}`);
77271
+ }
77272
+ }
77273
+ continue;
77274
+ }
77275
+ rec.resurrectionCount++;
77276
+ rec.resurrectedForThisFinish = true;
77277
+ 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)`);
77278
+ if (config.onResurrect != null) {
77279
+ try {
77280
+ config.onResurrect(agentId, description);
77281
+ } catch (cbErr) {
77282
+ log?.(`subagent-watcher: onResurrect callback error ${agentId}: ${cbErr.message}`);
77283
+ }
77284
+ }
77285
+ resurrectAgent(agentId, rec.filePath);
77286
+ }
77287
+ }
76742
77288
  function checkStalls() {
76743
77289
  const n = nowFn();
76744
77290
  for (const entry of registry.values()) {
@@ -76786,11 +77332,20 @@ function startSubagentWatcher(config) {
76786
77332
  continue;
76787
77333
  if (n - entry.stalledAt < silentStallTerminalMs)
76788
77334
  continue;
77335
+ if (entry.inflightToolUseIds.size > 0) {
77336
+ const totalIdleMs = n - entry.lastActivityAt;
77337
+ if (totalIdleMs < inflightTerminalCapMs) {
77338
+ 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)`);
77339
+ continue;
77340
+ }
77341
+ 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`);
77342
+ }
76789
77343
  entry.stallTerminalSynthesised = true;
76790
77344
  entry.state = "done";
76791
77345
  const postStallSec = Math.floor((n - entry.stalledAt) / 1000);
76792
77346
  const totalIdleSec = Math.floor((n - entry.lastActivityAt) / 1000);
76793
77347
  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`);
77348
+ recordFalseFinish(entry.agentId, entry.filePath, n);
76794
77349
  if (db2 != null) {
76795
77350
  try {
76796
77351
  const rowRef = db2.prepare("SELECT id FROM subagents WHERE jsonl_agent_id = ?").get(entry.agentId);
@@ -76947,6 +77502,7 @@ function startSubagentWatcher(config) {
76947
77502
  maybySendStateTransition(agentId);
76948
77503
  }
76949
77504
  checkStalls();
77505
+ checkResurrections();
76950
77506
  }
76951
77507
  rescanSubagentDirs();
76952
77508
  bootScanInProgress = false;
@@ -76954,7 +77510,16 @@ function startSubagentWatcher(config) {
76954
77510
  if (db2 == null)
76955
77511
  return;
76956
77512
  try {
76957
- const result = reapStuckRunningRows(db2, { ttlMs: reaperTtlMs, now: nowFn() });
77513
+ const result = reapStuckRunningRows(db2, {
77514
+ ttlMs: reaperTtlMs,
77515
+ now: nowFn(),
77516
+ isLive: (jsonlAgentId) => {
77517
+ if (jsonlAgentId == null)
77518
+ return false;
77519
+ const entry = registry.get(jsonlAgentId);
77520
+ return entry != null && entry.state === "running" && !entry.historical;
77521
+ }
77522
+ });
76958
77523
  if (result.reaped > 0) {
76959
77524
  log?.(`subagent-watcher: reaper transitioned ${result.reaped} stuck-running row(s) to stalled (ttl=${Math.round(reaperTtlMs / 60000)}min)`);
76960
77525
  }
@@ -77125,7 +77690,7 @@ function determineRestartReason(opts) {
77125
77690
  init_boot_card();
77126
77691
 
77127
77692
  // gateway/update-announce.ts
77128
- import { existsSync as existsSync41, mkdirSync as mkdirSync32, openSync as openSync7, closeSync as closeSync7, readFileSync as readFileSync43 } from "node:fs";
77693
+ import { existsSync as existsSync41, mkdirSync as mkdirSync32, openSync as openSync8, closeSync as closeSync8, readFileSync as readFileSync43 } from "node:fs";
77129
77694
  import { join as join44 } from "node:path";
77130
77695
  import { homedir as homedir15 } from "node:os";
77131
77696
 
@@ -77326,8 +77891,8 @@ function claimUpdateAnnouncement(requestId, opts = {}) {
77326
77891
  const safeId = requestId.replace(/[^A-Za-z0-9_.-]/g, "_").slice(0, 200);
77327
77892
  const path2 = join44(dir, safeId);
77328
77893
  try {
77329
- const fd = openSync7(path2, "wx");
77330
- closeSync7(fd);
77894
+ const fd = openSync8(path2, "wx");
77895
+ closeSync8(fd);
77331
77896
  return true;
77332
77897
  } catch {
77333
77898
  return false;
@@ -77550,10 +78115,10 @@ import { join as join46 } from "node:path";
77550
78115
 
77551
78116
  // ../src/issues/store.ts
77552
78117
  import {
77553
- closeSync as closeSync8,
78118
+ closeSync as closeSync9,
77554
78119
  existsSync as existsSync42,
77555
78120
  mkdirSync as mkdirSync33,
77556
- openSync as openSync8,
78121
+ openSync as openSync9,
77557
78122
  readdirSync as readdirSync9,
77558
78123
  readFileSync as readFileSync45,
77559
78124
  renameSync as renameSync15,
@@ -77675,7 +78240,7 @@ function withLock(stateDir, fn) {
77675
78240
  let fd = null;
77676
78241
  while (fd === null) {
77677
78242
  try {
77678
- fd = openSync8(lockPath, "wx");
78243
+ fd = openSync9(lockPath, "wx");
77679
78244
  try {
77680
78245
  writeSync5(fd, String(process.pid));
77681
78246
  } catch {}
@@ -77695,7 +78260,7 @@ function withLock(stateDir, fn) {
77695
78260
  return fn();
77696
78261
  } finally {
77697
78262
  try {
77698
- closeSync8(fd);
78263
+ closeSync9(fd);
77699
78264
  } catch {}
77700
78265
  try {
77701
78266
  unlinkSync17(lockPath);
@@ -79117,6 +79682,40 @@ function resolveQuotaWatchTuning(env) {
79117
79682
  function buildQuotaClaimKey(accountLabel, transition2, chatId) {
79118
79683
  return `quota-watch:${accountLabel}:${transition2}:${chatId}`;
79119
79684
  }
79685
+ var FLEET_ROLL_ANNOUNCE_KEY = "__fleet_roll_announce__";
79686
+ var FLEET_ROLL_MAX_AGE_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
79687
+ var QUOTA_WATCH_ROLL_DEDUP_MS = QUOTA_WATCH_CLAIM_WINDOW_MS;
79688
+ function evaluateFleetRollAnnounce(args) {
79689
+ const { roll, prev, now } = args;
79690
+ const maxAgeMs = args.maxAgeMs ?? FLEET_ROLL_MAX_AGE_MS;
79691
+ if (!roll)
79692
+ return { kind: "skip", reason: "no-roll" };
79693
+ if (prev.lastNotifiedAt >= roll.at)
79694
+ return { kind: "skip", reason: "already-announced" };
79695
+ if (now - roll.at > maxAgeMs)
79696
+ return { kind: "skip", reason: "stale-roll" };
79697
+ return {
79698
+ kind: "notify",
79699
+ message: buildFleetRollMessage(roll, now),
79700
+ newState: { lastNotifiedHealth: "healthy", lastNotifiedAt: roll.at }
79701
+ };
79702
+ }
79703
+ function buildFleetRollMessage(roll, now) {
79704
+ const winLabel = roll.window === "5h" ? "5-hour window" : roll.window === "7d" ? "7-day window" : "quota window";
79705
+ const pctPart = typeof roll.pct === "number" ? ` at ${fmtPct(roll.pct)}` : "";
79706
+ const resetPart = typeof roll.exhausted_until === "number" && roll.exhausted_until > now ? ` (resets ${formatRelative(new Date(roll.exhausted_until), new Date(now))})` : "";
79707
+ const softAvoid = roll.reason === "soft-avoid";
79708
+ 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}.`;
79709
+ return [
79710
+ `\uD83D\uDD01 **Switched fleet to \`${codeSpanSafe(roll.to)}\`**`,
79711
+ ``,
79712
+ causeLine,
79713
+ `Work continues uninterrupted \u2014 agents and scheduled jobs now serve from \`${codeSpanSafe(roll.to)}\`.`,
79714
+ ``,
79715
+ `_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"}._`
79716
+ ].join(`
79717
+ `);
79718
+ }
79120
79719
  function isLiveCorroboration(entry) {
79121
79720
  return entry?.result.ok === true && entry.served === "live";
79122
79721
  }
@@ -79142,6 +79741,16 @@ function evaluateQuotaWatchAccount(args) {
79142
79741
  lastNotifiedHealth: "throttling",
79143
79742
  lastNotifiedAt: now
79144
79743
  };
79744
+ const lastRoll = args.lastRoll;
79745
+ if (lastRoll && lastRoll.from === label && now - lastRoll.at <= QUOTA_WATCH_ROLL_DEDUP_MS && prev.lastNotifiedAt < lastRoll.at) {
79746
+ return {
79747
+ kind: "reconcile",
79748
+ accountLabel: label,
79749
+ newAccountState: newState,
79750
+ transition: "entered-throttling",
79751
+ reason: "roll-announced"
79752
+ };
79753
+ }
79145
79754
  return {
79146
79755
  kind: "notify",
79147
79756
  accountLabel: label,
@@ -79255,7 +79864,7 @@ function buildThrottlingMessage(agentName3, snap) {
79255
79864
  const activeNote = snap.isActive ? "" : `
79256
79865
  This is a non-active account. Consider \`/auth use ${codeSpanSafe(snap.label)}\` to switch, or keep it as a fallback reserve.`;
79257
79866
  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}.` : "";
79867
+ 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
79868
  return [
79260
79869
  `\uD83D\uDFE1 **Quota approaching limit** \u2014 \`${codeSpanSafe(snap.label)}\``,
79261
79870
  ``,
@@ -79313,15 +79922,50 @@ function patchQuotaWatchState(current, accountLabel, accountState) {
79313
79922
  return { ...current, [accountLabel]: accountState };
79314
79923
  }
79315
79924
 
79925
+ // fallback-card-collapse.ts
79926
+ var CARD_COLLAPSE_MAX_AGE_MS = 5 * 60000;
79927
+ function createModelUnavailableCardRegistry(maxAgeMs = CARD_COLLAPSE_MAX_AGE_MS) {
79928
+ const cards = new Map;
79929
+ return {
79930
+ record(chatId, rec) {
79931
+ cards.set(chatId, rec);
79932
+ },
79933
+ take(chatId, now) {
79934
+ const rec = cards.get(chatId);
79935
+ if (!rec)
79936
+ return null;
79937
+ cards.delete(chatId);
79938
+ if (!rec.promisedFallback)
79939
+ return null;
79940
+ if (now - rec.atMs > maxAgeMs)
79941
+ return null;
79942
+ return rec;
79943
+ },
79944
+ size() {
79945
+ return cards.size;
79946
+ }
79947
+ };
79948
+ }
79949
+ function decideAnnouncementDelivery(outcomeKind, card) {
79950
+ if (outcomeKind !== "switched")
79951
+ return "send";
79952
+ return card !== null ? "edit" : "send";
79953
+ }
79954
+ function foldAnnouncementIntoCard(cardText, announcement) {
79955
+ return `${cardText}
79956
+
79957
+ ${announcement}`;
79958
+ }
79959
+
79316
79960
  // gateway/gateway.ts
79317
79961
  init_auth_snapshot_format2();
79318
79962
 
79319
79963
  // gateway/turn-active-marker.ts
79320
79964
  import {
79321
- closeSync as closeSync9,
79965
+ closeSync as closeSync10,
79322
79966
  existsSync as existsSync46,
79323
79967
  mkdirSync as mkdirSync36,
79324
- openSync as openSync9,
79968
+ openSync as openSync10,
79325
79969
  readFileSync as readFileSync49,
79326
79970
  statSync as statSync13,
79327
79971
  unlinkSync as unlinkSync18,
@@ -79346,8 +79990,8 @@ function touchTurnActiveMarker2(stateDir) {
79346
79990
  utimesSync2(path2, now, now);
79347
79991
  } catch {
79348
79992
  try {
79349
- const fd = openSync9(path2, "r+");
79350
- closeSync9(fd);
79993
+ const fd = openSync10(path2, "r+");
79994
+ closeSync10(fd);
79351
79995
  } catch {}
79352
79996
  }
79353
79997
  }
@@ -79398,10 +80042,10 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
79398
80042
  }
79399
80043
 
79400
80044
  // ../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;
80045
+ var VERSION = "0.18.11";
80046
+ var COMMIT_SHA = "8fbd9574";
80047
+ var COMMIT_DATE = "2026-07-11T05:08:14Z";
80048
+ var LATEST_PR = 3053;
79405
80049
  var COMMITS_AHEAD_OF_TAG = 0;
79406
80050
 
79407
80051
  // gateway/boot-version.ts
@@ -80616,6 +81260,13 @@ When the restart hit, ${count} sub-agent${count === 1 ? " was" : "s were"} still
80616
81260
  ` + lines.join(`
80617
81261
  `) + overflow + closing;
80618
81262
  }
81263
+ function renderRestartCauseBlock(cause) {
81264
+ if (!cause || cause.note.trim().length === 0)
81265
+ return "";
81266
+ return `
81267
+
81268
+ Why this restart happened: ${cause.note.trim()}`;
81269
+ }
80619
81270
  var RESUME_SYNTHETIC_PROMPT_PREFIX = "You just restarted.";
80620
81271
  function isResumeSyntheticTurn(turn) {
80621
81272
  const p = turn.user_prompt_preview;
@@ -80648,6 +81299,8 @@ function buildResumeInterruptedInbound(ctx) {
80648
81299
  };
80649
81300
  if (ctx.turn.user_prompt_preview)
80650
81301
  meta.original_prompt = ctx.turn.user_prompt_preview;
81302
+ if (ctx.restartCause)
81303
+ meta.restart_cause = ctx.restartCause.reason;
80651
81304
  return {
80652
81305
  type: "inbound",
80653
81306
  chatId: ctx.turn.chat_id,
@@ -80656,7 +81309,7 @@ function buildResumeInterruptedInbound(ctx) {
80656
81309
  user: "switchroom",
80657
81310
  userId: 0,
80658
81311
  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),
81312
+ 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
81313
  meta
80661
81314
  };
80662
81315
  }
@@ -80679,6 +81332,8 @@ function buildResumeWatchdogReportInbound(ctx) {
80679
81332
  meta.tool_call_count = String(ctx.turn.tool_call_count);
80680
81333
  if (ctx.turn.user_prompt_preview)
80681
81334
  meta.original_prompt = ctx.turn.user_prompt_preview;
81335
+ if (ctx.restartCause)
81336
+ meta.restart_cause = ctx.restartCause.reason;
80682
81337
  return {
80683
81338
  type: "inbound",
80684
81339
  chatId: ctx.turn.chat_id,
@@ -80687,7 +81342,7 @@ function buildResumeWatchdogReportInbound(ctx) {
80687
81342
  user: "switchroom",
80688
81343
  userId: 0,
80689
81344
  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 }),
81345
+ 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
81346
  meta
80692
81347
  };
80693
81348
  }
@@ -80716,6 +81371,8 @@ function buildResumeDeferredReportInbound(ctx) {
80716
81371
  };
80717
81372
  if (ctx.turn.user_prompt_preview)
80718
81373
  meta.original_prompt = ctx.turn.user_prompt_preview;
81374
+ if (ctx.restartCause)
81375
+ meta.restart_cause = ctx.restartCause.reason;
80719
81376
  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
81377
  return {
80721
81378
  type: "inbound",
@@ -80725,7 +81382,7 @@ function buildResumeDeferredReportInbound(ctx) {
80725
81382
  user: "switchroom",
80726
81383
  userId: 0,
80727
81384
  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 }),
81385
+ 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
81386
  meta
80730
81387
  };
80731
81388
  }
@@ -80745,6 +81402,282 @@ function selectResumeBuilder(endedVia, opts) {
80745
81402
  return kind;
80746
81403
  }
80747
81404
 
81405
+ // gateway/bridge-dead-watchdog.ts
81406
+ import { readFileSync as readFileSync51, writeFileSync as writeFileSync40, renameSync as renameSync16, unlinkSync as unlinkSync19 } from "node:fs";
81407
+
81408
+ // gateway/cron-session.ts
81409
+ var CRON_IDENTITY_SUFFIX2 = "-cron";
81410
+ function isCronIdentity2(name) {
81411
+ return typeof name === "string" && name.endsWith(CRON_IDENTITY_SUFFIX2);
81412
+ }
81413
+
81414
+ // gateway/bridge-dead-watchdog.ts
81415
+ var BRIDGE_DEAD_RESTART_REASON = "bridge-dead-resume";
81416
+ var DEFAULT_BRIDGE_DEAD_GRACE_MS = 90000;
81417
+ var ESCALATION_MARKER_MAX_AGE_MS = 10 * 60000;
81418
+ var MAX_CONSECUTIVE_ESCALATIONS = 2;
81419
+ var CRASH_LOG_FRESH_WINDOW_MS = 15 * 60000;
81420
+ var CRASH_LOG_TAIL_LINES = 3;
81421
+ function decideBridgeDeadEscalation(s) {
81422
+ if (s.bridgeRegistered)
81423
+ return { action: "skip", why: "bridge-registered" };
81424
+ if (s.shuttingDown)
81425
+ return { action: "skip", why: "shutting-down" };
81426
+ if (s.alreadyEscalated)
81427
+ return { action: "skip", why: "already-escalated" };
81428
+ if (!s.bridgeEverRegistered && s.priorStreak >= s.maxConsecutive) {
81429
+ return { action: "skip", why: "streak-capped" };
81430
+ }
81431
+ if (!s.sessionAlive)
81432
+ return { action: "retry", why: "session-not-alive" };
81433
+ return { action: "escalate" };
81434
+ }
81435
+ function readFreshCrashLogTail(path2, opts = {}) {
81436
+ const nowMs2 = opts.nowMs ?? Date.now();
81437
+ const freshWindowMs = opts.freshWindowMs ?? CRASH_LOG_FRESH_WINDOW_MS;
81438
+ const maxLines = opts.maxLines ?? CRASH_LOG_TAIL_LINES;
81439
+ const readFile = opts.readFile ?? ((p) => readFileSync51(p, "utf8"));
81440
+ let raw;
81441
+ try {
81442
+ raw = readFile(path2);
81443
+ } catch {
81444
+ return null;
81445
+ }
81446
+ const fresh = raw.split(`
81447
+ `).filter((line) => {
81448
+ const m = line.match(/^(\d{4}-\d{2}-\d{2}T[0-9:.]+Z)\s/);
81449
+ if (!m)
81450
+ return false;
81451
+ const t = Date.parse(m[1]);
81452
+ return Number.isFinite(t) && nowMs2 - t >= 0 && nowMs2 - t <= freshWindowMs;
81453
+ }).slice(-maxLines);
81454
+ if (fresh.length === 0)
81455
+ return null;
81456
+ return fresh.map((l) => l.slice(0, 500)).join(" || ");
81457
+ }
81458
+ function writeBridgeDeadEscalationMarker(path2, marker) {
81459
+ const tmp = `${path2}.tmp-${process.pid}-${Date.now()}`;
81460
+ writeFileSync40(tmp, JSON.stringify(marker), "utf8");
81461
+ renameSync16(tmp, path2);
81462
+ }
81463
+ function consumeBridgeDeadEscalationMarker(path2, nowMs2 = Date.now(), maxAgeMs = ESCALATION_MARKER_MAX_AGE_MS) {
81464
+ let marker = null;
81465
+ try {
81466
+ const parsed = JSON.parse(readFileSync51(path2, "utf8"));
81467
+ if (typeof parsed.ts === "number" && Number.isFinite(parsed.ts) && typeof parsed.reason === "string") {
81468
+ const age = nowMs2 - parsed.ts;
81469
+ if (age >= 0 && age < maxAgeMs) {
81470
+ marker = { ts: parsed.ts, reason: parsed.reason };
81471
+ marker.count = typeof parsed.count === "number" && Number.isFinite(parsed.count) && parsed.count >= 1 ? Math.floor(parsed.count) : 1;
81472
+ if (typeof parsed.crashTail === "string")
81473
+ marker.crashTail = parsed.crashTail;
81474
+ }
81475
+ }
81476
+ } catch {}
81477
+ try {
81478
+ unlinkSync19(path2);
81479
+ } catch {}
81480
+ return marker;
81481
+ }
81482
+ function buildBridgeDeadIdleNoticeInbound(args) {
81483
+ const ts = args.nowMs ?? Date.now();
81484
+ return {
81485
+ type: "inbound",
81486
+ chatId: args.chatId,
81487
+ messageId: ts,
81488
+ user: "switchroom",
81489
+ userId: 0,
81490
+ ts,
81491
+ 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.`,
81492
+ meta: {
81493
+ source: "bridge_dead_restart",
81494
+ chat_id: args.chatId,
81495
+ message_id: String(ts),
81496
+ restart_cause: args.marker.reason
81497
+ }
81498
+ };
81499
+ }
81500
+ function isRealBridgeIdentity(agentName3) {
81501
+ return agentName3 != null && agentName3.length > 0 && !isCronIdentity2(agentName3);
81502
+ }
81503
+ function createBridgeDeadWatchdog(opts) {
81504
+ const setTimer = opts.setTimer ?? ((fn, ms) => {
81505
+ const t = setTimeout(fn, ms);
81506
+ t.unref?.();
81507
+ return t;
81508
+ });
81509
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
81510
+ const nowMs2 = opts.nowMs ?? (() => Date.now());
81511
+ const writeMarker = opts.writeMarker ?? writeBridgeDeadEscalationMarker;
81512
+ const readCrashTail = opts.readCrashTail ?? ((p, t) => readFreshCrashLogTail(p, { nowMs: t }));
81513
+ const priorStreak = opts.priorStreak ?? 0;
81514
+ const maxConsecutive = opts.maxConsecutive ?? MAX_CONSECUTIVE_ESCALATIONS;
81515
+ let timer3 = null;
81516
+ let bridgeRegistered = false;
81517
+ let bridgeEverRegistered = false;
81518
+ let escalated = false;
81519
+ const cancel = () => {
81520
+ if (timer3 != null) {
81521
+ clearTimer(timer3);
81522
+ timer3 = null;
81523
+ }
81524
+ };
81525
+ const effectiveGraceMs = () => !bridgeEverRegistered && priorStreak >= 1 ? opts.graceMs * 2 : opts.graceMs;
81526
+ const armInternal = () => {
81527
+ if (escalated)
81528
+ return;
81529
+ if (!bridgeEverRegistered && priorStreak >= maxConsecutive) {
81530
+ 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.`);
81531
+ return;
81532
+ }
81533
+ cancel();
81534
+ timer3 = setTimer(() => {
81535
+ timer3 = null;
81536
+ check();
81537
+ }, effectiveGraceMs());
81538
+ };
81539
+ const check = () => {
81540
+ const decision = decideBridgeDeadEscalation({
81541
+ bridgeRegistered,
81542
+ bridgeEverRegistered,
81543
+ sessionAlive: opts.isSessionAlive(),
81544
+ shuttingDown: opts.isShuttingDown(),
81545
+ alreadyEscalated: escalated,
81546
+ priorStreak,
81547
+ maxConsecutive
81548
+ });
81549
+ if (decision.action === "retry") {
81550
+ opts.log(`telegram gateway: [bridge-dead-watchdog] no bridge registered after ${effectiveGraceMs()}ms ` + `but claude session not found \u2014 re-arming (no escalation)`);
81551
+ armInternal();
81552
+ return decision;
81553
+ }
81554
+ if (decision.action === "skip") {
81555
+ if (decision.why !== "bridge-registered") {
81556
+ opts.log(`telegram gateway: [bridge-dead-watchdog] bridge missing but skipping escalation (${decision.why})`);
81557
+ }
81558
+ return decision;
81559
+ }
81560
+ escalated = true;
81561
+ const t = nowMs2();
81562
+ const crashTail = (() => {
81563
+ try {
81564
+ return readCrashTail(opts.crashLogPath, t);
81565
+ } catch {
81566
+ return null;
81567
+ }
81568
+ })();
81569
+ const streakCount = bridgeEverRegistered ? 1 : priorStreak + 1;
81570
+ try {
81571
+ const marker = {
81572
+ ts: t,
81573
+ reason: BRIDGE_DEAD_RESTART_REASON,
81574
+ count: streakCount
81575
+ };
81576
+ if (crashTail != null)
81577
+ marker.crashTail = crashTail;
81578
+ writeMarker(opts.markerPath, marker);
81579
+ } catch (err) {
81580
+ opts.log(`telegram gateway: [bridge-dead-watchdog] escalation marker write failed: ${err.message}`);
81581
+ }
81582
+ 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`));
81583
+ const fired = opts.escalate(BRIDGE_DEAD_RESTART_REASON);
81584
+ if (!fired) {
81585
+ opts.log(`telegram gateway: [bridge-dead-watchdog] escalate() reported failure \u2014 ` + `restart not dispatched (fuse stays blown; no retry this boot)`);
81586
+ }
81587
+ return decision;
81588
+ };
81589
+ return {
81590
+ arm: armInternal,
81591
+ noteBridgeRegistered: (agentName3) => {
81592
+ if (!isRealBridgeIdentity(agentName3))
81593
+ return;
81594
+ bridgeRegistered = true;
81595
+ bridgeEverRegistered = true;
81596
+ cancel();
81597
+ },
81598
+ noteBridgeDisconnected: (agentName3) => {
81599
+ if (!isRealBridgeIdentity(agentName3))
81600
+ return;
81601
+ bridgeRegistered = false;
81602
+ armInternal();
81603
+ },
81604
+ check,
81605
+ stop: cancel,
81606
+ hasEscalated: () => escalated
81607
+ };
81608
+ }
81609
+
81610
+ // gateway/boot-probes.ts
81611
+ import { readFileSync as readFileSync52, readdirSync as readdirSync10, existsSync as existsSync48 } from "fs";
81612
+ init_quota_cache();
81613
+ init_quota_check();
81614
+ import { execFile as execFileCb2 } from "child_process";
81615
+ import { promisify as promisify2 } from "util";
81616
+ var execFile2 = promisify2(execFileCb2);
81617
+ var realProcFs2 = {
81618
+ readdir: (p) => readdirSync10(p),
81619
+ readFile: (p) => readFileSync52(p, "utf-8")
81620
+ };
81621
+ function findAgentProcessInContainer2(fs3 = realProcFs2) {
81622
+ let entries;
81623
+ try {
81624
+ entries = fs3.readdir("/proc");
81625
+ } catch {
81626
+ return null;
81627
+ }
81628
+ const candidates = [];
81629
+ for (const entry of entries) {
81630
+ if (!/^\d+$/.test(entry))
81631
+ continue;
81632
+ const pid = Number(entry);
81633
+ if (!Number.isFinite(pid) || pid <= 0)
81634
+ continue;
81635
+ if (pid === process.pid)
81636
+ continue;
81637
+ let comm = "";
81638
+ try {
81639
+ comm = fs3.readFile(`/proc/${pid}/comm`).trim();
81640
+ } catch {
81641
+ continue;
81642
+ }
81643
+ let rssKb = 0;
81644
+ try {
81645
+ const status = fs3.readFile(`/proc/${pid}/status`);
81646
+ const m = status.match(/^VmRSS:\s+(\d+)/m);
81647
+ if (m)
81648
+ rssKb = parseInt(m[1], 10) || 0;
81649
+ } catch {
81650
+ continue;
81651
+ }
81652
+ let starttime = 0;
81653
+ try {
81654
+ const stat = fs3.readFile(`/proc/${pid}/stat`);
81655
+ const close = stat.lastIndexOf(")");
81656
+ const tail = close >= 0 ? stat.slice(close + 2) : stat;
81657
+ const fields = tail.trim().split(/\s+/);
81658
+ const st = Number(fields[19]);
81659
+ if (Number.isFinite(st) && st > 0)
81660
+ starttime = st;
81661
+ } catch {
81662
+ continue;
81663
+ }
81664
+ candidates.push({ pid, rssKb, comm, starttime });
81665
+ }
81666
+ if (candidates.length === 0)
81667
+ return null;
81668
+ const isAgent = (c) => c.comm === "claude";
81669
+ 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";
81670
+ const claudeMatches = candidates.filter(isAgent);
81671
+ if (claudeMatches.length > 0) {
81672
+ claudeMatches.sort((a, b) => b.rssKb - a.rssKb);
81673
+ return claudeMatches[0];
81674
+ }
81675
+ const nodeMatches = candidates.filter((c) => c.comm === "node" && !isWrapper(c)).sort((a, b) => b.rssKb - a.rssKb);
81676
+ if (nodeMatches.length > 0)
81677
+ return nodeMatches[0];
81678
+ return null;
81679
+ }
81680
+
80748
81681
  // registry/subagents-schema.ts
80749
81682
  var SUBAGENTS_SCHEMA_SQL = `
80750
81683
  CREATE TABLE IF NOT EXISTS subagents (
@@ -80900,7 +81833,7 @@ function alwaysAllowDrainDeps() {
80900
81833
  return {
80901
81834
  readConfigText: () => {
80902
81835
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
80903
- return readFileSync51(cfgPath, "utf8");
81836
+ return readFileSync53(cfgPath, "utf8");
80904
81837
  },
80905
81838
  resolveAllowList: (_configText, agentName3) => {
80906
81839
  const cfg = loadConfig2();
@@ -81011,6 +81944,13 @@ function triggerSelfRestart(targetAgent, reason, delayMs = 300) {
81011
81944
  return false;
81012
81945
  }
81013
81946
  }
81947
+ {
81948
+ const bootSmDir = resolveAgentDirFromEnv();
81949
+ if (bootSmDir != null && clearStaleGatewayShutdownIntent(bootSmDir)) {
81950
+ process.stderr.write(`telegram gateway: cleared stale gateway-shutdown relaunch-model intent (previous bounce was gateway-only \u2014 container never restarted)
81951
+ `);
81952
+ }
81953
+ }
81014
81954
  var cachedClaudeCliVersion = undefined;
81015
81955
  function getClaudeCliVersion() {
81016
81956
  if (cachedClaudeCliVersion !== undefined)
@@ -81040,7 +81980,7 @@ function formatBootVersion() {
81040
81980
  }
81041
81981
  try {
81042
81982
  chmodSync8(ENV_FILE, 384);
81043
- for (const line of readFileSync51(ENV_FILE, "utf8").split(`
81983
+ for (const line of readFileSync53(ENV_FILE, "utf8").split(`
81044
81984
  `)) {
81045
81985
  const m = line.match(/^(\w+)=(.*)$/);
81046
81986
  if (m && process.env[m[1]] === undefined)
@@ -81105,7 +82045,7 @@ bot.api.config.use(async (prev, method, payload, signal) => {
81105
82045
  });
81106
82046
  var GRAMMY_VERSION = (() => {
81107
82047
  try {
81108
- const raw = readFileSync51(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
82048
+ const raw = readFileSync53(new URL("../../node_modules/grammy/package.json", import.meta.url), "utf8");
81109
82049
  return JSON.parse(raw).version ?? "unknown";
81110
82050
  } catch {
81111
82051
  return "unknown";
@@ -81191,7 +82131,7 @@ function assertSendable(f) {
81191
82131
  }
81192
82132
  function readAccessFile() {
81193
82133
  try {
81194
- const raw = readFileSync51(ACCESS_FILE, "utf8");
82134
+ const raw = readFileSync53(ACCESS_FILE, "utf8");
81195
82135
  const parsed = JSON.parse(raw);
81196
82136
  const allowFrom = validateStringArray("allowFrom", parsed.allowFrom ?? []);
81197
82137
  const groups = {};
@@ -81229,7 +82169,7 @@ function readAccessFile() {
81229
82169
  if (err.code === "ENOENT")
81230
82170
  return defaultAccess();
81231
82171
  try {
81232
- renameSync16(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
82172
+ renameSync17(ACCESS_FILE, `${ACCESS_FILE}.corrupt-${Date.now()}`);
81233
82173
  } catch {}
81234
82174
  process.stderr.write(`telegram gateway: access.json is corrupt, moved aside. Starting fresh.
81235
82175
  `);
@@ -81251,7 +82191,7 @@ function loadAccess() {
81251
82191
  }
81252
82192
  function readPeopleFile() {
81253
82193
  try {
81254
- const raw = readFileSync51(PEOPLE_FILE, "utf8");
82194
+ const raw = readFileSync53(PEOPLE_FILE, "utf8");
81255
82195
  const parsed = JSON.parse(raw);
81256
82196
  if (!Array.isArray(parsed.entries))
81257
82197
  return [];
@@ -81275,9 +82215,9 @@ function saveAccess(a) {
81275
82215
  return;
81276
82216
  mkdirSync39(STATE_DIR, { recursive: true, mode: 448 });
81277
82217
  const tmp = ACCESS_FILE + ".tmp";
81278
- writeFileSync41(tmp, JSON.stringify(a, null, 2) + `
82218
+ writeFileSync42(tmp, JSON.stringify(a, null, 2) + `
81279
82219
  `, { mode: 384 });
81280
- renameSync16(tmp, ACCESS_FILE);
82220
+ renameSync17(tmp, ACCESS_FILE);
81281
82221
  }
81282
82222
  function pruneExpired(a) {
81283
82223
  const now = Date.now();
@@ -81304,6 +82244,7 @@ if (HISTORY_ENABLED) {
81304
82244
  }
81305
82245
  var turnsDb = null;
81306
82246
  var bootResumeInbound = null;
82247
+ var bridgeDeadPriorStreak = 0;
81307
82248
  try {
81308
82249
  const agentDir = STATE_DIR.endsWith("/telegram") ? STATE_DIR.slice(0, -"/telegram".length) : STATE_DIR;
81309
82250
  turnsDb = openTurnsDb(agentDir);
@@ -81312,11 +82253,11 @@ try {
81312
82253
  let markerAgeMs = null;
81313
82254
  try {
81314
82255
  const markerPath = join53(STATE_DIR, TURN_ACTIVE_MARKER_FILE2);
81315
- if (existsSync49(markerPath)) {
82256
+ if (existsSync50(markerPath)) {
81316
82257
  const st = statSync16(markerPath);
81317
82258
  markerAgeMs = Date.now() - st.mtimeMs;
81318
82259
  try {
81319
- const payload = JSON.parse(readFileSync51(markerPath, "utf8"));
82260
+ const payload = JSON.parse(readFileSync53(markerPath, "utf8"));
81320
82261
  if (typeof payload.turnKey === "string" && payload.turnKey.length > 0) {
81321
82262
  markerTurnKey = payload.turnKey;
81322
82263
  }
@@ -81339,6 +82280,16 @@ try {
81339
82280
  process.stderr.write(`telegram gateway: turn-registry initialized at ${join53(agentDir, "telegram", "registry.db")}
81340
82281
  `);
81341
82282
  }
82283
+ const bridgeDeadMarker = consumeBridgeDeadEscalationMarker(join53(STATE_DIR, "bridge-dead-escalation.json"));
82284
+ if (bridgeDeadMarker != null) {
82285
+ bridgeDeadPriorStreak = bridgeDeadMarker.count ?? 1;
82286
+ process.stderr.write(`telegram gateway: boot: prior restart was a bridge-dead escalation (reason=${bridgeDeadMarker.reason}, consecutive=${bridgeDeadPriorStreak}${bridgeDeadMarker.crashTail ? `, crashTail=${bridgeDeadMarker.crashTail}` : ""})
82287
+ `);
82288
+ }
82289
+ const bridgeDeadRestartCause = bridgeDeadMarker != null ? {
82290
+ reason: bridgeDeadMarker.reason,
82291
+ 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."
82292
+ } : undefined;
81342
82293
  const pending2 = findLatestTurnIfInterrupted(turnsDb);
81343
82294
  const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? "";
81344
82295
  if (pending2 != null && selfAgent) {
@@ -81370,7 +82321,11 @@ try {
81370
82321
  if (bootResumeKind === "resume") {
81371
82322
  bootResumeInbound = {
81372
82323
  agent: selfAgent,
81373
- msg: buildResumeInterruptedInbound({ turn: pending2, subagents: interruptedSubagents })
82324
+ msg: buildResumeInterruptedInbound({
82325
+ turn: pending2,
82326
+ subagents: interruptedSubagents,
82327
+ restartCause: bridgeDeadRestartCause
82328
+ })
81374
82329
  };
81375
82330
  } else if (bootResumeKind === "report") {
81376
82331
  let idleMs = pending2.turn_key === timeoutTurnKey && markerAgeMs != null ? markerAgeMs : null;
@@ -81385,7 +82340,12 @@ try {
81385
82340
  idleMs = Math.max(0, Date.now() - pending2.started_at);
81386
82341
  bootResumeInbound = {
81387
82342
  agent: selfAgent,
81388
- msg: buildResumeWatchdogReportInbound({ turn: pending2, idleMs, subagents: interruptedSubagents })
82343
+ msg: buildResumeWatchdogReportInbound({
82344
+ turn: pending2,
82345
+ idleMs,
82346
+ subagents: interruptedSubagents,
82347
+ restartCause: bridgeDeadRestartCause
82348
+ })
81389
82349
  };
81390
82350
  } else if (bootResumeKind === "defer-loop" || bootResumeKind === "defer-suppressed") {
81391
82351
  bootResumeInbound = {
@@ -81393,7 +82353,8 @@ try {
81393
82353
  msg: buildResumeDeferredReportInbound({
81394
82354
  turn: pending2,
81395
82355
  reason: bootResumeKind === "defer-loop" ? "loop-guard" : "clean-restart-suppressed",
81396
- subagents: interruptedSubagents
82356
+ subagents: interruptedSubagents,
82357
+ restartCause: bridgeDeadRestartCause
81397
82358
  })
81398
82359
  };
81399
82360
  }
@@ -81406,6 +82367,29 @@ try {
81406
82367
  }
81407
82368
  if (bootResumeInbound != null) {
81408
82369
  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}
82370
+ `);
82371
+ }
82372
+ }
82373
+ if (bridgeDeadMarker != null && bootResumeInbound == null && selfAgent) {
82374
+ const idleNoticeChat = (() => {
82375
+ try {
82376
+ return loadAccess().allowFrom[0] ?? null;
82377
+ } catch {
82378
+ return null;
82379
+ }
82380
+ })();
82381
+ if (idleNoticeChat != null) {
82382
+ bootResumeInbound = {
82383
+ agent: selfAgent,
82384
+ msg: buildBridgeDeadIdleNoticeInbound({
82385
+ chatId: String(idleNoticeChat),
82386
+ marker: bridgeDeadMarker
82387
+ })
82388
+ };
82389
+ process.stderr.write(`telegram gateway: boot: bridge-dead idle notice queued chat=${idleNoticeChat} (no turn was in flight)
82390
+ `);
82391
+ } else {
82392
+ process.stderr.write(`telegram gateway: boot: bridge-dead idle notice skipped \u2014 no allowFrom chat to surface it in
81409
82393
  `);
81410
82394
  }
81411
82395
  }
@@ -81423,13 +82407,13 @@ try {
81423
82407
  pending2.interrupt_reason != null ? `SWITCHROOM_PENDING_INTERRUPT_REASON=${pending2.interrupt_reason}` : `SWITCHROOM_PENDING_INTERRUPT_REASON=`
81424
82408
  ];
81425
82409
  const pendingEnvTmp = `${pendingEnvPath}.tmp-${process.pid}`;
81426
- writeFileSync41(pendingEnvTmp, lines.join(`
82410
+ writeFileSync42(pendingEnvTmp, lines.join(`
81427
82411
  `) + `
81428
82412
  `, { mode: 384 });
81429
- renameSync16(pendingEnvTmp, pendingEnvPath);
82413
+ renameSync17(pendingEnvTmp, pendingEnvPath);
81430
82414
  process.stderr.write(`telegram gateway: pending-turn env written to ${pendingEnvPath} turnKey=${pending2.turn_key} endedVia=${pending2.ended_via ?? "open"}
81431
82415
  `);
81432
- } else if (existsSync49(pendingEnvPath)) {
82416
+ } else if (existsSync50(pendingEnvPath)) {
81433
82417
  rmSync5(pendingEnvPath, { force: true });
81434
82418
  process.stderr.write(`telegram gateway: pending-turn env cleared (clean previous shutdown)
81435
82419
  `);
@@ -81520,7 +82504,7 @@ if (!STATIC) {
81520
82504
  function checkApprovals() {
81521
82505
  let files;
81522
82506
  try {
81523
- files = readdirSync11(APPROVED_DIR);
82507
+ files = readdirSync12(APPROVED_DIR);
81524
82508
  } catch {
81525
82509
  return;
81526
82510
  }
@@ -81631,10 +82615,10 @@ function noteAgentOutputAt(key, ts) {
81631
82615
  }
81632
82616
  var OBLIGATION_STORE_PATH = join53(STATE_DIR, "obligations.json");
81633
82617
  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)
82618
+ readFileSync: (p) => readFileSync53(p, "utf8"),
82619
+ writeFileSync: (p, d) => writeFileSync42(p, d),
82620
+ renameSync: (a, b) => renameSync17(a, b),
82621
+ existsSync: (p) => existsSync50(p)
81638
82622
  };
81639
82623
  var obligationLedger = new ObligationLedger(OBLIGATION_REPRESENT_MAX, {
81640
82624
  onChange: STATIC || !OBLIGATION_LEDGER_ENABLED ? undefined : (snapshot) => persistObligations(OBLIGATION_STORE_PATH, obligationStoreFs, snapshot)
@@ -81741,6 +82725,8 @@ async function deliverButtonTapInbound(agent, inbound) {
81741
82725
  return "buffered-bridge-offline";
81742
82726
  }
81743
82727
  var pendingRestarts = new Map;
82728
+ var pendingSessionCommand = createPendingSessionCommandSlots();
82729
+ var PENDING_CMD_DRAIN_CAP_MS = 60000;
81744
82730
  var lastSessionActiveFile = null;
81745
82731
  var compactState = initialCompactState();
81746
82732
  var compactDispatching = false;
@@ -82072,6 +83058,119 @@ async function fireDeferredInterrupt(reason) {
82072
83058
  `);
82073
83059
  }
82074
83060
  }
83061
+ var haltBoundaryWaiters = new Set;
83062
+ function notifyHaltBoundaryWaiters() {
83063
+ if (haltBoundaryWaiters.size === 0)
83064
+ return;
83065
+ for (const check of [...haltBoundaryWaiters])
83066
+ check();
83067
+ }
83068
+ function waitForSafeBoundary(maxWaitMs) {
83069
+ if (!toolFlightTracker.isMidToolCall())
83070
+ return Promise.resolve("boundary");
83071
+ return new Promise((resolve11) => {
83072
+ let done = false;
83073
+ const finish = (reason) => {
83074
+ if (done)
83075
+ return;
83076
+ done = true;
83077
+ clearTimeout(timer3);
83078
+ haltBoundaryWaiters.delete(check);
83079
+ resolve11(reason);
83080
+ };
83081
+ const timer3 = setTimeout(() => finish("timeout"), maxWaitMs);
83082
+ const check = () => {
83083
+ if (!toolFlightTracker.isMidToolCall())
83084
+ finish("boundary");
83085
+ };
83086
+ haltBoundaryWaiters.add(check);
83087
+ });
83088
+ }
83089
+ function cancelPendingPermissionsForHalt(origin) {
83090
+ if (pendingPermissions.size === 0)
83091
+ return;
83092
+ for (const [requestId, details] of pendingPermissions) {
83093
+ dispatchPermissionVerdict({
83094
+ type: "permission",
83095
+ requestId,
83096
+ behavior: "deny",
83097
+ message: "Cancelled \u2014 the operator stopped this turn. Do not retry."
83098
+ });
83099
+ stripCancelledPermissionCards(details.card_text, details.cards);
83100
+ pendingPermissions.delete(requestId);
83101
+ permCardStore.remove(requestId);
83102
+ process.stderr.write(`telegram gateway: halt-now cancelled pending permission origin=${origin} request=${requestId} tool=${details.tool_name}
83103
+ `);
83104
+ }
83105
+ }
83106
+ async function stripCancelledPermissionCards(cardText, cards) {
83107
+ for (const edit of buildCancelledCardEdits(cardText, cards)) {
83108
+ await swallowingApiCall(() => bot.api.editMessageText(edit.chatId, edit.messageId, richMessage2(edit.text), {}), { chat_id: edit.chatId, verb: "permission_halt.strip" });
83109
+ }
83110
+ }
83111
+ async function executeHaltNow(origin) {
83112
+ const agentName3 = process.env.SWITCHROOM_AGENT_NAME;
83113
+ if (!agentName3)
83114
+ return;
83115
+ const haltTarget = currentTurn;
83116
+ const haltTargetKey = haltTarget?.registryKey ?? null;
83117
+ const access = loadAccess();
83118
+ const timing = decideInterruptTiming({
83119
+ safeBoundaryEnabled: resolveSafeBoundaryEnabled(access.interruptSafeBoundary),
83120
+ midToolCall: toolFlightTracker.isMidToolCall()
83121
+ });
83122
+ const startedAt = Date.now();
83123
+ if (timing === "defer") {
83124
+ const maxWaitMs = resolveInterruptMaxWaitMs(access.interruptMaxWaitMs);
83125
+ const reason = await waitForSafeBoundary(maxWaitMs);
83126
+ process.stderr.write(`telegram gateway: halt-now boundary-wait origin=${origin} reason=${reason} waited_ms=${Date.now() - startedAt} in_flight=${toolFlightTracker.inFlightCount()}
83127
+ `);
83128
+ }
83129
+ if (currentTurn !== haltTarget) {
83130
+ 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"}
83131
+ `);
83132
+ return;
83133
+ }
83134
+ try {
83135
+ const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
83136
+ const r = sendAgentInterrupt2({ agentName: agentName3 });
83137
+ if ("ok" in r) {
83138
+ process.stderr.write(`telegram gateway: halt-now SIGINT delivered via tmux send-keys origin=${origin} agent=${agentName3}
83139
+ `);
83140
+ } else {
83141
+ process.stderr.write(`telegram gateway: halt-now SIGINT via tmux failed origin=${origin} agent=${agentName3}: ${r.error}
83142
+ `);
83143
+ }
83144
+ } catch (err) {
83145
+ process.stderr.write(`telegram gateway: halt-now SIGINT failed origin=${origin}: ${err.message}
83146
+ `);
83147
+ }
83148
+ cancelInterruptedObligation();
83149
+ cancelPendingPermissionsForHalt(origin);
83150
+ const turn = currentTurn;
83151
+ if (turn != null) {
83152
+ if (turnsDb != null && turn.registryKey != null) {
83153
+ const _turnKey = turn.registryKey;
83154
+ try {
83155
+ recordTurnEnd(turnsDb, {
83156
+ turnKey: _turnKey,
83157
+ endedVia: "stop",
83158
+ lastAssistantMsgId: turn.lastAssistantMsgId,
83159
+ lastAssistantDone: turn.lastAssistantDone,
83160
+ toolCallCount: turn.toolCallCount
83161
+ });
83162
+ } catch (err) {
83163
+ process.stderr.write(`telegram gateway: recordTurnEnd(halt) failed turnKey=${_turnKey}: ${err.message}
83164
+ `);
83165
+ }
83166
+ }
83167
+ const key = statusKey(turn.sessionChatId, turn.sessionThreadId);
83168
+ endCurrentTurnAtomic(turn);
83169
+ releaseTurnBufferGate(key, turn);
83170
+ }
83171
+ if (!turnInFlightForGate())
83172
+ drainPendingSessionCommand();
83173
+ }
82075
83174
  var preambleSuppressor = new PreambleSuppressor({
82076
83175
  emitAnswer: (cumulative) => {
82077
83176
  const stream = currentTurn?.answerStream ?? null;
@@ -82194,6 +83293,7 @@ function purgeReactionTracking(key, endingTurn) {
82194
83293
  }
82195
83294
  drainBufferedIfAllowed(endingTurn, "turn-complete");
82196
83295
  if (!turnInFlightForGate()) {
83296
+ drainPendingSessionCommand();
82197
83297
  if (pendingRestarts.size > 0) {
82198
83298
  for (const [agentName3, _timestamp] of pendingRestarts.entries()) {
82199
83299
  triggerSelfRestart(agentName3, "turn-complete-pending-restart");
@@ -82258,7 +83358,7 @@ function emitTurnRecord(turn, endedAt) {
82258
83358
  return;
82259
83359
  }
82260
83360
  },
82261
- rename: (from, to) => renameSync16(from, to)
83361
+ rename: (from, to) => renameSync17(from, to)
82262
83362
  });
82263
83363
  appendFileSync6(turnsPath, rec);
82264
83364
  } catch {}
@@ -83357,6 +84457,17 @@ var pendingStateReaper = setInterval(() => {
83357
84457
  triggerSelfRestart(agentName3, "restart-drain-cap-forced", 100);
83358
84458
  }
83359
84459
  }
84460
+ const queuedCmds = pendingSessionCommand.list();
84461
+ const cmdDecision = drainCapDecision(queuedCmds, now, PENDING_CMD_DRAIN_CAP_MS, turnInFlightForGate());
84462
+ if (cmdDecision === "defer-turn-in-flight") {
84463
+ process.stderr.write(`telegram gateway: [pending-cmd-drain] deferred reason=turn-in-flight queued=${queuedCmds.map((c) => c.kind).join(",")}
84464
+ `);
84465
+ } else if (cmdDecision === "force") {
84466
+ const oldest = Math.min(...queuedCmds.map((c) => c.requestedAt));
84467
+ 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
84468
+ `);
84469
+ drainPendingSessionCommand();
84470
+ }
83360
84471
  }, 60000);
83361
84472
  pendingStateReaper.unref();
83362
84473
  function looksLikeAuthCode(text5) {
@@ -83410,6 +84521,7 @@ function emitGatewayOperatorEvent(event) {
83410
84521
  const modelUnavailable = resolveModelUnavailableFromOperatorEvent(event);
83411
84522
  let renderedText;
83412
84523
  let renderedKeyboard;
84524
+ let cardPromisedFallback = false;
83413
84525
  if (modelUnavailable) {
83414
84526
  const isAutoKind = modelUnavailable.kind === "quota_exhausted";
83415
84527
  const willActuallyFire = isAutoKind && wouldFireFleetAutoFallback();
@@ -83419,6 +84531,7 @@ function emitGatewayOperatorEvent(event) {
83419
84531
  autoFallbackInFlight: willActuallyFire
83420
84532
  });
83421
84533
  renderedKeyboard = undefined;
84534
+ cardPromisedFallback = willActuallyFire;
83422
84535
  if (willActuallyFire) {
83423
84536
  const untilMs = resolveExhaustUntil(modelUnavailable.resetAt?.getTime());
83424
84537
  fireFleetAutoFallback(agent, untilMs);
@@ -83450,7 +84563,16 @@ function emitGatewayOperatorEvent(event) {
83450
84563
  ...renderedKeyboard ? { reply_markup: renderedKeyboard } : {},
83451
84564
  ...opEventThread != null ? { message_thread_id: opEventThread } : {}
83452
84565
  };
83453
- bot.api.sendRichMessage(chat_id, richMessage2(renderedText), opts).catch((e) => {
84566
+ bot.api.sendRichMessage(chat_id, richMessage2(renderedText), opts).then((sent) => {
84567
+ if (cardPromisedFallback) {
84568
+ modelUnavailableCardRegistry.record(String(chat_id), {
84569
+ messageId: sent.message_id,
84570
+ text: renderedText,
84571
+ atMs: Date.now(),
84572
+ promisedFallback: true
84573
+ });
84574
+ }
84575
+ }).catch((e) => {
83454
84576
  process.stderr.write(`telegram gateway: operator-event send to ${chat_id} failed agent=${agent} kind=${kind}: ${e}
83455
84577
  `);
83456
84578
  });
@@ -83512,18 +84634,18 @@ var statusPinChatIds = new Map;
83512
84634
  var statusPinPinnedAt = new Map;
83513
84635
  var STATUS_PIN_STORE_PATH = join53(STATE_DIR, "status-pins.json");
83514
84636
  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)
84637
+ readFileSync: (p) => readFileSync53(p, "utf8"),
84638
+ writeFileSync: (p, d) => writeFileSync42(p, d),
84639
+ renameSync: (a, b) => renameSync17(a, b),
84640
+ existsSync: (p) => existsSync50(p)
83519
84641
  };
83520
84642
  var statusPinPersistEnabled = !STATIC && PIN_STATUS_WHILE_WORKING;
83521
84643
  var ACTIVITY_CARD_STORE_PATH = join53(STATE_DIR, "activity-cards-pending.json");
83522
84644
  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)
84645
+ readFileSync: (p) => readFileSync53(p, "utf8"),
84646
+ writeFileSync: (p, d) => writeFileSync42(p, d),
84647
+ renameSync: (a, b) => renameSync17(a, b),
84648
+ existsSync: (p) => existsSync50(p)
83527
84649
  };
83528
84650
  var activityCardPersistEnabled = !STATIC;
83529
84651
  var bannerPinPersistEnabled = !STATIC;
@@ -84167,10 +85289,10 @@ var inboundSpool = STATIC ? undefined : createInboundSpool({
84167
85289
  path: join53(STATE_DIR, "inbound-spool.jsonl"),
84168
85290
  fs: {
84169
85291
  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),
85292
+ readFileSync: (p) => readFileSync53(p, "utf8"),
85293
+ writeFileSync: (p, d) => writeFileSync42(p, d),
85294
+ renameSync: (a, b) => renameSync17(a, b),
85295
+ existsSync: (p) => existsSync50(p),
84174
85296
  statSizeSync: (p) => statSync16(p).size
84175
85297
  },
84176
85298
  onDegraded: (info) => {
@@ -84351,6 +85473,27 @@ async function stripStalePermissionCard(card) {
84351
85473
  `);
84352
85474
  }
84353
85475
  }
85476
+ var BRIDGE_DEAD_ESCALATION_ENABLED = process.env.SWITCHROOM_BRIDGE_DEAD_ESCALATION !== "0";
85477
+ var BRIDGE_DEAD_GRACE_MS = (() => {
85478
+ const v = Number(process.env.SWITCHROOM_BRIDGE_DEAD_GRACE_MS);
85479
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_BRIDGE_DEAD_GRACE_MS;
85480
+ })();
85481
+ var bridgeDeadWatchdog = createBridgeDeadWatchdog({
85482
+ graceMs: BRIDGE_DEAD_GRACE_MS,
85483
+ isSessionAlive: () => findAgentProcessInContainer2()?.comm === "claude",
85484
+ isShuttingDown: () => shuttingDown,
85485
+ escalate: (reason) => triggerSelfRestart(process.env.SWITCHROOM_AGENT_NAME ?? "", reason, 1500),
85486
+ crashLogPath: join53(STATE_DIR, "bridge-crash.log"),
85487
+ markerPath: join53(STATE_DIR, "bridge-dead-escalation.json"),
85488
+ log: (line) => process.stderr.write(`${line}
85489
+ `),
85490
+ priorStreak: bridgeDeadPriorStreak
85491
+ });
85492
+ if (BRIDGE_DEAD_ESCALATION_ENABLED) {
85493
+ bridgeDeadWatchdog.arm();
85494
+ process.stderr.write(`telegram gateway: [bridge-dead-watchdog] armed (grace=${BRIDGE_DEAD_GRACE_MS}ms)
85495
+ `);
85496
+ }
84354
85497
  var ipcServer = createIpcServer({
84355
85498
  socketPath: SOCKET_PATH,
84356
85499
  onClientRegistered(client3) {
@@ -84367,6 +85510,7 @@ var ipcServer = createIpcServer({
84367
85510
  return;
84368
85511
  }
84369
85512
  const bridgeUpEffects = client3.agentName != null ? shadowEmit({ kind: "bridgeUp", at: Date.now() }) : [];
85513
+ bridgeDeadWatchdog.noteBridgeRegistered(client3.agentName);
84370
85514
  client3.send({ type: "status", status: "agent_connected" });
84371
85515
  if (client3.agentName != null) {
84372
85516
  if (isDispatchEnabled()) {
@@ -84498,6 +85642,8 @@ var ipcServer = createIpcServer({
84498
85642
  process.stderr.write(`telegram gateway: bridge disconnected \u2014 agent=${client3.agentName}
84499
85643
  `);
84500
85644
  shadowEmit({ kind: "bridgeDown", at: Date.now() });
85645
+ if (BRIDGE_DEAD_ESCALATION_ENABLED)
85646
+ bridgeDeadWatchdog.noteBridgeDisconnected(client3.agentName);
84501
85647
  }
84502
85648
  flushOnAgentDisconnect({
84503
85649
  agentName: client3.agentName,
@@ -84552,6 +85698,7 @@ var ipcServer = createIpcServer({
84552
85698
  if (pendingDeferredInterrupt != null && !toolFlightTracker.isMidToolCall()) {
84553
85699
  fireDeferredInterrupt("boundary");
84554
85700
  }
85701
+ notifyHaltBoundaryWaiters();
84555
85702
  if (currentTurn != null) {
84556
85703
  const key = statusKey(currentTurn.sessionChatId, currentTurn.sessionThreadId);
84557
85704
  if (ev.kind === "thinking") {
@@ -85948,20 +87095,58 @@ ${url}`;
85948
87095
  turn2.silentAnchorText = effectiveText;
85949
87096
  }
85950
87097
  }
85951
- const allPhotos = files.length >= 2 && files.length <= 10 && files.every((f) => PHOTO_EXTS.has(extname(f).toLowerCase()));
87098
+ const photoPrecheck = new Map;
87099
+ const documentReroutes = [];
87100
+ const sentAsDocument = new Set;
87101
+ for (const f of files) {
87102
+ if (!PHOTO_EXTS.has(extname(f).toLowerCase()))
87103
+ continue;
87104
+ const cls = classifyPhotoFile(f);
87105
+ photoPrecheck.set(f, cls);
87106
+ if (cls.route === "document") {
87107
+ documentReroutes.push({ path: f, reason: cls.reason });
87108
+ sentAsDocument.add(f);
87109
+ process.stderr.write(`telegram gateway: photo-precheck rerouting ${f} as document (${cls.reason})
87110
+ `);
87111
+ }
87112
+ }
87113
+ const sendableAsPhoto = (f) => PHOTO_EXTS.has(extname(f).toLowerCase()) && photoPrecheck.get(f)?.route !== "document";
87114
+ const allPhotos = files.length >= 2 && files.length <= 10 && files.every(sendableAsPhoto);
85952
87115
  const replyParams = reply_to != null && replyMode !== "off" ? { reply_parameters: { message_id: reply_to } } : {};
87116
+ const sendAsDocument = (f) => retryWithThreadFallback2(robustApiCall, (tid) => {
87117
+ const baseOpts = {
87118
+ ...replyParams,
87119
+ ...tid != null ? { message_thread_id: tid } : {}
87120
+ };
87121
+ return lockedBot.api.sendDocument(chat_id, new import_grammy13.InputFile(f), baseOpts);
87122
+ }, { threadId, chat_id, verb: "sendDocument" });
85953
87123
  if (allPhotos) {
85954
87124
  const media = files.map((f) => ({
85955
87125
  type: "photo",
85956
87126
  media: new import_grammy13.InputFile(f)
85957
87127
  }));
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" });
87128
+ let sent;
87129
+ try {
87130
+ sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
87131
+ const baseOpts = {
87132
+ ...replyParams,
87133
+ ...tid != null ? { message_thread_id: tid } : {}
87134
+ };
87135
+ return lockedBot.api.sendMediaGroup(chat_id, media, baseOpts);
87136
+ }, { threadId, chat_id, verb: "sendMediaGroup" });
87137
+ } catch (err) {
87138
+ if (!isPhotoDimensionRejectError(err))
87139
+ throw err;
87140
+ process.stderr.write(`telegram gateway: sendMediaGroup rejected the photo album (${err instanceof Error ? err.message : String(err)}); falling back to per-file sendDocument
87141
+ `);
87142
+ sent = [];
87143
+ const albumReason = "Telegram rejected the photo album; whole album re-sent as documents";
87144
+ for (const f of files) {
87145
+ sent.push(await sendAsDocument(f));
87146
+ sentAsDocument.add(f);
87147
+ documentReroutes.push({ path: f, reason: albumReason });
87148
+ }
87149
+ }
85965
87150
  if (threadId != null) {
85966
87151
  const first = sent[0];
85967
87152
  if (first && first.message_thread_id == null)
@@ -85971,23 +87156,33 @@ ${url}`;
85971
87156
  sentIds.push(m.message_id);
85972
87157
  } else {
85973
87158
  for (const f of files) {
85974
- const ext = extname(f).toLowerCase();
85975
87159
  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" });
87160
+ const isPhoto = sendableAsPhoto(f);
87161
+ let sent;
87162
+ try {
87163
+ sent = await retryWithThreadFallback2(robustApiCall, (tid) => {
87164
+ const baseOpts = {
87165
+ ...replyParams,
87166
+ ...tid != null ? { message_thread_id: tid } : {}
87167
+ };
87168
+ return isPhoto ? lockedBot.api.sendPhoto(chat_id, input, baseOpts) : lockedBot.api.sendDocument(chat_id, input, baseOpts);
87169
+ }, { threadId, chat_id, verb: isPhoto ? "sendPhoto" : "sendDocument" });
87170
+ } catch (err) {
87171
+ if (!(isPhoto && isPhotoDimensionRejectError(err)))
87172
+ throw err;
87173
+ process.stderr.write(`telegram gateway: sendPhoto rejected ${f} (${err instanceof Error ? err.message : String(err)}); falling back to sendDocument
87174
+ `);
87175
+ sent = await sendAsDocument(f);
87176
+ sentAsDocument.add(f);
87177
+ documentReroutes.push({ path: f, reason: "Telegram rejected it as a photo; re-sent as document" });
87178
+ }
85984
87179
  if (threadId != null && sent.message_thread_id == null) {
85985
87180
  threadId = undefined;
85986
87181
  }
85987
87182
  sentIds.push(sent.message_id);
85988
87183
  }
85989
87184
  }
85990
- const result = sentIds.length === 1 ? `sent (id: ${sentIds[0]})` : `sent ${sentIds.length} parts (ids: ${sentIds.join(", ")})`;
87185
+ const result = (sentIds.length === 1 ? `sent (id: ${sentIds[0]})` : `sent ${sentIds.length} parts (ids: ${sentIds.join(", ")})`) + rerouteResultSuffix(documentReroutes);
85991
87186
  if (HISTORY_ENABLED && sentIds.length > 0) {
85992
87187
  try {
85993
87188
  const fileCount = files.length;
@@ -85999,9 +87194,11 @@ ${url}`;
85999
87194
  attachKinds.push(null);
86000
87195
  }
86001
87196
  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");
87197
+ const f = files[i] ?? "";
87198
+ const ext = extname(f).toLowerCase();
87199
+ const kind = PHOTO_EXTS.has(ext) && !sentAsDocument.has(f) ? "photo" : "document";
87200
+ texts.push(`(${kind}: ${f})`);
87201
+ attachKinds.push(kind);
86005
87202
  }
86006
87203
  recordOutbound({ chat_id, thread_id: threadId ?? null, message_ids: sentIds, texts, attachment_kinds: attachKinds });
86007
87204
  } catch (err) {
@@ -86262,8 +87459,8 @@ async function publishToTelegraph(text5, shortName, authorName) {
86262
87459
  const accountPath = join53(STATE_DIR, "telegraph-account.json");
86263
87460
  let account = null;
86264
87461
  try {
86265
- if (existsSync49(accountPath)) {
86266
- const raw = readFileSync51(accountPath, "utf-8");
87462
+ if (existsSync50(accountPath)) {
87463
+ const raw = readFileSync53(accountPath, "utf-8");
86267
87464
  const parsed = JSON.parse(raw);
86268
87465
  if (parsed.shortName && parsed.accessToken) {
86269
87466
  account = parsed;
@@ -86283,7 +87480,7 @@ async function publishToTelegraph(text5, shortName, authorName) {
86283
87480
  account = created.value;
86284
87481
  try {
86285
87482
  mkdirSync39(STATE_DIR, { recursive: true, mode: 448 });
86286
- writeFileSync41(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
87483
+ writeFileSync42(accountPath, JSON.stringify(account, null, 2), { mode: 384 });
86287
87484
  } catch (err) {
86288
87485
  process.stderr.write(`telegram gateway: telegraph cache write failed: ${err.message}
86289
87486
  `);
@@ -86723,7 +87920,7 @@ async function executeVaultRequestAccess(args) {
86723
87920
  }
86724
87921
  function readLiveSwitchroomConfigText() {
86725
87922
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? findConfigFile2();
86726
- return readFileSync51(cfgPath, "utf8");
87923
+ return readFileSync53(cfgPath, "utf8");
86727
87924
  }
86728
87925
  var MENTAL_MODEL_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
86729
87926
  function buildMentalModelProposeKeyboard(stageId) {
@@ -86894,7 +88091,7 @@ async function executeDownloadAttachment(args) {
86894
88091
  });
86895
88092
  mkdirSync39(INBOX_DIR, { recursive: true, mode: 448 });
86896
88093
  assertInsideInbox(INBOX_DIR, dlPath);
86897
- writeFileSync41(dlPath, buf, { mode: 384 });
88094
+ writeFileSync42(dlPath, buf, { mode: 384 });
86898
88095
  return { content: [{ type: "text", text: dlPath }] };
86899
88096
  }
86900
88097
  async function executeEditMessage(args) {
@@ -88401,6 +89598,9 @@ async function handleInboundCoalesced(ctx, text5, downloadImage, attachment) {
88401
89598
  if (parseInterruptMarker(text5).isInterrupt) {
88402
89599
  return handleInbound(ctx, text5, downloadImage, attachment);
88403
89600
  }
89601
+ if (parseStopKeyword(text5) && downloadImage == null && attachment == null) {
89602
+ return handleInbound(ctx, text5, downloadImage, attachment);
89603
+ }
88404
89604
  const hasAttachment = downloadImage != null || attachment != null;
88405
89605
  const maxAttachments = coalesceMaxAttachments();
88406
89606
  if (hasAttachment && ctx.message?.media_group_id != null && maxAttachments <= 1) {
@@ -88509,6 +89709,27 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88509
89709
  process.stderr.write(`telegram gateway: inbound classifier error: ${err.message}
88510
89710
  `);
88511
89711
  }
89712
+ if (parseStopKeyword(text5) && downloadImage == null && attachment == null && (extraAttachments == null || extraAttachments.length === 0)) {
89713
+ const queuedLabels = pendingSessionCommand.list().map((c) => `/${c.kind} ${c.targetLabel}`);
89714
+ const inFlight = turnInFlightForGate();
89715
+ process.stderr.write(`telegram gateway: stop-keyword received chat_id=${chat_id} in_flight_turn=${inFlight} queued_cmds=${queuedLabels.length}
89716
+ `);
89717
+ if (inFlight) {
89718
+ if (msgId != null) {
89719
+ bot.api.setMessageReaction(chat_id, msgId, [
89720
+ { type: "emoji", emoji: "\u26A1" }
89721
+ ]).catch(() => {});
89722
+ }
89723
+ await executeHaltNow("stop-keyword");
89724
+ }
89725
+ const stopReply = buildStopReply(inFlight, queuedLabels);
89726
+ await swallowingApiCall(() => bot.api.sendMessage(chat_id, stopReply.text, messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
89727
+ chat_id,
89728
+ verb: "stop-keyword-reply",
89729
+ ...messageThreadId != null ? { threadId: messageThreadId } : {}
89730
+ });
89731
+ return;
89732
+ }
88512
89733
  const interrupt = parseInterruptMarker(text5);
88513
89734
  let deferInterrupt = false;
88514
89735
  if (interrupt.isInterrupt) {
@@ -88525,6 +89746,15 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88525
89746
  { type: "emoji", emoji: "\u26A1" }
88526
89747
  ]).catch(() => {});
88527
89748
  }
89749
+ if (interrupt.emptyBody) {
89750
+ await executeHaltNow("bang-empty");
89751
+ await swallowingApiCall(() => bot.api.sendMessage(chat_id, "\u26A1 Interrupted. Send your replacement instruction now.", messageThreadId != null ? { message_thread_id: messageThreadId } : {}), {
89752
+ chat_id,
89753
+ verb: "interrupt-empty-body",
89754
+ ...messageThreadId != null ? { threadId: messageThreadId } : {}
89755
+ });
89756
+ return;
89757
+ }
88528
89758
  if (agentName3 && !deferInterrupt) {
88529
89759
  try {
88530
89760
  const { sendAgentInterrupt: sendAgentInterrupt2 } = await Promise.resolve().then(() => (init_tmux(), exports_tmux));
@@ -88542,14 +89772,6 @@ async function handleInbound(ctx, text5, downloadImage, attachment, extraAttachm
88542
89772
  }
88543
89773
  cancelInterruptedObligation();
88544
89774
  }
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
89775
  text5 = interrupt.body;
88554
89776
  }
88555
89777
  if (STATUS_QUERY_RE.test(text5)) {
@@ -89420,7 +90642,7 @@ function writeRestartMarker(marker) {
89420
90642
  if (!p)
89421
90643
  return;
89422
90644
  try {
89423
- writeFileSync41(p, JSON.stringify(marker));
90645
+ writeFileSync42(p, JSON.stringify(marker));
89424
90646
  lastPlannedRestartAt = Date.now();
89425
90647
  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
90648
  `);
@@ -89439,7 +90661,7 @@ function readRestartMarker() {
89439
90661
  if (!p)
89440
90662
  return null;
89441
90663
  try {
89442
- return JSON.parse(readFileSync51(p, "utf8"));
90664
+ return JSON.parse(readFileSync53(p, "utf8"));
89443
90665
  } catch {
89444
90666
  return null;
89445
90667
  }
@@ -89588,7 +90810,7 @@ var _dockerReachable;
89588
90810
  function isDockerReachable() {
89589
90811
  if (_dockerReachable !== undefined)
89590
90812
  return _dockerReachable;
89591
- if (!existsSync49("/var/run/docker.sock")) {
90813
+ if (!existsSync50("/var/run/docker.sock")) {
89592
90814
  _dockerReachable = false;
89593
90815
  return _dockerReachable;
89594
90816
  }
@@ -89609,8 +90831,8 @@ function spawnSwitchroomDetached(args, onFailure) {
89609
90831
  let outFd = null;
89610
90832
  try {
89611
90833
  mkdirSync39(STATE_DIR, { recursive: true });
89612
- outFd = openSync10(logPath, "a");
89613
- writeFileSync41(logPath, `
90834
+ outFd = openSync11(logPath, "a");
90835
+ writeFileSync42(logPath, `
89614
90836
  [${new Date().toISOString()}] spawn ${SWITCHROOM_CLI} ${fullArgs.join(" ")}
89615
90837
  `, { flag: "a" });
89616
90838
  } catch {}
@@ -89624,7 +90846,7 @@ function spawnSwitchroomDetached(args, onFailure) {
89624
90846
  });
89625
90847
  if (outFd != null) {
89626
90848
  try {
89627
- closeSync10(outFd);
90849
+ closeSync11(outFd);
89628
90850
  } catch {}
89629
90851
  }
89630
90852
  if (onFailure) {
@@ -89636,7 +90858,7 @@ function spawnSwitchroomDetached(args, onFailure) {
89636
90858
  return;
89637
90859
  let tail = "";
89638
90860
  try {
89639
- const full = readFileSync51(logPath, "utf8");
90861
+ const full = readFileSync53(logPath, "utf8");
89640
90862
  tail = full.split(`
89641
90863
  `).slice(-30).join(`
89642
90864
  `).trim();
@@ -90004,9 +91226,9 @@ bot.use(async (ctx, next) => {
90004
91226
  function readRecentDenialsForAgent(agentName3, windowMs, limit) {
90005
91227
  try {
90006
91228
  const auditPath = join53(homedir17(), ".switchroom", "vault-audit.log");
90007
- if (!existsSync49(auditPath))
91229
+ if (!existsSync50(auditPath))
90008
91230
  return [];
90009
- const raw = readFileSync51(auditPath, "utf8");
91231
+ const raw = readFileSync53(auditPath, "utf8");
90010
91232
  return recentDenialsFromAuditLog(raw, { agentName: agentName3, windowMs, limit });
90011
91233
  } catch {
90012
91234
  return [];
@@ -90057,7 +91279,7 @@ async function buildAgentMetadata(agentName3) {
90057
91279
  try {
90058
91280
  const agentDir = resolveAgentDirFromEnv();
90059
91281
  if (agentDir) {
90060
- const raw = readFileSync51(join53(agentDir, ".claude", ".claude.json"), "utf8");
91282
+ const raw = readFileSync53(join53(agentDir, ".claude", ".claude.json"), "utf8");
90061
91283
  claudeJson = JSON.parse(raw);
90062
91284
  }
90063
91285
  } catch {}
@@ -90327,6 +91549,192 @@ function modelMenuReplyMarkup(reply) {
90327
91549
  }
90328
91550
  return kb;
90329
91551
  }
91552
+ function recordTypedModelSwitch(reply, requestedModelArg, deps) {
91553
+ const requested = requestedModelArg != null ? expandSrAlias(requestedModelArg) : null;
91554
+ if (requested?.toLowerCase() === "default") {
91555
+ const smDir2 = resolveAgentDirFromEnv();
91556
+ if (smDir2)
91557
+ clearSessionModelFile(smDir2);
91558
+ if (reply.selectedModel)
91559
+ sessionModelSource.setOverride(null);
91560
+ return "";
91561
+ }
91562
+ if (!reply.selectedModel)
91563
+ return "";
91564
+ sessionModelSource.setOverride(reply.selectedModel);
91565
+ const smDir = resolveAgentDirFromEnv();
91566
+ if (smDir && requested && isValidModelArg(requested) && !isSrModel(requested)) {
91567
+ try {
91568
+ writeSessionModelFile(smDir, requested, readConfiguredDefaultModel(smDir) ?? resolveMainModel(deps.getConfiguredModel() ?? undefined));
91569
+ } catch (err) {
91570
+ process.stderr.write(`telegram gateway: session-model persist failed (typed /model): ${err?.message ?? String(err)}
91571
+ `);
91572
+ return `
91573
+ \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
91574
+ }
91575
+ }
91576
+ return "";
91577
+ }
91578
+ function recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel) {
91579
+ if (outcome.selectedModel) {
91580
+ sessionModelSource.setOverride(outcome.selectedModel);
91581
+ const smDir = resolveAgentDirFromEnv();
91582
+ if (smDir && outcome.selectedModelToken) {
91583
+ try {
91584
+ writeSessionModelFile(smDir, outcome.selectedModelToken, readConfiguredDefaultModel(smDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
91585
+ } catch (err) {
91586
+ outcome.reply.text += `
91587
+ \u26A0\uFE0F Couldn\u2019t persist the sticky override \u2014 the switch is live now but won\u2019t survive a relaunch.`;
91588
+ process.stderr.write(`telegram gateway: session-model persist failed (menu): ${err?.message ?? String(err)}
91589
+ `);
91590
+ }
91591
+ }
91592
+ }
91593
+ if (outcome.clearedDefault) {
91594
+ const smDir = resolveAgentDirFromEnv();
91595
+ if (smDir)
91596
+ clearSessionModelFile(smDir);
91597
+ }
91598
+ if (outcome.selectedModel && isSrToClaudeTransition(prevSessionModel, outcome.selectedModel)) {
91599
+ const agentName3 = getMyAgentName();
91600
+ const agentDir = resolveAgentDirFromEnv();
91601
+ const token = outcome.selectedModelToken;
91602
+ if (agentDir && token) {
91603
+ try {
91604
+ writeSessionModelFile(agentDir, token, readConfiguredDefaultModel(agentDir) ?? resolveMainModel(modelDeps.getConfiguredModel() ?? undefined));
91605
+ sessionModelSource.setOverride(token);
91606
+ } catch (e) {
91607
+ process.stderr.write(`telegram gateway: sr-to-claude session-model write failed: ${e?.message ?? String(e)}
91608
+ `);
91609
+ }
91610
+ } else if (agentDir) {
91611
+ clearSessionModelFile(agentDir);
91612
+ }
91613
+ writeRestartMarker({ chat_id: cbChatId, thread_id: cbThreadId ?? null, ack_message_id: null, ts: Date.now() });
91614
+ stampUserRestartReason("user: sr-to-claude model switch (menu)");
91615
+ if (turnInFlightForGate()) {
91616
+ pendingRestarts.set(agentName3, Date.now());
91617
+ } else {
91618
+ sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, "sr-to-claude-model-switch", 1500));
91619
+ }
91620
+ return {
91621
+ restartNotice: `\uD83D\uDD04 Switching from **${escapeHtmlForTg2(prevSessionModel)}** back to Claude \u2014 restarting session cleanly. Claude will be ready in ~30s.`
91622
+ };
91623
+ }
91624
+ return {};
91625
+ }
91626
+ async function editPendingCommandCard(chatId, messageId, text5) {
91627
+ try {
91628
+ await robustApiCall(() => lockedBot.api.editMessageText(chatId, messageId, richMessage2(hardenCardBreaks2(text5)), {
91629
+ reply_markup: { inline_keyboard: [] }
91630
+ }), { chat_id: chatId, verb: "pending-cmd-card" });
91631
+ } catch (err) {
91632
+ process.stderr.write(`telegram gateway: pending-command card edit failed chat=${chatId} msg=${messageId}: ${err?.message ?? String(err)}
91633
+ `);
91634
+ }
91635
+ }
91636
+ function enqueueSessionCommand(cmd) {
91637
+ const displaced = pendingSessionCommand.set(cmd);
91638
+ if (displaced) {
91639
+ editPendingCommandCard(displaced.ackChatId, displaced.ackMessageId, supersededText(displaced, cmd, escapeHtmlForTg2));
91640
+ }
91641
+ if (!turnInFlightForGate())
91642
+ drainPendingSessionCommand();
91643
+ }
91644
+ async function applyQueuedModelCommand(cmd) {
91645
+ const deps = buildModelDeps({ chatId: cmd.chatId, threadId: cmd.threadId });
91646
+ if (cmd.origin === "menu") {
91647
+ const prevSessionModel = sessionModelSource.getOverride();
91648
+ const outcome = await handleModelMenuCallback(cmd.arg, deps);
91649
+ const { restartNotice } = recordModelMenuSideEffects(outcome, deps, cmd.chatId, cmd.threadId, prevSessionModel);
91650
+ return restartNotice ?? outcome.reply.text;
91651
+ }
91652
+ const reply = await handleModelCommand({ kind: "set", model: cmd.arg }, deps);
91653
+ const warning = recordTypedModelSwitch(reply, cmd.arg, deps);
91654
+ return reply.text + warning;
91655
+ }
91656
+ async function applyQueuedEffortCommand(cmd) {
91657
+ const deps = buildEffortDeps();
91658
+ if (cmd.origin === "menu") {
91659
+ const outcome = await handleEffortMenuCallback(cmd.arg, deps);
91660
+ return outcome.reply.text;
91661
+ }
91662
+ const parsed = cmd.arg === "default" ? { kind: "default" } : { kind: "set", level: cmd.arg };
91663
+ const reply = await handleEffortCommand(parsed, deps);
91664
+ return reply.text;
91665
+ }
91666
+ function reEnqueueUnlessSuperseded(cmd) {
91667
+ const newer = pendingSessionCommand.get(cmd.kind);
91668
+ if (newer != null) {
91669
+ editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, supersededText(cmd, newer, escapeHtmlForTg2));
91670
+ return;
91671
+ }
91672
+ pendingSessionCommand.set(cmd);
91673
+ }
91674
+ function persistQueuedCommandForRestart(action) {
91675
+ if (action.persist == null)
91676
+ return action.reissueText;
91677
+ const agentDir = resolveAgentDirFromEnv();
91678
+ if (agentDir == null)
91679
+ return action.reissueText;
91680
+ try {
91681
+ switch (action.persist) {
91682
+ case "model": {
91683
+ if (!isOfflineTrustedModelToken(action.arg)) {
91684
+ 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.`;
91685
+ }
91686
+ const configured = readConfiguredDefaultModel(agentDir) ?? resolveMainModel(undefined);
91687
+ writeSessionModelFile(agentDir, expandSrAlias(action.arg), configured);
91688
+ writeRelaunchModelIntent(agentDir, "keep", "queued /model carried across restart");
91689
+ break;
91690
+ }
91691
+ case "clear-model":
91692
+ clearSessionModelFile(agentDir);
91693
+ break;
91694
+ case "effort":
91695
+ writeSessionEffortFile(agentDir, action.arg, getConfiguredEffortForPersist());
91696
+ break;
91697
+ case "clear-effort":
91698
+ clearSessionEffortFile(agentDir);
91699
+ break;
91700
+ }
91701
+ return action.persistedText;
91702
+ } catch (err) {
91703
+ process.stderr.write(`telegram gateway: queued-command persist-for-restart failed kind=${action.cmd.kind} arg=${action.arg}: ${err?.message ?? String(err)}
91704
+ `);
91705
+ return action.reissueText;
91706
+ }
91707
+ }
91708
+ function getConfiguredEffortForPersist() {
91709
+ try {
91710
+ const data = switchroomExecJson(["agent", "list"]);
91711
+ return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? SWITCHROOM_DEFAULT_THINKING_EFFORT;
91712
+ } catch {
91713
+ return SWITCHROOM_DEFAULT_THINKING_EFFORT;
91714
+ }
91715
+ }
91716
+ var pendingCmdDraining = false;
91717
+ async function drainPendingSessionCommand() {
91718
+ if (pendingCmdDraining)
91719
+ return;
91720
+ if (pendingSessionCommand.size === 0)
91721
+ return;
91722
+ pendingCmdDraining = true;
91723
+ try {
91724
+ await drainTakenCommands(pendingSessionCommand.takeAll(), {
91725
+ restartPending: () => pendingRestarts.size > 0,
91726
+ turnInFlight: () => turnInFlightForGate(),
91727
+ apply: (cmd) => cmd.kind === "model" ? applyQueuedModelCommand(cmd) : applyQueuedEffortCommand(cmd),
91728
+ isBusyRefusal: isBusyRefusalText,
91729
+ resolveForRestartText: (cmd) => persistQueuedCommandForRestart(resolveForRestart(cmd, escapeHtmlForTg2)),
91730
+ editCard: (cmd, text5) => editPendingCommandCard(cmd.ackChatId, cmd.ackMessageId, text5),
91731
+ reEnqueue: reEnqueueUnlessSuperseded,
91732
+ failureText: (cmd, err) => `\u274C Couldn\u2019t apply the queued ${cmd.kind} switch to \`${escapeHtmlForTg2(cmd.targetLabel)}\`: ${escapeHtmlForTg2(err?.message ?? String(err))}`
91733
+ });
91734
+ } finally {
91735
+ pendingCmdDraining = false;
91736
+ }
91737
+ }
90330
91738
  bot.command("model", async (ctx) => {
90331
91739
  if (!isAuthorizedSender(ctx))
90332
91740
  return;
@@ -90340,38 +91748,53 @@ bot.command("model", async (ctx) => {
90340
91748
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: modelMenuReplyMarkup(menu) });
90341
91749
  return;
90342
91750
  }
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
- }
91751
+ if (parsed.kind === "set" && deps.isBusy()) {
91752
+ const target = expandSrAlias(parsed.model);
91753
+ const sent = await ctx.replyWithRichMessage(richMessage2(hardenCardBreaks2(ackText("model", target, escapeHtmlForTg2))), threadId != null ? { message_thread_id: threadId } : {});
91754
+ enqueueSessionCommand({
91755
+ kind: "model",
91756
+ origin: "typed",
91757
+ arg: target,
91758
+ targetLabel: target,
91759
+ chatId,
91760
+ threadId,
91761
+ ackChatId: chatId,
91762
+ ackMessageId: sent.message_id,
91763
+ requestedAt: Date.now()
91764
+ });
91765
+ return;
90365
91766
  }
91767
+ const reply = await handleModelCommand(parsed, deps);
91768
+ const persistWarning = recordTypedModelSwitch(reply, parsed.kind === "set" ? parsed.model : null, deps);
90366
91769
  await switchroomReply(ctx, reply.text + persistWarning, { html: reply.html });
90367
91770
  });
90368
91771
  function buildEffortDeps() {
90369
91772
  return {
90370
- applyEffort: (agent, level) => applyEffort(agent, level),
91773
+ applyEffort: async (agent, level) => {
91774
+ const result = await applyEffort(agent, level);
91775
+ if (result.ok) {
91776
+ const agentDir = resolveAgentDirFromEnv();
91777
+ if (agentDir) {
91778
+ try {
91779
+ writeSessionEffortFile(agentDir, level, getConfiguredEffortForPersist());
91780
+ } catch (err) {
91781
+ process.stderr.write(`telegram gateway: session-effort persist failed level=${level}: ${err?.message ?? String(err)}
91782
+ `);
91783
+ }
91784
+ }
91785
+ }
91786
+ return result;
91787
+ },
90371
91788
  getAgentName: getMyAgentName,
90372
- getConfiguredEffort: () => {
90373
- const data = switchroomExecJson(["agent", "list"]);
90374
- return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
91789
+ getConfiguredEffort: () => getConfiguredEffortForPersist(),
91790
+ clearSessionEffort: () => {
91791
+ const agentDir = resolveAgentDirFromEnv();
91792
+ if (agentDir)
91793
+ clearSessionEffortFile(agentDir);
91794
+ },
91795
+ getSessionEffort: () => {
91796
+ const agentDir = resolveAgentDirFromEnv();
91797
+ return agentDir ? readSessionEffortFile(agentDir)?.level ?? null : null;
90375
91798
  },
90376
91799
  escapeHtml: escapeHtmlForTg2
90377
91800
  };
@@ -90398,6 +91821,24 @@ bot.command("effort", async (ctx) => {
90398
91821
  await switchroomReply(ctx, menu.text, { html: true, reply_markup: effortMenuReplyMarkup(menu) });
90399
91822
  return;
90400
91823
  }
91824
+ if ((parsed.kind === "set" || parsed.kind === "default") && currentTurn !== null) {
91825
+ const requestedLevel = parsed.kind === "set" ? parsed.level : "default";
91826
+ const chatId = String(ctx.chat.id);
91827
+ const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
91828
+ const sent = await ctx.replyWithRichMessage(richMessage2(hardenCardBreaks2(ackText("effort", requestedLevel, escapeHtmlForTg2))), threadId != null ? { message_thread_id: threadId } : {});
91829
+ enqueueSessionCommand({
91830
+ kind: "effort",
91831
+ origin: "typed",
91832
+ arg: requestedLevel,
91833
+ targetLabel: requestedLevel,
91834
+ chatId,
91835
+ threadId,
91836
+ ackChatId: chatId,
91837
+ ackMessageId: sent.message_id,
91838
+ requestedAt: Date.now()
91839
+ });
91840
+ return;
91841
+ }
90401
91842
  const reply = await handleEffortCommand(parsed, deps);
90402
91843
  await switchroomReply(ctx, reply.text, { html: reply.html });
90403
91844
  });
@@ -90413,7 +91854,7 @@ bot.command("agentstart", async (ctx) => {
90413
91854
  }
90414
91855
  await dispatchShortVerbViaHostd(ctx, { v: 1, op: "agent_start", request_id: hostdRequestId2("gw-start"), args: { name } }, `start ${name}`, ["agent", "start", name]);
90415
91856
  });
90416
- bot.command("stop", async (ctx) => {
91857
+ bot.command("agentstop", async (ctx) => {
90417
91858
  if (!isAuthorizedSender(ctx))
90418
91859
  return;
90419
91860
  const name = ctx.match?.trim() || getMyAgentName();
@@ -90425,6 +91866,23 @@ bot.command("stop", async (ctx) => {
90425
91866
  }
90426
91867
  await dispatchShortVerbViaHostd(ctx, { v: 1, op: "agent_stop", request_id: hostdRequestId2("gw-stop"), args: { name } }, `stop ${name}`, ["agent", "stop", name]);
90427
91868
  });
91869
+ bot.command("stop", async (ctx) => {
91870
+ if (!isAuthorizedSender(ctx))
91871
+ return;
91872
+ const arg = ctx.match?.trim();
91873
+ if (arg) {
91874
+ await switchroomReply(ctx, `/stop takes no argument \u2014 it cancels MY in-flight turn. ` + `To stop a container, use /agentstop ${arg}. Nothing was stopped.`);
91875
+ return;
91876
+ }
91877
+ const queuedLabels = pendingSessionCommand.list().map((c) => `/${c.kind} ${c.targetLabel}`);
91878
+ const inFlight = turnInFlightForGate();
91879
+ if (!inFlight) {
91880
+ await switchroomReply(ctx, buildStopReply(false, queuedLabels).text);
91881
+ return;
91882
+ }
91883
+ await executeHaltNow("stop-command");
91884
+ await switchroomReply(ctx, buildStopReply(true, queuedLabels).text);
91885
+ });
90428
91886
  bot.command("restart", async (ctx) => {
90429
91887
  if (!isAuthorizedSender(ctx))
90430
91888
  return;
@@ -90443,10 +91901,10 @@ bot.command("restart", async (ctx) => {
90443
91901
  }
90444
91902
  const chatId = String(ctx.chat.id);
90445
91903
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90446
- const ackText = restartAckText(name);
91904
+ const ackText2 = restartAckText(name);
90447
91905
  let ackId = null;
90448
91906
  try {
90449
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
91907
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90450
91908
  disable_notification: true,
90451
91909
  ...tid != null ? { message_thread_id: tid } : {}
90452
91910
  }), { threadId, chat_id: chatId, verb: "restart.ack" });
@@ -90462,7 +91920,7 @@ bot.command("restart", async (ctx) => {
90462
91920
  {
90463
91921
  const smDir = resolveAgentDirFromEnv();
90464
91922
  if (smDir)
90465
- writeRelaunchModelIntent(smDir, "revert", "user: /restart from chat");
91923
+ writeRelaunchModelIntent(smDir, "keep", "user: /restart from chat");
90466
91924
  }
90467
91925
  await sweepBeforeSelfRestart();
90468
91926
  const hostdResp = await tryHostdDispatch2(getMyAgentName(), {
@@ -90496,8 +91954,8 @@ function flushAgentHandoff(agentDir) {
90496
91954
  for (const fname of [".handoff.md", ".handoff-topic"]) {
90497
91955
  const p = join53(agentDir, fname);
90498
91956
  try {
90499
- if (existsSync49(p)) {
90500
- unlinkSync20(p);
91957
+ if (existsSync50(p)) {
91958
+ unlinkSync21(p);
90501
91959
  removed++;
90502
91960
  }
90503
91961
  } catch (err) {
@@ -90535,24 +91993,24 @@ async function handleNewCommand(ctx) {
90535
91993
  const flushed = agentDir != null ? flushAgentHandoff(agentDir) : 0;
90536
91994
  const chatId = String(ctx.chat.id);
90537
91995
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90538
- const ackText = newSessionAckText(name, flushed > 0);
91996
+ const ackText2 = newSessionAckText(name, flushed > 0);
90539
91997
  let ackId = null;
90540
91998
  try {
90541
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
91999
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90542
92000
  disable_notification: true,
90543
92001
  ...tid != null ? { message_thread_id: tid } : {}
90544
92002
  }), { threadId, chat_id: chatId, verb: "new-or-reset.ack" });
90545
92003
  ackId = sent.message_id;
90546
92004
  if (HISTORY_ENABLED) {
90547
92005
  try {
90548
- recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: [sent.message_id], texts: [ackText], attachment_kinds: [] });
92006
+ recordOutbound({ chat_id: chatId, thread_id: threadId ?? null, message_ids: [sent.message_id], texts: [ackText2], attachment_kinds: [] });
90549
92007
  } catch {}
90550
92008
  }
90551
92009
  } catch {}
90552
92010
  writeRestartMarker({ chat_id: chatId, thread_id: threadId ?? null, ack_message_id: ackId, ts: Date.now() });
90553
92011
  if (agentDir != null) {
90554
92012
  try {
90555
- writeFileSync41(join53(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
92013
+ writeFileSync42(join53(agentDir, ".force-fresh-session"), `${kind} at ${new Date().toISOString()}
90556
92014
  `, "utf8");
90557
92015
  } catch (err) {
90558
92016
  process.stderr.write(`telegram gateway: failed to write force-fresh marker: ${err}
@@ -90628,7 +92086,7 @@ Either run \`switchroom update\` from the host shell, or set \`host_control.enab
90628
92086
  }
90629
92087
  const chatId = String(ctx.chat.id);
90630
92088
  const threadId = resolveThreadId(chatId, ctx.message?.message_thread_id);
90631
- const ackText = `\uD83D\uDE80 **update started** \u2014 running ${[
92089
+ const ackText2 = `\uD83D\uDE80 **update started** \u2014 running ${[
90632
92090
  "`switchroom update`",
90633
92091
  ...passthrough.map((t) => `\`${t}\``)
90634
92092
  ].join(" ")}
@@ -90636,7 +92094,7 @@ Either run \`switchroom update\` from the host shell, or set \`host_control.enab
90636
92094
  The gateway will restart as part of the recreate step; watch for the post-restart greeting card to confirm completion.`;
90637
92095
  let ackId = null;
90638
92096
  try {
90639
- const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText), {
92097
+ const sent = await retryWithThreadFallback2(robustApiCall, (tid) => lockedBot.api.sendRichMessage(chatId, richMessage2(ackText2), {
90640
92098
  disable_notification: true,
90641
92099
  ...tid != null ? { message_thread_id: tid } : {}
90642
92100
  }), { threadId, chat_id: chatId, verb: "update.ack" });
@@ -90919,16 +92377,16 @@ bot.command("interrupt", async (ctx) => {
90919
92377
  await runSwitchroomCommand(ctx, ["agent", "interrupt", name], `interrupt ${name}`);
90920
92378
  });
90921
92379
  var lockoutOps = {
90922
- readFileSync: (p, enc) => readFileSync51(p, enc),
90923
- writeFileSync: (p, data, opts) => writeFileSync41(p, data, opts),
90924
- existsSync: (p) => existsSync49(p),
92380
+ readFileSync: (p, enc) => readFileSync53(p, enc),
92381
+ writeFileSync: (p, data, opts) => writeFileSync42(p, data, opts),
92382
+ existsSync: (p) => existsSync50(p),
90925
92383
  mkdirSync: (p, opts) => mkdirSync39(p, opts),
90926
92384
  joinPath: (...parts) => join53(...parts)
90927
92385
  };
90928
92386
  var FLEET_FALLBACK_DEDUP_MS = 30000;
90929
92387
  function isAuthBrokerSocketReachable() {
90930
92388
  try {
90931
- return existsSync49(resolveAuthBrokerSocketPath2());
92389
+ return existsSync50(resolveAuthBrokerSocketPath2());
90932
92390
  } catch {
90933
92391
  return false;
90934
92392
  }
@@ -90937,6 +92395,7 @@ var fleetFallbackGate = createFleetFallbackGate({
90937
92395
  dedupMs: FLEET_FALLBACK_DEDUP_MS,
90938
92396
  brokerReachable: isAuthBrokerSocketReachable
90939
92397
  });
92398
+ var modelUnavailableCardRegistry = createModelUnavailableCardRegistry();
90940
92399
  var fleetFallbackResumeGate = createFleetFallbackResumeGate({
90941
92400
  maxAgeMs: (() => {
90942
92401
  const v = Number(process.env.SWITCHROOM_RESUME_MAX_AGE_MS);
@@ -91024,6 +92483,16 @@ async function doFireFleetAutoFallback(triggerAgent, untilMs) {
91024
92483
  return outcome.kind === "switched";
91025
92484
  const opts = { disable_notification: true };
91026
92485
  for (const chat_id of access.allowFrom) {
92486
+ const card = modelUnavailableCardRegistry.take(String(chat_id), Date.now());
92487
+ if (decideAnnouncementDelivery(outcome.kind, card) === "edit" && card) {
92488
+ try {
92489
+ await bot.api.editMessageText(chat_id, card.messageId, richMessage2(foldAnnouncementIntoCard(card.text, outcome.announcement)), {});
92490
+ continue;
92491
+ } catch (err) {
92492
+ process.stderr.write(`telegram gateway: [fleet-fallback] card edit failed chat=${chat_id} \u2014 sending announcement separately: ${err?.message ?? err}
92493
+ `);
92494
+ }
92495
+ }
91027
92496
  swallowingApiCall(() => bot.api.sendRichMessage(chat_id, richMessage2(outcome.announcement), opts), { chat_id, verb: "fleet-fallback:notify" });
91028
92497
  }
91029
92498
  if (outcome.kind === "switched") {
@@ -91144,6 +92613,38 @@ async function runQuotaWatch(opts = {}) {
91144
92613
  `);
91145
92614
  }
91146
92615
  process.stderr.write(`telegram gateway: quota-watch: fleet all-exhausted ${fleetDecision.transition}
92616
+ `);
92617
+ }
92618
+ }
92619
+ {
92620
+ const rollPrev = watchState[FLEET_ROLL_ANNOUNCE_KEY] ?? emptyAccountState();
92621
+ const rollDecision = evaluateFleetRollAnnounce({
92622
+ roll: listStateData.last_fleet_roll ?? null,
92623
+ prev: rollPrev,
92624
+ now
92625
+ });
92626
+ if (rollDecision.kind === "notify") {
92627
+ for (const chat_id of access.allowFrom) {
92628
+ if (tuning.fleetDedup) {
92629
+ const granted = await claimQuotaNotification(brokerClient, buildQuotaClaimKey(FLEET_ROLL_ANNOUNCE_KEY, String(listStateData.last_fleet_roll.at), chat_id));
92630
+ if (!granted) {
92631
+ process.stderr.write(`telegram gateway: quota-watch: fleet-roll claim denied chat=${chat_id} \u2014 another agent notified
92632
+ `);
92633
+ continue;
92634
+ }
92635
+ }
92636
+ await swallowingApiCall(() => bot.api.sendRichMessage(chat_id, richMessage2(rollDecision.message), {
92637
+ disable_notification: true
92638
+ }), { chat_id, verb: "quota-watch.fleet-roll" });
92639
+ }
92640
+ watchState = patchQuotaWatchState(watchState, FLEET_ROLL_ANNOUNCE_KEY, rollDecision.newState);
92641
+ try {
92642
+ saveQuotaWatchState(stateDir, watchState);
92643
+ } catch (err) {
92644
+ process.stderr.write(`telegram gateway: quota-watch: fleet-roll state save failed: ${err}
92645
+ `);
92646
+ }
92647
+ process.stderr.write(`telegram gateway: quota-watch: announced broker fleet roll ${listStateData.last_fleet_roll.from} \u2192 ${listStateData.last_fleet_roll.to}
91147
92648
  `);
91148
92649
  }
91149
92650
  }
@@ -91151,9 +92652,10 @@ async function runQuotaWatch(opts = {}) {
91151
92652
  const labelToSnapIndex = new Map(snapshots.map((s, i) => [s.label, i]));
91152
92653
  let reconciledCount = 0;
91153
92654
  let mutatedState = watchState;
92655
+ const lastRoll = listStateData.last_fleet_roll ?? null;
91154
92656
  for (const snap of snapshots) {
91155
92657
  const prev = watchState[snap.label] ?? emptyAccountState();
91156
- const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now, bootTick, tuning });
92658
+ const decision = evaluateQuotaWatchAccount({ agentName: agentName3, snap, prev, now, bootTick, tuning, lastRoll });
91157
92659
  if (decision.kind === "reconcile") {
91158
92660
  mutatedState = patchQuotaWatchState(mutatedState, decision.accountLabel, decision.newAccountState);
91159
92661
  reconciledCount++;
@@ -91209,7 +92711,7 @@ async function runQuotaWatch(opts = {}) {
91209
92711
  if (isLiveCorroboration(freshEntry) && freshEntry.result.ok && snapIndex >= 0) {
91210
92712
  const enrichedSnap = { ...snapshots[snapIndex], quota: freshEntry.result.data, capturedAtMs: undefined };
91211
92713
  const prev = watchState[accountLabel] ?? emptyAccountState();
91212
- const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now, bootTick, tuning });
92714
+ const re = evaluateQuotaWatchAccount({ agentName: agentName3, snap: enrichedSnap, prev, now, bootTick, tuning, lastRoll });
91213
92715
  if (re.kind === "notify" && re.transition === decision.transition) {
91214
92716
  enrichedDecision = re;
91215
92717
  } else if (re.kind === "reconcile") {
@@ -92045,6 +93547,28 @@ bot.on("callback_query:data", async (ctx) => {
92045
93547
  await ctx.answerCallbackQuery({ text: "Not authorized." });
92046
93548
  return;
92047
93549
  }
93550
+ const effLevel = data.startsWith("eff:s:") ? data.slice("eff:s:".length) : null;
93551
+ const effCardMsgId = ctx.callbackQuery?.message?.message_id;
93552
+ if (effLevel != null && currentTurn !== null && effCardMsgId != null) {
93553
+ await ctx.answerCallbackQuery({ text: "\uD83D\uDCE5 Queued \u2014 applies when the turn ends" }).catch(() => {});
93554
+ const effChatId = String(ctx.chat?.id ?? "");
93555
+ const effThreadId = resolveThreadId(effChatId, ctx.callbackQuery?.message?.message_thread_id);
93556
+ await ctx.editMessageText(richMessage2(hardenCardBreaks2(ackText("effort", effLevel, escapeHtmlForTg2))), {
93557
+ reply_markup: { inline_keyboard: [] }
93558
+ }).catch(() => {});
93559
+ enqueueSessionCommand({
93560
+ kind: "effort",
93561
+ origin: "menu",
93562
+ arg: data,
93563
+ targetLabel: effLevel,
93564
+ chatId: effChatId,
93565
+ threadId: effThreadId,
93566
+ ackChatId: effChatId,
93567
+ ackMessageId: effCardMsgId,
93568
+ requestedAt: Date.now()
93569
+ });
93570
+ return;
93571
+ }
92048
93572
  await ctx.answerCallbackQuery({ text: "Setting effort\u2026" }).catch(() => {});
92049
93573
  try {
92050
93574
  const outcome = await handleEffortMenuCallback(data, buildEffortDeps());
@@ -92075,6 +93599,46 @@ bot.on("callback_query:data", async (ctx) => {
92075
93599
  const cbThreadId = resolveThreadId(cbChatId, ctx.callbackQuery?.message?.message_thread_id);
92076
93600
  const modelDeps = buildModelDeps({ chatId: cbChatId, threadId: cbThreadId });
92077
93601
  if (modelDeps.isBusy()) {
93602
+ const cardMsgId = ctx.callbackQuery?.message?.message_id;
93603
+ let queueArg = null;
93604
+ let queueOrigin = "menu";
93605
+ let queueLabel = "the selected model";
93606
+ if (data.startsWith(MODEL_CALLBACK_SR)) {
93607
+ const srName = data.slice(MODEL_CALLBACK_SR.length);
93608
+ if (isValidModelArg(srName)) {
93609
+ queueArg = srName;
93610
+ queueOrigin = "typed";
93611
+ queueLabel = srFriendlyLabel(srName);
93612
+ }
93613
+ } else if (data.startsWith(MODEL_CALLBACK_ALIAS)) {
93614
+ const alias = data.slice(MODEL_CALLBACK_ALIAS.length);
93615
+ if (isValidModelArg(alias)) {
93616
+ queueArg = alias;
93617
+ queueOrigin = "typed";
93618
+ queueLabel = alias;
93619
+ }
93620
+ } else if (data.startsWith("mdl:s:")) {
93621
+ queueArg = data;
93622
+ queueOrigin = "menu";
93623
+ }
93624
+ if (queueArg != null && cardMsgId != null) {
93625
+ await ctx.answerCallbackQuery({ text: "\uD83D\uDCE5 Queued \u2014 applies when the turn ends" }).catch(() => {});
93626
+ await ctx.editMessageText(richMessage2(hardenCardBreaks2(ackText("model", queueLabel, escapeHtmlForTg2))), {
93627
+ reply_markup: { inline_keyboard: [] }
93628
+ }).catch(() => {});
93629
+ enqueueSessionCommand({
93630
+ kind: "model",
93631
+ origin: queueOrigin,
93632
+ arg: queueArg,
93633
+ targetLabel: queueLabel,
93634
+ chatId: cbChatId,
93635
+ threadId: cbThreadId,
93636
+ ackChatId: cbChatId,
93637
+ ackMessageId: cardMsgId,
93638
+ requestedAt: Date.now()
93639
+ });
93640
+ return;
93641
+ }
92078
93642
  await ctx.answerCallbackQuery({ text: "\u23F3 Agent is mid-turn \u2014 tap again when it\u2019s idle", show_alert: false }).catch(() => {});
92079
93643
  return;
92080
93644
  }
@@ -92099,56 +93663,14 @@ bot.on("callback_query:data", async (ctx) => {
92099
93663
  }
92100
93664
  return;
92101
93665
  }
92102
- const didInterimSrEdit = false;
92103
93666
  try {
92104
93667
  const prevSessionModel = sessionModelSource.getOverride();
92105
93668
  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)
93669
+ if (outcome.toastOnly)
92126
93670
  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
- }
93671
+ const { restartNotice } = recordModelMenuSideEffects(outcome, modelDeps, cbChatId, cbThreadId, prevSessionModel);
93672
+ if (restartNotice) {
93673
+ await ctx.editMessageText(richMessage2(restartNotice), { reply_markup: { inline_keyboard: [] } }).catch(() => {});
92152
93674
  return;
92153
93675
  }
92154
93676
  await ctx.editMessageText(richMessage2(outcome.reply.text), {
@@ -92365,7 +93887,7 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
92365
93887
  let audio = null;
92366
93888
  if (entry.filePath != null) {
92367
93889
  try {
92368
- audio = readFileSync51(entry.filePath);
93890
+ audio = readFileSync53(entry.filePath);
92369
93891
  } catch {
92370
93892
  audio = null;
92371
93893
  }
@@ -92443,8 +93965,8 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
92443
93965
  const cbMessageId = ctx.callbackQuery?.message?.message_id;
92444
93966
  const metaForMessage = cbMessageId != null ? agentButtonMeta.get(`${cbChatId}:${cbMessageId}`) : undefined;
92445
93967
  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(() => {});
93968
+ const ackText3 = typeof tapMeta?.ack_text === "string" && tapMeta.ack_text.length > 0 ? tapMeta.ack_text : "\u2713 received";
93969
+ await ctx.answerCallbackQuery({ text: ackText3 }).catch(() => {});
92448
93970
  const buttonText = (() => {
92449
93971
  const msg2 = ctx.callbackQuery?.message;
92450
93972
  const kb = msg2 && "reply_markup" in msg2 ? msg2.reply_markup : undefined;
@@ -92596,7 +94118,7 @@ ${interimLabel}` : interimLabel
92596
94118
  const unifiedDiff = (() => {
92597
94119
  try {
92598
94120
  const cfgPath = process.env.SWITCHROOM_CONFIG ?? SWITCHROOM_CONFIG ?? findConfigFile2();
92599
- const raw = readFileSync51(cfgPath, "utf8");
94121
+ const raw = readFileSync53(cfgPath, "utf8");
92600
94122
  return synthesizeAllowRuleDiff({ agentName: agentName3, rule: chosen.rule, configText: raw });
92601
94123
  } catch (err) {
92602
94124
  process.stderr.write(`telegram gateway: always-allow diff synth failed: ${err.message}
@@ -92732,7 +94254,7 @@ ${editLabel}` : editLabel), {
92732
94254
  }
92733
94255
  const scopedMins = Math.max(1, Math.round(scopedTtl / 60000));
92734
94256
  const windowGranted = timeBox != null && grantAgent !== "";
92735
- const ackText = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
94257
+ const ackText2 = behavior === "deny" ? "\u274C Denied" : windowGranted ? `\u2705 Allowed (${scopedMins} min)` : "\u2705 Allowed once";
92736
94258
  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
94259
  const msg = ctx.callbackQuery?.message;
92738
94260
  const baseText = msg && "text" in msg && msg.text ? escapeHtmlForTg2(msg.text) : "";
@@ -92745,7 +94267,7 @@ ${editLabel}` : editLabel), {
92745
94267
 
92746
94268
  ${resumeLine}` : htmlLabel;
92747
94269
  await finalizeCallback2(ctx, {
92748
- ackText: ackText.slice(0, 200),
94270
+ ackText: ackText2.slice(0, 200),
92749
94271
  newText: baseText ? `${baseText}
92750
94272
 
92751
94273
  ${labelWithResume}` : labelWithResume,
@@ -92787,7 +94309,7 @@ bot.on("message:photo", async (ctx) => {
92787
94309
  });
92788
94310
  mkdirSync39(INBOX_DIR, { recursive: true, mode: 448 });
92789
94311
  assertInsideInbox(INBOX_DIR, dlPath);
92790
- writeFileSync41(dlPath, buf, { mode: 384 });
94312
+ writeFileSync42(dlPath, buf, { mode: 384 });
92791
94313
  return dlPath;
92792
94314
  } catch (err) {
92793
94315
  const msg = err instanceof Error ? err.message : "unknown error";
@@ -93487,6 +95009,7 @@ bot.catch((err) => {
93487
95009
  `);
93488
95010
  });
93489
95011
  var SHUTDOWN_DRAIN_BUDGET_MS = 35000;
95012
+ var SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS = 1500;
93490
95013
  function countInFlight() {
93491
95014
  return pendingPermissions.size + pendingVaultOps.size + inboundCoalescer.size() + pendingReauthFlows.size;
93492
95015
  }
@@ -93498,6 +95021,7 @@ async function shutdown(signal) {
93498
95021
  const agentName3 = process.env.SWITCHROOM_AGENT_NAME ?? "-";
93499
95022
  process.stderr.write(`telegram gateway: shutting down
93500
95023
  `);
95024
+ bridgeDeadWatchdog.stop();
93501
95025
  const isOsSignal = signal === "SIGTERM" || signal === "SIGINT";
93502
95026
  if (isOsSignal) {
93503
95027
  try {
@@ -93508,12 +95032,43 @@ async function shutdown(signal) {
93508
95032
  `);
93509
95033
  } catch (err) {
93510
95034
  process.stderr.write(`telegram gateway: shutdown.clean_marker_write_failed err=${err.message}
95035
+ `);
95036
+ }
95037
+ try {
95038
+ const smDir = resolveAgentDirFromEnv();
95039
+ if (smDir != null) {
95040
+ const hasOverride = readSessionModelFile(smDir) != null;
95041
+ const intentAlreadyStamped = existsSync50(join53(smDir, RELAUNCH_MODEL_INTENT_FILE));
95042
+ if (hasOverride && !intentAlreadyStamped) {
95043
+ writeRelaunchModelIntent(smDir, "keep", `${GATEWAY_SHUTDOWN_INTENT_REASON_PREFIX} graceful ${signal} shutdown (deploy/rolling restart) \u2014 preserving user-chosen session model`);
95044
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamped signal=${signal}
95045
+ `);
95046
+ }
95047
+ }
95048
+ } catch (err) {
95049
+ process.stderr.write(`telegram gateway: shutdown.session_model_keep_stamp_failed err=${err.message}
93511
95050
  `);
93512
95051
  }
93513
95052
  } else {
93514
95053
  process.stderr.write(`telegram gateway: shutdown.clean_marker_skipped signal=${signal} (crash path \u2014 banner will fire on next boot)
93515
95054
  `);
93516
95055
  }
95056
+ const orphanedCmdActions = shutdownResolutionActions(pendingSessionCommand, escapeHtmlForTg2);
95057
+ const orphanedCmdEdits = orphanedCmdActions.map((a) => ({
95058
+ chatId: a.cmd.ackChatId,
95059
+ messageId: a.cmd.ackMessageId,
95060
+ text: persistQueuedCommandForRestart(a)
95061
+ }));
95062
+ if (orphanedCmdEdits.length > 0) {
95063
+ process.stderr.write(`telegram gateway: shutdown.pending_cmd_resolved count=${orphanedCmdEdits.length}
95064
+ `);
95065
+ await Promise.race([
95066
+ Promise.allSettled(orphanedCmdEdits.map((e) => editPendingCommandCard(e.chatId, e.messageId, e.text))),
95067
+ new Promise((resolve11) => {
95068
+ setTimeout(resolve11, SHUTDOWN_PROGRESS_FLUSH_BUDGET_MS).unref();
95069
+ })
95070
+ ]);
95071
+ }
93517
95072
  const shutdownTurn = currentTurn;
93518
95073
  if (turnsDb != null && shutdownTurn?.registryKey != null) {
93519
95074
  const wasScheduledRestart = pendingRestarts.size > 0;
@@ -93927,9 +95482,9 @@ var didOneTimeSetup = false;
93927
95482
  const smAgentDir = resolveAgentDirFromEnv();
93928
95483
  if (smAgentDir) {
93929
95484
  const activePath = join53(smAgentDir, ".active-session-model");
93930
- if (existsSync49(activePath)) {
95485
+ if (existsSync50(activePath)) {
93931
95486
  try {
93932
- const launched = readFileSync51(activePath, "utf8").trim();
95487
+ const launched = readFileSync53(activePath, "utf8").trim();
93933
95488
  const configured = (() => {
93934
95489
  const d = switchroomExecJson(["agent", "list"]);
93935
95490
  const raw = d?.agents?.find((a) => a.name === getMyAgentName())?.model ?? null;
@@ -93939,15 +95494,15 @@ var didOneTimeSetup = false;
93939
95494
  } catch {}
93940
95495
  }
93941
95496
  const alertPath = join53(smAgentDir, ".session-model-alert");
93942
- if (existsSync49(alertPath)) {
95497
+ if (existsSync50(alertPath)) {
93943
95498
  let alertText = null;
93944
95499
  try {
93945
- alertText = readFileSync51(alertPath, "utf8").trim();
95500
+ alertText = readFileSync53(alertPath, "utf8").trim();
93946
95501
  } catch {
93947
95502
  alertText = null;
93948
95503
  }
93949
95504
  try {
93950
- unlinkSync20(alertPath);
95505
+ unlinkSync21(alertPath);
93951
95506
  } catch {}
93952
95507
  if (alertText && alertText.length > 0) {
93953
95508
  const operators = loadAccess().allowFrom;
@@ -94067,6 +95622,20 @@ var didOneTimeSetup = false;
94067
95622
  parentStateDir: STATE_DIR,
94068
95623
  log: (msg) => process.stderr.write(`telegram gateway: ${msg}
94069
95624
  `),
95625
+ onResurrect: (agentId, _description) => {
95626
+ try {
95627
+ workerActivityFeed?.resurrect(agentId);
95628
+ } catch (err) {
95629
+ process.stderr.write(`telegram gateway: worker resurrect error agent=${agentId}: ${err.message}
95630
+ `);
95631
+ }
95632
+ process.stderr.write(`telegram gateway: worker ${agentId} card RESURRECTED \u2014 false terminal finish reversed, worker resumed (issue #3023)
95633
+ `);
95634
+ },
95635
+ onWorkerLost: (agentId, _description) => {
95636
+ process.stderr.write(`telegram gateway: worker ${agentId} NAMED AS LOST \u2014 falsely finalised twice, resurrection chain bound reached (issue #3023)
95637
+ `);
95638
+ },
94070
95639
  onFinish: ({ agentId, outcome, description, resultText, toolCount, durationMs, background: entryBackground }) => {
94071
95640
  deferredDoneReactions.promote();
94072
95641
  let fleetChatId = "";