From 0775f59a6fdf8209063da2a46cc0e7c8bb467158 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 4 May 2026 12:37:54 +0700 Subject: [PATCH] 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. --- packages/cli/src/commands/loop/run.ts | 15 ++++ packages/cli/src/commands/loop/types.ts | 4 + packages/cli/src/commands/schedule/create.ts | 2 + packages/cli/src/commands/schedule/index.ts | 4 + packages/cli/src/commands/schedule/shared.ts | 17 +++-- packages/server/src/client/daemon-client.ts | 4 + .../src/server/agent/provider-manifest.ts | 20 ++++- .../server/src/server/loop-service.test.ts | 74 +++++++++++++++++++ packages/server/src/server/loop-service.ts | 15 +++- .../server/src/server/loop/rpc-schemas.ts | 4 + .../src/server/schedule/service.test.ts | 38 ++++++++++ .../server/src/server/schedule/service.ts | 3 +- packages/server/src/server/session.ts | 2 + 13 files changed, 191 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/commands/loop/run.ts b/packages/cli/src/commands/loop/run.ts index 38461fb0c..fc8c25c54 100644 --- a/packages/cli/src/commands/loop/run.ts +++ b/packages/cli/src/commands/loop/run.ts @@ -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 for each fresh worker iteration") .option("--provider ", "Default provider for worker and verifier agents") .option("--model ", "Default model for worker and verifier agents") + .option( + "--mode ", + "Provider-specific mode for the worker agent (e.g. claude bypassPermissions, opencode build)", + ) .option("--verify-provider ", "Provider for the verifier agent") .option("--verify-model ", "Model for the verifier agent") + .option("--verify-mode ", "Provider-specific mode for the verifier agent") .option("--verify ", "Verifier agent prompt") .option( "--verify-check ", @@ -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; diff --git a/packages/cli/src/commands/loop/types.ts b/packages/cli/src/commands/loop/types.ts index 72ca2a67a..66db601dc 100644 --- a/packages/cli/src/commands/loop/types.ts +++ b/packages/cli/src/commands/loop/types.ts @@ -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; diff --git a/packages/cli/src/commands/schedule/create.ts b/packages/cli/src/commands/schedule/create.ts index 1829b24eb..d1bc60ad2 100644 --- a/packages/cli/src/commands/schedule/create.ts +++ b/packages/cli/src/commands/schedule/create.ts @@ -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, }); diff --git a/packages/cli/src/commands/schedule/index.ts b/packages/cli/src/commands/schedule/index.ts index ec9776b68..b4a05ccff 100644 --- a/packages/cli/src/commands/schedule/index.ts +++ b/packages/cli/src/commands/schedule/index.ts @@ -25,6 +25,10 @@ export function createScheduleCommand(): Command { "--provider ", "Agent provider, or provider/model (e.g. codex or codex/gpt-5.4)", ) + .option( + "--mode ", + "Provider-specific mode (e.g. claude bypassPermissions, opencode build)", + ) .option("--max-runs ", "Maximum number of runs") .option("--expires-in ", "Time to live for the schedule"), ).action(withOutput(runCreateCommand)); diff --git a/packages/cli/src/commands/schedule/shared.ts b/packages/cli/src/commands/schedule/shared.ts index 3087b88a7..b0c97d1f8 100644 --- a/packages/cli/src/commands/schedule/shared.ts +++ b/packages/cli/src/commands/schedule/shared.ts @@ -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, }); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 181342e28..6d96dd31b 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -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 } diff --git a/packages/server/src/server/agent/provider-manifest.ts b/packages/server/src/server/agent/provider-manifest.ts index fcc94705e..3edb52a7a 100644 --- a/packages/server/src/server/agent/provider-manifest.ts +++ b/packages/server/src/server/agent/provider-manifest.ts @@ -9,7 +9,10 @@ export interface AgentModeVisuals { colorTier: AgentModeColorTier; } -export type AgentProviderModeDefinition = Omit & AgentModeVisuals; +export type AgentProviderModeDefinition = Omit & + 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, diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 31ec361b2..2e7acbda6 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -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((resolve) => { diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 4c7bc741b..eac7e92bb 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -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, }; diff --git a/packages/server/src/server/loop/rpc-schemas.ts b/packages/server/src/server/loop/rpc-schemas.ts index 4644c9f18..f4e392373 100644 --- a/packages/server/src/server/loop/rpc-schemas.ts +++ b/packages/server/src/server/loop/rpc-schemas.ts @@ -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(), diff --git a/packages/server/src/server/schedule/service.test.ts b/packages/server/src/server/schedule/service.test.ts index f44e5305d..2bb199d9c 100644 --- a/packages/server/src/server/schedule/service.test.ts +++ b/packages/server/src/server/schedule/service.test.ts @@ -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, diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index b91e78d81..f77e12b85 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -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, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index baf6d16b5..a041233cb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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,