Commit Graph
100 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 16f5175205 scripts: rewrite the desktop teardown for the mirror-based setup
cleanup-desktop.sh still described the era when Officer installed a desktop of
its own — XFCE on a TigerVNC Xvnc — so tearing the remote desktop down meant
deleting a desktop nobody else used. That stopped being true when the setup
moved to mirroring the machine's existing session with x11vnc, and the script
was left actively dangerous: it purged dbus-x11 and Brave, deleted ~/.vnc, and
reinstalled gnome-keyring, all of which the current GNOME setup depends on or
deliberately removes. Running it today broke the desktop rather than cleaning
it up.

Rewritten as the actual inverse of setup-desktop.sh:

- removes what Officer added — x11vnc, ~/.vnc, the GDM auto-login and forced
  Xorg keys, the forced EDID and its kernel command line, the login-time mode
  setter, the legacy officer-vnc unit, the VNC entries in .env
- keeps ubuntu-desktop, gdm3 and dbus-x11, which are the machine's own desktop
  and not Officer's to delete
- still purges XFCE and TigerVNC when present, since a host set up by the older
  script carries them and they are precisely what this undoes
- Brave is opt-in behind --purge-brave: setup installs it, but by teardown time
  it is usually just the user's browser with their profile in it

The GRUB and GDM edits were checked against copies of the real files: stripping
the EDID parameters leaves other kernel arguments intact wherever they sit in
the line, and the GDM revert does not disturb the commented examples that ship
in custom.conf. The package matcher names each xfce-family prefix rather than
globbing '^libxf', which would have taken libxfixes, libxft and libxfont with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 11:02:16 +00:00
pastilhasandClaude Opus 5 4d2ec215a2 add /calendar and /contacts screens over the caldav json door
two workspace screens backed by one app folder: the collection list on the
left, the selected calendar's agenda or address book on the right. both read
the officer-caldav sidecar through the /api/caldav auth proxy, which holds no
DAV credentials of its own.

the selected collection is a DAV path with slashes in it, so it lives in
?collection= on the same route rather than as a path segment — still in the
URL, still bookmarkable, no selection channel. an absent or unknown value
resolves to the first collection inside the panels instead of redirecting,
because until the list has loaded there is no canonical URL to redirect to.

the agenda is a grouped list, not a month grid: the sidecar returns RRULE
unexpanded, so a grid would have to invent occurrences the server never
claimed existed. a repeating event gets a badge instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:50:53 +00:00
pastilhasandClaude Opus 5 a961d8a34f music: report what the nightly full actually found
The nightly full rebuilds every album by definition, so "it completed" says
nothing about whether it is still needed. Diff the from-scratch build against
the index that was already live, just before the slot swaps in, and log the
delta: albums added, removed, and entries whose contents changed.

A run reporting NONE did no useful work. A string of those is the evidence for
retiring the nightly; a delta that keeps coming back names the albums to go and
look at instead of guessing.

Entries are compared field by field rather than by JSON.stringify: the optional
fields are spread conditionally, so two entries built by the same code can
serialise with different key order, and stringify would report every album as
changed every night. A cache-format upgrade is labelled, since that rebuilds
everything legitimately and would otherwise read as total rot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 10:47:13 +00:00
pastilhasandClaude Opus 5 3770d7c647 re-adopt orphaned chat sessions on reconnect
restarting officer under a live turn left the browser connected but permanently
silent. the sidecars are pm2 peers, so the agent kept generating and kept
committing to chat_session_events — what died was officer's binding to it. on
`resume-cursor` the server only re-attached the socket when an in-memory session
still existed, so after a restart there was no session and, critically, no
session-scoped subscription relaying sidecar events to the client. the client got
its durable replay and then nothing, which reads exactly like the agent stopping.

adopt the session instead: recreate the record and re-open the subscription
without spawning anything. `_claudeKill` has to be set as part of that — handleChat
treats its absence as "first turn" and would open a second subscription, doubling
every message.

the client now echoes the model and cwd from its session:init back in the
handshake, since after a restart it is the only party that still remembers them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 10:36:02 +00:00
pastilhasandClaude Opus 5 75666e5e94 settings: manage dav app passwords
Settings → Integrations → Calendar & Contacts sync. without this there is no way
to mint a credential from the app, and minting one was the first step of testing
the whole caldav feature on a phone.

the generated password is returned by POST and never again — it is stored as an
argon2 hash, so there is nothing to read back. that one fact drives the screen:
the new credential appears in a panel that stays until dismissed, with the
server url and username beside it, because once it is gone the only remedy is to
revoke and mint another.

the server url is read from window.location.origin rather than configured. it is
by definition the address that reached this page, so it is the one that will
work on the phone.

rows show the hint prefix and last-used date. "never used" is the tell that a
device was set up wrong, so it gets its own wording rather than a blank.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:33:41 +00:00
pastilhasandClaude Opus 5 8cf210eae5 caldav: json door for the web ui
step 3 of docs/nextcloud-replacement.md. collections, events and contacts as
plain json, so the browser never has to parse multistatus xml to draw a list.

this talks dav to radicale over loopback rather than reading its on-disk format.
the storage layout is radicale's private business and changes between versions;
propfind is its supported interface and costs one in-process hop. parsing the
storage directly would be faster and would break silently on upgrade, which is a
bad trade for a calendar.

three bugs found and fixed while verifying, all of which fail quietly rather
than loudly:

  calendar-data and address-data are NOT webdav live properties. rfc 4791 and
  6352 define them as report-only, and radicale correctly returns an empty prop
  for them under propfind — so the first version returned a 207 full of nothing,
  which reads exactly like "your calendar is empty".

  the principal resource matched the calendar test, because `<C:calendar-home-set/>`
  satisfies /calendar\b/. the principal showed up in the list as a calendar
  called "1". the tag has to be required to end.

  the internal fetcher omitted X-Script-Name, so the ui was handed /1/work/ for
  the same collection a phone sees as /dav/1/work/, and nothing downstream could
  have matched them up.

ical.ts is deliberately small: it unfolds lines and pulls out the fields a list
shows. it does NOT expand rrule or resolve vtimezone — radicale owns correctness
there, and rrule is passed through raw so the ui can say "repeats" without
either of us pretending to know when.

verified against the running stack with a real vevent and a real vcard, then the
fixtures were removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:30:28 +00:00
pastilhasandClaude Opus 5 bc52ce6361 docs: phone photo backup contract, and the file-sync decision
photo sync turned out to be mostly built already. officer proxies immich through
officer-photos, and that sidecar's allow-list already permits the `assets`
resource for GET/POST/PUT/DELETE — so immich's own upload and bulk-upload-check
endpoints are already reachable with officer's auth in front and the immich key
never leaving the sidecar. the deliverable is therefore the contract, not a new
ingest service.

three gaps are written down rather than papered over:
  - immich is not currently connected in officer (_health says configured:false),
    so none of it could be verified live. the api key moved out of .env and was
    never re-entered in the ui. owner action.
  - upload bodies are buffered twice, once in createSidecarProxy and once in the
    photos sidecar. nothing fails at phone-photo sizes; a video library would be
    unpleasant. fixing it touches the shared factory, so it is a decision.
  - no resumable upload. immich's own app has the same limitation.

