switchroom 0.15.19 → 0.15.20

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "switchroom",
3
- "version": "0.15.19",
3
+ "version": "0.15.20",
4
4
  "description": "Run Claude Code 24/7 on your Claude Pro/Max subscription over Telegram. Open-source alternative to OpenClaw and NanoClaw — no API keys.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -38,22 +38,31 @@ tools is to let you do the edit yourself.
38
38
  derived from the entry content is assigned.
39
39
 
40
40
  **Mind the cost — pick the cheapest tier that does the job:**
41
- - *Default (no `model`)* — the fire runs as a **full turn in your live
42
- session**: your model, your whole context + memory. Right for work that
43
- genuinely needs *you* (your persona, your conversation history). Costly for
44
- routine checks every fire pays your full context.
45
- - *`model: "sonnet"`* (or `context: "fresh"`) routes the fire to a **cheap,
46
- minimal-context cron session** (Tier 1): a fresh Sonnet with just the
47
- prompt, no heavy context. Use for light, self-contained recurring work
48
- (summarise a feed, format a digest) where you don't need your memory. Much
49
- cheaper per fire. *(Honoured only when the operator has enabled cheap-cron;
50
- otherwise it still runs as a normal turn never silently dropped.)*
41
+ - *Default (no `model`)* — a **frequent** cron (every ≤60min) is auto-routed
42
+ to a **cheap, minimal-context cron session** (Tier 1: a fresh Sonnet that
43
+ still shares your memory + tools but drops your accumulated conversation
44
+ context). A **daily/weekly** cron defaults to a **full turn in your live
45
+ session** (Tier 2: your model, your whole context). So routine frequent
46
+ checks are already cheap without you doing anything.
47
+ - *`model: "sonnet"` / `context: "fresh"`* force the cheap Tier-1 session
48
+ even for a **daily/weekly** self-contained job (summarise a feed, format a
49
+ digest) overrides the cadence default. *`context: "agent"`* does the
50
+ opposite: pins a fire to your full live session when it genuinely needs your
51
+ accumulated conversation context (this always wins).
52
+ - *"Post/send a FIXED thing on a schedule"* (a set reminder message, a webhook
53
+ ping — text fully determined, no thinking) — ask the **operator** for a
54
+ **`kind: action`**: it runs **model-free, zero tokens** (no session at all),
55
+ posting your fixed/templated text or firing a fixed request. The cheapest
56
+ tier there is.
51
57
  - *"Only act when X changes"* — don't poll with a frequent prompt cron (every
52
- fire is a wasted turn when nothing changed). Ask the **operator** to set up
53
- a **poll** (model-free check, e.g. a webpage/API via `kind: poll`) or, for
54
- reaction-triggered work, **`reaction_dispatch`** (an emoji reaction wakes
55
- you instantly — zero polling). These need an operator config commit
56
- (egress/identity gates), so request them rather than authoring them yourself.
58
+ fire is a wasted turn when nothing changed). Ask the **operator** for a
59
+ **`kind: poll`** (model-free check, e.g. a webpage/API only a *change*
60
+ wakes you) or, for reaction-triggered work, **`reaction_dispatch`** (an
61
+ emoji reaction wakes you instantly — zero polling).
62
+ - `kind: action` / `kind: poll` / `reaction_dispatch` need an operator config
63
+ commit (egress / identity gates), so **request** them rather than authoring
64
+ them yourself — you can only self-author plain prompt crons (plus the
65
+ `model`/`context` tier hints).
57
66
 
58
67
  - **`schedule_remove(name | cron_hash)`** — delete by `name` (the slug from
59
68
  add) or by 12-hex `cron_hash` (shown in `cron_list` output). Both
@@ -43177,6 +43177,7 @@ function switchroomHelpText(agentName3) {
43177
43177
  `<code>/update</code> \u2014 dry-run plan; <code>/update apply</code> \u2014 actually pull images, reconcile, restart`,
43178
43178
  `<code>/restart [name|all]</code> \u2014 bounce agent (drains in-flight turn by default)`,
43179
43179
  `<code>/version</code> \u2014 show versions + running agent health summary`,
43180
+ `<code>/whoami</code> \u2014 this agent's sandbox: tools, MCP, vault key-names, powers`,
43180
43181
  ``,
43181
43182
  `<b>Auth &amp; config</b>`,
43182
43183
  `<code>/auth</code> \u2014 auth status or actions`,
@@ -53646,6 +53647,15 @@ function readBashCommand(inputPreview) {
53646
53647
  }
53647
53648
  }
