`sendFire` looked up a sidecar, and if none was registered it did nothing.
No log, no error, no return value — the command simply stopped existing. That
window is real and entirely predictable: every `pm2 restart officer` has one
while the sidecars reconnect, and somebody clicking a button has no idea they
are in it.
The cost landed hardest on answering a question. `answerClaudeQuestion` in the
agent documents this exact failure in a careful comment and cannot report it,
because the command never arrives to be reported. So the question card kept
spinning, which from the person's side is indistinguishable from a slow agent,
and the only move left is to send another message — the worst possible thing to
do to a waiting tool call. Observed tonight, on a restart I ran myself while a
commander was mid-question.
So `sendFire` returns whether anyone took the command and warns when nobody did,
and the two paths where a person is actively waiting — a question answer and a
plan decision — tell them instead of leaving the card to spin.
The other five callers are unchanged and now leave a log line, which is the part
that was missing when this was diagnosed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deleting a commander removed its transcript and the `claude` CLI wrote it
straight back. Verified rather than inferred: the file returned with 22 lines,
every one timestamped after the delete, and the deleted commander was sitting in
/chat as "(untitled)" — no longer in `command_hq_sessions`, so nothing filtered
it out. Delete a commander, get an untitled chat back.
Two causes, and the first one hid the second.
The kill never happened. `killClaudeSession` was imported straight from
`claude-manager` into a route that runs in `officer`, while the session map lives
in `officer-claude-code`. It read an empty map in the wrong process and returned
false every time. `claude:kill` and `killClaude` already existed for this; the
route just wasn't using them.
And killing is not being dead. `abort()` returns while the CLI is still
unwinding, and officer does not own that file — the CLI does. So a session now
carries an `exited` promise resolved by the same exit hook that already reaps it,
`claude:kill` replies only once that resolves, and both delete routes await the
reply before touching the filesystem.
Bounded at 5s and never fatal. A child that will not die delays a delete by
seconds instead of hanging it, and a sidecar that is down leaves the caller with
exactly the race it already had.
`markExited` fires before `dropDeadSession`'s identity guard, deliberately: a
waiter has to be released even when a newer session has taken its key, or it
blocks for the full timeout on a process that died on schedule.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and
apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem.
sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants
and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four
functions and a dozen message types in the platform, describing a feature that is not the platform's —
the rule CLAUDE.md states most plainly, and its clearest breach.
What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a
command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever
it likes through them and nothing here has to be edited when it adds a message.
The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind
a plugin permission cannot have and never will, because a permission nobody can delegate is a
contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied
to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not
exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the
key from its list.
Also gone: the app store's `vnc` entry, which would have offered an older second way to install the
same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13.
The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request".
It now uses the generic door, which is the better instrument — that file is about the blast radius of
a disconnect and never had anything to do with VNC.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a plugin that creates accounts in an upstream service — jellyfin first, immich
next — needs to know who the platform's users are. it now gets told.
DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles`
arrive on an unauthenticated socket and are believed, so a capability claimed
there would be a plugin granting itself one. the manifest was reviewed when the
owner installed it, and sits beside `permissions` where "this plugin will be
told who your users are" belongs — on the install screen, not discovered later.
PUSHED, not requested. a sidecar has no authenticated way to ask, so a request
could only be answered, never authorised. a push inverts that: the platform
decides who gets it, and a caller registering under a name with no such
manifest gets nothing.
`listProvisionableUsers` is a hand-written projection — id, email, username,
name, role — and NOT `select *`. `users` holds `password` and `os_user`, and
`getUsers()` returns both, so a plugin reaching for the obvious function would
get every account's password hash. naming the columns means adding a secret to
the table cannot silently add it to what plugins receive. pinned by a test that
asserts the key set, not just the absence of today's secrets.
the owner is excluded. they bypass every permission check and already have
whatever upstream account they want; provisioning them is at best a no-op and
at worst a second admin nobody asked for.
sent after the ack rather than inside it, so registration never waits on a
database query — a slow postgres would otherwise stall every sidecar's startup,
and none of them need the list to start.
and re-sent on every account create and delete, because the registration copy
is a snapshot: a plugin still holding a deleted user would keep an upstream
login alive for someone the platform has removed. read once per broadcast, so
two plugins cannot be told different things about the same moment.
reported from the field on the first real push: it failed with BadDeviceToken
and the server deleted the registration. the build was production-signed and
the row said sandbox — a configuration mismatch, with the device and the app
both perfectly fine.
pruning there destroys the evidence. the phone re-registers on next launch,
the row comes back looking healthy, and the next send fails identically; you
can lose a day to that loop without ever seeing what is actually wrong.
so the one set becomes three. Unregistered/UNREGISTERED/NOT_FOUND prune —
apple and google are saying the app is uninstalled. BadDeviceToken,
DeviceTokenNotForTopic, TopicDisallowed, INVALID_ARGUMENT and
SENDER_ID_MISMATCH are OURS: the row is kept and the log says what to check.
everything else is transient and still counts toward three strikes.
INVALID_ARGUMENT was the sharpest one. it is how fcm reports a malformed
MESSAGE — including a non-string in `data`, which is the mistake this codebase
is most likely to make and already has a test for. deleting on it meant one
bug in the payload builder would wipe every android device from the registry
on a single send, presenting as "android push stopped working" with nothing
left to explain why.
misconfigured is also not counted as a strike. not deleting is not enough on
its own — a persistent mismatch would reach three failures and delete the row
anyway, which is the same bug with a slower fuse.
the apns log now names the mismatch rather than the reason code, since the
code alone sends people to look at the device: it prints the row's environment
and topic and says to check the SHIPPED artefact rather than the intermediate
build, which is exactly where this one hid.
The harness answered every question itself, instantly, with "The user did not
answer the questions." — before the card had painted, let alone been read. The
agent then reasoned about a refusal that never happened: on 2026-08-19 it asked
the same question three times in a row while the answers sat on the screen.
The web UI already worked around it by rendering the card ungated and sending
the click back as an ordinary chat message. That got an answer through, but the
agent read it as a new instruction arriving from nowhere rather than as the
reply to what it asked, and nothing could distinguish a live question from one
answered last week — every replayed transcript rendered its old questions as
clickable.
A PreToolUse hook is the fix, and it has to be a hook rather than canUseTool:
under bypassPermissions — which is every session here — the SDK shadows the
callback and never calls it. Measured against 0.2.59: a bypassing session's
AskUserQuestion reached the hook and never reached canUseTool. Hooks run
regardless of permission mode.
The hook parks on a promise keyed by the harness's own tool_use_id, and returns
`deny` with the human's answer as the reason — which the harness feeds back as
the tool result verbatim. "Deny" reads wrong and is right: what it denies is the
tool's ability to answer for the user, and what replaces its output is what the
user actually said. Held open for 150s in testing with the turn completing
normally, so a person has time to think.
Keying on tool_use_id rather than an invented id means no new durable event: the
questions are already in the session log as an ordinary tool:start, and the
answer arrives as its tool:result. One source of truth, replay included — which
is what re-enables gating the card on `output`, and with it the staleness fix.
The two tests that pinned the old ungated behaviour asserted the workaround as
the contract; they now pin the reversal, including a pre-fix transcript reading
as settled, which it is.
Unanswered questions settle like plans do: 20 minutes, every session-ending path
draining them first, and wording that tells the agent the question is still on
the user's screen and must not be asked again.
Mobile (Butler) has the same workaround and is not fixed here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
opusplan has been in the picker since 57bbcca9 and did nothing: it means
'Opus while planning, Sonnet at rest', and claude-manager hardcoded
permissionMode: 'bypassPermissions', so a session never entered plan mode
and opusplan rested forever on Sonnet.
verified against a real claude first (SDK 0.3.233), and one finding changed
the design: approving ExitPlanMode drops the harness into 'default', NOT back
into bypassPermissions, and the agent carries on inside the same turn. so the
restore is fired the moment approval is given rather than at turn end, and
canUseTool allows everything that is not ExitPlanMode - for a few hundred ms
it is the only thing between the agent and its tools, and a default-deny there
would jail an agent the platform deliberately does not jail.
canUseTool is passed always. under bypassPermissions the SDK shadows it
entirely (CLAUDE_SDK_CAN_USE_TOOL_SHADOWED) so it costs a log line, and since
it is a query() option read once, passing it unconditionally is what makes the
mode toggleable on a live conversation instead of only at session start.
sticky per session on chat_session_models.plan_mode, beside the model because
they are one decision about what a turn costs. boolean rather than the
PermissionMode string: officer exposes two of the five, and a text column would
accept three the sidecar cannot handle.
plan approval is the only place the agent blocks on officer - a canUseTool
promise held in the sidecar while the plan round-trips to a browser. every path
that ends a session or a turn settles it as refused, plus a 20m timeout, because
a leaked entry is a hung turn rather than a stale value.
not auto-entered for opusplan: the CLI does not either. the toggle says so
instead - 'opusplan rests on Sonnet, turn on Plan for Opus'.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Committed before restarting, deliberately: this changes the registration wire,
and the restart that proves it also bounces officer-claude-code, which is what
the session doing the work runs on. An uncommitted 25-file protocol change is
worse to inherit than one marked unverified.
`capabilities: ['music']` → `handles: ['music']` on the registration message,
across 20 sidecars, both plugins, the connector, the registry and the protocol
type. findSidecarByCapability → findSidecarHandling, waitForCapability →
waitForHandler.
The name: a sidecar already has `handleCommand`, so the list is literally what
it handles. `provider` was rejected — 390 existing uses and it already means
websocket door. `serves` was rejected — collides with HTTP serving, which
sidecars also do.
THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting
`handles` reads `undefined` from a sidecar sending `capabilities`, registers it
with an empty list, and every sendCommand finds nobody — chat, terminal and
music all fail with "No sidecar handling X is connected". So the whole estate
has to restart together; there is no rolling upgrade.
If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is
in-memory and nothing about this touches the database, so a revert is complete.
Found a fifth meaning of the word on the way, correctly named and untouched:
the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences).
That is now five — permissions, the item store, routing keys, Lightning wallet
features, and terminfo.
tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
The control surface half of 7cb402b, and the code-side blocker on the gates.
kill, interrupt, clear-session, is-generating, find-session and list all took a
bare sessionKey, so any caller who could reach them could act on whichever
session happened to match — and list returned every session in the sidecar,
which host rightly called a disclosure on its own, before anyone kills anything.
All six now carry userId, resolved from the authenticated request and never
taken from the client, and every handler enforces it through one ownedSession
helper. list is filtered rather than labelled. find-session is scoped because it
is the reattach hinge: a browser holding a transcript uuid it should not have
would otherwise be handed the session key that drives it.
"Not yours" and "does not exist" answer identically everywhere, which is the
same choice getClaudeSession made: every caller treats them the same, and a
distinct answer for the second confirms to a guesser that a session exists under
a key they do not own.
One behaviour change beyond the scoping. endTurnIfAgentIsGone sweeps sessions on
a sidecar restart, and a session with no recorded userId now has no safe id to
ask as — asking as the owner would answer a member's orphaned session with the
owner's authority. It is skipped, so it stays marked generating until the next
reconnect corrects it, which is what happened before that loop existed.
This removes the code-side reason the gates cannot move. It does not make them
movable: no member has signed in, no member turn has run, spawnClaudeCodeProcess
has still never been called, and lifting them was never mine to decide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Live panel asked `claude:list` and nothing else, so a running OpenCode turn was invisible — the panel
claimed to show what the agent is doing and silently omitted half of it.
Adds `opencode:list` / `opencode:sessions` and merges both harnesses in `/chat/live`, asked in parallel,
each failing toward empty so one sidecar being down contributes nothing rather than breaking the panel.
The OpenCode row is deliberately thinner than the Claude one rather than faked into parity:
isGenerating always true — a subprocess exists only while it generates, so there is no "merely open"
pendingTasks always 0 — `opencode run` has no background-task concept; reporting a number would
suggest a capability that does not exist
title / cwd null — the session store is keyed on the `ses_…` id the runner reports, not on
our sessionKey, so an unreported turn shows unnamed rather than guessed
This is the incremental option from docs/opencode-serve-path.md — enumeration without moving turns onto
the serve, so it buys the Live panel with no warm sessions, no SSE loop and no lifetime questions.
NOT verified end to end: no OpenCode turn was running to enumerate, so the verb is wired and typechecked
but has never returned a non-empty list. See COMMS/BLOCKERS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer's session records are in memory and die with `pm2 restart officer`, while the agent is a PM2
peer and keeps generating. `adoptOrphanedSession` rebuilds a binding — but only when a browser
reconnects to a session *by id*, which you can only do if you already knew the id. So a session that
survived a restart was invisible, and nothing could answer "what is running right now".
`claude:list` returns each live session with `isGenerating` and `pendingTasks` — the same two fields the
agent's own `armIdle` consults before collecting a session, so a caller can tell "busy" from "merely
open" the way it does. Surfaced as `GET /chat/live`, which sits beside `/chat/sessions`: those are
transcripts on disk, these are the ones with a process behind them.
`getActiveSessionKeys` is replaced rather than joined. It returned bare keys, could not distinguish a
session mid-turn from one merely open, and had never been called by anything.
`listLiveClaudeSessions` fails toward EMPTY, where `isClaudeGenerating` beside it fails toward alive.
The asymmetry is deliberate: not knowing there means leaving a spinner up, and not knowing here would
mean inventing sessions.
Step 2 of docs/chat-session-lifetime.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Refreshing mid-turn appeared to kill the agent's output. It never did: the
session survives a dropped socket, the agent keeps generating into it and keeps
committing durable events, and `close` only detaches the socket and arms an
hour-long idle timer. What broke was purely delivery — and the reconnect path
that would have fixed it could not fire, because the browser came back having
forgotten officer's session key. It lived in page state. The only id left was
Claude's transcript uuid in the URL, and nothing accepted that.
So accept it. `attach` carries the uuid, and the agent's on-disk session map —
the single record relating the two — turns it back into the key everything else
is written in terms of. The uuid now also goes out at `system.init` rather than
only at `result`, which is what makes the first turn recoverable at all: until
now a chat had no address until it had finished, and a long first turn is
exactly the one worth reconnecting to.
`sync:live` deliberately carries no messages. The harness writes its transcript
as it goes, so the HTTP load on landing already supplies the past; sending the
server's record of the same messages on top of it would duplicate them, and
there is no shared id to reconcile the two by. Attach hands over the rest of the
turn, the half-written paragraph the transcript cannot hold, and the session's
cursor head — that last one so a *later* drop replays from the head instead of
re-delivering the whole conversation from zero.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
restarting officer-agent takes every persistent session with it and nothing
downstream notices: the browser's socket is healthy, officer's subscription is
a bus filter, and there is simply never another event. the spinner ran forever
and a refresh didn't help, because the transcript has no ending to read.
keyed off the agent *registering*, not disconnecting — a disconnect fires on
every `pm2 restart officer`, when the turn is fine. a registration socket dies
with its process, so an agent appearing on it is a new one. covers the sitting
tab; the reconnect path covers the rest, with the client now sending its belief
that a turn is in flight and officer checking it against the agent over a new
claude:is-generating. the check fails toward alive.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
from a full audit of all 179 route definitions under src/servers/api, tracing
consumers through useClient, raw fetch, EventSource, the capabilities repo and
the mobile monorepo. only routes with zero consumers anywhere are removed.
server-settings/applications.ts whole file — an app install/update registry
with no settings section to drive it
server-settings/claude-code.ts whole file — the ai settings screen talks
to chat-providers/* exclusively
GET browser/extension-download superseded by a static asset; BrowserRelay
links at /browser-relay-extension.zip
GET integrations/ a stub returning []
GET chat-providers/auth /api-keys says the same thing with more detail
GET desktop/vnc-status and with it the vnc:status command and reply,
which existed only to serve this route.
docs/sidecar-audit-2026-07.md called this
one dead months ago
deliberately KEPT, because "no caller" turned out not to mean "dead":
POST activity/announce not orphaned — it is the missing PRODUCER for the
detached[] list GET activity/tasks already returns
and ActivityScreen already renders. an unbuilt
feature, not dead code, and finishing or dropping
it is a product decision.
GET agents/runs three days old. part of agent grounds, still being
built. "not yet consumed" is not "dead".
PUT/GET vault/unlock-key six days old, storage half of a feature whose
client half is unwritten. the vault is off limits.
DELETE integrations/google/connection caller exists but is deliberately
commented out of the tree. dormant on purpose.
vnc-manager's getSession is now orphaned too, but it is sidecar-internal and
was not in scope; noted rather than chased.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
unregisterSidecar rejected every entry in the pending map, not just the ones
belonging to the sidecar that went away. Restarting any single sidecar failed
in-flight work on every other: `pm2 restart officer-music` could kill a running
agent turn with `Sidecar "music" disconnected` — a message pointing at a process
that had nothing to do with it. Pending entries now carry their owning sidecar
id and the rejection loop skips the rest.
Also deletes src/servers/api/anthropic-proxy.ts. It had no importers; PM2's
officer-anthropic-proxy runs src/servers/sidecar/claude/index.ts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Terminals were a set of commands the platform drove. Officer sent pty:init / pty:input /
pty:resize / pty:close / pty:list over the registration socket, subscribed to ONE global
output stream, filtered every frame down to a session and rewrapped it — double
JSON-encoded — on the way out. That is terminal knowledge living in the process whose job
is authentication, and it made officer part of the data path for every keystroke.
The sidecar now serves its own loopback HTTP + WebSocket listener and announces the port
as `pty:server`, like every other HTTP sidecar. Officer authenticates the upgrade and
relays frames without reading them.
Split into three files, because "the sidecar" was one:
- sessions.mjs — the shell store. Spawn, attach, detach, resize, kill, scrollback, the
OSC-title scrape. Clients are a Set per session, so two panels can watch one shell.
- server.mjs — the listener. /ws speaks the browser's existing contract unchanged
({input,resize} in, {output,replay,exit,panel-refresh} out), plus /_officer/sessions,
DELETE /_officer/sessions/:id and POST /_officer/panel-refresh.
- index.mjs — the registration socket, and nothing else. It carries a port now.
On the platform side /api/terminal/* becomes createSidecarProxy, deleting the hand-rolled
router from two days ago, and websocket.ts drops from a translating bridge to a byte relay
modelled on the vault one. The whole PtyCommand/PtyEvent/PtyInitConfig/PtySessionInfo
vocabulary is gone from protocol.ts, connect.ts and sidecar-registry.ts.
broadcastPanelRefresh is now a POST to the sidecar: officer no longer holds terminal
sockets to loop over. Fire-and-forget — a missed refresh is a stale panel, not a failure.
The frontend did not move. The sidecar speaks what the browser already spoke.
The integration test was rewritten against the new shape, and tests something stronger than
before: officer is stopped mid-session and the shell keeps streaming, because officer is
not in the path at all. It also covers re-attach replay, the session list and kill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.
Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.
`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.
move the translation and the write into the sidecar:
- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
of websocket.ts (delta buffering, flush before tool:start and result). pure and
synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
to officer, with its cursor id attached. per-session promise chain: translation is
synchronous and therefore in arrival order, and only the commit is queued, so
cursor ids are assigned in the order events actually happened. a delta that
overtook the assistant:text in front of it would make the client commit its stream
buffer at the wrong point, so deltas go through the same queue even though they are
never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
plus its seq. officer relays it verbatim and folds it into the in-memory session
for sync:messages. it no longer builds or persists chat messages for this harness.
gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).
a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.
pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.
opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.
this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry
and was spawned on demand by the main server, with stdout/stderr inherited. that
made every agent session a grandchild of officer, so pm2's tree-kill took the
session down on every `pm2 restart officer` — the single thing that makes it
impossible to work on the platform while an agent is running.
give it its own entry (officer-agent) and delete the spawn machinery:
ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and
the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns
no sidecar at all.
for that to work the sidecar had to stop needing officer to start:
- it resolves the owner from the database (getOwnerUser) instead of reading
CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard
invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins
when set, for manual runs, and a fresh install waits for bootstrap rather
than exiting into a restart loop.
- it reads the anthropic proxy secret from the proxy sidecar's own state file
rather than being handed it in env. lazily, because ensureProxySecret
persists on a 30s debounce and pm2 starts both processes together.
it registers as 'agent' with capability 'claude', so the registry finds it the
way it finds every other sidecar. that removes the email argument from
killClaude, interruptClaude and clearClaudeSession, which only ever existed to
locate a per-email sidecar by name.
what officer keeps is a short wait-for-capability, because pm2 brings peers up
together and the first request after a boot can beat the sidecar's registration.
also align the two officer port fallbacks in the sidecar (5000 for the socket,
9010 for the rest base) — same instance, so they cannot disagree.
this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while
officer is down are still lost; that is stage 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The desktop page has never worked on a fresh install. Two faults, both fatal.
The password could never be created. DesktopView fetches /desktop/vnc-password
before opening the WebSocket, but ensureVncPassword ran only from startSession,
which only the WebSocket triggers — so the endpoint answered "not configured",
the UI stopped, and the socket that would have provisioned it was never opened.
A new vnc:ensure-password sidecar command provisions it directly; the endpoint
asks for it instead of returning 500.
The rfbauth file could never be written either. ensureVncPassword shelled out to
tigervnc's `vncpasswd -f`, which is not installed — and, contrary to the comment
in setup-desktop.sh, is not in tigervnc-common, which ships only tigervncconfig.
The failure was swallowed because only a zero exit wrote the file, so x11vnc got
-rfbauth pointing at nothing. x11vnc writes that format itself with -storepasswd,
so the dependency is gone and a failure now throws.
Verified on the box: the endpoint returns a password, .vnc/{passwd,password} are
written 0600, and the sidecar reports mirroring :0 on 5900 with x11vnc using the
generated rfbauth file.
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>
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>
Capture HOST_HOME before user-instance overrides process.env.HOME so
Super Admin spawns claude in /home/pastilhas. Generate separate MCP
configs for sandbox (sandbox paths) and host (real filesystem paths),
pick based on role at spawn time.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
replaces the single hardcoded systemd VNC service with a dynamic
sidecar that manages per-user VNC sessions on demand. any authenticated
user can now access their own desktop, not just Super Admin.
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>