switchroom 0.15.20 → 0.15.21

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.21";
50481
+ var COMMIT_SHA = "36706e85";
50482
50482
 
50483
50483
  // src/cli/agent.ts
50484
50484
  init_source();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.20",
3
+ "version": "0.15.21",
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": {
@@ -45454,43 +45454,32 @@ async function handleEffortCommand(parsed, deps) {
45454
45454
  const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`;
45455
45455
  let result;
45456
45456
  try {
45457
- result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`);
45457
+ result = await deps.applyEffort(deps.getAgentName(), parsed.level);
45458
45458
  } catch (err) {
45459
45459
  const msg = err instanceof Error ? err.message : String(err);
45460
- return { text: `\u274c ${verbHtml} \u2014 inject failed: ${deps.escapeHtml(msg)}`, html: true };
45460
+ return { text: `\u274c ${verbHtml} \u2014 failed: ${deps.escapeHtml(msg)}`, html: true };
45461
45461
  }
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
- };
45462
+ return { text: applyResultText(parsed.level, result, deps), html: true };
45463
+ }
45464
+ function applyResultText(level, result, deps) {
45465
+ const verbHtml = `<code>/effort ${deps.escapeHtml(level)}</code>`;
45466
+ if (result.ok) {
45467
+ const lines = [`\u2705 ${verbHtml} \u2014 ${deps.escapeHtml(result.output)}`];
45468
+ if (result.confirmed) {
45469
+ lines.push("<i>Switched mid-conversation \u2014 your next turn re-reads the cached history (slower, one time).</i>");
45470
+ }
45471
+ lines.push(PERSIST_NOTE2);
45472
+ return lines.join(`
45473
+ `);
45473
45474
  }
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
- };
45475
+ if (result.reason === "session_missing") {
45476
+ 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
45477
  }
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
- };
45478
+ if (result.reason === "confirm_failed") {
45479
+ 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.";
45480
+ return `\u274c ${verbHtml} \u2014 couldn't confirm the switch.${wedged}`;
45489
45481
  }
45490
- return {
45491
- text: `\u274c ${verbHtml} \u2014 ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`,
45492
- html: true
45493
- };
45482
+ return `\u274c ${verbHtml} \u2014 sent, but couldn't confirm it applied. The agent may be mid-turn; check <code>/inject /status</code>.`;
45494
45483
  }
45495
45484
  var EFFORT_CALLBACK_PREFIX = "eff:";
45496
45485
  var EFFORT_CALLBACK_SELECT = "eff:s:";
@@ -45531,18 +45520,20 @@ async function handleEffortMenuCallback(data, deps) {
45531
45520
  let banner;
45532
45521
  let selected;
45533
45522
  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`;
45523
+ const result = await deps.applyEffort(deps.getAgentName(), level);
45524
+ if (result.ok) {
45525
+ 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
45526
  selected = level;
45538
- } else if (result.errorCode === "session_missing") {
45527
+ } else if (result.reason === "session_missing") {
45539
45528
  banner = "\u274c tmux session not found \u2014 is the agent running under the supervisor?";
45529
+ } else if (result.reason === "confirm_failed") {
45530
+ 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
45531
  } else {
45541
- banner = `\u274c couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? "inject failed")}`;
45532
+ banner = "\u274c Sent, but couldn\u2019t confirm it applied (agent may be mid-turn).";
45542
45533
  }
45543
45534
  } catch (err) {
45544
45535
  const msg = err instanceof Error ? err.message : String(err);
45545
- banner = `\u274c inject failed: ${deps.escapeHtml(msg)}`;
45536
+ banner = `\u274c failed: ${deps.escapeHtml(msg)}`;
45546
45537
  }
45547
45538
  const menu = buildEffortMenu(deps, selected);
45548
45539
  return {
@@ -45552,6 +45543,80 @@ ${menu.text}` },
45552
45543
  };
45553
45544
  }
45554
45545
 
45546
+ // ../src/agents/effort-picker.ts
45547
+ var CONFIRM_RE = /Change effort level\?/i;
45548
+ function appliedRe(level) {
45549
+ return new RegExp(`${level}\\s*\\u00b7\\s*/effort`);
45550
+ }
45551
+ function applyLine(pane, level) {
45552
+ const re = new RegExp(`Set effort level to ${level}\\b.*`, "i");
45553
+ for (const line of pane.split(`
45554
+ `)) {
45555
+ const m = line.match(re);
45556
+ if (m)
45557
+ return m[0].trim();
45558
+ }
45559
+ return `Set effort level to ${level}`;
45560
+ }
45561
+ var realSleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
45562
+ async function applyEffort(agentName3, level, opts = {}) {
45563
+ const runner = opts._runner ?? makeTmuxRunner2(opts.tmuxBin ?? "tmux");
45564
+ const socket = opts.socketName ?? `switchroom-${agentName3}`;
45565
+ const session = opts.sessionName ?? agentName3;
45566
+ const stepMs = opts.stepMs ?? 600;
45567
+ const timeoutMs = opts.timeoutMs ?? 1e4;
45568
+ const sleep2 = opts._sleep ?? realSleep2;
45569
+ const log = opts._log ?? ((line) => process.stderr.write(`${line}
45570
+ `));
45571
+ if (!runner.hasSession(socket, session)) {
45572
+ return { ok: false, reason: "session_missing" };
45573
+ }
45574
+ return withPaneLock(`${socket}:${session}`, async () => {
45575
+ const startedAt = Date.now();
45576
+ const expired2 = () => Date.now() - startedAt >= timeoutMs;
45577
+ try {
45578
+ runner.send(socket, session, ["send-keys", "-l", `/effort ${level}`]);
45579
+ runner.send(socket, session, ["send-keys", "Enter"]);
45580
+ let confirmed = false;
45581
+ let confirmKeys = 0;
45582
+ while (!expired2()) {
45583
+ await sleep2(stepMs);
45584
+ const pane = runner.capture(socket, session) ?? "";
45585
+ if (CONFIRM_RE.test(pane)) {
45586
+ if (confirmKeys >= 2) {
45587
+ runner.send(socket, session, ["send-keys", "Escape"]);
45588
+ await sleep2(stepMs);
45589
+ const after = runner.capture(socket, session) ?? "";
45590
+ log(`effort-picker: confirm modal would not dismiss for ${agentName3} ` + `(socket=${socket}) \u2014 cancelled`);
45591
+ return { ok: false, reason: "confirm_failed", wedged: CONFIRM_RE.test(after) };
45592
+ }
45593
+ runner.send(socket, session, ["send-keys", "Enter"]);
45594
+ confirmed = true;
45595
+ confirmKeys += 1;
45596
+ continue;
45597
+ }
45598
+ if (appliedRe(level).test(pane)) {
45599
+ return { ok: true, level, confirmed, output: applyLine(pane, level) };
45600
+ }
45601
+ }
45602
+ const final = runner.capture(socket, session) ?? "";
45603
+ if (CONFIRM_RE.test(final)) {
45604
+ runner.send(socket, session, ["send-keys", "Escape"]);
45605
+ log(`effort-picker: timeout with modal open for ${agentName3} \u2014 cancelled`);
45606
+ return { ok: false, reason: "confirm_failed", wedged: true };
45607
+ }
45608
+ return { ok: false, reason: "apply_unverified" };
45609
+ } finally {
45610
+ try {
45611
+ const pane = runner.capture(socket, session) ?? "";
45612
+ if (CONFIRM_RE.test(pane)) {
45613
+ runner.send(socket, session, ["send-keys", "Escape"]);
45614
+ }
45615
+ } catch {}
45616
+ }
45617
+ });
45618
+ }
45619
+
45555
45620
  // ../src/config/loader.ts
