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.
- package/dist/cli/switchroom.js +2 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +134 -87
- package/telegram-plugin/gateway/effort-command.ts +56 -47
- package/telegram-plugin/gateway/gateway.ts +60 -37
- package/telegram-plugin/tests/effort-command.test.ts +43 -34
- package/telegram-plugin/tests/vault-grant-auto-resume.test.ts +15 -4
- package/telegram-plugin/tests/vault-resume-turn-gated.test.ts +97 -0
package/dist/cli/switchroom.js
CHANGED
|
@@ -50477,8 +50477,8 @@ var {
|
|
|
50477
50477
|
} = import__.default;
|
|
50478
50478
|
|
|
50479
50479
|
// src/build-info.ts
|
|
50480
|
-
var VERSION = "0.15.
|
|
50481
|
-
var COMMIT_SHA = "
|
|
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
|
@@ -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.
|
|
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
|
|
45460
|
+
return { text: `\u274c ${verbHtml} \u2014 failed: ${deps.escapeHtml(msg)}`, html: true };
|
|
45461
45461
|
}
|
|
45462
|
-
|
|
45463
|
-
|
|
45464
|
-
|
|
45465
|
-
|
|
45466
|
-
|
|
45467
|
-
|
|
45468
|
-
|
|
45469
|
-
|
|
45470
|
-
|
|
45471
|
-
|
|
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.
|
|
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.
|
|
45485
|
-
|
|
45486
|
-
|
|
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.
|
|
45535
|
-
if (result.
|
|
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.
|
|
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 =
|
|
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
|
|
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.
|
|
54331
|
-
var COMMIT_SHA = "
|
|
54332
|
-
var COMMIT_DATE = "2026-06-
|
|
54333
|
-
var LATEST_PR =
|
|
54334
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
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
|
-
/**
|
|
70
|
-
|
|
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:
|
|
135
|
+
let result: EffortApplyResult
|
|
131
136
|
try {
|
|
132
|
-
result = await deps.
|
|
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} —
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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)
|
|
175
|
-
//
|
|
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>`
|
|
235
|
-
*
|
|
236
|
-
*
|
|
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.
|
|
253
|
-
if (result.
|
|
254
|
-
banner =
|
|
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.
|
|
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 =
|
|
268
|
+
banner = '❌ Sent, but couldn’t 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 = `❌
|
|
272
|
+
banner = `❌ failed: ${deps.escapeHtml(msg)}`
|
|
264
273
|
}
|
|
265
274
|
// Re-render with the just-selected level checked (or the configured
|
|
266
|
-
// default if
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16616
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 {
|
|
25
|
-
|
|
26
|
-
function
|
|
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
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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;
|
|
38
|
+
const calls: Array<{ agent: string; level: string }> = [];
|
|
49
39
|
const deps: EffortCommandDeps = {
|
|
50
|
-
|
|
51
|
-
calls.push({ agent,
|
|
52
|
-
return
|
|
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
|
|
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",
|
|
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
|
|
139
|
-
const { deps } = makeDeps({
|
|
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("
|
|
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
|
|
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>
|
|
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",
|
|
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
|
|
178
|
-
const { deps } = makeDeps({
|
|
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("
|
|
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
|
-
|
|
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("
|
|
61
|
+
const sendIdx = block.indexOf("deliverResumeSyntheticOrBuffer(");
|
|
51
62
|
expect(errorReturn).toBeGreaterThan(0);
|
|
52
|
-
expect(sendIdx, "
|
|
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
|
+
});
|