Commit Graph
100 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 aac20bf112 drop eight package.json scripts that point at nothing
Same origin as the phantom docs in the last commit — this architecture was based on another
project and some of its scripts came along without their targets.

- `build:editor` and its three variants run `scripts/build/editor.ts`, which has never
  existed here. CLAUDE.md carried a note saying exactly that, which the note now outlives.
- `start:sidecar` / `stop:sidecar` / `restart:sidecar` / `logs:sidecar` drive
  `systemctl … officer-pty-sidecar`, a systemd unit replaced by PM2. That unit still exists
  on this host, enabled, pointing at a `monorepo/` directory that is gone, and has been
  failing to start ever since — these scripts are how it stayed reachable.

CLAUDE.md's "Sidecar control" line pointed at the four systemd wrappers; it now says PM2 and
points at working-on-officer.md for which process a change needs restarted.

Verified afterwards that every remaining script's target exists. The rest of package.json is
untouched — the edit is nine lines out, no reformatting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 17:42:24 +00:00
pastilhasandClaude Opus 5 829b034d13 docs: fold the workspace-root docs into the repo, with honest status
The root held four Markdown files that were not in any git repo and were being read as
current. CLAUDE_SIDECAR_ISOLATION.md is the one that prompted this: it describes, in the
present tense, an agent that dies whenever officer restarts. That was true when it was
written and has not been true for two days.

Rather than delete analysis that version control was not holding, the two substantial ones
moved into platform/docs/ with headers that say what has since happened:

- claude-sidecar-isolation.md — stages 0-2 are done and running (R1, R2, R4, R5 all
  satisfied); stages 3-5 are the only live part.
- sidecar-audit-2026-07.md — a snapshot audit, largely executed. Email, pty, music and vnc
  have been done since; claude, opencode and the cross-cutting notes are still open. The
  vault stays off-limits.

MUSIC_IMAGES_SPEC.md is deleted outright: the sidecar serves /image and /poster, so the
spec is the feature. The workspace root now holds one Markdown file, CLAUDE.md, which is
where cross-cutting operational reality belongs.

Also corrected, in the same pass:
- root CLAUDE.md listed email as "the big one, and untouched" and pty stage 4 as
  outstanding; both are done. It now names what actually remains (claude stages 3-5, the
  terminal orphan leak) and what landed.
- docs/sidecar-topology.md said "nothing built yet". Two sidecars now serve their own
  transport; what has NOT happened is the part the document is about — fixed ports, the
  shared table, .env toggles — so it says exactly that rather than implying the design is
  underway. Migration order updated: pty and email went first, not music.
- docs/navigation-audit.md is cited as authoritative but still planned work on Projects,
  a feature since deleted. A status header marks H3 void, H1/H2 done and H4 the one open
  item, so nobody follows it into a directory that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:45:26 +00:00
pastilhasandClaude Opus 5 db9d17d6fe docs: the convention docs were describing a different codebase
Second pass. These three are the ones a new contributor reads first, and all three were
teaching things that are not true here.

src/databases/CLAUDE.md claimed "Three PostgreSQL databases", listed two, and there is
exactly one. Its type examples were Screenshot / Experiment / Company / GanOauth — none of
which have ever existed in this repo; it had been carried over from another project
wholesale. Rewritten against the real schema, queries and types, and it now carries the two
things that actually bite: push-not-migrations, and the rule that the schema is the source
of truth for what the database may CONTAIN, not just its shape — with the sql.raw trap in
check() written down, since getting it wrong breaks push for the whole schema.

src/apps/CLAUDE.md had the same problem in its examples (useExperimentsList, ExperimentCard,
a state/ directory layout that does not exist), listed a `useWebsockets` hook that is not
there while omitting useChatWebSocket, usePanelChannel and useJobs, and closed with links to
three app docs that have never existed. Examples now use real hooks, and it points at the
navigation audit — a frontend doc that did not mention the one rule the platform CLAUDE.md
calls authoritative was a real gap.

CONVENTIONS.md said, in bold, that useMemo and useCallback are "strictly prohibited" because
"React 19's compiler handles memoization automatically". Wrong twice: the React Compiler is
an opt-in build plugin that is NOT installed here, so React 19 memoizes nothing on its own —
and roughly 40 files use each hook regardless, including code added this week. Replaced with
guidance that matches both reality and the actual tradeoff, and says plainly what it used to
claim. A rule that is false and universally ignored makes every other rule in the file look
optional.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:42:47 +00:00
pastilhasandClaude Opus 5 04c0d89057 docs: bring the live docs back in line with the code
First pass of the documentation audit. Every doc was read against what the code actually
does now; this commit fixes the ones worth keeping and deletes the ones that were only
describing a past.

Corrected:
- CLAUDE.md — said seven WebSocket providers (there are eight, and terminal/vault are byte
  relays now, not translating bridges), listed channels/ as "Telegram / WhatsApp / Discord
  bridges" (they are gone; what remains is how /chat drives an agent turn), missed
  officer-wallet in the PM2 list and notify/ in the layout, and described the per-account
  email SQLite stores without saying they are the sidecar's and that nothing in the platform
  opens them. Further Reading pointed at four files that no longer exist and missed the four
  newest.
- docs/working-on-officer.md — PM2 list was four sidecars short, and it still explained the
  officer-claude rename as news. Replaced with the thing a reader actually needs: which
  process to restart for which change, and why restarting officer no longer costs you a
  terminal or an agent session.
- TODO.md — the "dead username plumbing" item was mostly resolved by deleting the channels,
  and two email items pointed at api/email/email-db.ts, which is sidecar/email/store.ts now.
- AGENTS.md — trailing paragraph listed the design notes being deleted here.
- MUSIC_API.md — playlists were entirely undocumented: seven endpoints the phone app has no
  reference for. Added from the sidecar's own contract.
