From a41abb4b0fbb2000fa38de3cc4698e9c88ed49b3 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 14:50:12 +0100 Subject: [PATCH 01/42] characterise the fork blocker: only free models run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not sonnet, and not variant. Swept models through the new pipeline: every -free model runs, every paid one silently does not — haiku, sonnet and codex-mini all never start. Ruled out: variant (sonnet advertises low/medium/high/max and echoes back an invalid "default", which looked like the answer and was not — setting high explicitly also never ran); credentials (zen key in auth.json plus ANTHROPIC_API_KEY); and the sidecar environment, since the same process runs sonnet fine through opencode run. So the new pipeline does not resolve paid-model credentials and says nothing, while run and the legacy path authenticate fine. Upstream bug in an in-progress pipeline, not our config. The fork stays blocked, but precisely: steer and queue are proven, and the day a paid model runs there the migration is worth doing immediately. Re-run the sweep after each upgrade. Co-Authored-By: Claude Opus 5 --- docs/opencode-fork-decision.md | 42 ++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 10 deletions(-) diff --git a/docs/opencode-fork-decision.md b/docs/opencode-fork-decision.md index 58f250a6..d1669fef 100644 --- a/docs/opencode-fork-decision.md +++ b/docs/opencode-fork-decision.md @@ -41,19 +41,41 @@ Steer and queue are exactly the two features we hand-built for Claude and that A changer. Having them as primitives, plus a resumable per-session cursor that mirrors officer's durable replay, is a strong argument for migrating. -## The open question, which is now the only blocker +## The blocker, now characterised: only free models run -**Why does `claude-sonnet-4-6` run under `opencode run --model` and silently die under the new API?** +It is not sonnet, and it is not `variant`. Swept four models through `POST /api/session/{id}/model` +followed by a prompt: -Until that is answered, the migration cannot be adopted: model choice is a user-facing control, and a -harness where picking the wrong model produces silence rather than an error is worse than one without -streaming. Things to try, cheapest first: +| Model | New pipeline | +| -------------------- | ------------ | +| `longcat-2.0-free` | **ran** | +| `ling-3.0-tiny-free` | **ran** | +| `claude-haiku-4-5` | never ran | +| `claude-sonnet-4-6` | never ran | +| `gpt-5.1-codex-mini` | never ran | -1. `variant` on `ModelRef` — it is the one field of the three we never set. -2. Compare `/config/providers` against what `run` resolves, to see whether the ids differ in form. -3. Whether it is auth-scoped: alpha's default model failed `401 missing_api_key` on a *different* - provider (`nano-gpt`), which proves the new pipeline surfaces provider auth errors properly when it - gets that far — so sonnet's silence is not a generic auth failure. +**Every `-free` model runs; every paid model silently does not.** Ruled out along the way: + +- **Not `variant`.** `claude-sonnet-4-6` advertises `["low","medium","high","max"]` and session create + echoes back `variant: "default"`, which is not among them — a promising theory that turned out to be + wrong: setting `variant: "high"` explicitly also never ran. Tested rather than assumed, which is the + whole lesson of this file. +- **Not missing credentials.** `opencode auth list` shows an OpenCode Zen API key in + `~/.local/share/opencode/auth.json` plus `ANTHROPIC_API_KEY` in the environment. +- **Not the sidecar's environment.** The *same* sidecar process runs `claude-sonnet-4-6` correctly + through `opencode run --model`, verified end to end. Same user, same home, same auth file. + +So the new pipeline does not resolve paid-model credentials, and fails **silently** rather than +reporting it — while the legacy path and `run` both authenticate fine. Note it *can* surface provider +auth errors when it reaches that far: alpha's default (`nano-gpt`) returned a clean +`401 missing_api_key`. The silence is specific to opencode-zen paid models. + +This reads as an upstream bug in an in-progress pipeline, not something configurable on our side. + +**Consequence for the fork:** blocked, but precisely. Officer's users pick real models; a harness that +works only on free tiers is not adoptable. Re-run the sweep above after each `opencode upgrade` — the +day a paid model runs there, the migration is unblocked and worth doing immediately, because steer and +queue are already proven. ## Revised recommendation From 73b8111216514b242ea3259300fa30fa21b1822d Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 16:49:39 +0100 Subject: [PATCH 02/42] send images to opencode, which never needed the fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/chat/list-models.ts | 21 ++++--- src/servers/api/chat/websocket.ts | 5 ++ src/servers/channels/send-opencode.ts | 4 +- src/servers/sidecar/opencode/runner.test.ts | 68 ++++++++++++++++++--- src/servers/sidecar/opencode/runner.ts | 61 +++++++++++++++++- src/servers/sidecar/protocol.ts | 8 +++ 6 files changed, 149 insertions(+), 18 deletions(-) diff --git a/src/servers/api/chat/list-models.ts b/src/servers/api/chat/list-models.ts index 8d2b1a8b..1e545dcc 100644 --- a/src/servers/api/chat/list-models.ts +++ b/src/servers/api/chat/list-models.ts @@ -39,8 +39,12 @@ export function invalidateModelCache(): void { openCodeCache = null; } +type OpenCodeModel = { + capabilities?: { input?: { image?: boolean }; reasoning?: boolean }; +}; + type ProvidersResponse = { - providers?: Array<{ id?: string; models?: Record }>; + providers?: Array<{ id?: string; models?: Record }>; }; // Enumerate OpenCode models from the fixed server's GET /config/providers (reliable — no subprocess). @@ -58,19 +62,20 @@ async function listOpenCodeModels(): Promise { const models: ModelInfo[] = []; for (const provider of data.providers ?? []) { const providerId = provider.id ?? ''; - for (const modelId of Object.keys(provider.models ?? {})) { + for (const [modelId, model] of Object.entries(provider.models ?? {})) { models.push({ id: `${providerId}/${modelId}`, name: modelId, provider: providerId, contextWindow: 200000, maxTokens: 8192, - reasoning: false, - // False because nothing carries them: `handleOpenCodeChat`'s message type has no `images` - // field, so an attached image is rendered in the bubble, never sent, and silently dropped. - // The composer gates on this flag, so advertising `true` offered a capability that did not - // exist. Flip it back when images are plumbed through OpenCodeRunParams (parity doc, Phase 4). - images: false, + reasoning: model?.capabilities?.reasoning ?? false, + // Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this + // flag, so advertising `true` offered a capability that did not exist. Images are now plumbed + // through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's + // own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model + // that does not declare the capability keeps the affordance hidden rather than offering it. + images: model?.capabilities?.input?.image ?? false, }); } } diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 32236343..7bac312d 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -396,6 +396,10 @@ async function handleOpenCodeChat( cwd?: string; cwdRoot?: string; resumeSessionId?: string; + // The whole of B4 lived in this omission. The browser sent images, the bubble rendered them, and + // they stopped at this signature — so they were never passed on and never reached the model, with + // nothing anywhere reporting a loss. + images?: PromptImage[]; }, effectivePrompt: string, ): Promise { @@ -457,6 +461,7 @@ async function handleOpenCodeChat( cwd, model, resumeSessionId: msg.resumeSessionId, + images: msg.images, onMessage, }); diff --git a/src/servers/channels/send-opencode.ts b/src/servers/channels/send-opencode.ts index 4be73c23..4ca28230 100644 --- a/src/servers/channels/send-opencode.ts +++ b/src/servers/channels/send-opencode.ts @@ -1,4 +1,4 @@ -import type { TurnMessage } from '@@/api/chat/types'; +import type { PromptImage, TurnMessage } from '@@/api/chat/types'; import { logger } from '@@/api/chat/logger'; import * as sidecar from '@@/sidecar-registry'; import { getOpenCodeSession } from '@@/api/chat/opencode/state'; @@ -20,6 +20,7 @@ type OpenCodeStreamingParams = { role?: string; resumeSessionId?: string; durable?: boolean; + images?: PromptImage[]; // Finished turn messages, already committed by the sidecar; `seq` is the cursor id to deliver them under. onMessage: (msg: TurnMessage, seq?: number) => void; }; @@ -59,6 +60,7 @@ export async function sendOpenCodeStreaming(params: OpenCodeStreamingParams): Pr model: params.model, resumeSessionId, durable: params.durable, + images: params.images, }); } catch (err) { unsub(); diff --git a/src/servers/sidecar/opencode/runner.test.ts b/src/servers/sidecar/opencode/runner.test.ts index 5932b238..6591db45 100644 --- a/src/servers/sidecar/opencode/runner.test.ts +++ b/src/servers/sidecar/opencode/runner.test.ts @@ -1,5 +1,5 @@ import { afterAll, describe, expect, it } from 'bun:test'; -import { mkdtempSync, writeFileSync, chmodSync, rmSync } from 'node:fs'; +import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import type { RunnerMessage } from './runner'; @@ -15,13 +15,13 @@ import { // // This covers the NDJSON → ChatEvent mapping from `opencode run --format json`, which is the piece most // likely to break against a new release: the event shape is not a documented contract, and this project -// already runs two different opencode versions across two machines (1.18.11 on the Mac, 1.17.9 on alpha -// — measured 2026-08-10; this file previously had them the wrong way round). -// Before this, a shape change would have surfaced as a silently empty or malformed turn. +// could easily run different versions on its two machines — both are on 1.18.16 as of 2026-08-10, but +// only because they were upgraded together that day; before it they were 1.18.11 and 1.17.9, and this +// file recorded which was which backwards. Without these tests a shape change surfaces as a silently +// empty or malformed turn. // -// The fixtures below are the shapes the live 1.17.9 binary emits. If one of these tests fails after an -// upgrade, re-read the real NDJSON from the installed binary before editing the expectation — the test -// failing is the feature. +// The fixtures below were captured from a live binary. If one fails after an upgrade, re-read the real +// NDJSON from the installed binary before editing the expectation — the test failing is the feature. describe('mapRunLine — what it forwards', () => { it('forwards a text part as final text, because run emits whole blocks not deltas', () => { @@ -143,6 +143,12 @@ const FAILING_BIN = join(stubDir, 'failing-opencode'); writeFileSync(FAILING_BIN, '#!/bin/sh\necho "boom" >&2\nexit 1\n'); chmodSync(FAILING_BIN, 0o755); +/** Records the argv it was invoked with, so the command line itself can be asserted. */ +const ARGS_FILE = join(stubDir, 'args.txt'); +const RECORDING_BIN = join(stubDir, 'recording-opencode'); +writeFileSync(RECORDING_BIN, `#!/bin/sh\nprintf '%s\\n' "$@" > ${ARGS_FILE}\nexit 0\n`); +chmodSync(RECORDING_BIN, 0o755); + const CONFIG = { bin: STUB_BIN, fallbackCwd: stubDir }; afterAll(() => { @@ -219,6 +225,54 @@ describe('runOpenCodeTurn — a second turn on a live session', () => { expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(false); }); + it('puts `--` between the attachments and the prompt, or the prompt is eaten as a filename', async () => { + // `--file` is an ARRAY option in opencode's parser, so it keeps consuming positionals. Without the + // separator the turn dies with `File not found: ` — confirmed against the + // real binary before this was written. Nothing else in the arg list can catch that, so it is pinned + // here rather than left to the next person to rediscover. + const messages: RunnerMessage[] = []; + runOpenCodeTurn( + { + sessionKey: 'sess-images', + prompt: 'describe this', + cwd: stubDir, + images: [{ mediaType: 'image/png', data: Buffer.from('not-really-a-png').toString('base64') }], + }, + { ...CONFIG, bin: RECORDING_BIN }, + (m) => messages.push(m), + ); + + await waitFor(() => existsSync(ARGS_FILE)); + const argv = readFileSync(ARGS_FILE, 'utf8').trim().split('\n'); + + const fileFlag = argv.indexOf('--file'); + const separator = argv.indexOf('--'); + expect(fileFlag).toBeGreaterThan(-1); + expect(separator).toBeGreaterThan(fileFlag); + expect(argv.at(-1)).toBe('describe this'); + // The attachment is a path on disk, not inline data — that is the whole reason for the temp file. + expect(argv[fileFlag + 1]).toMatch(/officer-oc-.*\.png$/); + }); + + it('leaves no `--` and no temp files behind when the turn carries no images', async () => { + const messages: RunnerMessage[] = []; + rmSync(ARGS_FILE, { force: true }); + runOpenCodeTurn( + { sessionKey: 'sess-noimg', prompt: 'plain', cwd: stubDir }, + { ...CONFIG, bin: RECORDING_BIN }, + (m) => messages.push(m), + ); + + await waitFor(() => existsSync(ARGS_FILE)); + const argv = readFileSync(ARGS_FILE, 'utf8').trim().split('\n'); + + // A bare `--` would be harmless here, but its absence is what proves the separator is tied to the + // attachments rather than added unconditionally. + expect(argv).not.toContain('--'); + expect(argv).not.toContain('--file'); + expect(argv.at(-1)).toBe('plain'); + }); + it('still reports a turn that dies on its own, rather than swallowing every exit', async () => { // The guard must not overreach: an ordinary failure is still an error the user needs to see. const sessionKey = 'sess-solo'; diff --git a/src/servers/sidecar/opencode/runner.ts b/src/servers/sidecar/opencode/runner.ts index 6f836bca..775f27c5 100644 --- a/src/servers/sidecar/opencode/runner.ts +++ b/src/servers/sidecar/opencode/runner.ts @@ -1,6 +1,8 @@ -import { existsSync } from 'node:fs'; +import { existsSync, unlinkSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { Subprocess } from 'bun'; -import type { ChatEvent, MessageCost } from '../../api/chat/types'; +import type { ChatEvent, MessageCost, PromptImage } from '../../api/chat/types'; import type { OpenCodeRunParams } from '../protocol'; // Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited @@ -98,6 +100,16 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, if (params.cwd) args.push('--dir', params.cwd); if (params.model) args.push('--model', params.model); if (params.resumeSessionId) args.push('--session', params.resumeSessionId); + + // Attachments are paths, not inline data, so the images are spilled to temp files for the length of + // the turn and removed in `settle`. + const imagePaths = writeTurnImages(params.images); + for (const path of imagePaths) args.push('--file', path); + + // `--` BEFORE the prompt, and it is load-bearing: `--file` is an ARRAY option, so without the + // separator the prompt is swallowed as another filename and the turn dies with + // `File not found: `. Verified against the binary. + if (imagePaths.length > 0) args.push('--'); args.push(params.prompt); const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd; @@ -138,6 +150,7 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, clearTimeout(hardTimer); if (inactivityTimer) clearTimeout(inactivityTimer); if (running.get(sessionKey) === handle) running.delete(sessionKey); + cleanUpTurnImages(imagePaths); if (event) emitEvent(event); }; @@ -268,6 +281,50 @@ export function listRunningOpenCodeTurns(): { sessionKey: string }[] { return Array.from(running.keys()).map((sessionKey) => ({ sessionKey })); } +// ── Image attachments ── +// +// `opencode run` takes files by PATH (`--file`), while the browser sends base64 over the socket, so the +// two are bridged by a temp file per image that lives exactly as long as the turn. +// +// Failing to write one is deliberately not fatal: an image that cannot be spilled costs the model that +// image, and sending the text anyway is better than failing a turn the user has already waited for. +// This is the same call the composer's own gate makes — degrade, do not remove the feature. + +/** Extension by media type, so the model sees a file it can identify. Unknown types keep `.bin`. */ +const IMAGE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', +}; + +function writeTurnImages(images: PromptImage[] | undefined): string[] { + if (!images?.length) return []; + const written: string[] = []; + for (const [index, image] of images.entries()) { + try { + const ext = IMAGE_EXTENSIONS[image.mediaType?.toLowerCase() ?? ''] ?? 'bin'; + const path = join(tmpdir(), `officer-oc-${process.pid}-${Date.now()}-${index}.${ext}`); + writeFileSync(path, Buffer.from(image.data, 'base64')); + written.push(path); + } catch (err) { + console.error('[opencode] could not write an attachment; continuing without it:', err); + } + } + return written; +} + +function cleanUpTurnImages(paths: string[]): void { + for (const path of paths) { + try { + unlinkSync(path); + } catch { + /* already gone */ + } + } +} + /** * Kill every turn this process is running, because the process itself is going away. * diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index dea40a5b..d6d55768 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -197,6 +197,14 @@ export type OpenCodeRunParams = { cwd?: string; // passed to `opencode run --dir` — hard-re-anchors tools to this directory model?: string; // `providerID/modelID` (e.g. opencode/claude-haiku-4-5); passed to --model verbatim resumeSessionId?: string; // OpenCode `ses_…` id to continue (`--session`) + /** + * Images for this turn, base64 as the browser sent them. + * + * `opencode run` takes attachments as PATHS (`-f`), not inline data, so the sidecar writes each one to + * a temp file and deletes it when the turn ends. They travel as data because that is what crosses the + * websocket from the browser, and because officer and the sidecar are not guaranteed to share a disk. + */ + images?: PromptImage[]; durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams }; From f0aa6dbf4b95d7c22feba8b2351a387e083d3ebd Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 16:49:52 +0100 Subject: [PATCH 03/42] record that images are done and were never fork-gated Bucket 1 lists them as No, and phase 4 put them behind the migration. opencode run takes --file, so the path we already use carries them. Co-Authored-By: Claude Opus 5 --- docs/opencode-parity.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/opencode-parity.md b/docs/opencode-parity.md index 948f396c..a4e28387 100644 --- a/docs/opencode-parity.md +++ b/docs/opencode-parity.md @@ -102,6 +102,13 @@ passed that one model. Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on 1.18.16. +**Images are done, and they never needed the fork** (bucket 1 lists them as "No — see B4", and Phase 4 +put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path +carries them today: the sidecar spills each image to a temp file for the turn and removes it in +`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back +"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded +`false`, so the composer gate became load-bearing in the right direction. + --- ## Bucket 1 — Claude has it, OpenCode does not From 90e0ca8ab42a5b8624264d295c6b929c1e24bf45 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 16:56:07 +0100 Subject: [PATCH 04/42] stop showing opencode placeholder titles as if they were names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode titles a session from the conversation, but asynchronously — a finished turn of ours ended up called "Single color in oc-red2.png", better than anything we would generate. Until then the session is literally named "New session - 2026-08-10T15:44:17.178Z". That window is exactly when a session is most visible: /chat/live shows turns that are RUNNING, so the placeholder is what the panel catches, and a live row was being labelled with a timestamp string. Recognise it and treat it as untitled, so the good name arrives on its own. Passing --title on the run was the other option and is worse: it fixes the transient case by permanently replacing opencode own title with a truncated prompt, degrading it where it lasts longest. A pattern match rather than startsWith, because a genuine title is allowed to begin with those words. Two defects the tests caught while writing them: a whitespace-only title was not treated as unnamed, and the mapping let undefined through where a string was required. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 8 +++--- .../api/chat/opencode-sessions.test.ts | 27 +++++++++++++++++++ src/servers/api/chat/opencode-sessions.ts | 19 ++++++++++++- 3 files changed, 50 insertions(+), 4 deletions(-) create mode 100644 src/servers/api/chat/opencode-sessions.test.ts diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index efffece4..545b00da 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -150,9 +150,11 @@ chatRouter.get('/live', async (ctx) => { isGenerating: true, pendingTasks: 0, harness: 'opencode' as const, - // Still null for the first seconds of a turn: the id is only known once the subprocess prints it. - // That window is real and short, and showing nothing beats showing a key the user has never seen. - title: meta?.title ?? null, + // Null for the first seconds of a turn (the id is only known once the subprocess prints it), and + // null again while OpenCode still calls the session `New session - ` — `listOpenCodeSessions` + // normalises that placeholder to `(untitled)`, and a live row is better blank than named after a + // timestamp. The real title arrives on a later poll, because OpenCode writes it asynchronously. + title: meta?.title && meta.title !== '(untitled)' ? meta.title : null, cwd: meta?.cwd || null, }; }); diff --git a/src/servers/api/chat/opencode-sessions.test.ts b/src/servers/api/chat/opencode-sessions.test.ts new file mode 100644 index 00000000..e41e531b --- /dev/null +++ b/src/servers/api/chat/opencode-sessions.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'bun:test'; +import { isPlaceholderOpenCodeTitle } from './opencode-sessions'; + +// OpenCode names a session `New session - ` until it has titled it from the conversation, which it +// does asynchronously. The strings below are real ones observed on 1.18.16 — both the placeholder and +// the good title it was replaced by. + +describe('isPlaceholderOpenCodeTitle', () => { + it('recognises the placeholder OpenCode uses before it has titled a session', () => { + expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T15:44:17.178Z')).toBe(true); + expect(isPlaceholderOpenCodeTitle('New session - 2026-08-10T12:41:02.757Z')).toBe(true); + }); + + it('treats an absent or empty title as unnamed too', () => { + expect(isPlaceholderOpenCodeTitle(undefined)).toBe(true); + expect(isPlaceholderOpenCodeTitle('')).toBe(true); + expect(isPlaceholderOpenCodeTitle(' ')).toBe(true); + }); + + it('keeps a real title, including one that merely mentions a session', () => { + // The title OpenCode actually gave a turn of ours, and the reason this is a pattern match rather + // than a `startsWith('New session')`: a genuine title is allowed to begin with those words. + expect(isPlaceholderOpenCodeTitle('Single color in oc-red2.png')).toBe(false); + expect(isPlaceholderOpenCodeTitle('New session handling in the runner')).toBe(false); + expect(isPlaceholderOpenCodeTitle('New session - what should it be called?')).toBe(false); + }); +}); diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index 277c19dc..b265974d 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -24,6 +24,23 @@ import { logger } from './logger'; * the `!cwd` escape never fired either and there was no configuration in which an OpenCode session * appeared in /chat. Verified against the live server: 7 sessions present, 0 returned. */ +/** + * OpenCode's own placeholder name for a session it has not titled yet. + * + * It titles a session from the conversation, but ASYNCHRONOUSLY and after there is something to read — + * a finished turn ends up called "Single color in oc-red2.png", which is better than anything we would + * generate. Until then the session is literally named `New session - 2026-08-10T15:44:17.178Z`. + * + * That window is exactly when a session is most visible: `/chat/live` shows turns that are RUNNING, so + * the placeholder is what the panel catches. Treating it as "not yet titled" lets the good name arrive + * on its own. Forcing `--title` on the run instead would fix the transient case by permanently + * replacing opencode's title with a truncated prompt — worse where it lasts longest. + */ +export const isPlaceholderOpenCodeTitle = (title: string | undefined): boolean => { + const trimmed = title?.trim(); + return !trimmed || /^New session - \d{4}-\d{2}-\d{2}T[\d:.]+Z?$/.test(trimmed); +}; + export async function listOpenCodeSessions(cwd?: string): Promise { try { const { baseUrl } = await ensureServer(); @@ -35,7 +52,7 @@ export async function listOpenCodeSessions(cwd?: string): Promise Date: Mon, 10 Aug 2026 17:00:13 +0100 Subject: [PATCH 05/42] name a live opencode row from the prompt until opencode names it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same shape as the claude side, which has never shown a live row without a name. OpenCode titles a session from the conversation and does it well, but asynchronously — so for the whole time a turn is RUNNING, which is exactly what /chat/live shows, the session is still called "New session - ". Its own title wins the moment it exists; until then the row falls back to the prompt that started the session. Kept per sessionKey, first turn only, so it stays the name of the conversation rather than following whatever was asked most recently. Dropped with the session id it sits beside. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 13 +++++++------ src/servers/api/chat/opencode/state.ts | 21 +++++++++++++++++++++ src/servers/api/chat/websocket.ts | 4 ++++ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index 545b00da..afdac3d7 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -21,7 +21,7 @@ import { renameOpenCodeSession, isOpenCodeSessionId, } from './opencode-sessions'; -import { getOpenCodeSession } from './opencode/state'; +import { getOpenCodePrompt, getOpenCodeSession } from './opencode/state'; import { listChatModels } from './list-models'; import { logger } from './logger'; import { readSttConfig } from '../server-settings/stt'; @@ -150,11 +150,12 @@ chatRouter.get('/live', async (ctx) => { isGenerating: true, pendingTasks: 0, harness: 'opencode' as const, - // Null for the first seconds of a turn (the id is only known once the subprocess prints it), and - // null again while OpenCode still calls the session `New session - ` — `listOpenCodeSessions` - // normalises that placeholder to `(untitled)`, and a live row is better blank than named after a - // timestamp. The real title arrives on a later poll, because OpenCode writes it asynchronously. - title: meta?.title && meta.title !== '(untitled)' ? meta.title : null, + // OpenCode's own title wins as soon as it exists — it is derived from the conversation and is + // better than anything we would compose. Until then (and it titles asynchronously, so "until + // then" covers the whole time a turn is RUNNING, which is exactly what this panel shows) fall + // back to the prompt that started the session. Same shape as the Claude side, which has never + // shown a live row without a name. + title: meta?.title && meta.title !== '(untitled)' ? meta.title : (getOpenCodePrompt(sessionKey) ?? null), cwd: meta?.cwd || null, }; }); diff --git a/src/servers/api/chat/opencode/state.ts b/src/servers/api/chat/opencode/state.ts index e7edf05c..8e852c0b 100644 --- a/src/servers/api/chat/opencode/state.ts +++ b/src/servers/api/chat/opencode/state.ts @@ -12,4 +12,25 @@ export const setOpenCodeSession = (sessionKey: string, opencodeSessionId: string export const clearOpenCodeSession = (sessionKey: string): void => { sessionKeyToOpenCode.delete(sessionKey); + sessionKeyToPrompt.delete(sessionKey); }; + +// What the user asked, kept only to name a session OpenCode has not named yet. +// +// OpenCode titles a session from the conversation, and does it well — but asynchronously, so a RUNNING +// turn is called `New session - `, which is precisely when `/chat/live` displays it. This is the +// stand-in for that window: shown while the real title is still the placeholder, and dropped the moment +// OpenCode publishes its own, which is always the better one. +// +// Set on the FIRST turn of a session only, so it stays the name of the conversation rather than +// following whatever was asked most recently. +const sessionKeyToPrompt = new Map(); + +export const rememberOpenCodePrompt = (sessionKey: string, prompt: string): void => { + if (sessionKeyToPrompt.has(sessionKey)) return; + const oneLine = prompt.replace(/\s+/g, ' ').trim(); + if (!oneLine) return; + sessionKeyToPrompt.set(sessionKey, oneLine.length > 60 ? `${oneLine.slice(0, 59)}…` : oneLine); +}; + +export const getOpenCodePrompt = (sessionKey: string): string | undefined => sessionKeyToPrompt.get(sessionKey); diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 7bac312d..ceeccce8 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -10,6 +10,7 @@ import type { UserSession, } from './types'; import { sessionManager } from './session-manager'; +import { rememberOpenCodePrompt } from './opencode/state'; import { sendClaudeCodeStreaming } from '@@/channels/send-claude-code'; import { sendOpenCodeStreaming } from '@@/channels/send-opencode'; import { ensureGeneralChatSessionsCwd } from './claude-sessions'; @@ -407,6 +408,9 @@ async function handleOpenCodeChat( const cwd = await resolveChatCwd(msg, email, userId); + // Names this session in the Live panel until OpenCode gets round to titling it. First turn only. + rememberOpenCodePrompt(sessionId, msg.displayText || msg.prompt); + const groupSlug = msg.groupSlug || null; const session = sessionManager.getOrCreate(sessionId, email, cwd, model, groupSlug, msg.context, msg.contextId); From b07cae142fbf8f1504f966344ec8dfa28d07aa87 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 17:03:17 +0100 Subject: [PATCH 06/42] adopt on a resume even when the harness is a guess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression from my own B7 change, reported within the hour: turns collapsing to "turn completed without output" and coming back only on refresh. B7 stopped resume-cursor defaulting an unidentified session to claude-code. Correct for the durable cut-off row, wrong for adoption: useChat sends model only if modelRef.current is set, so a reconnect without one is routine, not exotic. Declining to adopt left the socket unbound to the live session, so the running turn output went nowhere — and a refresh looked like a fix because it rebuilds from the durable log. Adoption is about DELIVERY and must be generous; only the durable write needs certainty. So adopt on the default again, mark it as an assumption, and skip the cut-off check on it. That keeps B7 fixed — no false "agent went away" written against an opencode session — with no unbound sockets. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/websocket.test.ts | 27 +++++++++++------ src/servers/api/chat/websocket.ts | 42 ++++++++++++++++---------- 2 files changed, 43 insertions(+), 26 deletions(-) diff --git a/src/servers/api/chat/websocket.test.ts b/src/servers/api/chat/websocket.test.ts index 94e1a2fd..a56b780a 100644 --- a/src/servers/api/chat/websocket.test.ts +++ b/src/servers/api/chat/websocket.test.ts @@ -28,17 +28,24 @@ describe('decideResume', () => { expect(decideResume(undefined, 'claude-code')).toEqual({ kind: 'adopt', model: 'claude-code' }); }); - it('stays unknown rather than defaulting to claude, which is the whole bug', () => { - // No record and no claim. Previously this became `claude-code` and was indistinguishable from a real - // Claude session; now it declines to adopt and declines to run the cut-off check. - expect(decideResume(undefined, undefined)).toEqual({ kind: 'replay-only' }); - expect(decideResume(undefined, '')).toEqual({ kind: 'replay-only' }); + it('marks a total unknown as an assumption instead of passing it off as knowledge', () => { + // No record and no claim. This still adopts — see below — but the caller can tell it apart, which is + // what keeps the durable cut-off row off a session nobody has identified. + expect(decideResume(undefined, undefined)).toEqual({ kind: 'assume', model: 'claude-code' }); + expect(decideResume(undefined, '')).toEqual({ kind: 'assume', model: 'claude-code' }); }); - it('never reports replay-only once anything is known, so a live turn is always re-bound', () => { - // The failure mode in the other direction: declining to adopt a session we could have identified - // would silently drop the reconnect that makes turn output resume. - expect(decideResume('claude-code', undefined).kind).toBe('known'); - expect(decideResume('', 'claude-code').kind).toBe('adopt'); + it('always yields a model to adopt with, because an unbound socket misses the whole turn', () => { + // Regression guard. Refusing to adopt when the harness was unknown looked principled and broke the + // app inside an hour: `useChat.ts` only sends `model` `if (modelRef.current)`, so a reconnect + // without one is routine. The socket never re-bound, the live turn's output went nowhere, and the + // transcript collapsed to "turn completed without output" until a refresh rebuilt it from the log. + for (const d of [ + decideResume('claude-code', undefined), + decideResume(undefined, 'opencode/x'), + decideResume(undefined, undefined), + ]) { + expect(d.model).toBeTruthy(); + } }); }); diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index ceeccce8..6429b283 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -626,19 +626,17 @@ async function handleResumeCursor( const known = sessionManager.getSession(sessionId); const decision = decideResume(known?.model, msg.model); - const model = decision.kind === 'replay-only' ? null : decision.model; - if (decision.kind === 'adopt') { + if (decision.kind !== 'known') { + // Adopt on an assumption too: an unbound socket misses the turn entirely, which is worse than a + // harness guess that only ever costs us the cut-off check below. adoptOrphanedSession(ws, sessionId, decision.model, msg.cwd ?? ''); - } else if (decision.kind === 'replay-only') { - // Replay the durable log and stop there. Not adopting costs a live re-subscription; adopting on a - // guess cost correctness — see decideResume. - logger.warn('resume-cursor names a session this process does not know, and no model; replaying only', { - sessionId, - }); + if (decision.kind === 'assume') { + logger.warn('resume-cursor for an unknown session with no model; adopting on the default', { sessionId }); + } } - sessionManager.attachWs(sessionId, ws); // a no-op when adoption was skipped + sessionManager.attachWs(sessionId, ws); wsToSessionMap.set(ws as any, sessionId); try { const events = await getChatEventsSince(sessionId, cursor ?? 0); @@ -648,15 +646,19 @@ async function handleResumeCursor( } catch (err) { logger.error('Failed to replay chat events on resume-cursor', { sessionId, error: String(err) }); } - // Only ask when the harness is actually known. The check writes a DURABLE row, so a wrong answer here - // is permanent — silence is the safe failure. - if (msg.generating && model) await endTurnIfAgentIsGone(ws, sessionId, model); + // Only ask when the harness is actually known — never on an assumption. This check writes a DURABLE + // row, so a wrong answer is permanent: that was B7, where a defaulted `claude-code` made officer ask + // the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went + // away" into a turn that was running perfectly well. + if (msg.generating && decision.kind !== 'assume') { + await endTurnIfAgentIsGone(ws, sessionId, decision.model); + } } export type ResumeDecision = | { kind: 'known'; model: string } | { kind: 'adopt'; model: string } - | { kind: 'replay-only' }; + | { kind: 'assume'; model: string }; /** * Which harness a resuming session belongs to, and what that permits. @@ -677,13 +679,21 @@ export type ResumeDecision = * * Two rules. **The server's own record beats the client's claim** — a session in memory already knows its * harness, and letting a socket re-declare it is how the wrong sidecar gets a session in the first place. - * **An unknown harness stays unknown**: no adoption, no cut-off check, just the replay. Defaulting is - * what made a guess indistinguishable from knowledge. + * **A guess is still a guess**: when neither knows, the session is adopted on the default so delivery + * keeps working, but it is marked `assume` and nothing durable may be written from it. + * + * The middle position — refusing to adopt at all when the harness is unknown — was tried and was WRONG, + * visibly so within the hour. `useChat.ts` sends `model` only `if (modelRef.current)`, so a reconnect + * without one is ordinary, not exotic; declining to adopt left the socket unbound to a live turn, and + * the running turn's output went nowhere. On screen: the transcript collapsed to "turn completed + * without output" and only a refresh — which rebuilds from the durable log — brought it back. + * + * So adoption is about DELIVERY and must be generous. Only the durable write needs certainty. */ export function decideResume(knownModel: string | undefined, claimedModel: string | undefined): ResumeDecision { if (knownModel) return { kind: 'known', model: knownModel }; if (claimedModel) return { kind: 'adopt', model: claimedModel }; - return { kind: 'replay-only' }; + return { kind: 'assume', model: DEFAULT_MODEL }; } /** From f2e38ed9a798966162d0ea0ba2bbed6c51591391 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 17:23:28 +0100 Subject: [PATCH 07/42] serve vaultwarden at officer own host, without officer auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit So the bitwarden browser extension can point here and the separate public vaultwarden hostname can be taken down. /api/vault cannot serve it: that router requires an officer session and REPLACES the caller Authorization header with a server-held vaultwarden token. Right for our own clients — the device then holds no vault credential — and impossible for a third-party client that gets its own token from /identity/connect/token and has nowhere to put a platform JWT. So a separate mount rather than a mode of that router: blending them would put an unauthenticated branch inside the authenticated path. This one forwards Authorization untouched and rewrites nothing. Leaving it open is not a new exposure — everything here was already reachable at the vaultwarden URL it replaces, behind the same master password, and officer cannot add a check it has no credential for. It is also going behind tailscale. Temporary. The end state is our own extension reusing @officer/vault, which already runs as a plain JS bundle outside react native (the iOS autofill extension hosts it in JavaScriptCore), against the /api/vault/session/login broker — then nothing addresses vaultwarden directly and this mount is deleted rather than adjusted. Needed its own entry in server.tsx: only listed paths reach hono and the rest fall through to the SPA, so without it the endpoint answered 200 with the react shell — a missing route that looks like a working one. Co-Authored-By: Claude Opus 5 --- src/server.tsx | 5 ++ src/servers/api/vault/public-router.ts | 78 ++++++++++++++++++++++++++ src/servers/hono.ts | 9 +++ 3 files changed, 92 insertions(+) create mode 100644 src/servers/api/vault/public-router.ts diff --git a/src/server.tsx b/src/server.tsx index b853b98a..fdffcba6 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -248,6 +248,11 @@ const server = serve({ '/.well-known/carddav': honoServer.fetch, '/dav': honoServer.fetch, '/dav/*': honoServer.fetch, + // Vaultwarden for third-party Bitwarden clients, unauthenticated at Officer's layer. Needs its own + // entry for the same reason /dav does: only the paths listed here reach hono, and anything else + // falls through to the SPA — which answers 200 with the React shell, so a missing line here looks + // like a working endpoint returning nonsense rather than a 404. + '/vaultwarden/*': honoServer.fetch, '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, diff --git a/src/servers/api/vault/public-router.ts b/src/servers/api/vault/public-router.ts new file mode 100644 index 00000000..2d0411e3 --- /dev/null +++ b/src/servers/api/vault/public-router.ts @@ -0,0 +1,78 @@ +import { createRouter } from '../../create-router'; +import { getVaultServerUrl } from './sidecar-server'; +import { stripHopByHop, redactPath } from './proxy-util'; + +// Vaultwarden, reachable at Officer's own hostname with NO Officer authentication. +// +// ── Why this exists ── +// +// The Bitwarden browser extension speaks Vaultwarden's protocol and nothing else: it obtains its own +// bearer token from `/identity/connect/token` and has nowhere to put a platform JWT. `/api/vault` cannot +// serve it — that router requires an Officer session and REPLACES the caller's Authorization header with +// a server-held Vaultwarden token, which is the right design for our own clients (the device then holds +// no vault credential at all) and an impossible one for a third-party client. +// +// So this mount exists to retire a second public hostname. Andre pointed the extension straight at +// Vaultwarden; that URL can now go away, and the extension can use the platform's. +// +// ── Why it is acceptable to leave unauthenticated ── +// +// It is not a new exposure, it is the same exposure through one door instead of two: everything here was +// already reachable at the Vaultwarden URL this replaces, guarded by the same thing that guards it now — +// the vault's own master password and Vaultwarden's own auth. Officer adds no security to a Bitwarden +// client today because it cannot: it has no credential to check. +// +// It is also going behind Tailscale, so the surface is a private network rather than the internet. +// +// ── TEMPORARY ── +// +// The intended end state is a browser extension of our own, reusing `@officer/vault` (already proven to +// run as a plain JS bundle outside React Native — the iOS autofill extension hosts it in JavaScriptCore) +// against the `/api/vault/session/login` broker. Then the client authenticates to OFFICER, Vaultwarden is +// never addressed directly by anything, and this mount is deleted rather than adjusted. +// +// ── The rule that keeps it safe ── +// +// DUMB PASS-THROUGH, and more so than `/api/vault`: that one rewrites the Authorization header, this one +// touches nothing. Method, path, query, headers, status and both body streams go through verbatim. The +// vault is end-to-end encrypted and no part of this may parse, decrypt, buffer, cache or log a body — +// paths are redacted in logs for the same reason. Every line of "cleverness" added here is reachable +// without an Officer session, which is the whole reason it must stay boring. + +export const publicVaultRouter = createRouter(); + +const PREFIX = '/vaultwarden'; + +publicVaultRouter.all('/*', async (ctx) => { + const base = getVaultServerUrl(); + if (!base) return ctx.text('Vault sidecar not available', 503); + + const url = new URL(ctx.req.url); + const subpath = url.pathname.slice(PREFIX.length) || '/'; + const target = `${base}${subpath}${url.search}`; + const method = ctx.req.method; + const hasBody = method !== 'GET' && method !== 'HEAD'; + + // Authorization is FORWARDED, not replaced — the opposite of `/api/vault`. The client's Vaultwarden + // token is the only credential in play, and Vaultwarden is the only thing entitled to judge it. + const headers = stripHopByHop(ctx.req.raw.headers); + + const init: RequestInit & { duplex?: 'half' } = { method, headers, redirect: 'manual' }; + if (hasBody) { + init.body = ctx.req.raw.body; + init.duplex = 'half'; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch { + console.error(`[vault:public] ${method} ${redactPath(subpath + url.search)} -> sidecar unreachable`); + return ctx.text('Vault sidecar unreachable', 502); + } + + // No 401-refresh-and-retry here, deliberately. That belongs to `/api/vault`, which owns the token it + // injected; this router holds no token and a 401 is Vaultwarden's answer to the CLIENT, which the + // client knows how to handle — it re-authenticates with the master password. + return new Response(upstream.body, { status: upstream.status, headers: stripHopByHop(upstream.headers) }); +}); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 99a8e447..ff96e9ae 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -23,6 +23,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; +import { publicVaultRouter } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; @@ -103,6 +104,14 @@ honoServer.route('/api/waitlist', waitlistRouter); // gating still applies via originScopeMiddleware above (OFFICER_VAULT_ORIGIN → /api/vault). The // notifications WebSocket is upgraded at the serve level (server.tsx). honoServer.route('/api/vault', vaultRouter); + +// The same Vaultwarden, with NO Officer authentication, so the Bitwarden browser extension can point at +// this host instead of at a second public hostname for Vaultwarden. Deliberately its own mount rather +// than a mode of the router above: that one requires an Officer session and swaps the caller's +// Authorization header for a server-held token, and blending the two would put an unauthenticated branch +// inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it +// open is not a new exposure. +honoServer.route('/vaultwarden', publicVaultRouter); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); // Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude From 6e9a8ee42ffab8ebfd5804d7d836e454dfd933b9 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 17:27:57 +0100 Subject: [PATCH 08/42] let the extension use the bare officer url, no path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the /vaultwarden mount: the suffix is superfluous if officer can tell a bitwarden client apart, and it can. Most of vaultwarden surface does not collide at all — /identity, /notifications, /icons and /events belong to it and to nothing here, so those are served at the root by path alone, no sniffing. Only /api collides (vaultwarden has /api/settings/domains, officer has /api/settings), and there the client says who it is: every bitwarden client stamps Bitwarden-Client-Name, older ones Device-Type. Trusting a client header is fine because this is ROUTING, not authentication — the worst a forged one achieves is reaching vaultwarden, which then demands its own credential exactly as it would have. Nothing is authorised by it. Registered before /api so it wins for a bitwarden client, and narrow enough that an ordinary officer request never matches. Verified: /identity reaches the proxy, /api/sync with the header diverts, /api/chat/models without it still answers 401 from officer, and the SPA is untouched. /vaultwarden still works for anything that prefers an explicit path. Co-Authored-By: Claude Opus 5 --- src/server.tsx | 7 ++++++ src/servers/api/vault/public-router.ts | 34 +++++++++++++++++++++++++- src/servers/hono.ts | 15 +++++++++++- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/src/server.tsx b/src/server.tsx index fdffcba6..e1b5d676 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -253,6 +253,13 @@ const server = serve({ // falls through to the SPA — which answers 200 with the React shell, so a missing line here looks // like a working endpoint returning nonsense rather than a 404. '/vaultwarden/*': honoServer.fetch, + // The same proxy at the root, so the extension needs only the bare Officer URL. These four prefixes + // are Vaultwarden's alone — nothing in Officer answers on them — so routing them here costs nothing. + // `/api/*` already reaches hono below, where a Bitwarden client header diverts it. + '/identity/*': honoServer.fetch, + '/notifications/*': honoServer.fetch, + '/icons/*': honoServer.fetch, + '/events/*': honoServer.fetch, '/': officerWeb, '/*': officerWeb, '/api': honoServer.fetch, diff --git a/src/servers/api/vault/public-router.ts b/src/servers/api/vault/public-router.ts index 2d0411e3..a9c34b86 100644 --- a/src/servers/api/vault/public-router.ts +++ b/src/servers/api/vault/public-router.ts @@ -43,12 +43,44 @@ export const publicVaultRouter = createRouter(); const PREFIX = '/vaultwarden'; +/** + * Paths that belong to Vaultwarden and to nothing else in this platform. + * + * These are served at the ROOT so the extension can be configured with the bare Officer URL — no + * `/vaultwarden` suffix — which is the point: one hostname, one URL, nothing to explain. None of them + * collide with an Officer route, so no sniffing is involved; the path alone is the answer. + */ +export const VAULT_ONLY_PREFIXES = ['/identity', '/notifications', '/icons', '/events']; + +/** + * Is this a Bitwarden client talking to us? + * + * Needed only for `/api/*`, which is the one namespace both servers claim — Vaultwarden has + * `/api/sync`, `/api/ciphers`, `/api/settings/domains`; Officer has `/api/chat`, `/api/settings` and + * everything else. The path cannot decide it, so the client says who it is: every Bitwarden client + * stamps `Bitwarden-Client-Name` (`browser`, `desktop`, `cli`, `web`), and older ones `Device-Type`. + * + * This is ROUTING, not authentication, which is why trusting a client-supplied header is fine here: the + * worst a forged header achieves is reaching Vaultwarden, which then demands its own credential exactly + * as it would have. Nothing is authorised by this function. + */ +export const isBitwardenClient = (headers: Headers): boolean => + headers.has('bitwarden-client-name') || headers.has('bitwarden-client-version') || headers.has('device-type'); + +/** Everything Vaultwarden serves, whichever door it arrived through. */ +export const isVaultwardenPath = (path: string, headers: Headers): boolean => { + if (VAULT_ONLY_PREFIXES.some((p) => path === p || path.startsWith(`${p}/`))) return true; + return path.startsWith('/api/') && isBitwardenClient(headers); +}; + publicVaultRouter.all('/*', async (ctx) => { const base = getVaultServerUrl(); if (!base) return ctx.text('Vault sidecar not available', 503); const url = new URL(ctx.req.url); - const subpath = url.pathname.slice(PREFIX.length) || '/'; + // Mounted at `/vaultwarden` AND at the root prefixes above. Strip the prefix only when it is there, + // so `/vaultwarden/identity/...` and a bare `/identity/...` both reach Vaultwarden's own path. + const subpath = (url.pathname.startsWith(PREFIX) ? url.pathname.slice(PREFIX.length) : url.pathname) || '/'; const target = `${base}${subpath}${url.search}`; const method = ctx.req.method; const hasBody = method !== 'GET' && method !== 'HEAD'; diff --git a/src/servers/hono.ts b/src/servers/hono.ts index ff96e9ae..e5b75a81 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -23,7 +23,7 @@ import { taskLogsRouter } from './api/task-logs/task-logs'; import { router as fileBrowserRouter } from './api/file-browser/router'; import { musicRouter } from './api/music/router'; import { vaultRouter } from './api/vault/router'; -import { publicVaultRouter } from './api/vault/public-router'; +import { publicVaultRouter, VAULT_ONLY_PREFIXES, isBitwardenClient } from './api/vault/public-router'; import { agentHandoffRouter } from './api/agent-handoff/router'; import { slskdRouter } from './api/slskd/router'; import { headscaleRouter } from './api/headscale/router'; @@ -112,6 +112,19 @@ honoServer.route('/api/vault', vaultRouter); // inside the authenticated path. Temporary — see public-router.ts for what replaces it and why leaving it // open is not a new exposure. honoServer.route('/vaultwarden', publicVaultRouter); + +// …and at the ROOT, so the extension can be pointed at the bare Officer URL with no path at all. +// +// Registered BEFORE `/api` is mounted, because hono matches in registration order and this has to win +// for a Bitwarden client. It is deliberately narrow: the four prefixes below belong to Vaultwarden and +// to nothing else here, and `/api/*` is diverted ONLY when the request carries a Bitwarden client +// header. An ordinary Officer request never matches, so nothing that worked before changes. +for (const prefix of VAULT_ONLY_PREFIXES) honoServer.route(prefix, publicVaultRouter); + +honoServer.use('/api/*', async (ctx, next) => { + if (!isBitwardenClient(ctx.req.raw.headers)) return next(); + return publicVaultRouter.fetch(ctx.req.raw, ctx.env); +}); honoServer.get('/api/integrations/google/callback', googleCallbackHandler); // Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude From ecb9025f8f16fb24a6a5ee74739cc0c9731a46ae Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 17:37:07 +0100 Subject: [PATCH 09/42] the fork blocker was a missing credential, not an upstream bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Andre said his terminal opencode reaches paid zen models and suggested it was simply not set up here. Correct, and my second wrong call on this page. The new /api pipeline has its own credential store — /api/integration and /api/credential — separate from auth.json, which is what the CLI, opencode run and the legacy /session surface read. Ours had none connected, so it fell back to what needs no credential: the free tier. One POST to /api/integration/opencode/connect/key fixes it, and it survives a serve restart. sonnet and haiku both run on the new pipeline now. The tell I had and did not use: the configured default is big-pickle, and a session with no model ran on ling-3.0-tiny-free INSTEAD of the default. A pipeline ignoring its configured default cannot use it — a credential symptom, sitting in /config/providers the whole time. So steer, queue, interrupt and the resumable per-session SSE are all available with real models. alpha needs the same one-time connect, and the sidecar should do it at boot rather than depend on someone having run it by hand. Co-Authored-By: Claude Opus 5 --- docs/opencode-fork-decision.md | 45 ++++++++++++++++++++++++++++++++-- docs/opencode-parity.md | 2 +- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/opencode-fork-decision.md b/docs/opencode-fork-decision.md index d1669fef..14f4ab4b 100644 --- a/docs/opencode-fork-decision.md +++ b/docs/opencode-fork-decision.md @@ -5,7 +5,8 @@ > The conclusion is reversed below. The mistake is written up rather than deleted, because the shape of > it is the useful part. -**Decision: the fork is worth taking. Not started, and gated on one open question.** +**Decision: the fork is worth taking, and it is no longer blocked.** Not started; the blocker turned out +to be a missing credential and is fixed — see RESOLVED below. --- @@ -41,7 +42,47 @@ Steer and queue are exactly the two features we hand-built for Claude and that A changer. Having them as primitives, plus a resumable per-session cursor that mirrors officer's durable replay, is a strong argument for migrating. -## The blocker, now characterised: only free models run +## RESOLVED — it was a missing credential, not a bug + +Andre said he had a paid Zen key working in his terminal and suggested it simply was not set up here. He +was right, and this is the second wrong conclusion I reached on this page. + +**The new pipeline has its own credential store, separate from `auth.json`.** `opencode run`, the CLI and +the legacy `/session` surface all read `~/.local/share/opencode/auth.json`, which holds the Zen key — so +they reach paid models. The `/api/*` surface reads integrations instead (`/api/integration`, +`/api/credential`), and ours had **none connected**. With no credential it silently fell back to what +needs none, which is exactly the free tier. + +The fix was one call, and it persists across a serve restart (verified — a paid model still ran after +`pm2 restart officer-opencode`): + +``` +POST /api/integration/opencode/connect/key { "key": "", "label": "…" } → 204 +``` + +Afterwards `claude-sonnet-4-6` and `claude-haiku-4-5` both run on the new pipeline. **The fork is +unblocked**, and everything the table above promises — steer, queue, interrupt, resumable per-session +SSE — is available with real models. + +Two consequences worth carrying: + +- **alpha needs the same one-time connect** before it can use the new pipeline. +- The sidecar should do this itself at boot rather than relying on someone having run it by hand, since + a missing credential degrades to "only free models work" with no error anywhere. + +### What the evidence looked like while I was getting it wrong + +Recorded because the shape repeats: the failure was **silent and total** for paid models, and the cost +table drew a perfect line — every cost-0 model ran, every cost>0 model did not. I read that as a billing +boundary inside a broken pipeline. It was a billing boundary caused by an absent credential, which is a +far more ordinary explanation and one Andre reached from knowing his own setup rather than from the API. + +The tell I had and did not use: the configured default is `opencode/big-pickle`, and a session created +with no model ran on `ling-3.0-tiny-free` **instead of the default**. A pipeline ignoring its configured +default is a pipeline that cannot use it — that is a credential symptom, and it was sitting in the +`/config/providers` output the whole time. + +## The original diagnosis, kept for the record: only free models run It is not sonnet, and it is not `variant`. Swept four models through `POST /api/session/{id}/model` followed by a prompt: diff --git a/docs/opencode-parity.md b/docs/opencode-parity.md index a4e28387..669f9370 100644 --- a/docs/opencode-parity.md +++ b/docs/opencode-parity.md @@ -91,7 +91,7 @@ is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork. -**The fork is REOPENED and worth taking.** The serve publishes a newer `/api/session/*` surface offering +**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface From 35970146bb1a803e184b70b6f0b287a67cd2a825 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 18:03:04 +0100 Subject: [PATCH 10/42] connect the opencode credential at sidecar boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../sidecar/opencode/connect-credential.ts | 99 +++++++++++++++++++ src/servers/sidecar/opencode/index.ts | 6 ++ 2 files changed, 105 insertions(+) create mode 100644 src/servers/sidecar/opencode/connect-credential.ts diff --git a/src/servers/sidecar/opencode/connect-credential.ts b/src/servers/sidecar/opencode/connect-credential.ts new file mode 100644 index 00000000..dbc2a3a3 --- /dev/null +++ b/src/servers/sidecar/opencode/connect-credential.ts @@ -0,0 +1,99 @@ +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +// Hand the serve's NEW api surface the provider key it cannot find on its own. +// +// ── The problem this exists to prevent ── +// +// opencode keeps credentials in two unrelated places. The CLI, `opencode run` and the legacy +// `/session/*` surface read `~/.local/share/opencode/auth.json`. The newer `/api/session/*` surface — +// the one with `delivery: "steer" | "queue"`, `/interrupt` and a resumable per-session event stream — +// reads its own integration store instead (`/api/integration`, `/api/credential`), and knows nothing +// about that file. +// +// With no credential the new pipeline does not fail. It falls back to whatever needs none, which is the +// free tier, and a request for a paid model is simply never executed: prompt accepted, `prompt.admitted` +// and `prompted` emitted, no step, no error, no assistant message, forever. That silence cost most of an +// afternoon to diagnose (docs/opencode-fork-decision.md) and would cost it again on every new machine. +// +// So the sidecar connects it at start-up rather than relying on somebody having run a curl by hand. +// +// ── Deliberately best-effort ── +// +// Never throws and never blocks start-up. Turns run through `opencode run`, which uses `auth.json` and +// is unaffected by any of this; failing here costs the new pipeline only, and the sidecar is far more +// useful up than down. The connection persists in opencode's own store, so this is a no-op on every +// start after the first. + +const AUTH_PATH = join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'opencode', 'auth.json'); + +const ATTEMPTS = 6; +const RETRY_DELAY_MS = 1_500; + +/** The provider key opencode already holds for itself, or null. Never logged, never returned to callers. */ +function readProviderKey(providerId: string): string | null { + try { + const auth = JSON.parse(readFileSync(AUTH_PATH, 'utf8')) as Record; + const entry = auth[providerId]; + return entry?.type === 'api' && typeof entry.key === 'string' && entry.key ? entry.key : null; + } catch { + return null; // no auth file, unreadable, or not JSON — nothing to connect + } +} + +/** + * Connect `auth.json`'s key for one provider to the serve's integration store. + * + * `providerId` doubles as the integration id: opencode names them the same, so the Zen key stored under + * `opencode` connects to integration `opencode`. + */ +export async function connectProviderCredential(baseUrl: string, providerId = 'opencode'): Promise { + const key = readProviderKey(providerId); + if (!key) { + console.log(`[opencode] no ${providerId} key in auth.json; the new API surface will only reach free models`); + return; + } + + // Retried, because `/api/health` answers before the integration store is ready: connecting immediately + // after the health check returns 500, and the identical request succeeds seconds later. Measured, not + // assumed — the first version of this shipped without the retry and failed on its first real boot. + // + // Only 5xx is retried. A 4xx means the request itself is wrong (bad key, unknown integration) and + // repeating it just prints the same complaint five times. + for (let attempt = 1; attempt <= ATTEMPTS; attempt++) { + try { + const res = await fetch(`${baseUrl}/api/integration/${providerId}/connect/key`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ key, label: 'officer-opencode sidecar' }), + signal: AbortSignal.timeout(10_000), + }); + + if (res.ok) { + console.log(`[opencode] connected the ${providerId} credential to the api surface`); + return; + } + // The body is deliberately not logged: a credential endpoint's error may quote what it was given. + if (res.status < 500) { + console.error(`[opencode] could not connect the ${providerId} credential: HTTP ${res.status}`); + return; + } + if (attempt === ATTEMPTS) { + console.error( + `[opencode] could not connect the ${providerId} credential after ${ATTEMPTS} attempts: HTTP ${res.status}`, + ); + return; + } + } catch (err) { + if (attempt === ATTEMPTS) { + console.error( + `[opencode] could not connect the ${providerId} credential:`, + err instanceof Error ? err.message : err, + ); + return; + } + } + await Bun.sleep(RETRY_DELAY_MS); + } +} diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index bb2aee96..93c12314 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { DATA_PATH } from '../../data-path'; import { createSidecarConnector } from '../connect'; import { sweepRecordedServe } from './serve-sweep'; +import { connectProviderCredential } from './connect-credential'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner'; @@ -132,6 +133,11 @@ if (!(await waitHealthy(baseUrl, HEALTH_TIMEOUT_MS))) { } console.log(`[opencode] serve healthy on port ${port}`); +// The new /api surface keeps credentials separately from auth.json and would otherwise reach free models +// only — silently. Best-effort and not awaited for correctness: turns go through `opencode run`, which +// reads auth.json directly and does not depend on this. +void connectProviderCredential(baseUrl); + // ── Command handlers ── type ReplyFn = (msg: SidecarEvent) => void; From 86fd03b35b0f18c5e9fb50a56273404795a7e8cc Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 18:16:37 +0100 Subject: [PATCH 11/42] say a missing opencode binary out loud instead of hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/opencode-parity.md | 5 ++++ src/servers/sidecar/opencode/runner.test.ts | 22 +++++++++++++++ src/servers/sidecar/opencode/runner.ts | 30 ++++++++++++++++----- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/opencode-parity.md b/docs/opencode-parity.md index 669f9370..6d9079ee 100644 --- a/docs/opencode-parity.md +++ b/docs/opencode-parity.md @@ -102,6 +102,11 @@ passed that one model. Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on 1.18.16. +**`messageCount` is a non-issue, not a gap.** `SessionList.tsx:197-203` renders an `OpenCode` badge in +place of the count for OpenCode rows, so the hardcoded `0` is never displayed. Computing a real count +would cost one HTTP call per listed session — the session record carries no count field — to populate +something nothing renders. Left alone deliberately. + **Images are done, and they never needed the fork** (bucket 1 lists them as "No — see B4", and Phase 4 put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path carries them today: the sidecar spills each image to a temp file for the turn and removes it in diff --git a/src/servers/sidecar/opencode/runner.test.ts b/src/servers/sidecar/opencode/runner.test.ts index 6591db45..b5b438ce 100644 --- a/src/servers/sidecar/opencode/runner.test.ts +++ b/src/servers/sidecar/opencode/runner.test.ts @@ -273,6 +273,28 @@ describe('runOpenCodeTurn — a second turn on a live session', () => { expect(argv.at(-1)).toBe('plain'); }); + it('reports a missing binary instead of throwing out of the handler', async () => { + // `Bun.spawn` throws on ENOENT rather than returning a failed process, and that throw used to escape + // `runOpenCodeTurn` before any event was emitted — so the browser kept a spinner nothing could end. + // A wrong OPENCODE_BIN is the ordinary way to get here. + const messages: RunnerMessage[] = []; + expect(() => + runOpenCodeTurn( + { sessionKey: 'sess-nobin', prompt: 'hi', cwd: stubDir }, + { ...CONFIG, bin: join(stubDir, 'nope') }, + (m) => messages.push(m), + ), + ).not.toThrow(); + + const errors = messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error'); + expect(errors).toHaveLength(1); + // The path is in the message: this is nearly always a misconfiguration, and naming the binary it + // tried is the difference between a fix and a debugging session. + expect(JSON.stringify(errors[0])).toContain('nope'); + // And it must not leave a phantom entry behind for the Live panel or the stop button. + expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey: 'sess-nobin' }); + }); + it('still reports a turn that dies on its own, rather than swallowing every exit', async () => { // The guard must not overreach: an ordinary failure is still an error the user needs to see. const sessionKey = 'sess-solo'; diff --git a/src/servers/sidecar/opencode/runner.ts b/src/servers/sidecar/opencode/runner.ts index 775f27c5..9ee005f0 100644 --- a/src/servers/sidecar/opencode/runner.ts +++ b/src/servers/sidecar/opencode/runner.ts @@ -114,12 +114,30 @@ export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd; - const proc = Bun.spawn([config.bin, ...args], { - cwd, - stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise - stdout: 'pipe', - stderr: 'pipe', - }); + // `Bun.spawn` THROWS on a missing or non-executable binary rather than resolving to a failed process, + // and that throw used to escape `runOpenCodeTurn` entirely: past the session bookkeeping below, out of + // the sidecar's command handler, with no `opencode:event` ever emitted. The browser sat on a spinner + // that nothing would ever end, because the code that ends turns had not been reached yet. + // + // A wrong `OPENCODE_BIN` is the ordinary cause, and it deserves to say so on screen instead of hanging. + let proc: Subprocess; + try { + proc = Bun.spawn([config.bin, ...args], { + cwd, + stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise + stdout: 'pipe', + stderr: 'pipe', + }); + } catch (err) { + cleanUpTurnImages(imagePaths); + const reason = err instanceof Error ? err.message : String(err); + emit({ + type: 'opencode:event', + sessionKey, + event: { type: 'error', message: `Could not start OpenCode (${config.bin}): ${reason}` }, + }); + return; + } // `finish` is a placeholder for the few synchronous lines until the real one below exists — it closes // over `handle`, so the two cannot both be defined first. Nothing can call it in between. From 38ebe168f01f1121c6721310811402bda687c263 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 18:27:23 +0100 Subject: [PATCH 12/42] retire two phantom gaps, and plan the migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Crash-recovery state is not a gap: state:sync goes to the proxy capability and carries proxySecret, and syncState/getCachedState have no callers at all. The row compared opencode against a mechanism officer never consults. The real recovery story now exists and is better — a sidecar restart stops in-flight turns and writes the reason to chat_session_events. Identity is deferred, not forgotten: TODO.md already records it, and chat is kind execution, which the grants API refuses to share at any level, so no member can reach it. Also adds the serve migration plan, written while the facts are fresh and nothing is on fire. It leads with the five things that will bite whoever implements it — per-request location, the data wrapper, delivery defaulting to steer, silent failure on an unconnected credential, and the session.next event names — because none of them are in the API docs and each cost time to find today. Phased so the old path stays one config flip away, and so warm-session lifetime (idle GC, orphan adoption, the supersede race) is imported deliberately rather than discovered. Co-Authored-By: Claude Opus 5 --- docs/opencode-parity.md | 14 ++++- docs/opencode-serve-migration-plan.md | 86 +++++++++++++++++++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 docs/opencode-serve-migration-plan.md diff --git a/docs/opencode-parity.md b/docs/opencode-parity.md index 6d9079ee..39405bd0 100644 --- a/docs/opencode-parity.md +++ b/docs/opencode-parity.md @@ -102,6 +102,18 @@ passed that one model. Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on 1.18.16. +**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries +`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` / +`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode +against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar +restart stops in-flight turns and writes the reason to `chat_session_events`, and `/chat/live` +enumerates what is running. + +**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault` +and `opencode` receive no identity and are covered today only because those capabilities are owner-only — +"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses +to share at any level, so this cannot be reached by a member. It is latent by construction. + **`messageCount` is a non-issue, not a gap.** `SessionList.tsx:197-203` renders an `OpenCode` badge in place of the count for OpenCode rows, so the hardcoded `0` is never displayed. Computing a real count would cost one HTTP call per listed session — the session record carries no count field — to populate @@ -135,7 +147,7 @@ Ordered roughly by user-visible value. | MCP tools | `--mcp-config` | **Nothing** — no MCP anywhere in the OpenCode path | No | | `messageCount` on the list | from the transcript | hardcoded `0` | No | | Idle GC / warm-session lifetime | 30-min heartbeat, task-aware | N/A — nothing warm to collect | **Yes** | -| Crash-recovery state on disk | `claude-state.json` | **None** — `state:sync` returns an error | No | +| Crash-recovery state on disk | `claude-state.json` | **not a gap — see below** | No | | Identity | validates `X-Officer-User` | **None** — flagged in `TODO.md:42-47` | No | | Tests | 4 test files on the pure pieces | **Zero** | No | diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md new file mode 100644 index 00000000..8bd94267 --- /dev/null +++ b/docs/opencode-serve-migration-plan.md @@ -0,0 +1,86 @@ +# Moving OpenCode turns onto the serve — the plan + +Written 2026-08-10, after the fork was unblocked (`docs/opencode-fork-decision.md`). **Nothing here is +implemented.** It exists so the work can start from verified facts rather than from the API docs, which +have been wrong or misleading three times on this path. + +Andre should read "What changes for the user" and "The risk I would not take blind" before this starts. + +--- + +## What we are moving from and to + +Today every turn is `opencode run --dir --format json`, a subprocess with `stdin: 'ignore'`. It +works, it is verified end to end, and its limits are all consequences of that one closed pipe. + +The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16: + +| Capability | How | Verified | +| ------------------------- | -------------------------------------------------------- | -------- | +| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn | +| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors | +| Token streaming | `GET /event` → `text/event-stream` | yes — `text.started`/`text.ended` observed | +| Reconnect + replay | same, `?after=` | yes — replayed a finished session | +| Interrupt, session lives | `POST /interrupt` → 204 | endpoint only, not exercised | +| Model selection | `POST /model` → 204 | yes — runs on the chosen model | +| Images | `prompt.files` | not exercised (we have images via `--file` already) | + +## Facts that will bite whoever implements this + +Each of these cost time to find. None is in the API docs. + +1. **The location is per REQUEST, not per session.** `x-opencode-directory: ` header, or + `?location[directory]=` as a deepObject query. A session created with `location` in the body and then + prompted without the header does not behave. +2. **Responses wrap in `{"data": …}`** on this surface; the legacy `/session/*` returns bare objects. + Reading `body.id` instead of `body.data.id` yields `undefined` silently. +3. **`delivery` defaults to `"steer"`.** Omitting it injects into a running turn, which is NOT the safe + default for an ordinary "send" — it must be set explicitly per intent. +4. **A model with no connected credential fails silently.** Prompt admitted, `prompt.admitted` and + `prompted` emitted, then nothing, forever. The sidecar now connects the credential at boot + (`connect-credential.ts`), and this failure mode is why that exists. +5. **The event names are `session.next.*`** — `step.started`, `text.started`, `text.ended`, + `tool.called`, `tool.success`, `step.ended`, `step.failed`. Not the shapes `mapRunLine` handles. + +## Shape of the work + +**Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing +runner: subscribe to `/api/session/{id}/event`, map `session.next.*` → `ChatEvent`, and prove the mapping +against real turns. Do not route any user traffic through it. This is where `mapRunLine`'s successor gets +written and tested, and it is the only phase with no user-visible risk. + +**Phase B — turns through the serve, behind a switch.** `POST /prompt` for the turn, events from Phase A, +`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the +known-good path. The switch is the deliverable, not a detail. + +**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now" +button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins +and they are cheap once B holds. + +**Phase D — retire the subprocess**, only after B has run for a while. Deleting it early converts every +future problem into an emergency. + +## What changes for the user + +Better: text appears as it is generated instead of in blocks; the queue and "send now" work on OpenCode +exactly as they do on Claude; stop interrupts without destroying the session. + +Worse, potentially: the serve becomes load-bearing. Today a serve crash costs session listing and nothing +else, because turns are subprocesses. After this it costs every turn in flight. That trade is the whole +decision. + +## The risk I would not take blind + +**Warm sessions bring a lifetime problem OpenCode does not currently have.** A subprocess ends when the +turn ends; there is nothing to garbage-collect, adopt after a restart, or leak. A serve session persists, +so this migration imports the entire class of problems the Claude path spent months getting right — idle +GC, orphan adoption, releasing versus killing, the supersede race I fixed this morning. + +That is not an argument against doing it. It is an argument for Phase B keeping the old path one config +flip away, and for not doing Phase D on the same day as Phase B. + +## Where to start + +Phase A, `runner.ts`'s sibling, with the `session.next.*` fixtures captured from a real turn rather than +hand-written — `docs/opencode-fork-decision.md` records how to drive one with plain `curl`, and +`runner.test.ts` is the pattern for pinning a mapping without spawning anything. From feb901009763e5a381633841d923d8d8101a25b0 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 18:33:33 +0100 Subject: [PATCH 13/42] phase A: map the serve event stream, routed nowhere MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/opencode-serve-migration-plan.md | 34 ++- .../sidecar/opencode/serve-events.test.ts | 196 ++++++++++++++++ src/servers/sidecar/opencode/serve-events.ts | 221 ++++++++++++++++++ 3 files changed, 442 insertions(+), 9 deletions(-) create mode 100644 src/servers/sidecar/opencode/serve-events.test.ts create mode 100644 src/servers/sidecar/opencode/serve-events.ts diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md index 8bd94267..594b6ba0 100644 --- a/docs/opencode-serve-migration-plan.md +++ b/docs/opencode-serve-migration-plan.md @@ -15,15 +15,31 @@ works, it is verified end to end, and its limits are all consequences of that on The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16: -| Capability | How | Verified | -| ------------------------- | -------------------------------------------------------- | -------- | -| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn | -| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors | -| Token streaming | `GET /event` → `text/event-stream` | yes — `text.started`/`text.ended` observed | -| Reconnect + replay | same, `?after=` | yes — replayed a finished session | -| Interrupt, session lives | `POST /interrupt` → 204 | endpoint only, not exercised | -| Model selection | `POST /model` → 204 | yes — runs on the chosen model | -| Images | `prompt.files` | not exercised (we have images via `--file` already) | +| Capability | How | Verified | +| ------------------------ | --------------------------------------------------- | --------------------------------------------------- | +| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn | +| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors | +| Token streaming | `GET /api/event` (GLOBAL, live) — `text.delta` | yes — deltas reassemble to the committed text | +| Reconnect + replay | `GET /api/session/{id}/event?after=` (durable) | yes — replayed a finished session | +| Interrupt, session lives | `POST /interrupt` → 204 | endpoint only, not exercised | +| Model selection | `POST /model` → 204 | yes — runs on the chosen model | +| Images | `prompt.files` | not exercised (we have images via `--file` already) | + +## There are TWO streams, and this is the thing to get right + +Corrected after Phase A; the table above originally implied one. The serve publishes each turn twice: + +- **`GET /api/session/{id}/event?after=`** — durable, per session, replayable, every event carrying + `durable.seq`. Whole values only (`text.ended` with the full text). **No deltas.** +- **`GET /api/event`** — live, **global**, ephemeral. Carries `text.delta` and `tool.input.delta`. No cursor. + +Measured on one real turn: 13 events durable, 21 live, the difference being 3 `text.delta` and 5 +`tool.input.delta`. **Reading only the per-session stream — which is what I did first — makes it look +like the serve cannot stream at all**, and would have quietly removed the main reason to migrate. + +The split maps exactly onto what officer already does for Claude: durable → `chat_session_events`, live → +UI deltas. The cost is that the live stream is GLOBAL, so a consumer must filter on `sessionID` and +cannot assume it owns the socket. ## Facts that will bite whoever implements this diff --git a/src/servers/sidecar/opencode/serve-events.test.ts b/src/servers/sidecar/opencode/serve-events.test.ts new file mode 100644 index 00000000..5391ff25 --- /dev/null +++ b/src/servers/sidecar/opencode/serve-events.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'bun:test'; +import { isKnownServeEvent, mapServeEvent } from './serve-events'; + +// Fixtures are VERBATIM captures from opencode 1.18.16 — one real turn that ran `echo hello-from-tool` +// through the bash tool and then answered in prose. Ids and timestamps are as they arrived. +// +// They are real for the same reason `runner.test.ts`'s are: this mapping's only job is to match a shape +// nobody documents, so a hand-written fixture would test my imagination rather than the binary. If one +// of these fails after an upgrade, re-capture before editing the expectation. + +const SESSION = 'ses_01346bde5ffeB4hWdcQiq1BQUG'; +const ASSISTANT = 'msg_fecb96b3e0010o8smEyjxT2noD'; +const CALL = 'toolu_01TyLVFSSXus2eoyANdjieWW'; + +describe('mapServeEvent — text', () => { + it('turns a text delta into a delta, because that is what makes output appear as it is typed', () => { + const out = mapServeEvent({ + type: 'session.next.text.delta', + data: { timestamp: 1, sessionID: SESSION, assistantMessageID: ASSISTANT, textID: 'text-0', delta: 'It' }, + } as never); + + expect(out?.events).toEqual([{ type: 'delta', text: 'It' }]); + expect(out?.sessionId).toBe(SESSION); + }); + + it('turns the ended text into the committed block', () => { + const out = mapServeEvent({ + type: 'session.next.text.ended', + data: { sessionID: SESSION, textID: 'text-0', text: 'It printed **hello-from-tool**.' }, + } as never); + + expect(out?.events).toEqual([{ type: 'text', text: 'It printed **hello-from-tool**.' }]); + }); + + it('says nothing for text.started, which carries no text at all', () => { + // Verified against the capture: `text.started` has `{textID}` and nothing else. Emitting an empty + // text here would put a blank assistant bubble on screen before a single token arrived. + const out = mapServeEvent({ + type: 'session.next.text.started', + data: { sessionID: SESSION, textID: 'text-0' }, + } as never); + expect(out?.events).toEqual([]); + }); + + it('drops an empty delta rather than emitting nothing-shaped events', () => { + expect(mapServeEvent({ type: 'session.next.text.delta', data: { delta: '' } } as never)?.events).toEqual([]); + }); +}); + +describe('mapServeEvent — tools', () => { + it('starts the tool row on tool.called, where the input is finally a real object', () => { + const out = mapServeEvent({ + type: 'session.next.tool.called', + data: { + sessionID: SESSION, + assistantMessageID: ASSISTANT, + callID: CALL, + tool: 'bash', + input: { command: 'echo hello-from-tool' }, + }, + } as never); + + expect(out?.events).toEqual([ + { type: 'tool:start', toolCallId: CALL, toolName: 'bash', toolInput: { command: 'echo hello-from-tool' } }, + ]); + }); + + it('stays silent through the input stream, which is half-parsed JSON', () => { + // This is the reason `tool:start` waits for `tool.called`. The real delta below is a fragment of a + // JSON object; rendering a tool row with `{"comman` as its arguments would be worse than waiting. + const started = mapServeEvent({ + type: 'session.next.tool.input.started', + data: { sessionID: SESSION, callID: CALL, name: 'bash' }, + } as never); + const delta = mapServeEvent({ + type: 'session.next.tool.input.delta', + data: { sessionID: SESSION, callID: CALL, delta: '{"comman' }, + } as never); + + expect(started?.events).toEqual([]); + expect(delta?.events).toEqual([]); + }); + + it('flattens the success content blocks into one output string', () => { + const out = mapServeEvent({ + type: 'session.next.tool.success', + data: { + sessionID: SESSION, + callID: CALL, + structured: { exit: 0, truncated: false }, + content: [{ type: 'text', text: 'hello-from-tool' }], + }, + } as never); + + expect(out?.events).toEqual([{ type: 'tool:result', toolCallId: CALL, output: 'hello-from-tool', isError: false }]); + }); + + it('reports a failed tool with its error as the output', () => { + const out = mapServeEvent({ + type: 'session.next.tool.failed', + data: { sessionID: SESSION, callID: CALL, error: { message: 'command not found' } }, + } as never); + + expect(out?.events).toEqual([ + { type: 'tool:result', toolCallId: CALL, output: 'command not found', isError: true }, + ]); + }); + + it('ignores a tool event with no callID, which cannot be correlated', () => { + expect(mapServeEvent({ type: 'session.next.tool.called', data: { tool: 'bash' } } as never)?.events).toEqual([]); + expect(mapServeEvent({ type: 'session.next.tool.success', data: {} } as never)?.events).toEqual([]); + }); +}); + +describe('mapServeEvent — accounting', () => { + it('reports per-step tokens and cost for the caller to accumulate', () => { + const out = mapServeEvent({ + type: 'session.next.step.ended', + data: { + sessionID: SESSION, + finish: 'tool-calls', + cost: 0.0042, + tokens: { input: 3, output: 57, reasoning: 0, cache: { read: 3850, write: 0 } }, + }, + } as never); + + expect(out?.costDelta).toEqual({ inputTokens: 3, outputTokens: 57, totalUSD: 0.0042 }); + }); + + it('emits nothing terminal for a step that merely ended in tool calls', () => { + // `finish: "tool-calls"` is a step boundary MID-turn — the model stopped to run a tool and will + // continue. Treating it as the end of the turn would cut every tool-using conversation in half. + const out = mapServeEvent({ + type: 'session.next.step.ended', + data: { sessionID: SESSION, finish: 'tool-calls', cost: 0, tokens: { input: 3, output: 57 } }, + } as never); + + expect(out?.events).toEqual([]); + }); + + it('treats missing tokens and cost as zero rather than NaN', () => { + const out = mapServeEvent({ type: 'session.next.step.ended', data: { sessionID: SESSION } } as never); + expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); + }); + + it('surfaces a failed step as an error carrying the provider message', () => { + // The real one, from a probe where the provider was down. + const out = mapServeEvent({ + type: 'session.next.step.failed', + data: { sessionID: SESSION, error: { type: 'unknown', message: 'Provider request failed with HTTP 503' } }, + } as never); + + expect(out?.events).toEqual([{ type: 'error', message: 'Provider request failed with HTTP 503' }]); + }); +}); + +describe('mapServeEvent — envelope', () => { + it('carries the durable cursor when reading the durable stream, and not otherwise', () => { + // The per-session stream stamps `durable.seq`; the global live stream does not. The caller needs + // that difference to know what it may commit and replay. + const durable = mapServeEvent({ + type: 'session.next.text.ended', + durable: { seq: 12 }, + data: { sessionID: SESSION, text: 'hi' }, + } as never); + const live = mapServeEvent({ type: 'session.next.text.ended', data: { sessionID: SESSION, text: 'hi' } } as never); + + expect(durable?.seq).toBe(12); + expect(live?.seq).toBeUndefined(); + }); + + it('always reports the session id, because the live stream is global', () => { + // Not a detail: `/api/event` carries EVERY session's events, so a consumer that forgets to filter + // splices one conversation into another. + for (const type of ['session.next.text.delta', 'session.next.tool.called', 'session.next.step.ended']) { + expect(mapServeEvent({ type, data: { sessionID: SESSION, delta: 'x', callID: CALL } } as never)?.sessionId).toBe( + SESSION, + ); + } + }); + + it('returns null for something that is not an event at all', () => { + expect(mapServeEvent(null)).toBeNull(); + expect(mapServeEvent(undefined)).toBeNull(); + expect(mapServeEvent({} as never)).toBeNull(); + }); + + it('is quiet about an unknown future type, but does not claim to know it', () => { + // Forward compatibility with a silent tell: an unrecognised type produces no events, and + // `isKnownServeEvent` is how a caller notices a release added something worth mapping. + expect(mapServeEvent({ type: 'session.next.something_new_in_1_19', data: {} } as never)?.events).toEqual([]); + expect(isKnownServeEvent('session.next.something_new_in_1_19')).toBe(false); + expect(isKnownServeEvent('session.next.text.delta')).toBe(true); + expect(isKnownServeEvent(undefined)).toBe(false); + }); +}); diff --git a/src/servers/sidecar/opencode/serve-events.ts b/src/servers/sidecar/opencode/serve-events.ts new file mode 100644 index 00000000..93f39fdc --- /dev/null +++ b/src/servers/sidecar/opencode/serve-events.ts @@ -0,0 +1,221 @@ +import type { ChatEvent } from '../../api/chat/types'; + +// `session.next.*` → ChatEvent. Phase A of the serve migration (docs/opencode-serve-migration-plan.md). +// +// NOTHING ROUTES THROUGH THIS YET. Turns still run as `opencode run` subprocesses via runner.ts; this is +// the mapping half of the serve path, written and pinned first so the switch-over is not also the moment +// the parsing is discovered to be wrong. +// +// ── The two streams, which is the part worth understanding ── +// +// The serve publishes the same turn twice, and the split maps exactly onto what officer already does for +// Claude: +// +// • `GET /api/session/{id}/event?after=` — DURABLE, per session, replayable. Every event carries +// `durable.seq`. Carries whole values (`text.ended` with the full text) and NO deltas. This is the +// transcript: what belongs in `chat_session_events`, and what a reconnecting browser replays. +// +// • `GET /api/event` — LIVE, GLOBAL, ephemeral. Carries the deltas +// (`text.delta`, `tool.input.delta`) and no cursor. This is what makes text appear as it is typed. +// +// Measured, not inferred: the same turn produced 13 events on the durable stream and 21 on the live one, +// the difference being 3 `text.delta` and 5 `tool.input.delta`. Reading only the durable stream — which +// is what I did first — makes it look like the serve cannot stream at all, and would have quietly killed +// the main reason for migrating. +// +// The live stream being GLOBAL is the real cost of this design: it carries every session's events, so a +// consumer must filter on `sessionID` and cannot assume it owns the socket. +// +// ── Fixture provenance ── +// +// Every shape here was captured from opencode 1.18.16 running a real turn that used the bash tool and +// then answered in prose. See `serve-events.test.ts`, whose fixtures are verbatim captures. + +/** The envelope every `session.next.*` event arrives in. `durable` is present only on the durable stream. */ +export type ServeEvent = { + type?: string; + durable?: { seq?: number }; + data?: { + sessionID?: string; + messageID?: string; + assistantMessageID?: string; + callID?: string; + textID?: string; + name?: string; + tool?: string; + input?: Record; + delta?: string; + text?: string; + content?: { type?: string; text?: string }[]; + structured?: Record; + error?: { message?: string; type?: string }; + finish?: string; + cost?: number; + tokens?: { input?: number; output?: number }; + model?: { id?: string; providerID?: string }; + }; +}; + +export type MappedServeEvent = { + /** The session this belongs to. The live stream is global, so a consumer MUST filter on it. */ + sessionId?: string; + /** The durable cursor, when this came from the durable stream. Absent on live events. */ + seq?: number; + events: ChatEvent[]; + costDelta: { inputTokens: number; outputTokens: number; totalUSD: number }; +}; + +const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; + +/** `tool.success` returns content blocks; officer's `tool:result` wants one string. */ +function textOfContent(content: { type?: string; text?: string }[] | undefined): string { + if (!Array.isArray(content)) return ''; + return content + .map((c) => (typeof c?.text === 'string' ? c.text : '')) + .filter(Boolean) + .join('\n'); +} + +/** + * One serve event in, whatever officer should do about it out. + * + * Pure by construction, exactly like `mapRunLine`: no accumulation, no memory, no I/O. The caller owns + * anything that spans events — summing cost across steps, and deciding whether it is reading the live or + * the durable stream. + * + * Returns `null` for an event that is not ours to interpret (another session's, or an envelope with no + * type). Everything recognised but deliberately ignored returns an empty `events` array instead, so + * "we saw it and had nothing to say" stays distinguishable from "we did not understand it". + */ +export function mapServeEvent(evt: ServeEvent | null | undefined): MappedServeEvent | null { + if (!evt || typeof evt.type !== 'string') return null; + + const d = evt.data ?? {}; + const base = { sessionId: d.sessionID, seq: evt.durable?.seq }; + const nothing = (): MappedServeEvent => ({ ...base, events: [], costDelta: NO_COST }); + + switch (evt.type) { + // ── Text ── + case 'session.next.text.delta': { + // Live stream only. `delta` is the increment, never the whole value. + const text = d.delta; + if (typeof text !== 'string' || text.length === 0) return nothing(); + return { ...base, events: [{ type: 'delta', text }], costDelta: NO_COST }; + } + case 'session.next.text.ended': { + // The committed block. Appears on BOTH streams, which is intended: officer's client treats `text` + // as the value that supersedes whatever the deltas built up, the same as the Claude path. + const text = d.text; + if (typeof text !== 'string' || text.length === 0) return nothing(); + return { ...base, events: [{ type: 'text', text }], costDelta: NO_COST }; + } + + // ── Tools ── + // + // `tool:start` is emitted on `tool.called`, NOT on `tool.input.started`, because only `tool.called` + // carries the resolved `input` object. `tool.input.started` knows the name but the arguments are + // still being streamed a few characters at a time (`tool.input.delta`: `{"comman`), and a tool row + // rendered with half-parsed JSON as its arguments is worse than one that appears a moment later. + case 'session.next.tool.called': { + const toolCallId = d.callID; + if (!toolCallId) return nothing(); + return { + ...base, + events: [ + { + type: 'tool:start', + toolCallId, + toolName: d.tool ?? d.name ?? 'tool', + toolInput: (d.input as Record) ?? {}, + }, + ], + costDelta: NO_COST, + }; + } + case 'session.next.tool.success': { + const toolCallId = d.callID; + if (!toolCallId) return nothing(); + return { + ...base, + events: [{ type: 'tool:result', toolCallId, output: textOfContent(d.content), isError: false }], + costDelta: NO_COST, + }; + } + case 'session.next.tool.failed': { + const toolCallId = d.callID; + if (!toolCallId) return nothing(); + // The error replaces the output rather than sitting beside it — same call the run path makes. + const message = d.error?.message ?? textOfContent(d.content) ?? ''; + return { + ...base, + events: [{ type: 'tool:result', toolCallId, output: String(message), isError: true }], + costDelta: NO_COST, + }; + } + + // ── Step accounting ── + case 'session.next.step.ended': { + // Per-step tokens and cost, to be summed by the caller. `finish: "tool-calls"` is a step boundary + // mid-turn, not the end of the turn — the turn has ended when the prompt is fully answered, which + // is a caller-level question, so nothing terminal is emitted here. + const t = d.tokens; + return { + ...base, + events: [], + costDelta: { + inputTokens: t?.input ?? 0, + outputTokens: t?.output ?? 0, + totalUSD: typeof d.cost === 'number' ? d.cost : 0, + }, + }; + } + case 'session.next.step.failed': { + const message = d.error?.message ?? 'OpenCode step failed'; + return { ...base, events: [{ type: 'error', message }], costDelta: NO_COST }; + } + + // ── Seen and deliberately silent ── + // + // Named rather than swept into `default` so that a genuinely NEW event type still lands in the + // unknown bucket, where it can be noticed. + case 'session.next.prompt.admitted': + case 'session.next.prompted': + case 'session.next.step.started': + case 'session.next.text.started': + case 'session.next.tool.input.started': + case 'session.next.tool.input.delta': + case 'session.next.tool.input.ended': + case 'session.next.tool.progress': + case 'session.next.model.switched': + case 'session.next.agent.switched': + case 'server.connected': + return nothing(); + + default: + return nothing(); + } +} + +/** Whether a type is one this mapper knows about — for logging what a new opencode release added. */ +export const isKnownServeEvent = (type: string | undefined): boolean => + typeof type === 'string' && (type === 'server.connected' || KNOWN.has(type)); + +const KNOWN = new Set([ + 'session.next.text.delta', + 'session.next.text.ended', + 'session.next.text.started', + 'session.next.tool.called', + 'session.next.tool.success', + 'session.next.tool.failed', + 'session.next.tool.input.started', + 'session.next.tool.input.delta', + 'session.next.tool.input.ended', + 'session.next.tool.progress', + 'session.next.step.started', + 'session.next.step.ended', + 'session.next.step.failed', + 'session.next.prompt.admitted', + 'session.next.prompted', + 'session.next.model.switched', + 'session.next.agent.switched', +]); From a06422bd4cbceddfc37900130a5d94d99c97e50e Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:12:49 +0100 Subject: [PATCH 14/42] phase B: run a turn through the serve, behind a switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/sidecar/opencode/index.ts | 36 ++- src/servers/sidecar/opencode/serve-events.ts | 10 + src/servers/sidecar/opencode/serve-runner.ts | 278 +++++++++++++++++++ 3 files changed, 319 insertions(+), 5 deletions(-) create mode 100644 src/servers/sidecar/opencode/serve-runner.ts diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index 93c12314..b5e6c283 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -7,7 +7,9 @@ import { sweepRecordedServe } from './serve-sweep'; import { connectProviderCredential } from './connect-credential'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; +import type { RunnerMessage } from './runner'; import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner'; +import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-runner'; // The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that // OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It @@ -143,6 +145,17 @@ void connectProviderCredential(baseUrl); type ReplyFn = (msg: SidecarEvent) => void; const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD }; +const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD }; + +// Which engine runs a turn. `subprocess` (the default) spawns `opencode run`; `serve` drives the +// serve's /api/session surface, which is the only way to get streaming, steer, queue and a stop that +// leaves the session alive. +// +// A switch rather than a replacement, and defaulted to the old path on purpose: the subprocess has +// worked all day and the serve path has not been lived with yet. A bad evening should cost one restart +// with OPENCODE_TURNS unset, not a revert. +const USE_SERVE_TURNS = (process.env.OPENCODE_TURNS ?? 'subprocess').toLowerCase() === 'serve'; +console.log(`[opencode] turn engine: ${USE_SERVE_TURNS ? 'serve (/api/session)' : 'subprocess (opencode run)'}`); function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { @@ -154,23 +167,35 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { // Turn output goes through the session log: translated to TurnMessages and committed to // chat_session_events here, in the process that produced it. Officer being down during a turn // no longer costs the transcript — the browser replays it from its cursor. - runOpenCodeTurn(cmd.params, RUNNER_CONFIG, (msg) => { + // Same emit contract either way, which is what makes the switch a switch: the durable commit and + // the routing fact behave identically whether a subprocess or the serve produced the event. + const onMessage = (msg: RunnerMessage) => { if (msg.type === 'opencode:event') { sessionLog.push(sessionKey, msg.event, durable); return; } // opencode:session is a routing fact (which `ses_…` to resume), not transcript — send it live. connection.send(msg); - }); + }; + + if (USE_SERVE_TURNS) void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage); + else runOpenCodeTurn(cmd.params, RUNNER_CONFIG, onMessage); reply({ type: 'opencode:spawned', id: cmd.id, sessionKey }); break; } case 'opencode:list': - reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningOpenCodeTurns() }); + reply({ + type: 'opencode:sessions', + id: cmd.id, + sessions: USE_SERVE_TURNS ? listRunningServeTurns() : listRunningOpenCodeTurns(), + }); break; case 'opencode:kill': - killOpenCodeTurn(cmd.sessionKey); + // On the serve this is an INTERRUPT: the turn stops and the session survives, so the conversation + // can be continued rather than only re-opened. + if (USE_SERVE_TURNS) void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG); + else killOpenCodeTurn(cmd.sessionKey); sessionLog.drop(cmd.sessionKey); break; default: @@ -221,7 +246,8 @@ async function shutdown(signal: string) { // Before the connection goes: killing a turn produces an event, and that event's durable write travels // over this socket. Tearing it down first would stop every turn silently — the exact outcome this is // here to prevent. - const stopped = stopAllOpenCodeTurns(`The OpenCode sidecar restarted (${signal}), so this turn stopped.`); + const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`; + const stopped = USE_SERVE_TURNS ? stopAllServeTurns(message) : stopAllOpenCodeTurns(message); if (stopped > 0) { console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`); await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]); diff --git a/src/servers/sidecar/opencode/serve-events.ts b/src/servers/sidecar/opencode/serve-events.ts index 93f39fdc..7b1162dc 100644 --- a/src/servers/sidecar/opencode/serve-events.ts +++ b/src/servers/sidecar/opencode/serve-events.ts @@ -63,6 +63,15 @@ export type MappedServeEvent = { seq?: number; events: ChatEvent[]; costDelta: { inputTokens: number; outputTokens: number; totalUSD: number }; + /** + * Present only on `step.ended`, and the answer to "is the turn over". + * + * A subprocess turn ends by exiting; a serve turn has no such moment, so this is the only signal. + * `"tool-calls"` means the model paused to run a tool and WILL continue — treating it as terminal cuts + * every tool-using conversation in half. `"stop"` is the real end. Anything else (e.g. `"length"`) is + * also an ending, so the caller should test for "not tool-calls" rather than for "stop". + */ + stepFinish?: string; }; const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; @@ -162,6 +171,7 @@ export function mapServeEvent(evt: ServeEvent | null | undefined): MappedServeEv return { ...base, events: [], + stepFinish: typeof d.finish === 'string' ? d.finish : undefined, costDelta: { inputTokens: t?.input ?? 0, outputTokens: t?.output ?? 0, diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts new file mode 100644 index 00000000..7b7834a8 --- /dev/null +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -0,0 +1,278 @@ +import type { ChatEvent, MessageCost } from '../../api/chat/types'; +import type { OpenCodeRunParams } from '../protocol'; +import type { RunnerMessage } from './runner'; +import { mapServeEvent } from './serve-events'; + +// Phase B: drive a turn through the serve instead of spawning `opencode run`. +// +// OFF BY DEFAULT. `index.ts` picks between this and `runner.ts` on `OPENCODE_TURNS`, and the subprocess +// stays the default until this has run for a while — a bad day should be one restart from the path that +// has worked all along, not a rollback. +// +// ── Why bother, given the subprocess works ── +// +// Everything the subprocess cannot do is a consequence of `stdin: 'ignore'`: no token streaming, no +// mid-turn injection, no queue, no interrupt that leaves the session alive. The serve offers all four as +// primitives, verified against 1.18.16 (docs/opencode-fork-decision.md). +// +// ── One global stream, demultiplexed ── +// +// The serve publishes each turn on two streams (docs/opencode-serve-migration-plan.md). This reads the +// LIVE one, `GET /api/event`, because it is a strict superset of the durable stream's content — same +// `tool.called`, `tool.success`, `step.ended`, `text.ended`, PLUS the deltas — and deltas are the point. +// +// It is GLOBAL: one socket carries every session on the box, so everything here filters on `sessionID`. +// Forgetting that would splice one conversation into another. There is exactly one subscription for the +// process, opened on the first turn and shared, because opening one per turn would multiply the same +// firehose by the number of turns. +// +// What this loses versus the durable stream is `durable.seq`, i.e. replay-after-the-fact. That matters +// for surviving an officer restart mid-turn and is deliberately NOT in Phase B: the sidecar commits +// every event to `chat_session_events` as it arrives (unchanged from the subprocess path), which is the +// same durability guarantee the subprocess had. Reading the durable stream to recover a turn this +// process never saw is its own change. + +type ServeConfig = { + /** The serve's base URL, e.g. http://127.0.0.1:53100 */ + baseUrl: string; + /** Used when a turn names no cwd — same fallback the subprocess runner applies. */ + fallbackCwd: string; +}; + +type Emit = (msg: RunnerMessage) => void; + +type ServeTurn = { + sessionKey: string; + openCodeSessionId: string; + cost: MessageCost; + done: boolean; + emit: Emit; + finish: (event: ChatEvent) => void; +}; + +/** Live turns, by OpenCode session id — the id the event stream speaks. */ +const byOpenCodeId = new Map(); +/** The same turns by officer's key, which is what `kill` and the Live panel use. */ +const bySessionKey = new Map(); + +// ── The one shared subscription ── + +let streamStarted = false; + +/** + * Read `/api/event` forever, routing each event to the turn that owns it. + * + * Reconnects on drop with a fixed delay. A serve restart, a network blip or the stream simply ending + * must not permanently deafen the sidecar — every subsequent turn would hang with no output, which is + * the worst failure this path has, because it looks exactly like a slow model. + */ +function ensureEventStream(config: ServeConfig): void { + if (streamStarted) return; + streamStarted = true; + + void (async () => { + for (;;) { + try { + const res = await fetch(`${config.baseUrl}/api/event`, { headers: { accept: 'text/event-stream' } }); + if (!res.ok || !res.body) throw new Error(`event stream → ${res.status}`); + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ''; + + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) { + if (!line.startsWith('data:')) continue; + try { + handleServeEvent(JSON.parse(line.slice(5).trim())); + } catch { + /* a frame we could not parse is not worth killing the stream over */ + } + } + } + } catch (err) { + console.error('[opencode] event stream dropped, reconnecting:', err instanceof Error ? err.message : err); + } + await Bun.sleep(1_000); + } + })(); +} + +function handleServeEvent(raw: unknown): void { + const mapped = mapServeEvent(raw as never); + if (!mapped?.sessionId) return; + + const turn = byOpenCodeId.get(mapped.sessionId); + if (!turn || turn.done) return; // another session's, or one we have already finished + + turn.cost = { + inputTokens: turn.cost.inputTokens + mapped.costDelta.inputTokens, + outputTokens: turn.cost.outputTokens + mapped.costDelta.outputTokens, + totalUSD: turn.cost.totalUSD + mapped.costDelta.totalUSD, + }; + + for (const event of mapped.events) { + // An error from the harness ends the turn: nothing follows a failed step, and leaving the turn open + // would strand the UI on a spinner. + if (event.type === 'error') { + turn.finish(event); + return; + } + turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event }); + } + + // `tool-calls` means the model paused to run a tool and will continue. Anything else is the end. + if (mapped.stepFinish && mapped.stepFinish !== 'tool-calls') { + turn.finish({ type: 'result', cost: turn.cost }); + } +} + +// ── HTTP helpers ── + +async function serveJson(config: ServeConfig, path: string, init: RequestInit & { cwd: string }): Promise { + const { cwd, ...rest } = init; + const res = await fetch(`${config.baseUrl}${path}`, { + ...rest, + headers: { + 'content-type': 'application/json', + // The location is per REQUEST on this surface, not a property of the session. A call without it + // runs against the serve's own directory, which is not where the user's files are. + 'x-opencode-directory': cwd, + ...(rest.headers ?? {}), + }, + signal: AbortSignal.timeout(30_000), + }); + if (!res.ok) throw new Error(`${path} → ${res.status}`); + const text = await res.text(); + if (!text) return null; + // This surface wraps everything in `{data: …}`; the legacy one does not. Reading `.id` off the + // envelope silently yields undefined, which is how an entire afternoon disappeared once. + const body = JSON.parse(text) as { data?: T } | T; + return (body as { data?: T }).data ?? (body as T); +} + +// ── The turn ── + +export async function runOpenCodeTurnOnServe( + params: OpenCodeRunParams, + config: ServeConfig, + emit: Emit, +): Promise { + const { sessionKey } = params; + const cwd = params.cwd || config.fallbackCwd; + + ensureEventStream(config); + + // Supersede any turn still registered under this key. Unlike the subprocess path there is no process + // to kill — the serve owns execution — so this is bookkeeping only, and the old turn is retired + // silently rather than reporting an error against a key that now belongs to its replacement. + const stale = bySessionKey.get(sessionKey); + if (stale) retire(stale, null); + + let openCodeSessionId = params.resumeSessionId ?? ''; + + try { + if (!openCodeSessionId) { + const created = await serveJson<{ id: string }>(config, '/api/session', { + method: 'POST', + body: JSON.stringify({ location: { directory: cwd } }), + cwd, + }); + if (!created?.id) throw new Error('session create returned no id'); + openCodeSessionId = created.id; + } + + if (params.model) { + // `providerID/modelID`, the same string the subprocess passes to `--model`. + const slash = params.model.indexOf('/'); + const providerID = slash > 0 ? params.model.slice(0, slash) : 'opencode'; + const id = slash > 0 ? params.model.slice(slash + 1) : params.model; + await serveJson(config, `/api/session/${openCodeSessionId}/model`, { + method: 'POST', + body: JSON.stringify({ model: { providerID, id } }), + cwd, + }); + } + } catch (err) { + emit({ + type: 'opencode:event', + sessionKey, + event: { type: 'error', message: `Could not start an OpenCode session: ${errText(err)}` }, + }); + return; + } + + const turn: ServeTurn = { + sessionKey, + openCodeSessionId, + cost: { inputTokens: 0, outputTokens: 0, totalUSD: 0 }, + done: false, + emit, + finish: (event) => retire(turn, event), + }; + byOpenCodeId.set(openCodeSessionId, turn); + bySessionKey.set(sessionKey, turn); + + // Officer learns which `ses_…` to resume next time — a routing fact, not transcript. + emit({ type: 'opencode:session', sessionKey, sessionId: openCodeSessionId }); + + try { + await serveJson(config, `/api/session/${openCodeSessionId}/prompt`, { + method: 'POST', + // `delivery` is stated explicitly because it DEFAULTS to `"steer"`, which injects into a running + // turn. For an ordinary send that is the wrong default — two quick messages would merge into one + // turn instead of running in order. `steer` is Phase C's job, wired to the button that means it. + body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'queue' }), + cwd, + }); + } catch (err) { + retire(turn, { type: 'error', message: `OpenCode refused the prompt: ${errText(err)}` }); + } +} + +function retire(turn: ServeTurn, event: ChatEvent | null): void { + if (turn.done) return; + turn.done = true; + if (byOpenCodeId.get(turn.openCodeSessionId) === turn) byOpenCodeId.delete(turn.openCodeSessionId); + if (bySessionKey.get(turn.sessionKey) === turn) bySessionKey.delete(turn.sessionKey); + if (event) turn.emit({ type: 'opencode:event', sessionKey: turn.sessionKey, event }); +} + +const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +/** The serve analog of `listRunningOpenCodeTurns`. Same shape, so the Live panel needs no changes. */ +export function listRunningServeTurns(): { sessionKey: string }[] { + return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey })); +} + +/** + * Stop a turn without destroying its session — the thing the subprocess path cannot do. + * + * `POST /interrupt` leaves the conversation intact and resumable, where killing a subprocess ended it. + */ +export async function killServeTurn(sessionKey: string, config: ServeConfig): Promise { + const turn = bySessionKey.get(sessionKey); + if (!turn) return; + try { + await fetch(`${config.baseUrl}/api/session/${turn.openCodeSessionId}/interrupt`, { + method: 'POST', + headers: { 'x-opencode-directory': config.fallbackCwd }, + signal: AbortSignal.timeout(10_000), + }); + } catch { + /* interrupt is best-effort; the turn is retired either way so the UI is never stuck */ + } + retire(turn, { type: 'stopped' }); +} + +/** Retire every live turn, for shutdown. Mirrors `stopAllOpenCodeTurns` on the subprocess path. */ +export function stopAllServeTurns(message: string): number { + const turns = [...bySessionKey.values()]; + for (const turn of turns) retire(turn, { type: 'error', message }); + return turns.length; +} From a058cbb3fdfa41ca0e8c3396f4bf9f652d57d6b8 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:17:26 +0100 Subject: [PATCH 15/42] inject a mid-turn message instead of replacing the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/opencode-serve-migration-plan.md | 13 +++++++ src/servers/sidecar/opencode/serve-runner.ts | 38 +++++++++++++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md index 594b6ba0..92208fa9 100644 --- a/docs/opencode-serve-migration-plan.md +++ b/docs/opencode-serve-migration-plan.md @@ -58,6 +58,19 @@ Each of these cost time to find. None is in the API docs. 5. **The event names are `session.next.*`** — `step.started`, `text.started`, `text.ended`, `tool.called`, `tool.success`, `step.ended`, `step.failed`. Not the shapes `mapRunLine` handles. +## Status, 2026-08-10 + +- **Phase A — done.** `serve-events.ts` + tests, fixtures captured from real turns. +- **Phase B — done, behind `OPENCODE_TURNS=serve` (default: subprocess).** `serve-runner.ts`. Verified + end to end through the chat socket: tool call, tool result, **3 streaming deltas**, text, cost. Stop is + an interrupt and the session survives it. +- **Phase C — server half done.** A message sent while a turn runs is injected with `delivery: "steer"` + into the RUNNING turn, verified end to end. No client change was needed: officer's composer already + sends mid-turn, and the subprocess path was superseding where the serve steers. +- **Phase D — not started, deliberately.** + +Not yet lived with. Nothing here has run a real conversation with a person at the other end. + ## Shape of the work **Phase A — read the stream without depending on it.** Add a serve-based reader alongside the existing diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index 7b7834a8..6160f79a 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -168,11 +168,39 @@ export async function runOpenCodeTurnOnServe( ensureEventStream(config); - // Supersede any turn still registered under this key. Unlike the subprocess path there is no process - // to kill — the serve owns execution — so this is bookkeeping only, and the old turn is retired - // silently rather than reporting an error against a key that now belongs to its replacement. - const stale = bySessionKey.get(sessionKey); - if (stale) retire(stale, null); + // ── A message arriving while a turn is running is an INJECTION, not a new turn ── + // + // This is where the subprocess and the serve genuinely part company. `opencode run` had no input + // channel, so a second message could only supersede: kill the process, start again, lose the turn. + // The serve takes another prompt into the RUNNING turn, so the right move is to hand it over and keep + // the existing turn exactly as it is. + // + // Keeping the same turn object is the load-bearing part. Retiring it and registering a replacement — + // which is what this did at first — stops officer routing the events the serve is still producing, + // while the serve carries on regardless. The output goes nowhere and the turn looks hung. + // + // `steer` because the user typed it during the turn and means it now; officer's composer already + // treats a send-while-generating as "add this to what you are doing". A prompt sent when nothing is + // running takes `queue`, which is a no-op with an empty queue but never accidentally merges two + // messages into one turn. + const live = bySessionKey.get(sessionKey); + if (live && !live.done) { + try { + await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, { + method: 'POST', + body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'steer' }), + cwd, + }); + } catch (err) { + // The turn itself is unharmed — only the injection failed — so say so and leave it running. + emit({ + type: 'opencode:event', + sessionKey, + event: { type: 'error', message: `OpenCode would not take that mid-turn: ${errText(err)}` }, + }); + } + return; + } let openCodeSessionId = params.resumeSessionId ?? ''; From b79eca45fb315c6a1242e6ebfbb6f7402c6e0bc7 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:25:48 +0100 Subject: [PATCH 16/42] send images on the serve path too, before anyone switches to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/sidecar/opencode/serve-runner.ts | 32 ++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index 6160f79a..a077b372 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -188,7 +188,10 @@ export async function runOpenCodeTurnOnServe( try { await serveJson(config, `/api/session/${live.openCodeSessionId}/prompt`, { method: 'POST', - body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'steer' }), + body: JSON.stringify({ + prompt: { text: params.prompt, files: promptFiles(params.images) }, + delivery: 'steer', + }), cwd, }); } catch (err) { @@ -255,7 +258,10 @@ export async function runOpenCodeTurnOnServe( // `delivery` is stated explicitly because it DEFAULTS to `"steer"`, which injects into a running // turn. For an ordinary send that is the wrong default — two quick messages would merge into one // turn instead of running in order. `steer` is Phase C's job, wired to the button that means it. - body: JSON.stringify({ prompt: { text: params.prompt }, delivery: 'queue' }), + body: JSON.stringify({ + prompt: { text: params.prompt, files: promptFiles(params.images) }, + delivery: 'queue', + }), cwd, }); } catch (err) { @@ -273,6 +279,28 @@ function retire(turn: ServeTurn, event: ChatEvent | null): void { const errText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); +/** + * Images as `prompt.files`, which this surface takes by URI. + * + * **`data:` URIs, not `file://`.** Measured, because the choice is not obvious and the wrong one fails + * at the provider rather than at the API: a `file://` attachment is accepted with a 200 and then dies + * inside the turn with `Anthropic Messages media must contain valid base64`. A `data:` URI round-trips + * and the model describes the image. + * + * This is strictly better than the subprocess path, which has to spill each image to a temp file for + * `--file` and delete it afterwards. Here the bytes go in the request and there is nothing to clean up. + * + * Silently dropping these is exactly defect B4 — the user sees their image in their own bubble and the + * model never receives it — so this exists before the serve path is switched on for anyone, not after. + */ +function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name: string }[] | undefined { + if (!images?.length) return undefined; + return images.map((image, index) => ({ + uri: `data:${image.mediaType || 'image/png'};base64,${image.data}`, + name: `attachment-${index + 1}`, + })); +} + /** The serve analog of `listRunningOpenCodeTurns`. Same shape, so the Live panel needs no changes. */ export function listRunningServeTurns(): { sessionKey: string }[] { return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey })); From a3dbda7d3b1e2ba23848b0c9548d2ed434352624 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:33:58 +0100 Subject: [PATCH 17/42] phase D: delete the subprocess path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/sidecar/opencode/index.ts | 38 +- src/servers/sidecar/opencode/runner.test.ts | 311 ------------- src/servers/sidecar/opencode/runner.ts | 463 ------------------- src/servers/sidecar/opencode/serve-runner.ts | 41 +- src/servers/sidecar/protocol.ts | 9 +- 5 files changed, 44 insertions(+), 818 deletions(-) delete mode 100644 src/servers/sidecar/opencode/runner.test.ts delete mode 100644 src/servers/sidecar/opencode/runner.ts diff --git a/src/servers/sidecar/opencode/index.ts b/src/servers/sidecar/opencode/index.ts index b5e6c283..aac33861 100644 --- a/src/servers/sidecar/opencode/index.ts +++ b/src/servers/sidecar/opencode/index.ts @@ -7,17 +7,16 @@ import { sweepRecordedServe } from './serve-sweep'; import { connectProviderCredential } from './connect-credential'; import { createSessionLogStore } from '../claude/session-log'; import type { SidecarCommand, SidecarEvent } from '../protocol'; -import type { RunnerMessage } from './runner'; -import { runOpenCodeTurn, killOpenCodeTurn, listRunningOpenCodeTurns, stopAllOpenCodeTurns } from './runner'; +import type { RunnerMessage } from './serve-runner'; import { runOpenCodeTurnOnServe, killServeTurn, listRunningServeTurns, stopAllServeTurns } from './serve-runner'; // The OpenCode sidecar (officer-opencode). Same philosophy as officer-claude: a singleton process that // OWNS its runtime — here, an `opencode serve` — registers with the API server, and answers commands. It // listens on a random port, reported to the API on connect so it can route there. // -// The serve's working directory is DATA_PATH/opencode_server, and that is now ALL it is: turns do not go -// through the serve, they are `opencode run --dir ` subprocesses (runner.ts). The serve is used for -// session CRUD and model enumeration only. +// The serve runs EVERYTHING: turns (serve-runner.ts), session CRUD and model enumeration. It used to be +// CRUD only, with turns spawned as `opencode run --dir ` subprocesses — that path was deleted on +// 2026-08-10 once the serve had streaming, mid-turn injection and interrupt working end to end. const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; const OPENCODE_BIN = process.env.OPENCODE_BIN || join(homedir(), '.opencode', 'bin', 'opencode'); @@ -144,19 +143,8 @@ void connectProviderCredential(baseUrl); type ReplyFn = (msg: SidecarEvent) => void; -const RUNNER_CONFIG = { bin: OPENCODE_BIN, fallbackCwd: SERVE_CWD }; const SERVE_RUNNER_CONFIG = { baseUrl, fallbackCwd: SERVE_CWD }; -// Which engine runs a turn. `subprocess` (the default) spawns `opencode run`; `serve` drives the -// serve's /api/session surface, which is the only way to get streaming, steer, queue and a stop that -// leaves the session alive. -// -// A switch rather than a replacement, and defaulted to the old path on purpose: the subprocess has -// worked all day and the serve path has not been lived with yet. A bad evening should cost one restart -// with OPENCODE_TURNS unset, not a revert. -const USE_SERVE_TURNS = (process.env.OPENCODE_TURNS ?? 'subprocess').toLowerCase() === 'serve'; -console.log(`[opencode] turn engine: ${USE_SERVE_TURNS ? 'serve (/api/session)' : 'subprocess (opencode run)'}`); - function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': @@ -178,24 +166,18 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { connection.send(msg); }; - if (USE_SERVE_TURNS) void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage); - else runOpenCodeTurn(cmd.params, RUNNER_CONFIG, onMessage); + void runOpenCodeTurnOnServe(cmd.params, SERVE_RUNNER_CONFIG, onMessage); reply({ type: 'opencode:spawned', id: cmd.id, sessionKey }); break; } case 'opencode:list': - reply({ - type: 'opencode:sessions', - id: cmd.id, - sessions: USE_SERVE_TURNS ? listRunningServeTurns() : listRunningOpenCodeTurns(), - }); + reply({ type: 'opencode:sessions', id: cmd.id, sessions: listRunningServeTurns() }); break; case 'opencode:kill': - // On the serve this is an INTERRUPT: the turn stops and the session survives, so the conversation - // can be continued rather than only re-opened. - if (USE_SERVE_TURNS) void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG); - else killOpenCodeTurn(cmd.sessionKey); + // An INTERRUPT, not a kill: the turn stops and the session survives, so the conversation can be + // continued rather than only re-opened. + void killServeTurn(cmd.sessionKey, SERVE_RUNNER_CONFIG); sessionLog.drop(cmd.sessionKey); break; default: @@ -247,7 +229,7 @@ async function shutdown(signal: string) { // over this socket. Tearing it down first would stop every turn silently — the exact outcome this is // here to prevent. const message = `The OpenCode sidecar restarted (${signal}), so this turn stopped.`; - const stopped = USE_SERVE_TURNS ? stopAllServeTurns(message) : stopAllOpenCodeTurns(message); + const stopped = stopAllServeTurns(message); if (stopped > 0) { console.log(`[opencode] ${signal} received, stopping ${stopped} in-flight turn(s)...`); await Promise.race([sessionLog.flush(), Bun.sleep(SHUTDOWN_FLUSH_MS)]); diff --git a/src/servers/sidecar/opencode/runner.test.ts b/src/servers/sidecar/opencode/runner.test.ts deleted file mode 100644 index b5b438ce..00000000 --- a/src/servers/sidecar/opencode/runner.test.ts +++ /dev/null @@ -1,311 +0,0 @@ -import { afterAll, describe, expect, it } from 'bun:test'; -import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { RunnerMessage } from './runner'; -import { - killOpenCodeTurn, - listRunningOpenCodeTurns, - mapRunLine, - runOpenCodeTurn, - stopAllOpenCodeTurns, -} from './runner'; - -// The first tests on the OpenCode path, which had none. -// -// This covers the NDJSON → ChatEvent mapping from `opencode run --format json`, which is the piece most -// likely to break against a new release: the event shape is not a documented contract, and this project -// could easily run different versions on its two machines — both are on 1.18.16 as of 2026-08-10, but -// only because they were upgraded together that day; before it they were 1.18.11 and 1.17.9, and this -// file recorded which was which backwards. Without these tests a shape change surfaces as a silently -// empty or malformed turn. -// -// The fixtures below were captured from a live binary. If one fails after an upgrade, re-read the real -// NDJSON from the installed binary before editing the expectation — the test failing is the feature. - -describe('mapRunLine — what it forwards', () => { - it('forwards a text part as final text, because run emits whole blocks not deltas', () => { - const out = mapRunLine(JSON.stringify({ type: 'text', sessionID: 'ses_1', part: { type: 'text', text: 'hello' } })); - - expect(out?.events).toEqual([{ type: 'text', text: 'hello' }]); - expect(out?.sessionId).toBe('ses_1'); - }); - - it('drops an empty text part rather than emitting a blank message', () => { - expect(mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: '' } }))?.events).toEqual([]); - }); - - it('splits one resolved tool part into start then result', () => { - // `run` hands over the tool already finished, unlike the SSE path which saw it transition. Both - // events are still emitted so the UI renders a call and its output rather than output alone. - const out = mapRunLine( - JSON.stringify({ - type: 'tool_use', - sessionID: 'ses_1', - part: { - type: 'tool', - tool: 'bash', - callID: 'call_1', - state: { status: 'completed', input: { cmd: 'ls' }, output: 'a\nb' }, - }, - }), - ); - - expect(out?.events).toEqual([ - { type: 'tool:start', toolCallId: 'call_1', toolName: 'bash', toolInput: { cmd: 'ls' } }, - { type: 'tool:result', toolCallId: 'call_1', output: 'a\nb', isError: false }, - ]); - }); - - it('reports a failed tool with its error as the output', () => { - const out = mapRunLine( - JSON.stringify({ - type: 'tool_use', - part: { - type: 'tool', - tool: 'bash', - callID: 'call_2', - state: { status: 'error', error: 'boom', output: 'ignored' }, - }, - }), - ); - - // The error replaces the output rather than sitting beside it: a failed call has nothing useful in - // `output`, and showing both would put a stale value under an error. - expect(out?.events[1]).toEqual({ type: 'tool:result', toolCallId: 'call_2', output: 'boom', isError: true }); - }); - - it('ignores a tool part with no callID, which cannot be correlated to a result', () => { - const out = mapRunLine(JSON.stringify({ type: 'tool_use', part: { type: 'tool', tool: 'bash' } })); - expect(out?.events).toEqual([]); - }); - - it('says nothing about step_start and other unknown types', () => { - expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_1' }))?.events).toEqual([]); - expect(mapRunLine(JSON.stringify({ type: 'something_new_in_1_19' }))?.events).toEqual([]); - }); - - it('returns null for a non-JSON line, because opencode interleaves plain logs with the stream', () => { - expect(mapRunLine('Shell cwd was reset to /somewhere')).toBeNull(); - expect(mapRunLine('')).toBeNull(); - }); -}); - -describe('mapRunLine — cost', () => { - it('reports per-step tokens and cost as a delta for the caller to accumulate', () => { - const out = mapRunLine( - JSON.stringify({ type: 'step_finish', part: { tokens: { input: 10, output: 4 }, cost: 0.002 } }), - ); - - expect(out?.costDelta).toEqual({ inputTokens: 10, outputTokens: 4, totalUSD: 0.002 }); - }); - - it('is zero for every line that is not a step_finish, so accumulation is unconditional', () => { - const text = mapRunLine(JSON.stringify({ type: 'text', part: { type: 'text', text: 'x' } })); - expect(text?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); - }); - - it('treats missing tokens and a missing cost as zero rather than NaN', () => { - // A NaN here would propagate into the turn total and render as an empty or broken cost in the UI. - const out = mapRunLine(JSON.stringify({ type: 'step_finish', part: {} })); - expect(out?.costDelta).toEqual({ inputTokens: 0, outputTokens: 0, totalUSD: 0 }); - }); -}); - -describe('mapRunLine — session id', () => { - it('reports the session id from any line that carries one', () => { - // The caller emits it only the first time; this function has no memory, which is what makes it - // testable line by line. - expect(mapRunLine(JSON.stringify({ type: 'step_start', sessionID: 'ses_abc' }))?.sessionId).toBe('ses_abc'); - expect(mapRunLine(JSON.stringify({ type: 'text', part: { text: 'hi' } }))?.sessionId).toBeUndefined(); - }); -}); - -// ── The turn lifecycle, which `mapRunLine`'s extraction deliberately left with the caller ── -// -// Emitting the session id once and accumulating cost were kept out of the pure mapper because they span -// lines. That was right, and it left them as the only untested logic on this path — which is exactly -// where the supersede defect lived: a replaced turn's `proc.exited` fired late and ran `finish()` against -// the turn that had replaced it. -// -// No real `opencode` needed. `RunnerConfig.bin` is the only injection point, so a shell script that -// outlives the test stands in for a turn that is still generating. - -const stubDir = mkdtempSync(join(tmpdir(), 'oc-runner-test-')); - -/** Stands in for a turn that is still generating. */ -const STUB_BIN = join(stubDir, 'fake-opencode'); -writeFileSync(STUB_BIN, '#!/bin/sh\nsleep 30\n'); -chmodSync(STUB_BIN, 0o755); - -/** Stands in for a turn that fails on its own. A script, not `/bin/false` — that is `/usr/bin/false` on macOS. */ -const FAILING_BIN = join(stubDir, 'failing-opencode'); -writeFileSync(FAILING_BIN, '#!/bin/sh\necho "boom" >&2\nexit 1\n'); -chmodSync(FAILING_BIN, 0o755); - -/** Records the argv it was invoked with, so the command line itself can be asserted. */ -const ARGS_FILE = join(stubDir, 'args.txt'); -const RECORDING_BIN = join(stubDir, 'recording-opencode'); -writeFileSync(RECORDING_BIN, `#!/bin/sh\nprintf '%s\\n' "$@" > ${ARGS_FILE}\nexit 0\n`); -chmodSync(RECORDING_BIN, 0o755); - -const CONFIG = { bin: STUB_BIN, fallbackCwd: stubDir }; - -afterAll(() => { - rmSync(stubDir, { recursive: true, force: true }); -}); - -/** - * Wait for something to BECOME true, rather than sleeping a guessed interval and hoping. - * - * These tests spawn real processes, so every "has it happened yet" is at the mercy of machine load — - * and a fixed `sleep(750)` duly failed once on a box that was busy running opencode probes. Polling - * makes a slow machine slow instead of red. Absence assertions still need a fixed wait, since there is - * no event to wait for; those are marked where they appear. - */ -async function waitFor(what: () => boolean, timeoutMs = 8000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (what()) return; - await Bun.sleep(25); - } -} - -describe('runOpenCodeTurn — a second turn on a live session', () => { - it('lets the replacement keep the session: no error, still listed, still killable', async () => { - const sessionKey = 'sess-supersede'; - const messages: RunnerMessage[] = []; - const emit = (msg: RunnerMessage) => messages.push(msg); - - runOpenCodeTurn({ sessionKey, prompt: 'first', cwd: stubDir }, CONFIG, emit); - runOpenCodeTurn({ sessionKey, prompt: 'second', cwd: stubDir }, CONFIG, emit); - - // Wait for the superseded child to actually die — its exit handler is what used to reach across. - // Absence assertion, so there is no event to wait for: give it a generous fixed window instead, and - // wait on something observable (the kill landing) rather than purely on the clock. - await waitFor(() => listRunningOpenCodeTurns().length === 1); - await Bun.sleep(1500); - - // 1. Nothing is emitted for a turn the system replaced on purpose. This one mattered most: the emit - // is committed to chat_session_events by the sidecar, so a false "OpenCode exited" became history. - expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toEqual([]); - - // 2. The replacement is still enumerable — the Live panel's whole purpose. - expect(listRunningOpenCodeTurns()).toContainEqual({ sessionKey }); - - // 3. And still reachable by the stop button, rather than orphaned with no handle. - killOpenCodeTurn(sessionKey); - await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')); - expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey }); - expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(true); - }); - - it('stops every in-flight turn on shutdown, and says why in the transcript', async () => { - // `opencode run` is spawned, not supervised, so a sidecar restart used to leave these alive: - // reparented, still spending tokens and still writing files, with nothing reading their output. - const messages: RunnerMessage[] = []; - const emit = (m: RunnerMessage) => messages.push(m); - - runOpenCodeTurn({ sessionKey: 'sess-x', prompt: 'a', cwd: stubDir }, CONFIG, emit); - runOpenCodeTurn({ sessionKey: 'sess-y', prompt: 'b', cwd: stubDir }, CONFIG, emit); - expect(listRunningOpenCodeTurns()).toHaveLength(2); - - const stopped = stopAllOpenCodeTurns('sidecar restarted'); - - // Synchronous on purpose: the caller is about to call process.exit, so nothing that waits for - // `proc.exited` would ever run. A turn killed that way just trails off mid-tool-call. - expect(stopped).toBe(2); - expect(listRunningOpenCodeTurns()).toEqual([]); - const errors = messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error'); - expect(errors).toHaveLength(2); - - // And the late exits must not add a second, worse ending on top of the one just written. - await Bun.sleep(500); - expect(messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error')).toHaveLength(2); - expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'stopped')).toBe(false); - }); - - it('puts `--` between the attachments and the prompt, or the prompt is eaten as a filename', async () => { - // `--file` is an ARRAY option in opencode's parser, so it keeps consuming positionals. Without the - // separator the turn dies with `File not found: ` — confirmed against the - // real binary before this was written. Nothing else in the arg list can catch that, so it is pinned - // here rather than left to the next person to rediscover. - const messages: RunnerMessage[] = []; - runOpenCodeTurn( - { - sessionKey: 'sess-images', - prompt: 'describe this', - cwd: stubDir, - images: [{ mediaType: 'image/png', data: Buffer.from('not-really-a-png').toString('base64') }], - }, - { ...CONFIG, bin: RECORDING_BIN }, - (m) => messages.push(m), - ); - - await waitFor(() => existsSync(ARGS_FILE)); - const argv = readFileSync(ARGS_FILE, 'utf8').trim().split('\n'); - - const fileFlag = argv.indexOf('--file'); - const separator = argv.indexOf('--'); - expect(fileFlag).toBeGreaterThan(-1); - expect(separator).toBeGreaterThan(fileFlag); - expect(argv.at(-1)).toBe('describe this'); - // The attachment is a path on disk, not inline data — that is the whole reason for the temp file. - expect(argv[fileFlag + 1]).toMatch(/officer-oc-.*\.png$/); - }); - - it('leaves no `--` and no temp files behind when the turn carries no images', async () => { - const messages: RunnerMessage[] = []; - rmSync(ARGS_FILE, { force: true }); - runOpenCodeTurn( - { sessionKey: 'sess-noimg', prompt: 'plain', cwd: stubDir }, - { ...CONFIG, bin: RECORDING_BIN }, - (m) => messages.push(m), - ); - - await waitFor(() => existsSync(ARGS_FILE)); - const argv = readFileSync(ARGS_FILE, 'utf8').trim().split('\n'); - - // A bare `--` would be harmless here, but its absence is what proves the separator is tied to the - // attachments rather than added unconditionally. - expect(argv).not.toContain('--'); - expect(argv).not.toContain('--file'); - expect(argv.at(-1)).toBe('plain'); - }); - - it('reports a missing binary instead of throwing out of the handler', async () => { - // `Bun.spawn` throws on ENOENT rather than returning a failed process, and that throw used to escape - // `runOpenCodeTurn` before any event was emitted — so the browser kept a spinner nothing could end. - // A wrong OPENCODE_BIN is the ordinary way to get here. - const messages: RunnerMessage[] = []; - expect(() => - runOpenCodeTurn( - { sessionKey: 'sess-nobin', prompt: 'hi', cwd: stubDir }, - { ...CONFIG, bin: join(stubDir, 'nope') }, - (m) => messages.push(m), - ), - ).not.toThrow(); - - const errors = messages.filter((m) => m.type === 'opencode:event' && m.event.type === 'error'); - expect(errors).toHaveLength(1); - // The path is in the message: this is nearly always a misconfiguration, and naming the binary it - // tried is the difference between a fix and a debugging session. - expect(JSON.stringify(errors[0])).toContain('nope'); - // And it must not leave a phantom entry behind for the Live panel or the stop button. - expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey: 'sess-nobin' }); - }); - - it('still reports a turn that dies on its own, rather than swallowing every exit', async () => { - // The guard must not overreach: an ordinary failure is still an error the user needs to see. - const sessionKey = 'sess-solo'; - const messages: RunnerMessage[] = []; - - runOpenCodeTurn({ sessionKey, prompt: 'only', cwd: stubDir }, { ...CONFIG, bin: FAILING_BIN }, (m) => - messages.push(m), - ); - await waitFor(() => messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')); - - expect(messages.some((m) => m.type === 'opencode:event' && m.event.type === 'error')).toBe(true); - expect(listRunningOpenCodeTurns()).not.toContainEqual({ sessionKey }); - }); -}); diff --git a/src/servers/sidecar/opencode/runner.ts b/src/servers/sidecar/opencode/runner.ts deleted file mode 100644 index 9ee005f0..00000000 --- a/src/servers/sidecar/opencode/runner.ts +++ /dev/null @@ -1,463 +0,0 @@ -import { existsSync, unlinkSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import type { Subprocess } from 'bun'; -import type { ChatEvent, MessageCost, PromptImage } from '../../api/chat/types'; -import type { OpenCodeRunParams } from '../protocol'; - -// Drives one chat turn by spawning `opencode run … --format json` and mapping its newline-delimited -// JSON events to the shared ChatEvent contract. This is the reliable path: `--dir ` hard-anchors -// every tool to the chat's working directory, and `run` (unlike serve + POST /message) reports tool -// completion faithfully and exits when the turn is done — no wedged "running" tools. `--dangerously- -// skip-permissions` auto-approves so tools never block on an approval we can't answer over this channel. -// -// A watchdog guards against a genuinely hung child (an interactive prompt, a `sleep`, a network stall): -// an inactivity timer (reset on every stdout chunk) and an absolute per-turn ceiling both kill the -// process and emit a clean `error`, so the UI never sits at "Working…" forever. - -const INACTIVITY_MS = 120_000; // no stdout for this long → assume wedged, kill -const HARD_CAP_MS = 10 * 60_000; // absolute per-turn ceiling - -export type RunnerConfig = { - bin: string; // absolute path to the opencode binary - fallbackCwd: string; // used when params.cwd is missing/nonexistent -}; - -// What a turn reports to the sidecar it runs in. `opencode:event` is deliberately not a wire event any -// more: the sidecar translates each one into a TurnMessage and commits it before officer sees anything, -// so the durable record does not depend on officer being up (see index.ts). -export type RunnerMessage = - | { type: 'opencode:event'; sessionKey: string; event: ChatEvent } - | { type: 'opencode:session'; sessionKey: string; sessionId: string }; - -type Emit = (msg: RunnerMessage) => void; - -type RunHandle = { - proc: Subprocess; - killedByUser: boolean; - /** - * Set when a newer turn has taken this sessionKey over. - * - * A killed process dies asynchronously, so a replaced turn's `proc.exited` fires LONG after its - * replacement is already running and registered under the same key. Without this flag that late - * handler ran the full completion path against the wrong turn: it emitted `OpenCode exited with code - * 143` — which the sidecar commits to `chat_session_events`, so a false failure became permanent - * history — and then deleted its replacement from `running`, which blinded the Live panel, made the - * stop button a no-op, and orphaned a process nothing could reach. - */ - superseded: boolean; - /** - * End this turn from outside the closure that owns it, with a reason. - * - * `killOpenCodeTurn` can kill a process and let `proc.exited` do the rest, because it has time. - * Shutdown does not: the sidecar is about to call `process.exit`, so nothing asynchronous will ever - * run again and a turn killed that way would simply stop mid-sentence, leaving a transcript that - * trails off. Settling synchronously is what puts the explanation in the log before we go. - */ - finish: (event: ChatEvent) => void; -}; - -// One turn per sessionKey; a new turn supersedes any stale process for that key. -const running = new Map(); - -// Shape of `opencode run --format json` events. -// -// Verified live against opencode 1.18.11 (this Mac) and 1.17.9 (alpha) — MEASURED on 2026-08-10, having -// previously been recorded the other way round here: the "this server" in the original note meant alpha, -// and the comment was copied to a machine where it was false. Nothing enforces a version anyway; the -// binary is whatever is installed, and the two machines in this project already differ. -// -// `runner.test.ts` pins the mapping so a shape change fails a test rather than a turn; if it starts -// failing, re-read the NDJSON from the installed binary before editing the test. -type RunPart = { - type?: string; - text?: string; - tool?: string; - callID?: string; - state?: { status?: string; input?: unknown; output?: unknown; error?: unknown }; - tokens?: { input?: number; output?: number }; - cost?: number; -}; -type RunEvent = { type?: string; sessionID?: string; part?: RunPart }; - -export function runOpenCodeTurn(params: OpenCodeRunParams, config: RunnerConfig, emit: Emit): void { - const { sessionKey } = params; - - // Supersede any lingering turn for this session. Mark it BEFORE killing: the flag is what tells its - // own exit handler that this death was intentional and belongs to nobody. - const stale = running.get(sessionKey); - if (stale) { - stale.superseded = true; - try { - stale.proc.kill(); - } catch { - /* already gone */ - } - running.delete(sessionKey); - } - - const args = ['run', '--format', 'json', '--dangerously-skip-permissions']; - if (params.cwd) args.push('--dir', params.cwd); - if (params.model) args.push('--model', params.model); - if (params.resumeSessionId) args.push('--session', params.resumeSessionId); - - // Attachments are paths, not inline data, so the images are spilled to temp files for the length of - // the turn and removed in `settle`. - const imagePaths = writeTurnImages(params.images); - for (const path of imagePaths) args.push('--file', path); - - // `--` BEFORE the prompt, and it is load-bearing: `--file` is an ARRAY option, so without the - // separator the prompt is swallowed as another filename and the turn dies with - // `File not found: `. Verified against the binary. - if (imagePaths.length > 0) args.push('--'); - args.push(params.prompt); - - const cwd = params.cwd && existsSync(params.cwd) ? params.cwd : config.fallbackCwd; - - // `Bun.spawn` THROWS on a missing or non-executable binary rather than resolving to a failed process, - // and that throw used to escape `runOpenCodeTurn` entirely: past the session bookkeeping below, out of - // the sidecar's command handler, with no `opencode:event` ever emitted. The browser sat on a spinner - // that nothing would ever end, because the code that ends turns had not been reached yet. - // - // A wrong `OPENCODE_BIN` is the ordinary cause, and it deserves to say so on screen instead of hanging. - let proc: Subprocess; - try { - proc = Bun.spawn([config.bin, ...args], { - cwd, - stdin: 'ignore', // == /dev/null: `run` hangs waiting on stdin otherwise - stdout: 'pipe', - stderr: 'pipe', - }); - } catch (err) { - cleanUpTurnImages(imagePaths); - const reason = err instanceof Error ? err.message : String(err); - emit({ - type: 'opencode:event', - sessionKey, - event: { type: 'error', message: `Could not start OpenCode (${config.bin}): ${reason}` }, - }); - return; - } - - // `finish` is a placeholder for the few synchronous lines until the real one below exists — it closes - // over `handle`, so the two cannot both be defined first. Nothing can call it in between. - const handle: RunHandle = { proc, killedByUser: false, superseded: false, finish: () => {} }; - running.set(sessionKey, handle); - - let done = false; - let reportedSession = false; - let cost: MessageCost = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; - let stderrTail = ''; - - const emitEvent = (event: ChatEvent) => emit({ type: 'opencode:event', sessionKey, event }); - - let inactivityTimer: ReturnType | undefined; - - /** - * Retire this turn: stop its watchdogs, release its slot, and optionally say why it ended. - * - * The delete is identity-checked because `sessionKey` is not this turn's to own once it has been - * superseded — the map may already hold a live replacement under that key, and deleting by name alone - * removed it. `null` retires silently, which is what a superseded turn needs: it must still clear its - * timers (an armed 10-minute `hardTimer` would otherwise fire an error at whichever turn holds the key - * by then, reproducing the same cross-talk on a delay) while emitting nothing at all. - */ - const settle = (event: ChatEvent | null) => { - if (done) return; - done = true; - clearTimeout(hardTimer); - if (inactivityTimer) clearTimeout(inactivityTimer); - if (running.get(sessionKey) === handle) running.delete(sessionKey); - cleanUpTurnImages(imagePaths); - if (event) emitEvent(event); - }; - - const finish = (event: ChatEvent) => settle(event); - handle.finish = finish; - - // ── Watchdogs ── - const hardTimer = setTimeout(() => { - try { - proc.kill(); - } catch { - /* already gone */ - } - finish({ type: 'error', message: `OpenCode turn exceeded ${HARD_CAP_MS / 1000}s and was stopped` }); - }, HARD_CAP_MS); - - const bumpInactivity = () => { - if (done) return; - if (inactivityTimer) clearTimeout(inactivityTimer); - inactivityTimer = setTimeout(() => { - try { - proc.kill(); - } catch { - /* already gone */ - } - finish({ - type: 'error', - message: `OpenCode turn stalled (no output for ${INACTIVITY_MS / 1000}s) and was stopped`, - }); - }, INACTIVITY_MS); - }; - bumpInactivity(); - - // ── Capture a tail of stderr for error reporting ── - void (async () => { - const dec = new TextDecoder(); - try { - for await (const chunk of proc.stderr as unknown as AsyncIterable) { - stderrTail = (stderrTail + dec.decode(chunk, { stream: true })).slice(-2000); - } - } catch { - /* stream closed */ - } - })(); - - // ── Parse stdout: newline-delimited JSON events ── - void (async () => { - const dec = new TextDecoder(); - let buf = ''; - try { - for await (const chunk of proc.stdout as unknown as AsyncIterable) { - bumpInactivity(); - buf += dec.decode(chunk, { stream: true }); - let nl: number; - while ((nl = buf.indexOf('\n')) >= 0) { - const line = buf.slice(0, nl).trim(); - buf = buf.slice(nl + 1); - if (line) handleLine(line); - } - } - const last = buf.trim(); - if (last) handleLine(last); - } catch { - /* stream closed / process killed */ - } - })(); - - function handleLine(line: string): void { - // A retired turn says nothing more. Stdout is drained asynchronously, so a killed process can still - // have buffered lines in flight — and for a superseded turn those would be emitted under a - // sessionKey that now belongs to its replacement, interleaving one turn's output into another's. - if (done) return; - - const mapped = mapRunLine(line); - if (!mapped) return; - - // Report the OpenCode session id once, so the API can resume it (`--session`) next turn. - if (!reportedSession && mapped.sessionId) { - reportedSession = true; - emit({ type: 'opencode:session', sessionKey, sessionId: mapped.sessionId }); - } - - cost = { - inputTokens: cost.inputTokens + mapped.costDelta.inputTokens, - outputTokens: cost.outputTokens + mapped.costDelta.outputTokens, - totalUSD: cost.totalUSD + mapped.costDelta.totalUSD, - }; - - for (const event of mapped.events) emitEvent(event); - } - - // ── Completion: process exit is the authoritative turn-done signal ── - void proc.exited.then((code) => { - if (done) return; - // Replaced on purpose: not a result, not an error, and not this turn's session any more. - if (handle.superseded) { - settle(null); - return; - } - if (handle.killedByUser) { - finish({ type: 'stopped' }); - return; - } - if (code === 0) { - finish({ type: 'result', cost }); - return; - } - const tail = stderrTail.trim(); - finish({ - type: 'error', - message: tail ? `OpenCode exited (${code}): ${tail.slice(-500)}` : `OpenCode exited with code ${code}`, - }); - }); -} - -/** - * The turns this process is running right now. - * - * The OpenCode analog of `claude-manager.listSessions`, and deliberately thinner. Claude holds a warm - * session that outlives a turn, so it can report one that is merely open; OpenCode spawns a subprocess - * per turn and has nothing between them. So a session appears here only while it is generating — which - * is exactly the state the Live panel exists to show, and the state that was invisible for OpenCode. - * - * No `pendingTasks`: `opencode run` has no background-task concept, so reporting 0 would suggest a - * capability that does not exist rather than an empty one. - */ -export function listRunningOpenCodeTurns(): { sessionKey: string }[] { - return Array.from(running.keys()).map((sessionKey) => ({ sessionKey })); -} - -// ── Image attachments ── -// -// `opencode run` takes files by PATH (`--file`), while the browser sends base64 over the socket, so the -// two are bridged by a temp file per image that lives exactly as long as the turn. -// -// Failing to write one is deliberately not fatal: an image that cannot be spilled costs the model that -// image, and sending the text anyway is better than failing a turn the user has already waited for. -// This is the same call the composer's own gate makes — degrade, do not remove the feature. - -/** Extension by media type, so the model sees a file it can identify. Unknown types keep `.bin`. */ -const IMAGE_EXTENSIONS: Record = { - 'image/png': 'png', - 'image/jpeg': 'jpg', - 'image/jpg': 'jpg', - 'image/gif': 'gif', - 'image/webp': 'webp', -}; - -function writeTurnImages(images: PromptImage[] | undefined): string[] { - if (!images?.length) return []; - const written: string[] = []; - for (const [index, image] of images.entries()) { - try { - const ext = IMAGE_EXTENSIONS[image.mediaType?.toLowerCase() ?? ''] ?? 'bin'; - const path = join(tmpdir(), `officer-oc-${process.pid}-${Date.now()}-${index}.${ext}`); - writeFileSync(path, Buffer.from(image.data, 'base64')); - written.push(path); - } catch (err) { - console.error('[opencode] could not write an attachment; continuing without it:', err); - } - } - return written; -} - -function cleanUpTurnImages(paths: string[]): void { - for (const path of paths) { - try { - unlinkSync(path); - } catch { - /* already gone */ - } - } -} - -/** - * Kill every turn this process is running, because the process itself is going away. - * - * A turn is a child of this sidecar only in the bookkeeping sense: `opencode run` is spawned, not - * supervised, so `pm2 restart officer-opencode` used to leave every in-flight turn ALIVE — reparented, - * still spending tokens, and still writing files as the agent, while the only reader of its stdout had - * exited. The turn's output went nowhere and the transcript simply stopped mid-tool-call, which is - * indistinguishable from the agent hanging. - * - * Both halves matter. Killing the children stops the invisible work; settling them synchronously writes - * a reason into the transcript, so a reload after a restart explains itself instead of trailing off. - * Returns how many were stopped, so the caller can skip the flush wait when there were none. - */ -export function stopAllOpenCodeTurns(message: string): number { - const handles = [...running.values()]; - for (const handle of handles) { - // Suppress the exit handler's own error: this death is accounted for, and `finish` below is the - // account. Without it a late `proc.exited` would be a second, less accurate ending. - handle.killedByUser = true; - try { - handle.proc.kill(); - } catch { - /* already gone */ - } - handle.finish({ type: 'error', message }); - } - return handles.length; -} - -export function killOpenCodeTurn(sessionKey: string): void { - const handle = running.get(sessionKey); - if (!handle) return; - handle.killedByUser = true; - try { - handle.proc.kill(); - } catch { - /* already gone */ - } - // proc.exited fires → finish({ type: 'stopped' }). -} - -// ── The NDJSON → ChatEvent mapping, as a pure function ── -// -// Extracted from `handleLine` so it can be tested without spawning a binary. This is the piece most -// likely to break against a new OpenCode release — the event shape is not a stable contract and this -// project already runs two different versions across two machines — and it was the only untested part -// of the path. `runner.test.ts` pins it. -// -// Pure by construction: it takes a line and returns what should happen, holding no state. The caller -// owns the two stateful concerns, because they span lines rather than belonging to one: emitting the -// session id exactly once, and accumulating cost across steps. - -export type MappedRunLine = { - /** Present on any event that names a session; the caller emits it only the first time. */ - sessionId?: string; - /** Events to forward, in order. A tool part yields `tool:start` then `tool:result`. */ - events: ChatEvent[]; - /** Per-step tokens/cost to add to the turn total. Zeroes for every non-`step_finish` line. */ - costDelta: { inputTokens: number; outputTokens: number; totalUSD: number }; -}; - -const NO_COST = { inputTokens: 0, outputTokens: 0, totalUSD: 0 }; - -/** `null` for a line that is not JSON at all — `opencode` interleaves plain log lines with the stream. */ -export function mapRunLine(line: string): MappedRunLine | null { - let evt: RunEvent; - try { - evt = JSON.parse(line) as RunEvent; - } catch { - return null; // non-JSON log line - } - - const sessionId = evt.sessionID; - const events: ChatEvent[] = []; - - switch (evt.type) { - case 'text': { - // `run` emits complete text parts (not token deltas) — forward each as final text. - const text = evt.part?.text; - if (typeof text === 'string' && text.length > 0) events.push({ type: 'text', text }); - return { sessionId, events, costDelta: NO_COST }; - } - case 'tool_use': { - // In `run`, the tool part arrives already resolved (status + output). Emit start then result. - const part = evt.part; - if (!part || part.type !== 'tool' || !part.callID) return { sessionId, events, costDelta: NO_COST }; - const st = part.state ?? {}; - events.push({ - type: 'tool:start', - toolCallId: part.callID, - toolName: part.tool ?? 'tool', - toolInput: (st.input as Record) ?? {}, - }); - const isError = st.status === 'error'; - events.push({ - type: 'tool:result', - toolCallId: part.callID, - output: String((isError ? st.error : st.output) ?? ''), - isError, - }); - return { sessionId, events, costDelta: NO_COST }; - } - case 'step_finish': { - const t = evt.part?.tokens; - return { - sessionId, - events, - costDelta: { - inputTokens: t?.input ?? 0, - outputTokens: t?.output ?? 0, - totalUSD: typeof evt.part?.cost === 'number' ? evt.part.cost : 0, - }, - }; - } - default: - return { sessionId, events, costDelta: NO_COST }; // step_start etc. — nothing to forward - } -} diff --git a/src/servers/sidecar/opencode/serve-runner.ts b/src/servers/sidecar/opencode/serve-runner.ts index a077b372..3812ff79 100644 --- a/src/servers/sidecar/opencode/serve-runner.ts +++ b/src/servers/sidecar/opencode/serve-runner.ts @@ -1,19 +1,17 @@ import type { ChatEvent, MessageCost } from '../../api/chat/types'; import type { OpenCodeRunParams } from '../protocol'; -import type { RunnerMessage } from './runner'; import { mapServeEvent } from './serve-events'; -// Phase B: drive a turn through the serve instead of spawning `opencode run`. +// How an OpenCode turn runs. The only way, since 2026-08-10. // -// OFF BY DEFAULT. `index.ts` picks between this and `runner.ts` on `OPENCODE_TURNS`, and the subprocess -// stays the default until this has run for a while — a bad day should be one restart from the path that -// has worked all along, not a rollback. +// It used to be `opencode run --format json`, a subprocess per turn with `stdin: 'ignore'`. Everything +// that path could not do followed from that one closed pipe: no token streaming, no mid-turn injection, +// no queue, and a stop that could only kill the session rather than interrupt it. The serve offers all +// four as primitives, each verified end to end before the subprocess was deleted +// (docs/opencode-fork-decision.md, docs/opencode-serve-migration-plan.md). // -// ── Why bother, given the subprocess works ── -// -// Everything the subprocess cannot do is a consequence of `stdin: 'ignore'`: no token streaming, no -// mid-turn injection, no queue, no interrupt that leaves the session alive. The serve offers all four as -// primitives, verified against 1.18.16 (docs/opencode-fork-decision.md). +// There is no fallback engine any more. If this path breaks, the recovery is git, not a config flag — +// a deliberate choice made while nothing depended on OpenCode. // // ── One global stream, demultiplexed ── // @@ -39,6 +37,18 @@ type ServeConfig = { fallbackCwd: string; }; +/** + * What a turn reports to the sidecar it runs in. + * + * Lived in `runner.ts` until the subprocess path was deleted (Phase D). `opencode:event` is not a wire + * event: the sidecar translates each one into a TurnMessage and commits it to `chat_session_events` + * before officer sees anything, so the durable record does not depend on officer being up. + * `opencode:session` is the routing fact — which `ses_…` to resume — and goes over the wire live. + */ +export type RunnerMessage = + | { type: 'opencode:event'; sessionKey: string; event: ChatEvent } + | { type: 'opencode:session'; sessionKey: string; sessionId: string }; + type Emit = (msg: RunnerMessage) => void; type ServeTurn = { @@ -301,7 +311,7 @@ function promptFiles(images: OpenCodeRunParams['images']): { uri: string; name: })); } -/** The serve analog of `listRunningOpenCodeTurns`. Same shape, so the Live panel needs no changes. */ +/** The turns running right now, for `opencode:list` and the Live panel. */ export function listRunningServeTurns(): { sessionKey: string }[] { return [...bySessionKey.keys()].map((sessionKey) => ({ sessionKey })); } @@ -326,7 +336,14 @@ export async function killServeTurn(sessionKey: string, config: ServeConfig): Pr retire(turn, { type: 'stopped' }); } -/** Retire every live turn, for shutdown. Mirrors `stopAllOpenCodeTurns` on the subprocess path. */ +/** + * Retire every live turn, for shutdown. + * + * Note what this does NOT do, and why it is right: the turns keep running inside the serve, which is a + * separate process and survives us. Officer stops routing them and says so in the transcript. When the + * subprocess ran turns, shutdown had to kill children or they were orphaned; here the work is somebody + * else's and killing it would be the wrong call. + */ export function stopAllServeTurns(message: string): number { const turns = [...bySessionKey.values()]; for (const turn of turns) retire(turn, { type: 'error', message }); diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index d6d55768..95bd97c7 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -26,8 +26,8 @@ export type LiveClaudeSession = { }; /** - * An OpenCode turn in flight. Only ever the generating ones — see `listRunningOpenCodeTurns` for why - * this carries neither `isGenerating` (it is always true) nor `pendingTasks` (no such concept). + * An OpenCode turn in flight. Only ever the generating ones, which is why it carries neither + * `isGenerating` (always true) nor `pendingTasks` (no such concept on this harness). * * It deliberately carries no id for OpenCode's own `ses_…` session, and does not need to: the sidecar * only ever knows its own `sessionKey`, while the `ses_…` is reported separately over `opencode:session` @@ -63,12 +63,13 @@ export type SidecarCommand = // records are in memory and die with `pm2 restart officer`, while the agent keeps running. Without it // a live session is invisible until a browser happens to reconnect to it by id. | { type: 'claude:list'; id: string } - // OpenCode — drive a turn via `opencode run … --format json` (tools re-anchored to cwd via --dir) + // OpenCode — drive a turn through the serve (POST /api/session/{id}/prompt), anchored to the chat cwd + // by a per-request location header. Was an `opencode run` subprocess until 2026-08-10. | { type: 'opencode:run-streaming'; id: string; params: OpenCodeRunParams } | { type: 'opencode:kill'; id: string; sessionKey: string } // Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a // reason: OpenCode has no warm session between turns, so there is nothing to report but the running - // ones. See `listRunningOpenCodeTurns`. + // ones — see `listRunningServeTurns`. | { type: 'opencode:list'; id: string } // VNC | { type: 'vnc:start'; id: string; params: VncStartParams } From 45e3e86d72a442dbe89ebb4fb1f00fc123147e71 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 19:34:48 +0100 Subject: [PATCH 18/42] write down what to test, and what is most likely broken MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The serve is the only path now, so this is not a comparison against a fallback. Split by what I have actually driven end to end versus what probes cannot answer. The second list is the real testing: resume from history (never exercised against the serve, and my pick for most likely broken), an idle session, a sidecar restart mid-turn, an officer restart mid-turn, and two conversations at once — that last one because the live event stream is global and a wrong sessionID filter would splice one conversation into another. Known gaps are listed so they do not get reported as bugs, and the one silent failure mode with a single cause — a turn producing nothing at all — points at the credential line from boot, which I have chased twice already. Co-Authored-By: Claude Opus 5 --- docs/opencode-serve-migration-plan.md | 8 ++- docs/opencode-testing-checklist.md | 86 +++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 docs/opencode-testing-checklist.md diff --git a/docs/opencode-serve-migration-plan.md b/docs/opencode-serve-migration-plan.md index 92208fa9..3ab2a073 100644 --- a/docs/opencode-serve-migration-plan.md +++ b/docs/opencode-serve-migration-plan.md @@ -67,9 +67,13 @@ Each of these cost time to find. None is in the API docs. - **Phase C — server half done.** A message sent while a turn runs is injected with `delivery: "steer"` into the RUNNING turn, verified end to end. No client change was needed: officer's composer already sends mid-turn, and the subprocess path was superseding where the serve steers. -- **Phase D — not started, deliberately.** +- **Phase D — done.** The subprocess is deleted: no `runner.ts`, no `OPENCODE_TURNS` switch, no fallback + engine. Andre called it — nothing depends on OpenCode, so the cost of removing the escape hatch is + near zero and the recovery is git. 818 lines went with it, all of them workarounds for `stdin` being + `/dev/null`. -Not yet lived with. Nothing here has run a real conversation with a person at the other end. +Not yet lived with. **Nothing here has run a real conversation with a person at the other end** — see +`docs/opencode-testing-checklist.md` for what to try and what is most likely to be broken. ## Shape of the work diff --git a/docs/opencode-testing-checklist.md b/docs/opencode-testing-checklist.md new file mode 100644 index 00000000..597759db --- /dev/null +++ b/docs/opencode-testing-checklist.md @@ -0,0 +1,86 @@ +# OpenCode: what to test tonight + +Every OpenCode turn now runs through the serve (`serve-runner.ts`). The `opencode run` subprocess is +deleted, so this is not a comparison against a fallback — it is the only path. + +I have driven each item in **Should already work** end to end through the real chat socket, with a +script rather than a browser. Nothing below has been used by a person in a real conversation, and the +things in **Unproven** are unproven because probes cannot answer them. + +--- + +## Before you start + +```bash +cd ~/projects/officer-suite/platform +pm2 logs officer-opencode --lines 40 # the sidecar's own account of what happened +``` + +On boot you should see three lines: `serve healthy on port …`, `connected the opencode credential to the +api surface`, and the sidecar registering. **If the credential line is missing or says it could not +connect, stop** — paid models will silently do nothing, which is the failure that cost most of an +afternoon (`docs/opencode-fork-decision.md`). + +Rolling back is `git revert` of `a3dbda7` (Phase D) and a restart. There is no config flag any more. + +Alpha is unaffected until you pull. + +--- + +## Should already work — confirm, do not investigate + +Each verified by me end to end. If one fails, that is new information and worth stopping on. + +- [ ] **A plain turn.** Ask for something short. Text arrives. +- [ ] **Streaming.** Ask for something long — "count slowly to 50". Text should appear **progressively**, + not in one block at the end. This is the headline change; the old path could not do it. +- [ ] **A tool call.** "Run `echo hi` with bash." A tool row appears with the command as its arguments, + then its output. +- [ ] **Cost.** The turn ends with a token count attached. +- [ ] **An image.** Drop a screenshot in and ask what it shows. It must actually be described — the + failure mode is a confident answer about nothing, which is what B4 was. +- [ ] **Stop.** Press stop mid-turn. The turn ends **and the conversation stays usable** — send another + message straight after and it should answer. Previously stop destroyed the session. +- [ ] **Mid-turn injection.** While a turn runs, send another message. It should join the RUNNING turn + rather than starting a new one or superseding it. + +## Unproven — this is the actual testing + +- [ ] **Resume from history.** Open an older OpenCode conversation from the list and continue it. This + exercises `resumeSessionId`, which I never tested against the serve. **Most likely thing to be + broken.** +- [ ] **An idle session, an hour later.** Send a message to a conversation you have not touched for a + while. Warm sessions are new here — the subprocess had nothing to go stale. +- [ ] **A sidecar restart mid-turn.** `pm2 restart officer-opencode` while a turn is generating. Expect + the transcript to say the turn stopped. **The turn itself keeps running inside the serve** — that + is intended, not a bug, but nobody has watched what it looks like from the browser. +- [ ] **An officer restart mid-turn.** Different from the above: officer is the relay, the sidecar keeps + committing to `chat_session_events`. On reload the transcript should be intact. This is the one I + would least like to be wrong about. +- [ ] **Two conversations at once.** The live event stream is GLOBAL — one socket carries every session + and `serve-runner` filters on `sessionID`. If that filter is wrong, output from one conversation + appears in another. Two panels side by side is the test. +- [ ] **The Live panel.** A running OpenCode turn should appear, named, and disappear when it ends. +- [ ] **A long turn.** Ten-plus minutes. The old path had watchdogs (inactivity, hard cap) that are gone + with it; the serve has its own ideas about timeouts and I have not found their edges. +- [ ] **A failing tool.** Ask it to run a command that does not exist. The error should land in the + transcript as a failed tool, not as a dead turn. + +## Known gaps, so you do not report them as bugs + +- **No durable replay.** `serve-runner` reads the live stream only. Events are still committed to + `chat_session_events` as they arrive, so the transcript survives — but recovering a turn *this sidecar + process never saw* would need the `?after=` cursor, and that is not built. +- **Notifications/thinking/background tasks.** Not implemented on this harness. Thinking is deliberately + out of scope for both harnesses. +- **`messageCount` shows nothing** for OpenCode rows. The UI renders an `OpenCode` badge instead; not a + gap (`docs/opencode-parity.md`). + +## What is useful to tell me + +For anything that misbehaves: what you did, what appeared, and the last twenty lines of +`pm2 logs officer-opencode`. The sidecar logs its own errors, and a silent failure with clean logs is a +different diagnosis from a loud one. + +If a turn produces **nothing at all** — no text, no error, no spinner ending — check the credential line +from boot first. That specific silence has one cause and I have chased it twice. From dc6b623ee19013439feeda2d001311a58248e4ae Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 20:06:35 +0100 Subject: [PATCH 19/42] talk to two officers at once from one browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat app on the iPad does this already and this is its model, not the music app one. Music keeps its active server — one library at a time is the right question there. Chat is the exception: two panels side by side, one on the laptop and one on alpha, both live, no switching. The mechanism is one string. A panel holds a serverId; that same string picks the base URL, the credential, the websocket host and the tail of the react-query key. Nothing global is consulted when it is named, which is exactly why two can be live at once — there is no active server in connections.ts at all, because there is nothing to switch. THIS ORIGIN IS NOT IN THE LIST. It is represented by null, so every existing useClient() call is untouched and adding a connection cannot break the app you are already signed into. That property is what makes this shippable before anyone has tried it. A second server is reached with an ofk_ API key minted there, verified against /api/auth/me before it is stored — a URL typo and a key from the wrong machine are otherwise indistinguishable from an empty conversation list an hour later. Copied deliberately from the mobile code: the base URL is derived per call rather than memoised (a cached one hands back whichever server was asked for first), the row stamps its server onto the selection BEFORE navigating (or the resolver reads the transcript from this origin, where two officers can hold the same uuid), and changing server clears the cwd and the open conversation, because a path from the machine you left names nothing on the one you arrived at. Not yet opened in a browser. Typecheck and 602 tests pass, and the cross-origin request with an API key is verified by curl, but no human has clicked any of this. Co-Authored-By: Claude Opus 5 --- .../Screens/Dashboard/ChatHistory/index.tsx | 14 +- src/workspaces/hooks/src/connections.ts | 146 ++++++++++++++ src/workspaces/hooks/src/index.ts | 12 ++ src/workspaces/hooks/src/useClient.ts | 63 +++--- src/workspaces/hooks/src/useServerClient.ts | 50 +++++ .../src/apps/ChatHistory/ChatDetailPanel.tsx | 19 +- .../src/apps/ChatHistory/ServerChips.tsx | 185 ++++++++++++++++++ .../src/apps/ChatHistory/SessionList.tsx | 27 ++- .../officerdev/src/hooks/useChat.ts | 24 ++- src/workspaces/state/src/useClaudeSessions.ts | 23 ++- 10 files changed, 514 insertions(+), 49 deletions(-) create mode 100644 src/workspaces/hooks/src/connections.ts create mode 100644 src/workspaces/hooks/src/useServerClient.ts create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 0d24e5ca..6cbc06c7 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -5,6 +5,7 @@ import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } fro import { toast } from '@/components/ui/sonner'; import { useIsMobile } from 'hooks/useIsMobile'; import { useClient } from 'hooks/useClient'; +import { serverClient } from 'hooks/useServerClient'; import { errorText } from 'helpers/error-text'; import { useDashboardState } from 'state/useDashboardState'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; @@ -73,7 +74,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { if (isNew) { // A new chat has no transcript to read a cwd from, so the group in the URL is the authority — // and it has to be on the selection, because that is what the composer runs in. - setSelected({ id: `new:${Date.now()}`, cwd: groupCwd }); + setSelected({ id: `new:${Date.now()}`, cwd: groupCwd, serverId: selectedRef.current?.serverId ?? null }); return; } if (!sessionId) return; @@ -81,7 +82,13 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { let cancelled = false; (async () => { try { - const detail = await client.get(`/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`); + // Read the transcript from the machine the row came from, not from this origin. The list + // stamps the server onto the selection before navigating, so it is known by the time this runs; + // a bare deep link has none and correctly resolves against this origin. + const remote = selectedRef.current?.serverId ?? null; + const detail = await serverClient(remote).get( + `/chat/sessions/${sessionId}?limit=${CHAT_TAIL}`, + ); if (cancelled) return; // The session's own cwd rides on the selection rather than being written back into the URL. // It used to do both, and the URL copy was the one the composer read — so a deep link ran its @@ -96,6 +103,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { cwd: detail.cwd, title: detail.title, partCount: detail.partCount, + serverId: remote, }); } catch (err) { if (cancelled) return; @@ -103,7 +111,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { // no hint that the transcript could not be read, which is indistinguishable from a new session. // Most often the id is stale — the transcript was deleted or pruned out from under the link. toast.error(`Could not load this conversation: ${errorText(err, 'not found')}`); - setSelected({ id: sessionId }); + setSelected({ id: sessionId, serverId: selectedRef.current?.serverId ?? null }); } })(); return () => { diff --git a/src/workspaces/hooks/src/connections.ts b/src/workspaces/hooks/src/connections.ts new file mode 100644 index 00000000..fd4d3ba2 --- /dev/null +++ b/src/workspaces/hooks/src/connections.ts @@ -0,0 +1,146 @@ +import { useSyncExternalStore } from 'react'; + +/** + * The other Officer servers this browser can talk to, and the credential for each. + * + * ## The model, which is the mobile chat app's and not the mobile app's + * + * `packages/core/src/services/servers.ts` in the mobile repo says "one active server at a time, + * always" — switching tears one session down and brings another up. That is right for Music, where the + * question "whose library is this" has one answer at a time, and that behaviour is untouched here. + * + * Chat is the deliberate exception. Andre works with two panes side by side, one talking to the laptop + * and one to alpha, both live, no switching. So there is **no active server in this module at all** — + * nothing to switch, nothing to leak. A caller names the server it wants or gets this origin. + * + * The mechanism is one string. A panel holds a `serverId`; that same string picks the base URL, the + * credential, the WebSocket host and the tail of every React Query key. Nothing global is consulted + * when it is named, which is precisely why two servers can be live at once. + * + * ## What lives here and what does not + * + * The list and its keys live in `localStorage`, because they must survive a reload and are per-browser + * by nature. What is deliberately NOT here is the layout — which tab holds which panes, and which pane + * is pointed where. A tab holding one conversation from the laptop and one from alpha belongs to + * neither server, so it is stored unscoped. + * + * ## THIS ORIGIN IS NOT IN THE LIST + * + * The server that served this page is always reachable and already authenticated by the session you + * signed in with. It is represented by `null`/absent — every existing `useClient()` call in the app + * passes no server and keeps working exactly as before. Adding a connection can therefore never break + * the app you are already using, which is the property that makes this safe to ship. + * + * ## The credential is an API key, not a password + * + * A second server is reached with an `ofk_…` key minted there (`POST /api/api-keys`). It carries the + * owner's full authority and does not expire, so it is a real secret sitting in `localStorage` — the + * same trade the mobile apps already make with their per-server tokens, on a device you control. + */ + +export type Connection = { + /** Derived from the URL, so re-adding the same server updates it rather than duplicating it. */ + id: string; + /** What to call it in the UI. Defaults to the host. */ + name: string; + /** Normalised, no trailing slash, includes the scheme. */ + baseUrl: string; + /** An `ofk_…` API key minted on THAT server. */ + key: string; +}; + +const STORE_KEY = 'officer.connections.v1'; + +/** + * Same derivation as the mobile app: the host, slugged. + * + * `https://example.com` and `example.com` are the same server and must not appear twice, so the scheme + * is only part of the id when it is explicitly insecure — otherwise adding a server by a slightly + * different URL silently produces a second entry with its own credential. + */ +export function connectionIdFor(rawUrl: string): string { + const url = normaliseUrl(rawUrl); + const { host, protocol } = new URL(url); + const slug = host.replace(/[^\w.-]+/g, '-'); + return protocol === 'http:' ? `http-${slug}` : slug; +} + +/** Assume https when no scheme is given — the mobile app's rule, and the safe default over a tailnet. */ +export function normaliseUrl(raw: string): string { + const trimmed = raw.trim().replace(/\/+$/, ''); + const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + return withScheme; +} + +// ── Storage, with subscribers so React re-renders on a change ── + +let cache: Connection[] | null = null; +const listeners = new Set<() => void>(); + +function read(): Connection[] { + if (cache) return cache; + try { + const raw = localStorage.getItem(STORE_KEY); + const parsed = raw ? (JSON.parse(raw) as unknown) : []; + cache = Array.isArray(parsed) ? (parsed as Connection[]).filter((c) => c?.id && c?.baseUrl && c?.key) : []; + } catch { + cache = []; // unreadable or not JSON — an empty list degrades to "this origin only" + } + return cache; +} + +function write(next: Connection[]): void { + cache = next; + try { + localStorage.setItem(STORE_KEY, JSON.stringify(next)); + } catch { + /* quota or private mode — the list still works for this page's lifetime */ + } + for (const listener of listeners) listener(); +} + +export function listConnections(): Connection[] { + return read(); +} + +export function getConnection(serverId: string | null | undefined): Connection | null { + if (!serverId) return null; + return read().find((c) => c.id === serverId) ?? null; +} + +export function upsertConnection(input: { url: string; key: string; name?: string }): Connection { + const baseUrl = normaliseUrl(input.url); + const id = connectionIdFor(baseUrl); + const connection: Connection = { + id, + name: input.name?.trim() || new URL(baseUrl).host, + baseUrl, + key: input.key.trim(), + }; + write([...read().filter((c) => c.id !== id), connection]); + return connection; +} + +export function removeConnection(serverId: string): void { + write(read().filter((c) => c.id !== serverId)); +} + +// `useSyncExternalStore` rather than a context: the store is a module singleton and every panel reads +// it independently, so there is no provider to place and no re-render cascade to reason about. +const subscribe = (listener: () => void) => { + listeners.add(listener); + return () => listeners.delete(listener); +}; + +export function useConnections(): Connection[] { + return useSyncExternalStore(subscribe, listConnections, () => []); +} + +/** + * The label for a server id, for a badge that says which machine a conversation is on. + * + * `null` means this origin, which is deliberately not in the list — see the module comment. + */ +export function connectionLabel(serverId: string | null | undefined, fallback = 'This server'): string { + return getConnection(serverId)?.name ?? fallback; +} diff --git a/src/workspaces/hooks/src/index.ts b/src/workspaces/hooks/src/index.ts index 9d7f3471..57e71466 100644 --- a/src/workspaces/hooks/src/index.ts +++ b/src/workspaces/hooks/src/index.ts @@ -1,6 +1,18 @@ export { useGlobal } from './useGlobal'; export { usePanelChannel } from './usePanelChannel'; export { useClient, createClient } from './useClient'; +export { + useConnections, + listConnections, + getConnection, + upsertConnection, + removeConnection, + connectionLabel, + connectionIdFor, + normaliseUrl, +} from './connections'; +export type { Connection } from './connections'; +export { useServerClient, serverClient, chatSocketUrl } from './useServerClient'; export { useDebounce } from './useDebounce'; export { useDragAndDrop } from './useDragAndDrop'; export { useImageLoader } from './useImageLoader'; diff --git a/src/workspaces/hooks/src/useClient.ts b/src/workspaces/hooks/src/useClient.ts index 2792cb7f..81c13655 100644 --- a/src/workspaces/hooks/src/useClient.ts +++ b/src/workspaces/hooks/src/useClient.ts @@ -1,7 +1,14 @@ import { useGlobal } from './useGlobal'; let theToken: string | null = null; -export const createClient = (baseUrl: string = '/api') => { +/** + * A client bound to one server. + * + * `baseUrl`/`token` omitted means this origin with the session you signed in with — every existing + * caller. Naming both is how a panel talks to ANOTHER Officer without any global being switched, which + * is what lets two panes hold two live conversations on two machines (see `connections.ts`). + */ +export const createClient = (baseUrl: string = '/api', token?: string | null) => { const lsToken = window.officerBearerToken || document.body.dataset['officerBearerToken'] || @@ -15,15 +22,15 @@ export const createClient = (baseUrl: string = '/api') => { return { baseUrl, - token: theToken, - get: (url: string) => get(url, baseUrl), - getText: (url: string) => getText(url, baseUrl), - getBlob: (url: string) => getBlob(url, baseUrl), + token: token ?? theToken, + get: (url: string) => get(url, baseUrl, token), + getText: (url: string) => getText(url, baseUrl, token), + getBlob: (url: string) => getBlob(url, baseUrl, token), // getStream: (url) => getStream(url, baseUrl), - post: (url: string, payload?: any) => post(url, payload, baseUrl), - put: (url: string, payload?: any) => put(url, payload, baseUrl), - patch: (url: string, payload?: any) => patch(url, payload, baseUrl), - delete: (url: string, payload?: any) => DELETE(url, payload, baseUrl), + post: (url: string, payload?: any) => post(url, payload, baseUrl, token), + put: (url: string, payload?: any) => put(url, payload, baseUrl, token), + patch: (url: string, payload?: any) => patch(url, payload, baseUrl, token), + delete: (url: string, payload?: any) => DELETE(url, payload, baseUrl, token), }; }; @@ -37,11 +44,15 @@ export const useClient = (baseUrl: string = '/api') => { return { ...client, apiError, setApiError }; }; -export const getHeaders = (isText: boolean = false) => { +export const getHeaders = (isText: boolean = false, token?: string | null) => { const headers: Record = {}; - if (theToken) { - headers['Authorization'] = `Bearer ${theToken}`; + // `token` names a specific server's credential; omitted falls back to this origin's session, which is + // every existing caller. See `connections.ts` — a second server is reached by naming it, never by + // switching a global, so nothing here changes for the app you are already signed into. + const effective = token ?? theToken; + if (effective) { + headers['Authorization'] = `Bearer ${effective}`; } headers['Content-Type'] = isText ? 'text/plain' : 'application/json'; @@ -60,8 +71,8 @@ const parseBody = async (res: Response): Promise => { return (text ? JSON.parse(text) : undefined) as T; }; -export const getText = async (uri: string, baseUrl = '') => { - const headers = getHeaders(true); +export const getText = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = getHeaders(true, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -69,16 +80,16 @@ export const getText = async (uri: string, baseUrl = '') => { return text; }; -export const get = async (uri: string, baseUrl = '') => { - const headers = getHeaders(); +export const get = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); return parseBody(res); }; -export const getBlob = async (uri: string, baseUrl = '') => { - const headers = { Authorization: `Bearer ${theToken!}` }; +export const getBlob = async (uri: string, baseUrl = '', token?: string | null) => { + const headers = { Authorization: `Bearer ${token ?? theToken!}` }; const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { headers }); await validateResponse(res); @@ -93,8 +104,8 @@ export const getBlob = async (uri: string, baseUrl = '') => { // return stream; // }; -export const post = async (uri: string, payload?: any, baseUrl = '') => { - const headers: Record = getHeaders(); +export const post = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers: Record = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); if (payload instanceof FormData) { @@ -109,8 +120,8 @@ export const post = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const put = async (uri: string, payload?: any, baseUrl = '') => { - const headers: any = getHeaders(); +export const put = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers: any = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const body = payload instanceof FormData ? payload : JSON.stringify(payload); @@ -127,8 +138,8 @@ export const put = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const patch = async (uri: string, payload?: any, baseUrl = '') => { - const headers = getHeaders(); +export const patch = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { method: 'PATCH', @@ -139,8 +150,8 @@ export const patch = async (uri: string, payload?: any, baseUrl = '') => { return parseBody(res); }; -export const DELETE = async (uri: string, payload?: any, baseUrl = '') => { - const headers = getHeaders(); +export const DELETE = async (uri: string, payload?: any, baseUrl = '', token?: string | null) => { + const headers = getHeaders(false, token); const theUrl = baseUrl ? `${baseUrl}/${uri.replace(/^\//, '')}` : uri; const res = await fetch(theUrl, { method: 'delete', diff --git a/src/workspaces/hooks/src/useServerClient.ts b/src/workspaces/hooks/src/useServerClient.ts new file mode 100644 index 00000000..8bfffd90 --- /dev/null +++ b/src/workspaces/hooks/src/useServerClient.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react'; +import { createClient } from './useClient'; +import { getConnection } from './connections'; + +/** + * A client for one named server, or for this origin when nothing is named. + * + * This is the single point where a `serverId` becomes a URL and a credential, and the two MUST come + * from the same place: a key minted on one host is meaningless to another, and presenting it produces a + * 401 that reads exactly like an expired session. Deriving both here makes that mismatch unspeakable. + * + * Deliberately NOT memoised across ids — a cached "current base URL" is the classic bug in this design, + * because it hands back whichever server was asked for first and every later pane inherits it. The + * mobile app hit this and left a comment about it (`api.ts:86-88`); the cheap fix is to derive per call + * and let React memoise on the id. + */ +export function serverClient(serverId?: string | null) { + const connection = getConnection(serverId); + // No connection → this origin, `/api`, and the session token the app already holds. That is every + // existing call site in the app, unchanged. + if (!connection) return createClient('/api'); + return createClient(`${connection.baseUrl}/api`, connection.key); +} + +export function useServerClient(serverId?: string | null) { + return useMemo(() => serverClient(serverId), [serverId]); +} + +/** + * The WebSocket URL for a server, with its credential in the query string. + * + * A browser WebSocket cannot set headers, so the token rides the query — the same shape the mobile app + * uses and the same one `/api/chat/ws` already accepts. `http→ws` by prefix swap, which gets `wss` for + * an https server for free. + * + * Returns null when a named server is unknown, so a caller opens no socket rather than dialling this + * origin under another server's name. + */ +export function chatSocketUrl(serverId: string | null | undefined, sessionToken: string | null): string | null { + const connection = getConnection(serverId); + + if (!connection) { + if (serverId) return null; // named but missing — say nothing rather than guess + const origin = window.location.origin.replace(/^http/, 'ws'); + return `${origin}/api/chat/ws?token=${encodeURIComponent(sessionToken ?? '')}`; + } + + const base = connection.baseUrl.replace(/^http/, 'ws'); + return `${base}/api/chat/ws?token=${encodeURIComponent(connection.key)}`; +} diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 105a447b..704901bb 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -13,6 +13,14 @@ import type { ChatMessage } from '../Chat/types'; export type SelectedSession = { id: string; + /** + * Which Officer this conversation lives on. Absent = the one that served this page. + * + * It rides the selection rather than living in a global, for the same reason the mobile chat app puts + * it on its `OpenTarget`: two panels can then hold conversations on two different machines at once. + * A global "current server" would make that unrepresentable. + */ + serverId?: string | null; model?: string | null; resumeSummary?: string; resumeSessionId?: string; @@ -140,10 +148,12 @@ type NewChatProps = { sessionCwd?: string | null; sessionTitle?: string | null; partCount?: number; + /** Which Officer runs this conversation. Absent = the one that served this page. */ + serverId?: string | null; }; function NewChat(props: NewChatProps) { - const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd } = props; + const { resumeSummary, resumeSessionId, initialMessages, total, initialOffset, sessionCwd, serverId } = props; const location = useLocation(); const locationState = location.state as ChatLocationState; const { invalidate: invalidateClaudeSessions } = useClaudeSessions(); @@ -161,6 +171,8 @@ function NewChat(props: NewChatProps) { initialMessages, onTurnComplete, context: 'chat', + // The one value that sends this conversation's requests and its socket to another machine. + serverId, // Only the tail is loaded up front — let the chat page older messages upward on scroll. paginate: resumeSessionId && typeof total === 'number' @@ -230,7 +242,7 @@ export const ChatDetailPanel = () => { // — so a rename reached the server, refreshed the row, and left this pane and the page title showing // the old name, which reads exactly like the rename having failed. Resolved here, once, and passed // down: renaming from either surface now retitles both, and the list's own pencil retitles an open pane. - const { sessions } = useClaudeSessions(selected?.cwd); + const { sessions } = useClaudeSessions(selected?.cwd, selected?.serverId); const title = sessions.find((session) => session.id === sessionId)?.title ?? selected?.title ?? null; // The id rides along so the shell can tell a rename of this conversation from opening a different one @@ -247,7 +259,7 @@ export const ChatDetailPanel = () => { return ( { sessionCwd={selected.cwd} sessionTitle={title} partCount={selected.partCount} + serverId={selected.serverId} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx new file mode 100644 index 00000000..59992b7f --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ServerChips.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { Plus, Server, Trash2 } from 'lucide-react'; +import { useConnections, upsertConnection, removeConnection } from 'hooks/connections'; + +/** + * Which Officer this panel is talking to. + * + * Chips rather than a dropdown, and no "active server" anywhere: the choice belongs to THIS panel, so + * two panels side by side can sit on two machines with both conversations live. That is the whole + * feature — a switcher would be the opposite of it. + * + * Hidden entirely until a second server exists, so a single-server browser looks exactly as it did. + */ +type ServerChipsProps = { + value: string | null; + onChange: (serverId: string | null) => void; +}; + +export const ServerChips = ({ value, onChange }: ServerChipsProps) => { + const connections = useConnections(); + const [adding, setAdding] = useState(false); + + // Nothing to choose between — but the add affordance still has to exist, or a second server can never + // be added in the first place. + if (connections.length === 0 && !adding) { + return ( + + ); + } + + return ( +
+ onChange(null)} /> + {connections.map((connection) => ( + onChange(connection.id)} + onRemove={() => { + removeConnection(connection.id); + // Panels pointed at it must not keep asking a server that no longer exists. + if (value === connection.id) onChange(null); + }} + /> + ))} + + {adding && setAdding(false)} onAdded={(id) => onChange(id)} />} +
+ ); +}; + +const Chip = ({ + label, + active, + onClick, + onRemove, +}: { + label: string; + active: boolean; + onClick: () => void; + onRemove?: () => void; +}) => ( + + + {onRemove && ( + + )} + +); + +/** + * Add a server by URL and API key. + * + * A key (`ofk_…`) rather than email and password, deliberately: this browser already holds a session for + * the server that served it, and a second server needs a credential that does not depend on signing in + * here. Mint one on the other machine with `POST /api/api-keys`. + * + * The key is verified against `/api/auth/me` before it is stored — a URL typo or a key from the wrong + * machine is otherwise indistinguishable from an empty conversation list later on. + */ +const AddServerForm = ({ onDone, onAdded }: { onDone: () => void; onAdded: (serverId: string) => void }) => { + const [url, setUrl] = useState(''); + const [key, setKey] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const submit = async () => { + setBusy(true); + setError(null); + try { + const base = /^https?:\/\//i.test(url.trim()) ? url.trim().replace(/\/+$/, '') : `https://${url.trim()}`; + const res = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${key.trim()}` } }); + if (!res.ok) throw new Error(res.status === 401 ? 'That key was rejected' : `Server answered ${res.status}`); + const me = (await res.json()) as { email?: string }; + const connection = upsertConnection({ url: base, key, name: name || me.email }); + onAdded(connection.id); + onDone(); + } catch (err) { + // A cross-origin failure lands here as a TypeError with no detail, which is worth naming: over + // HTTPS the browser blocks a plain-http server outright and no code of ours ever runs. + const message = err instanceof Error ? err.message : String(err); + setError( + message === 'Failed to fetch' + ? 'Could not reach it. If this page is HTTPS, the other server must be too.' + : message, + ); + } finally { + setBusy(false); + } + }; + + return ( +
+
+ Add an Officer server +
+ setUrl(ev.target.value)} + placeholder="macbook.pastilhas.dev" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + setKey(ev.target.value)} + placeholder="ofk_… (API key from that server)" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + setName(ev.target.value)} + placeholder="Name (optional)" + className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" + /> + {error &&
{error}
} +
+ + +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 4c836465..0c1eb3f9 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -6,6 +6,7 @@ import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, Rela import { useSelectedChatSession } from '../../channels'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; +import { ServerChips } from './ServerChips'; import type { SelectedSession } from './ChatDetailPanel'; import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { PwdSelector } from './PwdSelector'; @@ -23,7 +24,12 @@ export const SessionList = () => { // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; - const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); + + // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another + // machine is the entire point, and a shared "current server" would make that impossible to express. + // Seeded from the open conversation so a deep link into a remote session keeps its list on that host. + const [serverId, setServerId] = useState(selected?.serverId ?? null); + const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd, serverId); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [confirmingId, setConfirmingId] = useState(null); @@ -76,7 +82,17 @@ export const SessionList = () => { return (
-
+
+ { + setServerId(next); + // A path and a conversation from the machine you left name nothing on the one you arrived + // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). + setSelected(null); + navigate(chatListPath(null), { replace: true }); + }} + /> { @@ -97,7 +113,7 @@ export const SessionList = () => { onClick={() => { // A new chat starts in the group the list is showing, and says so in both places: on the // selection (which is what the composer actually runs in) and in the URL. - setSelected({ id: `new:${Date.now()}`, cwd: activeCwd }); + setSelected({ id: `new:${Date.now()}`, cwd: activeCwd, serverId }); navigate(chatNewPath(activeCwd), { replace: true }); }} // Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and @@ -189,6 +205,11 @@ export const SessionList = () => { not a thing, and nesting them is what breaks cmd-click on half the app's lists. */} setSelected({ id: session.id, cwd: session.cwd, title: session.title, serverId })} title={session.title} selected={isActive} className="min-w-0 flex-1" diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 57c98b2b..57b76ace 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; -import { useClient } from 'hooks/useClient'; +import { useServerClient, chatSocketUrl } from 'hooks/useServerClient'; import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import { spliceRunningTasks } from '../apps/Chat/running-tasks'; @@ -27,6 +27,14 @@ type UsePiChatOptions = { // When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in. paginate?: { sessionId: string; total: number; initialOffset: number }; onTurnComplete?: (hadToolCalls: boolean) => void; + /** + * Which Officer this conversation lives on. Absent = the one that served this page. + * + * This is the ONLY thing that makes a panel talk to another machine, and it is deliberately a plain + * string rather than a context: two panels side by side hold two different values, which is what lets + * one conversation run on the laptop while another runs on alpha, both live, with no switching. + */ + serverId?: string | null; }; /** @@ -59,6 +67,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, initialMessages: preloadedMessages, paginate, onTurnComplete, + serverId, } = options ?? {}; const [messages, setMessages] = useState(preloadedMessages ?? []); /** @@ -134,7 +143,10 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // reattachable the moment the harness names its transcript rather than when the turn finishes. const claudeSessionIdRef = useRef(resumeSessionId ?? null); - const client = useClient(); + // The one string that makes this panel talk to another machine. Absent = this origin, which is every + // existing caller. It picks the URL, the credential and the socket host together — a key minted on one + // host is meaningless to another, so they must never be derived separately. + const client = useServerClient(serverId); // Fetch the next older window and prepend it. The scroll container restores its position from the // height delta so the view stays put. Guarded against overlap and against running once fully paged in. @@ -165,9 +177,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const hasMoreOlder = !!paginate && oldestOffset > 0; - const token = localStorage.getItem('BEARER_TOKEN'); - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`; + // Null when a named server is unknown, which opens no socket rather than dialling this origin under + // another server's name — the failure that would put one machine's turn in another's pane. + const wsUrl = chatSocketUrl(serverId, localStorage.getItem('BEARER_TOKEN')); function flushStreaming() { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); @@ -529,7 +541,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, }); }, [sendAttach]); - const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen }); + const { isConnected, send } = useChatWebSocket({ url: wsUrl ?? '', onMessage: handleMessage, onOpen }); sendRef.current = send; // `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 26a09d3d..30a3612c 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -1,6 +1,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import { useClient } from 'hooks/useClient'; +import { useServerClient } from 'hooks/useServerClient'; import { useAuth } from 'hooks/useAuth'; const SESSIONS_KEY = 'CLAUDE_SESSIONS'; @@ -67,12 +68,15 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''); /** The default /chat dir plus every directory that already has Claude sessions. */ -export function useChatPwds() { - const client = useClient(); +export function useChatPwds(serverId?: string | null) { + const client = useServerClient(serverId); const { isAuthenticated } = useAuth(); const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({ - queryKey: ['CHAT_PWDS'], - enabled: isAuthenticated, + // Server-scoped: two machines have different working directories, and an unscoped key would show + // one machine's folders under the other's name. + queryKey: ['CHAT_PWDS', serverId ?? null], + // A named server carries its own API key, so it does not depend on this origin's session. + enabled: isAuthenticated || !!serverId, queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'), staleTime: 30 * 1000, }); @@ -80,15 +84,18 @@ export function useChatPwds() { } /** Sessions for a working directory, read from Claude's own transcript store (source of truth). */ -export function useClaudeSessions(cwd?: string | null) { - const client = useClient(); +export function useClaudeSessions(cwd?: string | null, serverId?: string | null) { + const client = useServerClient(serverId); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const q = cwdQuery(cwd); const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ - queryKey: [SESSIONS_KEY, cwd ?? 'default'], - enabled: isAuthenticated, + // The server id is part of the key because two Officers can hold transcripts with the SAME uuid — + // without it the cache hands one machine's conversation to the other, which looks like a UI glitch + // while actually being the wrong server's data under the right server's name. + queryKey: [SESSIONS_KEY, cwd ?? 'default', serverId ?? null], + enabled: isAuthenticated || !!serverId, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), staleTime: 30 * 1000, }); From ec4f06a313343897b4ed86f805dc7ba9e8517876 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 19:31:03 +0000 Subject: [PATCH 20/42] write down how an agent could commit as itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Not built, and deliberately so — this is the idea as it stood, with the spawn path read at dc6b623 so the next person does not have to re-derive it. The obvious approach is wrong here and the document leads with why: officer-agent is one process holding many sessions, so a PM2 env block or anything set in user-instance.ts is shared by every agent on the box and cannot distinguish them. The injection point that does work is claude-manager.ts:315, where cleanEnv is built once today but is already a per-query() option. Recorded alongside it: opencode cannot do this at all since the serve migration, because no process is spawned per turn; and per-agent identity is attribution, not isolation — agents share one working tree, so two of them in one repo will still fight over index.lock. That is the larger problem and it is named rather than solved. Co-Authored-By: Claude Opus 5 --- docs/agent-git-identity.md | 186 +++++++++++++++++++++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/agent-git-identity.md diff --git a/docs/agent-git-identity.md b/docs/agent-git-identity.md new file mode 100644 index 00000000..c660e38d --- /dev/null +++ b/docs/agent-git-identity.md @@ -0,0 +1,186 @@ +# Per-agent git identity + +**Status: idea, not implemented. Nothing in this document has been built.** Written 2026-08-10 from a +read of the live spawn path; the file:line references were verified against `dc6b623`. + +## The goal + +A team of agents works on this project, sometimes several of them in the same repository at once. Each +one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody +having to remember to say so. + +Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS +is concerned. + +## How git identity can be overridden at all + +Identity is unverified metadata, not authentication — a default, never a constraint. Anyone who can +commit can claim any name and email, by any of: + +```bash +git -c user.name=X -c user.email=x@y.z commit # per-invocation config +git commit --author="X " # author only; committer stays whoever ran it +GIT_AUTHOR_NAME=X GIT_AUTHOR_EMAIL=x@y.z \ +GIT_COMMITTER_NAME=X GIT_COMMITTER_EMAIL=x@y.z git commit # env; both identities +``` + +Precedence: `--author` > `GIT_AUTHOR_*` env > `-c user.email` > local config > global config. + +Two consequences that shape the design below: + +- **Author and committer are different fields.** `--author` alone leaves the committer as the owner, and + `git log` shows only the author by default. Set both, or the attribution is half-fiction. To read + both: `git log --format='%an <%ae> | %cn <%ce>'`. +- **Environment beats instruction.** Telling an agent "commit as X" in its prompt is a rule it can + forget. `GIT_AUTHOR_*` in the process environment applies to every git invocation in that process + whether or not anyone remembered. The whole point is to make the identity unforgettable rather than + well-intentioned, so this belongs in the environment. + +## Why the obvious approach does not work here + +The first instinct is a PM2 `env` block per agent, or setting `process.env.GIT_AUTHOR_NAME` in the +sidecar at boot. **Both are wrong on this platform**, and for the same reason: + +`officer-agent` is **one process running many concurrent sessions.** `sessions` is a +`Map` (`src/servers/sidecar/claude/claude-manager.ts:213`); each session owns +its own long-lived `query()` and its own warm `claude` child, reused across turns and collected after 30 +minutes idle. A singleton lockfile enforces one sidecar per owner (`user-instance.ts:95-98`). + +So a process-level variable — whether from PM2, from `.env`, or assigned in `user-instance.ts` — is +shared by every agent and every session on the box. It can say "an agent did this". It cannot say which. + +The `ecosystem.config.cjs` entry is bare anyway, which is worth recording since it looks like a place +where env might already be happening: + +```js +{ name: 'officer-agent', script: 'bun', args: 'run src/servers/sidecar/claude/user-instance.ts', watch: false }, +``` + +No `env`, no `cwd`, no `interpreter` — for any app in the file. + +## What the spawn path actually looks like + +The live path is the Agent SDK, not `Bun.spawn`. `claude-manager.ts:320-360`: + +```ts +const q = query({ + prompt: input.gen as AsyncIterable, + options: { + cwd: params.cwd ?? HOST_HOME, + permissionMode: 'bypassPermissions', + pathToClaudeCodeExecutable: CLAUDE_BIN, + env: cleanEnv as Record, + ... + }, +}); +``` + +`cleanEnv` is built once, at `claude-manager.ts:315`, by destructuring three keys back out of the +sidecar's own environment: + +```ts +const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ...cleanEnv } = process.env; +``` + +That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session +guards, and nothing is added per turn. + +**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there +is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point +for everything below. + +### What identity exists today + +Almost none, and none of it at the OS level. + +- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport + field on the pushed message. +- **Agent name and persona are prompt-only.** `buildAgentPrompt` + (`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user + message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file. +- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)` + (`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their + transcripts. + +`grep -rn "GIT_AUTHOR\|GIT_COMMITTER" src/` returns nothing. Verified. Any commit an agent makes today +is attributed to whatever `~/.gitconfig` says — the human owner, identically for every agent. + +## The proposed change + +Three edits, all on the claude path: + +1. **`src/servers/sidecar/protocol.ts`** — add an optional `gitIdentity: { name: string; email: string }` + to `ClaudeSpawnStreamingParams` (the type begins at line 170). Optional so every existing caller is + untouched. +2. **`claude-manager.ts:315`** — build `cleanEnv` per session rather than once, merging the identity in + as all four variables when present: + ```ts + ...(params.gitIdentity && { + GIT_AUTHOR_NAME: params.gitIdentity.name, + GIT_AUTHOR_EMAIL: params.gitIdentity.email, + GIT_COMMITTER_NAME: params.gitIdentity.name, + GIT_COMMITTER_EMAIL: params.gitIdentity.email, + }), + ``` +3. **`agent-runner.ts:144`** — populate it from `agent.dirName`, next to where the cwd pin is already + derived from the same field. + +Chat sessions (`api/chat/websocket.ts`) would pass nothing and keep committing as the owner, which is +almost certainly right: a chat turn is the human driving directly. + +Use a domain that is actually controlled — `@officer.dev` — so Gitea can be made to map or +deliberately not-map these authors later. + +## Known limitations of the proposal + +### opencode cannot do this at all + +Since the `serve` migration (`a3dbda7`, phase D) there is **no process spawned per turn**. One shared +`opencode serve` starts at sidecar boot (`src/servers/sidecar/opencode/index.ts:112-116`) with no `env` +key — full inheritance — and turns are driven over HTTP against it, with per-turn cwd carried as a +request header (`serve-runner.ts:155`). Every session shares that one process environment. + +Per-agent git identity on the opencode path therefore requires either a serve per agent, or an upstream +API field. Neither is a small change, and this document does not propose one. + +### Attribution is not isolation, and isolation is the real problem + +There is **no filesystem isolation** between agents. They share one real `HOME` +(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this +outright, and it is the stated reason `chat` is an `execution` capability that can never be granted. +`grep -ril worktree src/` returns nothing — worktrees are used nowhere. + +cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely. + +So two agents told to work on the same repository will share one working tree: fighting over +`index.lock`, staging each other's half-finished edits, interleaving commits. Per-agent identity makes +that **legible after the fact**. It does nothing to prevent it. + +If agents are genuinely to work the same repo concurrently, the isolation question is the larger and +more urgent one — a worktree or a clone per agent — and per-agent identity composes naturally with it +(a worktree per agent is also the cleanest place to put a per-worktree git identity). + +### If worktrees do arrive, note this trap + +`git worktree` shares one `.git/config`, so `git config --local user.email` in one worktree changes it +for **all** of them. Per-worktree config needs `extensions.worktreeConfig true` and then +`git config --worktree user.email …`. Environment variables sidestep the whole issue, which is another +argument for the env approach above. + +## Deliberately not proposed + +- **Signing.** Per-agent SSH signing keys would make attribution unforgeable rather than conventional. + For a trusted local fleet where every agent already runs as the owner with + `permissionMode: 'bypassPermissions'`, an agent that wanted to forge another's identity has far easier + routes. Revisit only if agents stop being equally trusted. +- **`Co-Authored-By:` trailers.** Useful when a human and an agent genuinely share a commit, and both + Gitea and GitHub attribute them. Orthogonal to this, and a prompt-level convention rather than an + environment one. + +## Open questions + +- Should pipeline steps (`pipeline-executor.ts`) get an identity too, or only named agents? +- Is `dirName` the right identity, or should an agent's `AGENT.md` declare its own name and email — so + the identity is authored where the persona is, rather than derived from a directory? +- Does anything downstream — Gitea webhooks, activity feeds, the dashboards — assume commits belong to + the owner and break when they do not? From a19895c216902d8f97e16a031821b90b0ac74e4f Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 21:17:01 +0100 Subject: [PATCH 21/42] tabs and panes: several conversations, several machines, one window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iPad layout in the browser. A tab holds one to three panes; each pane is a whole chat — its own server chips, its own list, its own conversation, its own socket. The blocker was that chat:selected-session is ONE channel for the screen, so two detail panels would have shown the same conversation. A pane now provides its own selection through context and usePaneSelection prefers it; outside a pane the context is absent and the channel behaves exactly as before, so the dashboard chat panel and the mobile layout are untouched. Context rather than props because SessionList and ChatDetailPanel sit at different depths and neither should know whether it is inside a pane. A pane shows its LIST until something is open and the CHAT afterwards, with one way back. Mobile can afford both at once inside a pane; three of those in a browser column would leave nothing for the conversation itself. The layout lives in one unscoped localStorage entry, deliberately not per server — a tab holding one conversation from the laptop and one from alpha belongs to neither. Pane keys are re-minted on restore, because keys from a previous page whose counter restarted at zero make React reuse the wrong subtree and a conversation appears in the wrong column. What this gives up, and it is the only thing: /chat/ still deep-links but can only open in the first pane. With three conversations on screen there is no single one for the address bar to name. WorkspaceView and the fixed three-panel layout are gone from this screen; the panels themselves are unchanged and still registered for the dashboard. Typecheck, 602 tests and the SPA bundle all pass. Nobody has clicked it. Co-Authored-By: Claude Opus 5 --- .../Screens/Dashboard/ChatHistory/index.tsx | 45 +--- .../src/apps/ChatHistory/ChatDetailPanel.tsx | 4 +- .../src/apps/ChatHistory/ChatPane.tsx | 57 +++++ .../src/apps/ChatHistory/ChatTabs.tsx | 213 ++++++++++++++++++ .../src/apps/ChatHistory/PaneSelection.tsx | 56 +++++ .../src/apps/ChatHistory/SessionList.tsx | 5 +- .../officerdev/src/apps/ChatHistory/index.ts | 3 + src/workspaces/officerdev/src/index.ts | 2 +- 8 files changed, 344 insertions(+), 41 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx create mode 100644 src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx diff --git a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx index 6cbc06c7..943d082e 100644 --- a/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx +++ b/src/apps/officer-web/Screens/Dashboard/ChatHistory/index.tsx @@ -1,15 +1,12 @@ import { useEffect, useRef } from 'react'; import { useParams, useNavigate } from 'react-router'; -import type { LayoutNode, SelectedSession } from 'officerdev'; -import { WorkspaceView, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev'; +import type { SelectedSession } from 'officerdev'; +import { ChatTabs, chatListPath, cwdFromSplat, useSelectedChatSession } from 'officerdev'; import { toast } from '@/components/ui/sonner'; -import { useIsMobile } from 'hooks/useIsMobile'; import { useClient } from 'hooks/useClient'; import { serverClient } from 'hooks/useServerClient'; import { errorText } from 'helpers/error-text'; -import { useDashboardState } from 'state/useDashboardState'; import type { ClaudeSessionDetail } from 'state/useClaudeSessions'; -import { defaultLayout, hasAppType } from './defaultLayout'; // How many messages to render on first open (anchored to the bottom); scroll-up pages older ones in. const CHAT_TAIL = 20; @@ -28,26 +25,7 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { selectedRef.current = selected; // A layout persisted before the chat panels were renamed still names `officerdev/chat`, which no // longer resolves; `appTypes` lands anything unknown on the detail panel. - const workspace = useDashboardState('screens/chat', defaultLayout); - const isMobile = useIsMobile(); const navigate = useNavigate(); - const mobilePanelId = isMobile && (sessionId || isNew) ? 'chat-detail' : undefined; - - // Adopt a structural change to this screen's layout. - // - // `useDashboardState` seeds its default ONLY when the key is absent, so anyone who has ever opened - // /chat keeps the shape it had then — for good. `appTypes`/`normalizeLayout` does not help: it repairs - // which app a panel runs, never the tree, so adding the Live panel above the list would have been - // invisible to every existing user and visible only on a fresh account. - // - // Replacing outright is safe *here* specifically because the screen is `locked`: its structure is - // dictated by code and the only thing a user can have contributed is the column sizes, which is a - // cheap thing to lose once. Terminates because the replacement contains the panel it tests for. - useEffect(() => { - if (!workspace.isLoaded) return; - if (hasAppType(workspace.value, 'chat-live')) return; - workspace.setValue(defaultLayout); - }, [workspace.isLoaded, workspace.value, workspace.setValue]); // Retire a legacy `?cwd=`. Nothing reads it any more and nothing writes it, but a refresh re-requests // the address bar verbatim — so one left over from before the path-based groups sits there forever, @@ -120,20 +98,15 @@ export const SessionListPage = ({ isNew }: SessionListPageProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [sessionId, isNew, groupCwd]); + // The tabbed, multi-pane chat replaces the fixed three-panel workspace. The panels themselves are + // unchanged and still registered for the dashboard; what changes is that a PANE owns its conversation + // rather than the whole screen sharing one, which is what lets two machines be live side by side. + // + // `useDashboardState`/`WorkspaceView` are no longer used here. The layout that matters now is the tab + // blob in localStorage, because a tab spanning two servers cannot be stored per server. return (
- { - // Back goes to the group's list, not the default one. On /chat/g/* that group is in the URL; - // on /chat/ it isn't (deliberately — see chat-routes.ts), so fall back to the open - // session's own directory, which the resolve above put on the selection. - if (!id) navigate(chatListPath(groupCwd ?? selectedRef.current?.cwd ?? null), { replace: true }); - }} - /> +
); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx index 704901bb..20d349f0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatDetailPanel.tsx @@ -3,7 +3,7 @@ import { useLocation, useParams } from 'react-router'; import { Unplug } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { EditableTitle } from '@/components/EditableTitle'; -import { useSelectedChatSession } from '../../channels'; +import { usePaneSelection } from './PaneSelection'; import { usePublishPageTitle } from '../../page-title'; import { useAuth } from 'hooks/useAuth'; import { errorText } from 'helpers/error-text'; @@ -226,7 +226,7 @@ function NewChat(props: NewChatProps) { } export const ChatDetailPanel = () => { - const [selected] = useSelectedChatSession(); + const [selected] = usePaneSelection(); // Name the page after the conversation, whenever the URL names a real one. Gated on the route param // rather than on `selected`, so `/chat` and `/chat/new` keep the plain "Chat" — the panel holds a diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx new file mode 100644 index 00000000..31959c3e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx @@ -0,0 +1,57 @@ +import { ArrowLeft } from 'lucide-react'; +import { connectionLabel } from 'hooks/connections'; +import { SessionList } from './SessionList'; +import { ChatDetailPanel } from './ChatDetailPanel'; +import type { SelectedSession } from './ChatDetailPanel'; +import { PaneSelectionProvider } from './PaneSelection'; + +/** + * One self-contained conversation column: its own server, its own list, its own chat. + * + * Modelled on the mobile app's pane, where "a pane is just a whole ChatScreen" — an empty one IS the + * conversation list, and filling it is tapping a row. That is what makes two panes independent without + * inventing a second concept: everything a conversation needs is already inside one. + * + * The web version differs in one way, deliberately. Mobile has room for a list and a chat side by side + * inside a pane; two or three of those in a browser column would leave nothing for the conversation. So + * a pane shows its LIST until something is open and the CHAT afterwards, with one way back. The tab bar + * above holds the panes; this holds one conversation. + */ +type ChatPaneProps = { + target: SelectedSession | null; + onTargetChange: (next: SelectedSession | null) => void; + /** Shown when more than one pane is open, so it is obvious which machine a column is on. */ + showServerBadge?: boolean; +}; + +export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => { + const open = !!target; + + return ( + +
+ {open && ( +
+ + {showServerBadge && ( + // Which machine this column is talking to. Only worth the space when there is more than + // one pane — with a single column the chips in the list already say it. + + {connectionLabel(target?.serverId)} + + )} +
+ )} + +
{open ? : }
+
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx new file mode 100644 index 00000000..8db421fb --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx @@ -0,0 +1,213 @@ +import { useEffect, useRef, useState } from 'react'; +import { Columns2, Plus, X } from 'lucide-react'; +import { connectionLabel } from 'hooks/connections'; +import { ChatPane } from './ChatPane'; +import type { SelectedSession } from './ChatDetailPanel'; + +/** + * Tabs of side-by-side conversations, each pane free to sit on a different Officer. + * + * This is the iPad layout brought to the browser: one window, one `/chat`, several live conversations + * on several machines at once. The mobile app proved the shape — what it adds over the old single-panel + * screen is that a pane owns its conversation (see `PaneSelection`) instead of the whole screen sharing + * one. + * + * ## What is stored where, which is the part that matters + * + * The layout — which tabs exist, which panes they hold, and what each pane has open — is kept in ONE + * unscoped `localStorage` entry, deliberately not per server. A tab holding one conversation from the + * laptop and one from alpha belongs to neither, so scoping it to either would be wrong. The mobile app + * makes the same call and says so. + * + * A pane's `target` carries its own `serverId`, so a restored tab reopens the right conversation on the + * right machine rather than looking it up on whichever server happens to be nearest. + * + * ## The URL + * + * `/chat/` still deep-links, and still opens in the FIRST pane. It cannot mean more than that: with + * three conversations on screen there is no single "the" conversation for the address bar to name, which + * is the one place this design gives something up. Everything else about the route conventions holds. + */ + +type Pane = { key: string; target: SelectedSession | null }; +type Tab = { key: string; title?: string; panes: Pane[] }; + +const STORE_KEY = 'officer.chat.tabs.v1'; +const MAX_PANES = 3; + +let seq = 0; +const nextKey = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${seq++}`; + +function load(): Tab[] { + try { + const raw = localStorage.getItem(STORE_KEY); + const parsed = raw ? (JSON.parse(raw) as Tab[]) : null; + if (!Array.isArray(parsed) || !parsed.length) throw new Error('empty'); + // Keys were minted by a previous page whose counter restarted at zero. Re-mint them, or React can + // reuse the wrong subtree and a conversation appears in the wrong column — the mobile app hit + // exactly this and guards it the same way. + return parsed.map((tab) => ({ + ...tab, + key: nextKey('tab'), + panes: (tab.panes ?? []).slice(0, MAX_PANES).map((pane) => ({ ...pane, key: nextKey('pane') })), + })); + } catch { + return [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; + } +} + +export const ChatTabs = () => { + const [tabs, setTabs] = useState(load); + const [activeKey, setActiveKey] = useState(() => ''); + const restored = useRef(false); + + // First render picks the first tab; afterwards the user owns it. + useEffect(() => { + if (restored.current) return; + restored.current = true; + setActiveKey(tabs[0]?.key ?? ''); + }, [tabs]); + + useEffect(() => { + try { + localStorage.setItem(STORE_KEY, JSON.stringify(tabs)); + } catch { + /* private mode or quota — the layout still works for this page's lifetime */ + } + }, [tabs]); + + const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0]; + + const update = (tabKey: string, fn: (tab: Tab) => Tab) => + setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab))); + + const addTab = () => { + const tab: Tab = { key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }; + setTabs((prev) => [...prev, tab]); + setActiveKey(tab.key); + }; + + const closeTab = (tabKey: string) => { + setTabs((prev) => { + const next = prev.filter((tab) => tab.key !== tabKey); + // Never leave nothing: an empty tab bar has no way back to a conversation. + const safe = next.length ? next : [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; + if (tabKey === activeKey) setActiveKey(safe[0]!.key); + return safe; + }); + }; + + const splitPane = () => + active && + update(active.key, (tab) => + tab.panes.length >= MAX_PANES ? tab : { ...tab, panes: [...tab.panes, { key: nextKey('pane'), target: null }] }, + ); + + const closePane = (paneKey: string) => + active && + update(active.key, (tab) => + tab.panes.length <= 1 ? tab : { ...tab, panes: tab.panes.filter((pane) => pane.key !== paneKey) }, + ); + + const setPaneTarget = (paneKey: string, target: SelectedSession | null) => + active && + update(active.key, (tab) => ({ + ...tab, + panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)), + })); + + if (!active) return null; + + return ( +
+ {/* Always visible: it is the only way to open a second tab or split a pane, so hiding it in the + single-conversation case would hide the feature from anyone who has not already used it. */} + { +
+ {tabs.map((tab) => { + // A tab is named after what is in it: the first pane's conversation, else the machine. + const first = tab.panes[0]?.target; + const label = + tab.title || + first?.title || + (tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat')); + return ( + + ); + })} + + + + +
+ } + +
+ {active.panes.map((pane, index) => ( +
0 ? 'border-l border-border' : ''}`} + style={{ width: `${100 / active.panes.length}%` }} + > + {active.panes.length > 1 && ( + + )} + setPaneTarget(pane.key, next)} + showServerBadge={active.panes.length > 1} + /> +
+ ))} +
+
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx new file mode 100644 index 00000000..bebbd2e5 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx @@ -0,0 +1,56 @@ +import { createContext, useContext, useMemo, useState } from 'react'; +import type { ReactNode } from 'react'; +import { useSelectedChatSession } from '../../channels'; +import type { SelectedSession } from './ChatDetailPanel'; + +/** + * Which conversation THIS pane has open. + * + * `chat:selected-session` is one channel for the whole screen, which was right while there was exactly + * one conversation on it. Two panes side by side make it wrong: both would read the same value and show + * the same chat, which is the opposite of the point. + * + * So a pane provides its own state here, and `usePaneSelection` prefers it. Outside a pane the context + * is absent and the channel is used exactly as before — every existing caller (the mobile layout, the + * dashboard's own chat panel) is untouched, which is what makes this safe to drop in. + * + * Deliberately a context rather than props: `SessionList` and `ChatDetailPanel` sit at different depths + * and neither should have to know whether it is inside a pane. + */ +type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null) => void]; + +const PaneSelectionContext = createContext(null); + +export function usePaneSelection(): PaneSelectionValue { + const scoped = useContext(PaneSelectionContext); + const channel = useSelectedChatSession(); + // Hooks must run unconditionally, so the channel is always read; the scoped value simply wins. + return scoped ?? ([channel[0], channel[1]] as PaneSelectionValue); +} + +/** + * Give the subtree its own selection. + * + * `value`/`onChange` make it controllable, so the tab shell can persist a pane's open conversation + * across a reload — the mobile app keeps the target on the pane for the same reason, and it is what + * makes a restored tab still point at the right chat on the right machine. + */ +export const PaneSelectionProvider = ({ + children, + value, + onChange, +}: { + children: ReactNode; + value?: SelectedSession | null; + onChange?: (next: SelectedSession | null) => void; +}) => { + const [internal, setInternal] = useState(null); + const controlled = value !== undefined && !!onChange; + + const pair = useMemo( + () => (controlled ? [value ?? null, onChange!] : [internal, setInternal]), + [controlled, value, onChange, internal], + ); + + return {children}; +}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 0c1eb3f9..75157bf2 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { useSelectedChatSession } from '../../channels'; +import { usePaneSelection } from './PaneSelection'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { ServerChips } from './ServerChips'; @@ -19,7 +19,8 @@ export const SessionList = () => { // them there rather than from the selection channel means the highlight and the group are correct on // a deep link and on back/forward, before any panel has published. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); - const [selected, setSelected] = useSelectedChatSession(); + // Scoped to this pane when inside one, the shared channel otherwise — see PaneSelection. + const [selected, setSelected] = usePaneSelection(); // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts index 893f8375..949666a3 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/index.ts +++ b/src/workspaces/officerdev/src/apps/ChatHistory/index.ts @@ -7,6 +7,9 @@ import { ChatDetailPanel } from './ChatDetailPanel'; export { SessionList }; export { LiveSessions }; export { ChatDetailPanel }; +export { ChatTabs } from './ChatTabs'; +export { ChatPane } from './ChatPane'; +export { PaneSelectionProvider, usePaneSelection } from './PaneSelection'; export type { SelectedSession } from './ChatDetailPanel'; export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './chat-routes'; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index c3b207a2..a66deb9e 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -29,7 +29,7 @@ export { } from './apps/Chat'; export type { UseEmbeddableChatType, UseChatType, UseAttachmentsType, UseAudioRecordingType } from './apps/Chat'; export * from './apps/Chat/types'; -export { SessionList, ChatDetailPanel } from './apps/ChatHistory'; +export { SessionList, ChatDetailPanel, ChatTabs } from './apps/ChatHistory'; export type { SelectedSession } from './apps/ChatHistory'; // The chat URL vocabulary, so the /chat screen, the panels and the server all spell a group the same way. export { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat, GROUP_SEGMENT } from './apps/ChatHistory'; From 8bab607366a024a85c4fc397297847d8c0ba6f5b Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 21:36:44 +0100 Subject: [PATCH 22/42] name a chat tab, and let that name win the page title MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Click the active tab (or double-click any) to rename it inline — Enter commits, Escape cancels, blur commits, and an empty value hands the tab back to its derived name. Same shape as renaming a conversation, which is the gesture that already exists here. The name outranks everything: chatTabName ?? label ?? override ?? route. It is the most specific statement anyone has made about the page — more specific than the conversation inside it, since there may be three, and more deliberate than a browser-tab name typed earlier on a different screen. Only a name you TYPED is published. Publishing the derived label would restate the title the chat already publishes one tier down, and would then outrank a browser-tab name for no reason the user could see. Cleared on unmount, or every other screen would keep being called by the chat tab you last had open. The rename field seeds from the typed name only, never the derived one — pre-filling a name the user never chose makes Enter silently adopt it as if they had. Co-Authored-By: Claude Opus 5 --- src/apps/officer-web/state/usePageTitle.ts | 8 +++- .../src/apps/ChatHistory/ChatTabs.tsx | 46 ++++++++++++++++++- src/workspaces/officerdev/src/index.ts | 2 +- src/workspaces/officerdev/src/page-title.ts | 32 +++++++++++++ 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/src/apps/officer-web/state/usePageTitle.ts b/src/apps/officer-web/state/usePageTitle.ts index 3693145e..1d9e666c 100644 --- a/src/apps/officer-web/state/usePageTitle.ts +++ b/src/apps/officer-web/state/usePageTitle.ts @@ -1,7 +1,7 @@ import { useCallback, useEffect, useRef } from 'react'; import { useLocation } from 'react-router'; import type { PageTitleOverride } from 'officerdev'; -import { usePageTitleOverride } from 'officerdev'; +import { usePageTitleOverride, useChatTabName } from 'officerdev'; import { useSessionState, writeSessionValue } from 'hooks/useSessionState'; type TitleRule = { match: (p: string) => boolean; title: string }; @@ -160,6 +160,7 @@ claimTabIdentity(); export function usePageTitle() { const { pathname } = useLocation(); const override = usePageTitleOverride(); + const chatTabName = useChatTabName(); const [label, setLabel] = useSessionState(TAB_LABEL_KEY, null); useEffect(() => onTabLabelDropped(() => setLabel(null)), [setLabel]); @@ -185,7 +186,10 @@ export function usePageTitle() { const rename = useCallback((next: string) => setLabel(next.trim() || null), [setLabel]); - return [label ?? override?.title ?? titleForPath(pathname), rename] as const; + // A chat tab's own name is the most specific thing anyone has said about this page — more specific + // than the conversation inside it (there may be three) and more deliberate than a browser-tab name + // typed earlier on a different screen. So it wins outright. + return [chatTabName ?? label ?? override?.title ?? titleForPath(pathname), rename] as const; } /** diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx index 8db421fb..896c2d28 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from 'react'; import { Columns2, Plus, X } from 'lucide-react'; import { connectionLabel } from 'hooks/connections'; +import { usePublishChatTabName } from '../../page-title'; import { ChatPane } from './ChatPane'; import type { SelectedSession } from './ChatDetailPanel'; @@ -59,6 +60,8 @@ function load(): Tab[] { export const ChatTabs = () => { const [tabs, setTabs] = useState(load); const [activeKey, setActiveKey] = useState(() => ''); + const [renamingKey, setRenamingKey] = useState(null); + const [renameValue, setRenameValue] = useState(''); const restored = useRef(false); // First render picks the first tab; afterwards the user owns it. @@ -78,6 +81,11 @@ export const ChatTabs = () => { const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0]; + // Only a name YOU typed is published — a label derived from the conversation would just restate the + // title the chat already publishes, one tier lower, and would then outrank a browser-tab name for no + // reason the user could see. + usePublishChatTabName(active?.title?.trim() || null); + const update = (tabKey: string, fn: (tab: Tab) => Tab) => setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab))); @@ -97,6 +105,21 @@ export const ChatTabs = () => { }); }; + const startRename = (tab: Tab) => { + setRenamingKey(tab.key); + // Seeded with the typed name only, not the derived label: pre-filling a name the user never chose + // makes Enter silently adopt it as if they had. + setRenameValue(tab.title ?? ''); + }; + + const commitRename = () => { + if (!renamingKey) return; + const next = renameValue.trim(); + // Empty hands the tab back to its derived name — the only way out, and no third state. + update(renamingKey, (tab) => ({ ...tab, title: next || undefined })); + setRenamingKey(null); + }; + const splitPane = () => active && update(active.key, (tab) => @@ -131,11 +154,32 @@ export const ChatTabs = () => { tab.title || first?.title || (tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat')); + if (renamingKey === tab.key) { + return ( + setRenameValue(ev.target.value)} + onKeyDown={(ev) => { + if (ev.key === 'Enter') commitRename(); + if (ev.key === 'Escape') setRenamingKey(null); + }} + onBlur={commitRename} + aria-label="Tab name" + placeholder={label} + className="w-32 shrink-0 rounded-t border-b border-primary/40 bg-muted px-2 py-1 text-xs outline-none" + /> + ); + } + return (
); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 75157bf2..99629874 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -95,6 +95,7 @@ export const SessionList = () => { }} /> { setSelected(null); // sessions belong to a cwd — clear the open one when switching diff --git a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts index a5f9ea03..e5b9df0b 100644 --- a/src/workspaces/officerdev/src/hooks/useFilesAPI.ts +++ b/src/workspaces/officerdev/src/hooks/useFilesAPI.ts @@ -1,9 +1,17 @@ import { useClient, getHeaders } from 'hooks/useClient'; +import { useServerClient } from 'hooks/useServerClient'; const API_URL = '/api'; -export const useFilesAPI = (root: string = 'home') => { - const client = useClient(); +/** + * `serverId` names another Officer; absent is this origin, which is every existing caller. + * + * Load-bearing for the chat directory pickers: a path only means something on the machine it came from, + * so browsing without it showed THIS server's folders while the pane was pointed at another — the exact + * bug the mobile app has with `browseDirectories`. + */ +export const useFilesAPI = (root: string = 'home', serverId?: string | null) => { + const client = useServerClient(serverId); const rootParam = root !== 'home' ? `root=${encodeURIComponent(root)}` : ''; const withRoot = (url: string) => rootParam ? (url.includes('?') ? `${url}&${rootParam}` : `${url}?${rootParam}`) : url; From 80d66538c25c81fd6dfbcf61ce6d994cacf27e46 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 21:51:04 +0100 Subject: [PATCH 24/42] fix the render loop I was warned about in the file I edited MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit React #185, maximum update depth, and the page with it. usePublishChatTabName named useGlobal setter as an effect dependency. useGlobal rebuilds that setter every render, so the effect re-ran every render, set global state, and rendered again. The publisher directly above it in the same file documents this exact hazard — I copied the shape and not the reason. Now through a ref, depending on the string alone, identical to usePublishPageTitle. Also stabilised setPaneTarget with useCallback. It is handed to every pane as onChange and a pane puts it in a context others read, so a fresh identity each render is the same loop waiting for the first consumer that depends on it. The active tab key is read through a ref so it never has to be a dependency. Co-Authored-By: Claude Opus 5 --- .../src/apps/ChatHistory/ChatTabs.tsx | 25 +++++++++++++------ src/workspaces/officerdev/src/page-title.ts | 16 +++++++----- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx index 896c2d28..2dbefff3 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Columns2, Plus, X } from 'lucide-react'; import { connectionLabel } from 'hooks/connections'; import { usePublishChatTabName } from '../../page-title'; @@ -63,6 +63,9 @@ export const ChatTabs = () => { const [renamingKey, setRenamingKey] = useState(null); const [renameValue, setRenameValue] = useState(''); const restored = useRef(false); + // Read inside the stable callback above, so it never has to be a dependency. + const activeKeyRef = useRef(activeKey); + activeKeyRef.current = activeKey; // First render picks the first tab; afterwards the user owns it. useEffect(() => { @@ -132,12 +135,20 @@ export const ChatTabs = () => { tab.panes.length <= 1 ? tab : { ...tab, panes: tab.panes.filter((pane) => pane.key !== paneKey) }, ); - const setPaneTarget = (paneKey: string, target: SelectedSession | null) => - active && - update(active.key, (tab) => ({ - ...tab, - panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)), - })); + // Stable across renders on purpose. It is handed to every pane as `onChange`, and a pane passes it + // into a context that other components read — an identity that changed every render would make any + // effect depending on it re-run forever, which is the render loop this file already caused once. + const setPaneTarget = useCallback( + (paneKey: string, target: SelectedSession | null) => + setTabs((prev) => + prev.map((tab) => + tab.key !== activeKeyRef.current + ? tab + : { ...tab, panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)) }, + ), + ), + [], + ); if (!active) return null; diff --git a/src/workspaces/officerdev/src/page-title.ts b/src/workspaces/officerdev/src/page-title.ts index 8f46a446..fa148fb2 100644 --- a/src/workspaces/officerdev/src/page-title.ts +++ b/src/workspaces/officerdev/src/page-title.ts @@ -76,12 +76,16 @@ export const useChatTabName = () => useGlobal(CHAT_TAB_NAME, null */ export function usePublishChatTabName(name: string | null) { const [, setName] = useGlobal(CHAT_TAB_NAME, null); - const nameRef = useRef(name); - nameRef.current = name; + + // Through a ref, and depending on the STRING only — exactly as `usePublishPageTitle` does, for the + // reason documented there: `useGlobal` rebuilds its setter every render, so naming it as a dependency + // re-runs this effect on every render, which sets global state, which renders again. That is React + // error #185, and it took the whole page down until the deps were narrowed to the value itself. + const setNameRef = useRef(setName); + setNameRef.current = setName; useEffect(() => { - setName(nameRef.current); - }, [name, setName]); - - useEffect(() => () => setName(null), [setName]); + setNameRef.current(name); + return () => setNameRef.current(null); + }, [name]); } From 4b3668f03cdb8a895d6684ee3d1bbea5833d99ef Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Mon, 10 Aug 2026 22:06:09 +0100 Subject: [PATCH 25/42] make a pane actually open the conversation you clicked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported: three panes, MacBook selected, click a chat and the body says "No sessions yet". Two causes, both from panes bypassing the screen-level machinery on purpose. The transcript was never loaded. The screen resolver fetches it and writes to the shared channel, which a pane deliberately does not read, so the pane got {id, title, cwd} and nothing else. It resolves its own now, from ITS server — two machines can hold the same uuid, so asking the wrong one is not merely empty, it is wrong — and shows a spinner while it does rather than an empty conversation. And the row navigated. That put /chat/ in the address bar, which reset the list cwd to the default — empty on that machine — which is the "No sessions yet" he actually saw. In a pane the directory is the pane, not the route: three panes cannot share one URL. Outside a pane everything still comes from the route exactly as before. Co-Authored-By: Claude Opus 5 --- .../src/apps/ChatHistory/ChatPane.tsx | 70 ++++++++++++++++++- .../src/apps/ChatHistory/PaneSelection.tsx | 10 +++ .../src/apps/ChatHistory/SessionList.tsx | 16 +++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx index 31959c3e..24d3c58e 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/ChatPane.tsx @@ -1,4 +1,6 @@ -import { ArrowLeft } from 'lucide-react'; +import { useEffect, useRef } from 'react'; +import { ArrowLeft, Loader2 } from 'lucide-react'; +import { serverClient } from 'hooks/useServerClient'; import { connectionLabel } from 'hooks/connections'; import { SessionList } from './SessionList'; import { ChatDetailPanel } from './ChatDetailPanel'; @@ -27,6 +29,60 @@ type ChatPaneProps = { export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPaneProps) => { const open = !!target; + /** + * Load the transcript for a row this pane just opened. + * + * The screen-level resolver does this for the single-panel layout, but it writes to the shared + * channel — which a pane deliberately does not read. So a pane clicked a row, got `{id, title, cwd}` + * and nothing else, and rendered an empty conversation while the list behind it reset to the default + * directory. It has to resolve its own, from ITS server: two machines can hold the same uuid, so + * asking the wrong one is not merely empty, it is wrong. + */ + const resolvingRef = useRef(null); + const id = target?.id ?? null; + const needsTranscript = !!id && !id.startsWith('new:') && !target?.resumeSessionId; + const serverId = target?.serverId ?? null; + + useEffect(() => { + if (!needsTranscript || !id) return; + if (resolvingRef.current === id) return; // one fetch per row, not one per render + resolvingRef.current = id; + let cancelled = false; + (async () => { + try { + const detail = await serverClient(serverId).get<{ + model?: string | null; + messages: unknown[]; + total: number; + offset: number; + cwd: string; + title?: string | null; + partCount?: number; + }>(`/chat/sessions/${id}?limit=20`); + if (cancelled) return; + onTargetChange({ + id, + serverId, + model: detail.model, + resumeSessionId: id, + initialMessages: detail.messages as never, + total: detail.total, + initialOffset: detail.offset, + cwd: detail.cwd, + title: detail.title ?? undefined, + partCount: detail.partCount, + }); + } catch { + // Leave the pane on the row it has. Falling back to an empty chat would look like a conversation + // that lost its history rather than one that could not be read. + if (!cancelled) resolvingRef.current = null; + } + })(); + return () => { + cancelled = true; + }; + }, [needsTranscript, id, serverId, onTargetChange]); + return (
@@ -50,7 +106,17 @@ export const ChatPane = ({ target, onTargetChange, showServerBadge }: ChatPanePr
)} -
{open ? : }
+
+ {!open ? ( + + ) : needsTranscript ? ( +
+ +
+ ) : ( + + )} +
); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx index bebbd2e5..739aae42 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx @@ -21,6 +21,16 @@ type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null const PaneSelectionContext = createContext(null); +/** + * Is this component inside a pane? + * + * A pane owns its conversation AND its directory, so it must not take either from the address bar: + * three panes cannot share one URL. Outside a pane the route stays the authority, exactly as before. + */ +export function useIsInPane(): boolean { + return useContext(PaneSelectionContext) !== null; +} + export function usePaneSelection(): PaneSelectionValue { const scoped = useContext(PaneSelectionContext); const channel = useSelectedChatSession(); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 99629874..aedcf245 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { usePaneSelection } from './PaneSelection'; +import { usePaneSelection, useIsInPane } from './PaneSelection'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { ServerChips } from './ServerChips'; @@ -24,7 +24,11 @@ export const SessionList = () => { // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. - const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; + // In a pane the directory is the pane's, not the route's — three panes cannot share one URL, and + // letting the address bar win is what reset this list to the default the moment a row was clicked. + const inPane = useIsInPane(); + const [paneCwd, setPaneCwd] = useState(null); + const activeCwd = inPane ? paneCwd : (cwdFromSplat(splat) ?? selected?.cwd ?? null); // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another // machine is the entire point, and a shared "current server" would make that impossible to express. @@ -88,10 +92,11 @@ export const SessionList = () => { value={serverId} onChange={(next) => { setServerId(next); + setPaneCwd(null); // A path and a conversation from the machine you left name nothing on the one you arrived // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). setSelected(null); - navigate(chatListPath(null), { replace: true }); + if (!inPane) navigate(chatListPath(null), { replace: true }); }} /> { value={activeCwd} onChange={(cwd) => { setSelected(null); // sessions belong to a cwd — clear the open one when switching - navigate(chatListPath(cwd), { replace: true }); + if (inPane) setPaneCwd(cwd); + else navigate(chatListPath(cwd), { replace: true }); }} />
@@ -206,7 +212,7 @@ export const SessionList = () => { {/* The row is the link and the actions are its siblings — a - {showServerBadge && ( - // Which machine this column is talking to. Only worth the space when there is more than - // one pane — with a single column the chips in the list already say it. - - {connectionLabel(target?.serverId)} - - )} -
- )} - -
- {!open ? ( - - ) : needsTranscript ? ( -
- -
- ) : ( - - )} -
-
- - ); -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx deleted file mode 100644 index 2dbefff3..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/ChatTabs.tsx +++ /dev/null @@ -1,268 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { Columns2, Plus, X } from 'lucide-react'; -import { connectionLabel } from 'hooks/connections'; -import { usePublishChatTabName } from '../../page-title'; -import { ChatPane } from './ChatPane'; -import type { SelectedSession } from './ChatDetailPanel'; - -/** - * Tabs of side-by-side conversations, each pane free to sit on a different Officer. - * - * This is the iPad layout brought to the browser: one window, one `/chat`, several live conversations - * on several machines at once. The mobile app proved the shape — what it adds over the old single-panel - * screen is that a pane owns its conversation (see `PaneSelection`) instead of the whole screen sharing - * one. - * - * ## What is stored where, which is the part that matters - * - * The layout — which tabs exist, which panes they hold, and what each pane has open — is kept in ONE - * unscoped `localStorage` entry, deliberately not per server. A tab holding one conversation from the - * laptop and one from alpha belongs to neither, so scoping it to either would be wrong. The mobile app - * makes the same call and says so. - * - * A pane's `target` carries its own `serverId`, so a restored tab reopens the right conversation on the - * right machine rather than looking it up on whichever server happens to be nearest. - * - * ## The URL - * - * `/chat/` still deep-links, and still opens in the FIRST pane. It cannot mean more than that: with - * three conversations on screen there is no single "the" conversation for the address bar to name, which - * is the one place this design gives something up. Everything else about the route conventions holds. - */ - -type Pane = { key: string; target: SelectedSession | null }; -type Tab = { key: string; title?: string; panes: Pane[] }; - -const STORE_KEY = 'officer.chat.tabs.v1'; -const MAX_PANES = 3; - -let seq = 0; -const nextKey = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${seq++}`; - -function load(): Tab[] { - try { - const raw = localStorage.getItem(STORE_KEY); - const parsed = raw ? (JSON.parse(raw) as Tab[]) : null; - if (!Array.isArray(parsed) || !parsed.length) throw new Error('empty'); - // Keys were minted by a previous page whose counter restarted at zero. Re-mint them, or React can - // reuse the wrong subtree and a conversation appears in the wrong column — the mobile app hit - // exactly this and guards it the same way. - return parsed.map((tab) => ({ - ...tab, - key: nextKey('tab'), - panes: (tab.panes ?? []).slice(0, MAX_PANES).map((pane) => ({ ...pane, key: nextKey('pane') })), - })); - } catch { - return [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; - } -} - -export const ChatTabs = () => { - const [tabs, setTabs] = useState(load); - const [activeKey, setActiveKey] = useState(() => ''); - const [renamingKey, setRenamingKey] = useState(null); - const [renameValue, setRenameValue] = useState(''); - const restored = useRef(false); - // Read inside the stable callback above, so it never has to be a dependency. - const activeKeyRef = useRef(activeKey); - activeKeyRef.current = activeKey; - - // First render picks the first tab; afterwards the user owns it. - useEffect(() => { - if (restored.current) return; - restored.current = true; - setActiveKey(tabs[0]?.key ?? ''); - }, [tabs]); - - useEffect(() => { - try { - localStorage.setItem(STORE_KEY, JSON.stringify(tabs)); - } catch { - /* private mode or quota — the layout still works for this page's lifetime */ - } - }, [tabs]); - - const active = tabs.find((tab) => tab.key === activeKey) ?? tabs[0]; - - // Only a name YOU typed is published — a label derived from the conversation would just restate the - // title the chat already publishes, one tier lower, and would then outrank a browser-tab name for no - // reason the user could see. - usePublishChatTabName(active?.title?.trim() || null); - - const update = (tabKey: string, fn: (tab: Tab) => Tab) => - setTabs((prev) => prev.map((tab) => (tab.key === tabKey ? fn(tab) : tab))); - - const addTab = () => { - const tab: Tab = { key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }; - setTabs((prev) => [...prev, tab]); - setActiveKey(tab.key); - }; - - const closeTab = (tabKey: string) => { - setTabs((prev) => { - const next = prev.filter((tab) => tab.key !== tabKey); - // Never leave nothing: an empty tab bar has no way back to a conversation. - const safe = next.length ? next : [{ key: nextKey('tab'), panes: [{ key: nextKey('pane'), target: null }] }]; - if (tabKey === activeKey) setActiveKey(safe[0]!.key); - return safe; - }); - }; - - const startRename = (tab: Tab) => { - setRenamingKey(tab.key); - // Seeded with the typed name only, not the derived label: pre-filling a name the user never chose - // makes Enter silently adopt it as if they had. - setRenameValue(tab.title ?? ''); - }; - - const commitRename = () => { - if (!renamingKey) return; - const next = renameValue.trim(); - // Empty hands the tab back to its derived name — the only way out, and no third state. - update(renamingKey, (tab) => ({ ...tab, title: next || undefined })); - setRenamingKey(null); - }; - - const splitPane = () => - active && - update(active.key, (tab) => - tab.panes.length >= MAX_PANES ? tab : { ...tab, panes: [...tab.panes, { key: nextKey('pane'), target: null }] }, - ); - - const closePane = (paneKey: string) => - active && - update(active.key, (tab) => - tab.panes.length <= 1 ? tab : { ...tab, panes: tab.panes.filter((pane) => pane.key !== paneKey) }, - ); - - // Stable across renders on purpose. It is handed to every pane as `onChange`, and a pane passes it - // into a context that other components read — an identity that changed every render would make any - // effect depending on it re-run forever, which is the render loop this file already caused once. - const setPaneTarget = useCallback( - (paneKey: string, target: SelectedSession | null) => - setTabs((prev) => - prev.map((tab) => - tab.key !== activeKeyRef.current - ? tab - : { ...tab, panes: tab.panes.map((pane) => (pane.key === paneKey ? { ...pane, target } : pane)) }, - ), - ), - [], - ); - - if (!active) return null; - - return ( -
- {/* Always visible: it is the only way to open a second tab or split a pane, so hiding it in the - single-conversation case would hide the feature from anyone who has not already used it. */} - { -
- {tabs.map((tab) => { - // A tab is named after what is in it: the first pane's conversation, else the machine. - const first = tab.panes[0]?.target; - const label = - tab.title || - first?.title || - (tab.panes.length > 1 ? `${tab.panes.length} panes` : connectionLabel(first?.serverId, 'Chat')); - if (renamingKey === tab.key) { - return ( - setRenameValue(ev.target.value)} - onKeyDown={(ev) => { - if (ev.key === 'Enter') commitRename(); - if (ev.key === 'Escape') setRenamingKey(null); - }} - onBlur={commitRename} - aria-label="Tab name" - placeholder={label} - className="w-32 shrink-0 rounded-t border-b border-primary/40 bg-muted px-2 py-1 text-xs outline-none" - /> - ); - } - - return ( - - ); - })} - - - - -
- } - -
- {active.panes.map((pane, index) => ( -
0 ? 'border-l border-border' : ''}`} - style={{ width: `${100 / active.panes.length}%` }} - > - {active.panes.length > 1 && ( - - )} - setPaneTarget(pane.key, next)} - showServerBadge={active.panes.length > 1} - /> -
- ))} -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/DirPickerModal.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/DirPickerModal.tsx index 37432966..01b92e60 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/DirPickerModal.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/DirPickerModal.tsx @@ -8,14 +8,12 @@ type DirPickerModalProps = { open: boolean; onClose: () => void; onSelect: (absPath: string) => void; - /** Whose filesystem to browse. Absent = this origin. */ - serverId?: string | null; }; // A simplified file-browser modal for picking a working directory (returns an absolute path). // Navigates within the home root; dirs elsewhere are reachable via the selector's free-text field. -export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerModalProps) => { - const api = useFilesAPI('home', serverId); +export const DirPickerModal = ({ open, onClose, onSelect }: DirPickerModalProps) => { + const api = useFilesAPI('home'); const [path, setPath] = useState('/'); // root-relative, always starts with '/' const [creating, setCreating] = useState(false); const [newName, setNewName] = useState(''); @@ -25,9 +23,7 @@ export const DirPickerModal = ({ open, onClose, onSelect, serverId }: DirPickerM const [showHidden, setShowHidden] = useState(false); const { data, isLoading, refetch } = useQuery({ - // Server in the key: two machines have different trees, and without it one machine's folders - // are served from cache under the other's name. - queryKey: ['dir-picker', path, serverId ?? null], + queryKey: ['dir-picker', path], queryFn: () => api.listDir(path), enabled: open, }); diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx deleted file mode 100644 index 739aae42..00000000 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PaneSelection.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { createContext, useContext, useMemo, useState } from 'react'; -import type { ReactNode } from 'react'; -import { useSelectedChatSession } from '../../channels'; -import type { SelectedSession } from './ChatDetailPanel'; - -/** - * Which conversation THIS pane has open. - * - * `chat:selected-session` is one channel for the whole screen, which was right while there was exactly - * one conversation on it. Two panes side by side make it wrong: both would read the same value and show - * the same chat, which is the opposite of the point. - * - * So a pane provides its own state here, and `usePaneSelection` prefers it. Outside a pane the context - * is absent and the channel is used exactly as before — every existing caller (the mobile layout, the - * dashboard's own chat panel) is untouched, which is what makes this safe to drop in. - * - * Deliberately a context rather than props: `SessionList` and `ChatDetailPanel` sit at different depths - * and neither should have to know whether it is inside a pane. - */ -type PaneSelectionValue = [SelectedSession | null, (next: SelectedSession | null) => void]; - -const PaneSelectionContext = createContext(null); - -/** - * Is this component inside a pane? - * - * A pane owns its conversation AND its directory, so it must not take either from the address bar: - * three panes cannot share one URL. Outside a pane the route stays the authority, exactly as before. - */ -export function useIsInPane(): boolean { - return useContext(PaneSelectionContext) !== null; -} - -export function usePaneSelection(): PaneSelectionValue { - const scoped = useContext(PaneSelectionContext); - const channel = useSelectedChatSession(); - // Hooks must run unconditionally, so the channel is always read; the scoped value simply wins. - return scoped ?? ([channel[0], channel[1]] as PaneSelectionValue); -} - -/** - * Give the subtree its own selection. - * - * `value`/`onChange` make it controllable, so the tab shell can persist a pane's open conversation - * across a reload — the mobile app keeps the target on the pane for the same reason, and it is what - * makes a restored tab still point at the right chat on the right machine. - */ -export const PaneSelectionProvider = ({ - children, - value, - onChange, -}: { - children: ReactNode; - value?: SelectedSession | null; - onChange?: (next: SelectedSession | null) => void; -}) => { - const [internal, setInternal] = useState(null); - const controlled = value !== undefined && !!onChange; - - const pair = useMemo( - () => (controlled ? [value ?? null, onChange!] : [internal, setInternal]), - [controlled, value, onChange, internal], - ); - - return {children}; -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx index 6799cf22..4748faa0 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/PwdSelector.tsx @@ -6,16 +6,14 @@ import { DirPickerModal } from './DirPickerModal'; type PwdSelectorProps = { value: string | null; // null = the default general_chat_sessions dir onChange: (cwd: string | null) => void; - /** Which Officer's directories to offer. Absent = this origin. */ - serverId?: string | null; }; // A shorter, friendlier label for a working directory. const shorten = (cwd: string) => cwd.replace(/^\/home\/[^/]+/, '~'); const basename = (cwd: string) => cwd.split('/').filter(Boolean).pop() ?? cwd; -export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) => { - const { pwds, defaultCwd } = useChatPwds(serverId); +export const PwdSelector = ({ value, onChange }: PwdSelectorProps) => { + const { pwds, defaultCwd } = useChatPwds(); const [open, setOpen] = useState(false); const [browse, setBrowse] = useState(false); const [custom, setCustom] = useState(''); @@ -98,7 +96,7 @@ export const PwdSelector = ({ value, onChange, serverId }: PwdSelectorProps) => )} - setBrowse(false)} onSelect={(p) => pick(p)} serverId={serverId} /> + setBrowse(false)} onSelect={(p) => pick(p)} /> ); }; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index aedcf245..0c1eb3f9 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -3,7 +3,7 @@ import { useNavigate, useParams } from 'react-router'; import { Plus, MessageSquare, RefreshCw, Trash2, Pencil, Check, X, Layers } from 'lucide-react'; import { toast } from '@/components/ui/sonner'; import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, RelativeTime } from '@/components/Data'; -import { usePaneSelection, useIsInPane } from './PaneSelection'; +import { useSelectedChatSession } from '../../channels'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; import { ServerChips } from './ServerChips'; @@ -19,16 +19,11 @@ export const SessionList = () => { // them there rather than from the selection channel means the highlight and the group are correct on // a deep link and on back/forward, before any panel has published. const { sessionId, '*': splat } = useParams<{ sessionId: string; '*': string }>(); - // Scoped to this pane when inside one, the shared channel otherwise — see PaneSelection. - const [selected, setSelected] = usePaneSelection(); + const [selected, setSelected] = useSelectedChatSession(); // A group path when we're on one; otherwise the open session's own directory, so /chat/ shows // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. - // In a pane the directory is the pane's, not the route's — three panes cannot share one URL, and - // letting the address bar win is what reset this list to the default the moment a row was clicked. - const inPane = useIsInPane(); - const [paneCwd, setPaneCwd] = useState(null); - const activeCwd = inPane ? paneCwd : (cwdFromSplat(splat) ?? selected?.cwd ?? null); + const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another // machine is the entire point, and a shared "current server" would make that impossible to express. @@ -92,20 +87,17 @@ export const SessionList = () => { value={serverId} onChange={(next) => { setServerId(next); - setPaneCwd(null); // A path and a conversation from the machine you left name nothing on the one you arrived // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). setSelected(null); - if (!inPane) navigate(chatListPath(null), { replace: true }); + navigate(chatListPath(null), { replace: true }); }} /> { setSelected(null); // sessions belong to a cwd — clear the open one when switching - if (inPane) setPaneCwd(cwd); - else navigate(chatListPath(cwd), { replace: true }); + navigate(chatListPath(cwd), { replace: true }); }} />
@@ -212,7 +204,7 @@ export const SessionList = () => { {/* The row is the link and the actions are its siblings — a - ); - } - - return ( -
- onChange(null)} /> - {connections.map((connection) => ( - onChange(connection.id)} - onRemove={() => { - removeConnection(connection.id); - // Panels pointed at it must not keep asking a server that no longer exists. - if (value === connection.id) onChange(null); - }} - /> - ))} - - {adding && setAdding(false)} onAdded={(id) => onChange(id)} />} -
- ); -}; - -const Chip = ({ - label, - active, - onClick, - onRemove, -}: { - label: string; - active: boolean; - onClick: () => void; - onRemove?: () => void; -}) => ( - - - {onRemove && ( - - )} - -); - -/** - * Add a server by URL and API key. - * - * A key (`ofk_…`) rather than email and password, deliberately: this browser already holds a session for - * the server that served it, and a second server needs a credential that does not depend on signing in - * here. Mint one on the other machine with `POST /api/api-keys`. - * - * The key is verified against `/api/auth/me` before it is stored — a URL typo or a key from the wrong - * machine is otherwise indistinguishable from an empty conversation list later on. - */ -const AddServerForm = ({ onDone, onAdded }: { onDone: () => void; onAdded: (serverId: string) => void }) => { - const [url, setUrl] = useState(''); - const [key, setKey] = useState(''); - const [name, setName] = useState(''); - const [error, setError] = useState(null); - const [busy, setBusy] = useState(false); - - const submit = async () => { - setBusy(true); - setError(null); - try { - const base = /^https?:\/\//i.test(url.trim()) ? url.trim().replace(/\/+$/, '') : `https://${url.trim()}`; - const res = await fetch(`${base}/api/auth/me`, { headers: { Authorization: `Bearer ${key.trim()}` } }); - if (!res.ok) throw new Error(res.status === 401 ? 'That key was rejected' : `Server answered ${res.status}`); - const me = (await res.json()) as { email?: string }; - const connection = upsertConnection({ url: base, key, name: name || me.email }); - onAdded(connection.id); - onDone(); - } catch (err) { - // A cross-origin failure lands here as a TypeError with no detail, which is worth naming: over - // HTTPS the browser blocks a plain-http server outright and no code of ours ever runs. - const message = err instanceof Error ? err.message : String(err); - setError( - message === 'Failed to fetch' - ? 'Could not reach it. If this page is HTTPS, the other server must be too.' - : message, - ); - } finally { - setBusy(false); - } - }; - - return ( -
-
- Add an Officer server -
- setUrl(ev.target.value)} - placeholder="macbook.pastilhas.dev" - className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" - /> - setKey(ev.target.value)} - placeholder="ofk_… (API key from that server)" - className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" - /> - setName(ev.target.value)} - placeholder="Name (optional)" - className="rounded border border-input bg-background px-2 py-1 text-xs focus:border-duck-teal/50 focus:outline-none" - /> - {error &&
{error}
} -
- - -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 0c1eb3f9..4c836465 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -6,7 +6,6 @@ import { DataList, DataRow, EmptyBlock, ErrorBlock, LoadingBlock, MetaItem, Rela import { useSelectedChatSession } from '../../channels'; import { errorText } from 'helpers/error-text'; import { useClaudeSessions } from 'state/useClaudeSessions'; -import { ServerChips } from './ServerChips'; import type { SelectedSession } from './ChatDetailPanel'; import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { PwdSelector } from './PwdSelector'; @@ -24,12 +23,7 @@ export const SessionList = () => { // that session among its neighbours instead of snapping the list back to the default group. Null = // the default general_chat_sessions dir. const activeCwd = cwdFromSplat(splat) ?? selected?.cwd ?? null; - - // Which Officer this list is reading. Panel-local state, NOT a global: another panel showing another - // machine is the entire point, and a shared "current server" would make that impossible to express. - // Seeded from the open conversation so a deep link into a remote session keeps its list on that host. - const [serverId, setServerId] = useState(selected?.serverId ?? null); - const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd, serverId); + const { sessions, isLoading, error, refetch, deleteSession, renameSession } = useClaudeSessions(activeCwd); const [editingId, setEditingId] = useState(null); const [editValue, setEditValue] = useState(''); const [confirmingId, setConfirmingId] = useState(null); @@ -82,17 +76,7 @@ export const SessionList = () => { return (
-
- { - setServerId(next); - // A path and a conversation from the machine you left name nothing on the one you arrived - // at — the mobile app clears cwd for exactly this reason (`useChatScreen.chooseServer`). - setSelected(null); - navigate(chatListPath(null), { replace: true }); - }} - /> +
{ @@ -113,7 +97,7 @@ export const SessionList = () => { onClick={() => { // A new chat starts in the group the list is showing, and says so in both places: on the // selection (which is what the composer actually runs in) and in the URL. - setSelected({ id: `new:${Date.now()}`, cwd: activeCwd, serverId }); + setSelected({ id: `new:${Date.now()}`, cwd: activeCwd }); navigate(chatNewPath(activeCwd), { replace: true }); }} // Was duck-teal filled with duck-yellow text. duck-teal is a bright cyan in dark mode and @@ -205,11 +189,6 @@ export const SessionList = () => { not a thing, and nesting them is what breaks cmd-click on half the app's lists. */} setSelected({ id: session.id, cwd: session.cwd, title: session.title, serverId })} title={session.title} selected={isActive} className="min-w-0 flex-1" diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index 3e15c637..57c98b2b 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -1,7 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { useChatWebSocket } from 'hooks/useChatWebSocket'; -import { useServerClient, chatSocketUrl } from 'hooks/useServerClient'; -import { resolveBearerToken } from 'hooks/useClient'; +import { useClient } from 'hooks/useClient'; import { useSettings } from 'state/useSettings'; import type { ChatMessage, ServerMessage, TaskInfo, Message } from '../apps/Chat/types'; import { spliceRunningTasks } from '../apps/Chat/running-tasks'; @@ -28,14 +27,6 @@ type UsePiChatOptions = { // When set, `initialMessages` is only the tail of a long transcript; scroll-up pages older ones in. paginate?: { sessionId: string; total: number; initialOffset: number }; onTurnComplete?: (hadToolCalls: boolean) => void; - /** - * Which Officer this conversation lives on. Absent = the one that served this page. - * - * This is the ONLY thing that makes a panel talk to another machine, and it is deliberately a plain - * string rather than a context: two panels side by side hold two different values, which is what lets - * one conversation run on the laptop while another runs on alpha, both live, with no switching. - */ - serverId?: string | null; }; /** @@ -68,7 +59,6 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, initialMessages: preloadedMessages, paginate, onTurnComplete, - serverId, } = options ?? {}; const [messages, setMessages] = useState(preloadedMessages ?? []); /** @@ -144,10 +134,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, // reattachable the moment the harness names its transcript rather than when the turn finishes. const claudeSessionIdRef = useRef(resumeSessionId ?? null); - // The one string that makes this panel talk to another machine. Absent = this origin, which is every - // existing caller. It picks the URL, the credential and the socket host together — a key minted on one - // host is meaningless to another, so they must never be derived separately. - const client = useServerClient(serverId); + const client = useClient(); // Fetch the next older window and prepend it. The scroll container restores its position from the // height delta so the view stays put. Guarded against overlap and against running once fully paged in. @@ -178,12 +165,9 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, const hasMoreOlder = !!paginate && oldestOffset > 0; - // Null when a named server is unknown, which opens no socket rather than dialling this origin under - // another server's name — the failure that would put one machine's turn in another's pane. - // The SAME resolution the HTTP client uses. Reading localStorage directly here meant a token held - // anywhere else authenticated every request and left the socket with `?token=`, refused 1002 and - // retrying forever — an app that loads and lists history but never connects. - const wsUrl = chatSocketUrl(serverId, resolveBearerToken()); + const token = localStorage.getItem('BEARER_TOKEN'); + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/api/chat/ws?token=${token}`; function flushStreaming() { if (rafRef.current !== null) cancelAnimationFrame(rafRef.current); @@ -545,7 +529,7 @@ export function useChat(initialSessionId?: string, initialModel?: string | null, }); }, [sendAttach]); - const { isConnected, send } = useChatWebSocket({ url: wsUrl ?? '', onMessage: handleMessage, onOpen }); + const { isConnected, send } = useChatWebSocket({ url: wsUrl, onMessage: handleMessage, onOpen }); sendRef.current = send; // `resumeSessionId` is resolved asynchronously by the panel that owns this hook, so it routinely lands diff --git a/src/workspaces/state/src/useClaudeSessions.ts b/src/workspaces/state/src/useClaudeSessions.ts index 30a3612c..26a09d3d 100644 --- a/src/workspaces/state/src/useClaudeSessions.ts +++ b/src/workspaces/state/src/useClaudeSessions.ts @@ -1,7 +1,6 @@ import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useCallback } from 'react'; import { useClient } from 'hooks/useClient'; -import { useServerClient } from 'hooks/useServerClient'; import { useAuth } from 'hooks/useAuth'; const SESSIONS_KEY = 'CLAUDE_SESSIONS'; @@ -68,15 +67,12 @@ export type ClaudePwd = { cwd: string; sessionCount: number; updatedAt: string; const cwdQuery = (cwd?: string | null) => (cwd ? `?cwd=${encodeURIComponent(cwd)}` : ''); /** The default /chat dir plus every directory that already has Claude sessions. */ -export function useChatPwds(serverId?: string | null) { - const client = useServerClient(serverId); +export function useChatPwds() { + const client = useClient(); const { isAuthenticated } = useAuth(); const { data } = useQuery<{ pwds: ClaudePwd[]; default: string }>({ - // Server-scoped: two machines have different working directories, and an unscoped key would show - // one machine's folders under the other's name. - queryKey: ['CHAT_PWDS', serverId ?? null], - // A named server carries its own API key, so it does not depend on this origin's session. - enabled: isAuthenticated || !!serverId, + queryKey: ['CHAT_PWDS'], + enabled: isAuthenticated, queryFn: () => client.get<{ pwds: ClaudePwd[]; default: string }>('/chat/pwds'), staleTime: 30 * 1000, }); @@ -84,18 +80,15 @@ export function useChatPwds(serverId?: string | null) { } /** Sessions for a working directory, read from Claude's own transcript store (source of truth). */ -export function useClaudeSessions(cwd?: string | null, serverId?: string | null) { - const client = useServerClient(serverId); +export function useClaudeSessions(cwd?: string | null) { + const client = useClient(); const queryClient = useQueryClient(); const { isAuthenticated } = useAuth(); const q = cwdQuery(cwd); const { data, isLoading, error, refetch } = useQuery<{ sessions: ClaudeSessionSummary[] }>({ - // The server id is part of the key because two Officers can hold transcripts with the SAME uuid — - // without it the cache hands one machine's conversation to the other, which looks like a UI glitch - // while actually being the wrong server's data under the right server's name. - queryKey: [SESSIONS_KEY, cwd ?? 'default', serverId ?? null], - enabled: isAuthenticated || !!serverId, + queryKey: [SESSIONS_KEY, cwd ?? 'default'], + enabled: isAuthenticated, queryFn: () => client.get<{ sessions: ClaudeSessionSummary[] }>(`/chat/sessions${q}`), staleTime: 30 * 1000, }); From bc13450fad728a1a992e277311bbc53736da2bc0 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 00:12:13 +0100 Subject: [PATCH 34/42] stop closing the chat socket on every remount, again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-applies bcb3d6d, which the tabs/panes revert (0a4ff54) took out as collateral: the fix lived in useChatWebSocket.ts, so reverting the panes work reverted it too. It was never panes-specific — the mechanism is React remounting a subtree, which happens on this screen with one conversation just as it did in a pane. The cleanup closed the socket while it was still CONNECTING, and the replacement was closed in turn, so the view churned and sat on Disconnected forever. The close is now deferred a tick and cancelled if the effect re-runs: a remount reclaims the live socket, a real unmount has nobody to cancel it. Diagnosed from the browser this time rather than guessed. A raw socket opened by hand from the console on the same origin, with the same token, reports RAW OPEN and stays open: new WebSocket(`wss://${location.host}/api/chat/ws?token=${localStorage.getItem('BEARER_TOKEN')}`) so transport, auth, the tailnet proxy and the server are all fine and the app was closing its own socket. Two earlier theories are dead and worth naming: the token resolution mismatch (52d5678) does not apply — the token IS in localStorage.BEARER_TOKEN where the old code looks — and StrictMode's double-invoke is not the trigger here, since pm2 runs `bun start` with NODE_ENV=production where React does not double-invoke. Some other remount is. Not verified in a browser yet: whether this alone clears Disconnected. If it does not, the remaining suspect is a continuous remount rather than a single one, which a WebSocket-constructor counter in the console will show as a rising count. Typecheck clean. Co-Authored-By: Claude Opus 5 --- src/workspaces/hooks/src/useChatWebSocket.ts | 33 +++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 776da795..e59f955b 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -15,6 +15,8 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const retryRef = useRef(0); const retryTimeoutRef = useRef(null); const isCleaningUpRef = useRef(false); + /** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */ + const closeTimerRef = useRef(null); const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; const onOpenRef = useRef(onOpen); @@ -59,18 +61,41 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar }; useEffect(() => { + // A pending teardown from a remount that is about to be undone — see below. + if (closeTimerRef.current !== null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } isCleaningUpRef.current = false; connect(); + return () => { + /** + * Close LATER, not now. + * + * Closing here directly is correct for a real unmount and disastrous for a remount, and this hook + * cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect + * (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key + * change — does the same. Each time, the socket was closed while still CONNECTING, the browser + * logged "closed before the connection is established", and the replacement was closed in turn, so + * the view could churn forever and never hold a connection while sitting on Disconnected. + * + * Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and + * cancels this timer, so the live socket is kept and the handshake completes. A real unmount has + * nobody to cancel it and the socket closes a frame later, which costs nothing. + */ isCleaningUpRef.current = true; if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } - if (socketRef.current) { - socketRef.current.close(); - socketRef.current = null; - } + const socket = socketRef.current; + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it + if (socket) socket.close(); + if (socketRef.current === socket) socketRef.current = null; + }, 0); }; }, [url]); From 66a41d0813dfed9acb20c0da1bffb1b7606d4b43 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 00:20:30 +0100 Subject: [PATCH 35/42] reclaim a connecting socket instead of orphaning it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to bc13450, found by actually driving a browser instead of reasoning about one. The deferred close keeps a remount's socket alive mid-handshake, but `connect` only treated OPEN as "already ours" — so the re-run built a second socket, overwrote socketRef, and left the first open forever with its `open` handler bailing on the mismatch. CONNECTING now counts too. What the browser actually says, headless Chrome against this server, fresh load of /chat/new: #1 NEW wss://…/api/chat/ws?token=… #2 NEW wss://…/api/chat/ws?token=… #1 OPEN #2 OPEN (neither ever closes, 12s) header: green dot, no "Disconnected" So the served code CONNECTS on a fresh load and the Disconnected report could not be reproduced here — which points the remaining report at the client's cached bundle rather than at this code. The chunk hash moved e3jsfax5 -> 81jec45w across these edits, so the rebuild is reaching the wire. Two sockets per load survive this fix and are NOT what it addresses: they come from two separate `useChat` instances mounting on that route, each with its own refs, so no per-instance guard can see the other. Left alone deliberately — both connect, and one conversation opening two agent sockets wants understanding before a fix. Also retired here: my claim that StrictMode's double-invoke was the trigger. The served bundle has no dev-only React internals at all (`doubleInvokeEffectsOnFiber`, `runWithFiberInDEV`, `commitPassiveUnmountEffectsInsideOfDeletedTree`: zero hits), because pm2 runs `bun start` with NODE_ENV=production. Typecheck clean. Repro harness is in the session scratchpad, not committed. Co-Authored-By: Claude Opus 5 --- src/workspaces/hooks/src/useChatWebSocket.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index e59f955b..7f1104b0 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -24,7 +24,16 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const connect = () => { if (isCleaningUpRef.current) return; - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; + // CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket + // alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run + // built a SECOND socket and orphaned the first, which then stayed open forever with its own `open` + // handler bailing on the socketRef mismatch. + // + // This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with + // a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each + // with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy. + const existing = socketRef.current; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return; const socket = new WebSocket(url); socketRef.current = socket; From b89a562614e7a3909be1b06d200ed6aa0c654622 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 00:31:04 +0100 Subject: [PATCH 36/42] back out tonight's socket changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts 66a41d0 and bc13450. Both were justified by reasoning that measurement then contradicted: the chat socket was never the fault. What actually fixed chat was tearing down and restarting the whole pm2 ecosystem, so the failure lived in process state, not in this hook. Leaves the tree identical to 31ffe08 — the pre-multi-server baseline Andre asked for — apart from docs/agent-git-identity.md. Co-Authored-By: Claude Opus 5 --- src/workspaces/hooks/src/useChatWebSocket.ts | 44 +++----------------- 1 file changed, 5 insertions(+), 39 deletions(-) diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 7f1104b0..776da795 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -15,8 +15,6 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const retryRef = useRef(0); const retryTimeoutRef = useRef(null); const isCleaningUpRef = useRef(false); - /** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */ - const closeTimerRef = useRef(null); const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; const onOpenRef = useRef(onOpen); @@ -24,16 +22,7 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const connect = () => { if (isCleaningUpRef.current) return; - // CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket - // alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run - // built a SECOND socket and orphaned the first, which then stayed open forever with its own `open` - // handler bailing on the socketRef mismatch. - // - // This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with - // a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each - // with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy. - const existing = socketRef.current; - if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return; + if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; const socket = new WebSocket(url); socketRef.current = socket; @@ -70,41 +59,18 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar }; useEffect(() => { - // A pending teardown from a remount that is about to be undone — see below. - if (closeTimerRef.current !== null) { - clearTimeout(closeTimerRef.current); - closeTimerRef.current = null; - } isCleaningUpRef.current = false; connect(); - return () => { - /** - * Close LATER, not now. - * - * Closing here directly is correct for a real unmount and disastrous for a remount, and this hook - * cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect - * (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key - * change — does the same. Each time, the socket was closed while still CONNECTING, the browser - * logged "closed before the connection is established", and the replacement was closed in turn, so - * the view could churn forever and never hold a connection while sitting on Disconnected. - * - * Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and - * cancels this timer, so the live socket is kept and the handshake completes. A real unmount has - * nobody to cancel it and the socket closes a frame later, which costs nothing. - */ isCleaningUpRef.current = true; if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } - const socket = socketRef.current; - closeTimerRef.current = window.setTimeout(() => { - closeTimerRef.current = null; - if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it - if (socket) socket.close(); - if (socketRef.current === socket) socketRef.current = null; - }, 0); + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } }; }, [url]); From d9857eef7c8a05ade507a65a040c1096b3a7a227 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 01:57:04 +0100 Subject: [PATCH 37/42] re-land: deliver a chat turn to every socket watching it, not the newest one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is 7726c9f, reverted a few hours ago as collateral with the tabs-and-panes work. It was never a panes feature — it is the fix for a bug that predates them, and tonight it was reproduced by hand. The symptom: on https://macbook.pastilhas.dev a chat connects, works briefly, and is dead after a refresh, never coming back. On http://localhost:9010 the same build is fine. The cause is ordering. A refresh means socket B attaches before socket A's close is delivered, and `detachWs(sessionId)` took no socket argument — it nulled the session's single `ws` field, so the dying socket silenced the live one that had already replaced it. Nothing re-attaches afterwards, which is why it never came back. Over loopback the close usually lands first and it survives; via NPM on alpha and back to this host the extra latency makes the late close the ordinary case. That is the whole of the localhost/domain asymmetry. `sockets: Set` plus `detachWs(sessionId, ws)` removes only the socket that actually closed, and delivery fans out to whatever is still attached. `hasSockets` then gates the idle GC, which used to arm on ANY close — a second pane closing could collect a conversation out from under the first. Ruled out on the way, so none of it is re-investigated: the reverse proxy relays upgrades correctly (a clean 101 through openresty, and a full turn streamed end to end over wss:// with deltas and a cost line); origin validation is off (ALLOW_ANY_ORIGIN defaults true and is unset here) and never runs on the upgrade, which is a literal Bun route and never reaches Hono; authenticated HTTP is 200 through both doors; the passkeys table is empty, so no origin-bound credential is involved; and the token-resolution fix 52d5678 — which I nearly re-landed first — was the WRONG diagnosis, because signin writes localStorage.BEARER_TOKEN, exactly where the socket url reads. That one is still worth having for embedded and ?officerToken= hosts, but it was never this. Not verified: a browser refresh against the domain, which is Andre's to confirm — it is the only step I cannot drive from here. Typecheck clean. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/session-manager.ts | 18 ++++++++++++++---- src/servers/api/chat/types.ts | 8 +++++++- src/servers/api/chat/websocket.ts | 24 +++++++++++++++--------- 3 files changed, 36 insertions(+), 14 deletions(-) diff --git a/src/servers/api/chat/session-manager.ts b/src/servers/api/chat/session-manager.ts index c697b332..fcde0940 100644 --- a/src/servers/api/chat/session-manager.ts +++ b/src/servers/api/chat/session-manager.ts @@ -24,7 +24,7 @@ class SessionManager { cwd, model, piProcess: null, - ws: null, + sockets: new Set(), lastActivity: Date.now(), idleTimer: null, streamBuffer: '', @@ -145,7 +145,7 @@ class SessionManager { attachWs(sessionId: string, ws: any): void { const session = this.sessions.get(sessionId); if (session) { - session.ws = ws; + session.sockets.add(ws); session.lastActivity = Date.now(); if (session.idleTimer) { @@ -155,14 +155,24 @@ class SessionManager { } } - detachWs(sessionId: string): void { + /** + * Removes one socket. The caller must say WHICH — a bare `detachWs(sessionId)` used to null the + * session's only socket field, so a stale client's close event silenced whichever client had attached + * after it. A close is only the end of the conversation when nothing else is still watching. + */ + detachWs(sessionId: string, ws: any): void { const session = this.sessions.get(sessionId); if (session) { - session.ws = null; + session.sockets.delete(ws); session.lastActivity = Date.now(); } } + /** Whether anything is still watching — the idle GC must not start while another client is attached. */ + hasSockets(sessionId: string): boolean { + return (this.sessions.get(sessionId)?.sockets.size ?? 0) > 0; + } + setIdleTimeout(sessionId: string, timeoutMs: number): void { const session = this.sessions.get(sessionId); if (!session) return; diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index b428467d..d49ddc05 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -299,7 +299,13 @@ export type UserSession = { cwd: string; model: string; piProcess: any | null; - ws: any | null; + /** + * Every socket watching this conversation, not the most recent one. Two panes in one window, or a + * laptop and an iPad on the same chat, are both ordinary now that a tab holds several panes — and a + * single `ws` field meant the newest attach silently stole the turn from everyone else, while any one + * of them closing set it to null and killed delivery for the rest. + */ + sockets: Set; lastActivity: number; idleTimer: Timer | null; streamBuffer: string; diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index 6429b283..ee36abe4 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -158,8 +158,10 @@ export function close(ws: ServerWebSocket): void { const sessionId = wsToSessionMap.get(ws); if (sessionId) { - sessionManager.detachWs(sessionId); - sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS); + sessionManager.detachWs(sessionId, ws); + // Only once nothing is watching. Another pane or another device still attached means the + // conversation is live, and arming the idle GC here would collect it out from under them. + if (!sessionManager.hasSockets(sessionId)) sessionManager.setIdleTimeout(sessionId, IDLE_TIMEOUT_MS); } } @@ -244,7 +246,7 @@ function createMessageHandler(sessionId: string, model: string) { const session = sessionManager.getSession(sessionId); if (!session) return; foldIntoSession(session, msg, model); - sendToClient(session.ws as ServerWebSocket | null, msg, seq); + for (const socket of session.sockets) sendToClient(socket as ServerWebSocket, msg, seq); }; } @@ -651,7 +653,7 @@ async function handleResumeCursor( // the Claude sidecar about an OpenCode session, hear "not generating", and write "the agent went // away" into a turn that was running perfectly well. if (msg.generating && decision.kind !== 'assume') { - await endTurnIfAgentIsGone(ws, sessionId, decision.model); + await endTurnIfAgentIsGone([ws], sessionId, decision.model); } } @@ -821,7 +823,7 @@ async function handleAttach(ws: ServerWebSocket, msg: { claudeSessionId: * so its sessions are left alone rather than guessed at. */ async function endTurnIfAgentIsGone( - ws: ServerWebSocket | null, + targets: Iterable | null>, sessionId: string, model: string, ): Promise { @@ -834,11 +836,11 @@ async function endTurnIfAgentIsGone( const event: ServerMessage = { type: 'cut-off' }; try { const seq = await appendChatEvent(sessionId, event); - sendToClient(ws, event, seq); + for (const target of targets) sendToClient(target, event, seq); } catch (err) { - // Still tell this client — an un-replayable explanation beats a spinner that never stops. + // Still tell every client — an un-replayable explanation beats a spinner that never stops. logger.error('Failed to persist cut-off notice', { sessionId, error: String(err) }); - sendToClient(ws, event); + for (const target of targets) sendToClient(target, event); } logger.info('Ended a turn whose agent had gone', { sessionId }); } @@ -850,7 +852,11 @@ async function endTurnIfAgentIsGone( sidecar.onClaudeSidecarStarted(() => { for (const session of sessionManager.getAllSessions()) { if (!session.isGenerating) continue; - void endTurnIfAgentIsGone(session.ws as ServerWebSocket | null, session.sessionId, session.model); + void endTurnIfAgentIsGone( + session.sockets as Set>, + session.sessionId, + session.model, + ); } }); From 9d2da49572aba60fc178bc827637ed6843e1ebc6 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 02:00:31 +0100 Subject: [PATCH 38/42] re-land the client half: hold the socket across a remount, and queue what was typed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three commits backed out a few hours ago as collateral, restored together because they are one fix: 243bd04 (queue sends until OPEN), bc13450 (defer the teardown close by a tick, cancellable) and 66a41d0 (count CONNECTING as ours, not only OPEN). Why they are needed, from evidence rather than reasoning. d9857ee fixed the server side and Andre still had nothing on https://macbook.pastilhas.dev after a restart. The decisive observation is an ABSENCE: his attempts appear nowhere in officer's log — no "Model selected for chat", no claude:stream for his session. Nothing reaches the server at all. Meanwhile a socket I drove by hand against the same wss:// url ran a full turn in 2.5s, so the transport is not it. That is `send` dropping the message. It returned silently on `readyState !== OPEN`, and the socket is not OPEN because the effect cleanup closed it on a remount while it was still CONNECTING, then closed its replacement the same way. Enter does nothing, forever, with the view sitting on Disconnected — and no error anywhere, on either side, which is why this reads as a dead server. Note what is still NOT fixed, and is written into the code comment rather than this message alone: a /chat/new load opens TWO sockets, from two separate useChat instances mounting. Measured with a constructor counter. Both connect, so it looks healthy; d9857ee is what makes it harmless. Correction to d9857ee's message, so the record is not wrong: it claims the localhost/domain split is latency changing the attach/close ordering. That is plausible and it is NOT what was demonstrated — the server-side fix alone did not help. It is still worth having (a stale close silencing a live client is real, and so is the idle GC gate), but the asymmetry is unexplained and the client drop above is what actually stopped a message. Client bundle changes, so this needs a hard reload as well as a restart. Not verified in a browser. Co-Authored-By: Claude Opus 5 --- src/workspaces/hooks/src/useChatWebSocket.ts | 75 ++++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/src/workspaces/hooks/src/useChatWebSocket.ts b/src/workspaces/hooks/src/useChatWebSocket.ts index 776da795..5ec7bb2e 100644 --- a/src/workspaces/hooks/src/useChatWebSocket.ts +++ b/src/workspaces/hooks/src/useChatWebSocket.ts @@ -15,14 +15,39 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar const retryRef = useRef(0); const retryTimeoutRef = useRef(null); const isCleaningUpRef = useRef(false); + /** A deferred teardown, cancelled when the effect re-runs — see the cleanup below. */ + const closeTimerRef = useRef(null); const onMessageRef = useRef(onMessage); onMessageRef.current = onMessage; const onOpenRef = useRef(onOpen); onOpenRef.current = onOpen; + /** + * Messages typed before the socket was ready. + * + * `send` used to drop them: `readyState !== OPEN` returned, silently, with no error and no retry — so + * pressing enter did nothing and the turn never happened. That window is not rare. React's dev + * StrictMode double-invokes effects, so every socket is created, closed and recreated on mount, and a + * reconnect after a drop reopens it again; with several chat panes on screen there are several sockets + * doing this at once. One of them is always briefly not OPEN. + * + * Queued and flushed on open, in order. The mobile chat app does exactly this and for exactly this + * reason — the composer is allowed to fire before the transport is ready. + */ + const pendingRef = useRef([]); + const connect = () => { if (isCleaningUpRef.current) return; - if (socketRef.current && socketRef.current.readyState === WebSocket.OPEN) return; + // CONNECTING counts as ours, not just OPEN. The deferred teardown below keeps a remount's socket + // alive mid-handshake, and this is what reclaims it: checking only OPEN meant an effect re-run + // built a SECOND socket and orphaned the first, which then stayed open forever with its own `open` + // handler bailing on the socketRef mismatch. + // + // This is per-instance and does NOT explain the two sockets a /chat/new load opens — measured with + // a WebSocket-constructor counter, those come from two separate `useChat` instances mounting, each + // with its own refs. Unresolved, and tracked separately; both connect, so it reads as healthy. + const existing = socketRef.current; + if (existing && (existing.readyState === WebSocket.OPEN || existing.readyState === WebSocket.CONNECTING)) return; const socket = new WebSocket(url); socketRef.current = socket; @@ -31,7 +56,12 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar if (socketRef.current !== socket) return; setIsConnected(true); retryRef.current = 0; + // BEFORE onOpen, deliberately: onOpen sends the resume/attach handshake, and anything the user + // typed while connecting belongs after that, not in front of it. + const queued = pendingRef.current; + pendingRef.current = []; onOpenRef.current?.(); + for (const message of queued) socket.send(message); }); socket.addEventListener('message', (ev) => { @@ -59,25 +89,56 @@ export const useChatWebSocket = ({ url, onMessage, onOpen }: UseChatWebSocketPar }; useEffect(() => { + // A pending teardown from a remount that is about to be undone — see below. + if (closeTimerRef.current !== null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } isCleaningUpRef.current = false; connect(); + return () => { + /** + * Close LATER, not now. + * + * Closing here directly is correct for a real unmount and disastrous for a remount, and this hook + * cannot tell them apart at the moment it runs. React's dev StrictMode double-invokes every effect + * (mount → unmount → mount), and a subtree that is re-created — a resolved transcript, a parent key + * change — does the same. Each time, the socket was closed while still CONNECTING, the browser + * logged "closed before the connection is established", and the replacement was closed in turn, so + * the view could churn forever and never hold a connection while sitting on Disconnected. + * + * Deferring by a tick makes the two distinguishable. A remount re-runs the effect immediately and + * cancels this timer, so the live socket is kept and the handshake completes. A real unmount has + * nobody to cancel it and the socket closes a frame later, which costs nothing. + */ isCleaningUpRef.current = true; if (retryTimeoutRef.current !== null) { clearTimeout(retryTimeoutRef.current); retryTimeoutRef.current = null; } - if (socketRef.current) { - socketRef.current.close(); - socketRef.current = null; - } + const socket = socketRef.current; + closeTimerRef.current = window.setTimeout(() => { + closeTimerRef.current = null; + if (!isCleaningUpRef.current) return; // remounted: the effect above already reclaimed it + if (socket) socket.close(); + if (socketRef.current === socket) socketRef.current = null; + }, 0); }; }, [url]); const send = (data: Record) => { const socket = socketRef.current; - if (!socket || socket.readyState !== WebSocket.OPEN) return; - socket.send(JSON.stringify(data)); + const message = JSON.stringify(data); + if (socket && socket.readyState === WebSocket.OPEN) { + socket.send(message); + return; + } + // Not open yet, or reconnecting. Hold it rather than dropping it — see `pendingRef`. Bounded so a + // socket that never comes back cannot grow this without limit; the oldest go first, because the + // newest message is the one the user is still waiting on. + pendingRef.current.push(message); + if (pendingRef.current.length > 50) pendingRef.current.shift(); }; return { isConnected, send }; From adaaba658c79e75ad23944789d0a8645e349934d Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 02:41:24 +0100 Subject: [PATCH 39/42] list opencode sessions from every project, not just the serve's own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An opencode session in a git directory never appeared in /chat. The list read GET /session, which answers for ONE project — the one the request's directory resolves to, and with no x-opencode-directory header that is the serve's own cwd, DATA_PATH/opencode_server. Not a git checkout, so it resolves to the catch-all project `global`, along with every other non-git directory. That is why the default chat dir listed fine and nothing looked broken: a cwd that IS a checkout gets its own project, and chat pwds are checkouts. Measured on the live serve before changing anything: /session returned 8 sessions, /api/session 13, the five missing ones being an old project's. A session created in a git directory came back 0 times from /session and 1 from /api/session. /api/session spans projects, so that is now the list. The per-id reads stay on /session — they answer for any session regardless of project, verified 200 with and without the header. The trap, and the reason listSessions normalises rather than returning the response: the two surfaces disagree in silence. /session carries the working directory as top-level `directory`, /api/session as `location.directory` with no top-level field, inside a {data: …} envelope. Swapping the endpoint without the mapping leaves `directory` undefined on every session, which the cwd filter turns into an empty list — the same shape as the metadata.officer.cwd bug this filter already had once. Verified against the live serve: with the mapping, a session in a git directory and one in the general chat dir both resolve to their cwd, and every session carries a directory. Co-Authored-By: Claude Opus 5 --- src/servers/api/chat/chat.ts | 3 ++- src/servers/api/chat/opencode-sessions.ts | 13 +++++---- src/servers/api/chat/opencode/client.ts | 33 ++++++++++++++++++++--- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts index afdac3d7..ea0d890f 100644 --- a/src/servers/api/chat/chat.ts +++ b/src/servers/api/chat/chat.ts @@ -32,7 +32,8 @@ export const chatRouter = createRouter(); // The working directory a request operates on: an explicit ?cwd= (a chosen pwd), else the default // general_chat_sessions dir. Claude groups sessions by cwd, so this selects which project group we read. -// (OpenCode sessions all live in the one fixed server and ignore cwd.) +// OpenCode runs on one fixed serve, but each session records the directory its turn ran in, so cwd +// selects there too. const cwdOf = (ctx: Context, email: string): string => ctx.req.query('cwd')?.trim() || getGeneralChatSessionsCwd(email); // GET /chat/pwds — the default /chat dir plus every directory that already has Claude sessions. diff --git a/src/servers/api/chat/opencode-sessions.ts b/src/servers/api/chat/opencode-sessions.ts index b265974d..229dd1e9 100644 --- a/src/servers/api/chat/opencode-sessions.ts +++ b/src/servers/api/chat/opencode-sessions.ts @@ -7,18 +7,17 @@ import { logger } from './logger'; // fixed pm2-managed server's HTTP API (never the DB directly). Returns the same shapes as the Claude // reader, tagged harness:'opencode', so chat.ts can merge both harnesses transparently. // -// Sessions do NOT all live in one project. That claim was true when turns went through the serve and -// inherited its directory; turns are now `opencode run --dir ` subprocesses, so each session -// records the directory it ran in and one serve happily lists sessions across many. Verified: a single -// serve returned 7 sessions spread over several directories, which is also why the cwd filter has to -// read `directory` rather than assume. +// Sessions do NOT all live in one project. Each records the directory its turn ran in — the serve takes +// it per request as `x-opencode-directory` — and one serve holds sessions for many. That is why the cwd +// filter has to read `directory` rather than assume, and why the list has to come from a read that spans +// projects (`client.listSessions`, which is where the project-scoping trap is written up). /** * List OpenCode sessions for a working directory, so each context (/chat pwd, email account, project) * sees only its own. With no cwd, returns all. Never throws — returns [] if the server is unavailable. * - * Filters on the session's own `directory`, which is what OpenCode records when the runner starts it - * with `--dir`. It used to filter on `metadata.officer.cwd`, a tag whose only writer + * Filters on the session's own `directory`, which is what OpenCode records from the directory the turn + * ran in. It used to filter on `metadata.officer.cwd`, a tag whose only writer * (`client.createSession`) has no callers — so the comparison was against `undefined` for every session * and the list was ALWAYS empty. `cwdOf` in chat.ts substitutes a default when no `?cwd=` is given, so * the `!cwd` escape never fired either and there was no configuration in which an OpenCode session diff --git a/src/servers/api/chat/opencode/client.ts b/src/servers/api/chat/opencode/client.ts index 7ac41dd9..92b461f9 100644 --- a/src/servers/api/chat/opencode/client.ts +++ b/src/servers/api/chat/opencode/client.ts @@ -1,5 +1,7 @@ // One connection per `opencode serve` base URL, for the REST reads the chat list and transcript need. -// The SDK-style `/session/*` route family is used (feature-complete, incl. DELETE). +// The SDK-style `/session/*` route family is used for the per-id reads and writes (feature-complete, +// incl. DELETE) — those answer for any session regardless of project, verified 200 with and without a +// directory header. The LIST is the exception and goes through `/api/session`; see `listSessions`. // // This used to also hold a shared SSE subscription (`GET /event`) demultiplexed per session, plus // `createSession`, `postMessage`, `abort` and `isServerHealthy` — the client half of a serve-based turn @@ -12,10 +14,30 @@ class ServerConnection { // ── Session history (REST; OpenCode's SQLite store is the source of truth) ── + /** + * Every session the serve knows, across every project. + * + * `GET /session` — the endpoint this used — answers for ONE project: the one the request's directory + * resolves to, which with no `x-opencode-directory` header is the serve's own cwd + * (`DATA_PATH/opencode_server`). That directory is not a git checkout, so it resolves to the catch-all + * project `global`, and so does every other non-git directory — which is why the general chat dir + * listed fine and nothing looked wrong. A cwd that IS a git checkout gets its own project and its + * sessions were simply never returned. Measured on the live serve: `/session` 8, `/api/session` 13, + * and a session created in a git directory came back 0 times from `/session` and 1 from `/api/session`. + * + * Chat pwds are project directories, so that is the ordinary case, not the edge one. + * + * The two surfaces disagree about the shape, and the difference is silent: `/session` carries the + * working directory as top-level `directory`, `/api/session` as `location.directory` with no top-level + * field at all, wrapped in `{data: …}`. Normalised here so callers keep reading `directory` — reading + * the wrong one yields `undefined` for every session, which filters the list down to nothing. + */ async listSessions(): Promise { - const res = await fetch(`${this.baseUrl}/session`); - if (!res.ok) throw new Error(`opencode GET /session → ${res.status}`); - return (await res.json()) as OpenCodeSessionInfo[]; + const res = await fetch(`${this.baseUrl}/api/session`); + if (!res.ok) throw new Error(`opencode GET /api/session → ${res.status}`); + const body = (await res.json()) as { data?: ApiSessionInfo[] } | ApiSessionInfo[]; + const sessions = Array.isArray(body) ? body : (body.data ?? []); + return sessions.map((s) => ({ ...s, directory: s.directory ?? s.location?.directory ?? null })); } /** One session's own record. The only place its working directory can be read on resume. */ @@ -72,6 +94,9 @@ export type OpenCodeSessionInfo = { directory?: string | null; }; +/** `/api/session`'s shape: the same record, with the directory one level down. Normalised by `listSessions`. */ +type ApiSessionInfo = OpenCodeSessionInfo & { location?: { directory?: string | null } }; + export type OpenCodeStoredPart = { type?: string; text?: string; From 801eba92843c8b43ba1bd2c37a49b9a7600b0ac7 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 02:43:14 +0100 Subject: [PATCH 40/42] say which harness owns a chat row, on both kinds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The list is merged from two stores and only OpenCode rows were badged, so Claude was marked by the ABSENCE of a badge — legible only if you already knew the list mixes two harnesses. Both carry one now, and since `harness` is absent on older Claude rows, anything not OpenCode reads as Claude, matching the server's own default. The badge no longer replaces the message count, it sits before it: the count is real on Claude rows and a hardcoded 0 on OpenCode ones (the session list has no count field and a real one costs an HTTP call per row), so those rows show the badge and no count rather than a zero that means "never asked". Co-Authored-By: Claude Opus 5 --- .../src/apps/ChatHistory/SessionList.tsx | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx index 4c836465..8cf84826 100644 --- a/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx +++ b/src/workspaces/officerdev/src/apps/ChatHistory/SessionList.tsx @@ -10,6 +10,24 @@ import type { SelectedSession } from './ChatDetailPanel'; import { chatListPath, chatNewPath, chatSessionPath, cwdFromSplat } from './chat-routes'; import { PwdSelector } from './PwdSelector'; +/** + * Which agent owns a conversation, said on EVERY row. + * + * Only OpenCode rows carried a badge before, which marks one harness and leaves the other inferred from + * its absence — fine when you already know the list mixes two, useless when you don't. The list is + * merged from two stores (Claude transcripts, OpenCode's session db) and the harness decides where a + * reply is dispatched, so it is a property of the row, not a footnote on the odd one out. + * + * `harness` is absent on older Claude rows, so anything that is not OpenCode reads as Claude — the same + * default the server applies. + */ +const HarnessBadge = ({ harness }: { harness?: 'claude' | 'opencode' }) => + harness === 'opencode' ? ( + OpenCode + ) : ( + Claude + ); + // Reads the /chat conversation list from Claude's own transcript store (source of truth). // Clicking a session loads its transcript and continues the real Claude session via --resume. export const SessionList = () => { @@ -194,13 +212,14 @@ export const SessionList = () => { className="min-w-0 flex-1" meta={[ , - session.harness === 'opencode' ? ( - - OpenCode - - ) : ( - `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}` - ), + , + // Claude's transcripts give a real turn count. OpenCode's session list has no + // count field, so the number on those rows is a hardcoded 0 and costs an HTTP + // call per row to make real — the badge stands in its place rather than beside a + // zero that means "not asked". + session.harness === 'opencode' + ? null + : `${session.messageCount} msg${session.messageCount === 1 ? '' : 's'}`, // One row, several transcripts: this conversation was `/clear`ed and carried on, // and the server has spliced the parts back together. Worth saying out loud — // the message count above covers all of them, and opening the row shows all of From d86d3ed1c00370dac511fcff0cdfd9fd4cdee39e Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 04:17:33 +0100 Subject: [PATCH 41/42] assess opencode's newer api, and find two live defects while doing it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tonight's brief was to read everything about "OpenCode API 2.0" and write down what moving to it would change and what it would buy. Two things fell out of the measuring that are not migration concerns at all — they are broken in production right now: The two surfaces are MUTUALLY BLIND. A session created through /api reads as [] on the legacy GET /session/{id}/message, and a legacy session 500s on GET /api/session/{id}/message. We run turns through /api since Phase D and read transcripts through legacy, so every opencode conversation created since 2026-08-10 opens empty — the row carries its title and directory from the session record, and the transcript underneath it is nothing. GET /api/session defaults to 50 rows and hands back a cursor.next. We send neither limit nor cursor, so the oldest sessions silently stop appearing once the store passes 50. The local store is at exactly 50 today. That is this morning's commit. On the name: there is no "2.0" in the running server, and "API 2.0" turns out to mean two different things. The /api/* surface in 1.18.16 has operation ids literally called v2.*, and we already run every turn on it — so it is not something to adopt, it is something to finish. OpenCode 2.0 the product is a separate beta (binary opencode2, npm @next) whose docs warn it may wipe data, and which REMOVES the two durable routes the restart-recovery work would depend on, in favour of an experimental/ path. Worth knowing before building on them. Verified by driving a real turn end to end: the durable event log replays from a cursor (?after=5 returned exactly 6-10, and the SSE at ?after=7 replayed 8,9,10 then held the socket), which is the answer to the gap Phase B left open. But deltas are live-only BY SCHEMA — the durable oneOf has 28 members and omits text.delta, tool.input.delta, reasoning.delta, compaction.delta — so both streams are needed, not one. Also reproduced a second silent-failure mode with the same signature as the missing credential: a session with no model, on a serve with no configured default, sits at admitted -> prompted forever. Our runner only sets a model when one was asked for. Probes cleaned up after themselves; the session store is back to the 50 rows it started with. Co-Authored-By: Claude Opus 5 --- docs/opencode-api-2-assessment.md | 353 ++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 docs/opencode-api-2-assessment.md diff --git a/docs/opencode-api-2-assessment.md b/docs/opencode-api-2-assessment.md new file mode 100644 index 00000000..b2d60c48 --- /dev/null +++ b/docs/opencode-api-2-assessment.md @@ -0,0 +1,353 @@ +# OpenCode's newer API — what it is, what it would cost, what it buys + +Written 2026-08-11 against **opencode 1.18.16**, from three sources: the running server's own OpenAPI +document (`GET /doc` on `opencode serve`), live probes against a real serve, and upstream docs/npm. +Every claim below is marked by where it came from. Measurements were taken on the local serve +(port 49698, the `officer-opencode` sidecar's own) and cleaned up afterwards — the session store is +back to the 50 rows it started with. + +Read this before starting any opencode work. Two live defects fell out of writing it (§1), and the +naming is actively misleading (§2). + +--- + +## 1. Two live defects, found while measuring + +Neither is a migration concern. Both are broken right now, in production, and both are consequences of +being half-migrated. + +### 1a. Every OpenCode conversation created since 2026-08-10 opens EMPTY + +Since Phase D, turns run through `POST /api/session/{id}/prompt`, so the session belongs to the newer +engine. But `loadOpenCodeSession` reads the transcript through the legacy route +(`client.ts:51` → `GET /session/{id}/message`). + +**The two surfaces are mutually blind.** Measured, both directions, on a session created via `/api` and +run to completion with a real model reply: + +| read | api-created session | legacy-created session | +|---|---|---| +| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript | +| `GET /api/session/{id}/message` | 200, 3 messages | **500** | +| `GET /session/{id}` (the record) | 200, title + directory | 200 | + +So the row appears in the list with its title and directory, and opens with nothing in it. And the +inverse is equally true: switching the reader to `/api` without keeping the old one would empty every +conversation from before 2026-08-10. + +The fix is not "swap the endpoint" — it is "route by which engine owns the session", and there is no +field that says so. The one usable discriminator found tonight is that the legacy read returns `[]` +rather than erroring. + +### 1b. The session list silently truncates at 50 + +`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in +the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200` +returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100 +with `Expected a value less than or equal to 100`). + +`client.ts:36` sends neither `limit` nor `cursor`, so **once the store passes 50 sessions the oldest +stop appearing in `/chat`**. The local store is at exactly 50 today. This is in code shipped this +morning (`adaaba6`). + +The same endpoint takes `directory=` — verified filtering correctly (`?directory=/tmp/oc-cap` → 11 +rows, all in that directory). We fetch everything and filter client-side in `opencode-sessions.ts:49`. +Pushing the filter down fixes the normal case and brings `search=`, `order=`, `project=` with it. + +--- + +## 2. The naming, because "API 2.0" means two different things + +There is no version string "2.0" in the running server. `GET /doc` self-reports +`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists: + +| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** | +|---|---|---|---| +| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` | +| routes | 111 paths | 51 paths | ~100 paths, still moving | +| operationIds | `session.list` | **`v2.session.list`** | — | +| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all | +| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs | + +So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its +operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*. + +**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs +carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs +may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes +(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must +migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere. + +Two facts worth knowing regardless: + +- **The repo moved.** `github.com/sst/opencode` 301s to **`github.com/anomalyco/opencode`**. Every npm + package now points there. No announcement was found explaining it. +- **There is already a typed client for the surface we run.** `@opencode-ai/sdk@1.18.16` ships two + generated clients: the default export covers legacy only, and **`@opencode-ai/sdk/v2` covers all 51 + `/api/*` routes**. We have no opencode dependency at all today — every call is hand-rolled `fetch`. + +--- + +## 3. What we call today + +Two of our processes talk to one serve, with no shared client. + +**Sidecar (`src/servers/sidecar/opencode/`) — already on `/api/*`:** `POST /api/session` +(`serve-runner.ts:222`), `POST …/model` (`:236`), `POST …/prompt` (`:266`, `:199`), `POST …/interrupt` +(`:328`), `GET /api/event` (`:86`), `GET /api/health` (`index.ts:79`), +`POST /api/integration/{provider}/connect/key` (`connect-credential.ts:66`). + +**API server (`src/servers/api/chat/opencode/client.ts`) — still legacy:** `GET /session/{id}` (`:45`), +`GET /session/{id}/message` (`:51`), `DELETE /session/{id}` (`:57`), `PATCH /session/{id}` (`:62`), +plus `GET /config/providers` for the model list (`list-models.ts:58`). The one exception is +`GET /api/session` for the list (`:36`), moved this morning. + +51 routes exist. We call 7. + +--- + +## 4. What the newer surface has that we don't use + +### 4a. Adding context to a turn that is already running + +The capability the subprocess path could never have, and the reason the migration happened. + +``` +POST /api/session/{id}/prompt +{ "id": "msg_…", "prompt": { "text", "files": [{uri,name,description,source}], + "agents": [{name,source}] }, + "delivery": "steer" | "queue", "resume": true|false } +``` + +Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is +false."* + +- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the + work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but + only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for + it, and nothing distinguishes "add this to what you're doing" from "here's my next message". +- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field + defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`). +- **`prompt.files[]`** attaches content to that same input; measured last night, it must be a `data:` + URI (a `file://` one is accepted with 200 and dies inside the provider). Each attachment also takes + a `description`, which we don't send. +- **`prompt.agents[]`** attaches an agent to the input. Unused, unexplored. +- **`id`** lets the caller mint the `msg_…` id, which is how a send survives a retry without + double-posting. We let the server mint it and therefore can't. + +Measured: the POST returns in **22 ms** with `{"admittedSeq":1,"id":"msg_…","delivery":"queue"}`. It is +an admission receipt, not a turn — and `admittedSeq` is the durable cursor for everything that follows. + +### 4b. Surviving a restart mid-turn — verified working + +``` +GET /api/session/{id}/event?after= "Replay durable events after an aggregate sequence, + then continue with new durable events." +GET /api/session/{id}/history?limit=&after= "Read one finite page of public durable Session events + after an exclusive aggregate sequence." +``` + +Driven end to end tonight on a real turn (free model, "Reply with exactly: hi"): + +``` +seq 1 session.next.prompt.admitted seq 6 session.next.context.updated +seq 2 session.next.prompted seq 7 session.next.step.started +seq 3 session.next.model.switched seq 8 session.next.text.started +seq 4 session.next.prompt.admitted seq 9 session.next.text.ended +seq 5 session.next.prompted seq 10 session.next.step.ended +``` + +`?after=5` returned exactly 6–10. `GET …/event?after=7` replayed 8, 9, 10 and then held the socket open +for more. Every durable event carries `{aggregateID, seq: integer, version}`, so `after=` is that +integer. This is the documented, working answer to the gap Phase B left open and +`docs/opencode-testing-checklist.md` calls the most likely thing to be broken. + +**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a +`oneOf` of exactly 28 members, and the five it omits are `text.delta`, `tool.input.delta`, +`reasoning.delta`, `compaction.delta` and the retry error. **Deltas are live-only by design; the +durable log stores whole values.** So a client that wants both token streaming and restart recovery +must read both streams: the global live one for deltas, the per-session durable one for the replayable +spine. Last night's 13-vs-21 event count was this same fact, found by counting instead of by reading. + +### 4c. Knowing what is running, without having started it + +``` +GET /api/session/active "Retrieve foreground Session drains currently owned by this OpenCode + process. Sessions absent from the result are inactive." +POST /api/session/{id}/wait "Wait for a session agent loop to become idle." +``` + +Today "what is running" is an in-memory map in our sidecar (`serve-runner.ts:66`). Restart the sidecar +and the truth is gone — which is why `/chat/live` can be wrong after a restart. `session/active` is the +server's own answer and survives us. + +### 4d. Permissions and questions — nothing in officer models this + +``` +GET|POST /api/session/{id}/permission POST …/permission/{requestID}/reply +GET /api/permission/saved DELETE /api/permission/saved/{id} +GET /api/session/{id}/question POST …/question/{requestID}/reply | /reject +``` + +Plus `permission.v2.asked` / `question.v2.asked` events (the v1 families still exist alongside; the +only `deprecated: true` operation in the entire document is `POST /session/{id}/permissions/{id}`). + +An opencode agent that wants consent, or that asks a question mid-turn, gets no answer from officer. We +don't subscribe to those events and have no route to reply on. Claude's harness runs +`--dangerously-skip-permissions`, so this has never been modelled for either harness. Largest single +behavioural gap. + +### 4e. Undo, compaction, context + +``` +POST /api/session/{id}/revert/stage {messageID, files?} …/revert/commit …/revert/clear +POST /api/session/{id}/compact GET /api/session/{id}/context +``` + +Stage a revert to a message, then commit or discard. Explicit compaction with +`compaction.started/delta/ended` events, and a readable context state. Officer has none of this. + +### 4f. The rest + +`GET /api/agent`, `/api/skill`, `/api/command`, `/api/model`, `/api/provider`, `/api/fs/{list,find,read}`, +`GET|POST /api/pty` (+`connect`, `connect-token`), `POST /api/session/{id}/agent` (switch agent +mid-session), `/api/reference`, `/api/location`, `/api/integration`, `/api/credential/{id}`. + +`GET /api/model` and `/api/provider` are the `/api` equivalents of the `/config/providers` call our +model list is built on (87 models locally). `/api/pty` overlaps our own pty sidecar. + +--- + +## 5. What the event stream carries that we drop + +Our mapper recognises 18 names and maps 7. The server emits **130 event type strings**, 32 in the +`session.next.*` family plus eight plain `session.*` (`idle`, `status`, `error`, `compacted`, +`created`, `deleted`, `updated`, `diff`). + +| dropped | what it would give | +|---|---| +| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode | +| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive | +| `tool.progress` | long tools reporting instead of appearing hung | +| `shell.started/ended` | shell commands as a first-class thing | +| `compaction.*` | telling the user the context was compacted | +| `revert.*` | §4e | +| `retried` | a retry that currently looks like a stall | +| `prompt.admitted` / `prompted` | acknowledgement — the exact window where silence has twice cost an afternoon | +| `session.idle` | the real turn-end signal (see below) | + +We end a turn on `step.ended` with `finish !== 'tool-calls'` (`serve-runner.ts:139`), because there is +no turn-ended event in what we read. `session.idle` looks like what that rule approximates, and it is +not in our `KNOWN` set. + +--- + +## 6. Two silent-failure modes, both reproduced tonight + +Both produce the identical signature — `prompt.admitted`, `prompted`, then **nothing, forever**: + +1. **No credential connected** for the `/api` surface. Already known and fixed at boot + (`connect-credential.ts`), but the failure has no error. +2. **No model on the session and no server default.** New tonight: my first probe sat at + `admitted → prompted` and stopped. `GET /config` reports `model: None`, and the session had no model + because I hadn't set one. `POST …/model` then re-prompting produced the full 10-event turn above. + +Our runner only sends `POST …/model` when `params.model` is set (`serve-runner.ts:231`). **A turn sent +with no model, against a serve with no configured default, hangs silently.** Worth an explicit check. + +--- + +## 7. What finishing the migration would cost + +- **Both readers stay.** §1a: `/api` reads 500 on legacy-owned sessions, legacy reads `[]` on + api-owned ones. Routing by ownership is required, and no field declares ownership. +- **Delete and rename cannot move.** `/api/session/{sessionID}` is **GET only**; `DELETE` and `PATCH` + exist only on the legacy route (spec-verified, and a live `DELETE` returned 200). +- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what + `opencode-sessions.ts:81` parses. `/api` items are + `{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}], + finish, cost, tokens}`. A second mapper, or a shared normaliser. +- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no + comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream + must resume at `?after=`, not restart. +- **Two envelope unwrappers and three hand-written type sets** (`serve-runner.ts:161`, `client.ts:38`; + types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). `@opencode-ai/sdk/v2` + would replace most of this — a dependency decision, and note our install is frozen, so it is a + deliberate lockfile change. +- **Stale comments in at least nine files** still describe the deleted `opencode run` subprocess path + (`protocol.ts:198`, `serve-events.ts:5`, `connect-credential.ts:24`, `index.ts:137`, + `websocket.ts:454`, `chat.ts:127`, `list-models.ts:75`, `sidecar-server.ts:8`, `send-opencode.ts:7`). + Two of them actively lie: they say turns read `auth.json` and don't depend on the credential connect. + They now do. + +Unrelated but found while inventorying: the settings UI writes provider keys to `~/.pi/agent/auth.json` +(`chat-providers.ts:10`) while the credential connect reads `~/.local/share/opencode/auth.json` +(`connect-credential.ts:29`). Two different files. + +--- + +## 8. What we'd gain immediately + +Ordered by value over effort. 1–3 are bug fixes, not features. + +1. **Transcripts that aren't empty** — route the read by session ownership. This is broken in + production now. (§1a) +2. **A list that doesn't stop at 50**, filtered server-side by `?directory=`. One call site. (§1b) +3. **A turn that can't hang silently** — set a model explicitly, or check `/config` for a default, and + say so out loud when neither exists. (§6) +4. **"Add to what you're doing" as a real control** — `delivery: "steer"` on a deliberate trigger + rather than only when a message happens to land mid-turn. The plumbing already exists. (§4a) +5. **Idempotent sends** — mint our own `msg_…`. One field. (§4a) +6. **Restart recovery** — subscribe `?after=` alongside the live stream. Verified working. (§4b) +7. **A truthful live panel** — `GET /api/session/active`. (§4c) +8. **Richer streaming for free** — reasoning deltas, tool-input deltas, tool progress, retries. Already + arriving on the socket we already read, and dropped in a `default:` case. (§5) +9. **Explicit compaction and context** instead of a long conversation quietly getting more expensive. + +Then the two that are real features needing UI: **permissions/questions** (§4d) and **revert** (§4e). + +--- + +## 9. What this does NOT get us + +- It does not retire the legacy surface: delete, rename and every pre-2026-08-10 transcript stay there, + with no deprecation date published. +- It does not touch the Claude harness — a different sidecar, a different protocol. Every gain above + lands on one harness only, while the chat UI assumes the two behave alike. +- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes** + `/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on + — replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path, + `experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function. + +--- + +## 10. Open questions + +1. Is there a field that says which engine owns a session? Tonight's only discriminator is behavioural + (legacy returns `[]`). If not, we need our own record — we already store `sessionKey → ses_…` in + `opencode/state.ts` and could record the surface with it. +2. Do the `v2` permission/question events replace the v1 ones or run alongside? Both are emitted; + which one a 1.18.16 agent actually uses is unconfirmed. +3. What is `/api/*`'s auth story? The spec declares no `securitySchemes` yet every route declares a + `401`. The v1 docs describe HTTP Basic via `OPENCODE_SERVER_PASSWORD`; we run with none, on + loopback. In the 2.0 beta this is formalised as basic auth read from + `~/.local/state/opencode/service.json`. +4. Does `@opencode-ai/sdk/v2` work against 1.18.16 exactly? Generated from the same surface, but + unverified by running it. +5. When did `/api/*` first appear in the 1.x line, and when does the legacy surface actually go? Both + UNKNOWN — the published changelog names no `/api/` additions at all. + +--- + +## 11. Sources + +- Live: `GET /doc` on `opencode serve` 1.18.16 (162 paths, 51 under `/api/`), plus the probes recorded + above against the local sidecar's serve on port 49698. +- Code: `src/servers/sidecar/opencode/*`, `src/servers/api/chat/opencode/*`, `opencode-sessions.ts`, + `list-models.ts`, `send-opencode.ts`. +- Upstream: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/docs/server, + github.com/anomalyco/opencode (formerly sst/opencode), npm `@opencode-ai/sdk` 1.18.16, + `@opencode-ai/client@next`. +- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`, + `-serve-path.md`, `-testing-checklist.md`, `-phase0-review.md`, `-phase1-report.md`, + `-phase1-review.md`. From f67bb44b7eb92057dbe951033eac12e9b2b60ba1 Mon Sep 17 00:00:00 2001 From: Andre Padez Date: Tue, 11 Aug 2026 04:21:17 +0100 Subject: [PATCH 42/42] fold the upstream source reading into the assessment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first pass was written from the running server's own OpenAPI document and live probes. This adds what the source at tag v1.18.16 says, which changes three things. The names are transitional at BOTH ends. session.next.* is the event family of the rewritten event-sourced engine, landed in 1.15.0 (PR #27415); on the v2 branch all 36 events have already dropped the .next. and some are renamed outright — agent.switched becomes agent.selected, prompted becomes prompt.promoted. Those renames are v2-branch only and the 1.x line we run still emits the old names, so the guidance is to code against them but keep one mapping table. The schema package's own AGENTS.md says the V2 suffix is going too. Upstream calls the /api surface EXPERIMENTAL in its own title — "Experimental HttpApi surface for selected instance routes", version 0.0.1 — while /session/* is what the public docs document and is not deprecated. Worth writing down plainly: the internal direction is unambiguous, the external commitment is nil, and we would be building on a surface its authors have not committed to. The SDK is generated from the exact document we probed: the build script runs opencode's own generate and feeds it to hey-api, and @opencode-ai/sdk/v2 exposes the whole /api surface, takes a directory and injects it as both the header and the location query param. That is our hand-rolled SSE reader, both envelope unwrappers, three type sets and the model-id splitting, deleted. Also corrected by reading rather than guessing: permissions v2 is a real contract change (rules, requests and the reply all change shape, and free-text replies are gone) while questions v2 is a pure re-homing with identical fields — so they are not one piece of work. And the durable cursor's replay-then-live is gap-free by construction: it re-reads the database on every wake instead of draining a buffer, with the prompt response's admittedSeq as the first cursor. Co-Authored-By: Claude Opus 5 --- docs/opencode-api-2-assessment.md | 128 +++++++++++++++++++++++++++--- 1 file changed, 116 insertions(+), 12 deletions(-) diff --git a/docs/opencode-api-2-assessment.md b/docs/opencode-api-2-assessment.md index b2d60c48..d04fe3ac 100644 --- a/docs/opencode-api-2-assessment.md +++ b/docs/opencode-api-2-assessment.md @@ -72,6 +72,26 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*. +Two qualifications, both from the source at tag `v1.18.16`: + +- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`, + version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with + every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually + document, and it is not deprecated. The internal direction is unambiguous; the external commitment is + nil. +- **`session.next` is the event family of that rewritten engine, and the name is already dead + upstream.** It arrived in **1.15.0** (PR #27415, "Add Effect-native core event system", merged + 2026-05-15) as an interim prefix. On the `v2` branch all 36 session events have dropped `.next.` — + `session.step.started`, `session.text.delta` — along with renames: `agent.switched` → + `agent.selected`, `model.switched` → `model.selected`, `prompted` → `prompt.promoted`. Those renames + are **v2-branch only**; the 1.x line we run still emits `session.next.*`. Code against + `session.next.*` today, but put the names behind one mapping table, because they are scheduled to + change wholesale. + +Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the +V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the +replacement architecture."* Both halves of today's naming are transitional. + **OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes @@ -162,6 +182,21 @@ for more. Every durable event carries `{aggregateID, seq: integer, version}`, so integer. This is the documented, working answer to the gap Phase B left open and `docs/opencode-testing-checklist.md` calls the most likely thing to be broken. +The upstream implementation (`packages/core/src/event.ts`, `durable()`) makes three things explicit +that matter for building on it: + +- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session. + Omitting it replays the session from 0. +- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`, + advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a + pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit + `Sequence mismatch` / `Replay diverged` errors. +- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery, + timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`. + +Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor +(`cursor.previous` / `cursor.next`), this one is a plain integer. + **But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a `oneOf` of exactly 28 members, and the five it omits are `text.delta`, `tool.input.delta`, `reasoning.delta`, `compaction.delta` and the retry error. **Deltas are live-only by design; the @@ -192,6 +227,23 @@ GET /api/session/{id}/question POST …/question/{requestID}/r Plus `permission.v2.asked` / `question.v2.asked` events (the v1 families still exist alongside; the only `deprecated: true` operation in the entire document is `POST /session/{id}/permissions/{id}`). +The two "v2"s are not the same kind of change, which matters if we implement one of them: + +- **Permissions v2 is a real contract change.** A rule goes from `{permission, pattern, action}` to + `{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to + `{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union + `source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public + V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2 + configuration."* +- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of + `{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and + event names changed. + +Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the +`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest +(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event` +union on our own `/doc`. + An opencode agent that wants consent, or that asks a question mid-turn, gets no answer from officer. We don't subscribe to those events and have no route to reply on. Claude's harness runs `--dangerously-skip-permissions`, so this has never been modelled for either harness. Largest single @@ -271,9 +323,8 @@ with no model, against a serve with no configured default, hangs silently.** Wor comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream must resume at `?after=`, not restart. - **Two envelope unwrappers and three hand-written type sets** (`serve-runner.ts:161`, `client.ts:38`; - types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). `@opencode-ai/sdk/v2` - would replace most of this — a dependency decision, and note our install is frozen, so it is a - deliberate lockfile change. + types pinned by comment to two different opencode versions, 1.17.9 and 1.18.16). This is the part a + dependency would delete outright — see below. - **Stale comments in at least nine files** still describe the deleted `opencode run` subprocess path (`protocol.ts:198`, `serve-events.ts:5`, `connect-credential.ts:24`, `index.ts:137`, `websocket.ts:454`, `chat.ts:127`, `list-models.ts:75`, `sidecar-server.ts:8`, `send-opencode.ts:7`). @@ -286,6 +337,40 @@ Unrelated but found while inventorying: the settings UI writes provider keys to --- +## 7b. The SDK is generated from the document we have been reading by hand + +`@opencode-ai/sdk@1.18.16` (published 2026-08-10, versioned in lockstep with the CLI) is built by +`packages/sdk/js/script/build.ts`, which runs opencode's own `generate` to produce the OpenAPI document +and feeds it to `@hey-api/openapi-ts`. **It is generated from the same `/doc` we probed**, which is +about as good a guarantee of shape-agreement as exists. + +It ships two clients. The default export is the legacy surface. `@opencode-ai/sdk/v2` is ours: + +```ts +import { createOpencodeClient } from '@opencode-ai/sdk/v2'; + +const client = createOpencodeClient({ baseUrl }); +const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' }); +const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq }); +for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ } +``` + +`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/ +context/history/events/interrupt/message(s); there is also `client.v2.event.subscribe`, +`client.v2.permission.*`, `client.v2.question.*`, `fs`, `model`, `provider`, `agent`, `skill`, `pty`. +`createOpencodeClient` takes `directory` and injects it as both the `x-opencode-*` headers and the +`location[directory]` query param — the thing we hand-roll in two places. + +That would delete: our hand-rolled SSE reader, both envelope unwrappers, three hand-written type sets, +and the model-id string splitting. It is a dependency change, and installs here are frozen, so it is a +deliberate `bun install --no-frozen-lockfile` plus a read of the lockfile diff. Worth noting the +package's only dependency is `cross-spawn`. + +Not to be confused with two siblings the v2 docs mention: `@opencode-ai/sdk-next` is marked private and +is not on npm, and `@opencode-ai/client` is a private generation target for the beta line. + +--- + ## 8. What we'd gain immediately Ordered by value over effort. 1–3 are bug fixes, not features. @@ -326,16 +411,27 @@ Then the two that are real features needing UI: **permissions/questions** (§4d) 1. Is there a field that says which engine owns a session? Tonight's only discriminator is behavioural (legacy returns `[]`). If not, we need our own record — we already store `sessionKey → ses_…` in `opencode/state.ts` and could record the surface with it. -2. Do the `v2` permission/question events replace the v1 ones or run alongside? Both are emitted; - which one a 1.18.16 agent actually uses is unconfirmed. +2. Which permission/question family does a 1.18.16 agent actually emit? Both are declared and both + appear in our `/doc`, because the server wires the full manifest. Measure before building UI. 3. What is `/api/*`'s auth story? The spec declares no `securitySchemes` yet every route declares a `401`. The v1 docs describe HTTP Basic via `OPENCODE_SERVER_PASSWORD`; we run with none, on loopback. In the 2.0 beta this is formalised as basic auth read from `~/.local/state/opencode/service.json`. -4. Does `@opencode-ai/sdk/v2` work against 1.18.16 exactly? Generated from the same surface, but - unverified by running it. -5. When did `/api/*` first appear in the 1.x line, and when does the legacy surface actually go? Both - UNKNOWN — the published changelog names no `/api/` additions at all. +4. Does `@opencode-ai/sdk/v2` work against 1.18.16 exactly? It is generated from this exact server's + OpenAPI output and versioned in lockstep, so it should — but nobody here has run it. +5. When did `/api/*` first appear in the 1.x line? UNKNOWN; the changelog names no `/api/` additions. + The event family underneath it landed in 1.15.0. And no dated removal plan exists for `/session/*`, + `permission.asked/replied` or `question.*` — only the undated internal intent quoted in §2. +6. Will the `v2`-branch event renames reach the 1.x line, or only ship with OpenCode 2.0? No merge + found, no statement either way. This decides whether the mapping table in §2 is a one-off or a + permanent seam. +7. Is a durable `seq` stable across a server restart or a session move? It is a database column, so it + should be, but no durability guarantee is documented and we have not tested it. Item 6 in §8 depends + on the answer. + +Version state at the time of writing: **1.18.16 is the newest release** (2026-08-10) and contains +nothing API-facing. The active stream is the 2.0 beta, cutting releases continuously — the most recent +was published hours before this file was written. --- @@ -345,9 +441,17 @@ Then the two that are real features needing UI: **permissions/questions** (§4d) above against the local sidecar's serve on port 49698. - Code: `src/servers/sidecar/opencode/*`, `src/servers/api/chat/opencode/*`, `opencode-sessions.ts`, `list-models.ts`, `send-opencode.ts`. -- Upstream: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/docs/server, - github.com/anomalyco/opencode (formerly sst/opencode), npm `@opencode-ai/sdk` 1.18.16, - `@opencode-ai/client@next`. +- Upstream, docs: opencode.ai/v2/docs/migrate-v1, opencode.ai/v2/docs, opencode.ai/v2/docs/permissions, + opencode.ai/docs/server, opencode.ai/changelog. +- Upstream, source at tag `v1.18.16` in **github.com/anomalyco/opencode** (formerly `sst/opencode`, + which 301s): `packages/protocol/src/api.ts` and `groups/session.ts` (the surface's own "experimental" + self-description, the `after` parameter), `packages/schema/src/session-event.ts` (`DurableDefinitions` + vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their + `v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md` + (the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/ + build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229 + (the renames). +- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`. - Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`, `-serve-path.md`, `-testing-checklist.md`, `-phase0-review.md`, `-phase1-report.md`, `-phase1-review.md`.