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.
- package/dist/cli/switchroom.js +2 -2
- package/dist/host-control/main.js +88 -2
- package/package.json +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +163 -88
- package/telegram-plugin/gateway/config-approval-handler.test.ts +24 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +28 -1
- package/telegram-plugin/gateway/effort-command.ts +56 -47
- package/telegram-plugin/gateway/gateway.ts +60 -37
- package/telegram-plugin/gateway/ipc-protocol.ts +8 -0
- package/telegram-plugin/gateway/ipc-server.ts +10 -0
- package/telegram-plugin/tests/effort-command.test.ts +43 -34
- package/telegram-plugin/tests/ipc-validator.test.ts +28 -0
- 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/telegram-plugin/uat/scenarios/jtbd-effort-command-dm.test.ts +90 -0
- package/telegram-plugin/uat/scenarios/jtbd-grant-resume-telegram-id-dm.test.ts +97 -0
- package/telegram-plugin/uat/scenarios/jtbd-model-command-dm.test.ts +7 -4
- package/telegram-plugin/uat/scenarios/jtbd-model-tap-dm.test.ts +71 -0
- package/telegram-plugin/uat/scenarios/jtbd-whoami-dm.test.ts +40 -0
|
@@ -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
|
|
|
@@ -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 {
|
|
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);
|
|
@@ -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("
|
|
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", () => {
|