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:
@@ -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