Skip to content

Commit 3fc2eb9

Browse files
ndemiancclaude
andcommitted
Atom++ AI: background command execution (M5, first slice)
run_command now accepts background:true — it still goes through the Approve card but returns immediately ("Started in the background as id …") so the agent keeps working instead of blocking on a dev server / watcher. A new read_command_output {id, lines} tool polls a background process's recent output + status ([running on :3000] / [exited 0] / [stopped]), with port/readiness sniffed from the logs (verify.js sniffPort/looksReady, unit-tested) and a "no new output since last read" delta to save context. - bgRuns registry (extension.js, module-scoped) holds {command,status,ring, port,…} per background run; only background commands are tracked (foreground one-shots keep old behavior + don't accumulate). - Reuses the existing approval card, termRun/termOutput/termExit streaming, and process-group Stop. The card shows "running in background…". - Background runs ignore commandTimeout (meant to run long); reaped on New Chat + extension unload so servers don't orphan their ports (the design flagged omitting this as a real footgun). - SYSTEM prompt teaches the model to use background:true for non-exiting commands and poll read_command_output for readiness before testing. Syntax-checked + sniffer unit tests pass; loaded cleanly in dev. Live end-to-end test pending. Deferred to slice 2: auto-background safety net, port badge, atompp.ai.agent.background.* settings, max-concurrent cap. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 014d8ce commit 3fc2eb9

4 files changed

Lines changed: 64 additions & 10 deletions

File tree

‎extensions/atom-ai/agent.js‎

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ const fs = require('fs');
1414
const path = require('path');
1515
const cp = require('child_process');
1616
const { streamClaudeAgentTurn } = require('./providers');
17-
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable } = require('./verify');
17+
const { formatVerifyFeedback, verifyOutcome, looksUnrunnable, sniffPort, looksReady } = require('./verify');
1818

1919
const SYSTEM = [
2020
"You are Atom++'s built-in autonomous coding agent. You accomplish the user's goal in their",
@@ -25,6 +25,7 @@ const SYSTEM = [
2525
'- To change an EXISTING file, use edit_file with an exact, unique snippet (old_str) and its replacement (new_str). This is preferred — small and reliable. Read the file first so old_str matches exactly. Prefer SEVERAL small edit_file calls over one huge one (smaller edits are faster and easier to review).',
2626
'- Use write_file ONLY to create a new file (or fully rewrite a short one); it needs the COMPLETE content. Do not rewrite large files — use edit_file repeatedly instead.',
2727
'- Your file edits are APPLIED IMMEDIATELY and the user reviews them afterward in the editor with Keep/Undo — do NOT wait for approval, and do NOT re-edit a file you just edited. Only run_command still needs approval; if the user skips a command, adapt or stop.',
28+
'- Commands that do NOT exit on their own (dev servers, file watchers, tail -f) MUST be run with run_command background:true — it returns immediately so you keep working instead of hanging. After starting one, call read_command_output with the returned id to watch for a readiness/port line (e.g. "listening on :3000") before you test against it. Use a normal foreground run_command for things that finish (builds, installs, tests, git, curl). This pairs with verification: bring the app up in the background, confirm it serves, fix, repeat.',
2829
'- Paths are relative to the workspace root.',
2930
'- For a multi-step goal, call update_plan FIRST with a short checklist (3-8 short items, all "pending"), then call it again to set an item "in_progress" when you start it and "done" when finished. Skip the plan for trivial single-step goals.',
3031
'- If the goal truly depends on a decision only the user can make (tech stack, scope, where to create files, must-have features), call ask_user ONCE with concise multiple-choice questions (a short header + 2-4 concrete options each) INSTEAD of writing the questions as prose. Then act on their answers and do not ask again. Do NOT ask about things you can reasonably decide yourself — prefer a sensible default and proceed.',
@@ -39,7 +40,8 @@ const TOOLS = [
3940
{ name: 'update_plan', description: 'Declare or update your task checklist for a multi-step goal. Pass the FULL list each time, each item with a status. Call it once up front (all pending), then again to mark an item in_progress when you start it and done when finished. Skip for trivial single-step goals.', input_schema: { type: 'object', properties: { todos: { type: 'array', items: { type: 'object', properties: { title: { type: 'string' }, status: { type: 'string', enum: ['pending', 'in_progress', 'done'] } }, required: ['title', 'status'] } } }, required: ['todos'] } },
4041
{ name: 'edit_file', description: 'Make a targeted edit to an EXISTING file: replace an exact, unique snippet (old_str) with new_str. Applied immediately; the user reviews it with Keep/Undo. old_str must appear exactly once — include enough surrounding context to be unique.', input_schema: { type: 'object', properties: { path: { type: 'string' }, old_str: { type: 'string' }, new_str: { type: 'string' } }, required: ['path', 'old_str', 'new_str'] } },
4142
{ name: 'write_file', description: 'Create a new file (or fully overwrite a short one) with the COMPLETE content. For edits to existing files, prefer edit_file. Applied immediately; the user reviews it with Keep/Undo.', input_schema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] } },
42-
{ name: 'run_command', description: 'Run a shell command in the workspace root. Requires approval.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string' } }, required: ['command'] } },
43+
{ name: 'run_command', description: 'Run a shell command in the workspace root. Requires approval. Pass background:true for commands that do not exit on their own (servers, watchers) so the agent is not blocked — it returns immediately and you read progress later with read_command_output.', input_schema: { type: 'object', properties: { command: { type: 'string' }, explanation: { type: 'string' }, background: { type: 'boolean', description: 'true = start it and keep working without waiting (dev servers, watchers, tail -f). Returns immediately with an id; poll read_command_output for its output/status.' } }, required: ['command'] } },
44+
{ name: 'read_command_output', description: 'Read recent output + status of a command started with run_command background:true. Returns a status header ([running on :3000] / [exited 0] / [stopped]) followed by the latest output lines. Poll this to wait for a server to become ready before testing against it.', input_schema: { type: 'object', properties: { id: { type: 'string', description: 'the id returned by a background run_command' }, lines: { type: 'number', description: 'max recent output lines to return (default 80, max 400)' } }, required: ['id'] } },
4345
{ name: 'ask_user', description: 'Ask the user one or more multiple-choice questions when the goal genuinely depends on a decision only they can make (tech stack, scope, where to put files, must-have features). The user picks by CLICKING — do NOT write questions as prose. Ask ONCE up front with all your questions, then proceed with the answers and never re-ask. Prefer sensible defaults over asking; only ask when a wrong guess would waste real work.', input_schema: { type: 'object', properties: { questions: { type: 'array', items: { type: 'object', properties: { header: { type: 'string', description: 'a 1-3 word tag for the question' }, question: { type: 'string' }, multiSelect: { type: 'boolean', description: 'true if several options can be picked at once' }, options: { type: 'array', items: { type: 'object', properties: { label: { type: 'string' }, description: { type: 'string' } }, required: ['label'] } } }, required: ['question', 'options'] } } }, required: ['questions'] } }
4446
];
4547

