meta.json is written in ffprobe/readdir order (arbitrary), and the web player
rendered/queued it as-is — so albums like Andrew Bird's "The Mysterious
Production of Eggs" showed scrambled (11, 7, 5, 14, 1, …). The web Track type
didn't even carry the `track` tag.
Add `track` to the Track type + a shared sortTracks(): by track NUMBER (parsed
from the "n/total" tag), falling back to tag title only for tracks without a
number. Applied to the rendered tracklist (setAlbum) and both play queues.
Verified it reorders that album to 1→14.
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>
Panels are now container contexts (@container → container-type: inline-size)
on both the normal and transparent PanelSlot render paths, so panel content
can respond to the *panel's* width instead of only the viewport's. Non-
breaking: existing screens keep their viewport (lg:/md:) variants untouched.
First adopter: the bTop system-monitor grid reflows on panel drag —
grid-cols-1 → 2 at @min-[600px] → 3 at @min-[960px] (panel width), keeping
cards ≥300px so they no longer squeeze on a narrow panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full /api/system-monitor/* contract (stats snapshot incl. cpu/mem/disks/temp/
gpu/net/power, pm2, docker, and the two SSE log streams) with response shapes,
auth (Bearer or ?token=), owner-only note, and the net/power rate caveats — so
the app can implement the same views. Also removes the orphaned Pm2Logs.tsx
(superseded by LogStream) that a prior commit left tracked.
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>
MusicBrowser now lists a folder's children as single-column list items and drills
via the shared channel (libraries → artists → albums). When the current path is an
album leaf it lists the album's siblings and highlights it, so you can switch
albums from the left while the right shows the tracklist. Handles the non-uniform
library layouts via the manifest's tracks count.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Split the screen into two registered panel apps that coordinate via a
'music:cwd' panel channel, like /chat:
- music-browser (left): library selector, publishes the path.
- music-detail (right): renders the path — album tracklist, artist discography
sections, or a folder grid — and drives the app-wide player.
MusicScreen is now a WorkspaceView over a horizontal 2-panel layout (persisted as
screens/music), so the panels are resizable. Registered in AppRegistry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A full-page music browser reusing the app-wide player + dock. Sidebar lists
libraries (1st level of ~/Music); main is a card-grid folder browse with rich
pages: albums show a header + tracklist, and artist folders render their album
cards grouped into discography sections (Studio Albums / Live / Compilation / …)
using /music/discography. Cards have hover-play; everything feeds useMusicPlayer.
Adds a green Music dock item (/music) to the default dock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Top-level dirs of ~/Music are libraries (tabs); within one you drill through
folders via /file-browser/ls with a breadcrumb until a folder has tracks, then
its songs (titled from the indexed /music/meta, filenames as fallback) with a
cover + play-all. Selecting a track feeds the app-wide player. Handles the
non-uniform library layouts (Albums/<Artist>/<Album> vs DJ Sets/<Artist>).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The widget browses the /api/music/* library (search albums → tracklist) and hands
a queue to an app-wide player. The player (useMusicPlayer, useGlobal-backed) and
its site-wide bottom dock (MusicPlayerHost) live in the persistent DashboardLayout,
so playback survives route changes. Dock has cover/title/artist, drag-scrubbing
(SeekBar), volume (persisted), and prev/play/next/close. The nav Dock slides up by
MUSIC_DOCK_HEIGHT while the music dock is present.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reload button (next to +) so newly-deployed widgets show up without a manual
hard-refresh after a rebuild+restart.
Co-Authored-By: Claude Opus 4.8 <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>
x11vnc mirrors :0, but with no monitor attached the connector has no EDID and no
CRTC, so GNOME renders nothing and the remote desktop is black. Reproduced the
hard way on this box: the desktop only worked because the session had started
while a screen was plugged in, and survived exactly until the next restart.
The step captures a connected monitor's EDID, installs it as
drm.edid_firmware with video=<connector>:1920x1080e so the connector reports
permanently attached, and adds a login-time hook to raise the resolution — the
replayed EDID's *preferred* mode is the captured panel's native one, which can be
tiny, and GNOME picks preferred. monitors.xml is the documented override but its
monitor matching did not take.
The EDID can only be captured from a screen that is plugged in, so a headless run
skips with instructions rather than pretending to succeed. Re-running is safe:
GRUB is left alone once the argument is present, and the mode setter no-ops when
the mode is already right.
officer-set-display.sh discovers the output and the largest mode within a cap
rather than hardcoding either, since setup runs before X exists and cannot know
them.
Co-Authored-By: Claude Opus 5 <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>
setup.sh installed pm2 but never ran anything with it, so a fresh install
finished with every dependency in place and nothing listening. That is not
cosmetic: /desktop returns 503 until officer-vnc is connected, and chat needs
officer-claude.
Adds a step that runs `pm2 startOrRestart ecosystem.config.cjs`, saves the
process list, and enables the boot unit when it is not already there. Using
startOrRestart rather than start means apps added to the ecosystem since the last
run get picked up — officer-music is in the ecosystem on this box but was never
running, for exactly that reason.
The verification block now reports which services are up, with the names read
from ecosystem.config.cjs so the list cannot drift as sidecars are added.
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>
Standalone reference for the mobile team: auth, streaming (/stream +
X-Audio-Duration), the synced library index (manifest/meta/cover + per-album v
diffing, ETag/304), building/refreshing (reindex + SSE progress), the
recommended resync algorithm, and the data shapes (IndexMeta/Manifest/
IndexStatus/IndexReport).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
It described a product that no longer exists: a multi-user intranet for small
businesses, a user-invitation API, bun dev serving a separate dashboard on port
5000, and a closing rule to "always consider user isolation and role-based
access" — the opposite of how this codebase now works. An agent opening this repo
read that before anything accurate.
Now mirrors the deployment root: AGENTS.md points at CLAUDE.md so the two cannot
drift, and lists which of the remaining root documents are current.
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>
The orientation layer above platform/CLAUDE.md and capabilities/CLAUDE.md: which
of the three directories a change belongs in, the two-repo git rules, how to run
and verify without disturbing the running server, and the task system's
conventions — including the ones capabilities/CLAUDE.md omits, like INPUT_INCLUDE
and inline: ask.
Also records failure modes found by running things rather than reading them: the
vision model inventing text for images that have none, Whisper's translate being
English-only, and the Host header that protected routes require.
Lives here rather than at the deployment root so it is versioned and reaches
every install; the root CLAUDE.md points at it.
Co-Authored-By: Claude Opus 5 <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>
OCR now exists as a task, with recursion, multi-select scoping and a choice of
inline or job. The file viewer's OCR button is untouched, and /file-browser/ocr
stays — it is what both that button and the task call.
Co-Authored-By: Claude Opus 5 <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>
Transcribe Audio covers it as a task, with recursion, multi-select scoping,
skip-if-already-done and a choice of inline or job — none of which the one-shot
menu entry had. Two ways to do the same thing from the same menu is worse than
one good one.
The file viewer's Transcribe button is untouched, and /file-browser/transcribe
stays: it is what both that button and the task call.
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>
A fresh clone has neither: index.gen.html is gitignored and built from
PUBLIC_URL, and the database schema is applied with push rather than migrations.
Without both, setup finishes on a checkout that cannot serve a page or reach a
table.
Runs after .env is written, since both depend on it. Failures warn rather than
abort so the rest of the verification still reports.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>