feat: add elevated ask/full modes

This commit is contained in:
Peter Steinberger
2026-01-22 05:32:13 +00:00
parent 5567bceb66
commit a2981c5a2c
29 changed files with 115 additions and 57 deletions

View File

@@ -395,9 +395,9 @@ function buildChatCommands(): ChatCommandDefinition[] {
args: [
{
name: "mode",
description: "on or off",
description: "on, off, ask, or full",
type: "string",
choices: ["on", "off"],
choices: ["on", "off", "ask", "full"],
},
],
argsMenu: "auto",

View File

@@ -219,7 +219,7 @@ describe("directive behavior", () => {
);
const events = drainSystemEvents(MAIN_SESSION_KEY);
expect(events.some((e) => e.includes("Elevated ON"))).toBe(true);
expect(events.some((e) => e.includes("Elevated ASK"))).toBe(true);
});
});
it("queues a system event when toggling reasoning", async () => {

View File

@@ -150,7 +150,7 @@ describe("directive behavior", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -143,7 +143,7 @@ describe("directive behavior", () => {
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Current elevated level: on");
expect(text).toContain("Options: on, off.");
expect(text).toContain("Options: on, off, ask, full.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});

View File

@@ -55,6 +55,16 @@ describe("directive parsing", () => {
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("on");
});
it("matches elevated ask", () => {
const res = extractElevatedDirective("/elevated ask please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("ask");
});
it("matches elevated full", () => {
const res = extractElevatedDirective("/elevated full please");
expect(res.hasDirective).toBe(true);
expect(res.elevatedLevel).toBe("full");
});
it("matches think at start of line", () => {
const res = extractThinkDirective("/think:high run slow");

View File

@@ -129,7 +129,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -223,7 +223,7 @@ describe("trigger handling", () => {
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(text).not.toContain("Elevated mode enabled");
expect(text).not.toContain("Elevated mode set to ask");
});
});
});

View File

@@ -184,7 +184,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;
@@ -226,7 +226,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -167,7 +167,7 @@ describe("trigger handling", () => {
cfg,
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Elevated mode enabled");
expect(text).toContain("Elevated mode set to ask");
const storeRaw = await fs.readFile(cfg.session.store, "utf-8");
const store = JSON.parse(storeRaw) as Record<string, { elevatedLevel?: string }>;

View File

@@ -120,7 +120,7 @@ async function resolveContextReport(
workspaceAccess: "rw" as const,
elevated: {
allowed: params.elevated.allowed,
defaultLevel: params.resolvedElevatedLevel === "off" ? ("off" as const) : ("on" as const),
defaultLevel: (params.resolvedElevatedLevel ?? "off") as "on" | "off" | "ask" | "full",
},
}
: { enabled: false };

View File

@@ -205,7 +205,7 @@ export async function handleDirectiveOnly(params: {
const level = currentElevatedLevel ?? "off";
return {
text: [
withOptions(`Current elevated level: ${level}.`, "on, off"),
withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"),
shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null,
]
.filter(Boolean)
@@ -213,7 +213,7 @@ export async function handleDirectiveOnly(params: {
};
}
return {
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on.`,
text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`,
};
}
if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) {
@@ -426,7 +426,9 @@ export async function handleDirectiveOnly(params: {
parts.push(
directives.elevatedLevel === "off"
? formatDirectiveAck("Elevated mode disabled.")
: formatDirectiveAck("Elevated mode enabled."),
: directives.elevatedLevel === "full"
? formatDirectiveAck("Elevated mode set to full (auto-approve).")
: formatDirectiveAck("Elevated mode set to ask (approvals may still apply)."),
);
if (shouldHintDirectRuntime) parts.push(formatElevatedRuntimeHint());
}

View File

@@ -16,10 +16,15 @@ export const withOptions = (line: string, options: string) =>
export const formatElevatedRuntimeHint = () =>
`${SYSTEM_MARK} Runtime is direct; sandboxing does not apply.`;
export const formatElevatedEvent = (level: ElevatedLevel) =>
level === "on"
? "Elevated ON — exec runs on host; set elevated:false to stay sandboxed."
: "Elevated OFF — exec stays in sandbox.";
export const formatElevatedEvent = (level: ElevatedLevel) => {
if (level === "full") {
return "Elevated FULL — exec runs on host with auto-approval.";
}
if (level === "ask" || level === "on") {
return "Elevated ASK — exec runs on host; approvals may still apply.";
}
return "Elevated OFF — exec stays in sandbox.";
};
export const formatReasoningEvent = (level: ReasoningLevel) => {
if (level === "stream") return "Reasoning STREAM — emit live <think>.";

View File

@@ -324,7 +324,12 @@ export function buildStatusMessage(args: StatusArgs): string {
const queueDetails = formatQueueDetails(args.queue);
const verboseLabel =
verboseLevel === "full" ? "verbose:full" : verboseLevel === "on" ? "verbose" : null;
const elevatedLabel = elevatedLevel === "on" ? "elevated" : null;
const elevatedLabel =
elevatedLevel && elevatedLevel !== "off"
? elevatedLevel === "on"
? "elevated"
: `elevated:${elevatedLevel}`
: null;
const optionParts = [
`Runtime: ${runtime.label}`,
`Think: ${thinkLevel}`,
@@ -395,7 +400,7 @@ export function buildHelpMessage(cfg?: ClawdbotConfig): string {
"/think <level>",
"/verbose on|full|off",
"/reasoning on|off",
"/elevated on|off",
"/elevated on|off|ask|full",
"/model <id>",
"/usage off|tokens|full",
];

View File

@@ -1,6 +1,7 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on";
export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream";
export type UsageDisplayLevel = "off" | "tokens" | "full";
@@ -112,10 +113,18 @@ export function normalizeElevatedLevel(raw?: string | null): ElevatedLevel | und
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "auto", "auto-approve", "autoapprove"].includes(key)) return "full";
if (["ask", "prompt", "approval", "approve"].includes(key)) return "ask";
if (["on", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
export function resolveElevatedMode(level?: ElevatedLevel | null): ElevatedMode {
if (!level || level === "off") return "off";
if (level === "full") return "full";
return "ask";
}
// Normalize reasoning visibility flags used to toggle reasoning exposure.
export function normalizeReasoningLevel(raw?: string | null): ReasoningLevel | undefined {
if (!raw) return undefined;