file sync is design-only, as agreed. the recommendation is syncthing supervised
as a sidecar rather than reimplementing nextcloud's sync protocol — a mount is
not a sync, and the local-first replica is the whole feature.

the iOS answer is stated plainly because it changes the design: continuous
background sync is not possible there, which is why nextcloud's own iOS app is
manual too. if iOS matters, webdav becomes first-class rather than optional, and
that is the owner's call to make before any code is written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:27:01 +00:00
pastilhasandClaude Opus 5 edf26323da memos sidecar
wraps the self-hosted memos instance, same shape as transmission and slskd. no
schema change was needed: service_connections already says `service` is text
because "adding a service should not be a schema change", and memos is the
one-instance-per-owner case that table was built for.

the sidecar holds the url and the personal access token; the platform side is
16 lines of createSidecarProxy and holds neither.

/_api/* is a pass-through onto the instance's own /api/v1 rather than a
hand-written wrapper per endpoint — memos generates its rest api from protobufs
and it moves between minor versions, so re-describing it here would be a second
thing to keep in sync. the allow-list is the one piece of policy, and it keeps
this from being a general ssrf hop. auth routes are excluded: signin/signout
would mint sessions on the instance, and this authenticates with a stored token.

probing is two calls on purpose. /healthz answers unauthenticated, so a bad url
is distinguishable from a bad token — memos returns 200 and an empty list for
unauthenticated reads rather than 401, so "the list came back" proves nothing.

verified against the live container: unconfigured reports not-connected, a bad
token is rejected WITH the reason and nothing is stored, and the platform mount
401s without a session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:24:15 +00:00
pastilhasandClaude Opus 5 36c7b1f3fd delete api routes with no consumers
from a full audit of all 179 route definitions under src/servers/api, tracing
consumers through useClient, raw fetch, EventSource, the capabilities repo and
the mobile monorepo. only routes with zero consumers anywhere are removed.

  server-settings/applications.ts   whole file — an app install/update registry
                                    with no settings section to drive it
  server-settings/claude-code.ts    whole file — the ai settings screen talks
                                    to chat-providers/* exclusively
  GET  browser/extension-download   superseded by a static asset; BrowserRelay
                                    links at /browser-relay-extension.zip
  GET  integrations/                a stub returning []
  GET  chat-providers/auth          /api-keys says the same thing with more detail
  GET  desktop/vnc-status           and with it the vnc:status command and reply,
                                    which existed only to serve this route.
                                    docs/sidecar-audit-2026-07.md called this
                                    one dead months ago

deliberately KEPT, because "no caller" turned out not to mean "dead":

  POST activity/announce      not orphaned — it is the missing PRODUCER for the
                              detached[] list GET activity/tasks already returns
                              and ActivityScreen already renders. an unbuilt
                              feature, not dead code, and finishing or dropping
                              it is a product decision.
  GET  agents/runs            three days old. part of agent grounds, still being
                              built. "not yet consumed" is not "dead".
  PUT/GET vault/unlock-key    six days old, storage half of a feature whose
                              client half is unwritten. the vault is off limits.
  DELETE integrations/google/connection   caller exists but is deliberately
                              commented out of the tree. dormant on purpose.

vnc-manager's getSession is now orphaned too, but it is sidecar-internal and
was not in scope; noted rather than chased.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:19:37 +00:00
pastilhasandClaude Opus 5 ccd104a28b caldav/carddav: officer-caldav sidecar and the /dav door
first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.

radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.

two doors, because a browser should not speak dav:

  /dav/*        top-level, http basic against a scoped app password, every
                verb and every dav header forwarded verbatim. this is what
                davx5 and ios talk to. same reasoning as /api/vault being
                mounted outside protectedRouter.
  /api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.

the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.

new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.

.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.

verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:16:17 +00:00
pastilhasandClaude Opus 5 86d979046b restore list markers in rendered markdown
tailwind's preflight resets `list-style: none` on every ul/ol, and none of the
three prose scopes put it back. the indent was there, so a bulleted list just
looked tight — but an ORDERED list rendered with no numbers at all, which reads
as the model having emitted broken markdown. pasting the same text into an
editor showed it numbered correctly, which is the tell.

the `li::marker` rules were colouring a marker that was never drawn.

fixed in .chat-md, .file-viewer-md and .skill-md, plus the inline
.markdown-preview block in MarkdownEditor, which had the same hole. nested
levels follow the usual convention (disc/circle/square, decimal/alpha/roman) and
task-list items drop the bullet, since the checkbox is already the marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 03:00:16 +00:00
pastilhasandClaude Opus 5 c591a8a771 make the /chat url a permalink again
reloading /chat/<id>?cwd=<dir> landed on an empty general_chat_sessions instead
of the conversation. one line did both halves of it:

  if (replaceUrl) window.history.replaceState(null, '', `/chat/${msg.sessionId}`)

that ran on session:init, and session:init's sessionId is officer's own
per-connection key — websocket.ts mints it as `msg.sessionId || randomUUID()`.
/chat/sessions/:id resolves a CLAUDE TRANSCRIPT uuid, so the address bar ended
up naming something no lookup could find; the detail fetch 404'd and the catch
dropped you into a blank chat. the template also had no location.search, so
?cwd= — added later, for agent grounds — was thrown away every time the socket
connected, which is why the pwd picker fell back to the default group.

the transcript uuid is only known once the turn reports it, and it only started
crossing the wire in 7b6ca5f, so move the rewrite to `result`, use
claudeSessionId, and carry the query string through untouched. new chats gain a
working permalink too — /chat/new used to become an unresolvable id the same way.

mobile back had the same query-string hole; it now keeps the search.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 02:15:30 +00:00
pastilhasandClaude Opus 5 7b6ca5f4ca attribute subagent output to the task that spawned it
the harness stamps every message a subagent produces with parent_tool_use_id.
the sidecar wrote it outgoing and nothing ever read it coming back, so a
subagent's prose and tool calls were spliced into the main transcript as if the
agent you are talking to had produced them — and worse, its deltas were appended
to the same text buffer, so two voices were concatenated inside one bubble.

both buffering layers (stream-parser's textBuffer and turn-stream's buffer) are
now maps keyed by parent, and parentToolUseId rides on ChatEvent, ServerMessage
and Message. useChat nests parented output under the Task row that spawned it;
ToolActivity draws the trace inside the expanded panel.

background tasks get the same treatment from the other end: task:started and
task:notification were two unrelated fake assistant bubbles minutes apart, and
are now one role:'task' row correlated by taskId that appears pending and
resolves in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:48:21 +00:00
pastilhasandClaude Opus 5 96ceb3aca6 delete the claude-done hook and its unauthenticated endpoint
The chain was: a Stop hook in Claude's settings curls POST /api/hooks/claude-done,
the platform POSTs /_officer/panel-refresh to the pty sidecar, the sidecar sends a
`panel-refresh` frame to every attached terminal, and the Claude Code panel bumps
`preview:refresh` and `files:refresh-signal`.

It has never fired. generateClaudeSettings writes settings.json into the MANAGED
home under DATA_PATH, but HOME_DIR points terminals at the owner's real login home
— which is where Claude reads its settings from. Verified on this machine: no
claude-done hook exists in ~/.claude/settings.json, and DATA_PATH/*/home/.claude
does not exist at all.

