Commit Graph
543 Commits
Author SHA1 Message Date
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