- docs/jobs-unification.md — phases 1-3 shipped, so it now says so at the top. Phase 4 (push
  notifications) is the only reason the file still exists, and email sync is explicitly no
  longer part of it.

Deleted, all superseded rather than merely old:
- PHONE_APP.md — a February plan for apps that now exist, with their own repo and README.
- MARKETING_WEBSITE.md — a plan for a site this repo does not contain.
- SECURITY_AUDIT.md + SECURITY_FIXES.md — a February audit of a codebase since restructured;
  it still cites queue/handlers, which is now empty.
- docs/DOCKERIZATION_PLAN.md — cites pty-sidecar, whatsapp and projects, all deleted.
- SETUP_GUIDE.md — documents systemd units and setup scripts replaced by PM2 and `bun setup`.
  Not harmless: /etc/systemd/system/officer-pty-sidecar.service is still enabled on this host,
  pointing at a `monorepo/` directory that no longer exists, and has been failing to start
  ever since. That guide is how it got there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 16:11:29 +00:00
pastilhasandClaude Opus 5 066c118723 fix db:push emitting bound parameters in check constraints
bun db:push failed for the whole schema with "there is no parameter $1" (42P02), after
"Pulling schema from database" succeeded. It was not caused by any recent change — pushing
with the new wallet table stashed reproduced it identically on HEAD.

The two provider CHECK constraints built their allowed-value lists with sql`${p}` over
JavaScript strings. Interpolating a JS value into a sql template binds it as a parameter, so
the constraint was emitted as

  CHECK ("provider" IN ($1, $2))

and Postgres rejects a parameter reference inside a CHECK. sql.raw renders the literals
instead. Safe because both lists are compile-time const tuples, not input.

Push now generates valid SQL. It still stops on server_integrations, where three rows
(discord, telegram, whatsapp) predate the constraint and violate it — real data, and the
owner's to decide about, since their config holds bot tokens.

Unrelated but worth recording: drizzle wants to name a user_integrations foreign key at 65
characters and Postgres truncates identifiers at 63, so the name it reads back never matches
the name it asked for and that constraint is proposed for drop/recreate on every push.
Cosmetic churn, not addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:13:07 +00:00
pastilhasandClaude Opus 5 2eda855551 persist the wallet's view of the chain
Opening a wallet meant waiting for a full gap-limit scan before any number appeared, and a
restart threw that work away. Worse, an unreachable Esplora rendered identically to an empty
wallet — as a zero balance — which is alarming for the one case where it is not true.

The backend now keeps a snapshot and serves it stale-while-revalidate: a snapshot inside the
TTL is served as-is, an older one is served immediately with a refresh started behind it, and
only a wallet that has genuinely never been read blocks on the network. wallet_chain_cache
holds one row per wallet so a refresh is a single atomic upsert.

The snapshot, not the endpoint, is the unit of caching. Balances, UTXOs and history were
three fetches over a shared scan, so the three queries a wallet screen fires on mount could
each observe a different moment; building them together costs the same requests and fixes
that incidentally.

Only the chain's own facts are stored. Addresses, scripts and pubkeys are re-derived from the
account xpub on load — cheaper than persisting them, and it means a restored snapshot cannot
disagree with the wallet's actual keys. Stored coordinates are validated rather than trusted,
and a snapshot at an unknown version is discarded, not migrated.

Two reads deliberately opt out. sendCoins takes a fresh snapshot because selecting coins from
a cached UTXO set builds a transaction spending outputs that may already be gone, and that
failure arrives as a broadcast rejection after signing. nextUnused does too, because handing
out an address whose stale record says "unused" is silent address reuse — a privacy leak the
owner cannot see or undo. Receive-address generation is therefore the one read that stops
working while the upstream is down, on purpose.

Failures are recorded alongside the last good snapshot rather than replacing it; wiping data
on failure would reproduce the exact bug this exists to fix. Every cache operation is
best-effort, so a database problem degrades to a slow load and can never fail a wallet
request. A sync block on balances, transactions and utxos carries the age to the UI, which
now distinguishes "empty" from "never read".

Verified against three live mainnet wallets: snapshots persisted and reloaded, and two
wallets kept their data and age through a real Esplora rate-limit failure while recording the
error separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:57 +00:00
pastilhasandClaude Opus 5 bed8854206 stop wallet chain reads hanging on an unreachable esplora
A wallet whose Esplora endpoint was slow or down rendered as a wallet with no data at all,
rather than as an error. The cause was a timeout inversion: Bun.serve's default idleTimeout
is 10s, which is shorter than the Esplora client's own 20s per-request timeout. Bun killed
the response before the scan could either finish or report why it had not, so the failure
never reached the handler that would have surfaced it.

The ceiling has to sit above the whole gap-limit walk, not one request — a scan is many
sequential rounds of address queries. Raised to 255s, Bun's maximum, which is what every
other sidecar already uses.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:41 +00:00
pastilhasandClaude Opus 5 2647510965 import lnd aezeed cipher seeds alongside bip39
An LND seed is not a BIP39 mnemonic. It shares the 24-word shape and the English wordlist,
which is exactly why it fails confusingly: the words validate as plausible input, the
checksum does not, and the user is told their own seed is invalid.

aezeed is a different construction — a 19-byte payload (version, birthday, 16-byte entropy)
sealed with AEZ under scrypt(passphrase, salt), with the salt carried in the mnemonic
itself. So it needs its own decipher, not a flag on the BIP39 path. The vendored aez/aezeed
implementation under sidecar/wallet/aezeed does that, and the recovered entropy becomes the
BIP32 root the same way BIP39 output does.

The seed envelope gains a kind ('bip39' | 'aezeed') so an unlock knows which derivation to
run rather than guessing from word count, which cannot distinguish them.

