Commit Graph
17 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 a3dbda7d3b phase D: delete the subprocess path
The serve is now the only way an opencode turn runs. runner.ts and its tests are gone, and
so is the OPENCODE_TURNS switch — there is no fallback engine any more, and the recovery for
a bad day is git rather than a config flag. Deliberate, and cheap right now precisely because
nothing depends on opencode yet.

What goes with it: mapRunLine and its NDJSON fixtures, the temp-file spill for --file image
attachments, the supersede-and-kill dance, the process watchdogs, the pidfile-adjacent child
tracking, and stopAllOpenCodeTurns. All of it existed to work around stdin being /dev/null.

Verified after deletion, with no env var set at all: tool call, tool result, 5 streaming
deltas, text and cost, through the real chat socket.

Also corrected the comments the deletion falsified rather than leaving them to mislead — the
module header, the wire contract description of opencode:run-streaming, and serve-runner own
header, which still announced itself as off by default.

One difference worth stating: shutdown no longer kills anything. Turns run inside the serve,
which is a separate process that survives us, so officer stops routing them and says so in
the transcript. When a subprocess ran the turn, failing to kill it orphaned it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:33:58 +01:00
pastilhasandClaude Opus 5 b79eca45fb send images on the serve path too, before anyone switches to it
runOpenCodeTurnOnServe ignored params.images entirely, which is defect B4 rebuilt on the new
path: the image renders in your own bubble and the model never receives it, with nothing
reporting a loss. Fixed before the path is switched on for anyone rather than after.

data: URIs, not file://, and that is measured — the wrong choice is accepted with a 200 and
then dies inside the turn with "Anthropic Messages media must contain valid base64". The
data URI round-trips and the model describes the image.

Strictly better than the subprocess path here: no temp file to spill and nothing to clean up,
because the bytes travel in the request.

Both prompt paths carry them — an ordinary send and a mid-turn injection. Verified end to end
through the chat socket with the serve engine on: a red png came back "**Red**", with deltas
streaming.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:25:48 +01:00
pastilhasandClaude Opus 5 a058cbb3fd inject a mid-turn message instead of replacing the turn
Phase C server half, and a real flaw in phase B.

On the subprocess path a second message could only supersede — kill the process, start again,
lose the turn — because opencode run has no input channel. The serve takes another prompt
into the running turn, so a message arriving mid-turn is handed over with delivery steer and
the existing turn is left exactly as it is.

Keeping the same turn object is the load-bearing part. Phase B retired it and registered a
replacement, which stops officer routing events the serve is still producing while the serve
carries on regardless: output goes nowhere and the turn looks hung.

Verified end to end through the chat socket — sent a count to 50, injected a change of plan
eight seconds in, and BANANA INJECTED came back inside the same turn with deltas streaming
throughout.

No client change was needed. Officer composer already sends while generating; the difference
is only what the sidecar does with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:17:26 +01:00
pastilhasandClaude Opus 5 a06422bd4c phase B: run a turn through the serve, behind a switch
OPENCODE_TURNS=serve picks the new engine; unset keeps the subprocess, which is the default
and stays the default until this has been lived with. A bad evening should cost one restart,
not a revert. Claude is a different sidecar and is untouched.

Verified end to end through the real chat socket:

  session:init -> tool:start(bash) -> tool:result -> assistant:delta x3 -> assistant:text
  -> result, cost in=304 out=73

Those deltas are the first token streaming an opencode turn has ever produced in officer.
Stop is now an INTERRUPT: the turn ends and the session survives — verified by sending a
second prompt to the same session afterwards and getting an answer, which killing a
subprocess could never do.

Reads the LIVE global stream rather than the durable per-session one, because it is a strict
superset — same tool.called, tool.success, step.ended, text.ended, plus the deltas that are
the whole point. Global means one socket carries every session, so everything filters on
sessionID; one subscription is shared for the process rather than one per turn.

A turn ends on step.ended with finish != tool-calls. tool-calls is a step boundary MID-turn,
and treating it as terminal would cut every tool-using conversation in half.

delivery is stated explicitly as queue because it DEFAULTS to steer, which injects into a
running turn — wrong for an ordinary send, where two quick messages would merge into one.
Wiring steer to the button that means it is phase C.

What phase B does not do: read the durable stream. The sidecar still commits every event to
chat_session_events as it arrives, so durability is unchanged, but recovering a turn this
process never saw needs the ?after= cursor and is its own change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:12:49 +01:00
pastilhasandClaude Opus 5 feb9010097 phase A: map the serve event stream, routed nowhere
The mapping half of the serve migration, written and pinned before anything depends on it,
so the switch-over is not also the moment the parsing turns out to be wrong. Nothing routes
through this — turns are still opencode run subprocesses, and the claude path is untouched.

