switchroom 0.15.20 → 0.15.22

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.
@@ -50477,8 +50477,8 @@ var {
50477
50477
  } = import__.default;
50478
50478
 
50479
50479
  // src/build-info.ts
50480
- var VERSION = "0.15.20";
50481
- var COMMIT_SHA = "0b63ab9e";
50480
+ var VERSION = "0.15.22";
50481
+ var COMMIT_SHA = "a6c13429";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
@@ -20817,6 +20817,85 @@ function stripCallerAllow(cfg, caller) {
20817
20817
  return clone;
20818
20818
  }
20819
20819
 
20820
+ // src/host-control/config-blast-radius.ts
20821
+ function toObject2(v) {
20822
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
20823
+ }
20824
+ function changedConfigPaths(before, after, prefix = "") {
20825
+ if (deepEqual(before, after))
20826
+ return [];
20827
+ const bObj = before && typeof before === "object" && !Array.isArray(before);
20828
+ const aObj = after && typeof after === "object" && !Array.isArray(after);
20829
+ if (bObj && aObj) {
20830
+ const keys = new Set([
20831
+ ...Object.keys(before),
20832
+ ...Object.keys(after)
20833
+ ]);
20834
+ const out = [];
20835
+ for (const k of keys) {
20836
+ out.push(...changedConfigPaths(before[k], after[k], prefix ? `${prefix}.${k}` : k));
20837
+ }
20838
+ return out;
20839
+ }
20840
+ return [prefix || "<root>"];
20841
+ }
20842
+ function deepEqual(a, b) {
20843
+ if (a === b)
20844
+ return true;
20845
+ if (typeof a !== typeof b)
20846
+ return false;
20847
+ if (a && b && typeof a === "object") {
20848
+ if (Array.isArray(a) !== Array.isArray(b))
20849
+ return false;
20850
+ if (Array.isArray(a) && Array.isArray(b)) {
20851
+ if (a.length !== b.length)
20852
+ return false;
20853
+ return a.every((x, i) => deepEqual(x, b[i]));
20854
+ }
20855
+ const ao = a;
20856
+ const bo = b;
20857
+ const keys = new Set([...Object.keys(ao), ...Object.keys(bo)]);
20858
+ for (const k of keys)
20859
+ if (!deepEqual(ao[k], bo[k]))
20860
+ return false;
20861
+ return true;
20862
+ }
20863
+ return false;
20864
+ }
20865
+ function classifyBlastRadius(beforeYaml, afterYaml) {
20866
+ let before;
20867
+ let after;
20868
+ try {
20869
+ before = toObject2($parse(beforeYaml));
20870
+ after = toObject2($parse(afterYaml));
20871
+ } catch {
20872
+ return { agents: [], fleetWide: true, changedPaths: ["<unparseable>"] };
20873
+ }
20874
+ const changedPaths = changedConfigPaths(before, after).sort();
20875
+ if (changedPaths.length === 0) {
20876
+ return { agents: [], fleetWide: false, changedPaths: [] };
20877
+ }
20878
+ const agents = new Set;
20879
+ let fleetWide = false;
20880
+ for (const path2 of changedPaths) {
20881
+ if (path2 === "<root>") {
20882
+ fleetWide = true;
20883
+ continue;
20884
+ }
20885
+ const segs = path2.split(".");
20886
+ if (segs[0] === "agents" && segs.length >= 2) {
20887
+ agents.add(segs[1]);
20888
+ } else {
20889
+ fleetWide = true;
20890
+ }
20891
+ }
20892
+ return {
20893
+ agents: fleetWide ? [] : [...agents].sort(),
20894
+ fleetWide,
20895
+ changedPaths
20896
+ };
20897
+ }
20898
+
20820
20899
  // src/host-control/server.ts