Also note the aezeed passphrase is not a BIP39 passphrase: it decrypts the seed rather than
salting the derivation, so a wrong one fails the checksum outright instead of silently
producing a different wallet. The UI can therefore tell the user they typed it wrong, which
is not possible for BIP39.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:35 +00:00
pastilhasandClaude Opus 5 fb70c83ae7 auth: survive a request that carries no origin
Signing in from a server-to-server client returned 500. originMiddleware leaves `origin`
undefined when a request has neither Origin nor Referer — exactly what such a client sends —
and signin.ts took it as a string, passed it into getPasskeysByUserIdAndOrigin, and Postgres
rejected the undefined parameter. It would have thrown on origin.startsWith() a few lines
later too.

It used to be unreachable: originValidationMiddleware rejected origin-less requests before
the handler ran, so the value was always a string by the time anything touched it. Turning
the origin checks off removed that gate without the code behind it ever having needed to
cope. This is the second thing that flag has surfaced rather than caused.

The two call sites want different answers, so they get different ones:

- signin normalises to ''. No passkey is registered against the empty origin, so an
  origin-less caller gets an empty list and falls through to password auth, which is what it
  is asking for.
- the four passkey routes now refuse with 400 "Passkey operations require an Origin header".
  WebAuthn is defined in terms of an origin — a passkey is registered against one and is only
  verifiable against the same one — so substituting '' there would be quietly wrong.

Also flips ALLOW_ANY_ORIGIN to default ON: the checks are off unless it is explicitly
'false'. A deliberate inversion of fail-closed, safe because of where this runs — the
perimeter is the tailnet, devices are admitted by hand, and every protected route still
requires a valid token. Verified both directions: unset lets a foreign origin through,
ALLOW_ANY_ORIGIN=false rejects it. The origin test suite pins the flag off, so it still
asserts the checks reject things rather than passing vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:16:39 +00:00
pastilhasandClaude Opus 5 f9a07bb8ee schema: constrain integration providers to the ones the code supports
`provider` was free text on both integration tables, which is how rows for telegram,
whatsapp and discord went on holding bot tokens long after the code that read them was
deleted: nothing structural said they had stopped being legal, so nothing noticed.

A CHECK constraint on each table now lists what may exist — google/apify for the server,
google/browser-relay for the user. Adding an integration means adding it to the list, which
is the point: the schema is the source of truth for what the database may contain, so a
provider the code no longer supports cannot sit there unnoticed.

This does NOT delete the existing rows, and no schema change can — drizzle-kit push diffs
structure, never data. What it does instead is refuse: push will fail to add the constraint
while violating rows exist. That is the enforcement working rather than a problem to route
around; the three rows have to go first, and then the structure guarantees they cannot come
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:08:55 +00:00
pastilhasandClaude Opus 5 c9d680b133 remove the telegram and whatsapp channels, reduce discord to notifications
All three existed to drive the platform from a chat app. The phone app does that now, so
they are dead weight — three bot gateways, three command parsers, account pairing, admin
config screens and bot tokens sitting in the database.

Gone entirely: telegram/ and whatsapp/, discord/'s bot and command handler, the shared
channel plumbing they were the only users of (pairing.ts, send-and-await.ts, types.ts,
routes.ts and its 20 config/pairing/status endpoints), the six settings components, and
their sections in the integrations screen.

What Discord keeps is the one piece worth keeping — pushing a message out — as
notify/discord.ts, configured by DISCORD_WEBHOOK_URL in the env. No UI, no pairing, no
stored credential, and it never throws: a notification that fails to send is logged and
dropped. Unset means notifications are silently skipped, which is the default state.

Kept deliberately: send-claude-code.ts and send-opencode.ts. They live under channels/ but
have nothing to do with chat apps — they are how /chat and the pipeline executor drive an
agent turn.

Also drops four dependencies with no remaining importer (discord.js,
node-telegram-bot-api, whatsapp-web.js, qrcode), and the /sync-now route added to the email
sidecar an hour ago, whose only consumer was the channel handlers.

The "Channel Models" settings section stays, with its description corrected — it is keyed
off the general access policy rather than anything channel-specific, so it governs non-owner
accounts, not chat apps. Whether that whole class still earns its place is the open
question already noted against origin-validation.

Not touched: the telegram, whatsapp and discord rows in server_integrations, which still
hold their bot tokens. Deleting rows is a different kind of decision and the SQL is in the
handover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:05:28 +00:00
pastilhasandClaude Opus 5 e9e144962d email: the sidecar schedules its own syncs
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>
2026-07-31 13:33:46 +00:00
pastilhasandClaude Opus 5 06cd6bdca3 email: give the sidecar the whole user, not just the id
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>
2026-07-31 13:24:37 +00:00
pastilhasandClaude Opus 5 7d1212765b docs: test checklist for the email sidecar migration
Written to be run later rather than now, so it says what changed underneath and where a
failure will land: the routes moved verbatim, but the transport (browser → proxy → sidecar
HTTP) and the source of user identity (X-Officer-User instead of userMiddleware) did not,
and that is where breakage will cluster.