The finding that matters: the serve publishes each turn TWICE, and reading the wrong one
makes it look like it cannot stream at all.

  /api/session/{id}/event?after=  durable, per session, replayable, durable.seq on every
                                  event, whole values only, NO deltas
  /api/event                      live, GLOBAL, ephemeral, carries text.delta and
                                  tool.input.delta, no cursor

Same turn: 13 events durable, 21 live, the difference being 3 text.delta and 5
tool.input.delta. I probed the per-session one first and nearly recorded "no streaming" as
a fact — it would have removed the main reason to migrate. The split maps exactly onto what
officer already does for claude: durable to chat_session_events, live to UI deltas. The cost
is that the live stream is global, so a consumer must filter on sessionID.

tool:start is emitted on tool.called, not tool.input.started, because only tool.called has
the resolved input object — the input arrives as JSON fragments ({"comman) and a tool row
rendered with half-parsed arguments is worse than one that appears a moment later.

step.ended with finish tool-calls is a step boundary MID-turn, not the end of the turn, so
nothing terminal is emitted for it. Treating it as the end would cut every tool-using
conversation in half.

Fixtures are verbatim captures from 1.18.16. Replaying both real streams through the mapper
reconstructs the turn identically from each, with the reassembled deltas exactly equal to
the committed text and identical cost, and zero unrecognised events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:33:33 +01:00
pastilhasandClaude Opus 5 86fd03b35b say a missing opencode binary out loud instead of hanging
Bun.spawn throws on a missing or non-executable binary rather than resolving to a failed
process, and that throw escaped runOpenCodeTurn entirely — past the bookkeeping, out of the
sidecar command handler, with no opencode:event ever emitted. The browser sat on a spinner
nothing could end, because the code that ends turns had not been reached.

A wrong OPENCODE_BIN is the ordinary way to get there, so the message names the path it
tried: that is the difference between a fix and a debugging session.

Also records that messageCount is not a gap. SessionList renders an OpenCode badge in place
of the count for those rows, so the hardcoded 0 never reaches a screen, and computing a real
one would cost an HTTP call per listed session — the session record has no count field — to
populate something nothing shows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:16:37 +01:00
pastilhasandClaude Opus 5 35970146bb connect the opencode credential at sidecar boot
opencode keeps credentials in two unrelated places. The CLI, opencode run and the legacy
/session surface read auth.json. The newer /api surface — the one with steer, queue,
interrupt and a resumable per-session stream — reads its own integration store and knows
nothing about that file.

With none connected it does not fail. It falls back to what needs no credential, the free
tier, and a request for a paid model is never executed: prompt accepted, admitted, prompted,
then no step, no error, no message, forever. That silence cost most of an afternoon and would
cost it again on every new machine — alpha included.

So the sidecar does it, rather than depending on someone having run a curl. Best-effort and
never blocking: turns go through opencode run, which reads auth.json and does not care.

Retried, because /api/health answers before the integration store is ready — the first
version of this shipped without a retry and failed on its very first real boot with a 500,
while the identical request succeeded seconds later. Only 5xx retries; a 4xx means the
request is wrong and repeating it just prints the same complaint six times.

Verified by deleting the credential, restarting, and running sonnet on the new pipeline with
no manual step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 18:03:04 +01:00
pastilhasandClaude Opus 5 73b8111216 send images to opencode, which never needed the fork
B4 properly. The composer gate was the honest stopgap; this is the fix. opencode run takes
attachments with --file, so images work on the subprocess path we already use — the parity
doc had them down as phase 4, behind the serve migration, and they were not.

The bug was one omission: handleOpenCodeChat`s msg type had no images field, so the browser
sent them, the bubble rendered them, and they stopped at that signature. Nothing reported a
loss anywhere.

Attachments are paths, not inline data, so the sidecar spills each image to a temp file for
the length of the turn and removes it in settle — the same place every other per-turn
resource is released, so a killed or superseded turn cleans up too.

The load-bearing detail is `--` before the prompt: --file is an array option, so without the
separator the prompt is eaten as another filename and the turn dies with "File not found:"
followed by the entire message. Confirmed against the binary, and pinned by a test that
records argv from a stub.

list-models now reports each model own capability instead of a hardcoded false — opencode
publishes capabilities.input.image per model and nothing had ever read it. Defaults to false,
so a model that does not declare it keeps the affordance hidden.

Verified end to end: a red png sent over the chat socket to opencode/claude-sonnet-4-6 came
back "Red".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 16:49:39 +01:00
pastilhasandClaude Opus 5 41663bc207 decide the phase 2 fork: turns stay on opencode run
The serve has a second, newer API surface nobody here had looked at, and it publishes
exactly what the parity doc calls impossible under stdin ignore: delivery steer and queue
on POST /prompt, an interrupt that does not tear down, and a per-session event stream with
an after cursor — the durable-replay machinery officer hand-built for claude, as a
primitive. That would have made migrating obvious.

It does not execute. A prompt is accepted with an admittedSeq, stored, emits
prompt.admitted and prompted, and then never steps. Ruled out separately: the model, the
permissions (build is *:allow, no pending requests), the per-request location (the surface
is location-scoped via header or a deepObject query, supplied everywhere, no change), and a
config gate. The legacy POST /session/id/message?directory= generates fine in 17s, so the
serve itself works — only the new pipeline is inert. session.next.* is the tell.

And not a version problem, which is the part everything here had backwards: this Mac runs
1.18.11 and alpha runs 1.17.9, measured. The dead pipeline was tested on the NEWER binary.
The original "this server runs 1.17.9" meant alpha and was copied to a machine where it was
false; corrected in runner.ts and the test.

So building against it now would produce code that looks finished and does nothing, which
is the failure mode this project keeps rediscovering. One request reopens the question
after any upgrade, and the doc names it.

Also de-flakes the lifecycle tests: they spawn real processes, and a fixed sleep(750) went
red once on a machine busy running these probes. Presence assertions poll now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:15:34 +01:00
pastilhasandClaude Opus 5 cdee320fed stop orphaning opencode turns and serves on restart
B8, both halves. They share index.ts, so they share a commit.

In-flight turns: `opencode run` is spawned, not supervised, so pm2 restart officer-opencode
left every turn ALIVE — reparented, still spending tokens, still writing files as the agent,
with the only reader of its stdout gone. The transcript stopped mid-tool-call, which reads
as the agent hanging.

stopAllOpenCodeTurns kills them and settles each synchronously, because the caller is about
to process.exit and nothing waiting on proc.exited would ever run. Settling writes a reason,
so a reload after a restart explains itself instead of trailing off. Turns are stopped BEFORE
the connection is destroyed — that write travels over it — and the flush is bounded, since
losing the explanation is bad but hanging the restart is worse.

Stale serves: the sweep read /proc, so it was a no-op on macOS and orphaned serves piled up,
one per unclean exit, each holding a port. Added a pidfile sweep alongside it. A pid we wrote
ourselves needs no cwd guard to prove it is ours, which is the part ps cannot answer portably
(macOS would need lsof), and a serve started by hand is never in the file.

The guard checks command AND subcommand: matching the word serve anywhere in the line would
sweep a running turn whose prompt merely mentioned it. Fixtures are real ps output from both
machines, not invented. Split into serve-sweep.ts because index.ts spawns a serve at module
scope, so a test importing it would start one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:28:49 +01:00
pastilhasandClaude Opus 5 c252f24f1d don't let a superseded turn finish somebody else's session
A replaced turn is killed but dies asynchronously, so its proc.exited fired long after
the replacement was registered under the same sessionKey — and then ran the whole
completion path against it: emitted "OpenCode exited with code 143", which the sidecar
commits to chat_session_events so a false failure became permanent history, then deleted
the replacement from `running`. That blinded the new Live panel, made the stop button a
no-op and orphaned a process nothing could reach.

Mark the handle before killing it, retire it silently, and identity-check the delete —
a superseded turn does not own that key any more.

Two leaks in the same family, found while fixing it. An early return would not have been
enough: both watchdogs call finish, so the armed 10-minute hardTimer would have fired an
error at whichever turn held the key by then. And handleLine had no `done` guard, so
stdout still draining from the killed process was emitted under the replacement key.

Reproduced before fixing. The lifecycle tests need no real opencode — RunnerConfig.bin
takes a shell script that sleeps. The control test pins that an ordinary non-zero exit
still reports an error, so the guard cannot overreach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 12:50:46 +01:00
pastilhasandClaude Opus 5 e8bd946272 show running opencode turns in the live panel
The Live panel asked `claude:list` and nothing else, so a running OpenCode turn was invisible — the panel
claimed to show what the agent is doing and silently omitted half of it.

Adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel,
each failing toward empty so one sidecar being down contributes nothing rather than breaking the panel.

The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:

  isGenerating  always true — a subprocess exists only while it generates, so there is no "merely open"
  pendingTasks  always 0    — `opencode run` has no background-task concept; reporting a number would
                              suggest a capability that does not exist
  title / cwd   null        — the session store is keyed on the `ses_…` id the runner reports, not on
                              our sessionKey, so an unreported turn shows unnamed rather than guessed

This is the incremental option from docs/opencode-serve-path.md — enumeration without moving turns onto
the serve, so it buys the Live panel with no warm sessions, no SSE loop and no lifetime questions.

NOT verified end to end: no OpenCode turn was running to enumerate, so the verb is wired and typechecked
but has never returned a non-empty list. See COMMS/BLOCKERS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 03:00:43 +00:00
pastilhasandClaude Opus 5 8b409e8af8 finish phase 1: correct the stale comments, and put the ndjson mapping under test
Path names: the serve's cwd is DATA_PATH/opencode_server, not opencode-sidecar. Two comments said
otherwise and would send the next reader to a directory that does not exist.

Version pin: the comment claimed "verified live against 1.17.9" as though that were a property of the
code. It is a property of whichever binary is installed, and this project already runs two — 1.17.9 here,
1.18.11 on the other machine. Says so now, and points at the test as the thing that actually enforces it.

Tests, the first on the OpenCode path. `runner.ts`'s NDJSON → ChatEvent mapping was described as pure and
untested; it was untested but not pure — it lived inside `handleLine` as a closure over `emit`, the
accumulated cost and a reported-session flag, so it could not be called without spawning a binary.

Extracted as `mapRunLine`, genuinely pure: line in, {sessionId, events, costDelta} out. The two concerns
that span lines stay with the caller, because they are not properties of a line — emitting the session id
exactly once, and accumulating cost across steps. Behaviour is unchanged.

11 tests over what the mapping forwards, what it drops and what it must not turn into NaN. The last one
matters: a missing `cost` on a step_finish would otherwise propagate NaN into the turn total.

Phase 1 is complete: dead code deleted (previous commit), comments corrected, tests added.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:58:11 +00:00
pastilhasandClaude Opus 5 cfbf58cdba delete the AGENTS.md injection; --dir was always the real anchor
The sidecar seeded an AGENTS.md into the serve's project root telling the agent to read its working
directory from the "Working directory for this session" line in its system prompt. The run path sends no
system prompt, so there was no such line and the instruction had been inert since turns moved off the
serve to `opencode run --dir`.

What it was standing in for, `--dir` does properly — tested rather than assumed
(docs/opencode-phase0-review.md): `--dir` anchors the agent's own file operations, not just the process
cwd, and the anchor survives a multi-step turn with a write in the middle. Nothing replaces it.

The generated file is removed from disk too, not only from the code that wrote it; leaving it would have
kept feeding standing instructions to every session while looking, in the source, as though it were gone.

Also corrects the claim that all OpenCode sessions live in one server's project. True when turns
inherited the serve's directory, false now: one serve lists 7 sessions across several directories, which
is why the cwd filter has to read `directory` rather than assume a single one.

docs/opencode-phase0-review.md, item 2 — Phase 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 02:51:20 +00:00
pastilhasandClaude Opus 4.8 71e39b7639 move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.

`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00
pastilhasandClaude Opus 4.8 5d077a4a54 route OpenCode chat through the officer-opencode sidecar
Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.

- runner.ts (new): spawn `run`, map its JSON events (text/tool_use/
  step_finish) to ChatEvent, report the `ses_` id for resume, accumulate
  cost; inactivity (120s) + hard-cap (10min) watchdogs kill a hung turn
  and emit a clean error instead of hanging forever.
- protocol.ts: opencode:run-streaming/kill commands; opencode:spawned/
  event/session events; OpenCodeRunParams.
- sidecar index.ts: wire run/kill; sweepStaleServes() on startup kills
  only an `opencode serve` whose resolved /proc/<pid>/cwd == SERVE_CWD,
  so an unclean prior exit can't leave two.
- sidecar-registry.ts: spawnOpenCodeStreaming/killOpenCode/onOpenCodeEvent/
  onOpenCodeSession helpers.
- send-opencode.ts: rewritten to mirror send-claude-code (subscribe →
  resolve resume id → spawn → kill handle).
- sidecar-server.ts: persist reported ses_ id into state for resume.
- list-models/server-manager: route to the sidecar's reported serve URL.

The serve stays up only for read-only calls that never hung (model
listing, session history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:54:52 +00:00
pastilhasandClaude Opus 4.8 203a8b0708 opencode: add the OpenCode sidecar (step 1 — serve lifecycle + port report)
New officer-opencode sidecar (same philosophy as officer-claude): a singleton that owns
an `opencode serve` running from DATA_PATH/opencode-sidecar (created if missing) on a
random port, registers with the API as capability 'opencode', and reports its port via a
new `opencode:server` protocol event. The API stores it (sidecar-server.ts, wired in
server.tsx via getOpenCodeServerUrl). ecosystem.config.cjs runs the sidecar instead of a
bare pm2 serve. Turn-running + API rewiring come in later steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:43:57 +00:00