Deleting rather than repairing it, because the Chat panel already does exactly this
job from onTurnComplete — in-process, conditioned on the turn having made tool
calls, with no hook, no HTTP round trip, and no endpoint. The chat UI is where agent
work happens; the terminal TUI is not the destination.

Also removes /api/hooks/claude-done, which was mounted above protectedRouter and so
was the one unauthenticated write-ish endpoint on the API surface.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:36:35 +00:00
pastilhasandClaude Opus 5 4eeaa3c93d fail only the disconnected sidecar's in-flight requests
unregisterSidecar rejected every entry in the pending map, not just the ones
belonging to the sidecar that went away. Restarting any single sidecar failed
in-flight work on every other: `pm2 restart officer-music` could kill a running
agent turn with `Sidecar "music" disconnected` — a message pointing at a process
that had nothing to do with it. Pending entries now carry their owning sidecar
id and the rejection loop skips the rest.

Also deletes src/servers/api/anthropic-proxy.ts. It had no importers; PM2's
officer-anthropic-proxy runs src/servers/sidecar/claude/index.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:33:29 +00:00
pastilhasandClaude Opus 5 69961a52cd enroll devices against the headscale server the owner picked
/api/vpn/enroll minted pre-auth keys itself, from HEADSCALE_URL, HEADSCALE_API_KEY
and HEADSCALE_USER in the host env. Three globals describe one server; Officer keeps
a registry of many in headscale_servers with one active, so the env could contradict
the server the owner had selected — and HEADSCALE_USER filed every joining device
under the same name on all of them.

The two credential vars had already been removed from the environment and nothing
noticed: the route checks `if (!base || !apiKey)` first, so it had been answering
503 to every enrollment attempt, silently. HEADSCALE_USER was read but never reached.

Enrollment moves into the sidecar that owns the registry and acts on the active
server. The owning user is resolved rather than hardcoded: an explicit userId wins,
one user on the server needs no choice, several is a 409 listing them instead of a
silent guess. The platform route keeps its path and response shape — both are a
contract with enrollVpn() in the mobile core — and is now a bare forward holding no
Headscale URL, key or user name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 01:07:56 +00:00
pastilhasandClaude Opus 5 8e7e129f02 drop a scratch file committed by mistake, and ignore the pattern
git add -A swept up a throwaway verification script. it held no key material —
it read addresses from a file outside the repo — but it had no business being
committed. ignoring *.tmp.ts so the next one cannot repeat it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:24:54 +00:00
pastilhasandClaude Opus 5 e526b8599c stop using a real account xpub as a test fixture
the key in chain-source-esplora.test.ts was labelled a published test vector and
was not one — it was the owner's live bip84 account xpub, pulled from the
database during an earlier verification and pasted in.

it cannot spend, but it discloses every address that wallet will ever use and
its whole history, permanently. replaced with the bip84 spec's own vector,
derived in the file from the published mnemonic so its provenance can be checked
rather than taken on trust, and pinned by an assertion against the spec's first
address so a future substitution fails loudly.

this does not remove the key from history. that needs a rewrite of 5c38236.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:20:36 +00:00
pastilhasandClaude Opus 5 5b30aebb1d let the owner pick which chain source the wallet reads from
esplora and nbxplorer are now both selectable from wallet settings. the two are
stored as separate service_connections rows but are mutually exclusive: saving
either retires the other, so "which endpoint is in use" is never decided by a
precedence rule.

the nbxplorer probe cross-checks the chain it reports indexing against the
configured network, so pointing a mainnet wallet at a testnet node is refused at
the form rather than discovered later as an unexplained zero balance. esplora
cannot report this, so there is nothing to check there.

/_health now runs the same probe the form does, instead of its own hardcoded
esplora path — the two can no longer disagree about what a working endpoint is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 00:01:18 +00:00
pastilhasandClaude Opus 5 5c38236b39 split the wallet's chain reads from its signing half
OnchainBackend depended on the concrete EsploraChain class, so the only wallet
it could ever have was an Esplora-backed one. The seam is now WalletChainSource,
and it is drawn at the scan rather than at the HTTP client: Esplora is
address-level and has to walk the gap limit, NBXplorer is wallet-level and has
no per-address endpoint at all, so there is nothing to share one level down.

The backend keeps the keys and the money — derivation, snapshot cache, coin
selection, PSBT construction, signing — and owns no HTTP. Which indexer answers
is a constructor argument.

Also adds the NBXplorer implementation of the seam, verified end to end against
the owner's own pruned node, and the first tests over any of this: a stub
Esplora drives a real backend through the gap-limit walk, balance summation,
UTXO mapping, transaction scoring and address issuance. Nothing covered the
scan before it was moved, which is the wrong time to have no tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:50:53 +00:00
pastilhasandClaude Opus 5 2d71424d5a add an nbxplorer client to the wallet sidecar
The second chain source. Esplora is address-level, so finding a wallet's coins means
walking the gap limit ourselves — 43-160 requests per refresh. NBXplorer is scheme-level:
register the account xpub once and every question after that is a single call. So this is
deliberately NOT an implementation of EsploraChain's interface; there is no per-address
query worth emulating, and emulating one would throw the advantage away.

Verified end to end against the owner's live 2.6.9 instance: status, track, balance,
utxos, transactions, unused address and fee estimates all round-trip, and NBXplorer
derives bc1qwnvlm8... for BIP84 0/0 — byte-identical to what keys.ts derives.

Three things the upstream docs get wrong or leave out, all confirmed by hand:
  - single-sig taproot is `-[taproot]`, absent from NBXplorer's own scheme table
  - querying an UNTRACKED scheme returns 200 with every figure zeroed, which reads as a
    real empty wallet; track() is therefore on every read path, not just at setup
  - a rejected broadcast returns 200 with success:false, never an HTTP error

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 23:31:55 +00:00
pastilhasandClaude Opus 5 ff73e86983 move the wallet's chain source out of env and into the ui
WALLET_ESPLORA_URL was the one wallet setting an owner actually has to change — off a
public explorer that rate-limits and sees every address, onto their own indexer — and it
was the one they could only change with a shell and a restart. It now lives in
service_connections under 'esplora' and is edited at Wallet -> Settings -> Chain source,
probed against /blocks/tip/height before it is stored.

The URL joins the backend fingerprint, so re-pointing rebuilds every on-chain backend and
drops the gap-limit scan taken through the old endpoint. /_health probes what the wallets
actually use rather than the built-in default, and /_officer/config no longer reports a URL
it cannot know.