Includes the attribution table — 503 vs 502 vs 401 each point at a different half — and
flags the two things that are expected rather than wrong: sync still runs platform-side on
purpose, and the channel handlers' "sync emails" was already broken before this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 12:13:58 +00:00
pastilhasandClaude Opus 5 af56eb36ff email: move the mail store and every route into the sidecar
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>
2026-07-31 12:10:35 +00:00
pastilhasandClaude Opus 5 aaf0161620 put every http sidecar on the proxy factory
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>
2026-07-31 11:46:29 +00:00
pastilhasandClaude Opus 5 7129cd82e6 pty: the sidecar owns its own transport
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>
2026-07-31 10:54:35 +00:00
pastilhasandClaude Opus 5 fccf212fe5 tmux that actually persists, and a running-shells panel
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>
2026-07-31 10:40:11 +00:00
pastilhasandClaude Opus 5 875f240e1c terminals: re-attach on reopen, and make orphaned shells findable
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>
2026-07-31 10:30:02 +00:00
pastilhasandClaude Opus 5 dfb20a734e terminals: resize with the panel, and five xterm addons
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>
2026-07-31 10:26:57 +00:00
pastilhas 188b45c113 remove projects and apps end to end
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.
2026-07-31 07:39:29 +00:00
pastilhasandClaude Opus 5 13b7f56b0f add a factory for sidecar proxy routers
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>
2026-07-31 07:28:24 +00:00
pastilhasandClaude Opus 5 b442084618 document the wallet key custody model
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>
2026-07-31 07:09:03 +00:00
pastilhasandClaude Opus 5 f8826e4c24 add the bitcoin wallet sidecar and ui
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>
2026-07-31 06:48:06 +00:00
pastilhasandClaude Opus 5 5ee56e736b add the invoices ui over the invoiceshelf sidecar
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>
2026-07-31 06:44:02 +00:00
pastilhasandClaude Opus 5 b6dc73915d add officer-invoiceshelf sidecar
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>
2026-07-30 20:34:55 +00:00
pastilhasandClaude Opus 5 f98ebe509f document the sidecar env vars in .env.example
the example stopped at the core five and never mentioned the sidecars, so a
fresh checkout gave no hint that transmission, slskd or the vault are
configured at all. every value here is a placeholder.

notes the two things that are easy to get wrong: transmission rejects an
empty basic header, so user/pass must stay empty rather than blank-filled
when the daemon has no rpc auth; and HEADSCALE_URL/API_KEY drive /api/vpn,
not the officer-headscale sidecar, which deliberately reads neither.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:16:18 +00:00
pastilhasandClaude Opus 5 387664964c add a transmission sidecar and ui
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>
2026-07-30 17:09:19 +00:00
pastilhasandClaude Opus 5 c39460ce2d document the navigation conventions in claude.md
the url-as-source-of-truth rules from docs/navigation-audit.md were only in the
audit; anything new was as likely to reach for a selection channel as a link.
also records the workspace-barrel requirement for shared route helpers, and adds
officer-headscale to the pm2 list.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:14:48 +00:00
pastilhasandClaude Opus 5 17522be71a drive the headscale section from the url
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>
2026-07-30 15:36:39 +00:00
pastilhasandClaude Opus 5 62dfc572d0 show id and hostname in the node list heading
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:27:59 +00:00
pastilhasandClaude Opus 5 8029e506df probe the active headscale server automatically
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>
2026-07-30 15:24:49 +00:00
pastilhasandClaude Opus 5 0d227c46a3 headscale nodes, users and pre-auth key management
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>
2026-07-30 15:08:02 +00:00
pastilhasandClaude Opus 5 adf922de30 add the officer-headscale sidecar and its server registry ui
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>
2026-07-30 14:55:32 +00:00
pastilhasandClaude Opus 5 1e3dff27f5 add a switch that turns origin checking off entirely
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>
2026-07-30 10:54:16 +00:00
pastilhasandClaude Opus 5 418729a470 temporarily allow any origin on /api/music
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>
2026-07-30 10:49:14 +00:00
pastilhasandClaude Opus 5 1e6b01cd21 docs: the target sidecar topology
The owner's design, agreed in conversation and written down before it evaporates — this
session started by recovering a design that had been lost with its chat.

The premise is what makes it small: the tailnet becomes the perimeter and devices are
admitted by hand, so there is no authentication work inside it. Sidecars keep trusting
their caller; the boundary moves from loopback to the tailnet.

Six points: ecosystem is the source of truth and PM2 starts everything (peers, never
children — that was the tree-kill bug); one fixed port per sidecar; the platform reads the
table rather than being told at runtime; .env toggles features; NPM maps /api/<feature>
straight at the sidecar; the platform keeps auth and the frontend's own state and comes off
the data path.

What it buys is deletion: connect.ts's dial-and-register loop, every *:server port
announcement, and most of sidecar-registry.ts.

Also records what was considered and dropped — token distribution to every sidecar, a
public auth sidecar with JWKS, and the idea that Origin headers are a security boundary —
so none of it gets re-argued from scratch.

Nothing is built. Music is the first migration, end to end, with officer's proxy kept as a
fallback.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:08:08 +00:00
pastilhasandClaude Opus 5 4598e53c89 derive app origins from the environment
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>
2026-07-30 08:55:17 +00:00
pastilhasandClaude Opus 5 44a9124ef5 add POST /api/vpn/enroll for OffTail
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>
2026-07-30 08:43:12 +00:00
pastilhasandClaude Opus 5 3da97ffe2e remove superseded design notes
CLAUDE.md already told readers these were "older design notes" and to treat the code as
the source of truth. Keeping them is worse than that warning: they are detailed enough to
be believed, and every one of them describes an app that has since changed.

- OFFICERDEV_BACKEND.md, OFFICERDEV_FRONTEND.md — architecture snapshots from February;
  CLAUDE.md and the code cover this now.
- SETUP_ANALYSIS.md — an analysis of problems in setup.sh, superseded by `bun setup`.
- event-handler-instances.md — the result of a one-off scan ("found 167 instances"),
  regenerable with a grep in less time than reading it.
- HOOKS.md — a bare list of hook file paths, likewise.
- docs/per-user-api-keys.md — an architecture analysis for per-user API keys, which
  contradicts the single-user hard invariant in CLAUDE.md.

