Commit Graph
39 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 47d03de307 chat: carry claude's own session uuid on the result event
The sidecar knew which transcript file a turn had landed in and kept it to itself —
setClaudeSession fed --resume and nothing else. A session officer started was therefore
unaddressable from the platform side. Put it on the result event so it crosses the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:39 +00:00
pastilhasandClaude Opus 5 f5d30245c1 take the first cwd of a transcript, not the last
Opening a chat from a non-default folder moved the cwd picker into a subdirectory of
that folder — three of the four sessions under ~/dockers/officer.dev jumped to
`platform`, the fourth to `Lyrics` — and everything after it was scoped to the wrong
directory. The list itself stayed correct, which is what made it confusing.

Two functions in claude-sessions.ts derived the cwd in opposite directions. parseSummary
(:103) keeps the first `cwd` it sees; parseClaudeTranscript (:182) overwrote it on every
entry, so it ended up with whatever a tool last cd'd into. A session's home is where it
was launched — that is how Claude Code files the transcript on disk and how the list
groups it — so the first one is right and the two now agree. It also stops a late entry
clobbering `fallbackCwd` when the caller already knew which group it was loading from.

Pre-existing, but only reachable since rows became real links (3682269): clicking one is
a route change now, so the deep-link resolver in ChatHistory/index.tsx:69 — previously
hit only on refresh or a pasted URL — runs on every click, and it feeds detail.cwd
straight into setActiveCwd.

Verified against the four real transcripts in that group: all four now resolve to
~/dockers/officer.dev. C4/C5/C6 pass in the browser (click, refresh, and fresh-tab
deep-link all land on the right folder).

Also adds docs/nav-test-checklist.md — the 56-check matrix for the navigation refactor
and the sidecar work, recovered from the transcript of the session that wrote it and
lost it (eff24773). The X block no longer needs its own branch now that sidecars is
merged. C is done, L/J/D/P/X/R are not.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00
pastilhasandClaude Opus 4.8 238c3b8097 make the agent sidecar the writer of record for chat output
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>
2026-07-30 04:51:52 +00:00
pastilhasandClaude Opus 4.8 62dc4c1a5c run the agent as a pm2 peer instead of a child of officer
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>
2026-07-30 04:34:01 +00:00
pastilhasandClaude Opus 4.8 b27dd7512b load chat transcripts as a tail window and lazy-load older on scroll-up
extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:49:13 +00:00
pastilhasandClaude Opus 4.8 3682269936 resolve /chat/<id> by scanning project groups and make session rows real links
deep-linking or refreshing /chat/<id> only has the id, so add loadClaudeSessionById
to scan every project group for the transcript and return its real cwd. the session
list page resolves on load from that, setting the cwd picker and resuming. session
rows are now <Link to=/chat/<id>> so clicking updates the url and flows through the
same resolve path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:35:47 +00:00
pastilhasandClaude Opus 4.8 35973a5505 activity: follow the agent's background work live + chat-event retention
"Activity" (placeholder name — jobs/tasks were taken) = watch background tasks
scroll in parallel with chat. Built DB-free; NOT restarted — deploy + test in
the morning.

- NDJSON progress contract (activity/progress.ts): capabilities append
  {job,cap,phase,status,pct,detail,ts,...} lines; tolerant parser treats any
  JSON object with phase/status as structured progress, else a raw log line.
- Backend (activity/router.ts, owner-only, path-guarded):
  - GET /api/activity/tasks — registry by scanning /tmp/claude-*/<cwd>/tasks/
    *.output (harness run_in_background) + announced detached jobs.
  - POST /api/activity/announce {name,path} — register a detached (setsid) job's
    log so it's followable too (the setsid case is on the critical path, since
    the warm worker now makes plain run_in_background the default for heavy jobs).
  - GET /api/activity/stream?task=<id>|path=<abs> — SSE tail (poll + offset),
    emitting {kind:'line'|'progress'} with NDJSON parsed.
- Frontend /activity screen + Radio nav item: task list (active dot) → live tail
  with a phase/pct progress header + raw log, following the /system-monitor pattern.
- Retention: startChatEventRetention() prunes chat_session_events >7d every 6h
  (wired in bootstrap) so the durable queue stays bounded.

Verified headlessly (no restart): parseTailLine classification, and the scan
finds 53 real task output files. Endpoints + UI untested until deploy.

