Opening a chat from a non-default folder moved the cwd picker into a subdirectory of
that folder — three of the four sessions under ~/dockers/officer.dev jumped to
`platform`, the fourth to `Lyrics` — and everything after it was scoped to the wrong
directory. The list itself stayed correct, which is what made it confusing.
Two functions in claude-sessions.ts derived the cwd in opposite directions. parseSummary
(:103) keeps the first `cwd` it sees; parseClaudeTranscript (:182) overwrote it on every
entry, so it ended up with whatever a tool last cd'd into. A session's home is where it
was launched — that is how Claude Code files the transcript on disk and how the list
groups it — so the first one is right and the two now agree. It also stops a late entry
clobbering `fallbackCwd` when the caller already knew which group it was loading from.
Pre-existing, but only reachable since rows became real links (3682269): clicking one is
a route change now, so the deep-link resolver in ChatHistory/index.tsx:69 — previously
hit only on refresh or a pasted URL — runs on every click, and it feeds detail.cwd
straight into setActiveCwd.
Verified against the four real transcripts in that group: all four now resolve to
~/dockers/officer.dev. C4/C5/C6 pass in the browser (click, refresh, and fresh-tab
deep-link all land on the right folder).
Also adds docs/nav-test-checklist.md — the 56-check matrix for the navigation refactor
and the sidecar work, recovered from the transcript of the session that wrote it and
lost it (eff24773). The X block no longer needs its own branch now that sidecars is
merged. C is done, L/J/D/P/X/R are not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
/email/messages and /email/stats built `labels LIKE '%${folder}%'` by string
interpolation, and `folder` comes straight off the query string. Five statements across
the two handlers were exposed. The fragment is bound now, and it carries its parameters
with it because each handler builds several statements from the same fragment and has
to spread them in order.
Checked against an in-memory table: inbox/INBOX/SENT/all return exactly what they
returned before, and `x' OR 1=1 --` now matches nothing instead of being SQL.
Also: page and limit reached the bindings as NaN for any non-numeric value, so a
mistyped query param was a 500. They fall back to their defaults now.
And deleted src/servers/sidecar/email-cron.ts — 92 lines imported by nothing. The live
cron is sidecar/email/email-cron.ts; this was an older copy that still reached into
queue-runner and google-auth directly, so leaving it there invites someone to fix the
wrong file.
This is the first commit on the email branch; the placement problems (the whole mail
store, both syncs, and the resync coalescing that cannot work across processes) are
untouched and much larger — see SIDECAR_WORK_LOG.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
cliamp playback was implemented entirely in officer: it located the cliamp binary,
validated the requested path against the owner's home, faked a PTY with `script`,
injected PULSE_SINK and an ALSA config shipped inside the API tree, spawned parec to
capture the sink, and set up the pulseaudio daemon and the virtual_out null sink at
every boot — about 356 lines of audio-pipeline knowledge in a process that is meant to
be a proxy, and none of it owned by the sidecar whose whole job is music.
all of it now lives in sidecar/music: cliamp-ws.ts serves both sockets (/cliamp/ws for
the player, /cliamp/audio/ws for the PCM capture) on the loopback server it already
runs, pulse-audio.ts does the daemon + sink setup at sidecar startup instead of at
officer's, and the asoundrc moved next to the code that passes it. officer keeps the
part that is actually its job — authenticating the browser — and relays frames both
ways without reading them (api/cliamp/relay.ts, same dumb-pipe shape as the vault
notifications relay). the browser's frame contract is unchanged, so the frontend is not
touched.
two things fixed on the way: the traversal check now requires a separator after the
home path, so a sibling directory whose name merely starts with it can no longer pass;
and the music proxy no longer special-cases /reindex and /reindex/stream by name to
extend the idle timeout — it extends the whole prefix, because a proxy should not know
which of the sidecar's routes are slow.
the music-specific `files` query param is out of the shared WS envelope too: upgradeWs
now carries the raw query string, which any relayed provider can use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The 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>
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>
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>
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>
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>
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>
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>
User-level state for the music app, served by the platform from Postgres (not
the sidecar, which is stateless about users) under the same /api/music prefix
so the music-app account gate permits it:
- music_favorites (userId, kind, key) — kind ∈ track|album|artist, opaque path
key the server never interprets; unique per (user,kind,key), newest-first.
GET /favorites (grouped), POST /favorites (idempotent), DELETE /favorites.
- music_now_playing (one row/user) — current track + position snapshot for
resume-across-launch/device, with a light title/artist/album cache so the
resume card renders before the library index syncs.
GET/PUT/DELETE /now-playing (upsert).
Routes registered before the catch-all proxy. Tables created via direct DDL;
query layer smoke-tested against the live DB. MUSIC_API.md documents the
contract for the app. App-side wiring (heart toggles, Favorites view, player
persistence) follows next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three more same-level grid cards (bTop now 7 cards; Temperature stays under
Memory via natural 3-col flow):
- GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant).
- Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats
calls, aggregate + top interfaces.
- Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon).
Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU
package power shows "—" unless made readable (a udev rule); GPU watts work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the
CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp.
- BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) —
big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe,
wifi…) listed beneath.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
`docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
`pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
live pulse + back button); autoscrolls, capped at ~1200 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel
selects the scope over the 'monitor:scope' channel, the right MonitorMain panel
renders it. Three scopes:
- bTop — the existing system snapshot (CPU/mem/disks/top processes)
- pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/
mem/restarts/uptime table)
- dockers — new GET /api/system-monitor/docker (docker ps → container cards with
state/status/image/ports)
Both new endpoints degrade gracefully to an error field. Persisted as
screens/system-monitor; owner-only.
Needs a restart (backend routes + rebundle) + hard-refresh to appear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Activity" (placeholder name — jobs/tasks were taken) = watch background tasks
scroll in parallel with chat. Built DB-free; NOT restarted — deploy + test in
the morning.
- NDJSON progress contract (activity/progress.ts): capabilities append
{job,cap,phase,status,pct,detail,ts,...} lines; tolerant parser treats any
JSON object with phase/status as structured progress, else a raw log line.
- Backend (activity/router.ts, owner-only, path-guarded):
- GET /api/activity/tasks — registry by scanning /tmp/claude-*/<cwd>/tasks/
*.output (harness run_in_background) + announced detached jobs.
- POST /api/activity/announce {name,path} — register a detached (setsid) job's
log so it's followable too (the setsid case is on the critical path, since
the warm worker now makes plain run_in_background the default for heavy jobs).
- GET /api/activity/stream?task=<id>|path=<abs> — SSE tail (poll + offset),
emitting {kind:'line'|'progress'} with NDJSON parsed.
- Frontend /activity screen + Radio nav item: task list (active dot) → live tail
with a phase/pct progress header + raw log, following the /system-monitor pattern.
- Retention: startChatEventRetention() prunes chat_session_events >7d every 6h
(wired in bootstrap) so the durable queue stays bounded.
Verified headlessly (no restart): parseTailLine classification, and the scan
finds 53 real task output files. Endpoints + UI untested until deploy.
Deferred (see handoff): cross-device sync + OpenCode parity (both touch the
now-stable chat path — won't ship un-restart-tested); task:progress into chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the turn/session decoupling so nothing is lost across disconnects.
Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
to the queue (even while the client is disconnected) and delivered live with
its seq. Streaming deltas stay ephemeral (live-only, never persisted).
Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
the (still-live) session (cancels idle-GC via attachWs) and replays every event
since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
the max seq and, on every (re)connect with an established session, sends
resume-cursor — so a dropped connection self-heals with no manual navigate
away/back, and background task notifications that landed while offline replay.
Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.
Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a per-session teardown, distinct from the existing turn-only "stop":
- New WS 'disconnect' message → handleDisconnect → sessionManager.deleteSession,
which fires _claudeKill (kills any in-flight Claude/OpenCode turn) + _sidecarUnsub,
clears the idle timer, and drops the in-memory session. WS stays open so a new
prompt starts fresh. Server acks with 'disconnected'.
- useChat: disconnectSession() + a 'disconnected' handler (commit partial stream,
settle to idle).
- UI: an Unplug button in the chat DetailBar (shown while connected).
Scope note: targets the CURRENTLY-OPEN session (correct in-memory sessionKey).
Disconnecting an arbitrary *listed* session isn't wired yet — session-list rows are
keyed by the on-disk transcript uuid, which isn't the live sessionKey, so that needs
a reverse lookup + a REST endpoint. NOT yet deployed (needs a server restart).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend GET /api/system-monitor/stats: one snapshot — CPU overall + per-core
(two /proc/stat samples), memory + swap (/proc/meminfo), disks (df), load,
uptime, top-20 processes (ps). Owner-only via the account gate.
- Frontend /system-monitor screen: polls every 2s; CPU/Memory/Disks cards with
bars + per-core mini-bars, top-processes table. Nav dock "Monitor" item.
- Register /music and /system-monitor in usePageTitle RULES so the browser tab
and editable header title update on those routes (/music was missing too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>