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/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 +89 -7
- package/telegram-plugin/gateway/gateway.ts +118 -2
- package/telegram-plugin/gateway/grant-restart.ts +30 -0
- package/telegram-plugin/tests/grant-restart.test.ts +38 -0
- package/telegram-plugin/welcome-text.ts +2 -1
package/package.json
CHANGED
|
@@ -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`)* —
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
(summarise a feed, format a
|
|
49
|
-
|
|
50
|
-
|
|
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**
|
|
53
|
-
|
|
54
|
-
reaction-triggered work, **`reaction_dispatch`** (an
|
|
55
|
-
you instantly — zero polling).
|
|
56
|
-
|
|
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 & 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.
|
|
54321
|
-
var COMMIT_SHA = "
|
|
54322
|
-
var COMMIT_DATE = "2026-06-
|
|
54323
|
-
var LATEST_PR =
|
|
54324
|
-
var COMMITS_AHEAD_OF_TAG =
|
|
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
|
|
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
|
-
:
|
|
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 & config</b>`,
|
|
379
380
|
`<code>/auth</code> — auth status or actions`,
|