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:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user