45556
45621
  init_dist();
45557
45622
  init_zod();
@@ -54327,11 +54392,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54327
54392
  }
54328
54393
 
54329
54394
  // ../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;
54395
+ var VERSION = "0.15.21";
54396
+ var COMMIT_SHA = "36706e85";
54397
+ var COMMIT_DATE = "2026-06-14T01:34:05Z";
54398
+ var LATEST_PR = 2345;
54399
+ var COMMITS_AHEAD_OF_TAG = 0;
54335
54400
 
54336
54401
  // gateway/boot-version.ts
54337
54402
  function formatRelativeAgo(iso) {
@@ -55829,6 +55894,23 @@ function formatFeedElapsed(ms) {
55829
55894
  function turnInFlightForGate() {
55830
55895
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0;
55831
55896
  }
55897
+ function deliverResumeSyntheticOrBuffer(agent, inbound) {
55898
+ const decision = decideInboundDelivery({
55899
+ turnInFlight: turnInFlightForGate(),
55900
+ isSteering: false,
55901
+ isInterrupt: false
55902
+ });
55903
+ if (decision === "buffer-until-idle") {
55904
+ pendingInboundBuffer.push(agent, inbound);
55905
+ return false;
55906
+ }
55907
+ const delivered = ipcServer.sendToAgent(agent, inbound);
55908
+ if (delivered)
55909
+ markClaudeBusyForInbound(inbound);
55910
+ else
55911
+ pendingInboundBuffer.push(agent, inbound);
55912
+ return delivered;
55913
+ }
55832
55914
  var pendingRestarts = new Map;
55833
55915
  var lastSessionActiveFile = null;
55834
55916
  var compactState = initialCompactState();
@@ -59311,11 +59393,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
59311
59393
  stage_id: armed.stageId
59312
59394
  }
59313
59395
  };
59314
- const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg);
59315
- if (fdelivered)
59316
- markClaudeBusyForInbound(failMsg);
59317
- else
59318
- pendingInboundBuffer.push(armed.agent, failMsg);
59396
+ deliverResumeSyntheticOrBuffer(armed.agent, failMsg);
59319
59397
  return true;
59320
59398
  }
59321
59399
  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 +59415,7 @@ async function captureProvidedSecret(ctx, chat_id, msgId, value) {
59337
59415
  stage_id: armed.stageId
59338
59416
  }
59339
59417
  };
59340
- const delivered = ipcServer.sendToAgent(armed.agent, synthetic);
59341
- if (delivered)
59342
- markClaudeBusyForInbound(synthetic);
59343
- else
59344
- pendingInboundBuffer.push(armed.agent, synthetic);
59418
+ const delivered = deliverResumeSyntheticOrBuffer(armed.agent, synthetic);
59345
59419
  process.stderr.write(`telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}
59346
59420
  `);
59347
59421
  return true;
@@ -59403,11 +59477,7 @@ async function handleSecretRequestCallback(ctx, data) {
59403
59477
  stage_id: stageId
59404
59478
  }
59405
59479
  };
59406
- const delivered = ipcServer.sendToAgent(pending2.agent, synthetic);
59407
- if (delivered)
59408
- markClaudeBusyForInbound(synthetic);
59409
- else
59410
- pendingInboundBuffer.push(pending2.agent, synthetic);
59480
+ deliverResumeSyntheticOrBuffer(pending2.agent, synthetic);
59411
59481
  return;
59412
59482
  }
59413
59483
  await ctx.answerCallbackQuery().catch(() => {});
@@ -62334,14 +62404,13 @@ bot.command("model", async (ctx) => {
62334
62404
  });
62335
62405
  function buildEffortDeps() {
62336
62406
  return {
62337
- inject: injectSlashCommand,
62407
+ applyEffort: (agent, level) => applyEffort(agent, level),
62338
62408
  getAgentName: getMyAgentName,
62339
62409
  getConfiguredEffort: () => {
62340
62410
  const data = switchroomExecJson(["agent", "list"]);
62341
62411
  return data?.agents?.find((a) => a.name === getMyAgentName())?.thinking_effort ?? null;
62342
62412
  },
62343
- escapeHtml: escapeHtmlForTg,
62344
- preBlock
62413
+ escapeHtml: escapeHtmlForTg
62345
62414
  };
62346
62415
  }
62347
62416
  function effortMenuReplyMarkup(reply) {
@@ -63608,14 +63677,9 @@ async function performVaultAccessApproval(ctx, pending2, stageId, senderId, atte
63608
63677
  stageId,
63609
63678
  operatorId: senderId
63610
63679
  });