@@ -276,16 +278,56 @@ async function runTool(tu, ctx) {
276278
}
277279
if (tu.name === 'run_command') {
278280
const cmd = String(input.command || '');
281+
const bg = input.background === true;
279282
const approved = await ctx.approve({ kind: 'command', command: cmd, explanation: input.explanation || '' });
280283
if (!approved) { return 'User skipped this command. Do not retry it.'; }
281284
const runId = tu.id || ('run-' + Date.now());
282-
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(root) || 'workspace' });
285+
ctx.post({ type: 'termRun', id: runId, command: cmd, cwd: path.basename(root) || 'workspace', background: bg });
283286
const stops = ctx.commandStops; // shared registry so the Stop button / ■ can kill the process group
284-
return await runCommand(root, cmd,
285-
(chunk, stream) => ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream }),
286-
(code, ms, how) => { if (stops) { stops.delete(runId); } ctx.post({ type: 'termExit', id: runId, code: code, ms: ms, how: how }); },
287-
(child, stop) => { if (stops) { stops.set(runId, stop); } },
288-
ctx.commandTimeout);
287+
// Only BACKGROUND commands get a registry entry (read_command_output reads it). Foreground
288+
// one-shots keep their old behavior + don't accumulate — the model already gets their output.
289+
const entry = bg ? { command: cmd, status: 'running', code: null, how: null, port: null, ready: false, ring: '', totalBytes: 0, lastReadOffset: 0, startedAt: Date.now() } : null;
290+
if (entry && ctx.commandRuns) { ctx.commandRuns.set(runId, entry); }
291+
const onChunk = (chunk, stream) => {
292+
ctx.post({ type: 'termOutput', id: runId, chunk: chunk, stream: stream });
293+
if (entry) {
294+
entry.ring = (entry.ring + chunk).slice(-100000); // bounded tail for read_command_output
295+
entry.totalBytes += chunk.length;
296+
if (!entry.port) { const p = sniffPort(chunk); if (p) { entry.port = p; } }
297+
if (!entry.ready && looksReady(chunk)) { entry.ready = true; }
298+
}
299+
};
300+
const onExit = (code, ms, how) => {
301+
if (stops) { stops.delete(runId); }
302+
if (entry) { entry.status = how === 'exit' ? 'exited' : how; entry.code = code; entry.how = how; } // exited | stopped | timeout
303+
ctx.post({ type: 'termExit', id: runId, code: code, ms: ms, how: how });
304+
};
305+
const onStart = (child, stop) => { if (stops) { stops.set(runId, stop); } };
306+
if (bg) {
307+
// Fire-and-forget: keep streaming + tracking, but return NOW so the agent loop isn't blocked.
308+
// No timeout — a background server is meant to run long (Stop / New Chat reap it).
309+
(async () => { try { await runCommand(root, cmd, onChunk, onExit, onStart, 0); } catch (e) { entry.status = 'error'; entry.how = 'error'; dbg('bg.error', { id: runId, msg: String((e && e.message) || e) }); } })();
310+
return 'Started in the background as id "' + runId + '" — it keeps running while you continue. Use read_command_output with this id to watch its output; wait for a readiness/port line before testing against it. Do not start it again.';
311+
}
312+
return await runCommand(root, cmd, onChunk, onExit, onStart, ctx.commandTimeout);
313+
}
314+
if (tu.name === 'read_command_output') {
315+
const runs = ctx.commandRuns;
316+
const id = String(input.id || '');
317+
const entry = runs && runs.get(id);
318+
if (!entry) { return 'ERROR: no command with id "' + id + '" (it may never have started, or was cleared by New Chat). Start it with run_command background:true first.'; }
319+
const lines = Math.min(400, Math.max(1, Number(input.lines) || 80));
320+
const statusStr = entry.status === 'running'
321+
? ('running' + (entry.port ? ' on :' + entry.port : entry.ready ? ' (ready)' : ''))
322+
: entry.status === 'exited' ? ('exited ' + entry.code)
323+
: entry.status; // stopped | timeout | error
324+
ctx.post({ type: 'agentTool', icon: 'terminal', text: 'read output of ' + id + ' [' + statusStr + ']' });
325+
const head = '[' + statusStr + '] ' + entry.command;
326+
if (!entry.ring) { return head + '\n(no output yet)'; }
327+
const noNew = entry.totalBytes <= entry.lastReadOffset;
328+
entry.lastReadOffset = entry.totalBytes;
329+
const tail = entry.ring.split('\n').slice(-lines).join('\n');
330+
return head + '\n' + (noNew ? '(no new output since your last read)\n' + tail : tail);
289331
}
290332
if (tu.name === 'ask_user') {
291333
const questions = Array.isArray(input.questions) ? input.questions : [];

‎extensions/atom-ai/extension.js‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ function newChat() {
334334
pendingContext = null;
335335
contextFiles = [];
336336
if (abort) { abort.abort(); }
337+
reapCommands(); // kill any background servers/watchers from the old session
337338
if (review) { review.finalizeAll(); } // drop review UI without reverting the user's files
338339
post({ type: 'reset' });
339340
postContextFiles();
@@ -369,6 +370,16 @@ function postActiveFile() {
369370

370371
/** Running approved commands: runId → stop() that kills the command's process group. */
371372
const commandStops = new Map();
373+
// Background command registry (runId → {command,status,ring,port,…}) so the agent can read a server's
374+
// output across turns via read_command_output. Module-scoped so it survives between agent runs; reaped
375+
// on New Chat + extension unload (servers hold ports — orphaning them breaks the next run).
376+
const bgRuns = new Map();
377+
/** Kill every running command and drop all command state (servers must not survive New Chat/unload). */
378+
function reapCommands() {
379+
for (const [, stop] of commandStops) { try { stop(); } catch (e) { /* already gone */ } }
380+
commandStops.clear();
381+
bgRuns.clear();
382+
}
372383

373384
/** Pending in-chat approval requests, keyed by id, resolved by the webview. */
374385
const pendingApprovals = new Map();
@@ -527,6 +538,7 @@ async function agentFlow(text) {
527538
ask: requestQuestions, // ask_user — clickable clarifying questions
528539
contextLimit: cfg.get('contextWindow', 200000), // model context window → drives the usage meter
529540
commandStops: commandStops, // runId → stop() (process-group kill); used by Stop button / ■
541+
commandRuns: bgRuns, // runId → background-process registry (read_command_output reads it)
530542
commandTimeout: cfg.get('commandTimeout', 120000), // backstop before a command is force-killed (0 = off)
531543
applyEdit: (req) => review.applyEdit(req), // file edits: apply-then-review
532544
verify: verifyCfg, // M5 auto-verify settings
@@ -872,6 +884,6 @@ function activate(context) {
872884
}
873885
}
874886

875-
function deactivate() { if (abort) { abort.abort(); } }
887+
function deactivate() { if (abort) { abort.abort(); } reapCommands(); }
876888

877889
module.exports = { activate, deactivate };

‎extensions/atom-ai/media/chat.html‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1021,7 +1021,7 @@
10211021
+ '<div class="termline"><span class="tprompt">➜</span> <span class="tcwd">' + esc(m.cwd || '') + '</span> '
10221022
+ '<span class="tcmd">' + esc(m.command || '') + '</span></div>'
10231023
+ '<pre class="termout"></pre>'
1024-
+ '<div class="termstatus running"><span class="tcaret"></span> running… '
1024+
+ '<div class="termstatus running"><span class="tcaret"></span> ' + (m.background ? 'running in background… ' : 'running… ')
10251025
+ '<button class="termstop" title="Stop this command">' + codicon('stop-circle') + ' Stop</button></div>'
10261026
+ '</div>';
10271027
log.appendChild(card); scrollIfStuck();

‎extensions/atom-ai/verify.js‎

1.1 KB
Binary file not shown.

0 commit comments

Comments
 (0)