Commit Graph
100 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 f98ebe509f document the sidecar env vars in .env.example
the example stopped at the core five and never mentioned the sidecars, so a
fresh checkout gave no hint that transmission, slskd or the vault are
configured at all. every value here is a placeholder.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:16:18 +00:00
pastilhasandClaude Opus 5 387664964c add a transmission sidecar and ui
officer-transmission is a new pm2 peer that owns the transmission rpc
connection and exposes a curated /_officer/* contract instead of proxying
raw rpc. it absorbs the three quirks callers otherwise have to know about:
the 409 x-transmission-session-id handshake, failures returned as
{"result": "..."} inside http 200, and basic auth where an empty username
must send no header at all.

/transmission is the ui, on the workspace/panel framework: a filter nav and
three sections (torrents, stats, settings). the torrent list is virtualised
with 30 available columns, multi-select, and a right-click menu; the detail
pane covers general, files as a real tree, peers and trackers. filters and
the open torrent live in the url, so a filtered view is a link.

phase 1 goal was parity with _references/transmission-web. follow-up work is
recorded in TODO.md.

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:14:48 +00:00
pastilhasandClaude Opus 5 17522be71a drive the headscale section from the url
sections were held in a usePanelChannel, which is exactly the opaque
click the navigation audit catalogues: the id lived in an onClick
closure, so a section could not be linked to, opened in a new tab or
reached with the back button.

/headscale/:section is now the source of truth. nav items are real
NavLinks with the active class coming from the router rather than
derived in js, and the screen redirects bare or unknown sections to a
canonical url so the highlight always matches the address bar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:36:39 +00:00
pastilhasandClaude Opus 5 62dfc572d0 show id and hostname in the node list heading
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:27:59 +00:00
pastilhasandClaude Opus 5 8029e506df probe the active headscale server automatically
the dot showed amber for the active server until you pressed test,
which read as a warning while every other section on the screen was
loading fine. probe the active one on mount so the dot always states
something checked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:24:49 +00:00
pastilhasandClaude Opus 5 0d227c46a3 headscale nodes, users and pre-auth key management
domain routes in the sidecar: nodes with per-route approval done as a
read-modify-write (headscale's approve_routes replaces the whole set),
users enriched with node counts, and pre-auth keys.

pre-auth key secrets are revealed by call path, not by inspecting the
value. headscale masks keys created since 0.28, but returns older
plaintext ones in full from the list endpoint for backwards
compatibility, so listing would otherwise ship live secrets into the
browser's query cache. the list always nulls the secret; only creation
reveals it, and the ui shows it once with a copy affordance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:08:02 +00:00
pastilhasandClaude Opus 5 adf922de30 add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and
their admin api keys, the >=0.29 version floor, and every multi-call composition
the ui needs. the platform side is auth+forward only and holds no headscale
credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars
are untouched and unrelated.

officer manages many servers rather than one. the owner registers each with a url
and a key generated on that server and switches between them; exactly one is
active, enforced by a partial unique index rather than by convention. keys are
encrypted at rest and never leave the sidecar — the list projection cannot return
one. registration validates before it saves: an unauthenticated GET /version to
prove something headscale-shaped is there and meets the floor, then an
authenticated call to prove the key works. an edit that moves either half
re-validates.

there is deliberately no transparent /api/v1/* passthrough. headscale serialises
every uint64 as a json string and its rest shape moved repeatedly below 0.29;
proxying raw would push all of that into the browser, which is the mistake the
soulseek panels made with 37 raw upstream calls.

the /headscale workspace is nav + view over the panel system. only the servers
section is implemented — nodes, users and pre-auth keys say so plainly rather
than rendering an empty table that reads as a failed fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:55:32 +00:00
pastilhasandClaude Opus 5 1e3dff27f5 add a switch that turns origin checking off entirely
The /api/music exemption was not enough on its own: an app has to reach /api/auth to sign
in before it ever calls its own feature, so the login was still 403ing on Invalid origin.

ALLOW_ANY_ORIGIN=true now accepts every Origin everywhere and skips the per-origin path
scoping. Set on this host; ALLOW_ANY_ORIGIN_MUSIC stays as the narrower option.

The account backstop is deliberately NOT disabled — a valid non-owner token is still
confined to /api/auth + /api/music whatever Origin it claims, because that rule is
account-based rather than origin-based, and it is the airtight half of the pair. Every
protected route still requires a valid token.

Reverting is an env edit and a restart. Both flags and their call sites come out when the
tailnet becomes the perimeter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:54:16 +00:00
pastilhasandClaude Opus 5 418729a470 temporarily allow any origin on /api/music
Off unless ALLOW_ANY_ORIGIN_MUSIC=true, so it is opt-in per host and reverting is an env
edit rather than a deploy. Set on this host now; the flag and the two call sites come out
when the tailnet becomes the perimeter and the music app stops needing to be public.

It drops one layer, not the lock: /api/music still requires a valid token through
userMiddleware, and the non-owner account backstop in originScopeMiddleware still confines
music accounts to /api/auth + /api/music whatever Origin they claim. Worth being plain
about what is lost — Origin was never authentication here. `officer://<hex>` is chosen by
the client, trivially forged outside a browser, and extractable from any shipped app
binary. It is defence in depth, and this removes it for one path prefix.

Two gates had to know: userMiddleware, which is what actually 403s, and the CORS origin
callback, which would otherwise echo an empty origin and block a browser client that
userMiddleware had already allowed through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:49:14 +00:00
pastilhasandClaude Opus 5 1e6b01cd21 docs: the target sidecar topology
The owner's design, agreed in conversation and written down before it evaporates — this
session started by recovering a design that had been lost with its chat.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 10:08:08 +00:00
pastilhasandClaude Opus 5 4598e53c89 derive app origins from the environment
Each app origin was declared three times — destructured from process.env, listed in
APP_ORIGINS, then given a rule — so adding an app meant editing the same file in three
places and remembering all three. Two apps in and the list was already wrong: OffTail had
been added to .env as OFF_SCALE_ORIGIN, which nothing read, so it did nothing.

Any `OFFICER_<APP>_ORIGIN` in the environment is now an app origin, and its rule comes
from its own name: an app may reach /api/auth (it has to sign in) plus the one feature it
is named for, so OFFICER_VAULT_ORIGIN gets /api/vault without being told. Adding an app is
adding an env var.

Two things stay hand-written because they are not that convention:

- OWNER_ONLY_APPS ('APP') — the main app is the whole platform rather than one feature of
  it, so it keeps full access but owner-only. A different kind of rule, not a path scope.
- APP_SCOPE_OVERRIDES ('TAIL' → /api/vpn) — the one app whose name and API surface differ.

NON_OWNER_PATHS also stays, and is worth not mistaking for the music app's rule even
though the value matches: it is the ACCOUNT backstop, applied whatever Origin a caller
claims or omits, and it is the airtight half of that pair.

Also renames MUSIC_APP_ORIGIN to OFFICER_MUSIC_ORIGIN (the other three already had the
shape) and drops the dead OFF_SCALE_ORIGIN. Env and code changed together; .env on this
host is updated, so this needs a restart, not a migration.

Verified the derived rules against the live .env: APP → superAdminOnly, MUSIC →
auth+music, VAULT → auth+vault, TAIL → auth+vpn — identical to what was hardcoded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:55:17 +00:00
pastilhasandClaude Opus 5 44a9124ef5 add POST /api/vpn/enroll for OffTail
The in-app Tailscale needs one thing from the platform: a way to turn an authenticated
Officer session into a Headscale pre-auth key, so the phone registers itself instead of
someone pasting a key by hand. The VPN's control and data planes talk directly to
Headscale — never through /api — so this is not a proxy and should not become one.

Headscale itself runs on a separate host, managed manually. The platform consumes
HEADSCALE_URL (also returned as controlUrl) and HEADSCALE_API_KEY, both from the host
env. Neither is set yet, which is why an unconfigured instance answers 503 rather than
crashing — Headscale is being stood up in parallel.

The response is `{ controlUrl, authKey }` exactly, because enrollVpn() in
@officer/core/officer-net.ts reads those two fields; changing the shape means changing the
app.

Two things the spec's sketch does not do:

- The `user` field changed meaning across Headscale versions — a name on <=v0.22, a
  numeric id on v0.23+ — and we cannot see which one is running from here. So it resolves
  the id via /api/v1/user and tries that first, falling back to the name. Whichever the
  live server accepts wins, and neither version needs a config flag.
- Upstream calls carry a 10s timeout, and upstream error bodies are logged but never
  returned to the client: that is an admin API and its errors are descriptive.

The standalone app's origin follows the platform's own convention rather than the spec's
literal: every other app origin is an env var with a scope rule, so this one is
OFFICER_TAIL_ORIGIN (set in .env on this host), restricted to /api/auth + /api/vpn the way
OffVault is restricted to /api/auth + /api/vault. The OffTail tile embedded in the main
Officer app needs nothing — it reuses OFFICER_APP_ORIGIN.

Not implemented: the optional GET/DELETE /api/vpn/devices. They are not needed for a first
connection and are better written against a running Headscale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:43:12 +00:00
pastilhasandClaude Opus 5 3da97ffe2e remove superseded design notes
CLAUDE.md already told readers these were "older design notes" and to treat the code as
the source of truth. Keeping them is worse than that warning: they are detailed enough to
be believed, and every one of them describes an app that has since changed.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:25:19 +00:00
pastilhasandClaude Opus 5 5a1e3d7e0b remove the projects feature
Being retired, so it is deleted rather than fixed — it had both defects dashboards just
had (edit dropping ?selected=, an abandoned edit following you onto the next item) and
repairing them was work for something on its way out.

Gone: the two screens and the panel apps, the three routes, the dock item and its entry
in the default dock paths (client and the three server-side copies), the page-title rule,
the app-registry entries, the barrel exports, the `projects` table with its queries and
row types, and the proj-meta/proj-layout/proj-terminals/proj-host-terminals branches in
the dashboards endpoint. The chat context and terminal state-key special cases for
`proj-layout-` went with them.

Deliberately kept: `getUserProjectsDir` in data-path.ts — the apps and dev-server routers
resolve user apps under the same on-disk Projects/ directory and are unrelated to this
feature. Nothing on disk is touched.

Needs `bun db:push` to drop the table; the schema change is the only thing standing
between the code and the database. One row was in it.

tsgo clean, 56/56 tests, no references left in src. Not yet exercised at runtime — the
restart is what will prove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:16:27 +00:00
pastilhasandClaude Opus 5 4970e7e70f dashboards: keep the selection while editing, and let the form win
Clicking edit dropped ?selected= from the url, so the address bar forgot which dashboard
you were editing — and on mobile that is worse than cosmetic: DashboardsScreen uses
`selected` to decide whether to show the right-hand panel, which is where the form
renders, so tapping edit opened it out of sight.

It cleared the param because it had to. The form was only reachable through
DashboardPreviewEmpty, which the preview renders when nothing is selected, so an edit
could only be shown by first pretending nothing was selected. DashboardPreview now checks
creating/editing itself, before it resolves the selection, and the empty state loses its
duplicate of that check — having two of them is what made the first attempt at this look
safe when it removed the only route to the form.

The form is shown only for the dashboard actually selected (`editingId === selectedId`),
and picking a row clears creating/editing. Without both, an abandoned edit followed you
onto the next dashboard: the url said you were on B while the form still edited A. The
click handler covers the ordinary path and the guard covers back/forward and pasted
links.

Verified in the browser: D5, D7, D8, D9 — edit keeps the param and opens the form, save
hands back to the preview of what you edited, switching dashboards mid-edit abandons it
cleanly, and returning to the first one shows its preview rather than the stale form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:00:14 +00:00
pastilhasandClaude Opus 5 67b91a7976 checklist: C, L and J pass; D under way
24 of 56 verified in the browser. C4 was the only failure (fixed in f5d3024). J1 looked
slow at first on a cold tab — the bundle had just been regenerated by a restart, and an
empty jobs list is indistinguishable from a loading one; GET /api/jobs answers in 3-5ms,
so there was nothing behind it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 07:35:59 +00:00
pastilhasandClaude Opus 5 9b9012d23d clip the content region instead of hiding its overflow
Clicking any link shifted the whole app up by ~70px: the top of the content slid behind
the fixed header, and the nav dock — which should stay hidden until you reach for it —
appeared at the bottom. It survived navigation and only a full reload cleared it.

`overflow-hidden` still makes an element a scroll container; it only hides the scrollbar.
The nav Dock is absolute inside this section and hides itself with
translateY(calc(100% + 24px)), and a transformed descendant contributes its transformed
box to the scrollable overflow area — so the section had ~70px of scrollable range it was
never meant to have. Clicking a link moves focus, the browser scrolls the nearest scroll
container to reveal the focused element, and everything inside (all of it absolute
inset-0) went up with it, the parked dock included.

overflow-clip clips identically but does not create a scroll container, so there is
nothing for focus-into-view to scroll.

Diagnosed from the running app rather than by reading: the section reported
scrollTop=70 at the moment the layout was wrong, matching the 70px the content and the
music bar had moved, while innerHeight/visualViewport stayed at 556 (ruling out the
dvh/URL-bar theory) and the outer div's scrollHeight equalled its clientHeight (ruling
out the shell). Confirmed fixed in the browser.

The outer div at :21 is also overflow-hidden and is left alone — it has no scrollable
overflow, so it cannot exhibit this.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:48:33 +00:00
pastilhasandClaude Opus 5 4e16a874dd merge the sidecar isolation work
Brings in the five per-sidecar branches that were integrated on `sidecars`: the agent
split out of officer's process tree (officer-anthropic-proxy + officer-agent as PM2
peers), turn output translated and committed by the sidecar that produced it for both
claude and opencode, the pty reconnect fix and its move under sidecar/, the vnc
password read and desktop reattach, the cliamp audio pipeline moved into the music
sidecar, and the email folder-filter SQL injection.

Verified live before merging: all ten PM2 entrypoints start clean, the agent resolves
the owner from the database and the proxy secret from disk, and a real turn round-trips
through the new path — the sidecar writes chat_session_events itself and `prevSeq`
chains correctly across messages.

One conflict, docs/navigation-audit.md (add/add): the copy on `sidecars` was an earlier
snapshot swept in by 62dc4c1, and master's is the same document with H1-H3 marked done.
Took master's wholesale — nothing was lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:29:04 +00:00
pastilhas bd1c8b578e Merge branch 'sidecars-opencode' into sidecars 2026-07-30 05:43:09 +00:00
pastilhas 9318fe8d0e Merge branch 'sidecars-claude' into sidecars 2026-07-30 05:43:05 +00:00
pastilhas f724d4b9d4 Merge branch 'sidecars-music' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas 5efffa9dcb Merge branch 'sidecars-vnc' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas 639d2a45da Merge branch 'sidecars-pty' into sidecars 2026-07-30 05:43:01 +00:00
pastilhas a5cb7b4246 Merge branch 'sidecars-email' into sidecars 2026-07-30 05:43:01 +00:00
pastilhasandClaude Opus 4.8 4c2582f184 audit doc: mark H1-H3 done + record the ?selected= design decision
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:36:08 +00:00
pastilhasandClaude Opus 4.8 63f45de869 projects: drive list selection from a ?selected= url param, not a global
mirror of the dashboards change: the /projects list row was a <div onClick> setting
SELECTED_PROJECT (a global) with no url change on-page. selection now lives in the url
as ?selected=<id>, read by the list, the screen (mobile panel), and the preview. rows
are real <Link>s (publish/edit/delete kept as sibling buttons); edit-save and delete
update/clear the param; NewProjectRedirect drops its now-redundant setSelected.

NOT runtime-tested yet (server not restarted) — verify /projects preview,
create/edit/delete/publish, and mobile panel flows on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:35:24 +00:00
pastilhasandClaude Opus 4.8 1b6b980af0 dashboards: drive list selection from a ?selected= url param, not a global
the /dashboards list row was a <div onClick> that set SELECTED_DASHBOARD_KEY (a
global) with no url change on-page, so selection wasn't deep-linkable and the row
wasn't a real link. selection now lives in the url as ?selected=<id>, read by the
list, the screen (mobile panel), and the preview. rows are real <Link>s (edit/delete
kept as sibling buttons, not nested in the anchor); the preview + its "open" link are
unchanged. edit-save and delete update/clear the param.

NOT runtime-tested yet (server not restarted) — verify the /dashboards preview,
create/edit/delete, and mobile panel flows on next restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:32:10 +00:00
pastilhasandClaude Opus 4.8 e3e67748c9 bind the email folder filter instead of interpolating it
/email/messages and /email/stats built `labels LIKE '%${folder}%'` by string
interpolation, and `folder` comes straight off the query string. Five statements across
the two handlers were exposed. The fragment is bound now, and it carries its parameters
with it because each handler builds several statements from the same fragment and has
to spread them in order.

Checked against an in-memory table: inbox/INBOX/SENT/all return exactly what they
returned before, and `x' OR 1=1 --` now matches nothing instead of being SQL.

Also: page and limit reached the bindings as NaN for any non-numeric value, so a
mistyped query param was a 500. They fall back to their defaults now.

And deleted src/servers/sidecar/email-cron.ts — 92 lines imported by nothing. The live
cron is sidecar/email/email-cron.ts; this was an older copy that still reached into
queue-runner and google-auth directly, so leaving it there invites someone to fix the
wrong file.

This is the first commit on the email branch; the placement problems (the whole mail
store, both syncs, and the resync coalescing that cannot work across processes) are
untouched and much larger — see SIDECAR_WORK_LOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:30:41 +00:00
pastilhasandClaude Opus 4.8 b83a6c7e77 move the whole local-audio pipeline into the music sidecar
cliamp playback was implemented entirely in officer: it located the cliamp binary,
validated the requested path against the owner's home, faked a PTY with `script`,
injected PULSE_SINK and an ALSA config shipped inside the API tree, spawned parec to
capture the sink, and set up the pulseaudio daemon and the virtual_out null sink at
every boot — about 356 lines of audio-pipeline knowledge in a process that is meant to
be a proxy, and none of it owned by the sidecar whose whole job is music.

all of it now lives in sidecar/music: cliamp-ws.ts serves both sockets (/cliamp/ws for
the player, /cliamp/audio/ws for the PCM capture) on the loopback server it already
runs, pulse-audio.ts does the daemon + sink setup at sidecar startup instead of at
officer's, and the asoundrc moved next to the code that passes it. officer keeps the
part that is actually its job — authenticating the browser — and relays frames both
ways without reading them (api/cliamp/relay.ts, same dumb-pipe shape as the vault
notifications relay). the browser's frame contract is unchanged, so the frontend is not
touched.

two things fixed on the way: the traversal check now requires a separator after the
home path, so a sibling directory whose name merely starts with it can no longer pass;
and the music proxy no longer special-cases /reindex and /reindex/stream by name to
extend the idle timeout — it extends the whole prefix, because a proxy should not know
which of the sidecar's routes are slow.

the music-specific `files` query param is out of the shared WS envelope too: upgradeWs
now carries the raw query string, which any relayed provider can use.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:27:42 +00:00
pastilhasandClaude Opus 4.8 04073dc6a0 jobs list rows are links to /jobs/:id instead of a navigate button
the row was a <button onClick={navigate(`/jobs/${id}`)}> with the id only in the
closure — no href, no cmd/middle-click. it's now a <Link>; the active-row highlight
already keys off useParams().id so nothing else changed. the stop/delete action stays
a button.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:24:43 +00:00
pastilhasandClaude Opus 4.8 397f464bb8 add navigation audit doc as reference for the routing work
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:21:18 +00:00
pastilhasandClaude Opus 4.8 71e39b7639 move opencode's turn output into its own sidecar
The second copy of the same problem. The opencode sidecar reported raw
ChatEvents and officer translated them, buffered the assistant text and wrote
every durable message to chat_session_events — so an officer restart mid-turn
lost whatever the model had produced since the last write, and `connect.ts`
dropped the events that arrived while it was down without a word.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:18:09 +00:00
pastilhasandClaude Opus 4.8 1014739b1b reattach the desktop when officer restarts under it
x11vnc mirrors :0 with `-forever`, so the desktop survives officer going away —
only the bridge socket dies. The panel treated that as the end of the session:
"Disconnected from desktop" until someone closed and reopened it. Retry with
backoff instead, and cover the password fetch too, since that is what fails
first while officer is still coming up.

Retries are generation-guarded so tearing an instance down cannot be mistaken
for a lost connection, and a security failure or a missing noVNC is terminal —
those do not come back on their own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:11:19 +00:00
pastilhasandClaude Opus 4.8 754fac42bb stop reading the owner's vnc password in the main process
The password lives in the owner's ~/.vnc, and the sidecar is the process that
writes it — together with the rfbauth file x11vnc actually authenticates
against. Officer read the plaintext half directly and answered with it before
ever asking the sidecar, which is both a secret the proxy has no business
opening and a way to hand out a password that no longer matches: if `passwd`
went missing while `password` survived, officer kept serving the old plaintext
and the desktop refused every login. `vnc:ensure-password` reconciles the pair,
so ask it every time and delete the reader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:09:13 +00:00
pastilhasandClaude Opus 4.8 ab261cf52d move the pty sidecar under src/servers/sidecar
it was the only sidecar living outside src/servers/sidecar/ — it sat in
api/terminal/ next to the bridge that talks to it, which is the one place a reader
looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs,
matching every peer, with a note on the pm2 entry about why this one is node and
.mjs (node-pty is a native addon) rather than bun and typescript like the rest.

the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs,
which is now its only consumer — the sidecar's duplicate seeder went with the
sandbox branch in the previous commit. api/terminal/ is left holding exactly one
thing: the websocket bridge.

no behaviour change. the pm2 entry's script path changed, so `pm2 restart
officer-pty` is not enough — pm2 remembers the old path until the entry is deleted
and started again. commands are in SIDECAR_WORK_LOG.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:03:03 +00:00
pastilhasandClaude Opus 4.8 1824f53c89 let the pty sidecar decide what shell it runs
officer built the whole PtyInitConfig: it read the owner's SHELL (defaulting to
/bin/zsh), added `-i`, read their HOME, expanded `~` against it, and hardcoded
`host: true`. none of that is a proxy's business — the sidecar is the process that
calls pty.spawn, so it is the one that should know what to spawn and where.

the config now carries only what the bridge actually knows: sessionId, the folder
the panel was opened on, and the client's cols/rows. shell, args, home and cwd
resolution moved into the sidecar. home comes from HOME_DIR ?? HOME, mirroring
data-path.ts:getOwnerHomeDir — terminal was the one host-executing surface reading
process.env.HOME directly, which is identical here and divergent anywhere HOME_DIR
is set to something else.

deleted the bwrap sandbox branch rather than moving it. it was selected by
`config.host`, which officer hardcoded to true, so it never ran — and it expected
`shell` to contain a fully-built bwrap command that nothing on either side ever
built. it could not have worked. a terminal here is the owner's own shell on the
owner's own machine by design (platform/CLAUDE.md), so there is no jail to preserve.
its ensureUserFiles half duplicated api/users/provision.ts:seedShellConfigs, which
is the live seeder of those same templates and stays.

also deleted the 'cwd' handler that turned a message into `cd <path>\r` typed at
the shell. no frontend has ever sent that message — the browser composes its own cd
— so it was unreachable, and synthesizing keystrokes is not something a relay
should do.

the integration test pins SHELL and HOME_DIR now that the sidecar reads them, and
asserts the shell starts in the resolved `~` rather than officer having resolved it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 05:02:10 +00:00
pastilhasandClaude Opus 4.8 b92e8dffad keep terminals alive when officer restarts under them
the pty sidecar registered `term.onData` with the socket that happened to be live
when the session was created. officer is a pm2 peer that restarts constantly, and
every restart hands this process a brand new socket, so every pre-existing session
went on writing to a closed one — where sendJson's readyState check dropped it
silently. the shell survived and still accepted input, because input arrives on the
new socket, but nothing ever came back. you typed and the terminal sat there. the
only way out was to close the panel, which orphaned the shell.

sendJson now reads the module-level socket at send time instead of taking one as an
argument, so there is no socket to capture and go stale. that is the whole fix.

the scrollback replay on re-attach becomes its own event, pty:replay -> 'replay'
on the browser socket. it used to arrive as ordinary output, which was fine for a
page load (fresh xterm) but not for a restart: the browser keeps its terminal, so
replaying blind printed a second copy of everything still on screen. marked as
history, Terminal.tsx resets and rebuilds from the sidecar's 50KB buffer instead.
it also stays out of the `output` branch so it cannot re-trigger the command /
initial-input logic that scrapes output for a sentinel.

added an integration test, because this is a reconnect bug and nothing short of an
actual reconnect proves it: it stands up a fake registration socket, runs the real
sidecar against it, echoes into a real shell, kills the socket, rebinds the same
port the way pm2 does, and asserts output still flows. verified it fails against
the old sendJson (times out after 15s waiting for the post-restart echo) and
passes in ~400ms with the fix. it never touches the running officer — the sidecar
dials API_URL, overridden per spawn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:59:06 +00:00
pastilhasandClaude Opus 4.8 238c3b8097 make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.

move the translation and the write into the sidecar:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:41:43 +00:00
pastilhasandClaude Opus 4.8 eca1f83162 workspace: maximize a panel without remounting its content
Maximize was swapping the app into a createPortal(document.body) branch, so React
unmounted + remounted it — losing scroll, playback, and in-flight state on every
maximize/restore. Now it's a pure CSS state toggle on the same element (fixed,
filling the content region), so the app instance is preserved and content stays
exactly as it was. The panel is confined to the layout's z-2 content stacking
context (under the fixed nav Header at z-10), so the maximized box starts below
the header — keeping the panel's own header/Restore visible and the nav reachable.
Dropped the portal + placeholder and the now-unused createPortal import.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:42:27 +00:00
pastilhasandClaude Opus 4.8 ba4503cdd9 vault: remove temp connect/token diagnostic log
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>
2026-07-29 03:13:16 +00:00
pastilhasandClaude Opus 4.8 f3dc4415bb music: per-device now-playing (browser vs phone)
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>
2026-07-29 03:13:16 +00:00
pastilhasandClaude Opus 4.8 91ed03d514 vault: pass full connect/token crypto through session/login
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>
2026-07-29 03:09:12 +00:00
pastilhasandClaude Opus 4.8 aae0fbd0ea vault: session-gated notifications WS + lifecycle cleanup
- 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>
2026-07-29 02:44:55 +00:00
pastilhasandClaude Opus 4.8 ccc86cca6d vault: session-gated auth-injecting proxy + token broker
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>
2026-07-29 02:40:21 +00:00
pastilhasandClaude Opus 4.8 192337cd1b vault: encrypted at-rest storage for brokered tokens + unlock key
Foundation for the platform-brokered vault auth (VAULT_AUTH_SPEC.md). Two
owner-keyed tables: vault_tokens (the brokered Vaultwarden access/refresh set)
and vault_unlock_keys (the Officer-app protector key). All secret columns are
AES-256-GCM encrypted via a VAULT_STORE_KEY-derived key (crypto.ts, lazy-loaded
so the platform still boots without it); queries encrypt/decrypt transparently.

Migration SQL is applied via db:push/psql (schema is source of truth); the
generated files are left out to avoid the shared-journal coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 02:33:28 +00:00
pastilhasandClaude Opus 4.8 4d6975934a vault: allow the OffVault origin to sign in to the platform
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>
2026-07-29 01:44:08 +00:00
pastilhasandClaude Opus 4.8 fcf6715844 vault: Vaultwarden reverse-proxy as the officer-vault sidecar
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>
2026-07-29 01:23:23 +00:00
pastilhasandClaude Opus 4.8 b2fb6f148c replace hardcoded download job with download-media script capability
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>
2026-07-29 01:10:32 +00:00
pastilhasandClaude Opus 4.8 b523c7d408 download job: also write a reference sidecar for FAILED items
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>
2026-07-28 19:34:35 +00:00
pastilhasandClaude Opus 4.8 7c43ff2291 download job: write each item's description as a sidecar .txt next to the media
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>
2026-07-28 19:26:16 +00:00
pastilhasandClaude Opus 4.8 459b8ab730 download job: restore the metadata phase — ReClip needs the title for filenames
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>
2026-07-28 19:17:27 +00:00
pastilhasandClaude Opus 4.8 7d149bd4d1 download job: use the panel's exact url list (no server re-expansion)
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>
2026-07-28 18:44:45 +00:00
pastilhasandClaude Opus 4.8 c55ec5884b download job: collapse to one phase (no metadata prefetch)
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>
2026-07-28 18:35:10 +00:00
pastilhasandClaude Opus 4.8 bc1b799a27 jobs: download-job UI — panel decision screen + live progress + /jobs renderer
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>
2026-07-28 18:20:07 +00:00
pastilhasandClaude Opus 4.8 4e78986e39 jobs: video/audio download as a two-phase job (backend)
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>
2026-07-28 18:09:37 +00:00
pastilhasandClaude Opus 4.8 612fd18c41 video download panel: per-format actions (video/audio) + select mode
Reworks the download model around a format choice (video = ReClip video, audio =
audioOnly) instead of a global "extract audio" toggle:

- Input phase is just URL + Fetch (dropped the audio checkbox).
- Each card tracks video + audio download state independently and shows a button
  per format (Video only when ReClip reports video formats — audio-only sources
  get just Audio).
- Playlist top actions: "All video" (when any item has video), "All audio", and
  "Select".
- Select mode: each card shows Video/Audio checkboxes (pick one, both, or none per
  item); a "Start download (N)" button runs the chosen set and shows a fake
  progress bar (N/M count, no real byte progress). Bulk "All video/audio" reuse
  the same progress bar.
- Single item = large card with the two format buttons, no bulk row.

VideoInfo gains `formats` so the client can tell video-capable from audio-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:32:06 +00:00
pastilhasandClaude Opus 4.8 46b1d62138 video download panel: grid layout — numbered playlist cells + large single card
Uses the resizable panel + container queries instead of a cramped vertical list:
- Single item → one large card (big 16:9 thumbnail, title, uploader·duration,
  full-width download button).
- Playlist → a responsive grid (2 cols, 3 at @520px, 4 at @760px of panel width)
  of compact cells, each with a position number badge and an overlay download/
  status chip.
Loading skeletons, error, and per-cell download states (download → downloading →
saving → saved / retry) carry over.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:15:21 +00:00
pastilhasandClaude Opus 4.8 0a8ec845ef web: music dock joins the layout flow — removes the nav-dock overlap hacks
The music dock was position:fixed, overlaying the bottom of the content, so the
nav dock needed a pile of hacks to dodge it: a hand-measured MUSIC_DOCK_HEIGHT
(72) constant, a presence flag, a translateY lift, and a matching hover-threshold
lift (via a ref) so it wouldn't slide away under the cursor. Fragile the moment
the music dock's height changed.

Now the dock is an in-flow bottom bar that reserves its own height:
- DashboardLayout is a flex column: the content region (flex-1, min-h-0) shrinks
  when the music dock takes its space; MusicPlayerHost renders an in-flow bar
  (shrink-0) instead of a fixed overlay.
- The nav Dock is absolute within the content region and measures its reveal/hide
  boundary from that region's bottom edge (a boundaryRef) — so it always sits just
  above whatever's at the bottom, music dock or not, with zero knowledge of it.
- Deleted MUSIC_DOCK_HEIGHT, musicDockPresent, the lift, and the transform hack.

Bonus: content at the very bottom is no longer hidden under the fixed dock.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:05:36 +00:00
pastilhasandClaude Opus 4.8 cc141ae91a web music: closing the dock clears the saved now-playing (so it stays closed)
The dock's X only reset local player state; the server's now-playing snapshot
survived, so a reload restored the dock. Closing now also DELETEs /music/now-playing
before clearing the queue.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:56:40 +00:00
pastilhasandClaude Opus 4.8 2540c9f5d3 music: fix stale cover/meta on the clients (cache revalidation) + purge removed covers
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>
2026-07-28 14:51:54 +00:00
pastilhasandClaude Opus 4.8 0ab82964b9 file browser: video downloader as an ephemeral side panel (was a modal)
Moves the video-download UI from a modal into the same ephemeral side-panel slot
the file viewer uses (double-click a video), per request. It's driven by the
`download` search param (the target folder) + `downloadRoot`, exactly like the
existing view/play/chat ephemeral panels.

- layouts: singleDownloadLayout (files-download panel).
- VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target
  folder/root from the params, does its own useFilesAPI, prefetch + per-entry /
  download-all flow (unchanged from the dialog), and bumps the shared
  `files:refresh-signal` so the browser re-lists when a file lands. Header shows
  a "Saving to <folder>" hint.
- useFileViewerPanels: register files-download (param → layout → panel + close),
  add download/downloadRoot to the on-refresh cleanup keys.
- useFileBrowserApp: replace the showVideoDownload modal state with
  openVideoDownload(), which sets the download param for the current folder+root.
- Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount
  and delete VideoDownloadDialog.tsx.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 14:33:51 +00:00
pastilhasandClaude Opus 4.8 f18d19e7d2 file browser: video download — metadata prefetch + playlist handling
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>
2026-07-28 14:19:07 +00:00
pastilhasandClaude Opus 4.8 43a4372ba3 web /music: reindex now refreshes BOTH panels in place
The MusicBrowser and MusicDetail panels each kept their OWN local `manifest`
(and folder-listing) state, fetched in their own effects. The reindex (↻) button
lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right
panel showing the tracklist/grid) never heard about it, so newly-indexed content
only appeared after navigating (which re-ran its effects).

Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser
bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder
fetches. In MusicDetail the fresh manifest object identity also re-triggers the
[cwd, manifest] listing effect, so the open album's meta/tracklist and any folder
grid refresh in place — no navigation required. Drops the browser's now-redundant
hand-refresh of its own state.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:16:11 +00:00
pastilhasandClaude Opus 4.8 597675d6a0 fix: paused track resumed on any page click (unlock listener overrode pause)
The AudioContext autoplay-unlock listener (pointerdown → ctx.resume()) was
persistent, so every click anywhere resumed the context — including after a
deliberate pause (pause = ctx.suspend()). Result: pause, click back on the page,
and the track resumed from its position while React's `playing` stayed false, so
the button showed "paused" and it took two clicks to actually stop it.

The gesture only needs to unlock the context ONCE; sticky activation lets
engine.play() resume it thereafter. Make the listener { once: true } so it can't
fight an intentional pause.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:44:25 +00:00
pastilhasandClaude Opus 4.8 f0d475ea2e fix: gapless player wiped the queue on natural track end (stale closure)
The engine is created once (mount effect), so its onIndex/onEndOfQueue callbacks
captured the first-render syncIndex/setPlaying. useGlobal's setData reads the
`data` from the render that created the setter when given a functional updater —
that's the INITIAL empty state. So when a track ended and the engine called
syncIndex(i) → setState(s => ({...s, index:i})), `s` was {queue:[], index:0}:
the queue got wiped, `current` went undefined, playback stopped and the dock
vanished. Manual track selection was unaffected because playQueue passes a plain
object (no stale `data` read) — which is why it seemed to work.

Route the two engine-invoked setters through refs kept current each render, so a
natural advance mirrors into the live state instead of the stale initial one.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:33:31 +00:00