Kept deliberately, though all are old: SECURITY_AUDIT.md and SECURITY_FIXES.md (a
findings record and its remediation log — not the kind of thing to bin on a hunch),
PHONE_APP.md (a native app is being built), MARKETING_WEBSITE.md and
docs/DOCKERIZATION_PLAN.md (plans that may not have been carried out yet), and
docs/jobs-unification.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:31:14 +00:00
pastilhasandClaude Opus 5 71c32b8044 checklist: the run is finished
41 of the 56 run, 15 voided with the projects feature. Three defects found and fixed —
the chat cwd picker (f5d3024), dashboards losing the selection on edit and an abandoned
edit following you to the next one (4970e7e) — plus the layout shift on every link click
(9b9012d), which turned up while testing rather than from the list.

D12-edit on mobile stays broken by decision, recorded in the file with its cause.

Folds docs/nav-test-remaining.md back in: it existed to hand the last six checks over to
be run separately, and they were run together instead, so keeping two files that disagree
would be worse than one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:25:19 +00:00
pastilhasandClaude Opus 5 5a1e3d7e0b remove the projects feature
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>
2026-07-30 08:16:27 +00:00
pastilhasandClaude Opus 5 4970e7e70f dashboards: keep the selection while editing, and let the form win
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>
2026-07-30 08:00:14 +00:00
pastilhasandClaude Opus 5 67b91a7976 checklist: C, L and J pass; D under way
24 of 56 verified in the browser. C4 was the only failure (fixed in f5d3024). J1 looked
slow at first on a cold tab — the bundle had just been regenerated by a restart, and an
empty jobs list is indistinguishable from a loading one; GET /api/jobs answers in 3-5ms,
so there was nothing behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:35:59 +00:00
pastilhasandClaude Opus 5 9b9012d23d clip the content region instead of hiding its overflow
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>
2026-07-30 07:08:40 +00:00
pastilhasandClaude Opus 5 f5d30245c1 take the first cwd of a transcript, not the last
Opening a chat from a non-default folder moved the cwd picker into a subdirectory of
that folder — three of the four sessions under ~/dockers/officer.dev jumped to
`platform`, the fourth to `Lyrics` — and everything after it was scoped to the wrong
directory. The list itself stayed correct, which is what made it confusing.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:33 +00:00
pastilhasandClaude Opus 5 4e16a874dd merge the sidecar isolation work
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>
2026-07-30 06:29:04 +00:00
pastilhas bd1c8b578e Merge branch 'sidecars-opencode' into sidecars 2026-07-30 05:43:09 +00:00
pastilhas 9318fe8d0e Merge branch 'sidecars-claude' into sidecars 2026-07-30 05:43:05 +00:00
pastilhas f724d4b9d4 Merge branch 'sidecars-music' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas 5efffa9dcb Merge branch 'sidecars-vnc' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas 639d2a45da Merge branch 'sidecars-pty' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas a5cb7b4246 Merge branch 'sidecars-email' into sidecars 2026-07-30 05:43:01 +00:00
pastilhasandClaude Opus 4.8 4c2582f184 audit doc: mark H1-H3 done + record the ?selected= design decision
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:36:08 +00:00
pastilhasandClaude Opus 4.8 63f45de869 projects: drive list selection from a ?selected= url param, not a global
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>
2026-07-30 05:35:24 +00:00
pastilhasandClaude Opus 4.8 1b6b980af0 dashboards: drive list selection from a ?selected= url param, not a global
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>
2026-07-30 05:32:10 +00:00
pastilhasandClaude Opus 4.8 e3e67748c9 bind the email folder filter instead of interpolating it
/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>
2026-07-30 05:30:41 +00:00
pastilhasandClaude Opus 4.8 b83a6c7e77 move the whole local-audio pipeline into the music sidecar
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>
2026-07-30 05:27:42 +00:00
pastilhasandClaude Opus 4.8 04073dc6a0 jobs list rows are links to /jobs/:id instead of a navigate button
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>
2026-07-30 05:24:43 +00:00
pastilhasandClaude Opus 4.8 397f464bb8 add navigation audit doc as reference for the routing work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:21:18 +00:00
pastilhasandClaude Opus 4.8 71e39b7639 move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00
pastilhasandClaude Opus 4.8 1014739b1b reattach the desktop when officer restarts under it
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>
2026-07-30 05:11:19 +00:00
pastilhasandClaude Opus 4.8 754fac42bb stop reading the owner's vnc password in the main process
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>
2026-07-30 05:09:13 +00:00
pastilhasandClaude Opus 4.8 ab261cf52d move the pty sidecar under src/servers/sidecar
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>
2026-07-30 05:03:03 +00:00
pastilhasandClaude Opus 4.8 1824f53c89 let the pty sidecar decide what shell it runs
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>
2026-07-30 05:02:10 +00:00
pastilhasandClaude Opus 4.8 b92e8dffad keep terminals alive when officer restarts under them
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>
2026-07-30 04:59:06 +00:00
pastilhasandClaude Opus 4.8 238c3b8097 make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.

move the translation and the write into the sidecar:

- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
  of websocket.ts (delta buffering, flush before tool:start and result). pure and
  synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
  to officer, with its cursor id attached. per-session promise chain: translation is
  synchronous and therefore in arrival order, and only the commit is queued, so
  cursor ids are assigned in the order events actually happened. a delta that
  overtook the assistant:text in front of it would make the client commit its stream
  buffer at the wrong point, so deltas go through the same queue even though they are
  never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
  plus its seq. officer relays it verbatim and folds it into the in-memory session
  for sync:messages. it no longer builds or persists chat messages for this harness.

gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).

a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.

pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.

opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.

this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:51:52 +00:00
pastilhasandClaude Opus 4.8 62dc4c1a5c run the agent as a pm2 peer instead of a child of officer
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry
and was spawned on demand by the main server, with stdout/stderr inherited. that
made every agent session a grandchild of officer, so pm2's tree-kill took the
session down on every `pm2 restart officer` — the single thing that makes it
impossible to work on the platform while an agent is running.

