editing one server then another without closing in between reused the same
mounted form, which seeds its fields at mount — so the second server showed
the first one's name, url and ssh host. submit diffs that stale state
against the new server prop, so saving would have written them to it.
policy: /_officer/policy GET/PUT. the text goes up byte for byte and
headscale's verdict comes back verbatim — it owns the only parser that
resolves groups, tags and hosts, so a second one here would disagree with
what actually enforces. a file-backed policy is still served over GET and
only refuses on PUT, so writability cannot be read: the first save finds
out, and a refusal becomes a persistent read-only banner rather than a
rejection the owner would go hunting for a syntax error over.
nodes: move between users, and a tag editor for the setTags route that had
no ui. both sit together in the expanded card because both decide which
policy rules apply to a node, and a move says so before it happens.
Reads the per-server Officer Companion (COMMS/HEADSCALE_COMPANION_API.md): container health with its
own diagnosis, a log snapshot, a live SSE tail, and restart/stop/start.
The companion sits at ${server.url}/officer-api behind the same admin key we already store, so there
is nothing new to register — but only the sidecar can decrypt that key, so the sidecar proxies it and
the browser never talks to the companion directly. That also rules out EventSource for the stream
(no Authorization header), which is why the tail is fetch() + a hand-rolled SSE reader.
Two inversions the code is built around: /health is always HTTP 200 and must be read by verdict, and
an absent companion is a state to render rather than an error — an HTML 502 is nginx, a JSON 502 is
the companion reporting a failed docker op, and the admin API on the same domain is independent
either way.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every diagnostic in this app goes through headscale's API, which is exactly the
channel that is gone when you most need it — headscale crashed, the tailnet is
down, the logs are the only evidence. This adds the escape hatch: a per-server
SSH address and a Console section that opens a shell on that machine.
Deliberately thin. `headscale_servers.ssh_host` stores where to point ssh and
nothing else: no password, no key, no port. The console runs plain `ssh <host>`
in the same pty every other terminal panel uses, authenticating with whatever
~/.ssh on this box already knows. There is no credential here to protect and
this file must never grow one.
The address is NOT derived from the control-server URL and the form warns when
you type the same host into both — a console that resolves through the name
headscale serves goes down with it, which is the one thing it exists to survive.
It is also not validated on save, for the same reason: refusing to store the
escape hatch because the machine is unreachable is precisely backwards. Reaching
it is a separate, explicit Test connection button (BatchMode=yes, so a key that
needs a passphrase fails visibly instead of hanging on a prompt).
The host is validated to a conservative charset rather than quoted, because it
is typed into an interactive shell — rejecting `1.2.3.4; rm -rf /` while the
form is still open beats letting it survive to the shell as someone else's
problem. A jump host or an odd port belongs in ~/.ssh/config as a Host alias,
which the field accepts by name.
Also fixes a latent bug this would have hit immediately: TerminalView's
`initialInput` guard is scoped to a mount, so a remount typed the command again
into a live shell. A `replay` frame proves the session already ran it, so treat
it as sent. Harmless for `ls`; for the console it meant an ssh nested inside the
ssh you were already in.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found while reading the app, not while fixing anything. The policy editor is the
only one that costs an ssh session today.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chrome came back for a couple of seconds every few minutes during a film with
nobody touching anything. Only three things wake it, and none of them is periodic —
so the culprit is a stray pointermove. A mouse sitting on a desk still emits the odd
one-pixel event, a bumped table emits a few, and the browser synthesises a zero-delta
move of its own when the cursor style changes, which this player does every single
time it hides the cursor.
A move now has to travel eight manhattan pixels from wherever the pointer last
genuinely woke it. The anchor only advances on a real wake, so a slow deliberate
drift still accumulates past the threshold — it is jitter around a fixed point that
stops counting.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The auto-hide timer was there and correct; `position` was in its dependency list.
That advances about four times a second while a video plays, so the effect re-ran
and cleared the pending timeout every ~250ms and the 2.5s never elapsed. The chrome
could only hide while playback was stopped — which is exactly when the code
deliberately keeps it up — so in practice it never hid at all.
Not a fullscreen bug, though fullscreen is where a permanent scrubber is
impossible to ignore.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things about the background-task strip, all the same complaint: the chip you
want is the one you cannot see.
Newest first, because the strip scrolls horizontally and the far end is off-screen —
chronological order put every task you just started exactly where you had to scroll to
reach it. Finished chips are capped at five, so a long session stops turning the tray
into a history; running ones are never counted or dropped, since watching them is the
whole point. And the default horizontal scrollbar is ~15px tall and laid out inside the
row, which is what made the chips read as squeezed — it is thin now, and the row and
chips have the padding back.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the gap limit was 1000, hardcoded, with the comment noting that raising it costs
node CPU and not correctness — and no way to raise it. it is the one thing about
a scan only the owner can know: how many addresses their old wallet handed out
and never had paid. RescanOptions threads from the POST body through the backend
and the source to utxos/scan, capped at 100k because past that the scan takes
longer than anyone waits. the card gets a "search depth" field beside the button,
blank meaning the default.
deleting a wallet with no seed took one unconfirmed request. the dialog asked for
the wallet's name and then threw the answer away, so the check existed only for
whoever went through the dialog — a node wallet still holds the credential, the
labels and the freezes. confirmName now travels with the request and the route
enforces it. a bodyless DELETE is told which field is missing rather than that
its JSON did not parse.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
four wallet defects, none urgent, all cheap:
selectCoins let an explicit coin-control pick override a freeze. naming an
outpoint now overrides only the confirmed-only default; frozen is absolute.
balances counted frozen coins in onchainConfirmed, so Send showed a figure a
max-value spend could not reach. Balances gains onchainFrozen — a component of
confirmed, not a deduction — filled at the route layer, because freezing is
Officer policy in Postgres and no backend can see it. The route only reads utxos
when something is actually frozen. Send subtracts it under "Spendable";
Overview lists it beside unconfirmed.
a rescan in flight upstream was invisible after a sidecar restart, and a second
POST would have queued behind it (scantxoutset is single-threaded node-wide).
adoptRescan polls an existing NBXplorer scan instead of starting one, and the
GET route falls back to it when local state is gone.
the per-variant scan deadline counted queue time, so a variant that sat behind
another wallet's scan timed out without ever having run. the deadline now
refreshes while the status reads Queued.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two holes found auditing the wallet sidecar after the frozen-utxo fix.
the brute-force backoff lived inside UnlockSession.unlock alone, so /unlock capped
at five guesses a minute while export-seed — the one endpoint that returns the words
in the clear — took unlimited ones. every passphrase check now goes through the same
guard. verifyPassphrase rethrows LOCKED_OUT rather than folding it into `false`, so a
caller can tell "wrong" from "stop".
nextUnused advanced its mark on every issuance, paid or not, so a run of unpaid
addresses walked it past the end of the window the next scan covers; a payment there
would never be found again, and esplora has no rescan to go looking. sources now
declare how far past a scan's last index they can still see, and issuance clamps to
it — re-offering a virgin address rather than handing out one that could lose money.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
freezing a coin promised it would not be spent, and the promise only
held when the caller named outpoints explicitly. an ordinary send picked
its own inputs from a snapshot in which every utxo said frozen: false —
the chain has no idea what officer froze — so selectCoins, which has
always filtered on that flag, never saw one set. sendAll was the worst
case: "send everything" swept the frozen coin too.
the frozen list now travels with the request, set by the route and
overwritten if a caller supplies one. it can only ever restrict what is
spendable, so smuggling a value in gains nothing. the backend still
reads no officer table.
three tests pin it, including a control that sends successfully once the
coin is unfrozen — without it the other two would pass on a wallet that
could not spend at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the route typed its body as {name, defaultBip, config} and passed the
parsed object straight to updateWallet, which also accepted sealedSeed
for the passphrase change. a TypeScript annotation strips nothing at
runtime, so any authenticated caller could send a sealedSeed key and
overwrite the encrypted seed — no passphrase, no unlock. encryptSecret
encrypts nonsense happily, so the damage would have surfaced at the next
unlock, not at the write.
updateWallet can no longer touch the seed at all; resealing moves to
replaceSealedSeed, whose only caller has already proved knowledge of the
old passphrase. the route rebuilds its patch field by field as well, so
the next field added there cannot re-open it.
verified against the test wallet: the envelope is byte-identical after
the same request that previously would have replaced it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the card keyed off `sync.rescan` being null, which is what esplora
reports — and equally what nbxplorer reports before its first scan. so
the guard could not tell "unsupported" from "not yet run" and resolved
it the wrong way: every esplora wallet got a button that 501s.
canRescan carries the distinction explicitly.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
registering an xpub only indexes it from that moment on, so an imported
seed with history read as a confident zero: every call succeeded, the
coins were simply absent. scantxoutset walks the node's current utxo set
directly and finds them regardless of when the account was registered.
runs all four script variants sequentially — the funds could be on any
one — and surfaces progress through the existing SyncState channel so
the balance says "scanning" rather than nothing. auto-fires on an
imported mnemonic only; a generated seed has no history to look for.
recovers spendable coins, not spent history.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Background tasks already had a row in the transcript, but a row scrolls away — and a
task started ten minutes ago is precisely the one you want to keep an eye on. The same
`role: 'task'` rows now also drive a tray docked above the chat input: a chip per task,
running ones pulsing, finished ones dismissable.
Clicking a chip opens what the task is actually doing right now. Nothing about that
crosses the wire between `task:started` and the notification, so it is read from the
file Claude Code streams the task into:
$TMPDIR/claude-<uid>/<project-slug>/<session-uuid>/tasks/<task-id>.output
For a backgrounded shell that file IS the log; for an agent it is a symlink to the
subagent's own transcript, which is ordinary session JSONL and so parses with the
reader we already had. Both kinds are therefore reachable from one directory.
Resolution is by task id alone, deliberately: the client learns a task id from
`task:started` and nothing else — officer's per-connection session key is not Claude's
session uuid, and the uuid only arrives with the turn result, long after the tray needs
to show the task. A task that has not written anything yet answers 200 `{kind:'pending'}`
rather than 404, because that is the ordinary first second of a task's life.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
it said fourteen; there are nineteen. photos, notify, caldav, memos and jellyfin have landed
since it was last written. also names the two mybiblepal entries that share this pm2 daemon
and belong to a different project, and points at `pm2 jlist` as the source of truth so the
next reader does not trust a list that goes stale every time a sidecar ships.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nothing the player did ever reached jellyfin, so nothing ever appeared in continue watching
or in the currently-playing list. the proxy was fine — a progress POST through the sidecar
moves the resume point upstream and answers 204. the client was the problem.
useClient() rebuilds its verbs on every render, so `report` had a new identity every render,
so `sendReport` did, so the effect whose cleanup reports the final position re-ran on every
render — and that cleanup nulls planRef. planRef went null a few milliseconds after the
stream opened and every report after that returned early. the ten-second heartbeat never
fired either: its interval was cleared and restarted on every render, and timeupdate renders
about four times a second.
both are now held in refs, and the two effects have honest dependency lists. same treatment
for the negotiate mutation, which react query also rebuilds per render.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sidecar now describes every audio and subtitle stream on the chosen source. a subtitle
jellyfin can deliver as an external file gets a /_jf webvtt path with the embedded ApiKey
stripped; one it cannot gets the reason instead, so the picker can show it disabled rather
than pretend it does not exist. colliding labels get their stream index — two tracks called
"English - ASS" are a coin flip otherwise.
quality is a named rung rather than a number. a bitrate the owner picks maps onto the rung
at or below it, which is what stops jellyfin's ResolutionNormalizer from inventing a
resolution from an unrounded number. auto stays absent so a copyable source still copies.
the control bar is ours in all three transports. progressive has no timeline the browser can
render, and an audio-track or quality change is a re-negotiation with the server rather than
something a <video> knows how to do — one bar for all three is what keeps those from being
three different players. the menus are hand-rolled because a radix dropdown portals to
document.body, which is outside the fullscreen element and would be invisible exactly when
the player is most likely to be used.
no scrubber thumbnails: they come from trickplay tiles and this server generates none.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a live transcode has no length and no byte ranges, so the progressive mp4 the player
used could not be seeked — a scrub restarted ffmpeg at a new offset. jellyfin's own
HLS playlist is VOD and spans the whole runtime, so seeking it is a segment request.
hls.js is checked in rather than installed. installs are frozen so that adding a
package is a reviewed act, and a committed file also has no install-time hook, which
is the vector the 2026-08-04 npm worm used. provenance, hashes and the update recipe
are in vendor/README.md; the tarball sha512 matches the registry's published integrity.
the sidecar now overrides VideoBitrate and MaxWidth on the TranscodingUrl jellyfin
hands back, for the same reason progressivePath computes them: jellyfin resolves that
bitrate from MaxStreamingBitrate (~119 Mbit, the ceiling that exists to let a stream
copy through) and sets no width, which asks a CPU-only container to encode 4K.
safari is deliberately not given the m3u8 — segment URIs are relative and would not
carry the ?token=, and it cannot set an Authorization header the way hls.js can.
progressive stays as the fallback for any browser without MSE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
a transcode url without `videoBitrate` is not "let the server choose".
jellyfin resolves the missing value to 0 and runs ResolutionNormalizer
over it, which maps a bitrate onto a resolution — 0 lands on the bottom
rung. verified here: a 3840x1600 hdr source came back through
scale=...min(max(iw,ih*a),416)... with -maxrate 0.
the number has to be picked per source, because the two cases pull
opposite ways: a cap is a reason for jellyfin to REFUSE a stream copy,
and the absence of one is what makes a cpu-only 4k encode hopeless. so
copyable h264 asks for a ceiling nothing hits and no width, and anything
being genuinely re-encoded asks for 1080p at 12 mbit.
verified both branches against the ffmpeg command jellyfin logs: h264 mkv
→ -codec:v:0 copy, hevc 10-bit hdr → libx264 at 1920 wide, tonemapped.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
adds the /jellyfin screen and its two panels (nav + view) on top of the
jellyfin sidecar, following the photos/invoiceshelf shape.
the transcode path is a progressive mp4 rather than hls, so no hls.js
dependency is added under the frozen-install rule. that stream has no
length and no byte ranges, so the player owns its own scrubber and seeks
by re-negotiating at a new startTimeTicks, tracking offset + currentTime;
a direct file keeps native controls. the hls url is still returned, so
switching later is a player change only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
officer-jellyfin owns the whole Jellyfin contract: the instance URL, the access
token, the Jellyfin user it belongs to and the DeviceId its sessions are keyed
by. The platform side is a 17-line proxy holding no credentials.
Servers are a registry, not a single row — this machine runs four instances and
the owner switches between them. The password is never stored: it is traded once
for an access token through AuthenticateByName, and only that token is persisted,
encrypted.
Two doors. /_officer/* is a hand-written JSON façade for the things the browser
should not have to know — the user id in the path, the Fields lists that decide
whether a grid has posters, the PlaybackInfo negotiation. /_jf/* is a GET-only,
allow-listed byte pass-through for images, video, HLS and subtitles; it keeps
Jellyfin's own paths because a master playlist references its segments
relatively, so any renaming would mean rewriting m3u8 bodies.
TranscodingUrl arrives with api_key=<access token> in its query string and would
otherwise be handed straight to a video element. It is stripped before anything
is returned; the pass-through re-adds the credential as a header.
Video only — Officer's own player owns audio, so music collections are filtered
out of the library list.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bunfig.toml sets [install] frozenLockfile = true, so `bun install` resolves from
bun.lock and nothing else — it fails rather than quietly picking up a newer
version, including a transitive one nobody chose.
Config rather than a documented habit, because a supply-chain compromise does not
wait for the one time somebody forgets a flag. On 2026-08-04 eleven cache
packages — keyv, flat-cache, file-entry-cache, cacheable-request, cache-manager,
the @cacheable/* scope and ecto — were published with a preinstall dropper that
harvested npm and GitHub tokens, AWS and Kubernetes credentials, SSH and PEM
keys, .env files and .claude/settings.json, then republished itself through any
npm token it found, reaching 434 further packages across 1381 versions. This
machine was unaffected only because nothing had installed since 2026-08-02.
Verified on bun 1.3.10 rather than assumed, and the first result was wrong: with
NO lockfile present, neither the flag nor the config refuses — bun simply creates
one, so an initial test made the setting look ignored. Against a lockfile that no
longer satisfies package.json, both exit 1 with "lockfile had changes, but
lockfile is frozen". The config is honoured; the earlier reading was a bad test.
`bun install` in this repo still reports 1058 installs, no changes.
Applied to monorepo-mobile too, where flat-cache, file-entry-cache and keyv are
present as eslint/got transitive deps — at versions old enough to be unaffected,
which was luck rather than design.
Documented in platform/CLAUDE.md and the workspace root, including the part that
matters most: do NOT add --no-frozen-lockfile to a script, a Dockerfile or CI to
make the error go away. The error means the lockfile and package.json disagree,
and an unexplained lockfile change in a diff is precisely the signal this exists
to produce.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
99.97% printed as "100.0%" beside a still-blue bar, which reads as a stuck
torrent. only a genuinely complete fraction shows a hundred now, as "100%".
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the album view already carries the transport, so the full-width dock was a
second bar costing the workspace a row. the host stays mounted (it owns the
engine) and only withholds its bar on /music; MusicMiniBar draws the scrubber
at the foot of the library panel, with play/pause and the lyrics toggle for
when you browse away from the album that's playing.
player-time now publishes duration alongside position so a scrubber outside
the host's tree can render without a 60hz state channel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
they are indexed like any other folder — all six carry cover:true and serve a
jpeg — so the root view uses the same card as the folder grid instead of flat
gradient tiles. a root that holds tracks directly gets the hover play button
for free.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
same useGlobal MUSIC_PLAYER state, so on the album that is already loaded it
shows pause while it plays and resumes rather than restarting from track one.
any other album still starts from the top.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the sheet forces the dark theme tokens on its own subtree — they are CSS
variables scoped to a .dark ancestor, so a light theme would otherwise paint
near-black text on black. inactive synced lines drop the /50 and use plain
muted-foreground, the same grade as the artist under each track title.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
the microphone is the same switch in two places — the play dock and the album
header — so its state moves to a channel seeded from localStorage. turning it
on splits the /music detail panel in two with a nested WorkspaceLayout: a fixed
layout, components keyed by panel id, no persistence and no registry entries.
the album view is handed to the left panel through context, so the split moves
the same element instead of remounting it and refetching the album.
playback position now reaches the pane through a module-level publisher rather
than props — it lives outside the player's subtree, and the feed ticks every
animation frame. the pane subscribes and re-renders only when the active line
changes, about once a line.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ports the mobile app's lyric parser (packages/core/src/services/lyrics.ts) to the web player and
expands a sheet above the play dock. Synced .lrc lines highlight, auto-scroll and seek on click;
plain .txt scrolls by hand. The server side already served all of this — /api/music/lyrics and the
indexer's embedded-USLT extraction — so nothing changed behind the proxy.
The sheet lives in the dock rather than a /music panel because the dock is mounted app-wide: lyrics
follow the music onto every screen, and no saved workspace layout has to be migrated to see it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards on user 1, the bootstrap account, so ownership survives whatever
happens to the rows.
ck_users_owner_is_super_admin — CHECK ((id <> 1) OR (role = 'Super Admin')).
In the database rather than in application code because the point is that it
holds against a stray UPDATE, a migration script or someone at a psql prompt,
not just against the API. A row-level CHECK can say "if this row is user 1 then
its role is Super Admin"; it cannot say "some row must be Super Admin", which
would need to see other rows. So it pins the bootstrap account and nothing else —
promoting and demoting everyone else stays free.
deleteUser() refuses id 1, because a CHECK cannot stop a DELETE and removing the
owner reaches the same end by another route: nobody who can open the vault, no
identity for the agent sidecar to run as, a web origin restricted to a Super
Admin that no longer exists, and the passkeys cascaded away so there is no
signing back in. It throws rather than returning false — its eventual caller is a
manage-users flow, where a silent false reads as "already gone".
OWNER_USER_ID is exported from the schema and used by both, so the number appears
once.
Tested on a scratch database, and the first harness was wrong — a shell variable
holding a command did not expand, every statement failed with "command not
found", and the check reported them all as allowed. Re-run directly:
insert user 1 as Super Admin allowed
demote user 1 -> Member REJECTED by CHECK
demote user 1 -> Admin REJECTED by CHECK
insert user 1 as Member REJECTED by CHECK
demote/promote users 2 and 3 allowed
deleteUser(1) refused with the message above
deleteUser(3) deleted
Pushing twice showed the CHECK adds no diff churn — still only the two known
pk_music_now_playing statements. Every row in officer_dev already satisfies it,
so it will apply without touching data.
NOT covered: nothing stops a second account also being Super Admin. The rule
asked for was "user 1 is always Super Admin", not "only user 1 is", and the two
are different constraints.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.
- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
undefined when no row holds it rather than falling back: the agent sidecar
refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
fresh install would take the column's 'Member' default and come up with NO
owner at all — no vault, no agent identity, and the web origin locked to a
Super Admin that does not exist. That bug was live the moment the column
landed; SUPER_ADMIN_EMAIL was masking it here.
Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.
Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.
Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
begin() only sets `started` after a fetch and a full decode, so anything that
called it during that window saw started === false and started a second decode
of the same track. both finished, both called startBuffer, and only one of the
two sources ended up in `cur`.
starting a queue from a paused player did this every time: the host commits a
new queue and playing: true in one render, its queue effect calls
load(autoplay) -> begin, and its playing effect then calls play() -> begin
again, same generation.
it compounds, which is why it sounded like three songs and not two. the twin
keeps its own onended, so at the boundary advance() ran twice: the index jumped
two tracks and a second source was promoted while the first was still sounding.
three changes. begin() refuses re-entry for a generation it is already running.
onended only advances the queue if the source that ended is the one in `cur`.
and every source is registered in a `live` set, because cur/nxt is what the
engine reasons about while `live` is what it is responsible for silencing — an
untracked web audio node cannot be stopped by anything except closing the
context.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bun db:push` planned 32 statements against a database that already matched the
schema, and stopped on a "do you want to truncate screens?" prompt that answering
could not resolve — the same question came back next run. Root cause found and
fixed rather than worked around.
drizzle-kit mis-diffs named composite unique CONSTRAINTS. It reads one back,
compares it against a schema declaring the identical name, columns and order,
decides they differ, and emits DROP + ADD. Fifteen of those, forever. Reproduced
on a database drizzle had itself created seconds earlier, so it is not drift.
Single-column .unique() is diffed correctly; only unique('name').on(a, b) is
affected. Unique indexes go through a different code path and are stable, so all
fifteen are now uniqueIndex.
A unique index enforces exactly what the constraint did — verified, a duplicate
insert still fails on uq_screens_user_name — and onConflictDoUpdate accepts it as
an arbiter. It cannot be a foreign-key target, but nothing here targets a
composite key; checked before converting.
Separately, user_integrations_server_integration_id_server_integrations_id_fk is
65 characters and Postgres truncates identifiers at 63, so drizzle compared its
generated name against the stored, truncated one and re-created the FK every run.
Declared explicitly as fk_user_integrations_server_integration.
Measured on a scratch database, pushing twice each time:
before 32 statements, interactive prompt
after uniqueIndex 4
after FK fix 2
The two that remain are a composite primaryKey with the same bug and no index
form to escape to — music_now_playing re-creates pk_music_now_playing every push.
Silent, no prompt even with rows, data unaffected, and naming it explicitly does
not help. Documented as expected.
The conversion itself was tested against populated tables, since that is what the
real database will do: no prompt, and all rows survived.
Docs rewritten in src/databases/CLAUDE.md — the rules committed an hour ago
described the broken behaviour and would have been wrong the moment this landed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
the letter that asked for the provisioning endpoint and the reply that answers
it. both lived only in an untracked COMMS/ directory on the dev box, which is
where the reasoning behind an api contract goes to be lost.
the feedback comes with it: a reply that answers a letter nobody can read is
half a record.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds `role` to users as a text column with a TS enum, matching how `status` is
done — no pgEnum, so adding a role stays a one-line schema change rather than an
ALTER TYPE while the set is still settling. USER_ROLES and UserRole are exported
from officerdb so the API and UI enumerate them from the column definition rather
than a second hand-written list. Defaults to 'Member', the least privileged, so a
row created by a path that does not think about authorisation cannot mint an
admin. NOT YET PUSHED — the column is in the schema, not in the database.
The larger half of this commit is a rule for anyone running `bun db:push`,
because the first time you run it, it looks like your change broke something.
It plans 16 statements against a database that already matches the schema, and
plans them again on the next run. Two separate causes, both diagnosed here:
- it drops and re-adds every NAMED COMPOSITE unique constraint — all 14, from
uq_screens_user_name to uq_wallet_labels_wallet_kind_ref. Single-column
.unique() diffs correctly and is untouched; only unique('name').on(a, b) is
affected. Names, columns and order in the database are identical to what the
schema declares. drizzle-kit 0.31.9 / drizzle-orm 0.45.1.
- it drops and re-adds one foreign key because
user_integrations_server_integration_id_server_integrations_id_fk is 65
characters and Postgres truncates identifiers at 63, so drizzle compares its
generated name against the stored, truncated one and always differs.
The rules, ranked by how much damage getting them wrong does: never answer "Yes,
truncate the table" — it destroys rows and does not help, since the constraint is
re-added next push either way; never delete a constraint from the schema to
silence the prompt, because upserts depend on it existing; do not reach for
--force until someone has established on a scratch database which branch it takes.
Run with --verbose first and read what it is actually planning.
Pointers added to platform/CLAUDE.md and the workspace root CLAUDE.md, since
those are what a session reads before it ever opens the database directory.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mints a dav app password, renders a configuration profile carrying both the
caldav and carddav payloads, and parks it behind a single-use five-minute token
that safari can fetch without a session.
one profile with both payloads is not a convenience: ios keys accounts by
server+username, so adding carddav separately gets folded into the existing
caldav account and contacts silently never appear.
the profile holds the password in plaintext, so it is held in memory only —
persisting it would falsify createDavAppPassword's "not stored" guarantee.
signing is opt-in via DAV_PROFILE_SIGN_CERT/_KEY/_CHAIN and off by default;
this box has no tls certificate, tls terminates upstream. signed at mint time
reading the cert from disk, so a renewal needs no restart and no hook.
the download route is registered before the /dav mount because hono matches in
registration order and the sync door's /* would otherwise demand http basic.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The three non-owner accounts had no directory of their own — only the owner did,
grown organically as features created what they needed. Adds an idempotent script
that creates the skeleton, and runs it for those three.
DATA_PATH/<email>/{home,attachments,cache,dashboards,email_accounts,
general_chat_sessions,logs,sidecar}
Most of those are also created on demand by whichever feature owns them, so
pre-creating buys legibility rather than function: the tree now shows the shape a
user has without having to use it first. `home` is the exception and the reason
this exists — nothing creates it today, because getOwnerHomeDir returns
process.env.HOME_DIR whenever it is set, which it always is on a real install. It
is where a non-owner's sessions will run.
Takes emails as arguments rather than reading the user table. The layout does not
depend on the database, keeping the DB out means it runs with nothing else up,
and at invite time the caller already knows the email. It refuses arguments that
are not email addresses, since a stray one would create a junk directory sitting
next to real user roots and looking like one.
Deliberately NOT a revival of the provision.ts deleted two commits ago. That one
was ~90% per-container scaffolding — shell rc files, a generated CLAUDE.md and
settings.json describing the container — wrapped around the two useful lines this
keeps.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first iteration gave every user their own Docker container: the user's whole
world lived inside it, and only the super admin could see the real filesystem.
That model is gone, but its scaffolding was still in the tree, and it had already
cost time today — the /usr/local/bin/claude symlink removed a few commits ago
existed only because the bwrap jail ro-bound /usr and could not see the
installer's target.
Deleted:
generate-container-context.ts built the CLAUDE.md and settings.json that told
an agent what its container looked like. Its
only importer was the provisioning removed in
the previous commit, so it had zero consumers.
getUserPiConfigDir pointed into the managed container home. No
consumers anywhere in the tree.
Renamed:
DATA_PATH/<email>/.container-context -> agent-config. It holds one file, the
MCP server config handed to the CLI, and has nothing to do with containers. The
path is written and consumed through a return value, so nothing else reads it;
an old directory left on disk is inert.
Documented rather than removed, because both still have live callers and pulling
them out is a refactor rather than a cleanup:
getHomeDir the container's home. Nothing executes there now — terminals,
chats and task runs all use getOwnerHomeDir — but it survives
as that function's fallback and in pipeline-executor.
toShellUsername named for deriving a Linux username inside the container,
32-char limit and all. Nothing creates a Linux user now; the
value ends up only as a claim in the signed task token, so it
is a sanitiser wearing an old name. Unpicking it means
changing that token and WSData.
Nothing to clean on disk: DATA_PATH/<email> has no home/ tree and no
.container-context/. The docs that still mention any of this are the two marked
"Historical" at the top, which are records of what was true then and should keep
saying so.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
provisionUserEnvironment seeded a managed home under DATA_PATH/<email> — shell
configs from a template dir, plus a generated CLAUDE.md and settings.json — and
bootstrap called it fire-and-forget when the owner account was created. It is
outdated: the managed home is not where anything runs. Task runs, terminals and
chats all execute in getOwnerHomeDir (the real login home, HOME_DIR), not the
getHomeDir tree this was populating.
Removed the function, its four template files, and the call in bootstrap. No
setup script referenced it — checked all of scripts/*.sh — and bootstrap was its
only caller anywhere in the tree.
getHomeDir and toShellUsername stay in data-path.ts: pipeline-executor and
pipeline-job-manager still use them.
This leaves src/servers/generate-container-context.ts with zero consumers, since
provision was the only thing importing it. Left in place rather than deleted in
the same commit — it is 173 lines that build a CLAUDE.md and a settings.json for
an agent, which is plausibly wanted somewhere else, and that is a call for the
owner rather than a side effect of this cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A light install is reached at localhost on the machine running it, so PUBLIC_URL
has exactly one right answer. setup.sh required it with a re-prompt loop; under
the light profile it now defaults to http://localhost:$PORT. The macOS installer
already did this — this is parity, and it removes the one prompt in a light run
whose answer a non-technical user could not be expected to produce.
setup_mac_light.sh also wrote PUBLIC_BUILD_ENV="development", justified in a
comment as "what makes plain http://localhost work". That is no longer true, and
the cost of it is not small. IS_DEV_BUILD gates exactly three things:
origin validation already off regardless — ALLOW_ANY_ORIGIN defaults to true
password rules validatePassword is skipped entirely on change-password
rate limiting the limiter returns next() before doing anything
So the only live effects were losing the last two, for a benefit that another
default already provided. It now writes "production", matching setup.sh. Nothing
about localhost needed relaxing: browsers treat http://localhost as a secure
context, so passkeys, getUserMedia and the clipboard all work over plain HTTP,
and passkeys in particular derive their RP ID from the request origin rather
than a configured domain.
That last point is the boundary worth knowing: http://192.168.x.x is NOT a
secure context, so reaching a light install from another device means putting
an HTTPS proxy in front of it. Recorded in the comments at both prompts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renamed for parity now that Linux has a light profile too:
scripts/setup_mac.sh -> scripts/setup_mac_light.sh
ecosystem.mac.config.cjs -> ecosystem.mac.light.config.cjs
The macOS process list was still a hand-copied subset, which is the shape that
broke it: written 2026-07-28, within days it was running the Anthropic proxy
under the name officer-claude with nothing spawning `claude`, and pointing at a
pty entry point that had moved. Both silent. It now declares names and reasons
and reads script/args from ecosystem.config.cjs, so a launch change on the host
reaches it for free.
The include/exclude checks moved into ecosystem.profile.cjs rather than being
copied into the second profile — duplicating the guard rails would have repeated
the mistake they exist to catch. Both profiles were re-tested against a mutated
host ecosystem: renaming an included app and adding an unclassified sidecar each
throw in both, and an unmodified host loads five apps in both.
Kept as two files rather than collapsed into one, even though they currently
produce identical output. The exclusions do not mean the same thing: on macOS
officer-vnc CANNOT run, there being no Xorg; on a Linux light install it could
run fine and you have chosen not to. Merging them would lose that, and they
diverge the moment one profile gains something the other cannot have.
setup_mac_light.sh's verification loop now reads app names with node instead of
grepping for `name:` — the derived profile has no literal keys, so the grep
would have silently listed no services at all, which reads the same as a healthy
install with nothing configured.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OFFICER_PROFILE=light installs the same thing the macOS build does, on Linux:
the file browser, the terminal, and Claude/opencode chat. It skips the archive
extras, the sudoers entry and auto-suspend disabling, Go, Rust, PulseAudio,
cliamp, Neovim, the shell tooling and yt-dlp, brings up Postgres alone of the
five Docker services, and starts ecosystem.light.config.cjs. Unset or `full`
behaves exactly as before.
The profile changes which processes start, not which code ships — every API
route stays mounted, so features whose sidecars are absent report themselves
unavailable rather than disappearing.
ecosystem.light.config.cjs DERIVES its apps from ecosystem.config.cjs rather
than copying them, because the hand-copied Mac list was broken within days of
being written by a sidecar split in two and a pty entry point that moved, and
both failures were silent. Here a script/args change on the host propagates for
free, and two consistency checks turn the silent cases loud:
- a name the profile needs that the host no longer defines throws at load
- an app added to the host that is in neither the include list nor the annotated
exclusion list throws, so a new sidecar cannot default to "not in the profile"
without someone deciding
Both were tested against a mutated copy of the host ecosystem: renaming
officer-agent and adding an unclassified sidecar each throw, and the unmodified
file loads five apps.
The verification block now reads app names with node instead of grepping for
`name:` — the derived file has no literal keys to match, so a grep would have
silently verified nothing — and skips the checks for tools the profile did not
install, so a clean light run does not report Go and cliamp as missing.
Full-profile behaviour is unchanged by construction: every guard wraps the
original code in an else branch. The preamble was tested across unset, full,
light and an invalid value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ecosystem.mac.config.cjs was written on 2026-07-28 and broken within days by two
changes it never caught up with, leaving two of its four processes dead:
- it ran `officer-claude` against src/servers/sidecar/claude/index.ts. That file
is the Anthropic credential proxy; the process that actually spawns `claude`
is user-instance.ts, which was never started. Chat had a credential holder and
nothing driving it. Split into officer-anthropic-proxy + officer-agent to
match the host, and retired the `officer-claude` name that caused it.
- officer-pty pointed at src/servers/api/terminal/pty-sidecar.mjs, which no
longer exists; the sidecar moved to src/servers/sidecar/pty/index.mjs. The
terminal could not start at all.
No startup ordering is needed between the proxy and the agent: the agent reads
the proxy secret from disk and, when it is not there yet, warns and re-reads
before the next spawn.
Five sidecars have been added since the file was written (memos, caldav, photos,
notify, wallet). All are excluded, and every exclusion is now listed with its
reason so the next reader can tell "not applicable on macOS" from "forgotten" —
which is the failure this commit is fixing.
setup_mac.sh needed no path corrections; its verification loop derives the
process names from the ecosystem file, so it picks the new list up on its own.
Two gaps closed there:
- it called `pm2 save` without `pm2 startup`, writing a process list that
nothing reads — a reboot left the machine with nothing running. Added the
launchd equivalent behind SETUP_BOOT, optional because a laptop is not a
server, and non-fatal because pm2's launchd integration can want a prompt.
- the closing "not installed on macOS" note listed four omissions when there are
now fourteen.
Both files parse and every ecosystem entry point resolves to a file that exists.
The launchd path is the one thing not verified here — it cannot be exercised
from the Linux host.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The symlink existed because the Claude sidecar hardcoded that path, and that
hardcoding came from the bwrap-sandboxed architecture: the jail ro-bound /usr
and could not see the installer's real target in ~/.local/bin. The sandbox is
gone, and claude-manager.ts now resolves the CLI itself — $CLAUDE_BIN, then
PATH, then ~/.local/bin/claude, /usr/local/bin/claude, /opt/homebrew/bin/claude.
Verified before removing rather than assumed:
- the only references left in the tree are the resolver's own fallback list and
this step; nothing in capabilities, no systemd unit, no crontab, no ecosystem
file and no shell rc mentions the path
- the agent sidecar's PATH under pm2 contains ~/.local/bin ahead of
/usr/local/bin, so Bun.which resolves to the installer's target and the
symlink is never consulted
- replaying the resolver in that exact environment with the symlink treated as
absent returns the same path, so it is not load-bearing
- resolveClaudeBin runs at claude-manager module scope, which ES import ordering
puts before user-instance.ts reassigns process.env.HOME — so the homedir()
candidate is evaluated against the real home, not the managed one
The install-and-verify step above is untouched, so a failed claude-code install
is still reported. Only the sudo-owned link into /usr/local/bin goes, a
directory macOS does not ship at all.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three files, all additive — nothing on master is modified by this beyond the
CLAUDE_BIN change below, and no file is deleted. The branch predates master by
about 180 commits, but it touches nothing master has touched since, so the
merge is clean.
The part that matters beyond macOS is claude-manager.ts. CLAUDE_BIN was pinned
to /usr/local/bin/claude, which dated from the bwrap-sandboxed architecture:
the jail ro-bound /usr and saw nothing else, so the installer's real target
(~/.local/bin/claude) had to be symlinked somewhere the sandbox could reach.
That sandbox is gone, and the hardcoded path left the sidecar unrunnable on any
host without it. It now resolves an explicit CLAUDE_BIN pin, then PATH, then the
locations Anthropic's installer actually writes to — mirroring how OPENCODE_BIN
is already resolved in the opencode sidecar.
ecosystem.mac.config.cjs is deliberately a trimmed set of processes rather than
a mac port of the full ecosystem. It is also stale in two specific ways, left
as-is here and worth fixing separately: it names officer-claude, which master
renamed to officer-anthropic-proxy, and its officer-pty runs
src/servers/api/terminal/pty-sidecar.mjs, which moved to
src/servers/sidecar/pty/index.mjs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One-tap calendar/contacts setup for the iOS and Android apps: the server
contract that already exists, and specs for the two platform mechanisms
that don't yet — a signed .mobileconfig for iOS and a DAVx5 intent for
Android.
Payload keys, intent identifiers and install flows are researched against
Apple's device-management reference and davx5-ose source rather than
recalled; the doc marks what is verified against a running deployment and
what is still specification.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The setup wrote /etc/sudoers.d/officer-service with tee and then chmod'd it.
Two problems, both with the same worst case: a malformed or wrongly-permissioned
file there breaks sudo completely, and you cannot sudo to repair it — on a
remote machine that means physical access or a rescue boot.
Generate into a temp file, gate on `visudo -c`, and only then install. Use
install(1) rather than tee+chmod so the content and the 0440 mode land in one
step; tee creates at the default umask first, and sudo refuses to read a sudoers
file with loose permissions, so the old ordering had a window where sudo could
reject its own configuration.
The re-run guard also grepped for the username anywhere in the file, so a
comment mentioning it counted as configured. Match the actual rule instead.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two guards that checked something other than the state they were protecting.
Section 17 skipped the entire remote desktop setup when `dpkg -s ubuntu-desktop`
succeeded, treating one package being present as proof that seven steps of
configuration had run. A host can have ubuntu-desktop and still be missing GDM
auto-login, the forced Xorg session, the captured EDID and its kernel command
line, and the login-time mode setter — which is exactly what this machine was
on 2026-08-02, while the guard cheerfully reported "skip". setup-desktop.sh is
idempotent throughout, so the guard bought nothing and cost a converged host.
The starship step had the opposite bug: it cp'd over ~/.config/starship.toml on
every run, so a customised config was silently destroyed. The nvim step two
sections down already guards on its config's existence; this now matches, and
distinguishes "absent" (deploy) from "identical" (skip) from "yours differs"
(keep, and say how to take ours).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a DAV client tells you almost nothing when it fails — iOS reports every setup
failure as "Cannot connect using SSL" regardless of cause — so the only way to
know whether a phone ever asked for an address book is to record that it did.
one line per proxied request with the client's own user-agent, and a warning
when a credential is present but wrong. a missing credential is the normal
opening move and stays unlogged; a wrong one is indistinguishable from it at
the client end, where both just say "password incorrect".
no body is ever logged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
two bugs, both of which iOS reports as "Cannot connect using SSL" — a message
about TLS for a problem that has nothing to do with TLS. the certificate was
never involved.
the well-known routes were registered with .get. RFC 6764 §6 has the client
probe the well-known URI with the method it actually intends to use, and iOS
sends PROPFIND — which fell through to the SPA catch-all and 404'd. they
answered correctly in a browser, which is why they looked healthy.
hono's cors() answers every OPTIONS itself as a preflight and never calls the
route beneath it, so OPTIONS /dav/ returned a bare 204 with no DAV header.
OPTIONS is not a preflight to a DAV client — it is how the client asks what the
server can do, and iOS refuses an account whose server does not advertise
calendar-access. cors now skips the DAV paths entirely; a CalDAV client is not
a browser and has no origin to check.
verified from the public internet: PROPFIND on both well-known paths 301s to
/dav/, and an authenticated OPTIONS now returns
`DAV: 1, 2, 3, calendar-access, addressbook, extended-mkcol`.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
/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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
-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>