53648
53649
 
53650
+ // gateway/grant-restart.ts
53651
+ function grantRestartDecision(opts) {
53652
+ if ((opts.killSwitch ?? "") === "0")
53653
+ return "disabled";
53654
+ if (!opts.selfAgent || opts.selfAgent !== opts.agentName)
53655
+ return "disabled";
53656
+ return opts.turnInFlight ? "deferred" : "now";
53657
+ }
53658
+
53649
53659
  // permission-diff.ts
53650
53660
  var TARGET_HEADER_A = "--- a/switchroom.yaml";
53651
53661
  var TARGET_HEADER_B = "+++ b/switchroom.yaml";
@@ -54317,11 +54327,11 @@ function readTurnActiveMarkerAgeMs(stateDir, now) {
54317
54327
  }
54318
54328
 
54319
54329
  // ../src/build-info.ts
54320
- var VERSION = "0.15.19";
54321
- var COMMIT_SHA = "3f40c6f9";
54322
- var COMMIT_DATE = "2026-06-14T00:06:47Z";
54323
- var LATEST_PR = 2337;
54324
- var COMMITS_AHEAD_OF_TAG = 0;
54330
+ var VERSION = "0.15.20";
54331
+ var COMMIT_SHA = "0b63ab9e";
54332
+ var COMMIT_DATE = "2026-06-14T10:58:14+10:00";
54333
+ var LATEST_PR = null;
54334
+ var COMMITS_AHEAD_OF_TAG = 5;
54325
54335
 
54326
54336
  // gateway/boot-version.ts
54327
54337
  function formatRelativeAgo(iso) {
@@ -61772,6 +61782,26 @@ async function sweepBeforeSelfRestart() {
61772
61782
  `);
61773
61783
  }
61774
61784
  }
61785
+ function scheduleGrantRestart(agentName3, chatId, threadId, reason) {
61786
+ const decision = grantRestartDecision({
61787
+ killSwitch: process.env.SWITCHROOM_AUTORESTART_ON_GRANT,
61788
+ selfAgent: process.env.SWITCHROOM_AGENT_NAME,
61789
+ agentName: agentName3,
61790
+ turnInFlight: turnInFlightForGate()
61791
+ });
61792
+ if (decision === "disabled")
61793
+ return decision;
61794
+ if (chatId != null) {
61795
+ writeRestartMarker({ chat_id: String(chatId), thread_id: threadId ?? null, ack_message_id: null, ts: Date.now() });
61796
+ }
61797
+ stampUserRestartReason(reason);
61798
+ if (decision === "deferred") {
61799
+ pendingRestarts.set(agentName3, Date.now());
61800
+ } else {
61801
+ sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName3, reason, 1500));
61802
+ }
61803
+ return decision;
61804
+ }
61775
61805
  function formatAuthOutputForTelegram(output) {
61776
61806
  const trimmed = stripAnsi2(output).trim();
61777
61807
  const url = trimmed.match(/https:\/\/\S+/)?.[0] ?? null;
@@ -65051,6 +65081,56 @@ bot.command("version", async (ctx) => {
65051
65081
  ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: true });
65052
65082
  }
65053
65083
  });
65084
+ bot.command("whoami", async (ctx) => {
65085
+ if (!isAuthorizedSender(ctx))
65086
+ return;
65087
+ try {
65088
+ let raw;
65089
+ try {
65090
+ raw = switchroomExecCombined(["config", "whoami"], 1e4);
65091
+ } catch (err) {
65092
+ raw = err.stdout ?? err.message ?? "whoami failed";
65093
+ }
65094
+ const trimmed = stripAnsi2(raw).trim();
65095
+ let card;
65096
+ try {
65097
+ card = formatWhoamiCard(JSON.parse(trimmed.split(`
65098
+ `).pop() ?? trimmed));
65099
+ } catch {
65100
+ card = preBlock(formatSwitchroomOutput(trimmed || "whoami: no output"));
65101
+ }
65102
+ await switchroomReply(ctx, card, { html: true });
65103
+ } catch (err) {
65104
+ await switchroomReply(ctx, `<b>whoami failed:</b>
65105
+ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: true });
65106
+ }
65107
+ });
65108
+ function formatWhoamiCard(v) {
65109
+ const esc = escapeHtmlForTg;
65110
+ const yn = (b) => b ? "\u2713" : "\u2717";
65111
+ const lines = [];
65112
+ lines.push(`\uD83D\uDC64 <b>${esc(v.name ?? "?")}</b> \xB7 ${esc(v.tier ?? "standard")}`);
65113
+ if (v.persona)
65114
+ lines.push(esc(v.persona));
65115
+ if (v.model)
65116
+ lines.push(`Model: ${esc(v.model)}`);
65117
+ const allow = v.tools?.allow ?? [];
65118
+ lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(", ")) + (allow.length > 8 ? ` \u2026(+${allow.length - 8})` : "") : "\u2014"}`);
65119
+ if ((v.tools?.deny ?? []).length)
65120
+ lines.push(`Denied: ${esc(v.tools.deny.join(", "))}`);
65121
+ if ((v.mcpServers ?? []).length)
65122
+ lines.push(`MCP: ${esc(v.mcpServers.join(", "))}`);
65123
+ if ((v.skills ?? []).length)
65124
+ lines.push(`Skills: ${esc(v.skills.join(", "))}`);
65125
+ if ((v.vault ?? []).length) {
65126
+ lines.push(`Vault keys (names only): ${v.vault.map((k) => `${esc(k.key)} ${yn(k.readable)}`).join(", ")}`);
65127
+ }
65128
+ const p = v.powers ?? {};
65129
+ lines.push(`Powers: admin ${yn(p.admin)} \xB7 root ${yn(p.root)} \xB7 config-edit ${yn(p.configEdit)} \xB7 cross-agent verbs ${yn(p.crossAgentHostVerbs)}`);
65130
+ lines.push(`Schedule: ${v.scheduleCount ?? 0} cron \xB7 Memory: ${esc(v.memoryBackend ?? "none")}`);
65131
+ return lines.join(`
65132
+ `);
65133
+ }
65054
65134
  bot.command("commands", async (ctx) => {
65055
65135
  if (!isAuthorizedSender(ctx))
65056
65136
  return;
@@ -65515,10 +65595,12 @@ ${preBlock(formatSwitchroomOutput(err.message ?? "unknown error"))}`, { html: tr
65515
65595
  }
