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.
@@ -22,7 +22,7 @@
22
22
  * unit-testable without booting the bot.
23
23
  */
24
24
 
25
- import type { InjectResult } from '../../src/agents/inject.js'
25
+ import type { EffortApplyResult } from '../../src/agents/effort-picker.js'
26
26
 
27
27
  /**
28
28
  * The effort levels the installed CLI accepts (`claude --help`:
@@ -66,8 +66,14 @@ export function parseEffortCommand(text: string): ParsedEffortCommand | null {
66
66
  }
67
67
 
68
68
  export interface EffortCommandDeps {
69
- /** Inject primitive — wired to injectSlashCommand in the gateway. */
70
- inject: (agent: string, command: string) => Promise<InjectResult>
69
+ /**
70
+ * Apply an effort level to the live session. Wired to `applyEffort`
71
+ * (src/agents/effort-picker.ts), which types `/effort <level>` AND drives
72
+ * the "Change effort level?" confirmation modal that claude shows when the
73
+ * switch would invalidate a cached conversation — so it never wedges the
74
+ * pane the way a bare inject would.
75
+ */
76
+ applyEffort: (agent: string, level: string) => Promise<EffortApplyResult>
71
77
  getAgentName: () => string
72
78
  /**
73
79
  * The agent's cascade-resolved `thinking_effort` from
@@ -76,7 +82,6 @@ export interface EffortCommandDeps {
76
82
  */
77
83
  getConfiguredEffort: () => string | null
78
84
  escapeHtml: (s: string) => string
79
- preBlock: (s: string) => string
80
85
  }
81
86
 
82
87
  export interface EffortCommandReply {
@@ -127,52 +132,48 @@ export async function handleEffortCommand(
127
132
  return helpText(deps, `not a valid effort level: ${parsed.level}`)
128
133
  }
129
134
  const verbHtml = `<code>/effort ${deps.escapeHtml(parsed.level)}</code>`
130
- let result: InjectResult
135
+ let result: EffortApplyResult
131
136
  try {
132
- result = await deps.inject(deps.getAgentName(), `/effort ${parsed.level}`)
137
+ result = await deps.applyEffort(deps.getAgentName(), parsed.level)
133
138
  } catch (err) {
134
139
  const msg = err instanceof Error ? err.message : String(err)
135
- return { text: `❌ ${verbHtml} — inject failed: ${deps.escapeHtml(msg)}`, html: true }
140
+ return { text: `❌ ${verbHtml} — failed: ${deps.escapeHtml(msg)}`, html: true }
136
141
  }
142
+ return { text: applyResultText(parsed.level, result, deps), html: true }
143
+ }
137
144
 
