From 95f07beeb1bdad52516d4065e4c5592b120c0a6f Mon Sep 17 00:00:00 2001 From: oratis Date: Sat, 8 Aug 2026 17:45:23 +0800 Subject: [PATCH] feat(core): give unattended runs an explicit approval policy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scheduled jobs and headless CI runs have no approver, but the loop expressed that only as a missing callback — which headless already fills with an auto-deny, so "no callback" and "no human" were not the same question. runAgent now takes `unattended` (stated by the host, not inferred) and `onApprovalRequired`: - `deny` (default) refuses the call and continues — what every host did before. - `abort` stops the run with stopReason `blocked`, exit code 6. A job whose first write was refused otherwise grinds on and reports a confidently wrong result. CronJob carries the policy; reads go through resolveUnattendedApproval so jobs stored before the field keep the old behaviour, and a hand-edited typo in cron.json falls back to deny rather than widening it. Two things found while wiring this up: - An unattended run inherits `permissions.defaultMode` from the same settings file used interactively, so a convenience `bypassPermissions` silently becomes the posture of every 3am job. Headless now warns when the mode is inherited rather than passed for this run. The clamp itself needs TriggerProfile to have an opt-in, so it lands later. - Aborting mid-batch left `tool_use` blocks unanswered, which a provider rejects on resume. Tool results are now flushed through one helper that synthesizes "never ran" entries for the remainder. docs/cli-flags.md's exit-code table contradicted the implementation (it listed 3 as "tool denied" and 5 as "API key invalid"); corrected to match headless.ts, which owns the contract. Co-Authored-By: Claude Opus 5 --- apps/cli/src/headless.ts | 24 ++++++ apps/cli/src/scheduler.ts | 11 ++- docs/cli-flags.md | 23 ++--- docs/quickstart.md | 22 ++++- packages/core/src/agent.test.ts | 91 ++++++++++++++++++++ packages/core/src/agent.ts | 118 +++++++++++++++++++------- packages/core/src/cron/index.test.ts | 47 ++++++++++ packages/core/src/cron/index.ts | 31 ++++++- packages/core/src/index.ts | 3 + packages/core/src/modes/index.ts | 14 +++ packages/core/src/tools/cron-tools.ts | 34 +++++++- packages/core/src/types.ts | 2 +- 12 files changed, 370 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 9ce53a8..99b60b9 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -13,6 +13,7 @@ // 3 API / provider error (network, auth) // 4 max turns reached without completion // 5 aborted by signal (SIGINT / SIGTERM) +// 6 unattended run stopped: a call needed approval and onApprovalRequired='abort' import { BashTool, @@ -48,8 +49,10 @@ import { collectPluginContributions, type AgentEvent, type Effort, + isPermissiveMode, type McpClientHandle, type Mode, + type UnattendedApprovalPolicy, type WireResult, } from '@deepcode/core'; import type { Writable } from 'node:stream'; @@ -84,6 +87,13 @@ export interface HeadlessOpts { /** In stream-json mode, also emit text_delta and thinking_delta events. * Default is to drop those for compact streams. */ includePartialMessages?: boolean; + /** + * What to do when a call needs approval and nobody is there to give it. + * `deny` (default) refuses that call and keeps going; `abort` ends the run + * with exit code 6, which scheduled jobs generally want — a job that got + * half its tool calls refused has usually produced a misleading result. + */ + onApprovalRequired?: UnattendedApprovalPolicy; } const DEFAULT_SYSTEM_PROMPT = `You are DeepCode, an AI coding assistant powered by DeepSeek. Help the user with their codebase using the available tools. Be concise and accurate. When you modify files, briefly explain what you changed and why.`; @@ -135,6 +145,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise { const model = opts.model ?? settings.model ?? 'deepseek-chat'; const mode = (opts.mode ?? settings.permissions?.defaultMode ?? 'default') as Mode; + // A permissive mode chosen for the REPL is inherited by every unattended run + // that reads the same settings file — including scheduled jobs firing at 3am. + // Passing `--mode` explicitly is a deliberate choice for this run, so only the + // inherited case is worth a warning. + if (!opts.mode && isPermissiveMode(mode)) { + errOutput.write( + `Warning: this unattended run inherits permissions.defaultMode="${mode}" from settings, ` + + `so tool calls execute without approval. Pass --mode default to override.\n`, + ); + } const effort = opts.effort ?? settings.effortLevel ?? 'medium'; const { maxTokens, temperature } = EFFORT_PARAMS[effort as Effort] ?? EFFORT_PARAMS.medium; const maxTurns = opts.maxTurns ?? DEFAULT_HEADLESS_MAX_TURNS; @@ -325,6 +345,8 @@ export async function runHeadless(opts: HeadlessOpts): Promise { // would normally need approval. Users wanting auto-yes should pass // --mode dontAsk or --mode bypassPermissions (gated by trust). approval: async () => false, + unattended: true, + onApprovalRequired: opts.onApprovalRequired ?? 'deny', onEvent, }); @@ -334,6 +356,8 @@ export async function runHeadless(opts: HeadlessOpts): Promise { exitCode = 4; } else if (result.stopReason === 'error') { exitCode = 3; + } else if (result.stopReason === 'blocked') { + exitCode = 6; } else { exitCode = 0; } diff --git a/apps/cli/src/scheduler.ts b/apps/cli/src/scheduler.ts index 5526b97..8c6305d 100644 --- a/apps/cli/src/scheduler.ts +++ b/apps/cli/src/scheduler.ts @@ -15,6 +15,7 @@ import { listCronJobs, loadCronStore, saveCronStore, + resolveUnattendedApproval, uninstallPlist, type CronJob, } from '@deepcode/core'; @@ -71,15 +72,23 @@ async function defaultRunJob(job: CronJob, home: string): Promise { await fs.mkdir(dirname(logPath), { recursive: true }); const log = createWriteStream(logPath, { flags: 'a' }); try { + const onApprovalRequired = resolveUnattendedApproval(job); log.write(`\n===== ${new Date().toISOString()} =====\n`); - await runHeadless({ + log.write(`[job] onApprovalRequired=${onApprovalRequired}\n`); + const code = await runHeadless({ output: log, errOutput: log, cwd: job.cwd, home, prompt: job.prompt, outputFormat: 'text', + onApprovalRequired, }); + // Exit 6 means the run stopped because a call needed an approver. Surface it + // as a failure so the scheduler log does not read like a clean run. + if (code === 6) { + throw new Error('stopped: a tool call required approval and onApprovalRequired=abort'); + } } finally { log.end(); } diff --git a/docs/cli-flags.md b/docs/cli-flags.md index b227788..2424c61 100644 --- a/docs/cli-flags.md +++ b/docs/cli-flags.md @@ -102,16 +102,19 @@ Later layers override earlier ones (deep-merge for objects, arrays replace). ## Exit codes -| Code | Meaning | -| ---- | ----------------------------------- | -| `0` | Success | -| `1` | General error (e.g. no credentials) | -| `2` | Unknown flag / bad argument | -| `3` | Tool denied by permissions | -| `4` | `--max-turns` reached | -| `5` | API key invalid | - -(Codes 3-5 are reserved for M3+ enforcement.) +| Code | Meaning | +| ---- | -------------------------------------------------------------------------------------------- | +| `0` | Success | +| `1` | General error (uncaught) | +| `2` | Unknown flag / bad argument | +| `3` | API / provider error (network, auth, no credentials) | +| `4` | `--max-turns` reached | +| `5` | Aborted by signal (SIGINT / SIGTERM) | +| `6` | Unattended run stopped: a call needed approval and the job set `onApprovalRequired: "abort"` | + +These match `apps/cli/src/headless.ts`, which owns the contract. An earlier +version of this table listed codes 3–5 as reserved with different meanings; the +implementation and `docs/quickstart.md` were always the accurate pair. ## Environment variables diff --git a/docs/quickstart.md b/docs/quickstart.md index badf8bf..42cbf83 100644 --- a/docs/quickstart.md +++ b/docs/quickstart.md @@ -50,7 +50,7 @@ deepcode --model deepseek-reasoner --effort high # deeper reasoning `-p`/`--print` runs a single prompt and exits. Combine with `--output-format json` for machine-readable output. Exit codes: `0` ok · `1` generic · `2` bad-input · -`3` api/auth · `4` max-turns · `5` aborted. +`3` api/auth · `4` max-turns · `5` aborted · `6` blocked (see below). ```bash deepcode -p "summarize the architecture" --output-format json @@ -59,6 +59,26 @@ deepcode -p "summarize the architecture" --output-format json For long-lived CI tokens, run `deepcode setup-token` once and store the printed token as `DEEPSEEK_AUTH_TOKEN` in your CI secrets. +### Scheduled jobs + +`CronCreate` (or `deepcode cron`) schedules a prompt to run headlessly on a cron +expression. Nobody is watching when it fires, so approval-requiring tool calls +cannot be answered. Each job chooses what happens then: + +| `onApprovalRequired` | Behaviour | +| -------------------- | ---------------------------------------------------------------------- | +| `deny` (default) | Refuse that one call, let the run continue | +| `abort` | Stop the run, exit `6`, and log the reason to `~/.deepcode/cron-logs/` | + +Pick `abort` when a partially-executed job is worse than no job — a run whose +first write was refused usually produces a confidently wrong summary otherwise. + +One thing to check before relying on a scheduled job: it reads the same +`settings.json` you use interactively, so `permissions.defaultMode` carries over. +If you set `bypassPermissions` for your own convenience, every scheduled job +inherits it and executes without approval. DeepCode prints a warning to the job +log when that happens; pass `--mode default` to opt a run out. + --- ## macOS desktop app diff --git a/packages/core/src/agent.test.ts b/packages/core/src/agent.test.ts index c21055c..ceb75d7 100644 --- a/packages/core/src/agent.test.ts +++ b/packages/core/src/agent.test.ts @@ -848,4 +848,95 @@ describe('runAgent', () => { expect(events).toContain('PreCompact'); expect(events).toContain('PostCompact'); }); + // ── unattended approval policy ─────────────────────────────────────── + // Scheduled/CI runs have no approver. These lock in that the loop says so + // explicitly rather than reporting the generic "requires approval", and that + // `abort` stops the run instead of letting it grind on against a wall. + describe('unattended runs', () => { + const writeCall: ToolUseBlock = { + type: 'tool_use', + id: 't-unattended', + name: 'Write', + input: { file_path: 'out.txt', content: 'x' }, + }; + + it('deny (the default) refuses the call and keeps running', async () => { + const provider = new MockProvider([ + toolUse('writing', writeCall), + endTurn('carried on without it'), + ]); + const result = await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + mode: 'default', + unattended: true, + }); + expect(result.stopReason).toBe('end_turn'); + const blocked = result.history + .flatMap((m) => m.content) + .find((b) => b.type === 'tool_result' && b.tool_use_id === 't-unattended'); + expect((blocked as { content: string }).content).toContain('unattended'); + }); + + it('abort stops the run with stopReason=blocked', async () => { + const provider = new MockProvider([toolUse('writing', writeCall)]); + const result = await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + mode: 'default', + unattended: true, + onApprovalRequired: 'abort', + }); + expect(result.stopReason).toBe('blocked'); + // The refusal is still recorded, so a transcript shows why it stopped. + const blocked = result.history + .flatMap((m) => m.content) + .find((b) => b.type === 'tool_result' && b.tool_use_id === 't-unattended'); + expect(blocked).toBeDefined(); + }); + + it('abort does not fire when nothing needs approval', async () => { + const provider = new MockProvider([endTurn('nothing to approve')]); + const result = await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + mode: 'default', + unattended: true, + onApprovalRequired: 'abort', + }); + expect(result.stopReason).toBe('end_turn'); + }); + + it('an attended run is untouched: the approval callback still decides', async () => { + const provider = new MockProvider([toolUse('writing', writeCall), endTurn('done')]); + const asked: string[] = []; + const result = await runAgent({ + provider, + tools: new ToolRegistry(), + systemPrompt: '', + userMessage: 'go', + model: 'deepseek-chat', + cwd, + mode: 'default', + approval: async (tool) => { + asked.push(tool); + return false; + }, + }); + expect(asked).toEqual(['Write']); + expect(result.stopReason).toBe('end_turn'); + }); + }); }); diff --git a/packages/core/src/agent.ts b/packages/core/src/agent.ts index 8567a09..0bddd60 100644 --- a/packages/core/src/agent.ts +++ b/packages/core/src/agent.ts @@ -3,6 +3,7 @@ import { compact, shouldCompact } from './compaction/index.js'; import type { PermissionRules } from './config/types.js'; +import type { UnattendedApprovalPolicy } from './cron/index.js'; import { dispatchToolCall, type DispatchVerdict } from './harness/tool-dispatcher.js'; import { TaskManager, type TaskRunner } from './tasks/manager.js'; import type { HookDispatcher } from './hooks/index.js'; @@ -70,6 +71,19 @@ export interface RunAgentOptions { permissions?: PermissionRules; hooks?: HookDispatcher; approval?: ApprovalCallback; + /** + * Declares that no human can answer an approval prompt for this run (cron, + * headless CI). Hosts state it rather than letting the loop infer it from a + * missing callback: headless installs an auto-deny `approval`, so absence of + * a callback is not the same question as absence of a human. + */ + unattended?: boolean; + /** + * What an unattended run does when a call resolves to `ask`. Defaults to + * `deny` — refuse that call, continue the run — which is what every host did + * before this option existed. `abort` ends the run instead. + */ + onApprovalRequired?: UnattendedApprovalPolicy; /** AutoModeConfig from settings.autoMode — used when mode === 'auto'. */ autoMode?: import('./config/types.js').AutoModeConfig; /** M3.5: passed through to Bash tool ctx for sandbox wrapping. */ @@ -139,8 +153,11 @@ export interface RunAgentResult { reasoningTokens: number; cacheReadTokens: number; }; - /** Reason the loop terminated. */ - stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; + /** + * Reason the loop terminated. `blocked` means an unattended run hit a call + * needing approval while configured with `onApprovalRequired: 'abort'`. + */ + stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error' | 'blocked'; /** Mode-control signals flipped by tools during this run (M3c-rest). */ modeSignal?: { exitPlanMode?: boolean; enterPlanMode?: boolean }; } @@ -576,6 +593,59 @@ export async function runAgent(opts: RunAgentOptions): Promise { type Ready = { toolUse: ToolUseBlock; handler: NonNullable> }; const ready: Ready[] = []; + /** + * Append this batch's tool results to history, in the model's original call + * order, and persist them. + * + * Any `tool_use` still without a result gets a synthetic one. That happens + * when the loop stops mid-batch (unattended abort): leaving a `tool_use` + * unanswered produces a message sequence the provider rejects on resume, + * so an explicit "never ran" beats a silent hole. + */ + const flushToolResults = async (): Promise => { + const toolResults: ToolResultBlock[] = toolBlocks.map( + (b) => + resultsById.get(b.id) ?? { + type: 'tool_result', + tool_use_id: b.id, + content: 'Tool call not executed: the run stopped before reaching it.', + is_error: true, + }, + ); + const resultMsg: StoredMessage = { + role: 'user', + content: toolResults as ContentBlock[], + timestamp: new Date().toISOString(), + }; + history.push(resultMsg); + if (opts.session && opts.persistSessionMessages !== false) { + await opts.session.manager.append(opts.session.id, resultMsg); + } + }; + + /** Record a gate refusal as both a tool result (the model sees it) and an event (the host does). */ + const recordBlocked = ( + toolUse: ToolUseBlock, + reason: string, + verdict: DispatchVerdict, + ): void => { + resultsById.set(toolUse.id, { + type: 'tool_result', + tool_use_id: toolUse.id, + content: `Tool call blocked: ${reason}`, + is_error: true, + }); + opts.onEvent?.({ + type: 'tool_result', + id: toolUse.id, + result: { + content: reason, + isError: true, + data: { dispatchSource: verdict.source, decision: verdict.decision }, + }, + }); + }; + // Phase 1 — sequential gate + approval. for (const toolUse of toolBlocks) { const handler = @@ -606,7 +676,18 @@ export async function runAgent(opts: RunAgentOptions): Promise { autoModeProvider: opts.provider, }); let allowed = verdict.decision === 'allow'; - if (verdict.decision === 'ask' && opts.approval) { + let blockReason = verdict.reason; + if (verdict.decision === 'ask' && opts.unattended) { + // No human can answer, so don't pretend to ask. Say so explicitly — + // "requires approval" in an unattended log reads like a transient + // condition when it is in fact terminal for this call. + blockReason = `approval required for ${toolUse.name}, but this run is unattended (no approver attached)`; + if (opts.onApprovalRequired === 'abort') { + recordBlocked(toolUse, blockReason, verdict); + await flushToolResults(); + return finish('blocked'); + } + } else if (verdict.decision === 'ask' && opts.approval) { const decision = await waitForApproval( opts.approval, toolUse.name, @@ -621,21 +702,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { allowed = decision === true || decision === 'always'; } if (!allowed) { - resultsById.set(toolUse.id, { - type: 'tool_result', - tool_use_id: toolUse.id, - content: `Tool call blocked: ${verdict.reason}`, - is_error: true, - }); - opts.onEvent?.({ - type: 'tool_result', - id: toolUse.id, - result: { - content: verdict.reason, - isError: true, - data: { dispatchSource: verdict.source, decision: verdict.decision }, - }, - }); + recordBlocked(toolUse, blockReason, verdict); continue; } @@ -728,20 +795,7 @@ export async function runAgent(opts: RunAgentOptions): Promise { await Promise.all(parallel.map(execOne)); for (const r of serial) await execOne(r); - // Re-assemble in the model's original tool-call order. - const toolResults: ToolResultBlock[] = toolBlocks - .map((b) => resultsById.get(b.id)) - .filter((r): r is ToolResultBlock => r !== undefined); - - const resultMsg: StoredMessage = { - role: 'user', - content: toolResults as ContentBlock[], - timestamp: new Date().toISOString(), - }; - history.push(resultMsg); - if (opts.session && opts.persistSessionMessages !== false) { - await opts.session.manager.append(opts.session.id, resultMsg); - } + await flushToolResults(); // M3c: auto-compact if the *current* context crossed the threshold. // diff --git a/packages/core/src/cron/index.test.ts b/packages/core/src/cron/index.test.ts index 3d46252..6fc3b76 100644 --- a/packages/core/src/cron/index.test.ts +++ b/packages/core/src/cron/index.test.ts @@ -10,6 +10,7 @@ import { listCronJobs, loadCronStore, removeCronJob, + resolveUnattendedApproval, validateCronExpr, } from './index.js'; @@ -147,3 +148,49 @@ describe('cron store CRUD', () => { ); }); }); + +describe('resolveUnattendedApproval', () => { + it('defaults to deny — jobs stored before the field existed keep old behaviour', () => { + expect(resolveUnattendedApproval({})).toBe('deny'); + expect(resolveUnattendedApproval({ onApprovalRequired: undefined })).toBe('deny'); + }); + + it('honours an explicit abort', () => { + expect(resolveUnattendedApproval({ onApprovalRequired: 'abort' })).toBe('abort'); + }); + + it('treats an unrecognised stored value as deny rather than trusting it', () => { + // The store is a plain JSON file a user can hand-edit; a typo must not + // silently widen behaviour. + expect( + resolveUnattendedApproval({ + onApprovalRequired: 'ABORT' as unknown as 'abort', + }), + ).toBe('deny'); + }); +}); + +describe('addCronJob — unattended policy', () => { + let home: string; + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'dc-cron-policy-')); + }); + afterEach(async () => { + await rm(home, { recursive: true, force: true }); + }); + + it('omits the field when unspecified, so the default stays implicit', async () => { + const job = await addCronJob({ schedule: '* * * * *', prompt: 'x', cwd: '/' }, home); + expect(job.onApprovalRequired).toBeUndefined(); + expect(resolveUnattendedApproval(job)).toBe('deny'); + }); + + it('persists an explicit abort across a store round-trip', async () => { + await addCronJob( + { schedule: '* * * * *', prompt: 'x', cwd: '/', onApprovalRequired: 'abort' }, + home, + ); + const [stored] = await listCronJobs(home); + expect(resolveUnattendedApproval(stored!)).toBe('abort'); + }); +}); diff --git a/packages/core/src/cron/index.ts b/packages/core/src/cron/index.ts index b7ad121..dc380fa 100644 --- a/packages/core/src/cron/index.ts +++ b/packages/core/src/cron/index.ts @@ -7,6 +7,16 @@ import { promises as fs } from 'node:fs'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; +/** + * What an unattended run does when a tool call resolves to `ask`. + * + * `deny` (default, and the pre-existing behaviour) refuses that one call and + * lets the run continue. `abort` stops the whole run instead — the right choice + * when a half-executed job is worse than no job, since a denied first call + * otherwise leaves the agent looping against a wall while it burns tokens. + */ +export type UnattendedApprovalPolicy = 'deny' | 'abort'; + export interface CronJob { id: string; /** 5-field cron expression: "min hour day-of-month month day-of-week". */ @@ -18,6 +28,19 @@ export interface CronJob { createdAt: string; lastRunAt?: string; enabled: boolean; + /** + * Unattended approval policy for this job. Absent on jobs created before this + * field existed, which is why every read goes through + * `resolveUnattendedApproval` instead of touching the field directly. + */ + onApprovalRequired?: UnattendedApprovalPolicy; +} + +/** The effective policy for a job, defaulting to the historical `deny`. */ +export function resolveUnattendedApproval( + job: Pick, +): UnattendedApprovalPolicy { + return job.onApprovalRequired === 'abort' ? 'abort' : 'deny'; } export interface CronStore { @@ -51,7 +74,12 @@ function newCronId(): string { } export async function addCronJob( - job: { schedule: string; prompt: string; cwd: string }, + job: { + schedule: string; + prompt: string; + cwd: string; + onApprovalRequired?: UnattendedApprovalPolicy; + }, home: string = homedir(), ): Promise { const invalid = validateCronExpr(job.schedule); @@ -64,6 +92,7 @@ export async function addCronJob( cwd: job.cwd, createdAt: new Date().toISOString(), enabled: true, + ...(job.onApprovalRequired ? { onApprovalRequired: job.onApprovalRequired } : {}), }; store.jobs.push(created); await saveCronStore(store, home); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index fb42c03..e7f3061 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -138,6 +138,7 @@ export { // Mode policy (M3) export { evaluateMode, + isPermissiveMode, modeVerdictReason, type ModeRequest, type ModeVerdict, @@ -424,8 +425,10 @@ export { validateCronExpr, isCronDue, dueJobs, + resolveUnattendedApproval, type CronJob, type CronStore, + type UnattendedApprovalPolicy, } from './cron/index.js'; // Keybindings (M8 — ~/.deepcode/keybindings.json + Vim mode state machine) diff --git a/packages/core/src/modes/index.ts b/packages/core/src/modes/index.ts index f74f16d..630da78 100644 --- a/packages/core/src/modes/index.ts +++ b/packages/core/src/modes/index.ts @@ -36,6 +36,20 @@ const PLAN_READONLY_TOOLS = new Set([ 'ToolSearch', ]); +/** + * Modes that let a tool call through without a human seeing it. + * + * These are fine interactively — the user chose them and is sitting there — but + * an unattended run inherits `permissions.defaultMode` from the same settings + * file, so a convenience choice made for the REPL silently becomes the posture + * of every scheduled job. Callers that run without a human use this to warn. + */ +const PERMISSIVE_MODES = new Set(['bypassPermissions', 'acceptEdits']); + +export function isPermissiveMode(mode: Mode): boolean { + return PERMISSIVE_MODES.has(mode); +} + export function evaluateMode(mode: Mode, req: ModeRequest): ModeVerdict { switch (mode) { case 'plan': { diff --git a/packages/core/src/tools/cron-tools.ts b/packages/core/src/tools/cron-tools.ts index ae8ad43..c987d77 100644 --- a/packages/core/src/tools/cron-tools.ts +++ b/packages/core/src/tools/cron-tools.ts @@ -4,12 +4,20 @@ // These CRUD the cron store (~/.deepcode/cron.json). Execution is handled // separately by `deepcode scheduler run` (fired by the launchd/systemd timer). -import { addCronJob, listCronJobs, removeCronJob, validateCronExpr } from '../cron/index.js'; +import { + addCronJob, + listCronJobs, + removeCronJob, + resolveUnattendedApproval, + validateCronExpr, + type UnattendedApprovalPolicy, +} from '../cron/index.js'; import type { ToolContext, ToolHandler, ToolResult } from '../types.js'; interface CreateInput { schedule?: string; prompt?: string; + onApprovalRequired?: UnattendedApprovalPolicy; } interface DeleteInput { id?: string; @@ -29,6 +37,12 @@ export const CronCreateTool: ToolHandler = { description: '5-field cron: min hour day-of-month month day-of-week.', }, prompt: { type: 'string', description: 'What the agent should do each run.' }, + onApprovalRequired: { + type: 'string', + enum: ['deny', 'abort'], + description: + 'What the unattended run does when a tool call needs approval and nobody is there: "deny" (default) refuses that call and continues; "abort" stops the run. Prefer "abort" when a partially-executed job is worse than no job.', + }, }, required: ['schedule', 'prompt'], }, @@ -40,15 +54,26 @@ export const CronCreateTool: ToolHandler = { } const invalid = validateCronExpr(input.schedule); if (invalid) return { content: `Error: ${invalid}`, isError: true }; + if (input.onApprovalRequired && !['deny', 'abort'].includes(input.onApprovalRequired)) { + return { content: 'Error: onApprovalRequired must be "deny" or "abort".', isError: true }; + } try { const job = await addCronJob({ schedule: input.schedule, prompt: input.prompt, cwd: ctx.cwd, + ...(input.onApprovalRequired ? { onApprovalRequired: input.onApprovalRequired } : {}), }); return { - content: `Scheduled "${job.id}" — \`${job.schedule}\` in ${job.cwd}.\nIt fires once the scheduler is installed (deepcode cron install).`, - data: { id: job.id, schedule: job.schedule }, + content: + `Scheduled "${job.id}" — \`${job.schedule}\` in ${job.cwd}.\n` + + `Unattended approval policy: ${resolveUnattendedApproval(job)}.\n` + + `It fires once the scheduler is installed (deepcode cron install).`, + data: { + id: job.id, + schedule: job.schedule, + onApprovalRequired: resolveUnattendedApproval(job), + }, }; } catch (err) { return { content: `Error scheduling job: ${(err as Error).message}`, isError: true }; @@ -68,7 +93,8 @@ export const CronListTool: ToolHandler = { if (jobs.length === 0) return { content: 'No scheduled jobs.', data: { jobs: [] } }; const lines = jobs.map( (j) => - `${j.id} [${j.schedule}]${j.enabled ? '' : ' (disabled)'} ${j.prompt.slice(0, 60)}` + + `${j.id} [${j.schedule}]${j.enabled ? '' : ' (disabled)'} ` + + `approval=${resolveUnattendedApproval(j)} ${j.prompt.slice(0, 60)}` + (j.lastRunAt ? ` (last: ${j.lastRunAt})` : ''), ); return { content: lines.join('\n'), data: { jobs } }; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0b04184..f0c3fc7 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -190,7 +190,7 @@ export type AgentEvent = /** The whole user turn reached one terminal state. Emitted exactly once. */ | { type: 'turn_complete'; - stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error'; + stopReason: 'end_turn' | 'max_turns' | 'aborted' | 'error' | 'blocked'; message?: StoredMessage; } | {