Also two receive-screen defects the Blockstream 429 exposed: a query error rendered as
"No address available", and the "new address" button called refetch() on the ?peek=true
query, so it re-fetched the same address instead of advancing the index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:59:00 +00:00
pastilhasandClaude Opus 5 f45bc9292b let the wallet be renamed from settings
the PATCH route already accepted a name; nothing in the UI ever sent one. adds an
inline editor on the settings header (pencil → input, enter saves, escape cancels)
and a rename mutation. renaming touches only the label, so it needs neither the
passphrase nor an unlocked wallet.

the route took the name unvalidated — it now trims and refuses a blank one, with a
64-char cap matched on create so a name you can create is one you can type back.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:44:16 +00:00
pastilhasandClaude Opus 5 d7b775113b move transmission and slskd credentials into the database
both sidecars read their upstream from a new service_connections table instead of
process.env: one row per (user, service), the secret encrypted at rest, upserted
through a /_config route the app drives. transmission gains a Connection section,
soulseek gains one too, and both take over the whole app while nothing is stored.

TRANSMISSION_URL/USER/PASS/RPC_PATH and SLSKD_URL/API_KEY can come out of .env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 18:04:22 +00:00
pastilhasandClaude Opus 5 f20d4a300e invoiceshelf: accounts are configured from the ui, not the environment
The same registry photos got: any number of labelled instances stored encrypted in
invoiceshelf_accounts, one selected, switchable from the nav. The token is write-only across
the sidecar boundary — the list has no field that could carry it back — and nothing reads
INVOICESHELF_URL/TOKEN/COMPANY_ID any more, so officer's own process.env no longer holds a
credential only the sidecar can use.

The company is pinned on the account row rather than resolved per request. InvoiceShelf's
`company` header does not error on a wrong or missing value; it silently returns another
company's books. So the choice is made once, at add time, and a token that can act for several
answers 409 with the list instead of guessing.

Both apps also take an email and password now, because neither service makes a key easy to get:
InvoiceShelf 2.4.2 ships no screen that issues tokens at all (POST /auth/login is the only way),
and Immich's is buried in account settings. The sidecar does the exchange — InvoiceShelf mints a
Sanctum token, Immich logs in, creates an all-permissions API key and closes the session again —
and stores only what comes back. The password is never persisted. Pasting a key still works.

Verified against the live instances: InvoiceShelf 2.4.2 and Immich 3.1.0, routes and DTOs read
from the running containers. The two sign-in paths are untested end to end — no second login to
try them with.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:44:28 +00:00
pastilhasandClaude Opus 5 632d5a1c1f photos: immich accounts are configured from the ui, not the environment
IMMICH_URL/IMMICH_API_KEY lived in the platform-wide .env, which was wrong
twice over: bun auto-loads .env into every process started in this directory,
so `officer` itself held an immich credential it has no code to use — and
connecting a library was a shell task on the server rather than something the
owner could do from the app.

it is a registry, not a single connection: any number of labelled accounts with
one selected, the same shape headscale_servers uses. two keys against the same
instance (one per immich user) is the ordinary case, so the label is what has to
be unique, not the url. one active account per owner is enforced by a partial
unique index rather than by convention.

keys are encrypted at rest and write-only across the sidecar boundary — no route
returns one, masked or otherwise. every save is validated against the live
instance first, so a wrong or under-scoped key is a 400 with the reason instead
of a stored row that makes every later screen fail mysteriously.

the drizzle snapshot under migrations/ is regenerated; nothing applies it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:05:58 +00:00
pastilhasandClaude Opus 5 c77e7ee598 photos: inspect the pictures behind a map cluster
clicking a cluster only zoomed, so a circle marked 700 was unopenable at any
zoom level that still grouped them. it now opens a lazy list of exactly the
assets that cluster covers, independent of zoom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:05:49 +00:00
pastilhasandClaude Opus 5 67d6a9702a scripts: name the desktop setup for what it installs
Section 17 was still labelled "XFCE + VNC" while the step it runs installs
ubuntu-desktop and is guarded on it, so the heading described a setup the
script had already stopped producing.

Also spell out why setup-desktop.sh disables lightdm: it is not a display
manager this script ever installs, it is residue on hosts set up by an earlier
version that did install XFCE, and left enabled it beats GDM to the seat.

