64. Background shells in the TUI and ACP: one merged background surface¶
Date: 2026-10-19
Status: Accepted
Context¶
ADR-0063 shipped background shells (model-side lifecycle) but deferred the human surface: "TUI/ACP panel wiring for background shells (live buffer attach, /bg integration) is not in this change: shells are model-owned state with no human command surface yet." Without it, a dev server started via bash run_in_background is invisible to the human: no row to look at, no way to watch its output except asking the model to bash_output it, and no way to stop it except asking the model to kill_bash. Background jobs (ADR-0043) already have all of that: the F3 background panel with click-to-attach live views (ADR-0047), /bg list|show|stop, and ACP tool-call rows with streamed live output.
The question was how to surface shells without inventing a parallel UI. The user's constraint: merge into the existing background panel rather than adding a new one.
Decision¶
Shells are a second row class in the one existing background surface — same panel, same attach machinery, same /bg, same ACP row shape. No new panel, command family, or protocol vocabulary.
- One merged panel (
F3//panel background, now titled "background").backgroundPanelLayout(jobs, shells)renders anagents (n)section and ashells (n)section (each shown only when non-empty;(none)when both are). The layout is the single source for both rendering and click mapping (bgPanelRowcarries the row's click target), recomputed on every click — so a shell or job that started or finished between render and click cannot shift the line→target mapping (the same rule ADR-0047 set for job rows). Shell rows:▶running,■stopping (killed, not yet exited),✓clean exit, dim■killed and exited (ending a dev server on request is normal, not an error),✗any other non-zero exit/error, plus the command as label and an elapsed badge — the job-row grammar. - Attach reuses ADR-0047 verbatim. A shell row double-clicks to the same live-attach view (new
attachShellkind) over the shell'slivebuf.Buffer(exactly whatbash_outputtails). Esc detaches; the buffer keeps accumulating. /bgunifies IDs./bgand/bg listshow both tables;/bg show <id>works for job and shell IDs (shell: status line + current output, capped like a job result);/bg stop <id>cancels a job or kills a shell (/bg stopis the UI'skill_bash). No new verbs.- ACP mirrors the job rows. Each shell gets one synthetic tool-call row (
shell:<id>, namebackground_shell) viaacp.BackgroundShellCall:in_progresswhile running,completed/failed/cancelled(killed) at exit, live output streamed into the row via the existingstreamLive/ToolCallContentPollmachinery, output flushed before the terminal status row./bgworks identically over ACP. - Lifecycle events fan out. The shell registry is process-global (the tool contract — every agent sees every shell), unlike the per-session job Manager.
local.AddShellListener(onStart, onFinish)therefore supports multiple listeners (one per TUI / ACP session, unregistered on session close), fires synchronously in start→finish order, and is the only new seam the UI layers need.
Consequences chosen deliberately:
- No transcript block for a finished shell (unlike a finished job's result block). A job's result is turn output the model produced for the user; a shell's output is a log the model reads on demand. Surfacing it in the transcript would be noise — the panel row + attach view +
/bg showare the read paths. Lifecycle is toast-only ("background shell s1 exited 0"). - Shells keep the background panel "live" (
panelContentFinalizedis false while any shell runs) — a running dev server is active background work, and the ADR-0059 manual-override reset then behaves the same as for jobs. - Not persisted (ADR-0063 unchanged): no stale rows after restart, no session association — shells are process/workspace-scoped, and the merged panel shows the process's shells regardless of which agent or session started them.
Consequences¶
- The human can now see, watch, and stop model-started background shells with zero new vocabulary; editors over ACP see them as ordinary tool-call rows.
- The click-to-attach line mapping moved from "line 2+i is job i" to a shared layout struct — slightly more code, but the render/click drift hazard is gone structurally.
- A session that closes unregisters its shell listener; the TUI registers one for the process lifetime.
Implementation: internal/agent/local/bashbg.go (ShellInfo, BackgroundShells, ShellLive, BackgroundShell, KillBackgroundShell, StartBackgroundShell, AddShellListener), cmd/milk/panel_background.go, cmd/milk/attach.go, cmd/milk/commands.go (/bg), cmd/milk/acp_features.go (setupShells), internal/transport/acp/map.go (BackgroundShellCall).