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.
@@ -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
 
@@ -391,6 +391,14 @@ export interface RequestConfigFinalizeMessage {
391
391
  outcome: "applied" | "reconcile_failed_rolled_back";
392
392
  /** Optional short diagnostic appended to the card body. */
393
393
  detail?: string;
394
+ /**
395
+ * On `applied`: agents that must restart for the edit to go live (claude
396
+ * loads config at boot). Empty when `fleetWide`. The finalize card offers a
397
+ * one-tap restart of these. Computed host-side by classifyBlastRadius.
398
+ */
399
+ affectedAgents?: string[];
400
+ /** On `applied`: a shared/inherited key changed → all agents affected. */
401
+ fleetWide?: boolean;
394
402
  }
395
403
 
396
404
  /**
@@ -307,6 +307,16 @@ export function validateClientMessage(msg: unknown): msg is ClientToGateway {
307
307
  if (m.detail !== undefined
308
308
  && (typeof m.detail !== "string"
309
309
  || (m.detail as string).length > 500)) return false;
310
+ // affectedAgents (optional): a bounded list of kebab-case agent names —
311
+ // they drive a restart button, so validate shape + charclass even though
312
+ // the sender (hostd) is trusted (defense in depth).
313
+ if (m.affectedAgents !== undefined) {
314
+ if (!Array.isArray(m.affectedAgents) || (m.affectedAgents as unknown[]).length > 64) return false;
315
+ for (const a of m.affectedAgents as unknown[]) {
316
+ if (typeof a !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/.test(a)) return false;
317
+ }
318
+ }
319
+ if (m.fleetWide !== undefined && typeof m.fleetWide !== "boolean") return false;
310
320
  return true;
311
321
  }
312
322
  case "request_drive_approval": {
@@ -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);
@@ -332,4 +332,32 @@ describe('validateClientMessage', () => {
332
332
  expect(validateClientMessage({ ...valid, ttlMs: '300000' })).toBe(false)
333
333
  })
334
334
  })
335
+
336
+ describe('request_config_finalize — affectedAgents / fleetWide (#2346)', () => {
337
+ const valid = { type: 'request_config_finalize', requestId: 'r1', outcome: 'applied' }
338
+
339
+ it('accepts the bare message and the new optional fields', () => {
340
+ expect(validateClientMessage(valid)).toBe(true)
341
+ expect(validateClientMessage({ ...valid, affectedAgents: ['clerk', 'gymbro'], fleetWide: false })).toBe(true)
342
+ expect(validateClientMessage({ ...valid, affectedAgents: [], fleetWide: true })).toBe(true)
343
+ })
344
+
345
+ it('rejects a non-array affectedAgents', () => {
346
+ expect(validateClientMessage({ ...valid, affectedAgents: 'clerk' })).toBe(false)
347
+ })
348
+
349
+ it('rejects affectedAgents with an unsafe / non-kebab name (defense in depth)', () => {
350
+ expect(validateClientMessage({ ...valid, affectedAgents: ['../etc'] })).toBe(false)
351
+ expect(validateClientMessage({ ...valid, affectedAgents: ['has space'] })).toBe(false)
352
+ expect(validateClientMessage({ ...valid, affectedAgents: [42] })).toBe(false)
353
+ })
354
+
355
+ it('rejects an over-long affectedAgents list', () => {
356
+ expect(validateClientMessage({ ...valid, affectedAgents: Array.from({ length: 65 }, (_, i) => `a${i}`) })).toBe(false)
357
+ })
358
+
359
+ it('rejects a non-boolean fleetWide', () => {
360
+ expect(validateClientMessage({ ...valid, fleetWide: 'yes' })).toBe(false)
361
+ })
362
+ })
335
363
  })
@@ -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", () => {