give it its own entry (officer-agent) and delete the spawn machinery:
ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and
the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns
no sidecar at all.

for that to work the sidecar had to stop needing officer to start:

- it resolves the owner from the database (getOwnerUser) instead of reading
  CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard
  invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins
  when set, for manual runs, and a fresh install waits for bootstrap rather
  than exiting into a restart loop.
- it reads the anthropic proxy secret from the proxy sidecar's own state file
  rather than being handed it in env. lazily, because ensureProxySecret
  persists on a 30s debounce and pm2 starts both processes together.

it registers as 'agent' with capability 'claude', so the registry finds it the
way it finds every other sidecar. that removes the email argument from
killClaude, interruptClaude and clearClaudeSession, which only ever existed to
locate a per-email sidecar by name.

what officer keeps is a short wait-for-capability, because pm2 brings peers up
together and the first request after a boot can beat the sidecar's registration.

also align the two officer port fallbacks in the sidecar (5000 for the socket,
9010 for the rest base) — same instance, so they cannot disagree.

this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while
officer is down are still lost; that is stage 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:34:01 +00:00
pastilhasandClaude Opus 4.8 86930f5b17 rename officer-claude to officer-anthropic-proxy
the pm2 entry named officer-claude never ran an agent. it starts
sidecar/claude/index.ts, which registers as capabilities: ['proxy'] and only
holds the anthropic proxy secret and forwards api traffic. the process that
actually spawns claude is sidecar/claude/user-instance.ts, which had no pm2
entry at all and was spawned on demand by the main server.

that misnomer is how both CLAUDE.md files ended up claiming that restarting
officer does not disturb a running agent session. it does: the agent was a
grandchild of officer and died with it. correct the name so the next reader
starts from a true model, and fix the claim in both files.

no behaviour change — officer-agent arrives in the next commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:28:19 +00:00
pastilhasandClaude Opus 4.8 b27dd7512b load chat transcripts as a tail window and lazy-load older on scroll-up
extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 03:35:47 +00:00
pastilhasandClaude Opus 4.8 32749eb665 document that bun format touches every dirty file
The script globs `git diff --name-only HEAD`, so running it after a small change also
reformatted an unrelated file the owner had in progress — whitespace churn landing in
someone else's diff with no explanation. Point at `bunx prettier --write <paths>` instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:41:43 +00:00
pastilhasandClaude Opus 4.8 eca1f83162 workspace: maximize a panel without remounting its content
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>
2026-07-30 02:38:05 +00:00
pastilhasandClaude Opus 4.8 1534bc2ea7 soulseek: cache each peer's profile instead of refetching it per click
Presence and profile lived in component state, and selecting a peer began by clearing it —
so every click blanked the panel back to "enter a username", then rebuilt it from two
network calls, even for a peer looked at seconds earlier. The panel is mostly used by
bouncing between the same handful of favourites, which made that the common path.

A query keyed by username makes the second visit free and the first one non-destructive:
the card now renders from the selected name, so it appears immediately with the presence
line filling in, and the shares browser below it stays mounted rather than unmounting
and losing its expanded tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:24:52 +00:00
pastilhasandClaude Opus 4.8 fc00d3861c soulseek: match share filter word by word
A folder's name is rarely something you can type in full — you remember the band and
one word of the album, not the year, the format tag or where the apostrophe went.
Whole-string matching made those two halves useless together: "pogues hell" matched
nothing, because no path has them adjacent. Each word is now its own substring test,
AND-ed and order-independent, so the two ends you do remember narrow the tree between them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:11:09 +00:00
pastilhasandClaude Opus 4.8 b7015b1ede file browser: type-ahead jump-to-item, flips to filter on longer bursts
Replaces the old "any key focuses the search box" with classic file-manager
type-ahead in FileGrid: keys within 1s accumulate into a burst. 1–2 chars select
and scroll to the first item (folder or file, in sort order) whose name starts
with the burst; the 3rd char flips it into the search filter (dumps the typed
text into the box and hands off focus, cursor at end). Idle >1s resets the burst.
Removed the single-key focus-search branch from useFileBrowserApp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:06:20 +00:00
pastilhasandClaude Opus 4.8 184adc0e8e soulseek: queue downloads straight out of the cached tree
a browsed folder is a path, not a file list, and what clicking one means is
"everything under here" — so the expansion happens in the sidecar, off the
cache, rather than making the browser walk the tree a level at a time and
rebuild paths it only half knows.

browse reports file names as basenames, unlike search, so the peer's real
path is rejoined from the folder row. sizes come from the cache too: slskd
matches a queued download on filename AND size, so a number supplied by the
client would be a transfer that silently never starts.

a subtree can be the peer's whole share (284k files on one measured peer), so
an over-limit request is refused with its count rather than truncated into a
partial download nobody asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:57:01 +00:00
pastilhasandClaude Opus 4.8 b94dccd17c soulseek: open a filtered row only when the filter hit something below it
every row in the filtered skeleton was rendered open, so searching a band
name unfolded each of its albums into a wall of tracks that matched nothing.
a row now starts open only when the filter matched a descendant; one that
matched on its own name stays shut, and opening it leaves the filter behind
and browses its real contents.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:37:25 +00:00
pastilhasandClaude Opus 4.8 85b7337b68 soulseek: let a filtered row open into its real contents
the tree search asked for no limit and clampInt read a missing param as 0
(Number(null) is 0, and 0 is finite), so it clamped to the minimum and ran
with LIMIT 1: one match came back, its siblings looked like non-matches, and
a folder with 25 matching albums showed none of them.