Deferred (see handoff): cross-device sync + OpenCode parity (both touch the
now-stable chat path — won't ship un-restart-tested); task:progress into chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 00:56:10 +00:00
pastilhasandClaude Opus 4.8 6b3eb247a3 chat: durable Postgres event queue + cursor replay on reconnect (Phase 2+3)
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>
2026-07-27 00:12:46 +00:00
pastilhasandClaude Opus 4.8 449f28b1e5 chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)
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>
2026-07-27 00:00:11 +00:00
pastilhasandClaude Opus 4.8 b9d539c1bd chat: "Disconnect session" — end the whole session from the UI (first stab)
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>
2026-07-26 23:22:53 +00:00
brunorezioandClaude Opus 5 044aacf4d5 remove the dead multi-user surface
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>
2026-07-25 23:30:20 +01:00
pastilhasandClaude Opus 4.8 5d077a4a54 route OpenCode chat through the officer-opencode sidecar
Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:43:57 +00:00
pastilhasandClaude Opus 4.8 d8e0dc79ef chat: send cwd on every message instead of reading it back from session metadata
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>
2026-07-25 12:26:41 +00:00
pastilhasandClaude Opus 4.8 2a940de76a chat: keep OpenCode session cwd consistent across turns + strengthen the override
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>
2026-07-25 12:21:28 +00:00
pastilhasandClaude Opus 4.8 88b74b2888 chat: OpenCode /chat cwd = general_chat_sessions (match Claude), drop home special-case
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>
2026-07-25 12:00:34 +00:00
pastilhasandClaude Opus 4.8 0c3f270419 chat: rename claude_sessions → general_chat_sessions; drop dead chat_sessions
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>
2026-07-25 11:50:48 +00:00
pastilhasandClaude Opus 4.8 9a79a76b95 chat: inject an Officer system prompt telling OpenCode its working directory (step 2)
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>
2026-07-25 11:06:17 +00:00
pastilhasandClaude Opus 4.8 7cd2be9fb9 chat: tag OpenCode sessions with officer metadata (cwd) + filter lists by it
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>
2026-07-25 10:50:48 +00:00
pastilhas bab0d1b7f8 Revert "chat: root the OpenCode server at the chat cwd (email chat now runs in the account dir)"
This reverts commit d60c73b3a3.
2026-07-25 10:08:48 +00:00
pastilhas 9b9938aaa9 Revert "chat: reap orphaned per-cwd opencode serves on startup"
This reverts commit 1f6eff3614.
2026-07-25 10:08:48 +00:00
pastilhasandClaude Opus 4.8 1f6eff3614 chat: reap orphaned per-cwd opencode serves on startup
Each pooled per-cwd server is a ~0.5GB process; across officer restarts the previous
instance's servers would orphan and pile up. On module load, kill any opencode serve on
a non-fixed port (the fixed OPENCODE_SERVER_URL port is preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:21:57 +00:00
pastilhasandClaude Opus 4.8 d60c73b3a3 chat: root the OpenCode server at the chat cwd (email chat now runs in the account dir)
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>
2026-07-24 20:20:15 +00:00
pastilhasandClaude Opus 4.8 cbe83f39a9 chat: run the /email chat from the email account's storage dir
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>
2026-07-24 17:37:48 +00:00
pastilhasandClaude Opus 4.8 ab134aa02b chat: surface all OpenCode models (drop the curated allow-list)
Removes the Big Pickle + Claude Haiku allow-list; the picker now lists every model
the fixed server reports from GET /config/providers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:15:10 +00:00
pastilhasandClaude Opus 4.8 7b16f3bc4c chat: point OpenCode at a fixed pm2-managed server (fixes empty model picker)
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>
2026-07-24 17:12:53 +00:00
pastilhasandClaude Opus 4.8 059539a64a chat: unified session history across Claude + OpenCode (Phase 3)
/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>
2026-07-24 16:56:20 +00:00
pastilhasandClaude Opus 4.8 3a2f3317b2 chat: curate OpenCode models to Big Pickle + Claude Haiku
The full `opencode models` catalog is ~58 entries; surface only opencode/big-pickle
and opencode/claude-haiku-4-5 in the picker for now via an allow-list. Easy to extend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:45:43 +00:00
pastilhasandClaude Opus 4.8 025c5dd38c chat: surface OpenCode models in the picker (Phase 2)
list-models.ts now merges the OpenCode catalog (from `opencode models`, cached;
ids are providerID/modelID) with the static Claude tiers, so /chat/models returns
both. invalidateModelCache clears the OpenCode cache for real now.

Adds the 'opencode' → 'OpenCode Zen' provider label in the /models response and the
ModelSelector's PROVIDER_DISPLAY. The existing useModels visibility gates already
pass non-claude-code providers through, so no gate changes are needed — Super Admin
sees all models. Selecting any non-claude-code model routes the turn to the OpenCode
harness (Phase 1).

Default model stays 'claude-code'. 58 OpenCode Zen models currently list; curating to
a flagship subset is an easy follow-up if the full catalog is unwieldy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:32:12 +00:00
pastilhasandClaude Opus 4.8 ad32c7516e chat: add OpenCode as a second harness — live turn (Phase 1)
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>
2026-07-24 16:29:55 +00:00
pastilhasandClaude Opus 4.8 49df1c0b0c chat: rename the pi chat transport + model list to chat (Stage 4b/1)
Pure rename, no behavior change. Moves the misnamed "pi" chat harness into the
chat namespace:

- api/pi/{websocket,session-manager,types,logger,list-models} → api/chat/
- merge api/pi/rest.ts into api/chat/chat.ts (/pi/models → /chat/models,
  /pi/stt → /chat/stt); drop the piRestRouter mount
- PiEvent → ChatEvent, piWebsocket → chatWebsocket, listPiModels → listChatModels
- WS route /api/pi/chat/ws → /api/chat/ws, provider tag 'pi' → 'chat'
- frontend: useChat/useAudioRecording URLs, usePiModels→useModels /
  useVisiblePiModels→useVisibleModels / useEnabledPiModels→useEnabledModels,
  'PI_MODELS' query key → 'CHAT_MODELS', attachments provider 'pi-mono' → 'chat'

The /pi-mono provider/harness settings router is renamed separately (next commit).
Note: the WS route change requires the mobile app to point at /api/chat/ws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:47:40 +00:00
pastilhasandClaude Opus 4.8 04bde70ed3 chat: pwd selector — switch the /chat working directory
The Sessions panel header gets a pwd selector: the default claude_sessions dir,
plus every directory that already has Claude sessions (auto-discovered by reading
the real cwd back from ~/.claude/projects), plus free-text entry. Switching pwd
refetches the list for that cwd's Claude project group and runs New Chat / resume
in it. Backend: GET /chat/pwds + a ?cwd= param on the session ops; the WS handler
honors a chosen cwd for /chat (default claude_sessions). Browse-modal picker next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:41:49 +00:00
pastilhasandClaude Opus 4.8 420aa08783 chat: run Claude un-isolated for the Super Admin (real HOME, ~/.claude parity)
Single-user platform: the Super Admin's Claude process now uses the real home
(HOME_DIR) instead of DATA_PATH/<email>/home, so its transcript store IS the same
~/.claude the terminal `claude` uses — platform and terminal sessions are
interchangeable (native `/resume` sees them). The session reader resolves the
same home. The generated container CLAUDE.md is no longer written for the Super
Admin (it would pollute the personal global ~/.claude/CLAUDE.md and is stale);
MCP tools still load via --mcp-config, and email/project panels inject their own
prompts. Sandboxed users keep their isolated home.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:19:31 +00:00
pastilhasandClaude Opus 4.8 b3b8a59863 chat: rename sessions in Claude's store + delete confirm
Rename appends a {"type":"summary",...} entry to the session's JSONL transcript
(Claude's own format, so the title lives in .claude); the reader takes the last
summary as the title, without a timestamp so it doesn't reorder the list.
PATCH /chat/sessions/:id/title backs it. The list gets inline rename (pencil ->
edit in place) and a two-step delete confirm so a stray click can't nuke a
transcript. Rename verified against a synthetic store.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 12:01:16 +00:00
pastilhasandClaude Opus 4.8 4215c8df0a chat: delete sessions + auto-refresh the /chat list after each turn
DELETE /chat/sessions/:id removes Claude's transcript file; the list gets a
per-row delete button. The list also invalidates on turn-complete so new and
continued sessions surface without a manual refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:14:27 +00:00
pastilhasandClaude Opus 4.8 9565cd9462 chat: click-to-resume /chat sessions via Claude --resume
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>
2026-07-24 10:11:58 +00:00
pastilhasandClaude Opus 4.8 eb4ce4301b chat: run /chat sessions from the dedicated claude_sessions cwd
The standalone /chat route now tags its context as 'chat'; the backend runs those
sessions with cwd = DATA_PATH/<email>/claude_sessions (created on demand), so their
transcripts form an isolated Claude project group instead of polluting the home-dir
history. Other chat contexts (email/project panels) keep their own cwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:43:01 +00:00
pastilhasandClaude Opus 4.8 319cf39e84 chat: read the /chat session list from Claude's own transcript store
First slice of making Claude's session store the source of truth. Adds a reader
(api/chat/claude-sessions.ts) that lists sessions straight from Claude's JSONL
transcripts under $HOME/.claude/projects/<cwd-slug>/, plus GET /chat/sessions
over a dedicated per-user claude_sessions working directory. No bookkeeping
layer — Claude's files are authoritative. Verified against real transcripts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 09:40:58 +00:00