65516
65596
  const ok = durable;
65517
65597
  const legacyNote = legacy && durable;
65518
- const ackText2 = ok ? legacyNote ? `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking (legacy path).` : `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking.` : editLockHint ? `\u26A0\uFE0F Allowed for now \u2014 config edits are locked. Enable hostd.config_edit_enabled.` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
65598
+ const restartScheduled = ok && !legacy && scheduleGrantRestart(agentName3, ctx.chat?.id, ctx.callbackQuery?.message?.message_thread_id, `always-allow: ${grantPhrase}`) !== "disabled";
65599
+ const liveSuffix = restartScheduled ? " \u2014 applying now (restarting to take effect)" : "";
65600
+ const ackText2 = ok ? legacyNote ? `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking (legacy path).` : `\u2705 Saved. ${agentName3} can now ${grantPhrase} without asking.${liveSuffix}` : editLockHint ? `\u26A0\uFE0F Allowed for now \u2014 config edits are locked. Enable hostd.config_edit_enabled.` : `\u26A0\uFE0F Allowed for now, but "always" did NOT save \u2014 it will ask again after restart. Check gateway log.`;
65519
65601
  const sourceMsg = ctx.callbackQuery?.message;
65520
65602
  const baseText2 = sourceMsg && "text" in sourceMsg && sourceMsg.text ? escapeHtmlForTg(sourceMsg.text) : "";
