Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/cli/src/headless.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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.`;
Expand Down Expand Up @@ -135,6 +145,16 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {

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;
Expand Down Expand Up @@ -325,6 +345,8 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
// 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,
});

Expand All @@ -334,6 +356,8 @@ export async function runHeadless(opts: HeadlessOpts): Promise<number> {
exitCode = 4;
} else if (result.stopReason === 'error') {
exitCode = 3;
} else if (result.stopReason === 'blocked') {
exitCode = 6;
} else {
exitCode = 0;
}
Expand Down
11 changes: 10 additions & 1 deletion apps/cli/src/scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
listCronJobs,
loadCronStore,
saveCronStore,
resolveUnattendedApproval,
uninstallPlist,
type CronJob,
} from '@deepcode/core';
Expand Down Expand Up @@ -71,15 +72,23 @@ async function defaultRunJob(job: CronJob, home: string): Promise<void> {
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();
}
Expand Down
23 changes: 13 additions & 10 deletions docs/cli-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 21 additions & 1 deletion docs/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
91 changes: 91 additions & 0 deletions packages/core/src/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
});
Loading
Loading