138
- if (result.outcome === 'ok') {
139
- return {
140
- text: [
141
- `${verbHtml}`,
142
- deps.preBlock(result.output),
143
- ...(result.truncated ? ['<i>truncated</i>'] : []),
144
- PERSIST_NOTE,
145
- ].join('\n'),
146
- html: true,
147
- }
148
- }
149
- if (result.outcome === 'ok_no_output') {
150
- return {
151
- text: [
152
- `${verbHtml} — sent, but no response captured. The agent may be mid-turn; check <code>/inject /status</code> to confirm the active effort.`,
153
- PERSIST_NOTE,
154
- ].join('\n'),
155
- html: true,
145
+ /**
146
+ * Render an effort-apply outcome. `confirmed` means a "Change effort level?"
147
+ * modal was answered — switching mid-conversation re-reads the history, so we
148
+ * say so honestly rather than just claiming success.
149
+ */
150
+ function applyResultText(level: string, result: EffortApplyResult, deps: EffortCommandDeps): string {
151
+ const verbHtml = `<code>/effort ${deps.escapeHtml(level)}</code>`
152
+ if (result.ok) {
153
+ const lines = [`✅ ${verbHtml} — ${deps.escapeHtml(result.output)}`]
154
+ if (result.confirmed) {
155
+ lines.push('<i>Switched mid-conversation — your next turn re-reads the cached history (slower, one time).</i>')
156
156
  }
157
+ lines.push(PERSIST_NOTE)
158
+ return lines.join('\n')
157
159
  }
158
- // outcome === 'failed'
159
- if (result.errorCode === 'session_missing') {
160
- return {
161
- text:
162
- '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.',
163
- html: true,
164
- }
160
+ if (result.reason === 'session_missing') {
161
+ return '❌ tmux session not found — the agent must be running under the tmux supervisor (the default). Remove <code>experimental.legacy_pty: true</code> if set.'
165
162
  }
166
- return {
167
- text: `❌ ${verbHtml} — ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`,
168
- html: true,
163
+ if (result.reason === 'confirm_failed') {
164
+ const wedged = result.wedged
165
+ ? ' The confirmation prompt may still be open on the pane — check it.'
166
+ : ' The change was cancelled and the pane left as it was.'
167
+ return `❌ ${verbHtml} — couldn't confirm the switch.${wedged}`
169
168
  }
169
+ // apply_unverified
170
+ return `❌ ${verbHtml} — sent, but couldn't confirm it applied. The agent may be mid-turn; check <code>/inject /status</code>.`
170
171
  }
171
172
 
172
173
  // ---------------------------------------------------------------------------
173
174
  // Button menu — five fixed levels, the live one marked ✅. No live discovery
174
- // (the levels don't churn) and no picker-driving (the inline `/effort <level>`
175
- // form sets it directly), so this is far simpler than the /model menu.
175
+ // (the levels don't churn). A tap applies the level via applyEffort, which
176
+ // drives the confirmation modal so it never wedges the pane.
176
177
  // ---------------------------------------------------------------------------
177
178
 
178
179
  export interface EffortMenuKeyboardButton {
@@ -231,9 +232,11 @@ export interface EffortCallbackOutcome {
231
232
  }
232
233
 
233
234
  /**
234
- * Handle an `eff:*` callback tap. `eff:s:<level>` injects claude's
235
- * `/effort <level>` and re-renders the menu with a one-line banner and the
236
- * new level checked. Never throws failures render as a banner.
235
+ * Handle an `eff:*` callback tap. `eff:s:<level>` applies the level via
236
+ * applyEffort (which drives the confirmation modal, so a mid-conversation
237
+ * switch confirms cleanly instead of wedging the pane) and re-renders the
238
+ * menu with a one-line banner and the new level checked. Never throws —
239
+ * failures render as a banner.
237
240
  */
238
241
  export async function handleEffortMenuCallback(
239
242
  data: string,
@@ -249,21 +252,27 @@ export async function handleEffortMenuCallback(
249
252
  let banner: string
250
253
  let selected: string | undefined
251
254
  try {
252
- const result = await deps.inject(deps.getAgentName(), `/effort ${level}`)
253
- if (result.outcome === 'ok' || result.outcome === 'ok_no_output') {
254
- banner = `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
255
+ const result = await deps.applyEffort(deps.getAgentName(), level)
256
+ if (result.ok) {
257
+ banner = result.confirmed
258
+ ? `✅ Effort → <code>${deps.escapeHtml(level)}</code> (mid-conversation: next turn re-reads history)`
259
+ : `✅ Effort → <code>${deps.escapeHtml(level)}</code> for this session`
255
260
  selected = level
256
- } else if (result.errorCode === 'session_missing') {
261
+ } else if (result.reason === 'session_missing') {
257
262
  banner = '❌ tmux session not found — is the agent running under the supervisor?'
263
+ } else if (result.reason === 'confirm_failed') {
264
+ banner = result.wedged
265
+ ? '⚠️ Couldn’t confirm the switch — the prompt may still be open on the pane.'
266
+ : '❌ Couldn’t confirm the switch — cancelled, effort unchanged.'
258
267
  } else {
259
- banner = `❌ couldn't set effort: ${deps.escapeHtml(result.errorMessage ?? 'inject failed')}`
268
+ banner = '❌ Sent, but couldnt confirm it applied (agent may be mid-turn).'
260
269
  }
261
270
  } catch (err) {
262
271
  const msg = err instanceof Error ? err.message : String(err)
263
- banner = `❌ inject failed: ${deps.escapeHtml(msg)}`
272
+ banner = `❌ failed: ${deps.escapeHtml(msg)}`
264
273
  }
265
274
  // Re-render with the just-selected level checked (or the configured
266
- // default if the inject failed) and the banner on top.
275
+ // default if it didn't apply) and the banner on top.
267
276
  const menu = buildEffortMenu(deps, selected)
268
277
  return {
269
278
  reply: { ...menu, text: `${banner}\n${menu.text}` },
@@ -280,6 +280,7 @@ import {
280
280
  type EffortCommandDeps,
281
281
  type EffortMenuReply,
282
282
  } from './effort-command.js'
283
+ import { applyEffort } from '../../src/agents/effort-picker.js'
283
284
  import { type BannerState } from '../slot-banner.js'
284
285
  import { refreshBanner } from '../slot-banner-driver.js'
285
286
  import { loadConfig as loadSwitchroomConfig, findConfigFile as findSwitchroomConfigFile } from '../../src/config/loader.js'; import { resolveAgentConfig } from '../../src/config/merge.js'
@@ -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
- const fdelivered = ipcServer.sendToAgent(armed.agent, failMsg)
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 = ipcServer.sendToAgent(armed.agent, synthetic)
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
- const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
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
- inject: injectSlashCommandImpl,
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
- const delivered = ipcServer.sendToAgent(pending.agent, synthetic)
16567
- if (delivered) markClaudeBusyForInbound(synthetic)
16656
+ // Turn-gated via deliverResumeSyntheticOrBuffer: mid-turn → buffer
16657
+ // (flushed at turn-end) so the resume never strands in claude's
16658
+ // composer (#1556); idle → deliver; bridge-down → buffer (#1150).
16659
+ const delivered = deliverResumeSyntheticOrBuffer(pending.agent, synthetic)
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 = ipcServer.sendToAgent(pending.agent, denyInbound)
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 = ipcServer.sendToAgent(pending.agent, discardInbound)
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 = ipcServer.sendToAgent(pending.agent, failInbound)
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 = ipcServer.sendToAgent(pending.agent, okInbound)
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
- : `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
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
+ }