63611
- const delivered = ipcServer.sendToAgent(pending2.agent, synthetic);
63612
- if (delivered)
63613
- markClaudeBusyForInbound(synthetic);
63680
+ const delivered = deliverResumeSyntheticOrBuffer(pending2.agent, synthetic);
63614
63681
  process.stderr.write(`telegram gateway: vault_grant_approved injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${delivered}
63615
63682
  `);
63616
- if (!delivered) {
63617
- pendingInboundBuffer.push(pending2.agent, synthetic);
63618
- }
63619
63683
  }
63620
63684
  async function handleVaultRequestAccessCallback(ctx, data) {
63621
63685
  const senderId = String(ctx.from?.id ?? "");
@@ -63657,14 +63721,9 @@ async function handleVaultRequestAccessCallback(ctx, data) {
63657
63721
  stageId,
63658
63722
  operatorId: senderId
63659
63723
  });
63660
- const denyDelivered = ipcServer.sendToAgent(pending2.agent, denyInbound);
63661
- if (denyDelivered)
63662
- markClaudeBusyForInbound(denyInbound);
63724
+ const denyDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, denyInbound);
63663
63725
  process.stderr.write(`telegram gateway: vault_grant_denied injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${denyDelivered}
63664
63726
  `);
63665
- if (!denyDelivered) {
63666
- pendingInboundBuffer.push(pending2.agent, denyInbound);
63667
- }
63668
63727
  return;
63669
63728
  }
63670
63729
  if (action === "approve") {
@@ -63756,13 +63815,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63756
63815
  stageId,
63757
63816
  operatorId: senderId
63758
63817
  });
63759
- const dDelivered = ipcServer.sendToAgent(pending2.agent, discardInbound);
63760
- if (dDelivered)
63761
- markClaudeBusyForInbound(discardInbound);
63818
+ const dDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, discardInbound);
63762
63819
  process.stderr.write(`telegram gateway: vault_save_discarded injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${dDelivered}
63763
63820
  `);
63764
- if (!dDelivered)
63765
- pendingInboundBuffer.push(pending2.agent, discardInbound);
63766
63821
  return;
63767
63822
  }
63768
63823
  if (action === "rename") {
@@ -63823,13 +63878,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63823
63878
  operatorId: senderId,
63824
63879
  reason: failReason
63825
63880
  });
63826
- const fDelivered = ipcServer.sendToAgent(pending2.agent, failInbound);
63827
- if (fDelivered)
63828
- markClaudeBusyForInbound(failInbound);
63881
+ const fDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, failInbound);
63829
63882
  process.stderr.write(`telegram gateway: vault_save_failed injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${fDelivered}
63830
63883
  `);
63831
- if (!fDelivered)
63832
- pendingInboundBuffer.push(pending2.agent, failInbound);
63833
63884
  return;
63834
63885
  }
63835
63886
  pendingVaultRequestSaves.delete(stageId);
@@ -63847,13 +63898,9 @@ async function handleVaultRequestSaveCallback(ctx, data) {
63847
63898
  stageId,
63848
63899
  operatorId: senderId
63849
63900
  });
63850
- const okDelivered = ipcServer.sendToAgent(pending2.agent, okInbound);
63851
- if (okDelivered)
63852
- markClaudeBusyForInbound(okInbound);
63901
+ const okDelivered = deliverResumeSyntheticOrBuffer(pending2.agent, okInbound);
63853
63902
  process.stderr.write(`telegram gateway: vault_save_completed injection agent=${pending2.agent} key=${pending2.key} stage=${stageId} delivered=${okDelivered}
63854
63903
  `);
63855
- if (!okDelivered)
63856
- pendingInboundBuffer.push(pending2.agent, okInbound);
63857
63904
  return;
63858
63905
  }
63859
63906
  await ctx.answerCallbackQuery({ text: "Unknown action" }).catch(() => {});
@@ -22,7 +22,7 @@
22
22
  * unit-testable without booting the bot.
23
23
  */
24
24
 
25
- import type { InjectResult } from '../../src/agents/inject.js'
25
+ import type { EffortApplyResult } from '../../src/agents/effort-picker.js'
26
26
 
27
27
  /**
28
28
  * The effort levels the installed CLI accepts (`claude --help`:
@@ -66,8 +66,14 @@ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
66
66
  }
67
67
 
68
68
  export interface EffortCommandDeps {
69
- /** Inject primitive — wired to injectSlashCommand in the gateway. */
70
- inject: (agent: string, command: string) => Promise<InjectResult>
69
+ /**
70
+ * Apply an effort level to the live session. Wired to `applyEffort`
71
+ * (src/agents/effort-picker.ts), which types `/effort <level>` AND drives
72
+ * the "Change effort level?" confirmation modal that claude shows when the
73
+ * switch would invalidate a cached conversation — so it never wedges the
74
+ * pane the way a bare inject would.
75
+ */
76
+ applyEffort: (agent: string, level: string) => Promise<EffortApplyResult>
71
77
  getAgentName: () => string
72
78
  /**
73
79
  * The agent's cascade-resolved `thinking_effort` from
@@ -76,7 +82,6 @@ export interface EffortCommandDeps {
76
82
  */
77
83
  getConfiguredEffort: () => string | null
78
84
  escapeHtml: (s: string) => string
79
- preBlock: (s: string) => string
80
85
  }
81
86
 
