src / integration / approvalCommands.ts
src / integration / approvalCommands.ts
import type { ApprovalRecord } from "../policy/approvalStore";
import type { PermissionMode } from "../policy/permissions";
export const APPROVAL_TAG = "<agentic-approval>";
export const MODE_HINT_TAG = "<agentic-mode>";
export interface ParsedApprovalCommand {
verb: "accept" | "deny";
id?: string;
reason?: string;
}
const COMMAND = /^\s*\/(accept|deny)(?:\s+((?:approval|plan)_\S+))?(?:\s+([\s\S]*?))?\s*$/i;
export function parseApprovalCommand(text: string): ParsedApprovalCommand | undefined {
const match = COMMAND.exec(text);
if (!match) return undefined;
const parsed: ParsedApprovalCommand = { verb: match[1].toLowerCase() as "accept" | "deny" };
if (match[2]) parsed.id = match[2];
if (match[3]?.trim()) parsed.reason = match[3].trim();
return parsed;
}
function block(lines: string[]): string {
return `${APPROVAL_TAG}\n${lines.join("\n")}\n</agentic-approval>`;
}
/**
* `mode` matters only for an approved plan: in MANUAL every mutating call is
* still staged individually (permissions.ts `decide()` consults the plan only
* in PLAN mode), so the unlock wording would be false there.
*/
export function renderDecisionMessage(
record: ApprovalRecord,
verb: "accept" | "deny",
reason?: string,
mode?: PermissionMode,
): string {
if (verb === "deny") {
return block([
`The user DENIED ${record.id} — "${record.title}".`,
...(reason ? [`Reason: ${reason}`] : []),
record.kind === "plan"
? "Do not re-issue it unchanged; revise the plan and propose again with workspace_plan(action=\"propose\")."
: "Do not re-issue it unchanged; revise the operation or ask the user what to change.",
]);
}
const note = reason ? [`User note: ${reason}`] : [];
if (record.kind === "plan") {
return block([
`The user APPROVED plan ${record.id} "${record.title}".`,
mode === "manual"
? "Plan noted. In MANUAL mode every mutating call still needs its own /accept — proceed step by step and report each approval id."
: "Execute it now step by step; mutating tools are unlocked until you call workspace_plan(action=\"complete\").",
...note,
]);
}
const resume = record.resume
? `Continue now by calling ${record.resume.tool} with ${JSON.stringify(record.resume.arguments)}.`
: "Continue now by re-issuing the same call.";
return block([
`The user APPROVED ${record.id} — "${record.title}".`,
resume,
"This approval is single-use and matches only that exact operation.",
...note,
]);
}
export function renderNoPendingMessage(verb: "accept" | "deny", id?: string, detail?: string): string {
// Store errors end with a period; strip it so the parenthetical does not read "(…).)".
const why = detail?.trim().replace(/\.+$/, "");
return block([
`/${verb}${id ? ` ${id}` : ""} matched no pending approval${why ? ` (${why})` : ""}.`,
"Tell the user briefly that nothing was waiting for a decision, then ask what they want to do next.",
]);
}
export interface ModeHintState {
mode: PermissionMode;
pendingPlan?: { id: string; title: string };
activePlan?: { id: string; title: string; approvedAt?: string };
pendingApprovals: Array<{ id: string; title: string }>;
}
/**
* How long ago the active plan was approved. Approvals are scoped to a
* workspace, not to a chat, and there is no chat id to scope them with, so an
* approved plan can be carried into a conversation that knows nothing about
* it; it now expires 24 h after it was proposed (`approvalStore`
* EXPIRABLE_STATUSES), and saying its age lets the model notice a plan from
* yesterday instead of silently executing it. Silent under an hour, so the
* common case — approve a plan, run it now — pays nothing.
*/
function planAge(approvedAt: string | undefined, now: number = Date.now()): string {
if (!approvedAt) return "";
const elapsed = now - Date.parse(approvedAt);
if (!Number.isFinite(elapsed) || elapsed < 60 * 60 * 1000) return "";
return ` ${Math.floor(elapsed / (60 * 60 * 1000))}h ago, expires at 24h,`;
}
/**
* The `<agentic-mode>` block is prepended to every user message outside Auto
* mode, so its size is a per-turn tax and `tests/descriptions.test.ts` holds a
* byte budget over it. `approvalStore` caps a title at 300 characters and the
* hint lists five of them, so an unclipped hint could reach ~2.1 KB — the
* budget was really only bounding the small fixture the test happened to use.
* Clipping every rendered title makes it a ceiling no chat can exceed.
*
* Measured in bytes, not characters, and cut on code-point boundaries: a title
* carries file paths and command lines, which are not always ASCII, and a
* character budget would let one blow past the byte ceiling by 3x.
*/
const TITLE_BUDGET_BYTES = 60;
function clipTitle(raw: string): string {
// A newline in a title would break the single-line block open.
const title = raw.replace(/\s+/g, " ").trim();
if (Buffer.byteLength(title, "utf8") <= TITLE_BUDGET_BYTES) return title;
const points = [...title];
let used = 0;
let end = 0;
for (const point of points) {
const size = Buffer.byteLength(point, "utf8");
// 3 = the bytes the ellipsis itself costs.
if (used + size > TITLE_BUDGET_BYTES - 3) break;
used += size;
end++;
}
return `${points.slice(0, end).join("")}…`;
}
export function renderModeHint(state: ModeHintState): string | undefined {
if (state.mode === "auto") return undefined;
let text: string;
if (state.mode === "plan") {
if (state.pendingPlan) {
text = `Permission mode: PLAN (plan ${state.pendingPlan.id} "${clipTitle(state.pendingPlan.title)}" is awaiting the user's /accept or /deny — do not call mutating tools; answer questions or wait).`;
} else if (state.activePlan) {
text = `Permission mode: PLAN (plan ${state.activePlan.id} "${clipTitle(state.activePlan.title)}" is approved${planAge(state.activePlan.approvedAt)} — execute it; call workspace_plan(action="complete") when every step is done; do not propose again unless the plan itself must change — a new proposal supersedes the approved plan and locks mutating tools again).`;
} else {
text =
'Permission mode: PLAN (no approved plan). Edits, commands, git/gh writes, browser control, web search/fetch and sub-agent runs will not execute until the user approves a plan. Inspect first, then call workspace_plan(action="propose") with title, objective, ordered steps, files, commands and risks; restate it to the user and stop. The user replies /accept <plan id> or /deny <plan id> <reason>. After approval execute the plan and call workspace_plan(action="complete").';
}
} else {
text =
'Permission mode: MANUAL. Every mutating call (edit apply/commit/rollback, command, git/gh write, browser control, web search/fetch, and sub-agent runs that may execute commands) returns status "pending_approval" with an approval id. Report the id and what it would do, then stop. After the user replies /accept <id>, re-issue exactly the call named in the approval message. Never retry a denied operation unchanged.';
if (state.pendingApprovals.length > 0) {
// `approve(undefined)` takes the newest pending record, and this list is
// newest-first (approvalStore.ts `list()` / `summarizeApprovals`). Say so:
// with more than one waiting, a bare /accept decides the wrong one unless
// the model reports the id it wants the user to name.
text += ` Pending, newest first (bare /accept takes the first): ${state.pendingApprovals
.slice(0, 5)
.map((approval) => `${approval.id} "${clipTitle(approval.title)}"`)
.join("; ")}.`;
}
}
return `\n\n${MODE_HINT_TAG}\n${text}\n</agentic-mode>`;
}
import type { ApprovalRecord } from "../policy/approvalStore";
import type { PermissionMode } from "../policy/permissions";
export const APPROVAL_TAG = "<agentic-approval>";
export const MODE_HINT_TAG = "<agentic-mode>";
export interface ParsedApprovalCommand {
verb: "accept" | "deny";
id?: string;
reason?: string;
}
const COMMAND = /^\s*\/(accept|deny)(?:\s+((?:approval|plan)_\S+))?(?:\s+([\s\S]*?))?\s*$/i;
export function parseApprovalCommand(text: string): ParsedApprovalCommand | undefined {
const match = COMMAND.exec(text);
if (!match) return undefined;
const parsed: ParsedApprovalCommand = { verb: match[1].toLowerCase() as "accept" | "deny" };
if (match[2]) parsed.id = match[2];
if (match[3]?.trim()) parsed.reason = match[3].trim();
return parsed;
}
function block(lines: string[]): string {
return `${APPROVAL_TAG}\n${lines.join("\n")}\n</agentic-approval>`;
}
/**
* `mode` matters only for an approved plan: in MANUAL every mutating call is
* still staged individually (permissions.ts `decide()` consults the plan only
* in PLAN mode), so the unlock wording would be false there.
*/
export function renderDecisionMessage(
record: ApprovalRecord,
verb: "accept" | "deny",
reason?: string,
mode?: PermissionMode,
): string {
if (verb === "deny") {
return block([
`The user DENIED ${record.id} — "${record.title}".`,
...(reason ? [`Reason: ${reason}`] : []),
record.kind === "plan"
? "Do not re-issue it unchanged; revise the plan and propose again with workspace_plan(action=\"propose\")."
: "Do not re-issue it unchanged; revise the operation or ask the user what to change.",
]);
}
const note = reason ? [`User note: ${reason}`] : [];
if (record.kind === "plan") {
return block([
`The user APPROVED plan ${record.id} "${record.title}".`,
mode === "manual"
? "Plan noted. In MANUAL mode every mutating call still needs its own /accept — proceed step by step and report each approval id."
: "Execute it now step by step; mutating tools are unlocked until you call workspace_plan(action=\"complete\").",
...note,
]);
}
const resume = record.resume
? `Continue now by calling ${record.resume.tool} with ${JSON.stringify(record.resume.arguments)}.`
: "Continue now by re-issuing the same call.";
return block([
`The user APPROVED ${record.id} — "${record.title}".`,
resume,
"This approval is single-use and matches only that exact operation.",
...note,
]);
}
export function renderNoPendingMessage(verb: "accept" | "deny", id?: string, detail?: string): string {
// Store errors end with a period; strip it so the parenthetical does not read "(…).)".
const why = detail?.trim().replace(/\.+$/, "");
return block([
`/${verb}${id ? ` ${id}` : ""} matched no pending approval${why ? ` (${why})` : ""}.`,
"Tell the user briefly that nothing was waiting for a decision, then ask what they want to do next.",
]);
}
export interface ModeHintState {
mode: PermissionMode;
pendingPlan?: { id: string; title: string };
activePlan?: { id: string; title: string; approvedAt?: string };
pendingApprovals: Array<{ id: string; title: string }>;
}
/**
* How long ago the active plan was approved. Approvals are scoped to a
* workspace, not to a chat, and there is no chat id to scope them with, so an
* approved plan can be carried into a conversation that knows nothing about
* it; it now expires 24 h after it was proposed (`approvalStore`
* EXPIRABLE_STATUSES), and saying its age lets the model notice a plan from
* yesterday instead of silently executing it. Silent under an hour, so the
* common case — approve a plan, run it now — pays nothing.
*/
function planAge(approvedAt: string | undefined, now: number = Date.now()): string {
if (!approvedAt) return "";
const elapsed = now - Date.parse(approvedAt);
if (!Number.isFinite(elapsed) || elapsed < 60 * 60 * 1000) return "";
return ` ${Math.floor(elapsed / (60 * 60 * 1000))}h ago, expires at 24h,`;
}
/**
* The `<agentic-mode>` block is prepended to every user message outside Auto
* mode, so its size is a per-turn tax and `tests/descriptions.test.ts` holds a
* byte budget over it. `approvalStore` caps a title at 300 characters and the
* hint lists five of them, so an unclipped hint could reach ~2.1 KB — the
* budget was really only bounding the small fixture the test happened to use.
* Clipping every rendered title makes it a ceiling no chat can exceed.
*
* Measured in bytes, not characters, and cut on code-point boundaries: a title
* carries file paths and command lines, which are not always ASCII, and a
* character budget would let one blow past the byte ceiling by 3x.
*/
const TITLE_BUDGET_BYTES = 60;
function clipTitle(raw: string): string {
// A newline in a title would break the single-line block open.
const title = raw.replace(/\s+/g, " ").trim();
if (Buffer.byteLength(title, "utf8") <= TITLE_BUDGET_BYTES) return title;
const points = [...title];
let used = 0;
let end = 0;
for (const point of points) {
const size = Buffer.byteLength(point, "utf8");
// 3 = the bytes the ellipsis itself costs.
if (used + size > TITLE_BUDGET_BYTES - 3) break;
used += size;
end++;
}
return `${points.slice(0, end).join("")}…`;
}
export function renderModeHint(state: ModeHintState): string | undefined {
if (state.mode === "auto") return undefined;
let text: string;
if (state.mode === "plan") {
if (state.pendingPlan) {
text = `Permission mode: PLAN (plan ${state.pendingPlan.id} "${clipTitle(state.pendingPlan.title)}" is awaiting the user's /accept or /deny — do not call mutating tools; answer questions or wait).`;
} else if (state.activePlan) {
text = `Permission mode: PLAN (plan ${state.activePlan.id} "${clipTitle(state.activePlan.title)}" is approved${planAge(state.activePlan.approvedAt)} — execute it; call workspace_plan(action="complete") when every step is done; do not propose again unless the plan itself must change — a new proposal supersedes the approved plan and locks mutating tools again).`;
} else {
text =
'Permission mode: PLAN (no approved plan). Edits, commands, git/gh writes, browser control, web search/fetch and sub-agent runs will not execute until the user approves a plan. Inspect first, then call workspace_plan(action="propose") with title, objective, ordered steps, files, commands and risks; restate it to the user and stop. The user replies /accept <plan id> or /deny <plan id> <reason>. After approval execute the plan and call workspace_plan(action="complete").';
}
} else {
text =
'Permission mode: MANUAL. Every mutating call (edit apply/commit/rollback, command, git/gh write, browser control, web search/fetch, and sub-agent runs that may execute commands) returns status "pending_approval" with an approval id. Report the id and what it would do, then stop. After the user replies /accept <id>, re-issue exactly the call named in the approval message. Never retry a denied operation unchanged.';
if (state.pendingApprovals.length > 0) {
// `approve(undefined)` takes the newest pending record, and this list is
// newest-first (approvalStore.ts `list()` / `summarizeApprovals`). Say so:
// with more than one waiting, a bare /accept decides the wrong one unless
// the model reports the id it wants the user to name.
text += ` Pending, newest first (bare /accept takes the first): ${state.pendingApprovals
.slice(0, 5)
.map((approval) => `${approval.id} "${clipTitle(approval.title)}"`)
.join("; ")}.`;
}
}
return `\n\n${MODE_HINT_TAG}\n${text}\n</agentic-mode>`;
}