Comments and one echo string; no behaviour change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:32:27 +00:00
pastilhasandClaude Opus 5 cc95f7fa17 run an agent from the file browser
Right-click an entry and agents whose triggers match appear under their own Run Agent
submenu — deliberately not folded in with tasks, because an agent run is a chat session and
not a job, and one menu promising both would lie about what a click does. The modal shows the
absolute target path, autofills entry_path with it (tilde expansion stays the server's job),
and on Run links to /chat?cwd=<runs dir> instead of a queue entry: there is no job row to view.

Trigger matching and category grouping are now shared with tasks rather than duplicated, and
the task input form is reused as-is.

Rescan counts agents and invalidates their caches, so a new AGENT.md shows up on the button
rather than after the 60s staleTime.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:51 +00:00
pastilhasandClaude Opus 5 6b46d9f1f9 chat: put the session list cwd in the url, not a panel channel
Which project group you are looking at is addressable state, so /chat?cwd=<dir> has to be a
link anyone can hand out — it survives a refresh and an agent run can point straight at its
own runs directory. Was usePanelChannel('chat:active-cwd'), which per the navigation audit is
for signals and refresh buses only. Row links and New Chat now carry the query string along.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:44 +00:00
pastilhasandClaude Opus 5 47d03de307 chat: carry claude's own session uuid on the result event
The sidecar knew which transcript file a turn had landed in and kept it to itself —
setClaudeSession fed --resume and nothing else. A session officer started was therefore
unaddressable from the platform side. Put it on the result event so it crosses the wire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 00:28:39 +00:00
pastilhasandClaude Opus 5 035a1ba8f6 add photos, an immich-backed library behind its own sidecar
officer-photos owns the whole Immich contract: the instance URL and the API
key live there and nowhere else, and the platform side is an auth-gated
forwarder holding no credentials. The route surface is an allow-list keyed on
the first path segment, so admin, auth, api-keys, sessions, jobs, system-config
and libraries are unreachable by construction rather than by enumeration.

The UI mirrors Immich's own sidebar — timeline, explore, map, search, albums,
people, favorites, sharing, archive, trash — because the point of a sidecar
screen is to reproduce what the upstream already ships, then extend it. The
timeline reads Immich's columnar time-bucket format directly; selection lives
in the URL per docs/navigation-audit.md.

Two things worth knowing for anyone touching this later:

- `duration` is an integer count of milliseconds in Immich 3.0. It was an
  HH:MM:SS.mmm string before, and every stale example still shows that form.
- the map container is sized with h-full/w-full, never `absolute inset-0`.
  maplibre's stylesheet sets `position: relative; overflow: hidden` on the
  element it is given, and an unlayered vendor rule beats Tailwind 4's layered
  `.absolute` regardless of source order — so the div collapses to height 0 and
  clips its own canvas away. Nothing errors: the GL context is healthy, tiles
  download and pixels are drawn into a buffer nobody ever composites.

maplibre-gl is pinned to 5.x deliberately; 6.0 resolves a separate worker file
from import.meta.url, which Officer's index.html fallback answers with HTML.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 00:18:33 +00:00
pastilhasandClaude Opus 5 4f4e0c5dbc music: drop the fs watcher, and let the incremental reindex self-heal
Bun's recursive fs.watch takes one inotify watch per ENTRY, files included —
~92k for this library against a 65536 ceiling — so the watch could never be
established. The ENOSPC came back asynchronously as an FSWatcher 'error' event
with no listener, which rethrew and killed the sidecar 17k times, draining the
per-UID watch pool for every other process on the machine along the way.
Reindexing is triggered instead (the browser button, the phone's pull-to-refresh,
the nightly full); an incremental over 6273 folders measures 1.8s.

Three index defects the nightly full had been papering over:

- outputsExist verified meta.json/cover.jpg/discography.json but neither lyrics/
  nor posters/, so a lost lyrics file kept a matching v and a passing check and
  the album was skipped on every incremental forever — only a full restored it.
  Record both counts in the manifest and compare them (CACHE_VERSION 2 -> 3).

- walk() read a failed readdir as "the folder is gone", and runBuild prunes
  whatever is missing from next — so one transient EIO on the library disk
  deleted that folder and its whole subtree from the index. Carry the previous
  entries forward for every error but ENOENT/ENOTDIR.

- a from-scratch build has no previous entries to carry, so it now refuses to
  publish a slot when any folder was unreadable, leaving the live index alone.
  A disk that hiccups during the nightly costs a skipped night, not a hole.

reindexNow builds in place, so a cache-format upgrade is handed to the staged
path rather than rewriting 6k albums underneath live readers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 17:41:56 +00:00
pastilhasandClaude Opus 5 00117206d9 vnc: reclaim port 5900 before mirroring, and do not trust a foreign listener
Re-applies to the mirror what f22c667 did for Xvnc, which I dropped by restoring vnc-manager from an
older commit. The bug is in the lifecycle, not the server, so it came straight back: the running mirror
lives in module state, a sidecar restart forgets it while the process keeps running, and waitForPort
accepted ANY listener on 5900 as proof of a healthy start.

It bit immediately. An Xvnc left over from the virtual-desktop experiment kept hold of 5900, so the
restarted sidecar reported a healthy mirror while the browser was being served the stale XFCE session
underneath it — which read as "the revert did not work".

reclaimPort frees the port before spawning (TERM, then KILL after two seconds) and waitForPort now also
fails when the process we spawned has exited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:39:52 +00:00
pastilhasandClaude Opus 5 25da4dc31d vnc: back to mirroring the physical screen, at 1920x1080
Reverts the Xvnc virtual-desktop work (83bf746, f22c667). The separate desktop was the right answer to
"4K on the TV and 1080p remote", but it brought a chain of its own problems — a dock left stranded
below the bottom of the screen after every resize, because xfce4-panel does not follow a RandR change
reliably — and the owner would rather have one session that works than two that need supervision.

So: one GNOME session, mirrored, with the TV set to 1920x1080. That is under SCALE_ABOVE_WIDTH, so
x11vnc serves it 1:1 with no scaling, and both ends see the same 1920x1080 desktop.

resizeSession is now off on the client — a mirror reflects a physical screen and cannot be resized.
scaleViewport stays ON and is load-bearing: noVNC maps a click as
(clientX - canvasRect.left) / display._scale, and autoscale() is the only code that sets the canvas's
displayed size and _scale in the same call. Disabling it pins _scale at 1 while the canvas is displayed
at some other size, which doubled every pointer coordinate. That was my change and my bug.

Kept from the Xvnc detour, because all of it applies to the mirror too: the display is discovered by
socket ownership rather than assuming :0 (GDM gives :0 to its greeter), -noxdamage is gone, the clip to
the primary output stays, noVNC loads as one bundle, and showDotCursor covers the invisible pointer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:35:22 +00:00
pastilhasandClaude Opus 5 5ba89ae74d desktop: restore scaleViewport — disabling it doubled every pointer coordinate
Reverts the scaleViewport=false half of 69c750d. That change was wrong and it caused a worse bug than
the one it was aimed at.

noVNC maps a click as (clientX - canvasRect.left) / display._scale. autoscale(), which only runs when
scaleViewport is enabled, sets the canvas's displayed size and _scale in the same call, so the two are
consistent by construction. With scaleViewport off, _scale is pinned at 1 while the canvas continues to
be displayed at some other size and nothing reconciles them.

Measured on the owner's session against a 1109x715 desktop: pointing a quarter of the way across
registered at x=575 (51%), and pointing at the middle saturated at x=1108, the right-hand edge. A clean
factor of two in both axes. Clicks near the right and bottom edges still appeared to work, because
doubling an already-large coordinate clamps back onto the edge it was aimed at — which is why the
panel clock and the dock stayed clickable while the middle of the screen did not.

My reasoning for the original change — that resizeSession and scaleViewport are alternatives — was
simply wrong. They are complementary: resize matches the session to the container, scaling covers the
interval before the server honours it, and scaling is also what keeps the coordinate maths honest.

showDotCursor stays: the invisible pointer was a real and separate defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 23:20:36 +00:00
pastilhasandClaude Opus 5 4cf39a6ed3 desktop: show a dot cursor when the server sends no cursor shape
The pointer was invisible, not misplaced. noVNC hides the browser's own cursor over the canvas and
draws the remote cursor in its place, but initialises that image to RFB.cursors.none — so until the
server sends a shape, nothing is drawn AND the real cursor stays hidden. The pointer simply vanishes.

Measured before changing anything: against a 1109x715 session the pointer covered x 5..1108 and
y 11..714, a clean 1:1 map with no clamping or scaling. Clicks were landing exactly where they were
aimed the whole time; the cursor just could not be seen, which is indistinguishable from a desync
when you are the one trying to click something.

showDotCursor renders a dot in exactly that case. It does not mask a real problem: when the server
does supply a shape, the shape still wins.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:19:56 +00:00
pastilhasandClaude Opus 5 69c750dd72 desktop: resizeSession without scaleViewport, so the cursor lands where you click
Both were enabled. They are alternative strategies, not complementary ones: resizeSession asks the
server to become the container's size, scaleViewport scales whatever the server sends to fit. Running
both means noVNC resizes AND then applies a scale factor, and any gap between the size requested and
the size actually granted leaves a fractional scale that every pointer coordinate is mapped through.
The visible symptom was a cursor offset from the real pointer, with clicks landing somewhere else.

It only started mattering with the move to Xvnc. x11vnc could not resize, so resizeSession was inert
and scaleViewport did all the work; Xvnc implements RandR SetDesktopSize, so now both are live and
they interfere.

Scaling is redundant now the session genuinely becomes the container size: pixels are 1:1 and there
is no coordinate arithmetic left to get wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 22:13:32 +00:00
pastilhasandClaude Opus 5 f22c667502 vnc: reclaim the port before starting, and do not trust a listener we did not spawn
Closes the TODO item about orphaned/duplicated VNC servers across sidecar restarts. The diagnosis
there was correct and outlived the move off x11vnc, because the shape of the bug is in the lifecycle,
not the server: the running desktop lives in module state, a sidecar restart forgets it while the
process keeps running, and waitForPort accepted ANY listener on 5900 as proof of a healthy start.
The next start would then spawn a server that could not bind the port, see the ORPHAN listening, and
report success — leaving the platform convinced it had started a desktop the browser was not
looking at.

Two changes. reclaimPort frees the port before spawning: TERM whatever holds it, KILL after two
seconds. waitForPort now also fails when the process we spawned has exited, so a foreign listener
cannot be mistaken for our own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:22:30 +00:00
pastilhasandClaude Opus 5 83bf746ab8 vnc: a dedicated virtual desktop instead of mirroring the screen
The TV runs at 4K so it can play 4K video; a usable remote desktop wants about 1080p. One framebuffer
cannot be both, so mirroring meant every remote session was a scaled-down 4K desktop — dense to read
and expensive to encode. This gives remote its own display at 1920x1080 and leaves the TV alone.

Xvnc (TigerVNC) rather than x11vnc: it is the X server AND the VNC server in one process, so nothing
polls or scales — the server knows which rectangles changed and encodes them directly, where x11vnc
had to diff a framebuffer it did not own. It also implements RandR SetDesktopSize, so the client's
existing resizeSession makes the desktop resize itself to the browser panel. No scaling on either
side at any panel size, which removes the density problem rather than trading it for blur.

XFCE rather than GNOME, and NOT because it is lighter. Ubuntu's GNOME is managed by per-USER systemd
units — org.gnome.Shell@x11.service, gnome-session-manager@ubuntu.service and the whole
org.gnome.SettingsDaemon.* set all sit under user@<uid>.service, and gnome-session@.target is marked
RefuseManualStart. A second GNOME session for the SAME user collides with every one of them. That is
almost certainly what 106e5bd recorded as "a ghost logind session that broke lightdm login" — a
property of the session model, not something care avoids. XFCE has no such per-user units and
coexists with the TV session: same user, same home, same files, different shell.

The cookie goes in ~/.vnc/Xauthority, not ~/.Xauthority, which belongs to the TV session and is not
ours to write. Display is chosen by scanning for a free number from :2 up, checking the lock file as
well as the socket because a stale lock alone stops an X server starting. Teardown kills the session
before the server, since killing Xvnc first leaves XFCE's children reparented and running.

Verified end to end on a scratch display before committing: Xvnc listened, XFCE came up with xfwm4,
xfdesktop, xfce4-panel and xfsettingsd, RandR reported a 1920x1080 VNC-0 output, and the GNOME
session on the TV was untouched throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:21:23 +00:00
pastilhasandClaude Opus 5 51b249b90a desktop: load noVNC as one bundle instead of 42 modules
public/novnc is unbundled noVNC source. The dynamic import pointed at rfb.js, so the browser walked
the module graph natively: fetch a file, parse it, discover its imports, fetch those, repeat. The
graph is 42 modules and six levels deep, so opening the desktop cost six SEQUENTIAL round trips and
42 requests before the VNC handshake could even start — and a hard refresh pays it in full every
time. Over the tailnet that is the "takes a long time to load", not the pixels.

Bundled with bun: 52 modules to one 190 KB file, 56 KB gzipped, one request. The regeneration
command is in a comment next to the constant so a future noVNC bump does not silently keep serving
a stale bundle.

The source tree stays: it is what gets bundled, and keeping it makes the diff of a version bump
readable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:52:50 +00:00
pastilhasandClaude Opus 5 794420810c vnc: count enabled outputs, not connected ones
A screen that is plugged in but switched off still reports "connected" to xrandr while contributing
nothing to the framebuffer — which is exactly the state this machine is now in, with the unused KVM
output disabled. Counting those made the mirror take the multi-output path and clip to the primary
when there was only one live screen; harmless here because the clip equalled the whole framebuffer,
but wrong, and it would have masked a real single-screen case.

Only an enabled output carries a WxH+X+Y geometry, so requiring that in the pattern is what tells
the two apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:31:06 +00:00
pastilhasandClaude Opus 5 2ddefa000c vnc: stop disabling XDAMAGE
-noxdamage was set in 106e5bd, the commit that introduced the mirror. That message explains every
other flag it chose — -scale, -shared, -forever, -localhost — and says nothing about this one, and
no doc or TODO mentions it either. It looks defensive rather than diagnosed.

It is not free. Without the DAMAGE extension x11vnc is never told which rectangles changed, so it
polls the entire framebuffer over and over to discover it. Cost then scales with screen AREA,
continuously, instead of with what actually moved. On a 4K mirror that dominates: it is why the
remote desktop here felt slow next to a machine with half the hardware and a sixth of the pixels.

xdpyinfo on this server reports DAMAGE, MIT-SHM and XFIXES, so the optimisation is available and
was simply switched off.

Kept as a comment rather than deleted silently: if stale patches ever appear (some drivers do
under-report damage) putting it back is the fix, and the next person should know that is the trade
rather than rediscovering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:30:13 +00:00
pastilhasandClaude Opus 5 c14cce6376 vnc: clip the mirror to the primary output
X composes every attached output into one framebuffer, so with a 4K monitor at +0+0 and a 1080p TV
at +3840+0 the framebuffer is 5760x2160 and mirroring it whole sent BOTH screens side by side,
then halved them for being over the scale threshold. The remote desktop showed a squashed double-width
image with the second monitor hanging off the right — correct, and useless.

Clip to the primary output instead: 3840x2160+0+0 here, which then scales to a clean 1920x1080.

Only clips when a primary is actually marked AND more than one output is connected. With a single
output the framebuffer already IS that screen, so clipping would add a failure mode for no gain.
The scale decision now keys off what is really being served — the clip when there is one, the whole
framebuffer otherwise — instead of a framebuffer width that may span screens.

Never showed up under LightDM because only one output was ever live there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 20:03:40 +00:00
pastilhasandClaude Opus 5 3b941f4d1f vnc: find the display instead of assuming :0
The mirror hardcoded :0. That held under LightDM, which gave the user session :0. GDM does not —
it keeps :0 for its own greeter and starts the user's Xorg with -displayfd, letting the number be
picked at runtime; on this machine the session lands on :1. So after migrating to Ubuntu Desktop
the mirror failed on every attempt with "Can't open display :0", with a healthy session sitting
one number over.

Resolve by socket ownership: /tmp/.X11-unix/X<n> is owned by whoever runs that X server, so the
socket owned by us is the owner's session and anything else is the greeter's. Falls back to the
lowest socket (a root-run Xorg, as LightDM had) and finally to 0, so it is never worse than the
constant it replaces.

The display is now threaded through rather than read from a module constant, so getFramebufferWidth
measures the display actually being mirrored and both log lines name it.

Found while verifying the XFCE-to-GNOME migration on this machine — the session was up and x11 with
the cookie in the GDM path, and only the display number was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 19:33:25 +00:00
pastilhasandClaude Opus 5 525557a952 data-path: the agents item type and run directory the router needs
Second half of c69cda4. The agents router imports getAgentRunsDir from data-path, which was still
an uncommitted edit, so a clone got past the missing-module error only to fail on a missing export:

  SyntaxError: Export named 'getAgentRunsDir' not found in module '.../src/servers/data-path.ts'

My check before c69cda4 verified that every module the agents files import is TRACKED, but not that
the SYMBOLS they import actually EXIST in the committed version of those modules. Module resolution
and named-export resolution are separate failures and the first check only covered the former.

The whole diff to this file is one feature — 'agents' joins ItemType/ITEM_TYPES, and
getAgentRunsDir is added — so it goes in whole rather than split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:23:50 +00:00
pastilhasandClaude Opus 5 c69cda480d add the agents router files that 3f22a80 referenced but never committed
3f22a80 committed hono.ts with `import { agentsRouter } from './api/agents/agents'` while
src/servers/api/agents/ was still untracked, so master has been unbootable for any clone:

  error: Cannot find module './api/agents/agents' from '.../src/servers/hono.ts'

That import line was work in progress from a parallel session that happened to be sitting in
hono.ts; staging the file to mount the notify router swept it in. The machine it was committed
from kept working because the files were there on disk, which is exactly why it went unnoticed.

Committing the three files completes what that commit already assumed. Verified first that every
module they import is tracked, and that the one cross-boundary import (`TurnMessage` from
../chat/types) is `import type`, so it is stripped at runtime and does not depend on the still
uncommitted edit to that file.

The frontend half of the same feature (AgentRunnerDialog, AgentRunnerModal, useAgents) is still
untracked and deliberately left that way — no committed file references it, so it cannot break a
clone, and it is not mine to commit.

A repo-wide scan of all 1278 tracked TS files in HEAD for imports resolving to untracked or missing
modules now comes back clean apart from index.gen.html, which is generated at boot by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 15:21:13 +00:00
pastilhasandClaude Opus 5 7d5e73cba8 soulseek: let a filtered result expand back to its whole folder
Filtering a big search is how you find an album, not just how you hide
files: you type one track you know, and the folder that comes back is the
one you want. But the filter had also stripped that folder down to the one
track, and "download folder" then queued only that track — so finding the
album and losing it were the same act.

A folder now keeps its whole self alongside its matching files, and says
"3 of 24". Expanding shows the album, keeps the matched track highlighted,
and widens every download button in it to the full folder. A peer whose
other folders matched nothing can be expanded the same way, since the album
you found usually sits next to the rest of the artist.

Nothing queues what isn't on screen: each button downloads exactly what is
shown under it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 13:06:02 +00:00
pastilhasandClaude Opus 5 93bf617151 soulseek: open a search's results when you start it
A history row is only as fresh as the last list load, and the list was only
read on mount — so a search you had just started sat at 0 responses looking
stalled until you navigated away and back. slskd's own web UI goes straight
to the search when you submit, and that view already polls while the search
runs, so follow it.

Which search is open now lives in ?search=<id> rather than local state: rows
are real links, Back returns to the history, and a reload keeps your place.
The list also keeps re-reading while any search is still running.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:51:23 +00:00
pastilhasandClaude Opus 5 82290bb3e0 add /qr-transfer — offline file transfer over animated QR
TEMPORARY / EXPERIMENTAL, at the owner's request, after deedy/qr-data-transfer (QRFerry).
Two panels: one loops a file as QR frames, the other scans them through the camera and
rebuilds it. Entirely client-side — nothing about a transfer reaches the server, which is
the point of the technique.

NOT RaptorQ, and that is the one real design decision here. QRFerry carries RFC 6330
fountain-coded symbols so a receiver can rebuild from ANY sufficient set of frames. This
uses a plain indexed carousel instead, for two reasons — the second being the deciding one:

  1. RFC 6330 is days of work and unpleasant to debug.
  2. The sender and receiver are being reimplemented on iOS and Android. A format one person
     can re-derive from protocol.ts in an afternoon is worth more here than optical
     efficiency. Every frame is independent, self-describing, and parses with a string split.

The cost is honest and written down: without fountain coding you must eventually capture each
specific frame, so a miss waits for the next pass rather than being covered by surplus. Fine
for a few hundred KB on a steady camera; it degrades where RaptorQ would start to pay for
itself.

Details that matter for the phone implementations:
- base64url, no padding — ':' and '/' would collide with the field separator.
- CRC-32 of the whole file in the meta frame, checked after reassembly. The test pins the
  reference value for "123456789" (cbf43926) so any stock implementation will agree.
- The meta frame repeats every 12 frames, so a receiver joining late learns the filename and
  total without waiting a full cycle.
- Error correction level L: frames are short-lived and repeated forever, so QR capacity is
  better spent staying sparse enough to scan than on recovery.

16 tests over the protocol, which is the spec the other implementations should match.
The receiver needs a secure origin for camera access; over the tailnet with HTTPS that holds,
and it says so plainly rather than failing silently on plain http.

Adds qrcode and jsqr. @types/qrcode was already present and orphaned — its runtime package had
been removed with the chat-channel cleanup.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 02:17:20 +00:00
pastilhasandClaude Opus 5 dc1b0636bf notify: a temporary test button on /jobs
The first producer, so the pipe can be exercised from the UI before any real event is wired
to it. Marked TEMPORARY in the source and meant to be deleted when a real producer replaces
it.

POST /_officer/notify now takes the user from the X-Officer-User header the proxy injects
when the body omits it. A producer inside the tailnet says who to notify; a browser reaching
this through /api/notify cannot know its own id, and the platform has already authenticated
whoever sent it. Body still wins where present.

The button sends { type: 'test' }, which fans out to every configured channel — Discord
today, APNs and FCM the moment their credentials exist — and toasts what each one reported,
so "no channels configured" is distinguishable from "sent".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 22:36:16 +00:00
pastilhasandClaude Opus 5 93796d882a notify: the FCM channel
Step 4, and the last transport. Google directly via FCM HTTP v1 — no Expo, no firebase-admin.

Unlike APNs the signed JWT is not the credential: it is an assertion exchanged at
oauth2.googleapis.com for a 1-hour access token, so there are two things to cache and a
network round trip on the cold path. Concurrent pushes share one in-flight exchange rather
than each starting their own, and the token refreshes five minutes early so a request cannot
race the expiry.

The detail that breaks most first FCM integrations: `message.data` values must all be
STRINGS. A number or boolean is rejected with a bare INVALID_ARGUMENT that does not say which
field. Everything is stringified on the way out — id, ok and count included — and a test
walks every value asserting its type rather than trusting the code that wrote it.

Also v1-specific: there is no multicast (the /batch endpoint is deprecated), so N devices is
N requests, which happens to match the APNs shape anyway.

Errors are read for `error.status` only. FCM's `message` field can echo the device token, so
logging the whole body would put device addresses in the logs. UNREGISTERED / INVALID_ARGUMENT
/ NOT_FOUND delete the row; anything else counts a strike.

21 tests across both channels, and verified against the real endpoint: a throwaway key gets
400 invalid_grant "account not found" from Google, meaning the endpoint, form encoding,
grant_type, RS256 signature and claim structure were all accepted and only the account is
missing. A malformed assertion would have failed earlier, with a different error.

The doorbell rule is asserted on this channel too: a producer passing subject/from cannot get
either into the serialized message.

Still needed for a real send: FCM_SERVICE_ACCOUNT, a Firebase project, and google-services.json
in the Android build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:57:29 +00:00
pastilhasandClaude Opus 5 707a2a5ba3 notify: the APNs channel
Step 3. Apple directly over HTTP/2 — no Expo, no library, just node:http2 and node:crypto.

Three things here are the difference between working and a silent failure:

- The provider JWT must be signed with dsaEncoding 'ieee-p1363'. Node's default is DER, which
  is a perfectly valid ECDSA signature that Apple rejects, and the rejection says nothing
  about why. A test asserts the signature is 64 bytes rather than trusting the flag.
- Apple rejects a token minted more than once per 20 minutes and any token older than 60, so
  it is cached and refreshed at 40 — between the two walls, not per request.
- APNs expects ONE long-lived HTTP/2 session carrying many requests. A session per push gets
  throttled, so sessions are kept per host and re-created only when they die, with an error
  handler so a transport failure cannot take the sidecar down as an unhandled rejection.

Sandbox and production are separate hosts and separate token namespaces, so devices are sent
per their stored `environment` — a debug-build token against production fails with
BadDeviceToken and no other symptom.

Dead tokens (BadDeviceToken, Unregistered, DeviceTokenNotForTopic) delete their row
immediately; everything else counts a strike. Going direct means Apple answers inline, so
none of Expo's deferred receipt-polling is needed.

The doorbell rule is now enforced by a test, not just by convention: a producer that passes
subject/from/body — which the type forbids but JavaScript permits — cannot get any of it into
the serialized payload. `aps.alert` carries a generic title composed from the category, and
the custom `officer` key carries ids the app fetches by.

12 tests, 100% of the JWT and payload paths. Verified against the real endpoint too: a
throwaway key gets 403 InvalidProviderToken from api.sandbox.push.apple.com, which means the
connection, path, apns-topic, push-type and JWT structure are all accepted and only the
credential is missing.

Still needed for a real send: APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, and a device token
from the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:45:48 +00:00
pastilhasandClaude Opus 5 3f22a808d6 notify: the sidecar shell, with Discord as the first channel
Step 2 of docs/push-notifications.md. One real channel working end to end before any Apple
or Google credential exists, so the pipe is proven before the hard part.

officer-notify is a PM2 peer with its own loopback listener, announced as notify:server and
proxied at /api/notify. It is a sidecar rather than platform code because the producers are
spread across sidecars — the queue, email, the agent — and a platform-owned notifier would
force every one of them to call back into the platform. That is the inversion just removed
from email; this avoids recreating it.

Channels sit behind one interface (types.ts) so APNs and FCM slot in beside Discord rather
than replacing anything. Each is awaited with its own error boundary and the dispatcher
always resolves: a job that finished has finished whether or not a banner appeared, so a
channel must never be able to break its producer.

text.ts is where the doorbell rule is actually enforced. APNs and FCM both need a title to
render a banner, so "send nothing" was never available — what we control is that the string
is composed HERE from the category alone. A producer sends { type: 'mail', count: 3 } and
the wire carries "3 new emails". It cannot carry a subject line because there is nowhere to
put one.

Device registration lives behind X-Officer-User, trusted because the listener binds loopback.
Platform and environment are validated rather than defaulted: an iOS token from a debug build
fails against production APNs with a silent BadDeviceToken, so a wrong value is a device that
never receives anything and never says why. GET /_officer/devices returns only the last 8
characters of a token — enough to identify a row, not enough to push to it.

Verified end to end against a fake webhook: /_health reports configured channels, a test
notification arrives as {"content":"Officer"}, { type: 'mail', count: 3 } arrives as
{"content":"3 new emails"}, and every validation path returns its own error.

Deletes src/servers/notify/discord.ts, which this supersedes and which had no other callers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:36:17 +00:00
pastilhasandClaude Opus 5 15f262539f push notifications: design, and the device registry
Design in docs/push-notifications.md. The short version: Apple and Google are unavoidable —
iOS suspends apps so only APNs can wake one, and Android only accepts pushes from FCM — but
Expo is not. Its push service is a relay in front of both and does not remove either
credential, so we talk to Apple and Google ourselves.

Both protocols were verified in Bun before designing around them: node:http2 works as a
client (APNs is HTTP/2-only), and ES256 signing produces the raw 64-byte r||s form Apple
requires rather than Node's default DER, which is silently rejected. No push library is
needed for either channel.

The load-bearing decision is the payload: a push is a doorbell, not a message. Apple and
Google see metadata regardless, so they must not also see content — a notification carries a
category and an id, never a subject, sender or error, and the app composes the visible text
locally and fetches the real thing over the tailnet on tap.

This commit is the registry: push_devices, holding native APNs/FCM tokens. environment is a
column because APNs sandbox and production are different hosts AND different token
namespaces — a debug-build token fails against production with a silent BadDeviceToken, so
guessing is not an option. Registration upserts on (token, bundle_id) because tokens rotate
and the app re-registers every launch. Failure counting prunes dead tokens; a hard rejection
deletes at once.

Nothing sensitive lands here: a token is useless without the APNs key or FCM service
account, both of which stay in the sidecar's env.

NOTE: `bun db:push` will fail until the telegram/whatsapp/discord rows are deleted from
server_integrations — the CHECK constraint added earlier refuses while they exist. That is
the enforcement working, not a problem to route around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 21:27:07 +00:00
pastilhasandClaude Opus 5 cffb99b9d0 docs: untrack the migration test checklists
nav-test-checklist.md and email-migration-checklist.md moved to the workspace root, out of
version control.

They are working notes for a migration in progress — which checks have been clicked through
on this machine and which have not. That is state about one host at one moment, not
something a clone of the repo should carry, and it goes stale the instant the migration
finishes. The reference docs they were sitting next to are the opposite: they describe the
system and should travel with it.

The root CLAUDE.md, which pointed at both by their tracked paths, now points at the new
location and records the convention so the next one is put in the right place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:44:18 +00:00
pastilhasandClaude Opus 5 a83f9cd378 delete the orphaned build script, and fix the second copy of the memo rule
scripts/build/runtime.ts had no caller after the build:editor scripts went. Its own usage
text gives away where it came from: --experiments, --tracking, --editor-setup, the same
phantom domain as the docs and examples cleaned up earlier. Nothing else references it —
the `./runtime` export in src/workspaces/types/package.json points at a different file,
which is untouched. helpers.ts stays; dashboard.ts and web.ts import it.

APP_CONVENTIONS.md carried its own copy of "React 19's compiler handles memoization. Never
use useCallback or useMemo." Correcting only CONVENTIONS.md would have left the two
contradicting each other, which is worse than either. Both now say the same thing and one
points at the other for the reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 18:12:19 +00:00
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