Adds GET /api/workspaces/:id/acp/stream. Client opens a WebSocket,
sends {"type":"prompt","content":"..."}, and receives a stream of
{"type":"chunk","messageId","text"} events followed by exactly
one {"type":"complete","stopReason"} or {"type":"error","error"}.
Closing the WS early triggers session/cancel.
- internal/acp/messages.go: StreamEvent wire shape.
- internal/acp/client.go:
- streamChs []chan StreamEvent set; AddStream / RemoveStream.
- sendStream non-blocking fanout.
- Client.Stream(ctx, content, out) registers out, sends prompt,
emits complete/error after the prompt response, unregisters.
- handleNotification fans chunk events to all stream consumers.
- notifyWG ensures chunk ordering vs the terminal event.
- internal/acp/service.go: Service.Stream(workspaceID, content, out)
mirrors Prompt (per-workspace lock, 5-min timeout, EnsureReady).
- internal/service/acp_service.go: thin AcpService.Stream wrapper
that maps acp.StreamEvent -> model.AcpStreamEvent.
- internal/model/acp.go: AcpStreamRequest, AcpStreamEvent DTOs.
- internal/api/acp_handler.go: stream WS handler (upgrade, read
prompt, run Stream in a goroutine, write events, ping/pong, Cancel
on client close).
- internal/api/router.go: register the new route.
- internal/acp/transport.go: dispatch notifications synchronously
(vs. goroutine per notification) so chunks preserve order before
the session/prompt response.
- internal/acp/client_test.go: TestClientStreamEmitsChunkAndComplete
with a fake transport that drives a known sequence.
- internal/api/acp_handler_test.go: TestAcpStreamHandlerRoutes
smoke test using a fake opencode acp script.
Existing POST /api/workspaces/:id/acp/prompt is unchanged.
E2E: prompt 'say hi in exactly 3 words' -> 3 chunk events
('Hi',' there','!') + 1 complete {stopReason: 'end_turn'}.
Conversation: 019f3680-200f-79b0-860b-43302e60d0ea
Same shape as the process package refactor:
- LocalManager.mu (sync.Mutex) -> sessionsMu (sync.RWMutex).
Read paths (Status, Subscribe, Stdin, ExitStatus, Resize) take
RLock; write paths (Start, Stop, Restart, List) take Lock.
- LocalManager.exited: was a hand-rolled map[workspaceID]map[shellID]struct{}
guarded by exitedMu; now a sync.Map keyed by shellID only (UUID
is globally unique, no need for the nested map). Helpers
IsExited / MarkAsExited / ClearExited.
- shellOrder stays a plain map; read+written under sessionsMu.
- waitExit remains the sole caller of MarkAsExited; Start /
Stop / Restart call ClearExited.
- New TestShellIsExitedHelpers covers the helper semantics.
go test -race -count=2 ./... clean. Same caveat as the process
package: at this app's concurrency level, neither sync.RWMutex nor
sync.Map measurably beats the previous pair — the change is mostly
stylistic (one fewer lock, no nested maps, more idiomatic Go).
Conversation: 019f3673-d2d5-78f0-a7a9-5e3e91b65933
Apply the suggested optimization on top of the previous race fix.
- internal/process/manager.go: LocalManager.sessionsMu is now an
RWMutex. Read paths (Status, Subscribe lookup, Stdin lookup,
ExitStatus lookup) take RLock; mutating paths (Start, Stop,
Restart) take Lock. exited is now a sync.Map instead of a
hand-rolled map+mutex; reads (IsExited) are lock-free, writes
(MarkAsExited from waitExit, ClearExited on Start/Stop/Restart)
are atomic. Added IsExited / MarkAsExited / ClearExited helpers.
No behavior change. The previous race fix (the only one in the
package) is preserved: waitExit remains the sole caller of
sess.Cmd.Wait(), and all other code paths read the exited marker
rather than cmd.ProcessState.
- ACP: nothing to do. The ACP service talks to opencode through
process.Manager; it never reads cmd.ProcessState directly. The
process package race fix already covers the ACP code path.
Verified: go test -race -count=2 ./... clean across the repo.
Note: at this app's concurrency level (tens of workspaces, accessed
occasionally), neither sync.RWMutex nor sync.Map measurably beats
the previous map+mutex pair — the critical sections are O(1)
lookups with no contention. The change is mostly stylistic
(removes one lock, fewer deadlock surfaces, more idiomatic Go).
E2E: start / status(running) / duplicate(409) / stop / status(stopped)
/ restart / status(running) / stop all behave correctly.
The race: os/exec writes cmd.ProcessState inside cmd.Wait(), which
runs in the waitExit goroutine. Start, Status, Subscribe, and
Restart all read cmd.ProcessState from other goroutines to decide
'still running?'. That's a textbook Go data race — fixed by
TestStartRejectsDuplicateRunningSession under -race.
Fix: port the same pattern the shell package uses for its multi-shell
refactor. The manager now owns an exited map[string]struct{} guarded
by exitedMu. waitExit is the ONLY place that calls cmd.Wait(); after
Wait() returns it records exited[workspaceID]. Everyone else checks
the exited map instead of reading cmd.ProcessState.
- internal/process/manager.go: add exited + exitedMu to LocalManager;
Start conflict check, Status, Subscribe, Restart, Stop all consult
the exited map; Start clears the entry on new process; Stop and
Restart delete the entry on stop; waitExit sets it after Wait().
- go.mod / go.sum: tidied (uuid v1.6.0 was already direct dep from
the shell refactor).
go test -race -count=3 ./... clean across the whole repo.
go test -race -count=5 ./internal/process/... clean.
BREAKING: every shell operation now requires a shellId. The Manager
previously keyed by workspaceID alone (one bash per workspace). It now
keys by (workspaceID, shellID) where shellID is a UUID returned by
Start.
Fixes the long-standing bug where the WS handler's closeAll called
stdin.Close() and killed bash when a WS client disconnected. The
Session owns the pty file; the WS handler no longer closes it. The
pty is only closed by Manager.Stop (explicit) or by captureOutput
when the process naturally exits (EOF).
- internal/shell/manager.go: Manager interface gains List and every
method takes shellID; storage becomes
map[workspaceID]map[shellID]*Session; Start returns (shellID, err)
via uuid.NewString; Resize/Status/ExitStatus/Subscribe/Stdin/Stop
route by shellID; new List(workspaceID) returns ShellInfo[] in
creation order.
- internal/shell/session.go: Session gains ShellID + CreatedAt; Status
type gains ShellID.
- internal/shell/manager_test.go: updated existing tests for new
signatures; added TestShellMultiInstance (two shells in one
workspace, no output cross-talk, independent stop, List behavior).
- internal/service/shell_service.go: wrappers carry shellID; new
List method.
- internal/service/workspace_service.go: auto-start captures/logs
shellID; Delete iterates and stops all workspace shells.
- internal/api/shell_handler.go: WS closeAll drops stdin.Close();
start/restart return 201 with {shellId, pid, ...}; new list handler;
stop/resize take shellId in body.
- internal/api/router.go: GET /api/workspaces/:id/shell (list).
- internal/model/shell.go: new ShellStartResponse, ShellInfo,
ShellListResponse, ShellStopRequest, ShellRestartRequest; updated
ShellStatusResponse + ShellResizeRequest to carry shellId.
- go.mod/go.sum: github.com/google/uuid.
E2E:
- workspace create -> 1 auto shell
- start 2 more -> 3 shells in list
- stop 1 -> 2 shells in list
- WS connect -> send cmd -> disconnect -> WS reconnect -> send cmd ->
response OK, no [process exited] banner
The PTY was hardcoded to 80x24 at start, so full-screen programs
(htop, vim, less, tmux, top) drew themselves for 80x24 regardless
of the actual xterm window. Add a path for the frontend to push the
real cols/rows to the backend, which calls pty.Setsize on the pty
file.
Backend:
- internal/shell/manager.go: Resize(workspaceID, cols, rows) added to
the Manager interface and implemented on LocalManager. Looks up the
session, type-asserts sess.Stdin.(*os.File), calls pty.Setsize.
Validates cols/rows in [1, 10000] and returns CodeBadRequest on
bad input, CodeNotFound when no session.
- internal/service/shell_service.go: ShellService.Resize wrapper that
checks workspace existence first.
- internal/api/shell_handler.go: resize handler, 204 on success.
- internal/api/router.go: register POST /workspaces/:id/shell/resize.
- internal/model/shell.go: ShellResizeRequest{Cols, Rows}.
- internal/shell/manager_test.go: 3 new tests (valid resize via
pty.Getsize round-trip, invalid size, missing session).
Frontend:
- web/src/lib/api/process.ts: useShellResize mutation hook.
- web/src/components/terminal/TerminalPanel.tsx: terminal.onResize
subscription with 100ms debounce; filters 0x0; only fires when
workspaceId is set; cleanup clears timeout + disposes listener.
E2E: 'stty size' after POST /shell/resize {cols:200, rows:50} returns
'50 200'. 0x40 → 400, missing workspace → 404, valid → 204.
Conversation: 019f360a-5eba-7c81-94d3-e5d58ad3c026
The shell manager used os.Pipe() for stdin/stdout/stderr, so bash ran
without a tty. Programs that check isatty() (python, htop, less,
vim-style REPLs) would drop echo, ignore line-editing, and leak literal
0x1b bytes into the program — Python's REPL reported 'invalid
non-printable character U+001B' on input.
Switch to github.com/creack/pty:StartWithSize with an 80x24 default
and TERM=xterm-256color in cmd.Env. The PTY file replaces both the
stdin and stdout pipes; sess.Stdin is the pty *os.File and the
output capture goroutine reads from it directly. waitExit no longer
needs to close sess.Stdin — the capture goroutine's defer handles it
when the process dies and the pty returns EOF.
- go.mod / go.sum: add creack/pty v1.1.24.
- internal/shell/manager.go: rewrite Start to use pty.StartWithSize;
captureOutput reads from the pty; waitExit simplified; race-clean
exited map to avoid the cmd.ProcessState data race.
- internal/shell/manager_test.go (new): TestShellPTYIsRealTerminal
(echo with echo + output interleaved proves PTY echo is on) and
TestShellPTYTermEnv (TERM=xterm-256color propagates).
E2E against the real bash: 'python3 -q' returns the >>> prompt,
print(2+2) returns 4, exit() returns to bash, 0 ESC bytes leak.
Conversation: 019f35fa-601b-73c1-bd9e-221971c31a56
The opencode acp binary streams agent_message_chunk notifications with
an EMPTY messageId. The backend was already correct (it groups when a
messageId is present), but the UI rendered every chunk as its own
bubble, so a single 'count 1 to 5' reply showed as 9 separate bubbles.
Backend:
- internal/acp/history.go: add MessageID to Message; new AppendOrCreate
method that finds or creates an entry by messageId and appends text
to it (preserving the original timestamp).
- internal/acp/client.go: agent_message_chunk and user_message_chunk
use AppendOrCreate.
- internal/model/acp.go: expose MessageID in the wire response.
- internal/service/acp_service.go: copy MessageID into the API DTO.
- internal/acp/client_test.go: add TestHistoryAppendOrCreateGroupsByMessageID.
Frontend:
- web/src/components/acp/groupMessages.ts: new pure helper that
coalesces consecutive same-role history entries into a single bubble
(keeps the first chunk's time + messageId).
- web/src/components/acp/AcpPanel.tsx: render coalesced messages;
auto-scroll effect now depends on coalesced so it fires as the
agent streams.
Verified via e2e: 'count from 1 to 5' now returns a single agent entry
with text '1\n2\n3\n4\n5' instead of 9 separate entries.
The ACP spec defines session/update content as a ContentBlock object
({type, text}) but we decoded it as a string. This dropped every
agent_message_chunk and left prompt responses empty. E2E against the
real opencode acp binary surfaced the bug.
- internal/acp/messages.go: add ContentBlock struct; Update.Content is
now ContentBlock.
- internal/acp/client.go: handleNotification extracts Text when
Content.Type == "text"; other types are logged at debug and dropped.
- internal/acp/client_test.go: NDJSON framing test sends content as a
ContentBlock object and reads .Text.
- README.md: document /acp/* routes + CODESPACE_OPENCODE_ARGS env.
Verified end-to-end: POST /acp/prompt returns text="Hi there!", 4
messages in history (1 user + 3 agent chunks), 0 invalid session/update
log entries.
Conversation: 019f357d-4236-7010-949c-e61067618d42
Adds a minimal but real ACP stack for the opencode process:
- pkg/config: process.args default ["acp"] (opencodeCommand still "opencode")
- internal/process: NewManager(command, args) — exec.Command uses args
- internal/acp (new): NDJSON transport + JSON-RPC client over the existing
process stdio. Implements initialize / session/new / session/prompt /
session/cancel. Serves fs/read_text_file and fs/write_text_file from the
workspace's fs.FileSystem. terminal/* requests get MethodNotFound.
- internal/service/acp_service: per-workspace Client + mutex; starts the
process on first prompt; transparently re-init on restart.
- internal/api/acp_handler: GET /acp/status, GET /acp/history,
POST /acp/prompt, POST /acp/cancel.
- internal/model/acp: API DTOs.
- internal/acp/client_test: NDJSON split-lines, request/response correlation,
notification dispatch, agent-initiated request handling (fs + terminal).
Existing process WS endpoint and Shell subsystem are unchanged.
Conversation: 019f354c-a51b-7ec3-83ad-c647e9b50b19