on top of that, a filtered row was a dead end — it only ever showed the
matches, with a note counting what it was hiding. now the note is the same
"show all N folders" button browse mode already had: clicking it drops that
row out of the filtered set, so from there down it is ordinary lazy
browsing, which is usually why you searched for the folder in the first
place. dropped the "don't match the filter" wording with it, since a
truncated result makes that sentence false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:27:10 +00:00
pastilhasandClaude Opus 4.8 1159c0787d soulseek: render cached shares as a tree, not a path list
slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.

The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.

Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.

Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.

The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:05:22 +00:00
pastilhasandClaude Opus 4.8 12b9641adc soulseek: make the folder filter match what you can type
Two ways the filter lied about the cache. First, input went into ILIKE raw, so its
metacharacters were live: a typed '_' matched all 18,295 folders, '50%' matched 126
unrelated ones, and — worst — a pasted path fragment matched nothing at all, since
backslash is LIKE's escape character and every name here is a backslash-delimited
remote path. Second, these names come off strangers' filesystems and are full of
punctuation no keyboard produces, so "Hell's Ditch", "1984-1985" and "Say... Pogue"
each returned zero against stored U+2019, U+2013 and U+2026. The U+2010 HYPHEN is
the nastiest of those: identical to '-' on screen, so "B-Sides" quietly found 15 of
23 folders and looked like it had worked.

Both sides are now folded to ASCII and the term is escaped. The fold rides on the
existing scan — a leading wildcard already ruled out the btree — but translate()
over ~18k rows does cost something: a filtered page went 16ms to 88ms. Still well
under the input debounce, and a normalized column with a trigram index is there if
it ever stops being true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:42:27 +00:00
pastilhasandClaude Opus 4.8 80ca9aa9b6 soulseek: scope cached folder reads to their peer
Probing the live routes turned up that /browse/<peer>/dirs/<id>/files only checked
the owner, not the peer: dir ids are global, so asking for one peer's folder id
under a different peer's name returned the other peer's files with a 200. The UI
always sends a matching pair so nothing misbehaved, but the URL was asserting a
relationship the query never verified — a mismatched or stale request would show
the wrong peer's contents rather than a 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:27:39 +00:00
pastilhasandClaude Opus 4.8 1ac5bffb6c soulseek: cache peer share trees server-side
Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with
the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k
files for a single real peer — and it takes minutes because it round-trips to that
peer. The browser was made to wait for that, so navigating away threw the whole
thing out and the panel showed an error more often than a tree.

So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch
shares" returns 202 and the job keeps running without the tab; the UI polls the
snapshot row and reads back pages. Folders are rows and files ride along as jsonb
on their folder, because folders are what you filter and page through while files
are only ever read for the one folder you opened — a row per file would be 284k
rows per peer for no gain.

A failed or in-flight refresh deliberately leaves the previous folders in place: a
peer going offline shouldn't cost you a good cache, so the panel drives off rows
existing rather than off status. Interrupted 'pending' snapshots are failed at
sidecar boot, since the job died with the process and would otherwise spin forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:59:45 +00:00
pastilhasandClaude Opus 4.8 df3643612c soulseek: fix browse crashing on the wrapped share-tree response
GET /users/{u}/browse returns { directories, directoryCount, lockedDirectories,
lockedDirectoryCount }, not the bare Directory[] that 0.26.0's source suggests,
so spreading it threw "E is not iterable" and every browse failed. Read
.directories, tolerating both shapes since the two disagree.

Also note in the type that browsed files carry only a basename, unlike search
results which carry the full remote path slskd needs to enqueue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:47:50 +00:00
pastilhasandClaude Opus 4.8 ef541bbc1b soulseek: always-visible search delete, clear all, brighter header
The per-search remove button was hover-gated with opacity-0, so it read as
absent until you happened to hover the row. Make it always visible and red on
hover, and add a Clear all next to refresh. slskd has no bulk delete (only
DELETE /searches/{id}), so clearing fans out one request per search, drops each
cached result set, and reconciles with a reload — partial failures surface in a
toast. The destructive click arms once and disarms itself after a few seconds
instead of opening a modal, matching the other panels, which confirm nothing.