65521
- const editLabel = ok ? legacyNote ? `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect` : `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect` : editLockHint ? `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
65603
+ const editLabel = ok ? legacyNote ? `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking (legacy path); restart agent for full effect` : restartScheduled ? `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking \u2014 applying now (restarting to take effect).` : `\u2705 <b>${escapeHtmlForTg(agentName3)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect` : editLockHint ? `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.` : `\u26A0\uFE0F <b>Allowed for now \u2014 "always" did NOT save.</b> It will ask again after restart. Check gateway log.`;
65522
65604
  await finalizeCallback(ctx, {
65523
65605
  ackText: ackText2.slice(0, 200),
65524
65606
  newText: baseText2 ? `${baseText2}
@@ -438,6 +438,7 @@ import {
438
438
  lookupScopedGrant,
439
439
  sweepScopedGrants,
440
440
  } from '../scoped-approval.js'
441
+ import { grantRestartDecision, type GrantRestartDecision } from './grant-restart.js'
441
442
  import { synthesizeAllowRuleDiff, extractAddedAllowRule } from '../permission-diff.js'
442
443
  import {
443
444
  readClaudeJsonOverage,
@@ -13401,6 +13402,54 @@ async function sweepBeforeSelfRestart(): Promise<void> {
13401
13402
  }
13402
13403
  }
13403
13404
 
13405
+ /**
13406
+ * Schedule a marker-safe, turn-deferred SELF-restart so a just-persisted
13407
+ * config change ACTUALLY takes effect. claude loads tools / MCP servers /
13408
+ * settings at process start, so a durable "Always allow" write is inert in
13409
+ * the running session until the next restart — the old "restart agent for
13410
+ * full effect" text asked the operator to do this by hand, and most never
13411
+ * did (so the grant didn't stick and the same prompt reappeared). This
13412
+ * completes the flow the operator already approved.
13413
+ *
13414
+ * CALLER-AGENT ONLY (an "Always allow" edits the calling agent's own
13415
+ * `agents.<self>.tools.allow`, a provably single-agent blast radius). The
13416
+ * restart fires when the CURRENT turn completes (via `pendingRestarts`),
13417
+ * never mid-turn — so the operator-approved action finishes first. If no
13418
+ * turn is in flight, it fires immediately after a short drain delay. A
13419
+ * marker is written first so the post-restart greeting lands in the chat
13420
+ * the operator tapped. Kill-switch: `SWITCHROOM_AUTORESTART_ON_GRANT=0`.
13421
+ *
13422
+ * Returns 'disabled' | 'deferred' | 'now'.
13423
+ */
13424
+ function scheduleGrantRestart(
13425
+ agentName: string,
13426
+ chatId: string | number | undefined,
13427
+ threadId: number | undefined,
13428
+ reason: string,
13429
+ ): GrantRestartDecision {
13430
+ const decision = grantRestartDecision({
13431
+ killSwitch: process.env.SWITCHROOM_AUTORESTART_ON_GRANT,
13432
+ selfAgent: process.env.SWITCHROOM_AGENT_NAME,
13433
+ agentName,
13434
+ turnInFlight: turnInFlightForGate(),
13435
+ })
13436
+ if (decision === "disabled") return decision
13437
+ // Marker first → the post-restart greeting lands in the chat the operator
13438
+ // tapped, not the default operator DM.
13439
+ if (chatId != null) {
13440
+ writeRestartMarker({ chat_id: String(chatId), thread_id: threadId ?? null, ack_message_id: null, ts: Date.now() })
13441
+ }
13442
+ stampUserRestartReason(reason)
13443
+ if (decision === "deferred") {
13444
+ pendingRestarts.set(agentName, Date.now()) // fires at turn-complete (marker-safe)
13445
+ } else {
13446
+ // No turn to drain — restart now, after a short delay so this callback's
13447
+ // card edit flushes first.
13448
+ void sweepBeforeSelfRestart().finally(() => triggerSelfRestart(agentName, reason, 1500))
13449
+ }
13450
+ return decision
13451
+ }
13452
+
13404
13453
  /**
13405
13454
  * Shape the `switchroom auth ...` CLI stdout into a Telegram-friendly
13406
13455
  * HTML block. Returns the body text AND the OAuth authorize URL (if
@@ -18622,6 +18671,55 @@ bot.command('version', async ctx => {
18622
18671
  })
18623
18672
 
18624
18673
 
18674
+ // /whoami — the operator's view of THIS agent's sandbox (the same
18675
+ // `config whoami` the agent itself can call as an MCP tool, and the host CLI
18676
+ // exposes). Read-only, isAuthorizedSender-gated like /version — surfaces
18677
+ // tools / MCP / vault key-NAMES (never values) / powers so the operator can
18678
+ // see at a glance what this agent is authorized for.
18679
+ bot.command('whoami', async ctx => {
18680
+ if (!isAuthorizedSender(ctx)) return
18681
+ try {
18682
+ let raw: string
18683
+ try { raw = switchroomExecCombined(['config', 'whoami'], 10000) }
18684
+ catch (err: unknown) { raw = (err as any).stdout ?? (err as any).message ?? 'whoami failed' }
18685
+ const trimmed = stripAnsi(raw).trim()
18686
+ let card: string
18687
+ try { card = formatWhoamiCard(JSON.parse(trimmed.split('\n').pop() ?? trimmed)) }
18688
+ catch { card = preBlock(formatSwitchroomOutput(trimmed || 'whoami: no output')) }
18689
+ await switchroomReply(ctx, card, { html: true })
18690
+ } catch (err: unknown) {
18691
+ await switchroomReply(ctx, `<b>whoami failed:</b>\n${preBlock(formatSwitchroomOutput((err as any).message ?? 'unknown error'))}`, { html: true })
18692
+ }
18693
+ })
18694
+
18695
+ /** Compact HTML card from the `config whoami` JSON view. Names/booleans only. */
18696
+ function formatWhoamiCard(v: {
18697
+ name?: string; persona?: string | null; model?: string | null; tier?: string;
18698
+ tools?: { allow?: string[]; deny?: string[] }; mcpServers?: string[]; skills?: string[];
18699
+ vault?: { key: string; readable: boolean }[];
18700
+ powers?: { admin?: boolean; root?: boolean; configEdit?: boolean; crossAgentHostVerbs?: boolean };
18701
+ scheduleCount?: number; memoryBackend?: string | null;
18702
+ }): string {
18703
+ const esc = escapeHtmlForTg
18704
+ const yn = (b?: boolean) => (b ? '✓' : '✗')
18705
+ const lines: string[] = []
18706
+ lines.push(`👤 <b>${esc(v.name ?? '?')}</b> · ${esc(v.tier ?? 'standard')}`)
18707
+ if (v.persona) lines.push(esc(v.persona))
18708
+ if (v.model) lines.push(`Model: ${esc(v.model)}`)
18709
+ const allow = v.tools?.allow ?? []
18710
+ lines.push(`Tools: ${allow.length ? esc(allow.slice(0, 8).join(', ')) + (allow.length > 8 ? ` …(+${allow.length - 8})` : '') : '—'}`)
18711
+ if ((v.tools?.deny ?? []).length) lines.push(`Denied: ${esc((v.tools!.deny!).join(', '))}`)
18712
+ if ((v.mcpServers ?? []).length) lines.push(`MCP: ${esc(v.mcpServers!.join(', '))}`)
18713
+ if ((v.skills ?? []).length) lines.push(`Skills: ${esc(v.skills!.join(', '))}`)
18714
+ if ((v.vault ?? []).length) {
18715
+ lines.push(`Vault keys (names only): ${v.vault!.map(k => `${esc(k.key)} ${yn(k.readable)}`).join(', ')}`)
18716
+ }
18717
+ const p = v.powers ?? {}
18718
+ lines.push(`Powers: admin ${yn(p.admin)} · root ${yn(p.root)} · config-edit ${yn(p.configEdit)} · cross-agent verbs ${yn(p.crossAgentHostVerbs)}`)
18719
+ lines.push(`Schedule: ${v.scheduleCount ?? 0} cron · Memory: ${esc(v.memoryBackend ?? 'none')}`)
18720
+ return lines.join('\n')
18721
+ }
18722
+
18625
18723
  bot.command('commands', async ctx => {
18626
18724
  if (!isAuthorizedSender(ctx)) return
18627
18725
  await switchroomReply(ctx, buildSwitchroomHelpText(getMyAgentName()), { html: true })
@@ -19356,10 +19454,26 @@ bot.on('callback_query:data', async ctx => {
19356
19454
 
19357
19455
  const ok = durable
19358
19456
  const legacyNote = legacy && durable
19457
+ // Make the durable grant LIVE: config_propose_edit's apply already
19458
+ // regenerated the scaffold (settings.json with the new tools.allow), so a
19459
+ // marker-safe, turn-deferred self-restart loads it — no more "restart by
19460
+ // hand" hint that the operator ignores (leaving the grant inert until the
19461
+ // next bounce). Only on the hostd-durable path (scaffold currency assured);
19462
+ // legacy path keeps the manual hint. Self-agent only; kill-switch
19463
+ // SWITCHROOM_AUTORESTART_ON_GRANT=0.
19464
+ const restartScheduled =
19465
+ ok && !legacy &&
19466
+ scheduleGrantRestart(
19467
+ agentName,
19468
+ ctx.chat?.id,
19469
+ (ctx.callbackQuery?.message as { message_thread_id?: number } | undefined)?.message_thread_id,
19470
+ `always-allow: ${grantPhrase}`,
19471
+ ) !== "disabled"
19472
+ const liveSuffix = restartScheduled ? " — applying now (restarting to take effect)" : ""
19359
19473
  const ackText = ok
19360
19474
  ? (legacyNote
19361
19475
  ? `✅ Saved. ${agentName} can now ${grantPhrase} without asking (legacy path).`
19362
- : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.`)
19476
+ : `✅ Saved. ${agentName} can now ${grantPhrase} without asking.${liveSuffix}`)
19363
19477
  : (editLockHint
19364
19478
  ? `⚠️ Allowed for now — config edits are locked. Enable hostd.config_edit_enabled.`
19365
19479
  : `⚠️ Allowed for now, but "always" did NOT save — it will ask again after restart. Check gateway log.`)
@@ -19376,7 +19490,9 @@ bot.on('callback_query:data', async ctx => {
19376
19490
  const editLabel = ok
19377
19491
  ? (legacyNote
19378
19492
  ? `✅ <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`)
19493
+ : restartScheduled
19494
+ ? `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking — applying now (restarting to take effect).`
19495
+ : `✅ <b>${escapeHtmlForTg(agentName)} can now ${escapeHtmlForTg(grantPhrase)}</b> without asking; restart agent for full effect`)
19380
19496
  : (editLockHint
19381
19497
  ? `⚠️ <b>Allowed for now — "always" did NOT save.</b> Config edits are locked; enable <code>hostd.config_edit_enabled</code>.`
19382
19498
  : `⚠️ <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
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Tests for grantRestartDecision — the gating for the "make a just-persisted
3
+ * grant LIVE" self-restart (item ② config-edit-takes-effect). Pins: kill-switch,
4
+ * self-agent-only (never a peer/fleet bounce), and turn-deferred-vs-now.
5
+ */
6
+
7
+ import { describe, it, expect } from "vitest";
8
+ import { grantRestartDecision } from "../gateway/grant-restart.js";
9
+
10
+ const base = { killSwitch: undefined, selfAgent: "clerk", agentName: "clerk", turnInFlight: true };
11
+
12
+ describe("grantRestartDecision", () => {
13
+ it("defers to turn-complete when a turn is in flight (marker-safe)", () => {
14
+ expect(grantRestartDecision({ ...base, turnInFlight: true })).toBe("deferred");
15
+ });
16
+
17
+ it("fires now when no turn is in flight", () => {
18
+ expect(grantRestartDecision({ ...base, turnInFlight: false })).toBe("now");
19
+ });
20
+
21
+ it("is disabled by the kill-switch (SWITCHROOM_AUTORESTART_ON_GRANT=0)", () => {
22
+ expect(grantRestartDecision({ ...base, killSwitch: "0" })).toBe("disabled");
23
+ });
24
+
25
+ it("default-on for any non-'0' kill-switch value", () => {
26
+ expect(grantRestartDecision({ ...base, killSwitch: "" })).toBe("deferred");
27
+ expect(grantRestartDecision({ ...base, killSwitch: "1" })).toBe("deferred");
28
+ });
29
+
30
+ it("NEVER restarts a peer — self-agent only", () => {
31
+ // edit targets a different agent than the gateway's own identity
32
+ expect(grantRestartDecision({ ...base, selfAgent: "clerk", agentName: "gymbro" })).toBe("disabled");
33
+ });
34
+
35
+ it("is disabled when self identity is unknown", () => {
36
+ expect(grantRestartDecision({ ...base, selfAgent: undefined })).toBe("disabled");
37
+ });
38
+ });
@@ -270,7 +270,7 @@ export const switchroomHelpCommandNames = [
270
270
  "agents", "agentstart", "stop", "restart", "logs", "memory",
271
271
  // Auth & config — consolidated onto the `/auth` dashboard.
272
272
  "auth", "model",
273
- "topics", "update", "version",
273
+ "topics", "update", "version", "whoami",
274
274
  "permissions", "grant", "dangerous", "vault", "doctor",
275
275
  "commands",
276
276
  // Note: "reconcile" is a deprecated alias still handled as a bot command
@@ -374,6 +374,7 @@ export function switchroomHelpText(agentName: string): string {
374
374
  `<code>/update</code> — dry-run plan; <code>/update apply</code> — actually pull images, reconcile, restart`,
375
375
  `<code>/restart [name|all]</code> — bounce agent (drains in-flight turn by default)`,
376
376
  `<code>/version</code> — show versions + running agent health summary`,
377
+ `<code>/whoami</code> — this agent's sandbox: tools, MCP, vault key-names, powers`,
377
378
  ``,
378
379
  `<b>Auth &amp; config</b>`,
379
380
  `<code>/auth</code> — auth status or actions`,