20821
20900
  function resolveDigests(imageRefs) {
20822
20901
  const out = new Map;
@@ -21509,7 +21588,12 @@ class HostdServer {
21509
21588
  const runner = this.opts.runReconcile ?? (async () => this.runSwitchroom(["apply"]));
21510
21589
  const recRes = await runner({ requestId: approvalId });
21511
21590
  if (recRes.exit_code === 0) {
21512
- await approval.finalize({ outcome: "applied" });
21591
+ const blast = classifyBlastRadius(snapshot, postApply);
21592
+ await approval.finalize({
21593
+ outcome: "applied",
21594
+ affectedAgents: blast.agents,
21595
+ fleetWide: blast.fleetWide
21596
+ });
21513
21597
  return {
21514
21598
  v: 1,
21515
21599
  request_id: req.request_id,
@@ -21893,7 +21977,9 @@ class SocketApprovalGateway {
21893
21977
  type: "request_config_finalize",
21894
21978
  requestId: req.requestId,
21895
21979
  outcome: outcome.outcome,
21896
- ...outcome.detail ? { detail: outcome.detail } : {}
21980
+ ...outcome.detail ? { detail: outcome.detail } : {},
21981
+ ...outcome.affectedAgents ? { affectedAgents: outcome.affectedAgents } : {},
21982
+ ...outcome.fleetWide !== undefined ? { fleetWide: outcome.fleetWide } : {}
21897
21983
  }) + `
21898
21984
  `);
21899
21985
  client2.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.20",
3
+ "version": "0.15.22",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -31013,6 +31013,7 @@ __export(exports_config_approval_handler, {
31013
31013
  parseConfigApprovalCallback: () => parseConfigApprovalCallback,
31014
31014
  handleRequestConfigFinalize: () => handleRequestConfigFinalize,
31015
31015
  handleRequestConfigApproval: () => handleRequestConfigApproval,
31016
+ buildLiveNote: () => buildLiveNote,
31016
31017
  buildConfigApprovalCardBody: () => buildConfigApprovalCardBody,
31017
31018
  _resetPendingConfigApprovalsForTest: () => _resetPendingConfigApprovalsForTest,
31018
31019
  _peekPendingConfigApprovalForTest: () => _peekPendingConfigApprovalForTest,
@@ -31156,6 +31157,22 @@ async function resolvePendingConfigApproval(requestId, verdict, deps) {
31156
31157
  }
31157
31158
  return true;
31158
31159
  }
31160
+ function buildLiveNote(affectedAgents, fleetWide) {
31161
+ if (fleetWide) {
31162
+ return `
31163
+
31164
+ \u26a0\ufe0f Shared config changed \u2014 affects all agents. Not live until they ` + `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`;
31165
+ }
31166
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
31167
+ if (agents.length === 0)
31168
+ return "";
31169
+ const list2 = agents.map(escapeHtml12).join(", ");
31170
+ const cmds = agents.map((a) => `/restart ${escapeHtml12(a)}`).join(" \u00b7 ");
31171
+ return `
31172
+
31173
+ \uD83D\uDD04 Not live until restart \u2014 affects: <b>${list2}</b>
31174
+ ${cmds}`;
31175
+ }
31159
31176
  async function handleRequestConfigFinalize(_client, msg, deps) {
31160
31177
  const entry = pending.get(msg.requestId);
31161
31178
  if (!entry) {
@@ -31163,8 +31180,9 @@ async function handleRequestConfigFinalize(_client, msg, deps) {
31163
31180
  return;
31164
31181
  }
31165
31182
  pending.delete(msg.requestId);
31183
+ const liveNote = msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
31166
31184
  const body = msg.outcome === "applied" ? `\u2705 <b>Applied</b>${msg.detail ? `
31167
- ${escapeHtml12(msg.detail)}` : ""}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31185
+ ${escapeHtml12(msg.detail)}` : ""}${liveNote}` : `\u26a0\ufe0f <b>Reconcile failed; rolled back</b>${msg.detail ? `
31168
31186
  ${escapeHtml12(msg.detail)}` : ""}`;
31169
31187
  try {
31170
31188
  await deps.editCard({
@@ -45454,43 +45472,32 @@ async function handleEffortCommand(parsed, deps) {
45454
45472
  const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`;
45455
45473
  let result;
45456
45474
  try {
45457
- result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`);
45475
+ result = await deps.applyEffort(deps.getAgentName(), parsed.level);
45458
45476
  } catch (err) {
45459
45477
  const msg = err instanceof Error ? err.message : String(err);
45460
- return { text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`, html: true };
45478
+ return { text: `\u274c ${verbHtml} \u2014 failed: ${deps.escapeHtml(msg)}`, html: true };
45461
45479
  }
45462
- if (result.outcome === "ok") {
45463
- return {
45464
- text: [
45465
- `${verbHtml}`,
45466
- deps.preBlock(result.output),
45467
- ...result.truncated ? ["<i>truncated</i>"] : [],
45468
- PERSIST_NOTE2
45469
- ].join(`
45470
- `),
45471
- html: true
45472
- };
45480
+ return { text: applyResultText(parsed.level, result, deps), html: true };
45481
+ }
45482
+ function applyResultText(level, result, deps) {
45483
+ const verbHtml = `<code>/effort ${deps.escapeHtml(level)}</code>`;
45484
+ if (result.ok) {
45485
+ const lines = [`\u2705 ${verbHtml} \u2014 ${deps.escapeHtml(result.output)}`];
45486
+ if (result.confirmed) {
45487
+ lines.push("<i>Switched mid-conversation \u2014 your next turn re-reads the cached history (slower, one time).</i>");
45488
+ }
45489
+ lines.push(PERSIST_NOTE2);
45490
+ return lines.join(`
45491
+ `);
45473
45492
  }
45474
- if (result.outcome === "ok_no_output") {
45475
- return {
45476
- text: [
45477
- `${verbHtml} \u2014 sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
45478
- PERSIST_NOTE2
45479
- ].join(`
45480
- `),
45481
- html: true
45482
- };
45493
+ if (result.reason === "session_missing") {
45494
+ return "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.";
45483
45495
  }
45484
- if (result.errorCode === "session_missing") {
45485
- return {
45486
- text: "\u274c tmux session not found \u2014 the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.",
45487
- html: true
45488
- };
45496
+ if (result.reason === "confirm_failed") {
45497
+ const wedged = result.wedged ? " The confirmation prompt may still be open on the pane \u2014 check it." : " The change was cancelled and the pane left as it was.";
45498
+ return `\u274c ${verbHtml} \u2014 couldn't confirm the switch.${wedged}`;
45489
45499
  }
45490
- return {
45491
- text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
45492
- html: true
45493
- };
45500
+ return `\u274c ${verbHtml} \u2014 sent, but couldn't confirm it applied. The agent may be mid-turn; check <code>/inject /status</code>.`;
45494
45501
  }
45495
45502
  var EFFORT_CALLBACK_PREFIX = "eff:";
45496
45503
  var EFFORT_CALLBACK_SELECT = "eff:s:";
@@ -45531,18 +45538,20 @@ async function handleEffortMenuCallback(data, deps) {
45531
45538
  let banner;
45532
45539
  let selected;
45533
45540
  try {
45534
- const result = await deps.inject(deps.getAgentName(), `/effort ${level}`);
45535
- if (result.outcome === "ok" || result.outcome === "ok_no_output") {
45536
- banner = `\u2705 Effort \u2192 <code>${deps.escapeHtml(level)}</code> for this session`;
45541
+ const result = await deps.applyEffort(deps.getAgentName(), level);
45542
+ if (result.ok) {
45543
+ banner = result.confirmed ? `\u2705 Effort \u2192 <code>${deps.escapeHtml(level)}</code> (mid-conversation: next turn re-reads history)` : `\u2705 Effort \u2192 <code>${deps.escapeHtml(level)}</code> for this session`;
45537
45544
  selected = level;
45538
- } else if (result.errorCode === "session_missing") {
45545
+ } else if (result.reason === "session_missing") {
45539
45546
  banner = "\u274c tmux session not found \u2014 is the agent running under the supervisor?";
45547
+ } else if (result.reason === "confirm_failed") {
45548
+ banner = result.wedged ? "\u26a0\ufe0f Couldn\u2019t confirm the switch \u2014 the prompt may still be open on the pane." : "\u274c Couldn\u2019t confirm the switch \u2014 cancelled, effort unchanged.";
45540
45549
  } else {
45541
- banner = `\u274c couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`;
45550
+ banner = "\u274c Sent, but couldn\u2019t confirm it applied (agent may be mid-turn).";
45542
45551
  }
45543
45552
  } catch (err) {
45544
45553
  const msg = err instanceof Error ? err.message : String(err);
45545
- banner = `\u274c inject failed: ${deps.escapeHtml(msg)}`;
45554
+ banner = `\u274c failed: ${deps.escapeHtml(msg)}`;
45546
45555
  }
45547
45556
  const menu = buildEffortMenu(deps, selected);
45548
45557
  return {
@@ -45552,6 +45561,80 @@ ${menu.text}` },
45552
45561
  };
45553
45562
  }
45554
45563
 
45564
+ // ../src/agents/effort-picker.ts
45565
+ var CONFIRM_RE = /Change effort level\?/i;
45566
+ function appliedRe(level) {
45567
+ return new RegExp(`${level}\\s*\\u00b7\\s*/effort`);
45568
+ }
45569
+ function applyLine(pane, level) {
45570
+ const re = new RegExp(`Set effort level to ${level}\\b.*`, "i");
45571
+ for (const line of pane.split(`
45572
+ `)) {
45573
+ const m = line.match(re);
45574
+ if (m)
45575
+ return m[0].trim();
45576
+ }
45577
+ return `Set effort level to ${level}`;
45578
+ }
45579
+ var realSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
45580
+ async function applyEffort(agentName3, level, opts = {}) {
45581
+ const runner = opts._runner ?? makeTmuxRunner2(opts.tmuxBin ?? "tmux");
45582
+ const socket = opts.socketName ?? `switchroom-${agentName3}`;
45583
+ const session = opts.sessionName ?? agentName3;
45584
+ const stepMs = opts.stepMs ?? 600;
45585
+ const timeoutMs = opts.timeoutMs ?? 1e4;
45586
+ const sleep2 = opts._sleep ?? realSleep2;
45587
+ const log = opts._log ?? ((line) => process.stderr.write(`${line}
45588
+ `));
45589
+ if (!runner.hasSession(socket, session)) {
45590
+ return { ok: false, reason: "session_missing" };
45591
+ }
45592
+ return withPaneLock(`${socket}:${session}`, async () => {
45593
+ const startedAt = Date.now();
45594
+ const expired2 = () => Date.now() - startedAt >= timeoutMs;
45595
+ try {
45596
+ runner.send(socket, session, ["send-keys", "-l", `/effort ${level}`]);
45597
+ runner.send(socket, session, ["send-keys", "Enter"]);
45598
+ let confirmed = false;
45599
+ let confirmKeys = 0;
45600
+ while (!expired2()) {
45601
+ await sleep2(stepMs);
45602
+ const pane = runner.capture(socket, session) ?? "";
45603
+ if (CONFIRM_RE.test(pane)) {
45604
+ if (confirmKeys >= 2) {
45605
+ runner.send(socket, session, ["send-keys", "Escape"]);
45606
+ await sleep2(stepMs);
45607
+ const after = runner.capture(socket, session) ?? "";
45608
+ log(`effort-picker: confirm modal would not dismiss for ${agentName3} ` + `(socket=${socket}) \u2014 cancelled`);
45609
+ return { ok: false, reason: "confirm_failed", wedged: CONFIRM_RE.test(after) };
45610
+ }
45611
+ runner.send(socket, session, ["send-keys", "Enter"]);
45612
+ confirmed = true;
45613
+ confirmKeys += 1;
45614
+ continue;
45615
+ }
45616
+ if (appliedRe(level).test(pane)) {
45617
+ return { ok: true, level, confirmed, output: applyLine(pane, level) };
45618
+ }
45619
+ }
45620
+ const final = runner.capture(socket, session) ?? "";
45621
+ if (CONFIRM_RE.test(final)) {
45622
+ runner.send(socket, session, ["send-keys", "Escape"]);
45623
+ log(`effort-picker: timeout with modal open for ${agentName3} \u2014 cancelled`);
45624
+ return { ok: false, reason: "confirm_failed", wedged: true };
45625
+ }
45626
+ return { ok: false, reason: "apply_unverified" };
45627
+ } finally {
45628
+ try {
45629
+ const pane = runner.capture(socket, session) ?? "";
45630
+ if (CONFIRM_RE.test(pane)) {
45631
+ runner.send(socket, session, ["send-keys", "Escape"]);
45632
+ }
45633
+ } catch {}
45634
+ }
45635
+ });
45636
+ }
45637
+
45555
45638
  // ../src/config/loader.ts
45556
45639
  init_dist();
45557
45640
  init_zod();
@@ -47576,6 +47659,16 @@ function validateClientMessage(msg) {
47576
47659
  return false;
47577
47660
  if (m.detail !== undefined && (typeof m.detail !== "string" || m.detail.length > 500))
47578
47661
  return false;
47662
+ if (m.affectedAgents !== undefined) {
47663
+ if (!Array.isArray(m.affectedAgents) || m.affectedAgents.length > 64)
47664
+ return false;
47665
+ for (const a of m.affectedAgents) {
47666
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a))
47667
+ return false;
47668
+ }
47669
+ }
47670
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean")
47671
+ return false;
47579
47672
  return true;
47580
47673
  }
47581
47674
  case "request_drive_approval": {
@@ -54327,11 +54420,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54327
54420
  }
54328
54421
 
54329
54422
  // ../src/build-info.ts
54330
- var VERSION = "0.15.20";
54331
- var COMMIT_SHA = "0b63ab9e";
54332
- var COMMIT_DATE = "2026-06-14T10:58:14+10:00";
54333
- var LATEST_PR = null;
54334
- var COMMITS_AHEAD_OF_TAG = 5;
54423
+ var VERSION = "0.15.22";
54424
+ var COMMIT_SHA = "a6c13429";
54425
+ var COMMIT_DATE = "2026-06-14T03:27:21Z";
54426
+ var LATEST_PR = 2349;
54427
+ var COMMITS_AHEAD_OF_TAG = 0;
54335
54428
 
54336
54429
  // gateway/boot-version.ts
54337
54430
  function formatRelativeAgo(iso) {
@@ -55829,6 +55922,23 @@ function formatFeedElapsed(ms) {
55829
55922
  function turnInFlightForGate() {
55830
55923
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
55831
55924
  }
55925
+ function deliverResumeSyntheticOrBuffer(agent, inbound) {
55926
+ const decision = decideInboundDelivery({
55927
+ turnInFlight: turnInFlightForGate(),
55928
+ isSteering: false,
55929
+ isInterrupt: false
55930
+ });
55931
+ if (decision === "buffer-until-idle") {
55932
+ pendingInboundBuffer.push(agent, inbound);
55933
+ return false;
55934
+ }
55935
+ const delivered = ipcServer.sendToAgent(agent, inbound);
55936
+ if (delivered)
55937
+ markClaudeBusyForInbound(inbound);
55938
+ else
55939
+ pendingInboundBuffer.push(agent, inbound);
55940
+ return delivered;
55941
+ }
55832
55942
  var pendingRestarts = new Map;
55833
55943
  var lastSessionActiveFile = null;
55834
55944
  var compactState = initialCompactState();
@@ -59311,11 +59421,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
59311
59421
  stage_id: armed.stageId
59312
59422
  }
59313
59423
  };
59314
- const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg);
59315
- if (fdelivered)
59316
- markClaudeBusyForInbound(failMsg);
59317
- else
59318
- pendingInboundBuffer.push(armed.agent, failMsg);
59424
+ deliverResumeSyntheticOrBuffer(armed.agent, failMsg);
59319
59425
  return true;
59320
59426
  }
59321
59427
  await switchroomReply(ctx, `\u2705 saved as <code>vault:${escapeHtmlForTg(armed.key)}</code> (masked: <code>${escapeHtmlForTg(maskToken2(value))}</code>). The agent can now reference it.`, { html: true });
@@ -59337,11 +59443,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
59337
59443
  stage_id: armed.stageId
59338
59444
  }
59339
59445
  };
59340
- const delivered = ipcServer.sendToAgent(armed.agent, synthetic);
59341
- if (delivered)
59342
- markClaudeBusyForInbound(synthetic);
59343
- else
59344
- pendingInboundBuffer.push(armed.agent, synthetic);
59446
+ const delivered = deliverResumeSyntheticOrBuffer(armed.agent, synthetic);
59345
59447
  process.stderr.write(`telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}
59346
59448
  `);
59347
59449
  return true;
@@ -59403,11 +59505,7 @@ async function handleSecretRequestCallback(ctx, data) {
59403
59505
  stage_id: stageId
59404
59506
  }
59405
59507
  };
59406
- const delivered = ipcServer.sendToAgent(pending2.agent, synthetic);
59407
- if (delivered)
59408
- markClaudeBusyForInbound(synthetic);
59409
- else
59410
- pendingInboundBuffer.push(pending2.agent, synthetic);
59508
+ deliverResumeSyntheticOrBuffer(pending2.agent, synthetic);
59411
59509
  return;
59412
59510
  }
59413
59511
  await ctx.answerCallbackQuery().catch(() => {});
@@ -62334,14 +62432,13 @@ bot.command("model", async (ctx) => {
62334
62432
  });
62335
62433
  function buildEffortDeps() {
62336
62434
  return {
62337
- inject: injectSlashCommand,
62435
+ applyEffort: (agent, level) => applyEffort(agent, level),
62338
62436
  getAgentName: getMyAgentName,
62339
62437
  getConfiguredEffort: () => {
62340
62438
  const data = switchroomExecJson(["agent", "list"]);
62341
62439
  return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
62342
62440
  },
62343
- escapeHtml: escapeHtmlForTg,
62344
- preBlock
62441
+ escapeHtml: escapeHtmlForTg
62345
62442
  };
62346
62443
  }
62347
62444
  function effortMenuReplyMarkup(reply) {
@@ -63608,14 +63705,9 @@ async function performVaultAccessApproval(ctx, pending2, stageId, senderId, atte
63608
63705
  stageId,
63609
63706
  operatorId: senderId
63610
63707
  });
63611
- const delivered = ipcServer.sendToAgent(pending2.agent, synthetic);
63612
- if (delivered)
63613
- markClaudeBusyForInbound(synthetic);
63708
+ const delivered = deliverResumeSyntheticOrBuffer(pending2.agent, synthetic);
63614
63709
  process.stderr.write(`telegram gateway: vault_grant_approved injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${delivered}
63615
63710
  `);
63616
- if (!delivered) {
63617
- pendingInboundBuffer.push(pending2.agent, synthetic);
63618
- }
63619
63711
  }
63620
63712
  async function handleVaultRequestAccessCallback(ctx, data) {
63621
63713
  const senderId = String(ctx.from?.id ?? "");
@@ -63657,14 +63749,9 @@ async function handleVaultRequestAccessCallback(ctx, data) {
63657
63749
  stageId,
63658
63750
  operatorId: senderId
63659
63751
  });
63660
- const denyDelivered = ipcServer.sendToAgent(pending2.agent, denyInbound);
63661
- if (denyDelivered)
63662
- markClaudeBusyForInbound(denyInbound);
63752
+ const denyDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, denyInbound);
63663
63753
  process.stderr.write(`telegram gateway: vault_grant_denied injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${denyDelivered}
63664
63754
  `);
63665
- if (!denyDelivered) {
63666
- pendingInboundBuffer.push(pending2.agent, denyInbound);
63667
- }
63668
63755
  return;
63669
63756
  }
63670
63757
  if (action === "approve") {
@@ -63756,13 +63843,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63756
63843
  stageId,
63757
63844
  operatorId: senderId
63758
63845
  });
63759
- const dDelivered = ipcServer.sendToAgent(pending2.agent, discardInbound);
63760
- if (dDelivered)
63761
- markClaudeBusyForInbound(discardInbound);
63846
+ const dDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, discardInbound);
63762
63847
  process.stderr.write(`telegram gateway: vault_save_discarded injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${dDelivered}
63763
63848
  `);
63764
- if (!dDelivered)
63765
- pendingInboundBuffer.push(pending2.agent, discardInbound);
63766
63849
  return;
63767
63850
  }
63768
63851
  if (action === "rename") {
@@ -63823,13 +63906,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63823
63906
  operatorId: senderId,
63824
63907
  reason: failReason
63825
63908
  });
63826
- const fDelivered = ipcServer.sendToAgent(pending2.agent, failInbound);
63827
- if (fDelivered)
63828
- markClaudeBusyForInbound(failInbound);
63909
+ const fDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, failInbound);
63829
63910
  process.stderr.write(`telegram gateway: vault_save_failed injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${fDelivered}
63830
63911
  `);
63831
- if (!fDelivered)
63832
- pendingInboundBuffer.push(pending2.agent, failInbound);
63833
63912
  return;
63834
63913
  }
63835
63914
  pendingVaultRequestSaves.delete(stageId);
@@ -63847,13 +63926,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63847
63926
  stageId,
63848
63927
  operatorId: senderId
63849
63928
  });
63850
- const okDelivered = ipcServer.sendToAgent(pending2.agent, okInbound);
63851
- if (okDelivered)
63852
- markClaudeBusyForInbound(okInbound);
63929
+ const okDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, okInbound);
63853
63930
  process.stderr.write(`telegram gateway: vault_save_completed injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${okDelivered}
63854
63931
  `);
63855
- if (!okDelivered)
63856
- pendingInboundBuffer.push(pending2.agent, okInbound);
63857
63932
  return;
63858
63933
  }
63859
63934
  await ctx.answerCallbackQuery({ text: "Unknown action" }).catch(() => {});
@@ -13,6 +13,7 @@
13
13
  import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
14
14
  import {
15
15
  buildConfigApprovalCardBody,
16
+ buildLiveNote,
16
17
  handleRequestConfigApproval,
17
18
  handleRequestConfigFinalize,
18
19
  parseConfigApprovalCallback,
@@ -266,6 +267,29 @@ describe("timeout path", () => {
266
267
  });
267
268
  });
268
269
 
270
+ describe("buildLiveNote", () => {
271
+ it("names specific affected agents + the per-agent restart command", () => {
272
+ const note = buildLiveNote(["clerk", "gymbro"], false);
273
+ expect(note).toContain("clerk, gymbro");
274
+ expect(note).toContain("/restart clerk");
275
+ expect(note).toContain("/restart gymbro");
276
+ expect(note).toContain("Not live until restart");
277
+ });
278
+ it("guides to a full rollout when fleet-wide (no per-agent list)", () => {
279
+ const note = buildLiveNote([], true);
280
+ expect(note).toContain("all agents");
281
+ expect(note).toContain("switchroom rollout");
282
+ expect(note).not.toContain("/restart");
283
+ });
284
+ it("is empty when nothing is runtime-affected", () => {
285
+ expect(buildLiveNote([], false)).toBe("");
286
+ expect(buildLiveNote(undefined, undefined)).toBe("");
287
+ });
288
+ it("HTML-escapes agent names", () => {
289
+ expect(buildLiveNote(["a<b>"], false)).toContain("a&lt;b&gt;");
290
+ });
291
+ });
292
+
269
293
  describe("handleRequestConfigFinalize", () => {
270
294
  it("edits the card to '✅ Applied' on success", async () => {
271
295
  const { client, deps, editCalls } = fakeDeps();
@@ -377,6 +377,27 @@ export async function resolvePendingConfigApproval(
377
377
  return true;
378
378
  }
379
379
 
380
+ /**
381
+ * The "make it live" note appended to an Applied card. claude loads config at
382
+ * boot, so an applied edit is inert in the running agents until they restart —
383
+ * this names exactly what must bounce (and the command) instead of letting the
384
+ * change silently not take effect. Fleet-wide (shared config) → guide to a full
385
+ * rollout, never a per-agent list. Empty when nothing runtime-affected.
386
+ */
387
+ export function buildLiveNote(affectedAgents?: string[], fleetWide?: boolean): string {
388
+ if (fleetWide) {
389
+ return (
390
+ `\n\n⚠️ Shared config changed — affects all agents. Not live until they ` +
391
+ `restart: run <code>switchroom rollout</code> (or <code>/update apply</code>).`
392
+ );
393
+ }
394
+ const agents = (affectedAgents ?? []).filter((a) => typeof a === "string" && a.length > 0);
395
+ if (agents.length === 0) return "";
396
+ const list = agents.map(escapeHtml).join(", ");
397
+ const cmds = agents.map((a) => `/restart ${escapeHtml(a)}`).join(" · ");
398
+ return `\n\n🔄 Not live until restart — affects: <b>${list}</b>\n${cmds}`;
399
+ }
400
+
380
401
  /** IPC `request_config_finalize` handler — edits the card to the terminal outcome. */
381
402
  export async function handleRequestConfigFinalize(
382
403
  _client: Pick<IpcClient, "send">,
@@ -393,9 +414,15 @@ export async function handleRequestConfigFinalize(
393
414
  // Clean up the pending entry — finalize is the terminal transition.
394
415
  pending.delete(msg.requestId);
395
416
 
417
+ // On apply, tell the operator what must restart for the edit to go LIVE —
418
+ // claude loads config at boot, so an applied edit is inert until restart.
419
+ // Specific agents → name them + the one-liner to bounce them; shared config
420
+ // → guide to a full rollout (never silently leave the change un-live).
421
+ const liveNote =
422
+ msg.outcome === "applied" ? buildLiveNote(msg.affectedAgents, msg.fleetWide) : "";
396
423
  const body =
397
424
  msg.outcome === "applied"
398
- ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`
425
+ ? `✅ <b>Applied</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}${liveNote}`
399
426
  : `⚠️ <b>Reconcile failed; rolled back</b>${msg.detail ? `\n${escapeHtml(msg.detail)}` : ""}`;
400
427
  try {
401
428
  await deps.editCard({