Add --mode to schedule and loop CLI, default background runs to unattended mode

Plumb --mode/--verify-mode through paseo schedule create and paseo loop run.
Mark each provider's unattended mode (claude bypassPermissions, codex/opencode
full-access, copilot autopilot) so loop and schedule services pick it by
default when no explicit modeId is given.
This commit is contained in:
Mohamed Boudra
2026-05-05 19:56:26 +07:00
parent 5feaa7a101
commit 0775f59a6f
13 changed files with 191 additions and 11 deletions
+15
View File
@@ -21,8 +21,10 @@ export interface LoopRunRow {
export interface LoopRunOptions extends CommandOptions {
provider?: string;
model?: string;
mode?: string;
verifyProvider?: string;
verifyModel?: string;
verifyMode?: string;
verify?: string;
verifyCheck?: string[];
archive?: boolean;
@@ -48,8 +50,13 @@ export function addLoopRunOptions(command: Command): Command {
.argument("<prompt>", "Prompt for each fresh worker iteration")
.option("--provider <provider>", "Default provider for worker and verifier agents")
.option("--model <model>", "Default model for worker and verifier agents")
.option(
"--mode <mode>",
"Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)",
)
.option("--verify-provider <provider>", "Provider for the verifier agent")
.option("--verify-model <model>", "Model for the verifier agent")
.option("--verify-mode <mode>", "Provider-specific mode for the verifier agent")
.option("--verify <prompt>", "Verifier agent prompt")
.option(
"--verify-check <command>",
@@ -116,6 +123,10 @@ export function buildLoopRunInput(prompt: string, options: LoopRunOptions): Loop
result.model = options.model.trim();
}
if (options.mode?.trim()) {
result.modeId = options.mode.trim();
}
// Resolve verifier provider/model
if (options.verifyProvider) {
const { provider, model } = resolveProviderAndModel({ provider: options.verifyProvider });
@@ -130,6 +141,10 @@ export function buildLoopRunInput(prompt: string, options: LoopRunOptions): Loop
result.verifierModel = options.verifyModel.trim();
}
if (options.verifyMode?.trim()) {
result.verifierModeId = options.verifyMode.trim();
}
if (verifyPrompt) result.verifyPrompt = verifyPrompt;
if (options.verifyCheck && options.verifyCheck.length > 0) {
result.verifyChecks = options.verifyCheck;
+4
View File
@@ -47,10 +47,12 @@ export interface LoopRecord {
cwd: string;
provider: string;
model: string | null;
modeId: string | null;
workerProvider: string | null;
workerModel: string | null;
verifierProvider: string | null;
verifierModel: string | null;
verifierModeId: string | null;
verifyPrompt: string | null;
verifyChecks: string[];
archive: boolean;
@@ -124,10 +126,12 @@ export interface LoopRunInput {
cwd: string;
provider?: string;
model?: string;
modeId?: string;
workerProvider?: string;
workerModel?: string;
verifierProvider?: string;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
@@ -16,6 +16,7 @@ export interface ScheduleCreateOptions extends ScheduleCommandOptions {
name?: string;
target?: string;
provider?: string;
mode?: string;
maxRuns?: string;
expiresIn?: string;
}
@@ -32,6 +33,7 @@ export async function runCreateCommand(
name: options.name,
target: options.target,
provider: options.provider,
mode: options.mode,
maxRuns: options.maxRuns,
expiresIn: options.expiresIn,
});
@@ -25,6 +25,10 @@ export function createScheduleCommand(): Command {
"--provider <provider>",
"Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)",
)
.option(
"--mode <mode>",
"Provider-specific mode (e.g. claude bypassPermissions, opencode build)",
)
.option("--max-runs <n>", "Maximum number of runs")
.option("--expires-in <duration>", "Time to live for the schedule"),
).action(withOutput(runCreateCommand));
+10 -7
View File
@@ -85,14 +85,14 @@ export function formatDurationMs(durationMs: number): string {
function resolveScheduleTarget(args: {
targetValue: string | undefined;
hasExplicitProviderSelection: boolean;
hasExplicitNewAgentOption: boolean;
createNewAgentTarget: () => ScheduleTarget;
}): ScheduleTarget {
const { targetValue, hasExplicitProviderSelection, createNewAgentTarget } = args;
const { targetValue, hasExplicitNewAgentOption, createNewAgentTarget } = args;
const currentAgentId = process.env.PASEO_AGENT_ID?.trim();
if (!targetValue) {
if (currentAgentId && !hasExplicitProviderSelection) {
if (currentAgentId && !hasExplicitNewAgentOption) {
return { type: "self", agentId: currentAgentId };
}
return createNewAgentTarget();
@@ -102,10 +102,10 @@ function resolveScheduleTarget(args: {
return createNewAgentTarget();
}
if (hasExplicitProviderSelection) {
if (hasExplicitNewAgentOption) {
throw {
code: "INVALID_TARGET",
message: "--provider can only be used with a new-agent target",
message: "--provider/--mode can only be used with a new-agent target",
details: "Use --target new-agent or omit --target to create a new agent schedule",
} satisfies CommandError;
}
@@ -130,6 +130,7 @@ export function parseScheduleCreateInput(options: {
name?: string;
target?: string;
provider?: string;
mode?: string;
maxRuns?: string;
expiresIn?: string;
}): CreateScheduleInput {
@@ -154,7 +155,8 @@ export function parseScheduleCreateInput(options: {
: { type: "cron", expression: options.cron!.trim() };
const targetValue = options.target?.trim();
const hasExplicitProviderSelection = options.provider !== undefined;
const modeId = options.mode?.trim();
const hasExplicitNewAgentOption = options.provider !== undefined || options.mode !== undefined;
const createNewAgentTarget = (): ScheduleTarget => {
const resolvedProviderModel = resolveProviderAndModel({
provider: options.provider,
@@ -165,12 +167,13 @@ export function parseScheduleCreateInput(options: {
provider: resolvedProviderModel.provider,
cwd: process.cwd(),
...(resolvedProviderModel.model ? { model: resolvedProviderModel.model } : {}),
...(modeId ? { modeId } : {}),
},
};
};
const target = resolveScheduleTarget({
targetValue,
hasExplicitProviderSelection,
hasExplicitNewAgentOption,
createNewAgentTarget,
});
@@ -475,8 +475,10 @@ export interface RunLoopOptions {
cwd: string;
provider?: string;
model?: string;
modeId?: string;
verifierProvider?: string;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string | null;
verifyChecks?: string[];
name?: string | null;
@@ -3751,8 +3753,10 @@ export class DaemonClient {
cwd: options.cwd,
...(options.provider ? { provider: options.provider } : {}),
...(options.model ? { model: options.model } : {}),
...(options.modeId ? { modeId: options.modeId } : {}),
...(options.verifierProvider ? { verifierProvider: options.verifierProvider } : {}),
...(options.verifierModel ? { verifierModel: options.verifierModel } : {}),
...(options.verifierModeId ? { verifierModeId: options.verifierModeId } : {}),
...(options.verifyPrompt ? { verifyPrompt: options.verifyPrompt } : {}),
...(options.verifyChecks && options.verifyChecks.length > 0
? { verifyChecks: options.verifyChecks }
@@ -9,7 +9,10 @@ export interface AgentModeVisuals {
colorTier: AgentModeColorTier;
}
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> & AgentModeVisuals;
export type AgentProviderModeDefinition = Omit<AgentMode, "icon" | "colorTier"> &
AgentModeVisuals & {
isUnattended?: boolean;
};
// TODO: `modes` should not be static. Providers (especially ACP) report their
// own modes at runtime via session/new. We should fetch modes from the provider
@@ -55,6 +58,7 @@ const CLAUDE_MODES: AgentProviderModeDefinition[] = [
description: "Skip all permission prompts (use with caution)",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
];
@@ -72,6 +76,7 @@ const CODEX_MODES: AgentProviderModeDefinition[] = [
description: "Edit files, run commands, and access the network without additional prompts.",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
];
@@ -96,6 +101,7 @@ const COPILOT_MODES: AgentProviderModeDefinition[] = [
description: "Autonomous mode that runs until task completion without user interaction",
icon: "ShieldOff",
colorTier: "dangerous",
isUnattended: true,
},
];
@@ -113,6 +119,7 @@ const OPENCODE_MODES: AgentProviderModeDefinition[] = [
description: "Automatically approves all tool permission prompts for the session",
icon: "ShieldAlert",
colorTier: "dangerous",
isUnattended: true,
},
{
id: "plan",
@@ -222,6 +229,17 @@ export function isValidAgentProvider(
return Array.isArray(validIds) ? validIds.includes(value) : new Set(validIds).has(value);
}
export function getUnattendedModeId(
provider: string,
definitions: AgentProviderDefinition[] = [
...AGENT_PROVIDER_DEFINITIONS,
...DEV_AGENT_PROVIDER_DEFINITIONS,
],
): string | undefined {
const definition = definitions.find((entry) => entry.id === provider);
return definition?.modes.find((mode) => mode.isUnattended)?.id;
}
export function getModeVisuals(
provider: string,
modeId: string,
@@ -432,6 +432,80 @@ describe("LoopService", () => {
expect(logs.entries.some((entry) => entry.text.includes("Verifier result"))).toBe(true);
});
test("defaults worker and verifier modeId to provider's unattended mode", async () => {
const workerConfigs: AgentSessionConfig[] = [];
const verifierConfigs: AgentSessionConfig[] = [];
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
if (config.title?.includes("worker")) {
workerConfigs.push(config);
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
verifierConfigs.push(config);
return '{"passed":true,"reason":"ok"}';
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt",
cwd: workspaceDir,
verifyPrompt: "Confirm that done.txt exists in the workspace.",
maxIterations: 1,
});
await waitForLoopCompletion(service, loop.id);
expect(workerConfigs[0]?.modeId).toBe("bypassPermissions");
expect(verifierConfigs[0]?.modeId).toBe("bypassPermissions");
});
test("explicit modeId wins over unattended default", async () => {
const workerConfigs: AgentSessionConfig[] = [];
const verifierConfigs: AgentSessionConfig[] = [];
const manager = new AgentManager({
clients: {
claude: new ScriptedAgentClient("claude", {
async onRun({ config }) {
if (config.title?.includes("worker")) {
workerConfigs.push(config);
writeFileSync(path.join(workspaceDir, "done.txt"), "ok");
return "created done.txt";
}
verifierConfigs.push(config);
return '{"passed":true,"reason":"ok"}';
},
}),
},
registry: storage,
logger,
});
const service = new LoopService({ paseoHome, agentManager: manager, logger });
await service.initialize();
const loop = await service.runLoop({
prompt: "Create done.txt",
cwd: workspaceDir,
modeId: "acceptEdits",
verifierModeId: "plan",
verifyPrompt: "Confirm that done.txt exists in the workspace.",
maxIterations: 1,
});
await waitForLoopCompletion(service, loop.id);
expect(workerConfigs[0]?.modeId).toBe("acceptEdits");
expect(verifierConfigs[0]?.modeId).toBe("plan");
});
test("stops a running loop and cancels the active worker", async () => {
let release: (() => void) | null = null;
const blocker = new Promise<void>((resolve) => {
+13 -2
View File
@@ -14,6 +14,7 @@ import type {
AgentProvider,
} from "./agent/agent-sdk-types.js";
import { execCommand, platformShell } from "../utils/spawn.js";
import { getUnattendedModeId } from "./agent/provider-manifest.js";
const LOOP_ID_LENGTH = 8;
const DEFAULT_LOOP_PROVIDER: AgentProvider = "claude";
@@ -71,10 +72,12 @@ const LoopRecordSchema = z.object({
cwd: z.string(),
provider: z.string(),
model: z.string().nullable(),
modeId: z.string().nullable().default(null),
workerProvider: z.string().nullable(),
workerModel: z.string().nullable(),
verifierProvider: z.string().nullable(),
verifierModel: z.string().nullable(),
verifierModeId: z.string().nullable().default(null),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
archive: z.boolean(),
@@ -109,10 +112,12 @@ export interface LoopRunOptions {
cwd: string;
provider?: AgentProvider;
model?: string;
modeId?: string;
workerProvider?: AgentProvider;
workerModel?: string;
verifierProvider?: AgentProvider;
verifierModel?: string;
verifierModeId?: string;
verifyPrompt?: string;
verifyChecks?: string[];
archive?: boolean;
@@ -376,10 +381,12 @@ export class LoopService {
cwd: path.resolve(input.cwd),
provider: input.provider ?? DEFAULT_LOOP_PROVIDER,
model: normalizePrompt(input.model, "model"),
modeId: normalizePrompt(input.modeId, "modeId"),
workerProvider: input.workerProvider ?? null,
workerModel: normalizePrompt(input.workerModel, "workerModel"),
verifierProvider: input.verifierProvider ?? null,
verifierModel: normalizePrompt(input.verifierModel, "verifierModel"),
verifierModeId: normalizePrompt(input.verifierModeId, "verifierModeId"),
verifyPrompt,
verifyChecks,
archive: input.archive ?? false,
@@ -789,10 +796,12 @@ export class LoopService {
}
private buildWorkerConfig(loop: LoopRecord, iteration: LoopIterationRecord): AgentSessionConfig {
const provider = loop.workerProvider ?? loop.provider;
return {
provider: loop.workerProvider ?? loop.provider,
provider,
cwd: loop.cwd,
model: loop.workerModel ?? loop.model ?? undefined,
modeId: loop.modeId ?? getUnattendedModeId(provider),
title: buildWorkerTitle(loop, iteration.index),
internal: true,
};
@@ -802,10 +811,12 @@ export class LoopService {
loop: LoopRecord,
iteration: LoopIterationRecord,
): AgentSessionConfig {
const provider = loop.verifierProvider ?? loop.provider;
return {
provider: loop.verifierProvider ?? loop.provider,
provider,
cwd: loop.cwd,
model: loop.verifierModel ?? loop.model ?? undefined,
modeId: loop.verifierModeId ?? loop.modeId ?? getUnattendedModeId(provider),
title: buildVerifierTitle(loop, iteration.index),
internal: true,
};
@@ -48,10 +48,12 @@ export const LoopRecordSchema = z.object({
cwd: z.string(),
provider: AgentProviderSchema,
model: z.string().nullable(),
modeId: z.string().nullable().default(null),
workerProvider: AgentProviderSchema.nullable(),
workerModel: z.string().nullable(),
verifierProvider: AgentProviderSchema.nullable(),
verifierModel: z.string().nullable(),
verifierModeId: z.string().nullable().default(null),
verifyPrompt: z.string().nullable(),
verifyChecks: z.array(z.string()),
archive: z.boolean(),
@@ -89,10 +91,12 @@ export const LoopRunRequestSchema = z.object({
cwd: z.string(),
provider: AgentProviderSchema.optional(),
model: z.string().trim().min(1).optional(),
modeId: z.string().trim().min(1).optional(),
workerProvider: AgentProviderSchema.optional(),
workerModel: z.string().trim().min(1).optional(),
verifierProvider: AgentProviderSchema.optional(),
verifierModel: z.string().trim().min(1).optional(),
verifierModeId: z.string().trim().min(1).optional(),
verifyPrompt: z.string().trim().min(1).optional(),
verifyChecks: z.array(z.string().trim().min(1)).optional(),
archive: z.boolean().optional(),
@@ -176,6 +176,44 @@ describe("ScheduleService", () => {
);
});
test("defaults new-agent modeId to provider's unattended mode", async () => {
const manager = new AgentManager({
logger: createTestLogger(),
clients: createTestAgentClients(),
registry: agentStorage,
});
const service = new ScheduleService({
paseoHome: tempDir,
logger: createTestLogger(),
agentManager: manager,
agentStorage,
now: () => now,
});
const created = await service.create({
prompt: "Respond with exactly hello",
cadence: { type: "every", everyMs: 60_000 },
target: {
type: "new-agent",
config: {
provider: "claude",
cwd: tempDir,
approvalPolicy: "never",
},
},
maxRuns: 1,
});
now = new Date("2026-01-01T00:01:00.000Z");
await service.tick();
const inspected = await service.inspect(created.id);
const agentId = inspected.runs[0]?.agentId;
expect(agentId).toBeTruthy();
const agent = manager.getAgent(agentId!);
expect(agent?.currentModeId).toBe("bypassPermissions");
});
test("advances stale nextRunAt on daemon restart", async () => {
const service1 = new ScheduleService({
paseoHome: tempDir,
@@ -6,6 +6,7 @@ import type { AgentStorage } from "../agent/agent-storage.js";
import type { AgentPromptInput, AgentSessionConfig } from "../agent/agent-sdk-types.js";
import { curateAgentActivity } from "../agent/activity-curator.js";
import { ensureAgentLoaded } from "../agent/agent-loading.js";
import { getUnattendedModeId } from "../agent/provider-manifest.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import type {
@@ -411,7 +412,7 @@ export class ScheduleService {
const config: AgentSessionConfig = {
provider: schedule.target.config.provider,
cwd: schedule.target.config.cwd,
modeId: schedule.target.config.modeId,
modeId: schedule.target.config.modeId ?? getUnattendedModeId(schedule.target.config.provider),
model: schedule.target.config.model,
thinkingOptionId: schedule.target.config.thinkingOptionId,
title: schedule.target.config.title,
+2
View File
@@ -8599,10 +8599,12 @@ export class Session {
cwd: request.cwd,
provider: request.provider,
model: request.model,
modeId: request.modeId,
workerProvider: request.workerProvider,
workerModel: request.workerModel,
verifierProvider: request.verifierProvider,
verifierModel: request.verifierModel,
verifierModeId: request.verifierModeId,
verifyPrompt: request.verifyPrompt,
verifyChecks: request.verifyChecks,
archive: request.archive,