switchroom 0.15.19 → 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 +519 -336
- package/package.json +1 -1
- package/profiles/_shared/agent-self-service.md.hbs +24 -15
- package/telegram-plugin/dist/gateway/gateway.js +217 -88
- package/telegram-plugin/gateway/effort-command.ts +56 -47
- package/telegram-plugin/gateway/gateway.ts +178 -39
- package/telegram-plugin/gateway/grant-restart.ts +30 -0
- package/telegram-plugin/tests/effort-command.test.ts +43 -34
- package/telegram-plugin/tests/grant-restart.test.ts +38 -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/welcome-text.ts +2 -1
|
@@ -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'
|
|
@@ -438,6 +439,7 @@ import {
|
|
|
438
439
|
lookupScopedGrant,
|
|
439
440
|
sweepScopedGrants,
|
|
440
441
|
} from '../scoped-approval.js'
|
|
442
|
+
import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
|
|
441
443
|
import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
|
|
442
444
|
import {
|
|
443
445
|
readClaudeJsonOverage,
|
|
@@ -1717,6 +1719,53 @@ function formatFeedElapsed(ms: number): string {
|
|
|
1717
1719
|
function turnInFlightForGate(): boolean {
|
|
1718
1720
|
return isDeliveryCutoverEnabled() ? isMachineInTurn() : claudeBusyKeys.size > 0
|
|
1719
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
|
+
|
|
1720
1769
|
const pendingRestarts = new Map<string, number>() // agentName -> timestamp when restart was requested
|
|
1721
1770
|
|
|
1722
1771
|
// ─── Proactive context compaction (session.max_context_tokens) ──────────
|
|
@@ -8907,9 +8956,7 @@ async function captureProvidedSecret(
|
|
|
8907
8956
|
stage_id: armed.stageId,
|
|
8908
8957
|
},
|
|
8909
8958
|
}
|
|
8910
|
-
|
|
8911
|
-
if (fdelivered) markClaudeBusyForInbound(failMsg)
|
|
8912
|
-
else pendingInboundBuffer.push(armed.agent, failMsg)
|
|
8959
|
+
deliverResumeSyntheticOrBuffer(armed.agent, failMsg)
|
|
8913
8960
|
return true
|
|
8914
8961
|
}
|
|
8915
8962
|
|
|
@@ -8942,9 +8989,7 @@ async function captureProvidedSecret(
|
|
|
8942
8989
|
stage_id: armed.stageId,
|
|
8943
8990
|
},
|
|
8944
8991
|
}
|
|
8945
|
-
const delivered =
|
|
8946
|
-
if (delivered) markClaudeBusyForInbound(synthetic)
|
|
8947
|
-
else pendingInboundBuffer.push(armed.agent, synthetic)
|
|
8992
|
+
const delivered = deliverResumeSyntheticOrBuffer(armed.agent, synthetic)
|
|
8948
8993
|
process.stderr.write(
|
|
8949
8994
|
`telegram gateway: secret_provided injection agent=${armed.agent} key=${armed.key} stage=${armed.stageId} delivered=${delivered}\n`,
|
|
8950
8995
|
)
|
|
@@ -9025,9 +9070,7 @@ async function handleSecretRequestCallback(ctx: Context, data: string): Promise<
|
|
|
9025
9070
|
stage_id: stageId,
|
|
9026
9071
|
},
|
|
9027
9072
|
}
|
|
9028
|
-
|
|
9029
|
-
if (delivered) markClaudeBusyForInbound(synthetic)
|
|
9030
|
-
else pendingInboundBuffer.push(pending.agent, synthetic)
|
|
9073
|
+
deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
|
|
9031
9074
|
return
|
|
9032
9075
|
}
|
|
9033
9076
|
|
|
@@ -13401,6 +13444,54 @@ async function sweepBeforeSelfRestart(): Promise<void> {
|
|
|
13401
13444
|
}
|
|
13402
13445
|
}
|
|
13403
13446
|
|
|
13447
|
+
/**
|
|
13448
|
+
* Schedule a marker-safe, turn-deferred SELF-restart so a just-persisted
|
|
13449
|
+
* config change ACTUALLY takes effect. claude loads tools / MCP servers /
|
|
13450
|
+
* settings at process start, so a durable "Always allow" write is inert in
|
|
13451
|
+
* the running session until the next restart — the old "restart agent for
|
|
13452
|
+
* full effect" text asked the operator to do this by hand, and most never
|
|
13453
|
+
* did (so the grant didn't stick and the same prompt reappeared). This
|
|
13454
|
+
* completes the flow the operator already approved.
|
|
13455
|
+
*
|
|
13456
|
+
* CALLER-AGENT ONLY (an "Always allow" edits the calling agent's own
|
|
13457
|
+
* `agents.<self>.tools.allow`, a provably single-agent blast radius). The
|
|
13458
|
+
* restart fires when the CURRENT turn completes (via `pendingRestarts`),
|
|
13459
|
+
* never mid-turn — so the operator-approved action finishes first. If no
|
|
13460
|
+
* turn is in flight, it fires immediately after a short drain delay. A
|
|
13461
|
+
* marker is written first so the post-restart greeting lands in the chat
|
|
13462
|
+
* the operator tapped. Kill-switch: `SWITCHROOM_AUTORESTART_ON_GRANT=0`.
|
|
13463
|
+
*
|
|
13464
|
+
* Returns 'disabled' | 'deferred' | 'now'.
|
|
13465
|
+
*/
|
|
13466
|
+
function scheduleGrantRestart(
|
|
13467
|
+
agentName: string,
|
|
13468
|
+
chatId: string | number | undefined,
|
|
13469
|
+
threadId: number | undefined,
|
|
13470
|
+
reason: string,
|
|
13471
|
+
): GrantRestartDecision {
|
|
13472
|
+
const decision = grantRestartDecision({
|
|
13473
|
+
killSwitch: process.env.SWITCHROOM_AUTORESTART_ON_GRANT,
|
|
13474
|
+
selfAgent: process.env.SWITCHROOM_AGENT_NAME,
|
|
13475
|
+
agentName,
|
|
13476
|
+
turnInFlight: turnInFlightForGate(),
|
|
13477
|
+
})
|
|
13478
|
+
if (decision === "disabled") return decision
|
|
13479
|
+
// Marker first → the post-restart greeting lands in the chat the operator
|
|
13480
|
+
// tapped, not the default operator DM.
|
|
13481
|
+
if (chatId != null) {
|
|
13482
|
+
writeRestartMarker({ chat_id: String(chatId), thread_id: threadId ?? null, ack_message_id: null, ts: Date.now() })
|
|
13483
|
+
}
|
|
13484
|
+
stampUserRestartReason(reason)
|
|
13485
|
+
if (decision === "deferred") {
|
|
13486
|
+
pendingRestarts.set(agentName, Date.now()) // fires at turn-complete (marker-safe)
|
|
13487
|
+
} else {
|
|
13488
|
+
// No turn to drain — restart now, after a short delay so this callback's
|
|
13489
|
+
// card edit flushes first.
|
|
13490
|
+
void sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName, reason, 1500))
|
|
13491
|
+
}
|
|
13492
|
+
return decision
|
|
13493
|
+
}
|
|
13494
|
+
|
|
13404
13495
|
/**
|
|
13405
13496
|
* Shape the `switchroom auth ...` CLI stdout into a Telegram-friendly
|
|
13406
13497
|
* HTML block. Returns the body text AND the OAuth authorize URL (if
|
|
@@ -14237,7 +14328,7 @@ bot.command('model', async ctx => {
|
|
|
14237
14328
|
// in effort-command.ts so it's unit-testable without booting the bot.
|
|
14238
14329
|
function buildEffortDeps(): EffortCommandDeps {
|
|
14239
14330
|
return {
|
|
14240
|
-
|
|
14331
|
+
applyEffort: (agent, level) => applyEffort(agent, level),
|
|
14241
14332
|
getAgentName: getMyAgentName,
|
|
14242
14333
|
getConfiguredEffort: () => {
|
|
14243
14334
|
type AgentListResp = { agents: Array<{ name: string; thinking_effort?: string | null }> }
|
|
@@ -14245,7 +14336,6 @@ function buildEffortDeps(): EffortCommandDeps {
|
|
|
14245
14336
|
return data?.agents?.find(a => a.name === getMyAgentName())?.thinking_effort ?? null
|
|
14246
14337
|
},
|
|
14247
14338
|
escapeHtml: escapeHtmlForTg,
|
|
14248
|
-
preBlock,
|
|
14249
14339
|
}
|
|
14250
14340
|
}
|
|
14251
14341
|
|
|
@@ -16563,22 +16653,14 @@ async function performVaultAccessApproval(
|
|
|
16563
16653
|
stageId,
|
|
16564
16654
|
operatorId: senderId,
|
|
16565
16655
|
})
|
|
16566
|
-
|
|
16567
|
-
|
|
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)
|
|
16568
16660
|
process.stderr.write(
|
|
16569
16661
|
`telegram gateway: vault_grant_approved injection agent=${pending.agent} ` +
|
|
16570
16662
|
`key=${pending.key} stage=${stageId} delivered=${delivered}\n`,
|
|
16571
16663
|
)
|
|
16572
|
-
// #1150 root cause: if `delivered=false` the bridge wasn't connected
|
|
16573
|
-
// at send-time (mid-reconnect, claude-session bouncing between
|
|
16574
|
-
// turns, etc). Pre-fix this just logged + dropped — the agent stayed
|
|
16575
|
-
// idle forever and the operator had to poke. Now we buffer the
|
|
16576
|
-
// inbound so the next bridge-register call drains it. Bounded to
|
|
16577
|
-
// 32 entries per agent (see pending-inbound-buffer.ts) — a never-
|
|
16578
|
-
// reconnecting bridge can't fill memory.
|
|
16579
|
-
if (!delivered) {
|
|
16580
|
-
pendingInboundBuffer.push(pending.agent, synthetic)
|
|
16581
|
-
}
|
|
16582
16664
|
}
|
|
16583
16665
|
|
|
16584
16666
|
async function handleVaultRequestAccessCallback(ctx: Context, data: string): Promise<void> {
|
|
@@ -16644,15 +16726,11 @@ async function handleVaultRequestAccessCallback(ctx: Context, data: string): Pro
|
|
|
16644
16726
|
stageId,
|
|
16645
16727
|
operatorId: senderId,
|
|
16646
16728
|
})
|
|
16647
|
-
const denyDelivered =
|
|
16648
|
-
if (denyDelivered) markClaudeBusyForInbound(denyInbound)
|
|
16729
|
+
const denyDelivered = deliverResumeSyntheticOrBuffer(pending.agent, denyInbound)
|
|
16649
16730
|
process.stderr.write(
|
|
16650
16731
|
`telegram gateway: vault_grant_denied injection agent=${pending.agent} ` +
|
|
16651
16732
|
`key=${pending.key} stage=${stageId} delivered=${denyDelivered}\n`,
|
|
16652
16733
|
)
|
|
16653
|
-
if (!denyDelivered) {
|
|
16654
|
-
pendingInboundBuffer.push(pending.agent, denyInbound)
|
|
16655
|
-
}
|
|
16656
16734
|
return
|
|
16657
16735
|
}
|
|
16658
16736
|
|
|
@@ -16823,13 +16901,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
16823
16901
|
stageId,
|
|
16824
16902
|
operatorId: senderId,
|
|
16825
16903
|
})
|
|
16826
|
-
const dDelivered =
|
|
16827
|
-
if (dDelivered) markClaudeBusyForInbound(discardInbound)
|
|
16904
|
+
const dDelivered = deliverResumeSyntheticOrBuffer(pending.agent, discardInbound)
|
|
16828
16905
|
process.stderr.write(
|
|
16829
16906
|
`telegram gateway: vault_save_discarded injection agent=${pending.agent} ` +
|
|
16830
16907
|
`key=${pending.key} stage=${stageId} delivered=${dDelivered}\n`,
|
|
16831
16908
|
)
|
|
16832
|
-
if (!dDelivered) pendingInboundBuffer.push(pending.agent, discardInbound)
|
|
16833
16909
|
return
|
|
16834
16910
|
}
|
|
16835
16911
|
|
|
@@ -16952,13 +17028,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
16952
17028
|
operatorId: senderId,
|
|
16953
17029
|
reason: failReason,
|
|
16954
17030
|
})
|
|
16955
|
-
const fDelivered =
|
|
16956
|
-
if (fDelivered) markClaudeBusyForInbound(failInbound)
|
|
17031
|
+
const fDelivered = deliverResumeSyntheticOrBuffer(pending.agent, failInbound)
|
|
16957
17032
|
process.stderr.write(
|
|
16958
17033
|
`telegram gateway: vault_save_failed injection agent=${pending.agent} ` +
|
|
16959
17034
|
`key=${pending.key} stage=${stageId} delivered=${fDelivered}\n`,
|
|
16960
17035
|
)
|
|
16961
|
-
if (!fDelivered) pendingInboundBuffer.push(pending.agent, failInbound)
|
|
16962
17036
|
return
|
|
16963
17037
|
}
|
|
16964
17038
|
|
|
@@ -16987,13 +17061,11 @@ async function handleVaultRequestSaveCallback(ctx: Context, data: string): Promi
|
|
|
16987
17061
|
stageId,
|
|
16988
17062
|
operatorId: senderId,
|
|
16989
17063
|
})
|
|
16990
|
-
const okDelivered =
|
|
16991
|
-
if (okDelivered) markClaudeBusyForInbound(okInbound)
|
|
17064
|
+
const okDelivered = deliverResumeSyntheticOrBuffer(pending.agent, okInbound)
|
|
16992
17065
|
process.stderr.write(
|
|
16993
17066
|
`telegram gateway: vault_save_completed injection agent=${pending.agent} ` +
|
|
16994
17067
|
`key=${pending.key} stage=${stageId} delivered=${okDelivered}\n`,
|
|
16995
17068
|
)
|
|
16996
|
-
if (!okDelivered) pendingInboundBuffer.push(pending.agent, okInbound)
|
|
16997
17069
|
return
|
|
16998
17070
|
}
|
|
16999
17071
|
|
|
@@ -18622,6 +18694,55 @@ bot.command('version', async ctx => {
|
|
|
18622
18694
|
})
|
|
18623
18695
|
|
|
18624
18696
|
|
|
18697
|
+
// /whoami — the operator's view of THIS agent's sandbox (the same
|
|
18698
|
+
// `config whoami` the agent itself can call as an MCP tool, and the host CLI
|
|
18699
|
+
// exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
|
|
18700
|
+
// tools / MCP / vault key-NAMES (never values) / powers so the operator can
|
|
18701
|
+
// see at a glance what this agent is authorized for.
|
|
18702
|
+
bot.command('whoami', async ctx => {
|
|
18703
|
+
if (!isAuthorizedSender(ctx)) return
|
|
18704
|
+
try {
|
|
18705
|
+
let raw: string
|
|
18706
|
+
try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
|
|
18707
|
+
catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
|
|
18708
|
+
const trimmed = stripAnsi(raw).trim()
|
|
18709
|
+
let card: string
|
|
18710
|
+
try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed)) }
|
|
18711
|
+
catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
|
|
18712
|
+
await switchroomReply(ctx, card, { html: true })
|
|
18713
|
+
} catch (err: unknown) {
|
|
18714
|
+
await switchroomReply(ctx, `<b>whoami failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
|
|
18715
|
+
}
|
|
18716
|
+
})
|
|
18717
|
+
|
|
18718
|
+
/** Compact HTML card from the `config whoami` JSON view. Names/booleans only. */
|
|
18719
|
+
function formatWhoamiCard(v: {
|
|
18720
|
+
name?: string; persona?: string | null; model?: string | null; tier?: string;
|
|
18721
|
+
tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
|
|
18722
|
+
vault?: { key: string; readable: boolean }[];
|
|
18723
|
+
powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
|
|
18724
|
+
scheduleCount?: number; memoryBackend?: string | null;
|
|
18725
|
+
}): string {
|
|
18726
|
+
const esc = escapeHtmlForTg
|
|
18727
|
+
const yn = (b?: boolean) => (b ? '✓' : '✗')
|
|
18728
|
+
const lines: string[] = []
|
|
18729
|
+
lines.push(`👤 <b>${esc(v.name ?? '?')}</b> · ${esc(v.tier ?? 'standard')}`)
|
|
18730
|
+
if (v.persona) lines.push(esc(v.persona))
|
|
18731
|
+
if (v.model) lines.push(`Model: ${esc(v.model)}`)
|
|
18732
|
+
const allow = v.tools?.allow ?? []
|
|
18733
|
+
lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
|
|
18734
|
+
if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
|
|
18735
|
+
if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
|
|
18736
|
+
if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
|
|
18737
|
+
if ((v.vault ?? []).length) {
|
|
18738
|
+
lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(k.key)} ${yn(k.readable)}`).join(', ')}`)
|
|
18739
|
+
}
|
|
18740
|
+
const p = v.powers ?? {}
|
|
18741
|
+
lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
|
|
18742
|
+
lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
|
|
18743
|
+
return lines.join('\n')
|
|
18744
|
+
}
|
|
18745
|
+
|
|
18625
18746
|
bot.command('commands', async ctx => {
|
|
18626
18747
|
if (!isAuthorizedSender(ctx)) return
|
|
18627
18748
|
await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
|
|
@@ -19356,10 +19477,26 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19356
19477
|
|
|
19357
19478
|
const ok = durable
|
|
19358
19479
|
const legacyNote = legacy && durable
|
|
19480
|
+
// Make the durable grant LIVE: config_propose_edit's apply already
|
|
19481
|
+
// regenerated the scaffold (settings.json with the new tools.allow), so a
|
|
19482
|
+
// marker-safe, turn-deferred self-restart loads it — no more "restart by
|
|
19483
|
+
// hand" hint that the operator ignores (leaving the grant inert until the
|
|
19484
|
+
// next bounce). Only on the hostd-durable path (scaffold currency assured);
|
|
19485
|
+
// legacy path keeps the manual hint. Self-agent only; kill-switch
|
|
19486
|
+
// SWITCHROOM_AUTORESTART_ON_GRANT=0.
|
|
19487
|
+
const restartScheduled =
|
|
19488
|
+
ok && !legacy &&
|
|
19489
|
+
scheduleGrantRestart(
|
|
19490
|
+
agentName,
|
|
19491
|
+
ctx.chat?.id,
|
|
19492
|
+
(ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
|
|
19493
|
+
`always-allow: ${grantPhrase}`,
|
|
19494
|
+
) !== "disabled"
|
|
19495
|
+
const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
|
|
19359
19496
|
const ackText = ok
|
|
19360
19497
|
? (legacyNote
|
|
19361
19498
|
? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
|
|
19362
|
-
: `✅ Saved. ${agentName} can now ${grantPhrase} without asking
|
|
19499
|
+
: `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
|
|
19363
19500
|
: (editLockHint
|
|
19364
19501
|
? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
|
|
19365
19502
|
: `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
|
|
@@ -19376,7 +19513,9 @@ bot.on('callback_query:data', async ctx => {
|
|
|
19376
19513
|
const editLabel = ok
|
|
19377
19514
|
? (legacyNote
|
|
19378
19515
|
? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect`
|
|
19379
|
-
:
|
|
19516
|
+
: restartScheduled
|
|
19517
|
+
? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking — applying now (restarting to take effect).`
|
|
19518
|
+
: `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
|
|
19380
19519
|
: (editLockHint
|
|
19381
19520
|
? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
|
|
19382
19521
|
: `⚠️ <b>Allowed for now — "always" did NOT save.</b> It will ask again after restart. Check gateway log.`)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure decision for the "make a just-persisted grant LIVE" self-restart
|
|
3
|
+
* (the side-effecting `scheduleGrantRestart` in gateway.ts wraps this).
|
|
4
|
+
*
|
|
5
|
+
* Extracted so the gating — kill-switch, self-agent-only, and
|
|
6
|
+
* turn-deferred-vs-now — unit-tests without gateway.ts's boot side-effects
|
|
7
|
+
* (same pattern as scoped-approval.ts / admin-commands/index.ts).
|
|
8
|
+
*
|
|
9
|
+
* Contract (reference/access-model.md): the restart only ever follows an
|
|
10
|
+
* operator-approved, single-agent, additive `tools.allow` edit, and only
|
|
11
|
+
* ever bounces the CALLER's own agent — never a peer, never fleet-wide.
|
|
12
|
+
*/
|
|
13
|
+
export type GrantRestartDecision = "disabled" | "deferred" | "now";
|
|
14
|
+
|
|
15
|
+
export function grantRestartDecision(opts: {
|
|
16
|
+
/** SWITCHROOM_AUTORESTART_ON_GRANT value ("0" disables; default on). */
|
|
17
|
+
killSwitch: string | undefined;
|
|
18
|
+
/** The gateway's own agent identity ($SWITCHROOM_AGENT_NAME). */
|
|
19
|
+
selfAgent: string | undefined;
|
|
20
|
+
/** The agent whose config was edited (must equal selfAgent — self only). */
|
|
21
|
+
agentName: string;
|
|
22
|
+
/** Whether a turn is currently in flight (defer the restart to its end). */
|
|
23
|
+
turnInFlight: boolean;
|
|
24
|
+
}): GrantRestartDecision {
|
|
25
|
+
if ((opts.killSwitch ?? "") === "0") return "disabled";
|
|
26
|
+
// Self-only: an "Always allow" edits agents.<self>.tools.allow. We never
|
|
27
|
+
// bounce a peer or the fleet from this path.
|
|
28
|
+
if (!opts.selfAgent || opts.selfAgent !== opts.agentName) return "disabled";
|
|
29
|
+
return opts.turnInFlight ? "deferred" : "now";
|
|
30
|
+
}
|