a stop button on running background jobs

Killing a job meant asking the agent in words — a whole turn to say "please stop
that", and only possible while the agent is responsive. Now it is a button on the
chip.

── Finding the process ──

The platform does not own these processes. Claude Code starts them and nothing
records a pid. What it has is the file the job streams into, and the job is still
holding that file open — so `fuser` gives the pid, and the file is resolved
through the SAME scoped lookup the read path already uses: the id is validated
against TASK_ID_RE, then matched only under this caller's own directories. An id
that resolves to nothing kills nothing.

Verified end to end rather than reasoned about: a stand-in job holding its output
file open, `fuser` finding it, `{"stopped":true}`, process gone.

SIGTERM, not SIGKILL — a dev server with children should get the chance to take
them down with it. SIGKILL orphans them and leaves the port bound, which is worse
than a stop that takes a second.

── Two things it deliberately does not do ──

It does not dismiss the chip or write a `stopped` status locally. The real end
arrives the way every other end does — the harness sees the exit and the
transcript gets the row — so faking it would be a guess that usually agrees with
reality and sometimes does not. A stop that fails leaves the chip running, which
is the truth.

It does not offer to stop a SUBAGENT. Those write a `.jsonl` transcript rather
than holding a log open, so there is no fd to trace; it says so instead of
pretending.

`Square`, not `X`. X on the same chip means "close this", and confusing "hide the
chip" with "kill the process" is the one mistake worth designing out — which is
why stop appears only on running tasks and dismiss only on finished ones.

tsgo clean, frontend builds, 851 pass / 7 fail unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 02:03:30 +00:00
co-authored by Claude Opus 5
parent 3512b06d5e
commit 048e718b21
3 changed files with 95 additions and 3 deletions
+11
View File
@@ -11,6 +11,7 @@ import {
deleteClaudeSession,
renameClaudeSession,
loadBackgroundTask,
stopBackgroundTask,
claudeSessionContext,
liveSessionTitle,
} from './claude-sessions';
@@ -270,6 +271,16 @@ chatRouter.get('/tasks/:id', async (ctx) => {
return ctx.json(detail ?? { kind: 'pending' });
});
// DELETE /chat/tasks/:id — stop a running background job.
//
// Exists so a job can be stopped by pressing a button rather than by asking the agent in words: a turn to
// say "please stop that" is a turn, and it only works while the agent is responsive.
chatRouter.delete('/tasks/:id', async (ctx) => {
const who = await chatIdentity(ctx.get('user'));
const result = await stopBackgroundTask(who, ctx.req.param('id'));
return ctx.json(result, result.stopped ? 200 : 400);
});
// GET /chat/models — Claude tiers only (the runner is the `claude` CLI).
chatRouter.get('/models', async (ctx: Context) => {
const who = await chatIdentity(ctx.get('user'));
+45
View File
@@ -719,6 +719,51 @@ function tailFile(osUser: AsUser, filePath: string, bytes: number): { text: stri
return { text: truncated ? text.slice(text.indexOf('\n') + 1) : text, truncated };
}
/**
* Stop a running background job.
*
* The platform does not own these processes — Claude Code starts them, and nothing records a pid. What it
* does have is the file the job streams into, and the job is still holding that file open. So the pid
* comes from `fuser`, and the file is resolved through the SAME scoped lookup the read path uses: a task
* id is validated, then matched only under this caller's own directories. An id that resolves to nothing
* kills nothing.
*
* SIGTERM, not SIGKILL. A dev server with children should get the chance to take them down with it;
* SIGKILL orphans them and leaves the port bound, which is worse than a job that took a second to stop.
*
* Only agent-run SHELL jobs can be stopped this way. A subagent writes a `.jsonl` transcript rather than
* holding a log open, so there is no fd to trace and this reports that rather than pretending.
*/
export async function stopBackgroundTask(
who: ChatIdentity,
taskId: string,
): Promise<{ stopped: boolean; reason?: string }> {
if (!TASK_ID_RE.test(taskId)) return { stopped: false, reason: 'bad task id' };
const found = findTaskOutput(who, taskId);
if (!found) return { stopped: false, reason: 'no such task' };
if (found.endsWith('.jsonl')) return { stopped: false, reason: 'subagents cannot be stopped this way' };
const proc = Bun.spawn(['fuser', found], { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
await proc.exited;
const pids = out
.split(/\s+/)
.map((t) => Number(t.replace(/\D/g, '')))
.filter((n) => Number.isInteger(n) && n > 1);
if (pids.length === 0) return { stopped: false, reason: 'already stopped' };
for (const pid of pids) {
try {
process.kill(pid, 'SIGTERM');
} catch {
// Gone between listing and killing, or not ours. Either way there is nothing to do about it.
}
}
return { stopped: true };
}
/**
* What a background task is doing right now. Returns null when nothing has been written yet — which is
* the normal state for the first second or two of a task's life, not an error.