The default was DATA_PATH/<email>/general_chat_sessions, a dedicated directory so /chat
sessions formed their own Claude project group instead of cluttering the home. It is a sibling
of the home, and confineUserTree makes every sibling the platform's at 0700 because the others
are attachments and email_accounts. So it was unreachable for a member: the first live member
turn started there and every Bash call failed on its own working directory before doing
anything.
A per-member copy inside each home fixed the symptom and left two rules to remember. The owner
chose one rule instead — the account's own home, whoever they are — and accepted the trade
knowingly: /chat sessions now share a project group with anything else run from that home,
which was the reason the dedicated directory existed.
Removed rather than left dangling: getGeneralChatSessionsCwd, ensureGeneralChatSessionsCwd,
ensureMemberChatCwd, and general_chat_sessions from USER_DIRS so new accounts stop getting it.
Existing directories are untouched and their transcripts stay where they are — Claude groups by
cwd, so the owner's old /chat history remains under its own project slug rather than moving.
The UI labels move with it: the default group now reads "home" rather than naming a directory
that no longer has a role.
ChatIdentity keeps carrying both email and home. The pairing was justified in the comment by
general_chat_sessions being email-derived, which is now gone — but the distinction it encodes
is real (the email says who, the home says where), so the comment explains that instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
host captured the first live member turn: uid 1001, nine env vars, zero
ANTHROPIC_*, zero POSTGRES_URL, zero JWT_SECRET. The privilege drop and the
allowlist both held. One defect.
The default chat cwd was DATA_PATH/<email>/general_chat_sessions — a sibling of
the member's home, which confineUserTree deliberately makes the platform's at
0700 because the other siblings are attachments and email_accounts. So the turn
ran in a directory the member cannot enter, and every Bash call failed on its
own cwd. The agent reported its shell as broken, which was true.
A member's default is now ~member/general_chat_sessions, created as them through
runAs. mkdir -p, so it is idempotent per turn and needs no reprovision. The
owner's path does not change, and the sibling stays 0700 — loosening it would
trade a broken shell for an open directory holding attachments and mail.
29 said this path is email-derived and therefore stays email-derived. True, and
it did not follow that it is usable: an email-derived path under DATA_PATH is
precisely the set a member is locked out of. Splitting identity from filesystem
path was right; assuming the identity side was inert was not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The owner authorized this explicitly. Two refusals removed together, because
they were always one guard in two places: the wholesale isSuperAdmin middleware
in api/chat/chat.ts, and the chat socket's 403 in server.tsx.
They were right for the day they stood. A turn spawned claude as the OWNER and
every transcript path resolved through the owner's home, so a granted member
would have read the owner's sessions and run an agent as them.
What replaced them, rather than what deleted them:
the turn runs as the member spawnClaudeAsMember through sudo setpriv,
proven against a real account by reading file
ownership rather than trusting the process
the credential is theirs --reset-env plus an allowlist, so the owner's
proxy variables cannot cross
the transcripts are theirs ChatIdentity carries a home from resolveHomeDir
and claude-sessions cannot invent one
the sessions are theirs every session records its owner and all six
sidecar commands refuse a mismatch
Also adds the precondition host asked for in 10: a member whose claude is not
signed in gets the instruction rather than a turn that dies on an auth error and
reads as a broken agent. Not installed and not signed in are separate messages
because they need different actions.
registry.ts and registry.test.ts now describe chat as confined in fact rather
than ahead of its implementation. The comments at both former guards say what
had to exist first, and that a revert should go back to a refusal rather than to
a narrower one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
host found a live regression from 95951fb, on the owner's own paths.
resolveBaseCwd used to take an email and resolve its own root. 95951fb made the
first parameter the home itself, and three callers outside that diff kept
passing an email: pipeline-executor twice and agent-handoff once. Both
parameters are string, so tsgo had nothing to say. Every pipeline step and
handoff with a relative cwd, a `~`, or no cwd was building a path out of an
address, resolving it against the platform's own working directory — the
checkout. Absolute paths kept working, which is what would have made it look
intermittent.
All three are owner-only, so they now pass getOwnerHomeDir(email) explicitly,
the way agent-runner does. The definition of resolveBaseCwd carries the warning:
an absolute path, NOT an email, with the reason.
Not done: the branded type this argues for. Two strings meaning "identity" and
"filesystem path" sat adjacent through a refactor and the compiler could not
help, which is a real gap — but it reaches every path function in the server,
and doing it at 01:00 on the back of a bug caused by a hasty refactor would be
the joke telling itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The history layer, and the last change that could be made without a live member.
claude-sessions.ts had `claudeHome = process.env.HOME_DIR ?? join(DATA_PATH,
email, 'home')`, which discards its argument whenever HOME_DIR is set — always,
on a real install. Every transcript read therefore resolved to the OWNER'S
~/.claude no matter who asked, and the comment above it asserted "single-user
platform" as though that were a property rather than an assumption. A member
reaching these functions would have been handed the owner's conversation list.
Now every read takes a ChatIdentity {email, home} with the home resolved from
resolveHomeDir(userId), and this file has no way to invent one. Both fields
travel together because they are genuinely different: general_chat_sessions
lives under DATA_PATH/<email>, not under a home. Collapsing them would be the
same class of mistake as undefined meaning "the owner".
websocket.ts's resolveCwd takes a home, so `~` expands against the caller's own.
Identity is resolved BEFORE the cwd — expanding `~` before knowing whose home it
is would be exactly the bug being removed — which also let a duplicate
resolveTurnIdentity call from 6aeb304 be deleted.
chat.ts resolves per request and throws FORBIDDEN rather than falling back, same
posture as resolveTurnIdentity. agent-runner passes the owner's home explicitly
rather than inheriting it, since that path really is owner-only.
Made at 01:00 after saying it should not be. Three things I am least sure of are
listed in COMMS 29 rather than left for the reviewer to find: chat routes now
have a failure mode they did not have, resolveBaseCwd's exported parameter
changed meaning rather than shape, and the bare-email rewrite in chat.ts was
mechanical with hand repair.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
host caught that resolveMemberRun failed open. Returning undefined means "run as
the server owner" downstream — their binary, their ~/.claude credential, their
HOME, their MCP config carrying OFFICER_AUTH_TOKEN — and three different inputs
produced it: the caller being the owner, resolveHomeDir failing, and a member
whose osUser is null. The last two mean "could not determine", and answering
them with the owner's identity is the single thing this feature exists to
prevent.
23's own comment said the caller must not fall back to the owner. The code did
exactly that. The prose was right.
Now a discriminated TurnIdentity: owner, member, or refuse-with-a-reason. The
call site ends the turn on refuse instead of spawning. The owner's identity is
reachable only by positively establishing isOwner, never by failing to establish
anything else — resolveHomeDir already reported it as a positive fact and the
funnel through undefined was the only thing discarding it.
The null-osUser case is not hypothetical: provisionOsAccount is non-fatal at
every stage and records the account either way, as its own source says. Tonight
provisioning failed three separate ways on a real member and the account
survived each time.
No test yet, and the reason is in COMMS rather than hidden: it needs database
fakes this repo has no pattern for, and inventing one at 01:00 to cover four
branches is how the next defect gets written. The union is exhaustive, so tsgo
catches a missing case — not the same thing, not nothing.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last mechanical link: chat socket -> resolveMemberRun(userId) ->
ClaudeSpawnStreamingParams.member -> claude-manager's branch ->
spawnClaudeAsMember -> sudo setpriv. The path from a request to a privilege drop
is now complete.
Resolved from the authenticated socket, never from the client message — the same
rule server.tsx applies to the pty sidecar, where it deletes any client-supplied
osUser/home from the query string before setting its own.
resolveMemberRun returns undefined rather than throwing when a home cannot be
resolved, because undefined means "the owner" downstream: an account with no
Linux user has nothing to confine a turn to, and falling back to the owner is
the one wrong answer that must not happen by accident. A separate function with
that reasoning attached rather than an inline ternary.
Still inert. Both gates refuse non-owners before this line is reached, so the
only path that reaches it today returns undefined via isOwner.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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 <noreply@anthropic.com>
Andre asked for zero, not another fix on top. Reverts ec4f06a..7726c9f — the ten commits from
"tabs and panes" onward: the tab bar and pane splitting, tab renaming and its page title, the
per-server directory picker, the render-loop fix, pane transcript resolution, the send queue,
the two socket fixes from the other session, the pane-socket notes, and my own socket-set change
from tonight. He is rebuilding from here.
Deliberately KEPT: dc6b623, "talk to two officers at once from one browser". That was a separate
ask that predates the tabs one, and the multi-server client, the server chips and the connections
store stand on their own without panes. Reverting it too is one more command if that was the
intent.
Collateral, worth naming: cb7ab55 carried an unrelated MusicPlayerHost change alongside its
socket instrumentation, so that came out with it.
Reverts, not a reset — every one of these is pushed and a second session is live in this repo.
Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both failing
identically before this and unrelated to chat.
What is NOT explained by this revert: the browser symptoms tonight. The server was verified good
throughout — two real turns streamed back through the public URL on both models, and the full
2,281-message history came through nginx intact. Whatever the client fault is, it is still
unfound, and the pre-tabs code is where it now has to be looked for.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reported from two devices at once: typing on the iPad, reading the reply on the Mac. Sending
from the Mac produced nothing there. Both halves are one field.
A session held `ws`, a single socket, and `attachWs` assigned it. So the newest attach silently
took the turn away from whoever was already watching — and with a tab now holding up to three
panes, plus a phone and a laptop on the same conversation, several sockets per session stopped
being exotic and became the ordinary case. Now a Set, and every message goes to all of them.
`detachWs(sessionId)` was worse, because it named no socket: it nulled the field on ANY close.
A stale client going away therefore killed delivery for the client that had attached after it,
which is the "nothing happens on the Mac" half. It takes the socket now and removes only that
one, and the idle GC is armed only once nothing is left watching — otherwise a close would
collect a session another pane is still reading.
endTurnIfAgentIsGone takes the whole set for the same reason: a cut-off notice explains a
spinner that will otherwise never stop, and telling one of three clients leaves two spinning.
Typecheck clean. 600 pass, 2 fail — cliamp path-escape and the pty transport test, both
failing identically on master before this change.
Nobody has clicked it; the two devices that reported it are the test.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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 <noreply@anthropic.com>
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 - <ISO>". 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 <noreply@anthropic.com>
B4 properly. The composer gate was the honest stopgap; this is the fix. opencode run takes
attachments with --file, so images work on the subprocess path we already use — the parity
doc had them down as phase 4, behind the serve migration, and they were not.
The bug was one omission: handleOpenCodeChat`s msg type had no images field, so the browser
sent them, the bubble rendered them, and they stopped at that signature. Nothing reported a
loss anywhere.
Attachments are paths, not inline data, so the sidecar spills each image to a temp file for
the length of the turn and removes it in settle — the same place every other per-turn
resource is released, so a killed or superseded turn cleans up too.
The load-bearing detail is `--` before the prompt: --file is an array option, so without the
separator the prompt is eaten as another filename and the turn dies with "File not found:"
followed by the entire message. Confirmed against the binary, and pinned by a test that
records argv from a stub.
list-models now reports each model own capability instead of a hardcoded false — opencode
publishes capabilities.input.image per model and nothing had ever read it. Defaults to false,
so a model that does not declare it keeps the affordance hidden.
Verified end to end: a red png sent over the chat socket to opencode/claude-sonnet-4-6 came
back "Red".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
B7. `msg.model || DEFAULT_MODEL` declared every session without an explicit model to be
claude-code, and the parity doc recorded only the visible half of what that cost.
The durable false cut-off is real: endTurnIfAgentIsGone asked the claude sidecar about a
key it had never held, was told false, and wrote "the agent went away" into a turn that
was running fine. It survives reload, because surviving reload is what that row is for.
The same default also handed the session to adoptOrphanedSession as a claude one, which
subscribes it to that sidecar bus and pins session.model — so an opencode turn output
never arrived, and stopping it called killClaude on a key that sidecar never had. A stop
button that silently does nothing.
decideResume makes both rules explicit: the server record beats the client claim, and an
unknown harness stays unknown — no adoption, no cut-off check, just the replay. Silence
is the safe failure when the wrong answer is written durably.
DEFAULT_MODEL stays in handleAttach and is now commented as to why: that path reached its
sessionId by asking the claude sidecar to resolve a claudeSessionId, so only claude could
have answered.
First test in api/chat, which had none. websocket.ts has no seam to drive the handler
through, so the decision is extracted and tested; the wiring around it is not covered.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three defects and one honest removal. Each was reproduced before being changed, as the doc asks.
B4 — images were offered and silently discarded. Every OpenCode model advertised `images: true`, the
composer gates on that flag, the bubble rendered the attachment, and `handleOpenCodeChat`'s message type
has no `images` field, so it never left officer. Flipped to false: 61 OpenCode models now decline, the
three Claude ones still accept. Plumbing them through OpenCodeRunParams stays Phase 4; advertising a
capability that does not exist is the part worth fixing today.
B5 — every OpenCode turn overwrote the previous turn's subscription handle without detaching it, so the
old session-scoped listener stayed attached and delivery doubled, tripled, and so on for any termination
that is not result/error/stopped. Deliberately NOT the Claude guard: Claude keeps one persistent session
and skips re-subscribing, while OpenCode spawns a fresh `opencode run` per turn, so a new subscription
each time is correct — detaching the old one is what was missing.
B6 — the sessionKey → `ses_…` map had no writer of deletions, so it grew for the process lifetime and a
reused key resumed a stale OpenCode session. Cleared in `deleteSession` only, never in `releaseSession`:
releasing means "let go, leave it running", and a returning browser must find the same `ses_…` again.
Phase 0 item 1 — the thinking toggle is removed rather than fixed. `thinking` is accepted on the wire
and forwarded by neither channel, so the control changed its own label and nothing else. Out of scope
for both harnesses by decision. The inert plumbing beneath it is left for a follow-up that touches the
socket contract; the props stay accepted-and-unread so no call site had to change.
Phase 0 is complete: B1, B2, B3 landed earlier; B4, B5, B6 and the selector here.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer's hour-long idle timer was doing two unrelated jobs: collecting its own in-memory binding, which
is its business, and terminating the agent, which is the sidecar's. It could not do the first without
the second, because `unsub` was a closure reachable only through `kill`.
So a browser that went away killed a live agent an hour later — including one the sidecar had
deliberately protected. The sidecar already refuses to collect a session that is mid-turn or holding
background tasks: `task:started` disarms its idle GC, and `armIdle` re-checks and re-arms rather than
firing once. Officer had no view of any of that. A laptop running out of battery overnight took a
`run_in_background` job with it for no reason.
`detach` now sits beside `kill` on both streaming handles, and `_sidecarUnsub` — declared and called for
a long time, never once assigned — is populated at all three sites. `releaseSession` unsubscribes and
forgets the record without killing; the idle timer points at it. `deleteSession` is unchanged, so an
explicit disconnect still ends the session.
The third assignment site was not in the plan: `adoptOrphanedSession` sets `_claudeKill` but nothing
else, so an adopted session that later idled out would have dropped its record while the listener stayed
subscribed — a leak of one per adopt-then-leave.
No double subscription: releasing unsubscribes first, so a returning browser either adopts with a fresh
listener or starts a first turn with none behind it.
Step 1 of docs/chat-session-lifetime.md. Step 2 (a list verb, so running sessions can be found after a
restart) is still open.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A task row is officer's own invention, synthesised from the harness's system.task_started, and
nothing corresponding to it is ever written to Claude's transcript. So rebuildTranscript can only
produce user/tool/assistant rows, and sync:live deliberately carries no messages — which left the
background-task tray empty after a mid-task refresh even though the work was still running.
Fold the durable log on attach into started-minus-notified and hand that back on sync:live. The
same read now supplies the cursor, so this costs one query rather than two. Finished tasks are
excluded: replaying those would resurrect rows already seen to resolve.
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>
The composer already uploaded an image, split its data URL and put the bytes on the wire as
`images`. Nothing on the server read them. The `chat` ClientMessage had no such field, and the
prompt reached the sidecar as a bare string, so all the model ever saw was the client-generated
`[Attached image: …]` placeholder — a label describing a picture it was never shown.
The transport was never the obstacle: `query()` consumes an async iterable of user messages whose
`content` is an Anthropic `MessageParam`, and only `pushTurn` hardcoding a string kept it to text.
So `images` is threaded through the four hops that dropped it and turned into native image content
blocks at the end, renaming `mediaType` to the API's `media_type` at that last step.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the cut-off notice is its own event now rather than an error: nothing is broken
and nothing is lost but the turn, so the row says what happened and offers the
one action that fixes it. the conversation is already durable — the claude
session id is written through to disk and passed back as resume: — so retry
just resends the prompt on a session the fresh agent picks up with full
context. read back out of the transcript, so a second window on the same
session can offer it too.
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>
restarting officer under a live turn left the browser connected but permanently
silent. the sidecars are pm2 peers, so the agent kept generating and kept
committing to chat_session_events — what died was officer's binding to it. on
`resume-cursor` the server only re-attached the socket when an in-memory session
still existed, so after a restart there was no session and, critically, no
session-scoped subscription relaying sidecar events to the client. the client got
its durable replay and then nothing, which reads exactly like the agent stopping.
adopt the session instead: recreate the record and re-open the subscription
without spawning anything. `_claudeKill` has to be set as part of that — handleChat
treats its absence as "first turn" and would open a second subscription, doubling
every message.
the client now echoes the model and cwd from its session:init back in the
handshake, since after a restart it is the only party that still remembers them.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the harness stamps every message a subagent produces with parent_tool_use_id.
the sidecar wrote it outgoing and nothing ever read it coming back, so a
subagent's prose and tool calls were spliced into the main transcript as if the
agent you are talking to had produced them — and worse, its deltas were appended
to the same text buffer, so two voices were concatenated inside one bubble.
both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are
now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage
and Message. useChat nests parented output under the Task row that spawned it;
ToolActivity draws the trace inside the expanded panel.
background tasks get the same treatment from the other end: task:started and
task:notification were two unrelated fake assistant bubbles minutes apart, and
are now one role:'task' row correlated by taskId that appears pending and
resolves in place.
Co-Authored-By: Claude Opus 5 <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>
Completes the turn/session decoupling so nothing is lost across disconnects.
Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
to the queue (even while the client is disconnected) and delivered live with
its seq. Streaming deltas stay ephemeral (live-only, never persisted).
Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
the (still-live) session (cancels idle-GC via attachWs) and replays every event
since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
the max seq and, on every (re)connect with an established session, sends
resume-cursor — so a dropped connection self-heals with no manual navigate
away/back, and background task notifications that landed while offline replay.
Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.
Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.
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>
Adds a per-session teardown, distinct from the existing turn-only "stop":
- New WS 'disconnect' message → handleDisconnect → sessionManager.deleteSession,
which fires _claudeKill (kills any in-flight Claude/OpenCode turn) + _sidecarUnsub,
clears the idle timer, and drops the in-memory session. WS stays open so a new
prompt starts fresh. Server acks with 'disconnected'.
- useChat: disconnectSession() + a 'disconnected' handler (commit partial stream,
settle to idle).
- UI: an Unplug button in the chat DetailBar (shown while connected).
Scope note: targets the CURRENTLY-OPEN session (correct in-memory sessionKey).
Disconnecting an arbitrary *listed* session isn't wired yet — session-list rows are
keyed by the on-disk transcript uuid, which isn't the live sessionKey, so that needs
a reverse lookup + a REST endpoint. NOT yet deployed (needs a server restart).
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>
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>
The default /chat working directory is used by both the Claude and OpenCode harnesses
now, so its Claude-specific name was misleading.
- Rename the dir + accessors: getClaudeSessionsCwd → getGeneralChatSessionsCwd,
ensureClaudeSessionsCwd → ensureGeneralChatSessionsCwd, path segment claude_sessions
→ general_chat_sessions (data-path on disk + code + UI labels/comments). No history
migration — the old Claude transcript slug is orphaned (intentionally).
- Remove the vestigial chat_sessions dir (leftover from the retired session store):
it only ever held empty claude/archived/ dirs, recreated by a signin hook. Drop that
hook (+ its dead imports) and the 4 unused data-path accessors (getUserSessionsDir,
getClaudeDir, getSessionDir, getArchivedSessionDir), and delete the dir.
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>
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>
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>