Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.
Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.
Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.
Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.
getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.
- runner.ts (new): spawn `run`, map its JSON events (text/tool_use/
step_finish) to ChatEvent, report the `ses_` id for resume, accumulate
cost; inactivity (120s) + hard-cap (10min) watchdogs kill a hung turn
and emit a clean error instead of hanging forever.
- protocol.ts: opencode:run-streaming/kill commands; opencode:spawned/
event/session events; OpenCodeRunParams.
- sidecar index.ts: wire run/kill; sweepStaleServes() on startup kills
only an `opencode serve` whose resolved /proc/<pid>/cwd == SERVE_CWD,
so an unclean prior exit can't leave two.
- sidecar-registry.ts: spawnOpenCodeStreaming/killOpenCode/onOpenCodeEvent/
onOpenCodeSession helpers.
- send-opencode.ts: rewritten to mirror send-claude-code (subscribe →
resolve resume id → spawn → kill handle).
- sidecar-server.ts: persist reported ses_ id into state for resume.
- list-models/server-manager: route to the sidecar's reported serve URL.
The serve stays up only for read-only calls that never hung (model
listing, session history).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prior "treat this as your cwd" wording didn't stop the model from using bare
globs/relative paths, which OpenCode resolves against the server cwd. Reworded to
explicitly require absolute paths under the target dir on every tool call (and `cd`
for bash). Still a soft override; a stronger attempt before considering `opencode run --dir`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Simpler + more efficient than the previous per-turn GET /session: the client already
has the selected cwd, so it now sends it on every message (it's constant for a session).
The server uses msg.cwd directly for OpenCode's per-turn working-directory system prompt,
and still tags the cwd at creation for listing. Drops the getSession round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
msg.cwd only rides the first message, but OpenCode's system prompt is rebuilt every
turn — so turn 2+ reverted to general_chat_sessions and the model fell back to the
server's real cwd. Fix: the cwd is bound at creation (metadata.officer.cwd), so for an
existing/resumed session read it back (new client.getSession) and reuse it for the
system prompt every turn. Also make the prompt explicit that it overrides any other
working directory the environment reports.
Verified: a session created with a cwd reads the same cwd back on a later turn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both harnesses now use the resolved chat cwd as their working directory: Claude runs
in it natively, and OpenCode is told the same via its system prompt. Removes the
earlier special-case that pointed OpenCode at the user's home for /chat, so `workingDir`
collapses into `cwd` — which now both tags the session (metadata.officer.cwd) and drives
the Officer system prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenCode can't set a real per-session cwd (every session runs in the fixed server's
dir), so we tell the model its working directory via a system prompt appended to
OpenCode's own — sent as a system message, so it never appears in the visible chat
(verified against source + live). Claude doesn't need this (it honors cwd natively).
- client.postMessage(…, system?) forwards a `system` string on the message.
- send-opencode builds the Officer prompt from `workingDir` and sends it every turn.
- websocket: workingDir = the resolved cwd, except the general /chat (whose cwd is the
claude_sessions grouping placeholder) uses the user's home.
Verified live: with the prompt, the model reports the injected dir as its cwd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenCode has no per-session directory (every session runs in the fixed server's cwd),
so sessions from every context (/chat pwd, email account, project) all landed in one
list. Now each session is tagged on creation with its logical cwd via the free-form
session `metadata`: { officer: { cwd } } — API-settable, round-trips on list+detail,
never touched by opencode core (confirmed by source dive + live test).
- client.createSession(metadata?) sends `metadata`; adds OfficerSessionMeta + officerMeta() helper.
- send-opencode tags new sessions with { officer: { cwd } } (the resolved chat cwd).
- websocket: pass the full resolved cwd for every context (not just non-/chat).
- listOpenCodeSessions(cwd?) filters by metadata.officer.cwd; chat.ts passes the request cwd.
Verified live: sessions tagged with distinct cwds list only under their own cwd.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OpenCode has no per-session `directory` — a session inherits the server's cwd (POST
/session ignores extra fields). So the previous "pass directory on create" was a no-op
and every OpenCode chat ran in the fixed server's dir (~), including the email chat.
Fix: hybrid server model. server-manager.ensureServer(cwd?, home?) returns the fixed
pm2 server (OPENCODE_SERVER_URL, :4096) for the general /chat, but for a context-scoped
cwd (email account dir, project dir) it spawns/pools an `opencode serve` rooted at that
directory — so the session's agent actually operates there. send-opencode passes the
resolved cwd + the user's home; createSession drops the ignored directory param.
Verified live: a cwd-scoped server reports the session directory as the target dir
(not ~), while /chat still uses :4096.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The email chat's working directory now resolves to
DATA_PATH/<owner>/email_accounts/<accountEmail> (created if missing), so the agent
operates in the selected account's dir (emails.db, attachment_cache, …).
- websocket.ts: new resolveChatCwd — context 'email' → the account dir (via a new
resolveEmailCwd), 'chat' → the pwd/claude_sessions dir, else the given cwd. Both the
Claude and OpenCode handlers use it. The account defaults to the owner's first enabled
account for now; the account selector will pass it as contextId later.
- OpenCode honors the cwd again: send-opencode passes it as the session `directory`
(client.createSession(directory?)) for context-scoped chats; the general /chat still
omits it and uses the fixed server's default project. Verified against the live server
that directory-bound sessions create + list.
No frontend change — the /email panel already sends context:'email'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-cwd `opencode serve` spawning is replaced by a single fixed server
(http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as
`officer-opencode` in ecosystem.config.cjs (cwd = home).
Root-cause fix for the empty model selector: list-models shelled out to
`opencode models`, which failed at runtime on the deployed server (the picker got
only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP —
11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up.
- server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check.
- client.ts — createSession no longer binds a directory (sessions live in the one
server's project).
- send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the
cwd/home plumbing. Session list/load/delete/rename now hit :4096.
Verified end-to-end against the live server: model list, streaming turn, session
list, and transcript load all work with no spawning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/chat's session list, transcript load, delete, and rename now span both harnesses.
- opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never
the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the
same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory-
scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps
user/assistant/tool parts and drops reasoning (parity with the delta filter).
- client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*.
- chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and
/title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional
`harness` tag.
- send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id,
reuse that OpenCode session instead of creating a new one.
- SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions.
Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript
rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduces an OpenCode chat harness alongside Claude, driven over HTTP + SSE against
a persistent `opencode serve`, emitting the same ChatEvent contract so the entire
chat UI and createEventHandler pipeline are unchanged.
New servers/api/chat/opencode/:
- server-manager.ts — one warm `opencode serve` per cwd (free port, health-gated,
respawn on exit; HOME set so it reads the user's ~/.local/share/opencode auth).
Binary pinned via OPENCODE_BIN (installed is 1.17.9; the 1.18.4 upgrade never landed).
- client.ts — per-server HTTP calls (/session create, /message, /abort) + a single
reconnecting `/event` SSE stream demuxed to per-session listeners.
- event-mapper.ts — SSE → ChatEvent. Verified live against 1.17.9: message.part.delta
→ delta, tool parts → tool:start/tool:result, message.updated → cost, session.idle
→ result. Crucially, deltas are gated on partID being a `text` part (declared before
its deltas) so the model's reasoning — which also streams as field:'text' — is
dropped, matching the Claude harness hiding thinking.
- state.ts — sessionKey ↔ opencode ses_ id map for resume.
channels/send-opencode.ts — the OpenCode analog of send-claude-code: ensure serve,
create/reuse session, subscribe, post the message, forward mapped events; kill = abort.
websocket.ts — replaces the Claude-only coercion with harness routing:
provider 'claude-code' → Claude sidecar, everything else → handleOpenCodeChat.
handleStop aborts the right harness.
Verified end-to-end (streaming text, tool call/result, cost, abort) against a
throwaway serve using the free deepseek model — no prod restart involved. UI-level
model selection + session history follow in Phases 2–3.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reorganizes email storage: the DB moves from DATA_PATH/<user>/emails.db to
DATA_PATH/<user>/email_accounts/<accountEmail>/emails.db, with a shared
email_accounts/attachment_cache/ (was Gmail/emails/attachments). openEmailDb now
takes (owner, account); a new openUserEmailDb(owner, userId) resolves the user's
configured account (first enabled) for read paths. Threads the account through
email.ts, accounts, resync, queue sync, channel handlers, and the email_db MCP
tool path. Drops the dead getUserEmailDir helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Clicking a session in the list loads its transcript (GET /chat/sessions/:id,
parsed from Claude's JSONL into display messages) and continues the actual Claude
session: a resumeSessionId is threaded chat handler -> send-claude-code -> sidecar
-> claude-manager, which passes --resume <uuid> (in-memory session mapping still
takes precedence for live turns). Parser verified against real transcripts.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stage 1 of removing Pi (Claude-only). Cuts the non-Claude branches in the chat
WS handler, pipeline executor, and channel send-and-await; deletes the Pi
sidecar, its ecosystem entry, pi-bridge, and the Pi model-listing spawn (now a
static Claude tier list). Adds a guard coercing any legacy non-claude-code model
preference to the Claude default so old settings don't break chat or jobs.
Removes the dead no-op session-save REST route and stale Pi docs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- extract buildSandboxPrefix/buildRunuserSuffix into shared sandbox.ts
- super admin bypasses bwrap for full host access (claude, pi, terminal)
- member pi processes now use bwrap instead of sudo -u
- member terminals now use bwrap instead of sudo -u
- mount /run for systemd-resolved DNS inside sandbox
- pass role through claude spawn params and channel types
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Flips the connection model so sidecars register themselves with the API
server via WebSocket at /api/sidecar/register, enabling dynamic discovery,
location independence, and automatic reconnection from either side.
- Add registration protocol types and PTY command/event types
- Create sidecar-registry.ts (replaces sidecar-client.ts) as passive registry
- Create sidecar connector (connect.ts) with exponential backoff reconnect
- Convert process sidecar from WS server to WS client
- Convert PTY sidecar from WS server to multiplexed WS client
- Simplify terminal bridge to thin adapter using registry
- Add PTY sidecar as PM2-managed process
- Update all consumer imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduces a separate Bun process (port 5100) that owns all spawned
processes and long-running work, so the API server can restart freely
without disrupting active sessions.
The sidecar owns:
- Anthropic proxy (port 5051) with persisted secret across restarts
- Claude Code process spawning and session tracking (--resume support)
- Pi agent spawning and RPC lifecycle (prompt/abort/thinking)
- Job queue engine (lane processing, retries, notifications)
The API server becomes a thin client that forwards commands over a
single WebSocket connection with auto-reconnect. send-claude-code.ts
goes from 550 lines of spawn logic to 73 lines of sidecar delegation.
State persisted to data/sidecar/state.json every 30s and on shutdown.
Lockfile prevents duplicate instances. See SIDECAR.md for full docs
and manual testing procedures.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Local HTTP proxy on 127.0.0.1:5051 intercepts Claude Code API requests
from sandboxed member users, injects the real OAuth token server-side,
and forwards to Anthropic. Users only see a proxy secret, never the
real credentials.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Added detailed error logging to detect snap node compatibility issues
- When Pi process exits with code 1, log helpful diagnostic info including node path
- Add hint to check for snap node and reinstall via apt/nvm
- Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide
- Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes
- Provide clear installation instructions for NodeSource and nvm alternatives
- add claude-code as virtual model in channel messaging (telegram/discord/whatsapp)
- new send-claude-code.ts: docker exec claude -p with session resumption
- route claude-code model in sendAndAwait before Pi pipeline
- append claude-code to listPiModels output
- fix container .claude mount (rw for sub-mounts), hooks format (matcher-based)
- pre-seed hasTrustDialogAccepted and bypassPermissions in container settings
- git init in entrypoint to skip workspace trust prompt
- fix ~/~ double-tilde in CommandTerminalWrapper cwd resolution
- remove --continue from claude-code panel command
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Channel session IDs now include userId (channel-{provider}-{userId}-{contextId})
to prevent cross-user contamination in multi-user setups. WhatsApp disconnect
properly logs out and clears cached auth. Browser relay uses server-derived token
directly.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>