Reworks the download-video dialog into a prefetch-then-download flow, mirroring
ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader
logic moves to the platform.
Server (thin ReClip proxies alongside /download-video):
- POST /file-browser/video-info { url } → ReClip /api/info → { title, thumbnail, duration, uploader }
- POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls }
Both return { error } inline (200) so the client can render failures per-card.
UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files`
and drops the old single-shot state/handler):
- Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then
each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per
video), rendering a card (thumbnail, title, uploader, duration) that fills in
progressively.
- Per-entry Download, plus Download All when there's more than one; per-card
status (downloading → saving → saved / retry-on-error) via the existing
background job + poll.
- Playlists get an optional "subfolder you name" field (ReClip's /api/playlist
carries no playlist title); blank = current folder.
- Quality is always best (matches the mobile Share flow — no picker); the
audio-only toggle applies to the whole batch.
Verified ReClip's contract live: /api/info returns the metadata fields, and
/api/playlist returns { urls } (17 entries in ~1.2s).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Favorites / now-playing / playlists were being served by the platform router
straight from Postgres, which violated the intended split (officer = auth +
proxy; officer-music = the whole /api/music/* contract). Move them into the
sidecar so it owns ALL music endpoints — library AND user state.
- sidecar (index.ts): serves /favorites, /now-playing, /playlists[/:id[/items]]
backed by Postgres (the same officerdb queries other sidecars already use).
The authenticated user id arrives in X-Officer-User; the sidecar is loopback-
only, so it trusts the header (401 if absent). HTTP-contract comment updated.
- platform (router.ts): reduced to a pure auth+proxy catch-all — it now injects
X-Officer-User from the authenticated ctx user and forwards the request body
(favorites/now-playing/playlist writes carry JSON) in addition to Range/query.
No schema change — the tables are unchanged, only WHERE they're served moves.
Verified live: booted the real sidecar against the live DB and exercised the
endpoints with the X-Officer-User header — 401-without-header, favorites round-
trip, and full playlist CRUD with ownership scoping all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mirrors the existing per-user music state (favorites / now-playing): Postgres
tables + query layer + REST endpoints on the music router, all scoped to the
caller's user id and served directly by the platform (not proxied to the
user-stateless sidecar). Item `key`s are opaque track homePaths, same contract
as favorites — the server never interprets them.
- schema: music_playlists (name unique per user) + music_playlist_items
(0-based position, dupes allowed, cascade delete).
- queries: get/create/rename/delete playlists; add (append) / set (replace,
covers reorder+remove) items; every mutation ownership-checked; item ops in a
transaction that also bumps the playlist updatedAt.
- router (/api/music, before the catch-all proxy): GET/POST /playlists,
GET/PATCH/DELETE /playlists/:id, POST/PUT /playlists/:id/items. 409 on
name collision, 404 on a playlist that isn't the caller's.
- migration 0002 (also backfills music_favorites/now_playing into the snapshot,
which were originally applied via a direct db:push). Applied to the DB.
Verified end-to-end against the live DB: create, dupes, ordering, append,
replace/reorder, ownership scoping, rename, counts, delete — all pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The watcher's localized reindex was non-recursive and only ever touched the
exact folders it was handed, so renaming/moving a *container* dir (an artist
folder, or a whole subtree) left the manifest inconsistent: the renamed-in
album children were never indexed, and the old path's album keys lingered
forever (only the nightly full reindex healed it).
reindexFolder now takes { recursive } and, for any folder, also prunes any
manifest descendant whose top-level child dir has vanished from disk. The
watcher enqueues the containing folder SHALLOW (rebuild-this-album + prune a
renamed/removed-away child) and the event path itself RECURSIVE (index a
new/renamed-in container's album children). A recursive reindex of a plain
file or leaf album stays a cheap no-op / single rebuild, and a shallow reindex
of a big container (e.g. Albums) is just a readdir + key scan — no deep walk.
Verified in an isolated temp library: a case-only artist rename prunes the old
keys and indexes the 3 renamed albums while leaving unrelated albums alone; a
leaf file edit rebuilds just that album; deleting an album folder prunes it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The default POST /reindex is incremental (skips unchanged albums by version
stamp), so it can't backfill a meta-format change like the new track `disc`
field. Add ?full=1 to run reindexFull() instead — a from-scratch rebuild into a
fresh slot, atomically swapped in (safe, never disrupts the live index). Same
30-min per-request timeout applies. The nightly 3am run still does this
automatically; this is the on-demand trigger.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ffprobe surfaces ID3 TPOS as the `disc` tag ("n" or "n/total"); parse the leading
number → IndexTrack.disc?: number in meta.json (undefined when absent/unparseable),
alongside `track`. Verified on Pink Floyd - The Wall (Disc 1 → 1, Disc 2 → 2).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Analog of the videos feature — exposes per-folder images (excluding the album
cover files) under /api/music so the app can show an "Images" section.
Indexer: IMAGE_EXT + isImage; collect loose images (minus COVER_FILES); add them
to the version signature (img: parts, so v changes when one is added/removed —
no re-sync hack needed, source files); write meta.images: [{ file }] and a
manifest images count. Folders with only images now index too.
Serving: GET /image?path=<rel>&file=<img> streams the ORIGINAL image bytes from
the library folder (image/*, ETag=<v>, 304), basename + prefix-guarded against
traversal. Documented in the contract comment.
Verified: meta.images lists loose images with the cover excluded, manifest count
correct, /image path resolution + traversal guard. App side (music-api, Images
section) is the app's to add. No CACHE_VERSION bump — the sig change reindexes
exactly the folders that have images.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Rework resolveTrackLyrics so a track's OWN embedded lyrics win, and SYNCED always
beats plain:
synced-embedded > synced-sidecar(.lrc) > plain-embedded > plain-sidecar(.txt)
So an mp3 whose synced lyrics are embedded (as LRC text in the USLT/`lyrics` tag)
becomes the canonical source over leftover .lrc/.txt sidecars — but a track with
only a *plain* embed still serves a synced .lrc until it's re-embedded (no silent
downgrade). Binary SYLT frames aren't readable here, so sync must be stored as
LRC text in the text lyrics tag (verified it round-trips + is detected as lrc).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Display-only panel at the top of the Get Lyrics run-task form (single file only):
- Backend: GET /file-browser/audio-meta?path= — ffprobe format tags + duration,
plus a second probe for embedded lyrics (USLT/SYLT/lyrics* keys, case-insensitive).
Returns { title, artist, duration, hasLyrics }; tolerant of missing tags/probe
failures.
- Client: files.audioMeta(path) + AudioMeta type in useFilesAPI.
- TaskRunnerModal: prefetch audioMeta for get-lyrics single-file runs (bypasses
the hasTrackPickers early-return, error-tolerant), and render AudioMetaPanel
above TaskInputForm — title/artist + a muted length + Lyrics: Yes/No chip,
filename fallback. Directories + other tasks unaffected (no panel, no probing).
Verified ffprobe logic on a real embedded-lyrics file. tsgo clean; formatted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract walk()'s per-folder logic into a reusable buildFolder(), so a single
album/artist can be (re)indexed on its own. Add:
- reindexFolder(rel): rebuild just one folder's live cache entry + patch the
manifest (prune if the folder vanished). Its mtime-based signature means any
change — add / re-tag / delete — is picked up, and irrelevant touches no-op.
- withIndexLock: serialize ALL index mutations (full / incremental / localized)
so a localized reindex can never race the full reindex's atomic swap.
- watcher.ts: fs.watch(~/Music, { recursive }) → log every change → debounce 3s →
reindexFolder the affected folder(s). Verified: Bun's recursive watch fires
through the ~/Music symlink and on new-dir creation (so a new album's contents
are read by reindexFolder); inotify max_user_watches (~483k) >> folder count.
Started at boot, stopped on shutdown. The nightly full reindex backstops any
change that lands after a new folder's debounce.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Self-scheduling timer in the sidecar (fresh setTimeout each night, so it always
fires at 3am local regardless of drift) runs reindexFull() — builds into a fresh
slot and swaps atomically only on success, never disrupting the live index.
Started at boot, cleared on shutdown. Logs the next scheduled time + each run.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for a safe from-scratch reindex. The live cache path is now a SYMLINK
to a slot dir; readers + incremental writes follow it.
- ensureCacheSetup() (run at sidecar boot): makes `cache` a symlink to a slot,
migrating an existing real cache dir once (a fast rename, not a copy).
- runBuild(outRoot, prev): the build now writes to a given root and returns the
manifest without touching live serving state.
- reindexNow(): incremental, in-place live build (manual + localized updates).
- reindexFull(): builds a complete index into a FRESH slot without touching the
live one, then activateSlot() swaps the symlink atomically (rename-over) ONLY
on success — a failed rebuild leaves the live index untouched; old slots pruned.
walk() takes the output root. Verified end-to-end: fresh setup, full build +
swap + prune, incremental-through-symlink, and the one-time real-dir migration
(content preserved). Restart officer-music to apply (triggers the migration).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reading the manifest (which every app refresh hits) used to call
ensureIndexFresh(), kicking off a debounced rebuild — and after the CACHE_VERSION
bump that meant a plain refresh could launch a full library rebuild. Make reads
side-effect-free: /manifest now just returns the last completed index. Builds are
explicit only (POST /reindex or the SSE stream); pick up disk changes by
reindexing.
Removes the now-unused ensureIndexFresh + lastBuildFinishedAt, and drops
/manifest from the 30-min per-request timeout extension (both hops) since it no
longer blocks on a build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A from-scratch rebuild holds the triggering request open for many minutes with
no bytes flowing, so both server hops' idle timeouts would drop it. Bun caps the
server-level idleTimeout at 255s, but server.timeout(req, seconds) allows more
per-request:
- sidecar Bun.serve: extend /reindex, /manifest, /reindex/stream to 1800s.
- platform proxy (music router): same, via the Bun server exposed as Hono's env.
Baseline idleTimeouts unchanged (sidecar 255, main server 60). The build always
completed in the background regardless; this keeps the request itself alive.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A long full rebuild logged only "resync started" then nothing until "done".
Add a console heartbeat (throttled to 3s, piggybacked on emitProgress) showing
folders/built/skipped/tracks/videos/posters/lyrics counts + the current path, so
progress is visible in the pm2 logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The one-time v1→v2 full rebuild (and long range/SSE reads) take far longer than
Bun.serve's default 10s request idle-timeout, which dropped the triggering
request mid-flight ("request timed out after 10 seconds"). Set idleTimeout: 255
(Bun's max). A build that still outruns it completes in the background regardless
— a closed socket doesn't cancel the in-flight build promise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The fixed ~10% frame grab could land on a black/near-black frame for clips that
fade in from black (or on a title card). Keep the ~10% seek to skip intros, but
select the frame with `thumbnail=n=300` — ffmpeg picks the most representative
frame from the batch, which avoids uniform/black frames. Verified on a
fade-from-black video: luma ~122 (vs 0 at t=0), and it dodges the fade even when
seeking from the start.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Videos/tracks indexed before posters+lyrics existed were skipped by the per-album
`v` check (unchanged v → skip), so their posters/ and lyrics/ never generated —
a plain reindex couldn't fix it.
Add CACHE_VERSION (now 2). The `v` skip is only trusted when the on-disk
manifest is already at the current format; an older version forces a one-time
FULL rebuild that regenerates every album (incl. the new posters/lyrics), then
writes version:2 so subsequent builds skip normally. Verified: old-cache rebuild
regenerates the poster, next build skips (no loop).
Deploy = restart officer-music, then one reindex (a full rebuild, slower than an
incremental — one time only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each track's lyrics are resolved and cached at cache/<rel>/lyrics/<file>.<lrc|txt>,
with the format recorded as `lyrics: 'lrc'|'txt'` on the meta.tracks entry.
Precedence: external "<base>.lrc" > external "<base>.txt" > embedded tag
(lyrics / lyrics-<lang> / unsyncedlyrics — ffprobe now reads all format tags).
Content that contains [mm:ss] lines is stored as lrc even from a .txt/embedded
source. Only track-matching sidecars affect the version signature (a stray
notes.txt is ignored). Lyrics dir is wiped+regenerated per rebuild; new
`lyricsIndexed` counter.
Served by GET /api/music/lyrics?path=<rel>&file=<track> (text/plain +
X-Lyrics-Format header, ETag=<v>, 304, 404 when none).
Verified end-to-end: external .lrc wins over embedded; embedded → plain txt;
unmatched .txt ignored. MUSIC_API.md documents the field + endpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each indexed video gets a compressed poster (a frame grab ~10% in, capped at
30s, scaled ≤600px q5 like covers), written to cache/<rel>/posters/<file>.jpg
and recorded as `poster` on the meta.videos entry. The posters dir is wiped and
regenerated on each rebuild so orphans (removed videos) don't linger. New
`postersSaved` status counter.
Served by a new sidecar route GET /api/music/poster?path=<rel>&file=<video>
(image/jpeg, ETag=<v>, 304, 404 when none) — path-safe via basename.
Verified end-to-end on a real .mp4: video-only album → manifest {tracks:0,
videos:1}, meta.poster set, 14 KB poster on disk. MUSIC_API.md documents the
poster field + endpoint + postersSaved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Videos (all phone-compatible .mp4, plus common containers) that live in an
artist or album folder are now indexed alongside audio:
- Move 'mp4' out of AUDIO_EXT into a new VIDEO_EXT (mp4/m4v/mkv/mov/webm/avi) —
it was wrongly treated as an audio track before.
- ffprobeVideo captures file/title/durationSec/width/height per video.
- meta.json gains an optional `videos: IndexVideo[]`; a folder with only videos
now still gets a meta.json. Manifest entries gain optional `videos: N`.
- Video files join the album version signature (changes bump `v` for resync).
- New `videosIndexed` status counter + resync-log line.
Location is inherent in the folder rel (always an artist/album dir), so no
extra location field is needed. MUSIC_API.md documents the videos field +
manifest count. No poster/thumbnail generation yet (folder cover is reused).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
User-level state for the music app, served by the platform from Postgres (not
the sidecar, which is stateless about users) under the same /api/music prefix
so the music-app account gate permits it:
- music_favorites (userId, kind, key) — kind ∈ track|album|artist, opaque path
key the server never interprets; unique per (user,kind,key), newest-first.
GET /favorites (grouped), POST /favorites (idempotent), DELETE /favorites.
- music_now_playing (one row/user) — current track + position snapshot for
resume-across-launch/device, with a light title/artist/album cache so the
resume card renders before the library index syncs.
GET/PUT/DELETE /now-playing (upsert).
Routes registered before the catch-all proxy. Tables created via direct DDL;
query layer smoke-tested against the live DB. MUSIC_API.md documents the
contract for the app. App-side wiring (heart toggles, Favorites view, player
persistence) follows next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three more same-level grid cards (bTop now 7 cards; Temperature stays under
Memory via natural 3-col flow):
- GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant).
- Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats
calls, aggregate + top interfaces.
- Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon).
Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU
package power shows "—" unless made readable (a udev rule); GPU watts work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the
CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp.
- BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) —
big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe,
wifi…) listed beneath.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
`docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
`pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
live pulse + back button); autoscrolls, capped at ~1200 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel
selects the scope over the 'monitor:scope' channel, the right MonitorMain panel
renders it. Three scopes:
- bTop — the existing system snapshot (CPU/mem/disks/top processes)
- pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/
mem/restarts/uptime table)
- dockers — new GET /api/system-monitor/docker (docker ps → container cards with
state/status/image/ports)
Both new endpoints degrade gracefully to an error field. Persisted as
screens/system-monitor; owner-only.
Needs a restart (backend routes + rebundle) + hard-refresh to appear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 1 gap surfaced by a long overnight job: armIdle fired 30 min after the
last turn regardless of in-flight background work, so a silent run_in_background
job outliving the timeout got its persistent SDK session aborted — killing the
harness that delivers its task_notification (and any detached watcher's hook).
Fix = task-lifecycle heartbeat: track task:started → task:notification per
session; suppress/re-arm the idle timer while any task is pending. task:started
also clears a pending idle timer. So long run_in_background jobs keep their own
session alive and their completion is delivered; idle-GC resumes only once all
tasks finish and the session is truly idle. Pairs with the Activity path-tail
(35973a5) for the pure-setsid case.
Not deployed (no restart — long job still running); lands with the Activity
batch on next restart.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Activity" (placeholder name — jobs/tasks were taken) = watch background tasks
scroll in parallel with chat. Built DB-free; NOT restarted — deploy + test in
the morning.
- NDJSON progress contract (activity/progress.ts): capabilities append
{job,cap,phase,status,pct,detail,ts,...} lines; tolerant parser treats any
JSON object with phase/status as structured progress, else a raw log line.
- Backend (activity/router.ts, owner-only, path-guarded):
- GET /api/activity/tasks — registry by scanning /tmp/claude-*/<cwd>/tasks/
*.output (harness run_in_background) + announced detached jobs.
- POST /api/activity/announce {name,path} — register a detached (setsid) job's
log so it's followable too (the setsid case is on the critical path, since
the warm worker now makes plain run_in_background the default for heavy jobs).
- GET /api/activity/stream?task=<id>|path=<abs> — SSE tail (poll + offset),
emitting {kind:'line'|'progress'} with NDJSON parsed.
- Frontend /activity screen + Radio nav item: task list (active dot) → live tail
with a phase/pct progress header + raw log, following the /system-monitor pattern.
- Retention: startChatEventRetention() prunes chat_session_events >7d every 6h
(wired in bootstrap) so the durable queue stays bounded.
Verified headlessly (no restart): parseTailLine classification, and the scan
finds 53 real task output files. Endpoints + UI untested until deploy.
Deferred (see handoff): cross-device sync + OpenCode parity (both touch the
now-stable chat path — won't ship un-restart-tested); task:progress into chat.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the turn/session decoupling so nothing is lost across disconnects.
Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
to the queue (even while the client is disconnected) and delivered live with
its seq. Streaming deltas stay ephemeral (live-only, never persisted).
Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
the (still-live) session (cancels idle-GC via attachWs) and replays every event
since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
the max seq and, on every (re)connect with an established session, sends
resume-cursor — so a dropped connection self-heals with no manual navigate
away/back, and background task notifications that landed while offline replay.
Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.
Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a per-session teardown, distinct from the existing turn-only "stop":
- New WS 'disconnect' message → handleDisconnect → sessionManager.deleteSession,
which fires _claudeKill (kills any in-flight Claude/OpenCode turn) + _sidecarUnsub,
clears the idle timer, and drops the in-memory session. WS stays open so a new
prompt starts fresh. Server acks with 'disconnected'.
- useChat: disconnectSession() + a 'disconnected' handler (commit partial stream,
settle to idle).
- UI: an Unplug button in the chat DetailBar (shown while connected).
Scope note: targets the CURRENTLY-OPEN session (correct in-memory sessionKey).
Disconnecting an arbitrary *listed* session isn't wired yet — session-list rows are
keyed by the on-disk transcript uuid, which isn't the live sessionKey, so that needs
a reverse lookup + a REST endpoint. NOT yet deployed (needs a server restart).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- POST /reindex now runs the build to completion before responding (via a
coalescing reindexNow), and GET /manifest ensures a fresh (debounced 3s)
index first — so on-disk changes show up on a plain app refresh, not only
via the explicit reindex sheet.
- Log each resync in the officer-music sidecar (start + one-line summary,
or a failure line).
- Fix albums whose cover file isn't a decodable image (junk .jpg): the
manifest cover flag and the skip check now reflect whether a cover was
actually cached, so they settle to cover:false instead of rebuilding every
run (and the app no longer 404s fetching a cover that was never there).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend GET /api/system-monitor/stats: one snapshot — CPU overall + per-core
(two /proc/stat samples), memory + swap (/proc/meminfo), disks (df), load,
uptime, top-20 processes (ps). Owner-only via the account gate.
- Frontend /system-monitor screen: polls every 2s; CPU/Memory/Disks cards with
bars + per-core mini-bars, top-processes table. Nav dock "Monitor" item.
- Register /music and /system-monitor in usePageTitle RULES so the browser tab
and editable header title update on those routes (/music was missing too).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a Super Admin ("owner") identity — SUPER_ADMIN_EMAIL, else the
bootstrap/first user (super-admin.ts) — and closes the hole where a music
account could sign into the full platform:
- Rename EXPO_PUBLIC_CLIENT_ORIGIN -> OFFICER_APP_ORIGIN.
- PUBLIC_URL + OFFICER_APP_ORIGIN are owner-only origins; MUSIC_APP_ORIGIN
stays path-scoped to /api/auth + /api/music.
- Account backstop (origin-independent): a valid non-owner token may reach
only /api/auth + /api/music regardless of Origin — airtight even if the
header is omitted/forged.
- signin rejects a non-owner logging in from an owner-only origin.
Owner keeps full access (verified); music users are confined to the music app.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add MUSIC_APP_ORIGIN to the origin allowlist and a global
originScopeMiddleware that restricts scoped app origins (the standalone
officer-music client) to their permitted path prefixes — /api/auth and
/api/music — and 403s everything else. The main web origin is unaffected,
and the gate no-ops while MUSIC_APP_ORIGIN is unset.
Lets extra sign-in-only users authenticate through the music app and reach
only music + auth, without reintroducing any per-user permission scheme.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Whisper feeds its own decoded output forward as the prompt for the next 30s
window, which is what makes it loop once it slips. max_context is the budget for
that carried text; setting it to 0 stops the loops but also disables the initial
prompt, since whisper.cpp gates both on n_max_text_ctx > 0. carry_initial_prompt
puts the prompt in a static slot that is filled first and in full, so making the
prompt longer than the budget leaves nothing over for decoded text — the prompt
reaches every window and none of the model's own output does.
The prompt is a style exemplar, not an instruction: whisper imitates what it is
primed with. A first version described the format ("Commas separate clauses")
and a character surname came back as "Commas", so both prompts are now ordinary
conversational prose with no meta-language, one per language.
Also switch to verbose_json for segment timestamps and rejoin the segments into
paragraphs on pause length, rather than emitting one line per utterance with a
leading space; decode with beam search instead of greedy; widen the VAD segments
so they break at real pauses instead of at breaths; and stop verbose_json from
re-running a full language auto-detect that step 1 already answered.
Measured on a 43 minute episode against the previous output: redundant repeated
sentences 18 -> 9 (the long distinctive loops are gone, what remains is a
catchphrase), paragraphs 1 -> 93, lone-punctuation lines 3 -> 0, lines with a
leading space 1021 -> 0.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each Albums/<Artist>/_discography.md (author-maintained source of truth, never
modified) is compiled into a per-artist discography.json in the cache = album
folder → normalized release type (Studio/Live/Compilation/Single/EP/…), so the
player can split an artist's album list into sections.
- indexer.ts: parse the md table, normalize the Type (EP?→EP, Compilation (VA)→
Compilation, …), write discography.json. The artist folder's `v` now includes
_discography.md so regenerating it re-syncs just that small JSON (isolated from
the albums' meta/cover). Manifest gains `disco: true` on such entries. Also
fixed the skip check to require all expected outputs to exist, so artist/
cover-only folders no longer rebuild every run. New `discographies` counter.
- sidecar: GET /discography?path=<artist rel> (ETag/304), documented in the
contract header.
- MUSIC_API.md: §2.4 + manifest disco flag + resync algorithm updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
-scale 0.5 was hardcoded on the assumption that :0 is 4K. With no monitor
plugged in X falls back to something tiny — 800x480 on this box — and halving
that served an unreadable 400x240.
Read the framebuffer width from xrandr and scale only above 2560px. When the
width cannot be read, serve 1:1: too many pixels beats a thumbnail.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The desktop page has never worked on a fresh install. Two faults, both fatal.
The password could never be created. DesktopView fetches /desktop/vnc-password
before opening the WebSocket, but ensureVncPassword ran only from startSession,
which only the WebSocket triggers — so the endpoint answered "not configured",
the UI stopped, and the socket that would have provisioned it was never opened.
A new vnc:ensure-password sidecar command provisions it directly; the endpoint
asks for it instead of returning 500.
The rfbauth file could never be written either. ensureVncPassword shelled out to
tigervnc's `vncpasswd -f`, which is not installed — and, contrary to the comment
in setup-desktop.sh, is not in tigervnc-common, which ships only tigervncconfig.
The failure was swallowed because only a zero exit wrote the file, so x11vnc got
-rfbauth pointing at nothing. x11vnc writes that format itself with -storepasswd,
so the dependency is gone and a failure now throws.
Verified on the box: the endpoint returns a password, .vnc/{passwd,password} are
written 0600, and the sidecar reports mirroring :0 on 5900 with x11vnc using the
generated rfbauth file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bun's fetch aborts at 300s by default. whisper.cpp runs at roughly 8x realtime,
so a 43-minute recording needs about 5.4 minutes and died with "The operation
timed out" some 20 seconds short of the answer — long enough to look like a slow
machine rather than a ceiling.
AbortSignal.timeout() does not raise that ceiling; only `timeout: false` does,
which the DOM RequestInit type does not declare, hence the local BunRequestInit.
Verified end to end: a 43-minute episode through the task now completes in 547s
with a 27k-character transcript, where it previously failed at exactly 300s.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The proxy is an opaque catch-all, so the endpoint surface wasn't perceivable from
the platform side. Add a contract header (all routes + params + SSE/response
shapes) atop the sidecar fetch handler where the routes are defined, and point
the proxy router at it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a live progress channel for the library index:
- indexer.ts: progress subscribers (onIndexProgress) + throttled emit during the
walk, and buildReport() for a final summary.
- sidecar: GET /reindex/stream (SSE) — triggers a build if idle (?trigger=0 to
watch only), streams `progress` events, ends with a `done` event carrying the
report; auto-proxied at /api/music/reindex/stream for the app. Sidecar also
writes DATA_PATH/music/.server (its port) for local tooling.
- scripts/reindex-music.ts: CLI that reads the port file, follows the SSE, prints
live progress + a final report. Run: bun scripts/reindex-music.ts
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The officer-music sidecar now builds a cache tree mirroring the library (server
counterpart of the app's music-index.ts), and exposes an rsync-clean diff surface.
Indexer (indexer.ts): walks HOME_DIR/Music; per album computes a version `v` =
hash of the source signature (track name+size+mtime, cover size+mtime); ffprobe
→ meta.json (phone IndexMeta schema: file/title/artist/albumArtist/album/track/
year/durationSec); ffmpeg compresses the cover to <=600px q5 cover.jpg. Writes
DATA_PATH/music/cache/<rel>/. Incremental (skip albums whose `v` is unchanged),
prunes cache dirs for albums removed from the library, maintains manifest.json.
Endpoints (sidecar, auto-proxied by /api/music/*):
POST /reindex async build; GET /reindex/status polls progress
GET /manifest { version, albums: { "<rel>": { v, cover, tracks } } }
GET /meta?path=<rel> album meta.json (ETag: v, 304 on If-None-Match)
GET /cover?path=<rel> compressed cover (ETag: v, 304 on If-None-Match)
Phone resync: GET /manifest, diff `v` against last-stored → fetch only changed
albums' meta+cover; drop rels missing from the manifest. No re-download of
unchanged albums.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extract Audio now exists as a task, with recursion, multi-select scoping, a
format choice and multi-track handling — none of which the one-shot menu entry
had, since it always produced a single mp3.
/extract-audio stays because the file viewer's button plays its output rather
than saving it beside the video, but it no longer returns a cached file: like
transcription and OCR, it always redoes the work. The path is now named outRel,
since it is an output location rather than a cache.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds an officer-music sidecar that owns an audio-streaming HTTP server, and a
thin authenticating proxy on the platform. All processing (path resolution,
byte-range streaming, ffprobe duration) is in the sidecar; the platform only
authenticates and forwards.
App-facing contract (handoff):
GET /api/music/stream?path=<home-relative path>&token=<jwt>
- auth via userMiddleware (Bearer or ?token= for media elements)
- 200 full / 206 on Range, with Accept-Ranges, Content-Length,
Content-Range, Content-Type, and X-Audio-Duration (seconds, ffprobe)
- path resolved within HOME_DIR, traversal-guarded (400); 404 if missing
Purpose: stream + seek without pre-downloading the whole file — the app can
read X-Audio-Duration instead of scanning for VBR duration.
Pieces:
- sidecar/music/{index.ts,stream-audio.ts}: Bun.serve on a random port, /stream
+ /health, duration cached by path+mtime; reports its port via a new
music:server sidecar event on connect.
- api/music/{sidecar-server.ts,router.ts}: capture the port; reverse-proxy
/api/music/* → sidecar, streaming status + headers through.
- protocol.ts music:server event; hono.ts mounts /api/music; ecosystem adds
officer-music.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both endpoints kept a copy under cache/ and returned it on the next call, and
also short-circuited when the sibling .md already existed. So a re-run never
re-ran: a bad transcription stayed bad, and there was no way to ask for a fresh
one. Every caller passes saveNextTo, so the cache-path return was dead code
anyway.
Both now always do the work and overwrite the sibling. CACHE_PREFIXES drops the
two prefixes, since /save-result has nothing left to promote for them; the tts
and audio caches are untouched.
Also fixes the task runner output being unreadable in light mode. The panel is a
fixed dark terminal, but stdout lines were classed text-foreground, which follows
the app theme and renders black on the dark background. They now inherit the
pre's own colour.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes that Transcribe Audio needs.
Whisper's translate mode only ever outputs English, so it cannot honour
"translate into <language>" for anything else — it answered Portuguese audio with
a rough English rendering instead of a transcript. The translate decision is gone
and transcription is always faithful to the detected language; spokenLanguages
now only breaks ties on clips Whisper is unsure about.
Script tasks get OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN so they
can call Officer's own endpoints rather than reimplementing server-side work.
Requests go to 127.0.0.1 so nothing depends on DNS or the proxy, but origin
validation matches Host against PUBLIC_URL, hence the separate host variable.
`inline` accepts "ask", which offers both affordances in the runner — Run here
streams into the modal, Run as job queues it. Useful when the same task can take
a second or an hour depending on whether it was pointed at a file or a library.
Existing true/false values behave exactly as before.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CATEGORY_ORDER hardcoded Video/Audio/Cleanup in the frontend, so adding a
category meant a code change. The order now lives in categories.yaml at the root
of the items store and reaches the client via GET /tasks/categories — the
platform no longer knows any category by name.
The endpoint is declared before /:name, which would otherwise match
"categories". Categories used by a task but absent from the file still work: they
sort alphabetically after the listed ones, and Other stays last.
Menus consume grouped tasks rather than grouping them per row. Groups are built
from the tasks and the file only ranks them, so a category listed with no
matching tasks cannot produce an empty submenu — locked in by tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every task declares a directory trigger, so right-clicking any folder listed all
fifteen at once — Tag Album offered on a folder of photos. TASK.md gains an
optional `category`, and the context menu nests by it: Run Task > Video > …
Nesting only kicks in when more than one category matches. A .mp4 matches eight
tasks that are all Video, so file menus stay flat rather than gaining a pointless
hop. Known categories lead (Video, Audio, Cleanup); anything else follows
alphabetically with Other last.
Applied to both menus — the right-click one and the ⋮ dropdown — which carried
identical blocks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>