The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.
Both harnesses speak ChatEvents, so the sidecar reuses the agent's session log
verbatim: translate, commit, then deliver the finished message with its cursor
id as `opencode:message`. Officer folds it into the in-memory transcript and
relays it, exactly as it now does for claude — `createEventHandler` (166 lines,
a duplicate of turn-stream.ts) and `emitToSession` are gone, and nothing in
officer writes to chat_session_events any more.
`opencode:event` stops being a wire event; it is the runner's internal report to
the sidecar it runs in, typed as such so it cannot leak back onto the socket.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The password lives in the owner's ~/.vnc, and the sidecar is the process that
writes it — together with the rfbauth file x11vnc actually authenticates
against. Officer read the plaintext half directly and answered with it before
ever asking the sidecar, which is both a secret the proxy has no business
opening and a way to hand out a password that no longer matches: if `passwd`
went missing while `password` survived, officer kept serving the old plaintext
and the desktop refused every login. `vnc:ensure-password` reconciles the pair,
so ask it every time and delete the reader.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
it was the only sidecar living outside src/servers/sidecar/ — it sat in
api/terminal/ next to the bridge that talks to it, which is the one place a reader
looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs,
matching every peer, with a note on the pm2 entry about why this one is node and
.mjs (node-pty is a native addon) rather than bun and typescript like the rest.
the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs,
which is now its only consumer — the sidecar's duplicate seeder went with the
sandbox branch in the previous commit. api/terminal/ is left holding exactly one
thing: the websocket bridge.
no behaviour change. the pm2 entry's script path changed, so `pm2 restart
officer-pty` is not enough — pm2 remembers the old path until the entry is deleted
and started again. commands are in SIDECAR_WORK_LOG.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
officer built the whole PtyInitConfig: it read the owner's SHELL (defaulting to
/bin/zsh), added `-i`, read their HOME, expanded `~` against it, and hardcoded
`host: true`. none of that is a proxy's business — the sidecar is the process that
calls pty.spawn, so it is the one that should know what to spawn and where.
the config now carries only what the bridge actually knows: sessionId, the folder
the panel was opened on, and the client's cols/rows. shell, args, home and cwd
resolution moved into the sidecar. home comes from HOME_DIR ?? HOME, mirroring
data-path.ts:getOwnerHomeDir — terminal was the one host-executing surface reading
process.env.HOME directly, which is identical here and divergent anywhere HOME_DIR
is set to something else.
deleted the bwrap sandbox branch rather than moving it. it was selected by
`config.host`, which officer hardcoded to true, so it never ran — and it expected
`shell` to contain a fully-built bwrap command that nothing on either side ever
built. it could not have worked. a terminal here is the owner's own shell on the
owner's own machine by design (platform/CLAUDE.md), so there is no jail to preserve.
its ensureUserFiles half duplicated api/users/provision.ts:seedShellConfigs, which
is the live seeder of those same templates and stays.
also deleted the 'cwd' handler that turned a message into `cd <path>\r` typed at
the shell. no frontend has ever sent that message — the browser composes its own cd
— so it was unreachable, and synthesizing keystrokes is not something a relay
should do.
the integration test pins SHELL and HOME_DIR now that the sidecar reads them, and
asserts the shell starts in the resolved `~` rather than officer having resolved it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the pty sidecar registered `term.onData` with the socket that happened to be live
when the session was created. officer is a pm2 peer that restarts constantly, and
every restart hands this process a brand new socket, so every pre-existing session
went on writing to a closed one — where sendJson's readyState check dropped it
silently. the shell survived and still accepted input, because input arrives on the
new socket, but nothing ever came back. you typed and the terminal sat there. the
only way out was to close the panel, which orphaned the shell.
sendJson now reads the module-level socket at send time instead of taking one as an
argument, so there is no socket to capture and go stale. that is the whole fix.
the scrollback replay on re-attach becomes its own event, pty:replay -> 'replay'
on the browser socket. it used to arrive as ordinary output, which was fine for a
page load (fresh xterm) but not for a restart: the browser keeps its terminal, so
replaying blind printed a second copy of everything still on screen. marked as
history, Terminal.tsx resets and rebuilds from the sidecar's 50KB buffer instead.
it also stays out of the `output` branch so it cannot re-trigger the command /
initial-input logic that scrapes output for a sentinel.
added an integration test, because this is a reconnect bug and nothing short of an
actual reconnect proves it: it stands up a fake registration socket, runs the real
sidecar against it, echoes into a real shell, kills the socket, rebinds the same
port the way pm2 does, and asserts output still flows. verified it fails against
the old sendJson (times out after 15s waiting for the post-restart echo) and
passes in ~400ms with the fix. it never touches the running officer — the sidecar
dials API_URL, overridden per spawn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.
move the translation and the write into the sidecar:
- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
of websocket.ts (delta buffering, flush before tool:start and result). pure and
synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
to officer, with its cursor id attached. per-session promise chain: translation is
synchronous and therefore in arrival order, and only the commit is queued, so
cursor ids are assigned in the order events actually happened. a delta that
overtook the assistant:text in front of it would make the client commit its stream
buffer at the wrong point, so deltas go through the same queue even though they are
never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
plus its seq. officer relays it verbatim and folds it into the in-memory session
for sync:messages. it no longer builds or persists chat messages for this harness.
gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).
a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.
pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.
opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.
this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry
and was spawned on demand by the main server, with stdout/stderr inherited. that
made every agent session a grandchild of officer, so pm2's tree-kill took the
session down on every `pm2 restart officer` — the single thing that makes it
impossible to work on the platform while an agent is running.
give it its own entry (officer-agent) and delete the spawn machinery:
ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and
the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns
no sidecar at all.
for that to work the sidecar had to stop needing officer to start:
- it resolves the owner from the database (getOwnerUser) instead of reading
CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard
invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins
when set, for manual runs, and a fresh install waits for bootstrap rather
than exiting into a restart loop.
- it reads the anthropic proxy secret from the proxy sidecar's own state file
rather than being handed it in env. lazily, because ensureProxySecret
persists on a 30s debounce and pm2 starts both processes together.
it registers as 'agent' with capability 'claude', so the registry finds it the
way it finds every other sidecar. that removes the email argument from
killClaude, interruptClaude and clearClaudeSession, which only ever existed to
locate a per-email sidecar by name.
what officer keeps is a short wait-for-capability, because pm2 brings peers up
together and the first request after a boot can beat the sidecar's registration.
also align the two officer port fallbacks in the sidecar (5000 for the socket,
9010 for the rest base) — same instance, so they cannot disagree.
this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while
officer is down are still lost; that is stage 2.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
extremely long transcripts made bottom-anchoring the virtualized list unreliable
(thousands of unmeasured variable-height items = a huge estimate the scroll never
lands on). now GET /chat/sessions/:id takes limit+before and returns a windowed
slice plus total+offset. the chat opens on the last 20 messages, anchors to the
bottom instantly, and scrolling near the top pages in the next older window,
prepending it and pinning the previously-top message so the view stays put.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deep-linking or refreshing /chat/<id> only has the id, so add loadClaudeSessionById
to scan every project group for the transcript and return its real cwd. the session
list page resolves on load from that, setting the cwd picker and resuming. session
rows are now <Link to=/chat/<id>> so clicking updates the url and flows through the
same resolve path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
a browsed folder is a path, not a file list, and what clicking one means is
"everything under here" — so the expansion happens in the sidecar, off the
cache, rather than making the browser walk the tree a level at a time and
rebuild paths it only half knows.
browse reports file names as basenames, unlike search, so the peer's real
path is rejoined from the folder row. sizes come from the cache too: slskd
matches a queued download on filename AND size, so a number supplied by the
client would be a transfer that silently never starts.
a subtree can be the peer's whole share (284k files on one measured peer), so
an over-limit request is refused with its count rather than truncated into a
partial download nobody asked for.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the tree search asked for no limit and clampInt read a missing param as 0
(Number(null) is 0, and 0 is finite), so it clamped to the minimum and ran
with LIMIT 1: one match came back, its siblings looked like non-matches, and
a folder with 25 matching albums showed none of them.
on top of that, a filtered row was a dead end — it only ever showed the
matches, with a note counting what it was hiding. now the note is the same
"show all N folders" button browse mode already had: clicking it drops that
row out of the filtered set, so from there down it is ordinary lazy
browsing, which is usually why you searched for the folder in the first
place. dropped the "don't match the filter" wording with it, since a
truncated result makes that sentence false.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.
The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.
Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.
Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.
The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Probing the live routes turned up that /browse/<peer>/dirs/<id>/files only checked
the owner, not the peer: dir ids are global, so asking for one peer's folder id
under a different peer's name returned the other peer's files with a 200. The UI
always sends a matching pair so nothing misbehaved, but the URL was asserting a
relationship the query never verified — a mismatched or stale request would show
the wrong peer's contents rather than a 404.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with
the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k
files for a single real peer — and it takes minutes because it round-trips to that
peer. The browser was made to wait for that, so navigating away threw the whole
thing out and the panel showed an error more often than a tree.
So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch
shares" returns 202 and the job keeps running without the tab; the UI polls the
snapshot row and reads back pages. Folders are rows and files ride along as jsonb
on their folder, because folders are what you filter and page through while files
are only ever read for the one folder you opened — a row per file would be 284k
rows per peer for no gain.
A failed or in-flight refresh deliberately leaves the previous folders in place: a
peer going offline shouldn't cost you a good cache, so the panel drives off rows
existing rather than off status. Interrupted 'pending' snapshots are failed at
sidecar boot, since the job died with the process and would otherwise spin forever.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes
only endpoint/browse/directory/info/status — so Officer owns that data itself:
a soulseek_favorites table served by the slskd sidecar under a /_officer/*
namespace, which can never collide with slskd's /api/v0/*. The main server
gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays
a thin auth proxy and grows no Soulseek logic. The route is handled before the
upstream check, so favorites keep working with slskd down.
Usernames in search results and downloads become a dropdown (browse shares,
toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel
so the Users section looks the peer up without re-running the expensive browse
on every remount, and favorites get their own section at the top of that panel,
which doubles as its landing content. CardHeader had to split its toggle row to
host the dropdown, since a trigger can't live inside the collapse button.
The schema file is deliberately self-contained so it can move wholesale into
the sidecar directory when sidecars start owning their own schema. Its DDL was
applied by hand, matching drizzle's constraint naming, rather than running a
whole-schema push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add officer-slskd, a singleton sidecar that reverse-proxies to a
self-hosted slskd (Soulseek) instance and reports its loopback port to
the API on connect. All slskd knowledge (URL + API key) lives in the
sidecar; the platform is a thin auth+forward proxy for /api/slskd/* and
holds no slskd credentials. Mirrors the officer-vault pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The crypto-shape question is answered (classic + v2 fields both present); drop
the diagnostic that logged the response field names.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Key music_now_playing on (user_id, device) instead of user_id alone so the
browser ('web') and phone ('' default) each keep their own resume snapshot
instead of sharing one row. Sidecar /now-playing threads device (?device=,
default '') through get/set/clearNowPlaying; the web client tags its calls
?device=web. Phone unchanged → '' bucket, inherits the existing row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Vaultwarden (2025.12.0/f21a3ada) returns both classic (Key/PrivateKey/Kdf*) and
v2 crypto (UserDecryptionOptions.MasterPasswordUnlock, AccountKeys) synthesized
from the classic stored fields. session/login now returns the whole native
connect/token response minus the transport tokens (under `connectToken`),
alongside the spec's protectedUserKey/privateKey/kdf aliases, so the SDK gets
whatever unlock path it uses. Temp diagnostic logs the response field NAMES
(values redacted) to empirically confirm on a real login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- token-store: shared "give me a valid Vaultwarden access token" (proactive
refresh) used by both the HTTP proxy and the WS; router refactored onto it.
- notifications WS: validates the platform session in `open` (deferred, owner
only), injects the stored Vaultwarden token into the upstream, and buffers
client frames during the async setup so the SignalR handshake isn't dropped.
The device connects with its platform JWT (?access_token=), never a vault one.
- lifecycle: logout drops the vault token set (keeps the protector); distress
(/auth/revoke) and panic wipe both token set and protector, forcing a
one-time master-password re-setup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the platform half of VAULT_AUTH_SPEC.md. /api/vault is now gated on
an owner platform session (userMiddleware, no bodyParser → streaming preserved)
and origin-scoped as before; the device holds no Vaultwarden token.
- POST /session/login {email, authHash, kdf, device*} → broker calls Vaultwarden
/identity/connect/token via the sidecar, stores the encrypted token set tied to
the owner, and returns {protectedUserKey, privateKey, kdf} (ciphertext to us).
- GET/PUT /unlock-key → store/release the Officer-app protector key (owner only).
- Catch-all proxy swaps the incoming platform JWT for the stored Vaultwarden
access token, proactively refreshes near expiry, and retries once on a 401 for
replayable requests. Bodies are never parsed.
client_id column added to vault_tokens (needed to refresh). Broker error text is
read across Vaultwarden's message/errorModel/error fields.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Scope OFFICER_VAULT_ORIGIN to ['/api/auth', '/api/vault'] instead of
'/api/vault' only. The OffVault app authenticates to the platform first
(/api/auth/signin) and only then reaches the vault proxy — same shape as the
music app's ['/api/auth', '/api/music']. Without /api/auth the signin was
rejected in originScopeMiddleware with "Origin not permitted".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault
(Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True
out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL,
paths, notifications WebSocket) on a random loopback port and registers via the
sidecar connector; the platform is a thin origin-gated forwarder that knows only
the sidecar's port. Never decrypts/parses/rewrites/logs bodies.
- sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health
- api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate
- origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault
- mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd
- protocol: vault:server event; ecosystem: officer-vault app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
move video/audio downloads off the dedicated ReClip download lane onto the
generic script-job path:
- delete execute-download.ts, reclip-client.ts and the POST /jobs/download
endpoint; drop the 'download' mode from the pipeline_jobs enum (legacy rows
tolerated)
- execute-script.ts: strip the @@officer:progress@@ sentinel from the log,
emit progress events, and isolate viewer/log writes (safeEmit/safeLog) so a
broadcast or log throw can't wedge the stdout pump
- pipeline-job-manager.ts: persist latest progress; guard sendToViewer sends
- ScriptJobDetail: render the two progress bars; DownloadJobDetail kept for
legacy history rows
- TaskRunnerModal: ScriptRunner descends into the triggered directory
- VideoDownloadPanel: rewire startJob to the script-job path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A failed download has no media file, so its description was lost — no way to tell
which video was missed. Now a failure writes a `<title>.txt` (or `<videoId>.txt`
when untitled) holding the URL + description, so every missed item leaves a
recoverable reference. Always written (even with an empty description — the URL is
the reference); skipped only on a deliberate Stop, not a genuine error.
Verified: successful item → "<media base>.txt" (description); failed item →
"<title>.txt" (url + description); spaces preserved in both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The metadata phase already fetches ReClip's description (ReClip now forwards it in
/api/info). Carry it into the download phase and write it to a text file with the
same base name as the media — "Song Name.mp3" → "Song Name.txt".
reclipDownloadOne gains an onFilename callback that fires the moment the final
filename is known (before the file transfers), so the executor writes the sidecar
in parallel with the download stream, and the exact name guarantees they pair up.
Empty descriptions write nothing; the write is best-effort (never fails a download).
Verified: correct base name + .txt, exact content, and no sidecar for an empty
description.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against live ReClip: POST /api/download with title:"" → a hash filename
(b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip
names the file from the title WE send (falling back to a hash) — it does not
self-name. The title is mandatory, which means a metadata pass is required.
Back to two phases:
1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip
errors.
2. download — download each survivor passing its title, so files land with real
names; skip download errors.
Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per-
item download. Progress is two counters again (Titles + Download); UI shows two
bars. ~2 requests/item is inherent to needing the title (per the user's call:
correctness over speed).
Verified two-phase filtering + title passthrough + skip-on-error with a mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every
/api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to
re-expand the playlist server-side, so it would download a different list than the
count shown on the decision screen.
The panel now passes the already-expanded `urls[]` into the job, and the executor
uses them verbatim (falling back to expanding `url` only when no list is given).
The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as
inputs.urls) or `url`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.
Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
ReClip names the file from the video title itself, so it's a single request
per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
skip failures. Progress is a single { done, failed, total, current } counter
(no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.
Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Front half of the download-job feature.
Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known,
a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all
as a job" (POST /jobs/download), or "fetch inline to pick individually" (the
existing card grid). A single video still goes straight to the inline card. The
job phase shows live two-phase progress (polled from the job) + a "View in Jobs"
link; it notes the job runs server-side so closing the panel is fine, and it
refreshes the browser as each file lands.
/jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact
two-phase readout — Metadata and Download bars (processed/total, found/skipped and
saved/failed) + the current item — polled from the job's progress, with a Stop.
Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases
read the same `(done+failed)/total`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.
- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
(single download → streams the file to a dir, abort-aware). Extracted so both
the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
errors), phase 2 download (each survivor in the chosen format; skip download
errors). Emits a compact `download:progress` snapshot (counters, not per-item
events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
download:progress; adds execution LANES (download vs default) so the two run
independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
getPendingJobs() query for lane filling.
Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two problems behind "deleted/changed the folder image but it still shows" (on
both web and app):
1. Client caching. Cover/meta/etc. were served with an ETag(=v) but NO
Cache-Control, so browsers served them straight from the heuristic cache at
the same URL — a changed cover kept showing the old image. And the platform
proxy never forwarded If-None-Match, so the ETag revalidation couldn't work
anyway. Now the sidecar sends `Cache-Control: no-cache` on every version-
stamped artifact (cover/meta/poster/lyrics/image) + the manifest, and the
proxy forwards If-None-Match → the client revalidates every time and gets a
cheap 304 when unchanged, a fresh 200 when v changed.
2. Removed covers lingered. On rebuild the indexer only (over)wrote cover.jpg
when a source cover existed — a deleted or now-undecodable source left the
old cover.jpg in the cache (still served, still cover:true). Now it clears
cover.jpg first and regenerates only if there's a valid source.
Verified live against a booted sidecar: cover carries no-cache + ETag, a
matching If-None-Match → 304, and deleting the source folder.jpg drops the
cached cover (404, meta.cover cleared, manifest cover:false).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the download-video dialog into a prefetch-then-download flow, mirroring
ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader
logic moves to the platform.
Server (thin ReClip proxies alongside /download-video):
- POST /file-browser/video-info { url } → ReClip /api/info → { title, thumbnail, duration, uploader }
- POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls }
Both return { error } inline (200) so the client can render failures per-card.
UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files`
and drops the old single-shot state/handler):
- Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then
each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per
video), rendering a card (thumbnail, title, uploader, duration) that fills in
progressively.
- Per-entry Download, plus Download All when there's more than one; per-card
status (downloading → saving → saved / retry-on-error) via the existing
background job + poll.
- Playlists get an optional "subfolder you name" field (ReClip's /api/playlist
carries no playlist title); blank = current folder.
- Quality is always best (matches the mobile Share flow — no picker); the
audio-only toggle applies to the whole batch.
Verified ReClip's contract live: /api/info returns the metadata fields, and
/api/playlist returns { urls } (17 entries in ~1.2s).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
the path was pinned to /usr/local/bin/claude for 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 constant
outlived it — the sidecar could not run anywhere the symlink was absent.
a stock macos host has no /usr/local/bin at all.
resolve at module load instead: CLAUDE_BIN pins it explicitly, else
whatever is on PATH, else the locations the installer writes to. same
shape as OPENCODE_BIN in the opencode sidecar. the resolved path is
logged at startup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Favorites / now-playing / playlists were being served by the platform router
straight from Postgres, which violated the intended split (officer = auth +
proxy; officer-music = the whole /api/music/* contract). Move them into the
sidecar so it owns ALL music endpoints — library AND user state.
- sidecar (index.ts): serves /favorites, /now-playing, /playlists[/:id[/items]]
backed by Postgres (the same officerdb queries other sidecars already use).
The authenticated user id arrives in X-Officer-User; the sidecar is loopback-
only, so it trusts the header (401 if absent). HTTP-contract comment updated.
- platform (router.ts): reduced to a pure auth+proxy catch-all — it now injects
X-Officer-User from the authenticated ctx user and forwards the request body
(favorites/now-playing/playlist writes carry JSON) in addition to Range/query.
No schema change — the tables are unchanged, only WHERE they're served moves.
Verified live: booted the real sidecar against the live DB and exercised the
endpoints with the X-Officer-User header — 401-without-header, favorites round-
trip, and full playlist CRUD with ownership scoping all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the existing per-user music state (favorites / now-playing): Postgres
tables + query layer + REST endpoints on the music router, all scoped to the
caller's user id and served directly by the platform (not proxied to the
user-stateless sidecar). Item `key`s are opaque track homePaths, same contract
as favorites — the server never interprets them.
- schema: music_playlists (name unique per user) + music_playlist_items
(0-based position, dupes allowed, cascade delete).
- queries: get/create/rename/delete playlists; add (append) / set (replace,
covers reorder+remove) items; every mutation ownership-checked; item ops in a
transaction that also bumps the playlist updatedAt.
- router (/api/music, before the catch-all proxy): GET/POST /playlists,
GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on
name collision, 404 on a playlist that isn't the caller's.
- migration 0002 (also backfills music_favorites/now_playing into the snapshot,
which were originally applied via a direct db:push). Applied to the DB.
Verified end-to-end against the live DB: create, dupes, ordering, append,
replace/reorder, ownership scoping, rename, counts, delete — all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The watcher's localized reindex was non-recursive and only ever touched the
exact folders it was handed, so renaming/moving a *container* dir (an artist
folder, or a whole subtree) left the manifest inconsistent: the renamed-in
album children were never indexed, and the old path's album keys lingered
forever (only the nightly full reindex healed it).
reindexFolder now takes { recursive } and, for any folder, also prunes any
manifest descendant whose top-level child dir has vanished from disk. The
watcher enqueues the containing folder SHALLOW (rebuild-this-album + prune a
renamed/removed-away child) and the event path itself RECURSIVE (index a
new/renamed-in container's album children). A recursive reindex of a plain
file or leaf album stays a cheap no-op / single rebuild, and a shallow reindex
of a big container (e.g. Albums) is just a readdir + key scan — no deep walk.
Verified in an isolated temp library: a case-only artist rename prunes the old
keys and indexes the 3 renamed albums while leaving unrelated albums alone; a
leaf file edit rebuilds just that album; deleting an album folder prunes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default POST /reindex is incremental (skips unchanged albums by version
stamp), so it can't backfill a meta-format change like the new track `disc`
field. Add ?full=1 to run reindexFull() instead — a from-scratch rebuild into a
fresh slot, atomically swapped in (safe, never disrupts the live index). Same
30-min per-request timeout applies. The nightly 3am run still does this
automatically; this is the on-demand trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ffprobe surfaces ID3 TPOS as the `disc` tag ("n" or "n/total"); parse the leading
number → IndexTrack.disc?: number in meta.json (undefined when absent/unparseable),
alongside `track`. Verified on Pink Floyd - The Wall (Disc 1 → 1, Disc 2 → 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analog of the videos feature — exposes per-folder images (excluding the album
cover files) under /api/music so the app can show an "Images" section.
Indexer: IMAGE_EXT + isImage; collect loose images (minus COVER_FILES); add them
to the version signature (img: parts, so v changes when one is added/removed —
no re-sync hack needed, source files); write meta.images: [{ file }] and a
manifest images count. Folders with only images now index too.
Serving: GET /image?path=<rel>&file=<img> streams the ORIGINAL image bytes from
the library folder (image/*, ETag=<v>, 304), basename + prefix-guarded against
traversal. Documented in the contract comment.
Verified: meta.images lists loose images with the cover excluded, manifest count
correct, /image path resolution + traversal guard. App side (music-api, Images
section) is the app's to add. No CACHE_VERSION bump — the sig change reindexes
exactly the folders that have images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework resolveTrackLyrics so a track's OWN embedded lyrics win, and SYNCED always
beats plain:
synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)
So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag)
becomes the canonical source over leftover .lrc/.txt sidecars — but a track with
only a *plain* embed still serves a synced .lrc until it's re-embedded (no silent
downgrade). Binary SYLT frames aren't readable here, so sync must be stored as
LRC text in the text lyrics tag (verified it round-trips + is detected as lrc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Display-only panel at the top of the Get Lyrics run-task form (single file only):
- Backend: GET /file-browser/audio-meta?path= — ffprobe format tags + duration,
plus a second probe for embedded lyrics (USLT/SYLT/lyrics* keys, case-insensitive).
Returns { title, artist, duration, hasLyrics }; tolerant of missing tags/probe
failures.
- Client: files.audioMeta(path) + AudioMeta type in useFilesAPI.
- TaskRunnerModal: prefetch audioMeta for get-lyrics single-file runs (bypasses
the hasTrackPickers early-return, error-tolerant), and render AudioMetaPanel
above TaskInputForm — title/artist + a muted length + Lyrics: Yes/No chip,
filename fallback. Directories + other tasks unaffected (no panel, no probing).
Verified ffprobe logic on a real embedded-lyrics file. tsgo clean; formatted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract walk()'s per-folder logic into a reusable buildFolder(), so a single
album/artist can be (re)indexed on its own. Add:
- reindexFolder(rel): rebuild just one folder's live cache entry + patch the
manifest (prune if the folder vanished). Its mtime-based signature means any
change — add / re-tag / delete — is picked up, and irrelevant touches no-op.
- withIndexLock: serialize ALL index mutations (full / incremental / localized)
so a localized reindex can never race the full reindex's atomic swap.
- watcher.ts: fs.watch(~/Music, { recursive }) → log every change → debounce 3s →
reindexFolder the affected folder(s). Verified: Bun's recursive watch fires
through the ~/Music symlink and on new-dir creation (so a new album's contents
are read by reindexFolder); inotify max_user_watches (~483k) >> folder count.
Started at boot, stopped on shutdown. The nightly full reindex backstops any
change that lands after a new folder's debounce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-scheduling timer in the sidecar (fresh setTimeout each night, so it always
fires at 3am local regardless of drift) runs reindexFull() — builds into a fresh
slot and swaps atomically only on success, never disrupting the live index.
Started at boot, cleared on shutdown. Logs the next scheduled time + each run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for a safe from-scratch reindex. The live cache path is now a SYMLINK
to a slot dir; readers + incremental writes follow it.
- ensureCacheSetup() (run at sidecar boot): makes `cache` a symlink to a slot,
migrating an existing real cache dir once (a fast rename, not a copy).
- runBuild(outRoot, prev): the build now writes to a given root and returns the
manifest without touching live serving state.
- reindexNow(): incremental, in-place live build (manual + localized updates).
- reindexFull(): builds a complete index into a FRESH slot without touching the
live one, then activateSlot() swaps the symlink atomically (rename-over) ONLY
on success — a failed rebuild leaves the live index untouched; old slots pruned.
walk() takes the output root. Verified end-to-end: fresh setup, full build +
swap + prune, incremental-through-symlink, and the one-time real-dir migration
(content preserved). Restart officer-music to apply (triggers the migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading the manifest (which every app refresh hits) used to call
ensureIndexFresh(), kicking off a debounced rebuild — and after the CACHE_VERSION
bump that meant a plain refresh could launch a full library rebuild. Make reads
side-effect-free: /manifest now just returns the last completed index. Builds are
explicit only (POST /reindex or the SSE stream); pick up disk changes by
reindexing.
Removes the now-unused ensureIndexFresh + lastBuildFinishedAt, and drops
/manifest from the 30-min per-request timeout extension (both hops) since it no
longer blocks on a build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A from-scratch rebuild holds the triggering request open for many minutes with
no bytes flowing, so both server hops' idle timeouts would drop it. Bun caps the
server-level idleTimeout at 255s, but server.timeout(req, seconds) allows more
per-request:
- sidecar Bun.serve: extend /reindex, /manifest, /reindex/stream to 1800s.
- platform proxy (music router): same, via the Bun server exposed as Hono's env.
Baseline idleTimeouts unchanged (sidecar 255, main server 60). The build always
completed in the background regardless; this keeps the request itself alive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A long full rebuild logged only "resync started" then nothing until "done".
Add a console heartbeat (throttled to 3s, piggybacked on emitProgress) showing
folders/built/skipped/tracks/videos/posters/lyrics counts + the current path, so
progress is visible in the pm2 logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one-time v1→v2 full rebuild (and long range/SSE reads) take far longer than
Bun.serve's default 10s request idle-timeout, which dropped the triggering
request mid-flight ("request timed out after 10 seconds"). Set idleTimeout: 255
(Bun's max). A build that still outruns it completes in the background regardless
— a closed socket doesn't cancel the in-flight build promise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fixed ~10% frame grab could land on a black/near-black frame for clips that
fade in from black (or on a title card). Keep the ~10% seek to skip intros, but
select the frame with `thumbnail=n=300` — ffmpeg picks the most representative
frame from the batch, which avoids uniform/black frames. Verified on a
fade-from-black video: luma ~122 (vs 0 at t=0), and it dodges the fade even when
seeking from the start.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>