diff --git a/src/app/api/agents/[id]/run/route.ts b/src/app/api/agents/[id]/run/route.ts
index 1ae0c90..6bab04a 100644
--- a/src/app/api/agents/[id]/run/route.ts
+++ b/src/app/api/agents/[id]/run/route.ts
@@ -3,6 +3,7 @@ import { db, schema } from '@/lib/db'
import { eq } from 'drizzle-orm'
import { runAgent, writeAgentSkillConfig } from '@/lib/claude'
import { resolveAgentCwd } from '@/lib/directories'
+import { registerRun, unregisterRun } from '@/lib/run-registry'
export const runtime = 'nodejs'
// Allow long-running streams; default Vercel limit is short, but we're
@@ -72,6 +73,9 @@ export async function POST(
const encoder = new TextEncoder()
const collected: string[] = []
let exitCode: number | null = null
+ // Track whether the run was killed via the Stop endpoint so we can
+ // record status='cancelled' instead of 'failed' on a non-zero exit.
+ let cancelled = false
const stream = new ReadableStream({
async start(controller) {
@@ -94,6 +98,14 @@ export async function POST(
systemPrompt: agent.systemPrompt ?? '',
model: agent.model,
skills: agent.skills ?? [],
+ onChild: (child) => {
+ registerRun(run.id, child)
+ // Detect external SIGTERM (i.e. our Stop endpoint) so we
+ // can persist 'cancelled' instead of 'failed' on exit.
+ child.once('exit', (_code, signal) => {
+ if (signal === 'SIGTERM') cancelled = true
+ })
+ },
})) {
send(event as object)
collected.push(JSON.stringify(event))
@@ -112,10 +124,15 @@ export async function POST(
type: 'error',
message: err instanceof Error ? err.message : String(err),
})
+ } finally {
+ unregisterRun(run.id)
}
- const status: 'completed' | 'failed' =
- exitCode === 0 ? 'completed' : 'failed'
+ const status: 'completed' | 'failed' | 'cancelled' = cancelled
+ ? 'cancelled'
+ : exitCode === 0
+ ? 'completed'
+ : 'failed'
try {
await db
.update(schema.runs)
diff --git a/src/app/api/runs/[id]/stop/route.ts b/src/app/api/runs/[id]/stop/route.ts
new file mode 100644
index 0000000..974c9cd
--- /dev/null
+++ b/src/app/api/runs/[id]/stop/route.ts
@@ -0,0 +1,24 @@
+import { NextResponse } from 'next/server'
+import { killRun, isRunning } from '@/lib/run-registry'
+
+export const runtime = 'nodejs'
+
+/**
+ * POST /api/runs/[id]/stop
+ *
+ * SIGTERM the run's child process group if it's still alive. The
+ * run-route's SSE loop sees the child exit, persists `status='cancelled'`
+ * (detected via the SIGTERM signal), and closes the stream. Idempotent:
+ * stopping an already-finished run returns 200 with stopped:false.
+ */
+export async function POST(
+ _req: Request,
+ { params }: { params: Promise<{ id: string }> },
+) {
+ const { id } = await params
+ if (!isRunning(id)) {
+ return NextResponse.json({ ok: true, stopped: false })
+ }
+ const killed = killRun(id)
+ return NextResponse.json({ ok: true, stopped: killed })
+}
diff --git a/src/app/workspace/[id]/page.tsx b/src/app/workspace/[id]/page.tsx
index 2058935..a102aac 100644
--- a/src/app/workspace/[id]/page.tsx
+++ b/src/app/workspace/[id]/page.tsx
@@ -105,9 +105,14 @@ export default async function WorkspacePage({
Agents
-
+
+ Each agent runs independently. Hit Run on more than one to
+ watch them work in parallel; Stop sends SIGTERM to that
+ agent's child without touching the others.
+
+
{agents.length === 0 && (
-
+
No agents in this workspace yet. Create one below.
)}
diff --git a/src/components/AgentPanel.tsx b/src/components/AgentPanel.tsx
index 6eed0ca..9d5dd5a 100644
--- a/src/components/AgentPanel.tsx
+++ b/src/components/AgentPanel.tsx
@@ -30,15 +30,30 @@ export function AgentPanel({
const [prompt, setPrompt] = useState('')
const [events, setEvents] = useState([])
const [running, setRunning] = useState(false)
+ // RunId becomes known after the server emits its run:start frame.
+ // The Stop button needs this to address the right child process.
+ const [runId, setRunId] = useState(null)
// Bumped each time a live run ends, so RunHistory re-fetches the
// newly persisted row without a full page reload.
const [historyVersion, setHistoryVersion] = useState(0)
const outputRef = useRef(null)
+ async function stop() {
+ if (!runId) return
+ try {
+ await fetch(`/api/runs/${runId}/stop`, { method: 'POST' })
+ } catch {
+ // Server might be gone or restarted. The run-end frame will
+ // never arrive in that case; the UI just stays in "running"
+ // until the user reloads. Not worth more handling for v1.
+ }
+ }
+
async function run() {
if (!prompt.trim() || running) return
setRunning(true)
setEvents([])
+ setRunId(null)
try {
const res = await fetch(`/api/agents/${agent.id}/run`, {
method: 'POST',
@@ -70,6 +85,14 @@ export function AgentPanel({
if (!dataLine) continue
try {
const event = JSON.parse(dataLine.slice('data: '.length))
+ if (
+ event &&
+ typeof event === 'object' &&
+ event.type === 'run:start' &&
+ typeof event.runId === 'string'
+ ) {
+ setRunId(event.runId)
+ }
setEvents((prev) => [...prev, event])
// Auto-scroll to bottom of output
requestAnimationFrame(() => {
@@ -92,6 +115,7 @@ export function AgentPanel({
])
} finally {
setRunning(false)
+ setRunId(null)
setHistoryVersion((v) => v + 1)
}
}
@@ -159,14 +183,26 @@ export function AgentPanel({
disabled={running}
className="rounded border border-neutral-800 bg-neutral-950 px-3 py-2 text-sm focus:border-neutral-600 focus:outline-none disabled:opacity-50"
/>
-