- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
`pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
live pulse + back button); autoscrolls, capped at ~1200 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel
selects the scope over the 'monitor:scope' channel, the right MonitorMain panel
renders it. Three scopes:
- bTop — the existing system snapshot (CPU/mem/disks/top processes)
- pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/
mem/restarts/uptime table)
- dockers — new GET /api/system-monitor/docker (docker ps → container cards with
state/status/image/ports)
Both new endpoints degrade gracefully to an error field. Persisted as
screens/system-monitor; owner-only.
Needs a restart (backend routes + rebundle) + hard-refresh to appear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"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>
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>
- Backend GET /api/system-monitor/stats: one snapshot — CPU overall + per-core
(two /proc/stat samples), memory + swap (/proc/meminfo), disks (df), load,
uptime, top-20 processes (ps). Owner-only via the account gate.
- Frontend /system-monitor screen: polls every 2s; CPU/Memory/Disks cards with
bars + per-core mini-bars, top-processes table. Nav dock "Monitor" item.
- Register /music and /system-monitor in usePageTitle RULES so the browser tab
and editable header title update on those routes (/music was missing too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Super Admin ("owner") identity — SUPER_ADMIN_EMAIL, else the
bootstrap/first user (super-admin.ts) — and closes the hole where a music
account could sign into the full platform:
- Rename EXPO_PUBLIC_CLIENT_ORIGIN -> OFFICER_APP_ORIGIN.
- PUBLIC_URL + OFFICER_APP_ORIGIN are owner-only origins; MUSIC_APP_ORIGIN
stays path-scoped to /api/auth + /api/music.
- Account backstop (origin-independent): a valid non-owner token may reach
only /api/auth + /api/music regardless of Origin — airtight even if the
header is omitted/forged.
- signin rejects a non-owner logging in from an owner-only origin.
Owner keeps full access (verified); music users are confined to the music app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Whisper feeds its own decoded output forward as the prompt for the next 30s
window, which is what makes it loop once it slips. max_context is the budget for
that carried text; setting it to 0 stops the loops but also disables the initial
prompt, since whisper.cpp gates both on n_max_text_ctx > 0. carry_initial_prompt
puts the prompt in a static slot that is filled first and in full, so making the
prompt longer than the budget leaves nothing over for decoded text — the prompt
reaches every window and none of the model's own output does.
The prompt is a style exemplar, not an instruction: whisper imitates what it is
primed with. A first version described the format ("Commas separate clauses")
and a character surname came back as "Commas", so both prompts are now ordinary
conversational prose with no meta-language, one per language.
Also switch to verbose_json for segment timestamps and rejoin the segments into
paragraphs on pause length, rather than emitting one line per utterance with a
leading space; decode with beam search instead of greedy; widen the VAD segments
so they break at real pauses instead of at breaths; and stop verbose_json from
re-running a full language auto-detect that step 1 already answered.
Measured on a 43 minute episode against the previous output: redundant repeated
sentences 18 -> 9 (the long distinctive loops are gone, what remains is a
catchphrase), paragraphs 1 -> 93, lone-punctuation lines 3 -> 0, lines with a
leading space 1021 -> 0.
Co-Authored-By: Claude Opus 5 <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>
Bun's fetch aborts at 300s by default. whisper.cpp runs at roughly 8x realtime,
so a 43-minute recording needs about 5.4 minutes and died with "The operation
timed out" some 20 seconds short of the answer — long enough to look like a slow
machine rather than a ceiling.
AbortSignal.timeout() does not raise that ceiling; only `timeout: false` does,
which the DOM RequestInit type does not declare, hence the local BunRequestInit.
Verified end to end: a 43-minute episode through the task now completes in 547s
with a 27k-character transcript, where it previously failed at exactly 300s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy is an opaque catch-all, so the endpoint surface wasn't perceivable from
the platform side. Add a contract header (all routes + params + SSE/response
shapes) atop the sidecar fetch handler where the routes are defined, and point
the proxy router at it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract Audio now exists as a task, with recursion, multi-select scoping, a
format choice and multi-track handling — none of which the one-shot menu entry
had, since it always produced a single mp3.
/extract-audio stays because the file viewer's button plays its output rather
than saving it beside the video, but it no longer returns a cached file: like
transcription and OCR, it always redoes the work. The path is now named outRel,
since it is an output location rather than a cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an officer-music sidecar that owns an audio-streaming HTTP server, and a
thin authenticating proxy on the platform. All processing (path resolution,
byte-range streaming, ffprobe duration) is in the sidecar; the platform only
authenticates and forwards.
App-facing contract (handoff):
GET /api/music/stream?path=<home-relative path>&token=<jwt>
- auth via userMiddleware (Bearer or ?token= for media elements)
- 200 full / 206 on Range, with Accept-Ranges, Content-Length,
Content-Range, Content-Type, and X-Audio-Duration (seconds, ffprobe)
- path resolved within HOME_DIR, traversal-guarded (400); 404 if missing
Purpose: stream + seek without pre-downloading the whole file — the app can
read X-Audio-Duration instead of scanning for VBR duration.
Pieces:
- sidecar/music/{index.ts,stream-audio.ts}: Bun.serve on a random port, /stream
+ /health, duration cached by path+mtime; reports its port via a new
music:server sidecar event on connect.
- api/music/{sidecar-server.ts,router.ts}: capture the port; reverse-proxy
/api/music/* → sidecar, streaming status + headers through.
- protocol.ts music:server event; hono.ts mounts /api/music; ecosystem adds
officer-music.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both endpoints kept a copy under cache/ and returned it on the next call, and
also short-circuited when the sibling .md already existed. So a re-run never
re-ran: a bad transcription stayed bad, and there was no way to ask for a fresh
one. Every caller passes saveNextTo, so the cache-path return was dead code
anyway.
Both now always do the work and overwrite the sibling. CACHE_PREFIXES drops the
two prefixes, since /save-result has nothing left to promote for them; the tts
and audio caches are untouched.
Also fixes the task runner output being unreadable in light mode. The panel is a
fixed dark terminal, but stdout lines were classed text-foreground, which follows
the app theme and renders black on the dark background. They now inherit the
pre's own colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes that Transcribe Audio needs.
Whisper's translate mode only ever outputs English, so it cannot honour
"translate into <language>" for anything else — it answered Portuguese audio with
a rough English rendering instead of a transcript. The translate decision is gone
and transcription is always faithful to the detected language; spokenLanguages
now only breaks ties on clips Whisper is unsure about.
Script tasks get OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN so they
can call Officer's own endpoints rather than reimplementing server-side work.
Requests go to 127.0.0.1 so nothing depends on DNS or the proxy, but origin
validation matches Host against PUBLIC_URL, hence the separate host variable.
`inline` accepts "ask", which offers both affordances in the runner — Run here
streams into the modal, Run as job queues it. Useful when the same task can take
a second or an hour depending on whether it was pointed at a file or a library.
Existing true/false values behave exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CATEGORY_ORDER hardcoded Video/Audio/Cleanup in the frontend, so adding a
category meant a code change. The order now lives in categories.yaml at the root
of the items store and reaches the client via GET /tasks/categories — the
platform no longer knows any category by name.
The endpoint is declared before /:name, which would otherwise match
"categories". Categories used by a task but absent from the file still work: they
sort alphabetically after the listed ones, and Other stays last.
Menus consume grouped tasks rather than grouping them per row. Groups are built
from the tasks and the file only ranks them, so a category listed with no
matching tasks cannot produce an empty submenu — locked in by tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every task declares a directory trigger, so right-clicking any folder listed all
fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an
optional `category`, and the context menu nests by it: Run Task > Video > …
Nesting only kicks in when more than one category matches. A .mp4 matches eight
tasks that are all Video, so file menus stay flat rather than gaining a pointless
hop. Known categories lead (Video, Audio, Cleanup); anything else follows
alphabetically with Other last.
Applied to both menus — the right-click one and the ⋮ dropdown — which carried
identical blocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Language selection ranked only the user's spoken languages, so a user who speaks
one language always got that language. With spoken=["en"], Portuguese audio
detected at 0.968 pt was sent as language=en, and Whisper answered with a rough
English rendering rather than a transcript. `translated` stayed false, so nothing
downstream could tell a translation had happened, and detectedLanguage reported
"en" for audio that was not English.
Take Whisper's top language when it clears 0.5, and fall back to the spoken
languages only when the clip really is ambiguous — which is the case the bias was
written for.
Verified against the local whisper server with pt/en/es recordings:
spoken=["en"] pt -> translated English, es -> translated English, en -> as-is
spoken=["en","pt"] pt -> Portuguese transcript, no translation
spoken=[] pt -> Portuguese transcript
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
OpenAI-compatible TTS servers disagree on the /v1/audio/voices payload: some
return plain strings, Kokoro returns objects like { id, name }. The handler cast
the response to { voices: string[] } without checking, so the objects reached
the voice <Select>, which renders each entry directly — React error #31, and the
whole system settings page unmounted.
Flatten to ids at the boundary, preferring id then voice_id then name, and drop
entries that yield neither. An empty result now falls through to the HuggingFace
lookup instead of returning an empty list. The ElevenLabs branch goes through the
same helper so a shape change there cannot throw either.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Onboarding was dead in three layers:
- The OnboardingAdmin screen was only reachable from a route block in App.tsx
that has been commented out, so it never rendered. Its ServerTypeCard carried
accountMode ('organization' | 'single'), inherited from the codebase this was
based on and meaningless for a single-user platform.
- Two /onboarding-complete endpoints, one public and one protected, that no
frontend code called. Both read a server_config key that was never written, so
both answered false while the app's own path defaulted to true.
- HomeScreen gated on settings.onboarding.complete to show a welcome panel, and
seedHomeDir created an Onboarding folder from DATA_PATH/Onboarding and
/Onboarding_Admin — neither seed directory exists, so it only ever produced an
empty folder.
Also drops the onboarding key from UserSettings and the now-empty home-header
panel from the default home layout.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- DiscordAccount seeded DiscordStatus without its two nullable fields.
- bug-report typed reporter.name as string, but users.name is nullable; and the
Discord upload wrapped a Buffer directly in a Blob.
- Lucide icons take no `title` prop, so the sync spinner's tooltip moved to a
wrapping span.
- DesktopView cast its dynamic import to a type that included `| null`.
- dock PUT cast the request body straight to string[]; it now rejects anything
that is not an array of strings instead of writing it to the database.
- buildZodSchema assembles a mutable record, since z.ZodRawShape is readonly in
zod v4.
- The dev-server proxy forwards Bun's `string | Buffer` frames through a helper
that satisfies WebSocket.send without copying.
bunx tsgo is now clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
resolveUserPath and five sibling checks used startsWith(rootDir), which also
accepts a sibling directory whose name begins with the root's: from a root of
/home/br, "../br-backup/secret" resolves to /home/br-backup/secret and passed.
Compare against root + sep (or the root itself) via a shared isInside helper.
Verified the escape cases now deny while "", ".", and ordinary relative paths
still resolve.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Origin validation, every rate limiter and the password-strength check each
treated an unset PUBLIC_BUILD_ENV as "relaxed", so a deployment that forgot the
variable silently ran with CORS reflecting any origin, no brute-force limit on
the sole account, and no password rules. setup.sh writes it, but .env.example
never mentioned it.
The three now share IS_DEV_BUILD, which is true only when PUBLIC_BUILD_ENV is
explicitly "dev" or "development". Anything else, including unset, is hardened.
Documented in .env.example.
Co-Authored-By: Claude Opus 5 <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>
Collapse the two-phase bootstrap (email a verification link → verify screen) into
one direct step: the Bootstrap form collects name/email/username/password and posts
once to /bootstrap, which creates the first user directly as an active Super Admin
(+ provisions DATA_PATH/<email>). Still gated to an empty user table.
The invite flow (/verify, /verify-token) is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the multi-user provisioning leftovers (the provision-existing-users.sh
migration was already deleted in the prior commit):
- gut provisionVncEnv from provision.ts (per-user startxfce4 virtual desktop, dead
since the switch to mirroring :0 — vnc-manager.ts self-provisions its own passwd)
- drop the Pi `.pi/agent/sessions` seed and the now-orphaned `run` helper
Wire provisioning into bootstrapHandler: the super admin (first user) is created via
createUser, which never called provisionUserEnvironment — only the invite/verify flow
did. So the single user's DATA_PATH/<email> was never provisioned up front. Now it is.
Co-Authored-By: Claude Opus 4.8 <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>
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>
Simpler + more efficient than the previous per-turn GET /session: the client already
has the selected cwd, so it now sends it on every message (it's constant for a session).
The server uses msg.cwd directly for OpenCode's per-turn working-directory system prompt,
and still tags the cwd at creation for listing. Drops the getSession round-trip.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
msg.cwd only rides the first message, but OpenCode's system prompt is rebuilt every
turn — so turn 2+ reverted to general_chat_sessions and the model fell back to the
server's real cwd. Fix: the cwd is bound at creation (metadata.officer.cwd), so for an
existing/resumed session read it back (new client.getSession) and reuse it for the
system prompt every turn. Also make the prompt explicit that it overrides any other
working directory the environment reports.
Verified: a session created with a cwd reads the same cwd back on a later turn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both harnesses now use the resolved chat cwd as their working directory: Claude runs
in it natively, and OpenCode is told the same via its system prompt. Removes the
earlier special-case that pointed OpenCode at the user's home for /chat, so `workingDir`
collapses into `cwd` — which now both tags the session (metadata.officer.cwd) and drives
the Officer system prompt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
OpenCode has no per-session `directory` — a session inherits the server's cwd (POST
/session ignores extra fields). So the previous "pass directory on create" was a no-op
and every OpenCode chat ran in the fixed server's dir (~), including the email chat.
Fix: hybrid server model. server-manager.ensureServer(cwd?, home?) returns the fixed
pm2 server (OPENCODE_SERVER_URL, :4096) for the general /chat, but for a context-scoped
cwd (email account dir, project dir) it spawns/pools an `opencode serve` rooted at that
directory — so the session's agent actually operates there. send-opencode passes the
resolved cwd + the user's home; createSession drops the ignored directory param.
Verified live: a cwd-scoped server reports the session directory as the target dir
(not ~), while /chat still uses :4096.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The email chat's working directory now resolves to
DATA_PATH/<owner>/email_accounts/<accountEmail> (created if missing), so the agent
operates in the selected account's dir (emails.db, attachment_cache, …).
- websocket.ts: new resolveChatCwd — context 'email' → the account dir (via a new
resolveEmailCwd), 'chat' → the pwd/claude_sessions dir, else the given cwd. Both the
Claude and OpenCode handlers use it. The account defaults to the owner's first enabled
account for now; the account selector will pass it as contextId later.
- OpenCode honors the cwd again: send-opencode passes it as the session `directory`
(client.createSession(directory?)) for context-scoped chats; the general /chat still
omits it and uses the fixed server's default project. Verified against the live server
that directory-bound sessions create + list.
No frontend change — the /email panel already sends context:'email'.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The per-cwd `opencode serve` spawning is replaced by a single fixed server
(http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as
`officer-opencode` in ecosystem.config.cjs (cwd = home).
Root-cause fix for the empty model selector: list-models shelled out to
`opencode models`, which failed at runtime on the deployed server (the picker got
only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP —
11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up.
- server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check.
- client.ts — createSession no longer binds a directory (sessions live in the one
server's project).
- send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the
cwd/home plumbing. Session list/load/delete/rename now hit :4096.
Verified end-to-end against the live server: model list, streaming turn, session
list, and transcript load all work with no spawning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/chat's session list, transcript load, delete, and rename now span both harnesses.
- opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never
the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the
same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory-
scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps
user/assistant/tool parts and drops reasoning (parity with the delta filter).
- client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*.
- chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and
/title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional
`harness` tag.
- send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id,
reuse that OpenCode session instead of creating a new one.
- SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions.
Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript
rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
Deletes all session persistence that isn't Claude's native transcript store, per
the "only harness-native session management survives" rule.
Backend: delete api/pi/storage.ts (meta.json+messages.json file store), the
api/saved-sessions router (+ unmount), the /pi/sessions REST endpoints, and the
storage.save/loadSession calls in the chat WS handler (in-memory session-manager
stays for live turns; no disk persistence — Claude's transcript is the record).
Also drops the Postgres saved_sessions layer: schema/chat.ts, queries/saved-sessions.ts,
its types and re-exports.
Frontend: delete state/useSavedSessions, ChatList, and the ChatHistory Widget
(all pure saved-session UI); slim ChatHeader to a label; strip the auto-load-latest
+ Save wiring from ChatPanelWrapper and ChatDetailPanel; drop the old resume path
from useChat and SessionListPage; remove the /chat/saved/:id route and the
useInitialData prefetch.
Behavior removed (intended): the Save-session button, email/project panels
auto-resuming the last chat, and /chat/saved/:id. /chat itself is unchanged —
already fully on Claude transcripts. The orphaned saved_sessions Postgres table
is dropped on the next `bun db:push`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bun closes a WS idle for idleTimeout (60s) and only resets its timer on frames
received from the client — but during a chat turn the client only receives, so a
long tool call or a gap between turns tripped the timeout (clean close 1000,
mid-stream cuts, reconnects). Now each chat connection is pinged every 25s in the
WS open handler (cleared in close); the client auto-pongs at the protocol level,
resetting Bun's timer. Covers every client (web, mobile) with one server change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>