Generated: 2026-04-02 Status: Pending Review
When a shell command runs indefinitely (e.g. tail -f, dev server, long test), two issues occur:
Ctrl+C only cancels the agent loop, not the actual process — The agent loop returns, but
exec.CommandContextmay not reliably kill all child processes (especially in Docker sandbox where only the CLI process is killed, container processes continue). The process may leak.Entire agent loop blocks —
execOne()inengine.go:967synchronously callstoolExecutor(execCtx, tc). In default serial mode (engine.go:1108-1116), one stuck shell = entire iteration stuck. No other tools can run, no progress updates reach the user.
Implement a background task system similar to Claude Code’s approach:
- Shell tool gets
background: trueparameter — when set, the command runs in a goroutine and returns a task ID immediately - Task lifecycle management —
task_kill,task_status,task_readtools for the LLM to monitor/control - Notification on completion — when a background task finishes, its result is injected into the conversation as a user message, triggering a new iteration
- User can kill via CLI —
/kill <task_id>command or Ctrl+C during background execution
- Scheduled/loop tasks (
/looplike Claude) — can be added later - Background tasks for non-Shell tools — Shell is the only tool that commonly runs indefinitely
- Persistent tasks across restarts — tasks are in-memory only
| File | Role | Change Type |
|---|---|---|
tools/shell.go | Shell tool implementation | Modify (add background param) |
tools/task_manager.go | NEW — background task lifecycle manager | New |
tools/task_tools.go | NEW — task_kill/task_status/task_read tools | New |
agent/engine.go | Agent loop, tool execution | Modify (inject task manager, handle bg results) |
agent/engine_wire.go | Wire task manager into RunConfig/ToolContext | Modify |
tools/interface.go | ToolContext, ToolResult | Modify (add TaskManager field) |
channel/cli.go | CLI rendering, /kill command | Modify (render bg tasks, /kill) |
agent/progress.go | Progress data structures | Modify (add bg task info) |
LLM calls Shell(background=true, command="npm run dev")
│
▼
engine.execOne()
├── Foreground path (background=false): synchronous as before
└── Background path (background=true):
├── Create BackgroundTask{ID, Command, Status: Running, StartedAt}
├── Launch goroutine: sandbox.Exec(detachedCtx, spec)
├── Store task in TaskManager
└── Return ToolResult{Summary: "Background task started [id: abc123]"}
│
▼
Agent continues next iteration with task ID info
│
▼
Goroutine completes:
├── Update task {Status: Done/Error, Result, FinishedAt}
└── Notify engine via callback → inject result as user message
│
▼
New iteration: LLM sees "[bg:abc123] Task completed: ..." and can act on it
// tools/task_manager.go
type BackgroundTask struct {
ID string `json:"id"` // unique ID (8-char hex)
Command string `json:"command"` // original command
Status string `json:"status"` // "running" | "done" | "error" | "killed"
StartedAt time.Time `json:"started_at"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
Output string `json:"output"` // stdout+stderr (truncated to 50KB)
ExitCode int `json:"exit_code"`
Error string `json:"error,omitempty"`
cancel context.CancelFunc // internal, not serialized
}
type TaskManager interface {
// Start launches a background task and returns immediately
Start(ctx context.Context, sandbox Sandbox, spec ExecSpec, userID string) (*BackgroundTask, error)
// Kill terminates a running task
Kill(taskID string) error
// Status returns current task state
Status(taskID string) (*BackgroundTask, error)
// List returns all tasks for a session
List(sessionKey string) []*BackgroundTask
// OnComplete registers a callback for task completion
OnComplete(sessionKey string, callback func(task *BackgroundTask))
}
The key improvement over current behavior: process group kill.
For NoneSandbox:
// Instead of plain exec.CommandContext:
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
// Kill: syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) // kill entire process group
For DockerSandbox:
// After docker exec, use: docker exec --sig-kill <container> kill -9 <pid>
// Or: docker stop <exec_id> (docker 1.41+)
When a background task completes, the engine needs to inject a “virtual user message” to trigger a new LLM iteration:
Background goroutine completes
→ TaskManager calls OnComplete callback
→ Engine injects into messages: {role: "user", content: "[bg:{id}] completed (exit {code}):\n{output}"}
→ Triggers next iteration of the agentic loop
Implementation: The engine’s Run() loop needs a secondary channel to receive background task notifications:
// In engine.go Run():
taskNotifyCh := make(chan *BackgroundTask, 16) // buffered
for i := 0; i < maxIter; i++ {
select {
case task := <-taskNotifyCh:
// Inject bg task result as user message
messages = append(messages, llm.Message{
Role: "user",
Content: fmt.Sprintf("[bg:%s] Task %s (exit %d):\n%s",
task.ID, task.Status, task.ExitCode, truncate(task.Output, 50000)),
})
i-- // don't count this as an iteration
continue
default:
}
// ... normal iteration logic
}
task_status — Check status of a background task:
{
"name": "task_status",
"parameters": {"task_id": "abc123"}
}
Returns: current status, output so far (for running tasks, returns last N bytes of output).
task_kill — Kill a running background task:
{
"name": "task_kill",
"parameters": {"task_id": "abc123"}
}
Returns: confirmation that task was killed.
task_read (optional, can defer) — Read more output from a completed task:
{
"name": "task_read",
"parameters": {"task_id": "abc123", "offset": 0, "limit": 8000}
}
Progress display: Background tasks show in progress area as:
● bg:abc123 npm run dev (running 45s)/killcommand: User types/kill abc123to kill a specific task, or/killto list running tasks.Completion notification: When a bg task completes during idle (no active agent loop), show a status bar notification like:
[bg:abc123] npm run dev exited with code 0 (tap to view output)
- 1.1 Create
tools/task_manager.go—BackgroundTaskstruct,TaskManagerinterface, in-memory implementation with sync.RWMutex - 1.2 Implement
Start()— creates task, launches goroutine with sandbox.Exec, calls OnComplete callback when done - 1.3 Implement
Kill()— cancels context, kills process group (syscall.Kill(-pgid, SIGKILL)) - 1.4 Implement
Status()andList()— thread-safe reads - 1.5 Per-session task tracking — tasks scoped to sessionKey, cleaned up on session close
- 2.1 Add
backgroundparameter to Shell tool —Parameters()add{Name: "background", Type: "boolean", ...} - 2.2 In
ShellTool.Execute(), checkbackground=true→ callTaskManager.Start()instead of blocking - 2.3 Return immediate result with task ID:
"Background task started [id: abc123]. Use task_status to monitor, task_kill to terminate." - 2.4 For background tasks, use process group kill (
Setpgid: true) in NoneSandbox
- 3.1 Create
tools/task_tools.go—TaskStatusTool,TaskKillTool,TaskReadTool - 3.2 Register tools in
tools/interface.go— add to core tool registration - 3.3
TaskStatusTool: returns status + last 2000 chars of output for running, full output for completed - 3.4
TaskKillTool: calls TaskManager.Kill(), returns confirmation - 3.5
TaskReadTool: paginated output reading for completed tasks (optional, can defer)
- 4.1 Add
TaskManagertoToolContext—interface.go - 4.2 Add
TaskManagertoRunConfig—engine.go - 4.3 Wire TaskManager in
buildToolContext()— pass from RunConfig - 4.4 In
Run()loop, addselecton task completion channel — inject result as user message, decrement iteration counter - 4.5 Register
OnCompletecallback in engine setup — pipes bg task results into the notification channel - 4.6 Update system prompt / tool descriptions to document background mode
- 5.1 Add background task info to
StructuredProgress—progress.go - 5.2 Render running bg tasks in progress block — dimmed line with
● bg:id command (running Xs) - 5.3 Add
/kill <task_id>command parsing in CLI Update — call TaskManager.Kill() via closure - 5.4 Completion notification during idle — tempStatus or status bar hint
- 6.1 Unit test: TaskManager Start/Kill/Status lifecycle
- 6.2 Unit test: background Shell tool returns task ID immediately
- 6.3 Unit test: background task completion injects notification
- 6.4 Integration test: agent loop receives bg task result in next iteration
go build ./...compiles cleanlygo test ./...all pass- Manual test: run
sleep 1000with background=true → verify immediate return - Manual test: kill bg task with
/kill→ verify process dies - Manual test: bg task completes → verify agent gets notified and acts on result
All changes are additive (new tools + new parameter). If issues arise:
- Remove
backgroundparameter from Shell tool → reverts to sync-only - Remove task_* tools from registration → no bg task control
- Engine
selecton taskNotifyCh falls through todefault→ no behavioral change
- Output buffering: Background tasks buffer stdout/stderr. Need to handle large output — cap at 50KB per task, rotate if needed
- Process group kill is critical: Without
Setpgid, child processes leak. This applies to both foreground and background modes — should fix for foreground too- NoneSandbox:
none_sandbox.goneedscmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}+syscall.Kill(-pid, SIGKILL)for kill. Must be platform-conditional (Unix only, Windows uses Job Objects) - DockerSandbox:
docker_sandbox.go— need to track exec PID inside container and kill withdocker exec kill -9 <pid>. Complex — Phase 1 may only implement NoneSandbox process group kill, Docker can follow
- NoneSandbox:
- Remote sandbox: Background tasks need the runner to support async exec. May need runner protocol extension. Phase 1-3 should skip remote sandbox — background only works with NoneSandbox initially, Docker in Phase 2+
- Concurrency: Multiple bg tasks should run in parallel. TaskManager goroutines should be independent
- Memory: Each task holds up to 50KB output. With 10 concurrent tasks, that’s 500KB — acceptable
- Timeout: Background tasks have NO timeout by default (they run indefinitely until killed or completed). This is the whole point. But we should add a max lifetime (e.g. 24h) as safety net
- Existing tool timeout: The engine’s
execCtx, cancel = context.WithTimeout(ctx, toolTimeout)inengine.go:958must NOT apply to background tasks. Background tasks use their own detached context
✅ Self-review passed