That header row sits outside any card, where the muted zinc greys wash out
against dark mode's green background — switch the whole level to white.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:36:42 +00:00
pastilhasandClaude Opus 4.8 b00608f3b3 soulseek: peer menu with browse and favorites
slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes
only endpoint/browse/directory/info/status — so Officer owns that data itself:
a soulseek_favorites table served by the slskd sidecar under a /_officer/*
namespace, which can never collide with slskd's /api/v0/*. The main server
gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays
a thin auth proxy and grows no Soulseek logic. The route is handled before the
upstream check, so favorites keep working with slskd down.

Usernames in search results and downloads become a dropdown (browse shares,
toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel
so the Users section looks the peer up without re-running the expensive browse
on every remount, and favorites get their own section at the top of that panel,
which doubles as its landing content. CardHeader had to split its toggle row to
host the dropdown, since a trigger can't live inside the collapse button.

The schema file is deliberately self-contained so it can move wholesale into
the sidecar directory when sidecars start owning their own schema. Its DDL was
applied by hand, matching drizzle's constraint naming, rather than running a
whole-schema push.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:36:37 +00:00
pastilhasandClaude Opus 4.8 f777ed4197 soulseek: revalidate cached search results instead of freezing them
The results cache was write-once, read-forever: opening a search before its
first responses landed cached the empty grouping, and every later visit
short-circuited on it — the history row showed results while the detail view
stayed empty, permanently.

Serve the cache as a first paint only and always re-fetch behind it, poll
while the search is still running so responses fill in live, and add a manual
refresh. Collapse state now survives a revalidation (only newly arrived users
auto-expand), the empty state distinguishes "still searching" from "no
results", and deleting a search drops its cache entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 22:37:57 +00:00
pastilhasandClaude Opus 4.8 bb90dba952 jobs: wait for persisted layout before mounting the panel group
ResizablePanel reads defaultSize only at mount, so mounting during the
dashboard-state query's loading window froze the fallback sizes and ignored
the persisted layout that arrives after — layout survived in-app nav (query
cached) but not a full refresh. Gate the render on isLoaded so the group
mounts once, with the stored sizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:58:58 +00:00
pastilhasandClaude Opus 4.8 aa0cb733a5 jobs: persist the panel layout across reloads
The page backed WorkspaceLayout with local useState, so onLayoutChange
(fired on every resize) only updated ephemeral state — pane sizes reset
to the default split on reload. Back it with useDashboardState under
screens/jobs, like the other workspace routes, with a structural guard
that falls back to the default when a persisted layout's panel ids no
longer match PANEL_COMPONENTS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:39:34 +00:00
pastilhasandClaude Opus 4.8 fd203138bf client: tolerate empty response bodies on 2xx
Every verb ended in an unconditional res.json(), which throws
"Unexpected end of JSON input" on 201/204 responses that carry no body —
so a request that actually succeeded still surfaced as an error (e.g. a
sent chat message toasting a failure). Read text first and only parse
when non-empty, otherwise resolve undefined. Non-empty JSON is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:35:40 +00:00
pastilhasandClaude Opus 4.8 f4a47a035a soulseek: wire sections into the view, default to dashboard, persist zoom
- render dashboard/uploads/rooms/chat/users/system from the section channel
- nav + view both default to the dashboard section
- register the view panel's zoom header; read the persisted zoom via useDashboardState

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:30 +00:00
pastilhasandClaude Opus 4.8 5ea06e2d8c soulseek: dashboard, uploads, rooms, chat, users, and system panels
- dashboard: connection health, live download/upload tallies, recent searches
- uploads: mirror of downloads (what peers pull from us), per-row cancel/clear
- rooms: join chat rooms, transcript + composer, available-room datalist
- chat: private 1:1 conversations with unread badges and self-aligned bubbles
- users: look up a peer's presence/profile and browse their shared folders
- system: server connection state with connect/disconnect and version info
- view header: +/- zoom controls persisted per panel
- search view: restyle history to the shared dark (zinc) theme

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:25 +00:00
pastilhasandClaude Opus 4.8 e25143cc9c soulseek: download bulk actions, queue position, per-folder download
- downloads: split into active/completed with a bulk toolbar (retry errored,
  cancel all, remove completed) mirroring slskd's downloads page
- downloads: queued rows hover to show place-in-queue and click to force a refresh
- search results: add a per-folder "Download folder (N)" button alongside the
  per-user download-all

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:16 +00:00
pastilhasandClaude Opus 4.8 520944c6ca soulseek: shared slskd types, helpers, and card primitives
- add types for transfers, private conversations, users, rooms, and server state
- add formatClock / folderLabel / formatDuration helpers and the groupResponses shaper
- move the per-panel zoom key into the persisted screens/ namespace
- extract shared dark-theme Card / SubCard / RowList primitives used by the panels

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 21:27:07 +00:00
pastilhasandClaude Opus 4.8 e5950b6493 music: dismiss the dock when playback ends
The in-flow music dock reserves its height whenever a track is loaded (current),
even paused — so after a queue finished (onEndOfQueue set playing=false but kept
the queue) the dock lingered, shifting the layout up with a faint idle bar. On
natural end-of-queue, close() the queue so the dock releases its space. The saved
now-playing snapshot is untouched, so reload still resumes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:34:44 +00:00
pastilhasandClaude Opus 4.8 c1594594f5 soulseek: nav sections, search results subpanel, stable client deps
- split into nav (vertical section menu) + view panels via soulseek:section channel
- search view: history list + input, opens results subpanel for a past search
- search results load stored responses (no re-run), session-cached, ranked
- remove client from effect/callback deps (useClient is a fresh object per render)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:19:39 +00:00
pastilhasandClaude Opus 4.8 2a509646e7 slskd: /soulseek search + downloads on the Workspace/Panel framework
Two panels (search + transfers) via WorkspaceView, coordinating over the
soulseek:refresh channel. Adds the page-title rule and documents the
route conventions in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:21:29 +00:00
pastilhasandClaude Opus 4.8 c023082975 slskd: scaffold reverse-proxy sidecar
Add officer-slskd, a singleton sidecar that reverse-proxies to a
self-hosted slskd (Soulseek) instance and reports its loopback port to
the API on connect. All slskd knowledge (URL + API key) lives in the
sidecar; the platform is a thin auth+forward proxy for /api/slskd/* and
holds no slskd credentials. Mirrors the officer-vault pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:49:09 +00:00
pastilhasandClaude Opus 4.8 0325f14770 db: collapse migrations into a single baseline
Squash the incremental 0000–0004 migrations into one baseline generated from the
schema, which drizzle-kit confirms reproduces the current DB (26/26 tables, incl.
the hand-applied music_now_playing (user_id,device) PK and vault_tokens.client_id).
The Drizzle schema is the source of truth; this repo uses db:push, so a single
baseline is all that's needed to recreate the structure from scratch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:28:21 +00:00
pastilhasandClaude Opus 4.8 362b806c8a file browser: restore the inline video-download modal, drop the side panel
Reverts the video-download UI from the ephemeral side panel back to the modal
(VideoDownloadDialog) that predated it — the "download video" button opens the
modal again and downloads inline via /download-video, exactly as before. The
six wiring files had no non-download changes since the modal→panel conversion,
so they're restored verbatim from that commit's parent; the panel file is
removed. The inline ReClip routes it uses are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:42:27 +00:00