Stage 2, and the end of the inversion. The two sync handlers (1,093 lines) ran in the
platform's queue, which meant the sidecar reached back over its registration socket to ask
the platform to enqueue work, and the credentials travelled through Postgres job metadata to
get there. Option (A) from the plan: they run here now, and the Jobs screen is left to the
things it actually describes.
The handlers moved almost unedited. Their bodies were already a list of steps taking a
context, so sync-runner.ts synthesizes that context and runs them; what went away is the
JobHandler wrapper and the registration. `job.userId` is the OWNER'S EMAIL rather than a
numeric id — the queue's naming — and it resolves the mail store path, so it is called out
in the type. That is the same field whose absence made the mailbox read as empty two commits
ago; it is set from user.email and checked this time.
Deliberately not a queue: one run per account, no persistence, no retry. A failure is picked
up by the ten-minute cron like any other, and a sync interrupted by a restart resumes from
the stored cursor rather than the beginning. PermanentError survives as a local class — it
signalled "do not retry" to the queue and now just carries its message to the sync state.
accounts.ts asks the runner whether an account is syncing instead of scanning job rows, and
the queue-over-WS shim in index.ts is gone: enqueueViaWs, listJobsViaWs, the pending-response
map and the queue branch in the command handler. Nothing but a port crosses that socket now.
The three chat channels stop opening the mail store directly. They each carried their own
copy of count-rows / enqueue / poll / count-again, coupling three chat bridges to the mail
schema — and they enqueued `gmail-sync` unconditionally, the OAuth path, for an
app-password account that syncs over IMAP, so the command was already broken. One shared
helper calls a new POST /sync-now on the sidecar, which syncs and reports what arrived.
queue/handlers/ is now empty; both handlers there were email. The queue is untouched and
still serves the Jobs screen.
Not moved, and fine where they are: scripts/migrate-emails-to-sqlite.ts and
scripts/seed-imap-uids.ts are one-off maintenance scripts that open the store directly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mailbox read as empty after the migration — "No emails synced yet", and Sync Now
reporting no new mail against 18,430 messages that were sitting on disk untouched.
The store is keyed by the owner's email: DATA_PATH/<owner>/email_accounts/<account>/emails.db.
The platform's userMiddleware put the entire user row on the context, so `user.email`
resolved. The proxy injects only an id, and the middleware I wrote to replace it set
`{ id }` and nothing else — so `user.email` was undefined, the path never resolved to the
real mailbox, reads found nothing, and the sync compared against an empty set and concluded
there was nothing new. Nothing was written to the wrong place and no data was touched.
The sidecar loads the full row via getUserById now, matching what the middleware provided.
Cached: it runs on every request and single-user is a hard invariant.
Worth keeping in mind for the sidecars still to be extracted — moving routes across a
process boundary silently changes what is on the context, and it type-checks either way.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Email was the one sidecar built inside out. The platform held ~1,800 lines — the per-account
SQLite store, all 14 HTTP routes, account CRUD, resync, IMAP validation — while the 314-line
sidecar was a scheduler that reached BACK into the platform to do anything
(`import { performResync } from '../../api/email/resync'`).
The sidecar now serves its own HTTP listener and announces `email:server`, and
/api/email/* on the platform is createSidecarProxy like every other one: 1,801 lines down
to 22, with no mail knowledge left in it — not a message, not a folder, not a credential.
The routes moved verbatim, Hono and all. http.ts only reconstructs what the platform's
middleware used to provide: `user` on the context, from the X-Officer-User header the proxy
injects (trusted because this server binds loopback), and an error handler that turns
custom-errors into status codes.
The /email/events SSE stream went with them, which removes a whole round trip: the IDLE
watcher used to send `email:new` over the registration socket so the platform could push to
its SSE clients. Those clients are here now, so it calls broadcastEmailNew in-process and
`email:new` is gone from the wire protocol.
DELIBERATELY NOT DONE YET, and left backwards on purpose rather than half-moved:
- The two sync handlers (email-sync 381 lines, gmail-sync 712) still run in the platform's
queue and now import the store from its new home — a platform → sidecar import, which is
the wrong direction and is temporary. Moving them is option (A) from the plan: the sidecar
schedules its own syncs, independent of the platform Jobs list.
- accounts.ts still imports queue/init to enqueue a sync and to report sync status, and
index.ts still carries the queue-over-WS shim that inversion needs.
- The three channel handlers still open the mail store directly rather than asking over HTTP.
Two things worth knowing while testing: a from-scratch sync holds a proxied request open
well past the 60s idle default, hence timeoutSeconds on the proxy; and `gmail-sync` is
hardcoded in all three channel handlers even though the only account is provider=gmail with
auth_type=password, which routes to IMAP — so "sync emails" from a chat channel is
almost certainly already broken, and folds into the next stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createSidecarProxy arrived with the wallet but nothing else moved onto it, so five sidecars
still carried their own copy of the same two files: a sidecar-server.ts that remembered a
port announced as `<name>:server`, and a router.ts that forwarded the subpath. Byte for
byte identical once the app name was normalised away — which is exactly what the factory's
own header said it existed to end.
headscale, transmission, invoiceshelf, slskd and music are now wallet-shaped: create the
proxy, export the router and the URL getter. 386 lines deleted against 163 added, and the
five feature directories go from ~70 lines each to ~18.
Two deviations were real and moved INTO the factory rather than being dropped, because both
are HTTP concerns rather than app knowledge:
- Range and If-None-Match are now forwarded for every sidecar. music needed both (seeking,
and ETag revalidation returning a cheap 304 instead of a cover image) and slskd needed
Range. Forwarding them everywhere costs nothing and removes the reason to hand-roll.
- timeoutSeconds, used only by music at 1800. A from-scratch reindex holds the proxied
connection open for minutes with no bytes flowing, which the 60s idle timeout would drop.
It applies to the whole prefix — the proxy must not know which of a sidecar's routes are
slow.
The five side-effect imports in hono.ts are gone with them: the port listener now registers
when createSidecarProxy runs inside the router this file already imports. Vault keeps its
hand-rolled pair and its side-effect import — it is off-limits by standing instruction, and
is the one sidecar this commit deliberately does not touch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Terminals were a set of commands the platform drove. Officer sent pty:init / pty:input /
pty:resize / pty:close / pty:list over the registration socket, subscribed to ONE global
output stream, filtered every frame down to a session and rewrapped it — double
JSON-encoded — on the way out. That is terminal knowledge living in the process whose job
is authentication, and it made officer part of the data path for every keystroke.
The sidecar now serves its own loopback HTTP + WebSocket listener and announces the port
as `pty:server`, like every other HTTP sidecar. Officer authenticates the upgrade and
relays frames without reading them.
Split into three files, because "the sidecar" was one:
- sessions.mjs — the shell store. Spawn, attach, detach, resize, kill, scrollback, the
OSC-title scrape. Clients are a Set per session, so two panels can watch one shell.
- server.mjs — the listener. /ws speaks the browser's existing contract unchanged
({input,resize} in, {output,replay,exit,panel-refresh} out), plus /_officer/sessions,
DELETE /_officer/sessions/:id and POST /_officer/panel-refresh.
- index.mjs — the registration socket, and nothing else. It carries a port now.
On the platform side /api/terminal/* becomes createSidecarProxy, deleting the hand-rolled
router from two days ago, and websocket.ts drops from a translating bridge to a byte relay
modelled on the vault one. The whole PtyCommand/PtyEvent/PtyInitConfig/PtySessionInfo
vocabulary is gone from protocol.ts, connect.ts and sidecar-registry.ts.
broadcastPanelRefresh is now a POST to the sidecar: officer no longer holds terminal
sockets to loop over. Fire-and-forget — a missed refresh is a stale panel, not a failure.
The frontend did not move. The sidecar speaks what the browser already spoke.
The integration test was rewritten against the new shape, and tests something stronger than
before: officer is stopped mid-session and the shell keeps streaming, because officer is
not in the path at all. It also covers re-attach replay, the session list and kill.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Tmux panel typed bare `tmux`, which starts a NEW session every time — so the panel
forgot your windows whenever the shell behind it went away, including on a
`pm2 restart officer-pty`. It now runs `new-session -A -s <name>`, which attaches if the
session exists and creates it otherwise, named per panel from panelId (stable in the saved
layout). The tmux server outlives the pty, so this survives what a pty session cannot.
CommandTerminalWrapper had the same unmount bug TerminalWrapper did — it deleted the panel
-> session mapping on unmount, so every layout change abandoned the shell AND re-ran the
command from scratch. Kept across unmounts now, same as the plain terminal.
Two things the seeded .tmux.conf needs that were missing:
- COLORTERM=truecolor in the pty env. TERM only advertises 256 colours, and COLORTERM is
what programs check before emitting 24-bit — so we were throwing away colour depth for
tmux, neovim, bat, delta and any modern TUI. xterm.js renders it fine.
- macOptionIsMeta. On macOS Option is a compose key, so Alt bindings never reach the shell
— silently breaking the M-arrow and M-hjkl pane switching the config leans on. No effect
on other platforms. Also rightClickSelectsWord, so right-click stops opening the browser
menu over the terminal.
Adds a Running Shells panel: every shell the sidecar holds, with the title it set for
itself (usually the running command), pid, size, idle and uptime, and a kill button.
That is the other half of keeping session mappings across unmounts — the leak stops being
invisible, and stops needing curl to find.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing a terminal panel abandoned its shell. TerminalWrapper deleted the panel -> session
mapping on *unmount*, so any layout or route change generated a fresh uuid on the way back
and left the old shell running: alive, unreachable, and never killed, because nothing has
ever sent pty:close. The mapping now outlives the mount, so reopening a panel re-attaches
to the shell you left — which is also what finally makes the sidecar's replay buffer worth
having. It is persisted dashboard state, so this survives a reload too.
That trades an invisible leak for a visible one: a panel deleted for good still leaves its
shell behind. So `pty:list` now enumerates live sessions, and GET /api/terminal/sessions +
DELETE /api/terminal/sessions/:id expose them. pty:close finally has a sender.
Each session carries createdAt, lastActivityAt, pid, and the title the shell sets for
itself via OSC 0/2 — usually the running command, which is what turns "some uuid" into
"the one running claude" when you are deciding what to kill.
Killing on unmount is still not an option: it needs the panel system to distinguish a real
close from an incidental remount, which it cannot currently do.
Also raises the sidecar replay buffer from 50KB to 512KB — 50KB was about one long agent
turn, so reconnecting mid-task showed you the tail and nothing before it — and cuts the
buffer on a line boundary rather than a byte offset. A blind slice can land inside an
escape sequence, and the replay then opens with the tail of a colour or cursor-move code,
which xterm renders as garbage or applies as a real instruction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The terminal was xterm with one addon (fit) and a single fit() call at connect, so the
shell kept whatever cols/rows it was born with. Drag a panel wider and the PTY never
heard about it — anything drawing a full screen (an agent TUI, top, vim) rendered into a
box that no longer matched the one you were looking at. A ResizeObserver now refits and
sends pty:resize, coalesced to one fit per frame because dragging an edge fires
continuously and each fit reflows.
Scrollback goes from xterm's default 1000 lines to 10,000 — one long agent turn was
enough to lose the start of it.
Addons, all off the shelf and none previously loaded:
- webgl — the renderer, and the reason fast repainting feels smooth rather than syrupy.
Guarded twice: construction can throw where there is no GL context, and the context can
be lost later, so both paths fall back to the DOM renderer instead of a dead canvas.
- unicode11 (+ allowProposedApi) — correct widths for emoji, CJK and box-drawing. The
default unicode 6 tables are why agent TUI frames sit a column out.
- web-links — URLs in output are clickable.
- search — with a find bar on Ctrl/Cmd-Shift-F. Not plain Ctrl-F: that is forward-one-char
in readline and emacs, and swallowing it would break every shell in the app.
- serialize — installed for exact-state replay, not yet wired.
Also reports the shell's own title (OSC 0/2) and the bell through new optional callbacks,
so a panel can show what is running in it and flag a finished turn you weren't watching.
Nothing consumes them yet.
Unrelated but found by this run: three origin-validation tests were failing. Not from this
change — ALLOW_ANY_ORIGIN=true in .env, added last night, and Bun loads the host .env into
tests, so the kill switch had silently turned the assertions into no-ops. The test now pins
both escape hatches off, since its whole job is asserting the checks reject things.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deletes the last of the projects/apps cluster: the published-app store
(/api/apps + /api/app-serve), the project dev-server and its websocket
proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite
they were the only consumers of, and their frontend — the Preview panel,
the UserApp panel/header, useUserApps and the /settings/apps screen.
also drops getUserProjectsDir and getUserAppsDir, the ProjectType and
ProjectDefinition types, and the 'dev-server' websocket provider from
server.tsx. nothing on disk is touched.
1440 deletions, 31 insertions. tsgo clean.
eight sidecars hand-rolled the same port capture — byte-identical once
the app name is normalised — and six repeated the same auth-and-forward
router. createSidecarProxy collapses both into one call and covers the
variants the others need: a ws:// url for music and vault, an onRegister
hook for opencode.
the wallet adopts it first: two files become one, 54 lines of router
become 16, and hono no longer needs a side-effect import to capture the
port. the no-body-parsing, no-body-logging rule moves into the factory
with its rationale, since that restraint is what keeps unlock
passphrases and macaroons out of the platform process.
costs 31 net lines today and pays back from the second adopter on.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
covers both encryption layers, what each one does and does not protect
against, the watch-only-while-locked property and the unlock session
rules. records the known limits: the heap cannot be reliably wiped, the
storage key is derived with a plain sha-256 rather than a kdf, and
rotating VAULT_STORE_KEY has no migration path.
also corrects the changePassphrase doc comment, which claimed rotation
never touches the dek. it mints a fresh salt, dek and ivs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.
officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.
no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.
not reviewed line by line; assembled and verified to build, not audited.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a /invoices workspace screen replicating the parts of the invoiceshelf web ui
that are actually used: dashboard, invoices, estimates, recurring invoices,
payments, expenses, customers, items and reports.
one document editor serves invoices, estimates and recurring invoices — they
share the line-item table, the discounts, the taxes and the totals, and differ
only in a header strip. the arithmetic is a port of upstream's
use-document-calculations, rounding points included, because the server
re-validates the totals it is sent. money is integer minor units throughout and
is converted to major units at the edges only.
selection and editor state live in the url (`/invoices/:section`, `?selected=`,
`?edit=`), so the nav highlight is derived rather than held, back closes an
editor instead of leaving the screen, and a half-written form survives a reload.
sending a document is behind an explicit confirmation — it emails the customer.
built against the running 2.4.2 instance rather than the 3.0 checkout in
_references; the two differ materially. typechecked, not yet exercised in a
browser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
owns the invoiceshelf contract: instance url, sanctum token and the company
header that scopes every request. the platform side is the usual thin
auth+forward proxy at /api/invoiceshelf and holds no credentials.
built against the live 2.4.2 instance rather than the 3.0.0-alpha.1 checkout
in _references — the route allow-list came from artisan route:list on the
running container. they differ: 2.4.2 has estimates/{id}/convert-to-invoice
but no invoices/{id}/convert-to-estimate.
three upstream quirks absorbed here:
- accept: application/json is mandatory, or an unauthenticated request 302s
to an html login instead of returning 401
- origin/referer must never be sent, or statefulapi() switches to session+csrf
and every request 419s. the proxy forwards neither.
- a wrong company header does not error, it silently returns another company's
data. the pinned company is explicit and logged.
document pdfs are repaired: 2.4.2 prefixes them with a literal serialised http
response (201 bytes) inside a body already typed application/pdf. we slice to
the %PDF- magic. the report routes don't have the bug.
resources are an allow-list. backups, disks, modules, update/*, installation/*,
mail config, settings writes and ownership transfer stay unreachable, and the
per-resource action list keeps `send` — which really emails the customer —
from being reachable by accident.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.
/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.
phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
sections were held in a usePanelChannel, which is exactly the opaque
click the navigation audit catalogues: the id lived in an onClick
closure, so a section could not be linked to, opened in a new tab or
reached with the back button.
/headscale/:section is now the source of truth. nav items are real
NavLinks with the active class coming from the router rather than
derived in js, and the screen redirects bare or unknown sections to a
canonical url so the highlight always matches the address bar.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the dot showed amber for the active server until you pressed test,
which read as a warning while every other section on the screen was
loading fine. probe the active one on mount so the dot always states
something checked.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
domain routes in the sidecar: nodes with per-route approval done as a
read-modify-write (headscale's approve_routes replaces the whole set),
users enriched with node counts, and pre-auth keys.
pre-auth key secrets are revealed by call path, not by inspecting the
value. headscale masks keys created since 0.28, but returns older
plaintext ones in full from the list endpoint for backwards
compatibility, so listing would otherwise ship live secrets into the
browser's query cache. the list always nulls the secret; only creation
reveals it, and the ui shows it once with a copy affordance.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-headscale owns the whole Headscale contract: the registered servers and
their admin api keys, the >=0.29 version floor, and every multi-call composition
the ui needs. the platform side is auth+forward only and holds no headscale
credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars
are untouched and unrelated.
officer manages many servers rather than one. the owner registers each with a url
and a key generated on that server and switches between them; exactly one is
active, enforced by a partial unique index rather than by convention. keys are
encrypted at rest and never leave the sidecar — the list projection cannot return
one. registration validates before it saves: an unauthenticated GET /version to
prove something headscale-shaped is there and meets the floor, then an
authenticated call to prove the key works. an edit that moves either half
re-validates.
there is deliberately no transparent /api/v1/* passthrough. headscale serialises
every uint64 as a json string and its rest shape moved repeatedly below 0.29;
proxying raw would push all of that into the browser, which is the mistake the
soulseek panels made with 37 raw upstream calls.
the /headscale workspace is nav + view over the panel system. only the servers
section is implemented — nodes, users and pre-auth keys say so plainly rather
than rendering an empty table that reads as a failed fetch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The /api/music exemption was not enough on its own: an app has to reach /api/auth to sign
in before it ever calls its own feature, so the login was still 403ing on Invalid origin.
ALLOW_ANY_ORIGIN=true now accepts every Origin everywhere and skips the per-origin path
scoping. Set on this host; ALLOW_ANY_ORIGIN_MUSIC stays as the narrower option.
The account backstop is deliberately NOT disabled — a valid non-owner token is still
confined to /api/auth + /api/music whatever Origin it claims, because that rule is
account-based rather than origin-based, and it is the airtight half of the pair. Every
protected route still requires a valid token.
Reverting is an env edit and a restart. Both flags and their call sites come out when the
tailnet becomes the perimeter.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Off unless ALLOW_ANY_ORIGIN_MUSIC=true, so it is opt-in per host and reverting is an env
edit rather than a deploy. Set on this host now; the flag and the two call sites come out
when the tailnet becomes the perimeter and the music app stops needing to be public.
It drops one layer, not the lock: /api/music still requires a valid token through
userMiddleware, and the non-owner account backstop in originScopeMiddleware still confines
music accounts to /api/auth + /api/music whatever Origin they claim. Worth being plain
about what is lost — Origin was never authentication here. `officer://<hex>` is chosen by
the client, trivially forged outside a browser, and extractable from any shipped app
binary. It is defence in depth, and this removes it for one path prefix.
Two gates had to know: userMiddleware, which is what actually 403s, and the CORS origin
callback, which would otherwise echo an empty origin and block a browser client that
userMiddleware had already allowed through.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each app origin was declared three times — destructured from process.env, listed in
APP_ORIGINS, then given a rule — so adding an app meant editing the same file in three
places and remembering all three. Two apps in and the list was already wrong: OffTail had
been added to .env as OFF_SCALE_ORIGIN, which nothing read, so it did nothing.
Any `OFFICER_<APP>_ORIGIN` in the environment is now an app origin, and its rule comes
from its own name: an app may reach /api/auth (it has to sign in) plus the one feature it
is named for, so OFFICER_VAULT_ORIGIN gets /api/vault without being told. Adding an app is
adding an env var.
Two things stay hand-written because they are not that convention:
- OWNER_ONLY_APPS ('APP') — the main app is the whole platform rather than one feature of
it, so it keeps full access but owner-only. A different kind of rule, not a path scope.
- APP_SCOPE_OVERRIDES ('TAIL' → /api/vpn) — the one app whose name and API surface differ.
NON_OWNER_PATHS also stays, and is worth not mistaking for the music app's rule even
though the value matches: it is the ACCOUNT backstop, applied whatever Origin a caller
claims or omits, and it is the airtight half of that pair.
Also renames MUSIC_APP_ORIGIN to OFFICER_MUSIC_ORIGIN (the other three already had the
shape) and drops the dead OFF_SCALE_ORIGIN. Env and code changed together; .env on this
host is updated, so this needs a restart, not a migration.
Verified the derived rules against the live .env: APP → superAdminOnly, MUSIC →
auth+music, VAULT → auth+vault, TAIL → auth+vpn — identical to what was hardcoded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The in-app Tailscale needs one thing from the platform: a way to turn an authenticated
Officer session into a Headscale pre-auth key, so the phone registers itself instead of
someone pasting a key by hand. The VPN's control and data planes talk directly to
Headscale — never through /api — so this is not a proxy and should not become one.
Headscale itself runs on a separate host, managed manually. The platform consumes
HEADSCALE_URL (also returned as controlUrl) and HEADSCALE_API_KEY, both from the host
env. Neither is set yet, which is why an unconfigured instance answers 503 rather than
crashing — Headscale is being stood up in parallel.
The response is `{ controlUrl, authKey }` exactly, because enrollVpn() in
@officer/core/officer-net.ts reads those two fields; changing the shape means changing the
app.
Two things the spec's sketch does not do:
- The `user` field changed meaning across Headscale versions — a name on <=v0.22, a
numeric id on v0.23+ — and we cannot see which one is running from here. So it resolves
the id via /api/v1/user and tries that first, falling back to the name. Whichever the
live server accepts wins, and neither version needs a config flag.
- Upstream calls carry a 10s timeout, and upstream error bodies are logged but never
returned to the client: that is an admin API and its errors are descriptive.
The standalone app's origin follows the platform's own convention rather than the spec's
literal: every other app origin is an env var with a scope rule, so this one is
OFFICER_TAIL_ORIGIN (set in .env on this host), restricted to /api/auth + /api/vpn the way
OffVault is restricted to /api/auth + /api/vault. The OffTail tile embedded in the main
Officer app needs nothing — it reuses OFFICER_APP_ORIGIN.
Not implemented: the optional GET/DELETE /api/vpn/devices. They are not needed for a first
connection and are better written against a running Headscale.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Being retired, so it is deleted rather than fixed — it had both defects dashboards just
had (edit dropping ?selected=, an abandoned edit following you onto the next item) and
repairing them was work for something on its way out.
Gone: the two screens and the panel apps, the three routes, the dock item and its entry
in the default dock paths (client and the three server-side copies), the page-title rule,
the app-registry entries, the barrel exports, the `projects` table with its queries and
row types, and the proj-meta/proj-layout/proj-terminals/proj-host-terminals branches in
the dashboards endpoint. The chat context and terminal state-key special cases for
`proj-layout-` went with them.
Deliberately kept: `getUserProjectsDir` in data-path.ts — the apps and dev-server routers
resolve user apps under the same on-disk Projects/ directory and are unrelated to this
feature. Nothing on disk is touched.
Needs `bun db:push` to drop the table; the schema change is the only thing standing
between the code and the database. One row was in it.
tsgo clean, 56/56 tests, no references left in src. Not yet exercised at runtime — the
restart is what will prove it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking edit dropped ?selected= from the url, so the address bar forgot which dashboard
you were editing — and on mobile that is worse than cosmetic: DashboardsScreen uses
`selected` to decide whether to show the right-hand panel, which is where the form
renders, so tapping edit opened it out of sight.
It cleared the param because it had to. The form was only reachable through
DashboardPreviewEmpty, which the preview renders when nothing is selected, so an edit
could only be shown by first pretending nothing was selected. DashboardPreview now checks
creating/editing itself, before it resolves the selection, and the empty state loses its
duplicate of that check — having two of them is what made the first attempt at this look
safe when it removed the only route to the form.
The form is shown only for the dashboard actually selected (`editingId === selectedId`),
and picking a row clears creating/editing. Without both, an abandoned edit followed you
onto the next dashboard: the url said you were on B while the form still edited A. The
click handler covers the ordinary path and the guard covers back/forward and pasted
links.
Verified in the browser: D5, D7, D8, D9 — edit keeps the param and opens the form, save
hands back to the preview of what you edited, switching dashboards mid-edit abandons it
cleanly, and returning to the first one shows its preview rather than the stale form.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking any link shifted the whole app up by ~70px: the top of the content slid behind
the fixed header, and the nav dock — which should stay hidden until you reach for it —
appeared at the bottom. It survived navigation and only a full reload cleared it.
`overflow-hidden` still makes an element a scroll container; it only hides the scrollbar.
The nav Dock is absolute inside this section and hides itself with
translateY(calc(100% + 24px)), and a transformed descendant contributes its transformed
box to the scrollable overflow area — so the section had ~70px of scrollable range it was
never meant to have. Clicking a link moves focus, the browser scrolls the nearest scroll
container to reveal the focused element, and everything inside (all of it absolute
inset-0) went up with it, the parked dock included.
overflow-clip clips identically but does not create a scroll container, so there is
nothing for focus-into-view to scroll.
Diagnosed from the running app rather than by reading: the section reported
scrollTop=70 at the moment the layout was wrong, matching the 70px the content and the
music bar had moved, while innerHeight/visualViewport stayed at 556 (ruling out the
dvh/URL-bar theory) and the outer div's scrollHeight equalled its clientHeight (ruling
out the shell). Confirmed fixed in the browser.
The outer div at :21 is also overflow-hidden and is left alone — it has no scrollable
overflow, so it cannot exhibit this.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Brings in the five per-sidecar branches that were integrated on `sidecars`: the agent
split out of officer's process tree (officer-anthropic-proxy + officer-agent as PM2
peers), turn output translated and committed by the sidecar that produced it for both
claude and opencode, the pty reconnect fix and its move under sidecar/, the vnc
password read and desktop reattach, the cliamp audio pipeline moved into the music
sidecar, and the email folder-filter SQL injection.
Verified live before merging: all ten PM2 entrypoints start clean, the agent resolves
the owner from the database and the proxy secret from disk, and a real turn round-trips
through the new path — the sidecar writes chat_session_events itself and `prevSeq`
chains correctly across messages.
One conflict, docs/navigation-audit.md (add/add): the copy on `sidecars` was an earlier
snapshot swept in by 62dc4c1, and master's is the same document with H1-H3 marked done.
Took master's wholesale — nothing was lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mirror of the dashboards change: the /projects list row was a <div onClick> setting
SELECTED_PROJECT (a global) with no url change on-page. selection now lives in the url
as ?selected=<id>, read by the list, the screen (mobile panel), and the preview. rows
are real <Link>s (publish/edit/delete kept as sibling buttons); edit-save and delete
update/clear the param; NewProjectRedirect drops its now-redundant setSelected.
NOT runtime-tested yet (server not restarted) — verify /projects preview,
create/edit/delete/publish, and mobile panel flows on next restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the /dashboards list row was a <div onClick> that set SELECTED_DASHBOARD_KEY (a
global) with no url change on-page, so selection wasn't deep-linkable and the row
wasn't a real link. selection now lives in the url as ?selected=<id>, read by the
list, the screen (mobile panel), and the preview. rows are real <Link>s (edit/delete
kept as sibling buttons, not nested in the anchor); the preview + its "open" link are
unchanged. edit-save and delete update/clear the param.
NOT runtime-tested yet (server not restarted) — verify the /dashboards preview,
create/edit/delete, and mobile panel flows on next restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/email/messages and /email/stats built `labels LIKE '%${folder}%'` by string
interpolation, and `folder` comes straight off the query string. Five statements across
the two handlers were exposed. The fragment is bound now, and it carries its parameters
with it because each handler builds several statements from the same fragment and has
to spread them in order.
Checked against an in-memory table: inbox/INBOX/SENT/all return exactly what they
returned before, and `x' OR 1=1 --` now matches nothing instead of being SQL.
Also: page and limit reached the bindings as NaN for any non-numeric value, so a
mistyped query param was a 500. They fall back to their defaults now.
And deleted src/servers/sidecar/email-cron.ts — 92 lines imported by nothing. The live
cron is sidecar/email/email-cron.ts; this was an older copy that still reached into
queue-runner and google-auth directly, so leaving it there invites someone to fix the
wrong file.
This is the first commit on the email branch; the placement problems (the whole mail
store, both syncs, and the resync coalescing that cannot work across processes) are
untouched and much larger — see SIDECAR_WORK_LOG.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cliamp playback was implemented entirely in officer: it located the cliamp binary,
validated the requested path against the owner's home, faked a PTY with `script`,
injected PULSE_SINK and an ALSA config shipped inside the API tree, spawned parec to
capture the sink, and set up the pulseaudio daemon and the virtual_out null sink at
every boot — about 356 lines of audio-pipeline knowledge in a process that is meant to
be a proxy, and none of it owned by the sidecar whose whole job is music.
all of it now lives in sidecar/music: cliamp-ws.ts serves both sockets (/cliamp/ws for
the player, /cliamp/audio/ws for the PCM capture) on the loopback server it already
runs, pulse-audio.ts does the daemon + sink setup at sidecar startup instead of at
officer's, and the asoundrc moved next to the code that passes it. officer keeps the
part that is actually its job — authenticating the browser — and relays frames both
ways without reading them (api/cliamp/relay.ts, same dumb-pipe shape as the vault
notifications relay). the browser's frame contract is unchanged, so the frontend is not
touched.
two things fixed on the way: the traversal check now requires a separator after the
home path, so a sibling directory whose name merely starts with it can no longer pass;
and the music proxy no longer special-cases /reindex and /reindex/stream by name to
extend the idle timeout — it extends the whole prefix, because a proxy should not know
which of the sidecar's routes are slow.
the music-specific `files` query param is out of the shared WS envelope too: upgradeWs
now carries the raw query string, which any relayed provider can use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the row was a <button onClick={navigate(`/jobs/${id}`)}> with the id only in the
closure — no href, no cmd/middle-click. it's now a <Link>; the active-row highlight
already keys off useParams().id so nothing else changed. the stop/delete action stays
a button.
Co-Authored-By: Claude Opus 4.8 <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>
x11vnc mirrors :0 with `-forever`, so the desktop survives officer going away —
only the bridge socket dies. The panel treated that as the end of the session:
"Disconnected from desktop" until someone closed and reopened it. Retry with
backoff instead, and cover the password fetch too, since that is what fails
first while officer is still coming up.
Retries are generation-guarded so tearing an instance down cannot be mistaken
for a lost connection, and a security failure or a missing noVNC is terminal —
those do not come back on their own.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The password lives in the owner's ~/.vnc, and the sidecar is the process that
writes it — together with the rfbauth file x11vnc actually authenticates
against. Officer read the plaintext half directly and answered with it before
ever asking the sidecar, which is both a secret the proxy has no business
opening and a way to hand out a password that no longer matches: if `passwd`
went missing while `password` survived, officer kept serving the old plaintext
and the desktop refused every login. `vnc:ensure-password` reconciles the pair,
so ask it every time and delete the reader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
it was the only sidecar living outside src/servers/sidecar/ — it sat in
api/terminal/ next to the bridge that talks to it, which is the one place a reader
looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs,
matching every peer, with a note on the pm2 entry about why this one is node and
.mjs (node-pty is a native addon) rather than bun and typescript like the rest.
the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs,
which is now its only consumer — the sidecar's duplicate seeder went with the
sandbox branch in the previous commit. api/terminal/ is left holding exactly one
thing: the websocket bridge.
no behaviour change. the pm2 entry's script path changed, so `pm2 restart
officer-pty` is not enough — pm2 remembers the old path until the entry is deleted
and started again. commands are in SIDECAR_WORK_LOG.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
officer built the whole PtyInitConfig: it read the owner's SHELL (defaulting to
/bin/zsh), added `-i`, read their HOME, expanded `~` against it, and hardcoded
`host: true`. none of that is a proxy's business — the sidecar is the process that
calls pty.spawn, so it is the one that should know what to spawn and where.
the config now carries only what the bridge actually knows: sessionId, the folder
the panel was opened on, and the client's cols/rows. shell, args, home and cwd
resolution moved into the sidecar. home comes from HOME_DIR ?? HOME, mirroring
data-path.ts:getOwnerHomeDir — terminal was the one host-executing surface reading
process.env.HOME directly, which is identical here and divergent anywhere HOME_DIR
is set to something else.
deleted the bwrap sandbox branch rather than moving it. it was selected by
`config.host`, which officer hardcoded to true, so it never ran — and it expected
`shell` to contain a fully-built bwrap command that nothing on either side ever
built. it could not have worked. a terminal here is the owner's own shell on the
owner's own machine by design (platform/CLAUDE.md), so there is no jail to preserve.
its ensureUserFiles half duplicated api/users/provision.ts:seedShellConfigs, which
is the live seeder of those same templates and stays.
also deleted the 'cwd' handler that turned a message into `cd <path>\r` typed at
the shell. no frontend has ever sent that message — the browser composes its own cd
— so it was unreachable, and synthesizing keystrokes is not something a relay
should do.
the integration test pins SHELL and HOME_DIR now that the sidecar reads them, and
asserts the shell starts in the resolved `~` rather than officer having resolved it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the pty sidecar registered `term.onData` with the socket that happened to be live
when the session was created. officer is a pm2 peer that restarts constantly, and
every restart hands this process a brand new socket, so every pre-existing session
went on writing to a closed one — where sendJson's readyState check dropped it
silently. the shell survived and still accepted input, because input arrives on the
new socket, but nothing ever came back. you typed and the terminal sat there. the
only way out was to close the panel, which orphaned the shell.
sendJson now reads the module-level socket at send time instead of taking one as an
argument, so there is no socket to capture and go stale. that is the whole fix.
the scrollback replay on re-attach becomes its own event, pty:replay -> 'replay'
on the browser socket. it used to arrive as ordinary output, which was fine for a
page load (fresh xterm) but not for a restart: the browser keeps its terminal, so
replaying blind printed a second copy of everything still on screen. marked as
history, Terminal.tsx resets and rebuilds from the sidecar's 50KB buffer instead.
it also stays out of the `output` branch so it cannot re-trigger the command /
initial-input logic that scrapes output for a sentinel.
added an integration test, because this is a reconnect bug and nothing short of an
actual reconnect proves it: it stands up a fake registration socket, runs the real
sidecar against it, echoes into a real shell, kills the socket, rebinds the same
port the way pm2 does, and asserts output still flows. verified it fails against
the old sendJson (times out after 15s waiting for the post-restart echo) and
passes in ~400ms with the fix. it never touches the running officer — the sidecar
dials API_URL, overridden per spawn.
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>
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>
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>
Maximize was swapping the app into a createPortal(document.body) branch, so React
unmounted + remounted it — losing scroll, playback, and in-flight state on every
maximize/restore. Now it's a pure CSS state toggle on the same element (fixed,
filling the content region), so the app instance is preserved and content stays
exactly as it was. The panel is confined to the layout's z-2 content stacking
context (under the fixed nav Header at z-10), so the maximized box starts below
the header — keeping the panel's own header/Restore visible and the nav reachable.
Dropped the portal + placeholder and the now-unused createPortal import.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>