82
87
  export interface EffortCommandReply {
@@ -127,52 +132,48 @@ export async function handleEffortCommand(
127
132
  return helpText(deps, `not a valid effort level: ${parsed.level}`)
128
133
  }
129
134
  const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`
130
- let result: InjectResult
135
+ let result: EffortApplyResult
131
136
  try {
132
- result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`)
137
+ result = await deps.applyEffort(deps.getAgentName(), parsed.level)
133
138
  } catch (err) {
134
139
  const msg = err instanceof Error ? err.message : String(err)
135
- return { text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`, html: true }
140
+ return { text: `❌ ${verbHtml} — failed: ${deps.escapeHtml(msg)}`, html: true }
136
141
  }
142
+ return { text: applyResultText(parsed.level, result, deps), html: true }
143
+ }
137
144
 
138
- if (result.outcome === 'ok') {
139
- return {
140
- text: [
141
- `${verbHtml}`,
142
- deps.preBlock(result.output),
143
- ...(result.truncated ? ['<i>truncated</i>'] : []),
144
- PERSIST_NOTE,
145
- ].join('\n'),
146
- html: true,
147
- }
148
- }
149
- if (result.outcome === 'ok_no_output') {
150
- return {
151
- text: [
152
- `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
153
- PERSIST_NOTE,
154
- ].join('\n'),
155
- html: true,
145
+ /**
146
+ * Render an effort-apply outcome. `confirmed` means a "Change effort level?"
147
+ * modal was answered — switching mid-conversation re-reads the history, so we
148
+ * say so honestly rather than just claiming success.
149
+ */
150
+ function applyResultText(level: string, result: EffortApplyResult, deps: EffortCommandDeps): string {
151
+ const verbHtml = `<code>/effort ${deps.escapeHtml(level)}</code>`
152
+ if (result.ok) {
153
+ const lines = [`✅ ${verbHtml} — ${deps.escapeHtml(result.output)}`]
154
+ if (result.confirmed) {
155
+ lines.push('<i>Switched mid-conversation — your next turn re-reads the cached history (slower, one time).</i>')
156
156
  }
157
+ lines.push(PERSIST_NOTE)
158
+ return lines.join('\n')
157
159
  }
158
- // outcome === 'failed'
159
- if (result.errorCode === 'session_missing') {
160
- return {
161
- text:
162
- '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
163
- html: true,
164
- }
160
+ if (result.reason === 'session_missing') {
161
+ return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.'
165
162
  }
166
- return {
167
- text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
168
- html: true,
163
+ if (result.reason === 'confirm_failed') {
164
+ const wedged = result.wedged
165
+ ? ' The confirmation prompt may still be open on the pane — check it.'
166
+ : ' The change was cancelled and the pane left as it was.'
167
+ return `❌ ${verbHtml} — couldn't confirm the switch.${wedged}`
169
168
  }
169
+ // apply_unverified
170
+ return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check <code>/inject /status</code>.`
170
171
  }
171
172
 
172
173
  // ---------------------------------------------------------------------------
173
174
  // Button menu — five fixed levels, the live one marked ✅. No live discovery
174
- // (the levels don't churn) and no picker-driving (the inline `/effort <level>`
175
- // form sets it directly), so this is far simpler than the /model menu.
175
+ // (the levels don't churn). A tap applies the level via applyEffort, which
176
+ // drives the confirmation modal so it never wedges the pane.
176
177
  // ---------------------------------------------------------------------------
177
178
 
178
179
  export interface EffortMenuKeyboardButton {
@@ -231,9 +232,11 @@ export interface EffortCallbackOutcome {
231
232
  }
232
233
 
233
234
  /**
234
- * Handle an `eff:*` callback tap. `eff:s:<level>` injects claude's
235
- * `/effort <level>` and re-renders the menu with a one-line banner and the
236
- * new level checked. Never throws failures render as a banner.
235
+ * Handle an `eff:*` callback tap. `eff:s:<level>` applies the level via
236
+ * applyEffort (which drives the confirmation modal, so a mid-conversation
237
+ * switch confirms cleanly instead of wedging the pane) and re-renders the
238
+ * menu with a one-line banner and the new level checked. Never throws —
239
+ * failures render as a banner.
237
240
  */
238
241
  export async function handleEffortMenuCallback(
239
242
  data: string,
@@ -249,21 +252,27 @@ export async function handleEffortMenuCallback(
249
252
  let banner: string
250
253
  let selected: string | undefined
251
254
  try {
252
- const result = await deps.inject(deps.getAgentName(), `/effort ${level}`)
253
- if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
254
- banner = `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
255
+ const result = await deps.applyEffort(deps.getAgentName(), level)
256
+ if (result.ok) {
257
+ banner = result.confirmed
258
+ ? `✅ Effort → <code>${deps.escapeHtml(level)}</code> (mid-conversation: next turn re-reads history)`
259
+ : `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
255
260
  selected = level
256
- } else if (result.errorCode === 'session_missing') {
261
+ } else if (result.reason === 'session_missing') {
257
262
  banner = '❌ tmux session not found — is the agent running under the supervisor?'
263
+ } else if (result.reason === 'confirm_failed') {
264
+ banner = result.wedged
265
+ ? '⚠️ Couldn’t confirm the switch — the prompt may still be open on the pane.'
266
+ : '❌ Couldn’t confirm the switch — cancelled, effort unchanged.'
258
267
  } else {
259
- banner = `❌ couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`
268
+ banner = '❌ Sent, but couldnt confirm it applied (agent may be mid-turn).'
260
269
  }
261
270
  } catch (err) {
262
271
  const msg = err instanceof Error ? err.message : String(err)
263
- banner = `❌ inject failed: ${deps.escapeHtml(msg)}`
272
+ banner = `❌ failed: ${deps.escapeHtml(msg)}`
264
273
  }
265
274
  // Re-render with the just-selected level checked (or the configured
266
- // default if the inject failed) and the banner on top.
275
+ // default if it didn't apply) and the banner on top.
267
276
  const menu = buildEffortMenu(deps, selected)
268
277
  return {
269
278
  reply: { ...menu, text: `${banner}\n${menu.text}` },
@@ -280,6 +280,7 @@ import {
280
280
  type EffortCommandDeps,
281
281
  type EffortMenuReply,
282
282
  } from './effort-command.js'
283
+ import { applyEffort } from '../../src/agents/effort-picker.js'
283
284
  import { type BannerState } from '../slot-banner.js'
284
285
  import { refreshBanner } from '../slot-banner-driver.js'
285
286
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -1718,6 +1719,53 @@ function formatFeedElapsed(ms: number): string {
1718
1719
  function turnInFlightForGate(): boolean {
1719
1720
  return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0
1720
1721
  }
1722
+
1723
+ /**
1724
+ * Deliver a synthetic "resume" inbound — the wake-up the gateway sends
1725
+ * after an operator approves/denies a vault grant, provides/declines a
1726
+ * requested secret, or completes/fails/discards a vault save — turn-gated
1727
+ * exactly like a real Telegram inbound.
1728
+ *
1729
+ * THE BUG (clerk `hotdoc/credentials`, 2026-06-13): these synthetics did a
1730
+ * raw `ipcServer.sendToAgent` and buffered ONLY on `delivered=false`
1731
+ * (bridge disconnected). But approvals routinely land WHILE the agent's
1732
+ * grant-requesting turn is still finishing — the socket write succeeds
1733
+ * (`delivered=true`) yet claude is mid-turn, so the channel notification
1734
+ * is typed into its TUI composer and stranded by the turn-completion race
1735
+ * (#1556, the lawgpt wedge). `delivered=true` → the buffer never rescued
1736
+ * it → the agent sat idle until the operator manually poked it. Observed:
1737
+ * injection 179ms BEFORE turn_end, then 2 minutes of silence.
1738
+ *
1739
+ * Fix: route through the SAME `decideInboundDelivery` gate the Telegram
1740
+ * `handleInbound` path uses. Mid-turn → `buffer-until-idle` (the
1741
+ * turn-complete hook `releaseTurnBufferGate → drainBufferedIfAllowed`,
1742
+ * plus the idle-drain timer, flush it the instant claude goes idle, where
1743
+ * it lands cleanly as a fresh turn). Idle → deliver now; buffer on a
1744
+ * genuine delivery miss exactly as before. Unlike the cron `inject_inbound`
1745
+ * path (deliberately ungated — at-least-once replay), a one-shot resume
1746
+ * synthetic must never strand, so it IS gated.
1747
+ *
1748
+ * Returns true iff delivered to the bridge now (false = buffered/held;
1749
+ * the caller's forensic log records this as `delivered=false`, which now
1750
+ * means "held mid-turn OR bridge-down" — both are "will flush when idle",
1751
+ * never "dropped").
1752
+ */
1753
+ function deliverResumeSyntheticOrBuffer(agent: string, inbound: InboundMessage): boolean {
1754
+ const decision = decideInboundDelivery({
1755
+ turnInFlight: turnInFlightForGate(),
1756
+ isSteering: false,
1757
+ isInterrupt: false,
1758
+ })
1759
+ if (decision === 'buffer-until-idle') {
1760
+ pendingInboundBuffer.push(agent, inbound)
1761
+ return false
1762
+ }
1763
+ const delivered = ipcServer.sendToAgent(agent, inbound)
1764
+ if (delivered) markClaudeBusyForInbound(inbound)
1765
+ else pendingInboundBuffer.push(agent, inbound)
1766
+ return delivered
1767
+ }
1768
+
1721
1769
  const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
1722
1770
 
1723
1771
  // ─── Proactive context compaction (session.max_context_tokens) ──────────
@@ -8908,9 +8956,7 @@ async function captureProvidedSecret(
8908
8956
  stage_id: armed.stageId,
8909
8957
  },
8910
8958
  }
8911
- const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
8912
- if (fdelivered) markClaudeBusyForInbound(failMsg)
8913
- else pendingInboundBuffer.push(armed.agent, failMsg)
8959
+ deliverResumeSyntheticOrBuffer(armed.agent, failMsg)
8914
8960
  return true
8915
8961
  }
8916
8962
 
@@ -8943,9 +8989,7 @@ async function captureProvidedSecret(
8943
8989
  stage_id: armed.stageId,
8944
8990
  },
8945
8991
  }
8946
- const delivered = ipcServer.sendToAgent(armed.agent, synthetic)
8947
- if (delivered) markClaudeBusyForInbound(synthetic)
8948
- else pendingInboundBuffer.push(armed.agent, synthetic)
8992
+ const delivered = deliverResumeSyntheticOrBuffer(armed.agent, synthetic)
8949
8993
  process.stderr.write(
8950
8994
  `telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}\n`,
8951
8995
  )
@@ -9026,9 +9070,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
9026
9070
  stage_id: stageId,
9027
9071
  },
9028
9072
  }
9029
- const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
9030
- if (delivered) markClaudeBusyForInbound(synthetic)
9031
- else pendingInboundBuffer.push(pending.agent, synthetic)
9073
+ deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
9032
9074
  return
9033
9075
  }
9034
9076
 
@@ -14286,7 +14328,7 @@ bot.command('model', async ctx => {
14286
14328
  // in effort-command.ts so it's unit-testable without booting the bot.
14287
14329
  function buildEffortDeps(): EffortCommandDeps {
14288
14330
  return {
14289
- inject: injectSlashCommandImpl,
14331
+ applyEffort: (agent, level) => applyEffort(agent, level),
14290
14332
  getAgentName: getMyAgentName,
14291
14333
  getConfiguredEffort: () => {
14292
14334
  type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
@@ -14294,7 +14336,6 @@ function buildEffortDeps(): EffortCommandDeps {
14294
14336
  return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
14295
14337
  },
14296
14338
  escapeHtml: escapeHtmlForTg,
14297
- preBlock,
14298
14339
  }
14299
14340
  }
14300
14341
 
@@ -16612,22 +16653,14 @@ async function performVaultAccessApproval(
16612
16653
  stageId,
16613
16654
  operatorId: senderId,
16614
16655
  })
16615
- const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
16616
- if (delivered) markClaudeBusyForInbound(synthetic)
16656
+ // Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
16657
+ // (flushed at turn-end) so the resume never strands in claude's
16658
+ // composer (#1556); idle → deliver; bridge-down → buffer (#1150).
16659
+ const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
16617
16660
  process.stderr.write(
16618
16661
  `telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
16619
16662
  `key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
16620
16663
  )
16621
- // #1150 root cause: if `delivered=false` the bridge wasn't connected
16622
- // at send-time (mid-reconnect, claude-session bouncing between
16623
- // turns, etc). Pre-fix this just logged + dropped — the agent stayed
16624
- // idle forever and the operator had to poke. Now we buffer the
16625
- // inbound so the next bridge-register call drains it. Bounded to
16626
- // 32 entries per agent (see pending-inbound-buffer.ts) — a never-
16627
- // reconnecting bridge can't fill memory.
16628
- if (!delivered) {
16629
- pendingInboundBuffer.push(pending.agent, synthetic)
16630
- }
16631
16664
  }
16632
16665
 
16633
16666
  async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
@@ -16693,15 +16726,11 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
16693
16726
  stageId,
16694
16727
  operatorId: senderId,
16695
16728
  })
16696
- const denyDelivered = ipcServer.sendToAgent(pending.agent, denyInbound)
16697
- if (denyDelivered) markClaudeBusyForInbound(denyInbound)
16729
+ const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
16698
16730
  process.stderr.write(
16699
16731
  `telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
16700
16732
  `key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
16701
16733
  )
16702
- if (!denyDelivered) {
16703
- pendingInboundBuffer.push(pending.agent, denyInbound)
16704
- }
16705
16734
  return
16706
16735
  }
16707
16736
 
@@ -16872,13 +16901,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
16872
16901
  stageId,
16873
16902
  operatorId: senderId,
16874
16903
  })
16875
- const dDelivered = ipcServer.sendToAgent(pending.agent, discardInbound)
16876
- if (dDelivered) markClaudeBusyForInbound(discardInbound)
16904
+ const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
16877
16905
  process.stderr.write(
16878
16906
  `telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
16879
16907
  `key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
16880
16908
  )
16881
- if (!dDelivered) pendingInboundBuffer.push(pending.agent, discardInbound)
16882
16909
  return
16883
16910
  }
16884
16911
 
@@ -17001,13 +17028,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
17001
17028
  operatorId: senderId,
17002
17029
  reason: failReason,
17003
17030
  })
17004
- const fDelivered = ipcServer.sendToAgent(pending.agent, failInbound)
17005
- if (fDelivered) markClaudeBusyForInbound(failInbound)
17031
+ const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
17006
17032
  process.stderr.write(
17007
17033
  `telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
17008
17034
  `key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
17009
17035
  )
17010
- if (!fDelivered) pendingInboundBuffer.push(pending.agent, failInbound)
17011
17036
  return
17012
17037
  }
17013
17038
 
@@ -17036,13 +17061,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
17036
17061
  stageId,
17037
17062
  operatorId: senderId,
17038
17063
  })
17039
- const okDelivered = ipcServer.sendToAgent(pending.agent, okInbound)
17040
- if (okDelivered) markClaudeBusyForInbound(okInbound)
17064
+ const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
17041
17065
  process.stderr.write(
17042
17066
  `telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
17043
17067
  `key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
17044
17068
  )
17045
- if (!okDelivered) pendingInboundBuffer.push(pending.agent, okInbound)
17046
17069
  return
17047
17070
  }
17048
17071
 
@@ -21,40 +21,29 @@ import {
21
21
  EFFORT_CALLBACK_PREFIX,
22
22
  type EffortCommandDeps,
23
23
  } from "../gateway/effort-command.js";
24
- import type { InjectResult } from "../../src/agents/inject.js";
25
-
26
- function okResult(output: string): InjectResult {
27
- return {
28
- outcome: "ok",
29
- output,
30
- truncated: false,
31
- command: "/effort",
32
- meta: { description: "Set reasoning effort", expectsOutput: true },
33
- };
24
+ import type { EffortApplyResult } from "../../src/agents/effort-picker.js";
25
+
26
+ function applyOk(level: string, confirmed = false): EffortApplyResult {
27
+ return { ok: true, level, confirmed, output: `Set effort level to ${level}` };
34
28
  }
35
29
 
36
- function failedResult(errorMessage: string): InjectResult {
37
- return {
38
- outcome: "failed",
39
- output: "",
40
- truncated: false,
41
- command: "/effort",
42
- errorMessage,
43
- meta: { description: "Set reasoning effort", expectsOutput: true },
44
- };
30
+ function applyFail(
31
+ reason: "session_missing" | "confirm_failed" | "apply_unverified",
32
+ wedged?: boolean,
33
+ ): EffortApplyResult {
34
+ return { ok: false, reason, ...(wedged !== undefined ? { wedged } : {}) };
45
35
  }
46
36
 
47
37
  function makeDeps(overrides: Partial<EffortCommandDeps> = {}) {
48
- const calls: Array<{ agent: string; command: string }> = [];
38
+ const calls: Array<{ agent: string; level: string }> = [];
49
39
  const deps: EffortCommandDeps = {
50
- inject: async (agent, command) => {
51
- calls.push({ agent, command });
52
- return okResult("Set effort level to high");
40
+ applyEffort: async (agent, level) => {
41
+ calls.push({ agent, level });
42
+ return applyOk(level);
53
43
  },
54
44
  getAgentName: () => "carrie",
55
45
  getConfiguredEffort: () => "low",
56
46
  escapeHtml: (s) => s,
57
- preBlock: (s) => `<pre>${s}</pre>`,
58
47
  ...overrides,
59
48
  };
60
49
  return { deps, calls };
@@ -127,18 +116,24 @@ describe("effort-command: handler", () => {
127
116
  expect(r.text).toContain("low");
128
117
  });
129
118
 
130
- it("set injects exactly '/effort <level>' and relays output", async () => {
119
+ it("set applies exactly the level and relays output", async () => {
131
120
  const { deps, calls } = makeDeps();
132
121
  const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
133
- expect(calls).toEqual([{ agent: "carrie", command: "/effort high" }]);
122
+ expect(calls).toEqual([{ agent: "carrie", level: "high" }]);
134
123
  expect(r.text).toContain("Set effort level to high");
135
124
  expect(r.text).toMatch(/reverts to the configured default/);
136
125
  });
137
126
 
138
- it("set surfaces an inject failure", async () => {
139
- const { deps } = makeDeps({ inject: async () => failedResult("pane locked") });
127
+ it("set notes the re-read cost when a confirmation was needed", async () => {
128
+ const { deps } = makeDeps({ applyEffort: async (_a, l) => applyOk(l, true) });
129
+ const r = await handleEffortCommand({ kind: "set", level: "high" }, deps);
130
+ expect(r.text).toMatch(/re-reads the cached history/);
131
+ });
132
+
133
+ it("set surfaces a confirm_failed outcome honestly", async () => {
134
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("confirm_failed", false) });
140
135
  const r = await handleEffortCommand({ kind: "set", level: "max" }, deps);
141
- expect(r.text).toContain("pane locked");
136
+ expect(r.text).toContain("couldn't confirm the switch");
142
137
  expect(r.text).toContain("❌");
143
138
  });
144
139
 
@@ -146,7 +141,7 @@ describe("effort-command: handler", () => {
146
141
  const { deps, calls } = makeDeps();
147
142
  // Hand-craft a parsed object that skipped the parser's gate.
148
143
  const r = await handleEffortCommand({ kind: "set", level: "evil; rm -rf" as never }, deps);
149
- expect(calls).toEqual([]); // never injected
144
+ expect(calls).toEqual([]); // never applied
150
145
  expect(r.text).toMatch(/not a valid effort level/);
151
146
  });
152
147
  });
@@ -164,24 +159,38 @@ describe("effort-command: menu + callback", () => {
164
159
  expect(menu.keyboard![0]).toHaveLength(5);
165
160
  });
166
161
 
167
- it("callback eff:s:<level> injects the level and checks it in the re-render", async () => {
162
+ it("callback eff:s:<level> applies the level and checks it in the re-render", async () => {
168
163
  const { deps, calls } = makeDeps();
169
164
  const out = await handleEffortMenuCallback(effortSelectCallbackData("xhigh"), deps);
170
- expect(calls).toEqual([{ agent: "carrie", command: "/effort xhigh" }]);
165
+ expect(calls).toEqual([{ agent: "carrie", level: "xhigh" }]);
171
166
  expect(out.selectedEffort).toBe("xhigh");
172
167
  expect(out.reply.text).toContain("Effort → ");
173
168
  const checked = out.reply.keyboard!.flat().find((b) => b.text.startsWith("✅"));
174
169
  expect(checked?.text).toBe("✅ xhigh");
175
170
  });
176
171
 
177
- it("callback with a failed inject keeps the menu and shows the error, no selection", async () => {
178
- const { deps } = makeDeps({ inject: async () => failedResult("session_missing") });
172
+ it("callback notes the re-read when a confirmation was answered", async () => {
173
+ const { deps } = makeDeps({ applyEffort: async (_a, l) => applyOk(l, true) });
174
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("high"), deps);
175
+ expect(out.reply.text).toMatch(/re-reads history/);
176
+ expect(out.selectedEffort).toBe("high");
177
+ });
178
+
179
+ it("callback with a failed apply keeps the menu and shows the error, no selection", async () => {
180
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("session_missing") });
179
181
  const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
180
182
  expect(out.selectedEffort).toBeUndefined();
181
183
  expect(out.reply.text).toContain("❌");
182
184
  expect(out.reply.keyboard!.flat()).toHaveLength(5); // buttons preserved
183
185
  });
184
186
 
187
+ it("callback surfaces a wedged confirm_failed as a warning, no selection", async () => {
188
+ const { deps } = makeDeps({ applyEffort: async () => applyFail("confirm_failed", true) });
189
+ const out = await handleEffortMenuCallback(effortSelectCallbackData("max"), deps);
190
+ expect(out.selectedEffort).toBeUndefined();
191
+ expect(out.reply.text).toMatch(/may still be open/);
192
+ });
193
+
185
194
  it("callback ignores a malformed level", async () => {
186
195
  const { deps, calls } = makeDeps();
187
196
  const out = await handleEffortMenuCallback(`${EFFORT_CALLBACK_PREFIX}s:bogus`, deps);
@@ -39,17 +39,28 @@ function extractPerformBlock(): string {
39
39
  describe("performVaultAccessApproval injects a synthetic inbound on success (#1052)", () => {
40
40
  const block = extractPerformBlock();
41
41
 
42
- it("calls ipcServer.sendToAgent AFTER successful mint + token-write", () => {
42
+ it("routes the resume injection through the turn-gated helper AFTER successful mint + token-write", () => {
43
43
  // fails when: the auto-resume injection gets dropped. Pre-fix
44
44
  // operator had to message the agent again to resume the task —
45
45
  // the injection is the load-bearing wiring.
46
- expect(block, "missing ipcServer.sendToAgent call").toMatch(/ipcServer\.sendToAgent\(/);
46
+ //
47
+ // The raw `ipcServer.sendToAgent` was replaced by
48
+ // `deliverResumeSyntheticOrBuffer` (the mid-turn-strand fix,
49
+ // 2026-06-14): a resume delivered while the grant-requesting turn
50
+ // is still finishing used to strand in claude's composer
51
+ // (delivered=true but mid-turn → #1556). The helper turn-gates the
52
+ // send (buffer-until-idle when a turn is in flight) so the resume
53
+ // always lands as a fresh turn. Pinning the helper call (not raw
54
+ // sendToAgent) is the new load-bearing contract.
55
+ expect(block, "missing deliverResumeSyntheticOrBuffer call").toMatch(
56
+ /deliverResumeSyntheticOrBuffer\(/,
57
+ );
47
58
  // Must run AFTER the mint-success path (i.e., after the
48
59
  // `result.kind === 'error'` early-return guard).
49
60
  const errorReturn = block.indexOf("result.kind === 'error'");
50
- const sendIdx = block.indexOf("ipcServer.sendToAgent(");
61
+ const sendIdx = block.indexOf("deliverResumeSyntheticOrBuffer(");
51
62
  expect(errorReturn).toBeGreaterThan(0);
52
- expect(sendIdx, "sendToAgent must come AFTER the error-return early exit").toBeGreaterThan(errorReturn);
63
+ expect(sendIdx, "the resume helper must come AFTER the error-return early exit").toBeGreaterThan(errorReturn);
53
64
  });
54
65
 
55
66
  it("delegates inbound construction to buildVaultGrantApprovedInbound", () => {
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Regression guard — vault/secret RESUME synthetics are turn-gated
3
+ * (the clerk `hotdoc/credentials` mid-turn-strand, 2026-06-14).
4
+ *
5
+ * THE BUG: when an operator approved a vault grant (or provided a
6
+ * secret, or completed a save) WHILE the agent's grant-requesting turn
7
+ * was still finishing, the gateway did a raw `ipcServer.sendToAgent` of
8
+ * the resume synthetic. The socket write succeeded (`delivered=true`)
9
+ * but claude was mid-turn, so the channel notification was typed into
10
+ * its TUI composer and stranded by the turn-completion race (#1556).
11
+ * The pending-inbound buffer never rescued it (it only catches
12
+ * `delivered=false`), so the agent sat idle until the operator manually
13
+ * poked it.
14
+ *
15
+ * Live proof (clerk, 2026-06-13 22:10:57):
16
+ * 22:10:57.098 vault_grant_approved injection delivered=true
17
+ * 22:10:57.277 turn_end #14081 finalAnswer=true (still mid-turn!)
18
+ * 22:12:57.713 inbound msg=14085 → turnStart (operator poke, 2m later)
19
+ *
20
+ * THE FIX: every resume synthetic goes through
21
+ * `deliverResumeSyntheticOrBuffer`, which consults the SAME
22
+ * `decideInboundDelivery` gate the Telegram handleInbound path uses —
23
+ * mid-turn → `buffer-until-idle` (flushed cleanly at turn-end). This
24
+ * file pins (a) the gate decision for a resume synthetic's
25
+ * shape, and (b) that no resume callsite regressed to a raw
26
+ * `ipcServer.sendToAgent`.
27
+ */
28
+ import { describe, it, expect } from "vitest";
29
+ import { readFileSync } from "node:fs";
30
+ import { resolve } from "node:path";
31
+ import { decideInboundDelivery } from "../gateway/inbound-delivery-gate.js";
32
+
33
+ const gatewaySrc = readFileSync(
34
+ resolve(__dirname, "..", "gateway", "gateway.ts"),
35
+ "utf-8",
36
+ );
37
+
38
+ describe("resume synthetics use the turn-gate (mid-turn → buffer)", () => {
39
+ it("a resume synthetic's gate shape buffers mid-turn, delivers when idle", () => {
40
+ // A resume synthetic is never steering and never an interrupt — the
41
+ // exact inputs deliverResumeSyntheticOrBuffer passes to the gate.
42
+ const shape = { isSteering: false as const, isInterrupt: false as const };
43
+ expect(decideInboundDelivery({ ...shape, turnInFlight: true })).toBe(
44
+ "buffer-until-idle",
45
+ );
46
+ expect(decideInboundDelivery({ ...shape, turnInFlight: false })).toBe(
47
+ "deliver",
48
+ );
49
+ });
50
+
51
+ it("the helper exists and gates on decideInboundDelivery before sending", () => {
52
+ const start = gatewaySrc.indexOf(
53
+ "function deliverResumeSyntheticOrBuffer",
54
+ );
55
+ expect(start, "deliverResumeSyntheticOrBuffer helper missing").toBeGreaterThan(0);
56
+ const body = gatewaySrc.slice(start, start + 900);
57
+ // Gate consulted...
58
+ expect(body).toMatch(/decideInboundDelivery\(/);
59
+ // ...and the buffer-until-idle branch buffers BEFORE any send.
60
+ const gateIdx = body.indexOf("decideInboundDelivery(");
61
+ const bufferIdx = body.indexOf("pendingInboundBuffer.push(");
62
+ const sendIdx = body.indexOf("ipcServer.sendToAgent(");
63
+ expect(gateIdx).toBeGreaterThan(0);
64
+ expect(bufferIdx, "must buffer in the helper").toBeGreaterThan(gateIdx);
65
+ expect(sendIdx, "must still deliver in the idle branch").toBeGreaterThan(gateIdx);
66
+ expect(bufferIdx, "buffer-until-idle branch precedes the send branch").toBeLessThan(sendIdx);
67
+ });
68
+
69
+ it("no resume synthetic is sent via a raw ungated ipcServer.sendToAgent", () => {
70
+ // Every resume wake-up — vault_grant_approved/denied, secret_provided/
71
+ // declined, secret_provide_failed, vault_save_completed/failed/discarded
72
+ // — must route through the helper. A raw sendToAgent of one of these
73
+ // named inbound vars would reintroduce the mid-turn strand. The helper
74
+ // deliberately names its param `inbound` (NOT any of these), so the
75
+ // ONLY legitimate raw sendToAgent is the helper's own
76
+ // `ipcServer.sendToAgent(agent, inbound)`; every resume-synthetic var
77
+ // name below must be absent as a raw send argument.
78
+ const rawResumeSends = [
79
+ ...gatewaySrc.matchAll(
80
+ /ipcServer\.sendToAgent\([^,]+,\s*(synthetic|failMsg|denyInbound|discardInbound|failInbound|okInbound)\)/g,
81
+ ),
82
+ ];
83
+ expect(
84
+ rawResumeSends.map((m) => m[1]),
85
+ "resume synthetic sent via raw sendToAgent — must use deliverResumeSyntheticOrBuffer",
86
+ ).toEqual([]);
87
+ });
88
+
89
+ it("the helper's send uses a param name distinct from every resume var (keeps the grep guard honest)", () => {
90
+ // If the helper param were renamed back to `synthetic`, the guard
91
+ // above would get a false pass (the helper's own send would mask a
92
+ // regressed callsite). Pin the param name.
93
+ const start = gatewaySrc.indexOf("function deliverResumeSyntheticOrBuffer");
94
+ const sig = gatewaySrc.slice(start, start + 120);
95
+ expect(sig).toMatch(/deliverResumeSyntheticOrBuffer\(agent: string, inbound: InboundMessage\)/);
96
+ });
97
+ });