Commit Graph
640 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 4.8 35973a5505 activity: follow the agent's background work live + chat-event retention
"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>
2026-07-27 00:56:10 +00:00
pastilhasandClaude Opus 4.8 6b3eb247a3 chat: durable Postgres event queue + cursor replay on reconnect (Phase 2+3)
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>
2026-07-27 00:12:46 +00:00
pastilhasandClaude Opus 4.8 449f28b1e5 chat: persistent Agent SDK session per chat — decouple worker from turn (Phase 1)
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>
2026-07-27 00:00:11 +00:00
pastilhasandClaude Opus 4.8 b9d539c1bd chat: "Disconnect session" — end the whole session from the UI (first stab)
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>
2026-07-26 23:22:53 +00:00
pastilhasandClaude Opus 4.8 bff11bc86d music: fresh-on-refresh reindex, resync logs, and stop the junk-cover rebuild loop
- 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>
2026-07-26 23:13:17 +00:00
pastilhasandClaude Opus 4.8 d1e19d1473 system-monitor: /system-monitor route (CPU/mem/disk/processes) + page titles
- 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>
2026-07-26 19:23:32 +00:00
pastilhasandClaude Opus 4.8 1d441e3aa1 auth: confine non-owner accounts to the music app (account + origin gates)
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>
2026-07-26 18:38:29 +00:00
pastilhasandClaude Opus 4.8 b40464632c auth: origin-scope the music app to /api/auth + /api/music
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>
2026-07-26 18:14:08 +00:00
brunorezioandClaude Opus 5 f4054193ae transcription: tune whisper for single-speaker audio, and paragraph the output
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>
2026-07-26 17:26:42 +01:00
pastilhasandClaude Opus 4.8 e46b28e14e music: left panel is a drill-down list navigator (never a grid)
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>
2026-07-26 13:05:30 +00:00
pastilhasandClaude Opus 4.8 6fab49eddc music: /music uses the Workspace/Panel system (2 vertical panels)
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>
2026-07-26 12:59:56 +00:00
pastilhasandClaude Opus 4.8 c3ed9158b3 music: /music route + nav dock item (Spotify-style page, v1)
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>
2026-07-26 12:49:05 +00:00
pastilhasandClaude Opus 4.8 219cfba7ce music: widget becomes a library/folder browser (tabs + Artist/Album/Song)
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>
2026-07-26 10:32:26 +00:00
pastilhasandClaude Opus 4.8 3308e4e24d music: Music Player widget + app-wide player dock
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>
2026-07-26 10:29:14 +00:00
pastilhasandClaude Opus 4.8 8a1e8cd79e widgets: resync button on the panel
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>
2026-07-26 10:29:14 +00:00
pastilhasandClaude Opus 4.8 9bf9b708cb reindex-music: show discography count in the report
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:40:58 +00:00
pastilhasandClaude Opus 4.8 946da85e4c music: index artist discographies (album → release type) for the app
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>
2026-07-26 09:39:11 +00:00
brunorezioandClaude Opus 5 592cc72f85 setup-desktop: configure the headless display
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>
2026-07-26 06:02:10 +01:00
brunorezioandClaude Opus 5 f0a0166ecd vnc: only halve the stream when the framebuffer is actually large
-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>
2026-07-26 05:21:48 +01:00
brunorezioandClaude Opus 5 02662fc780 setup.sh: actually start the services, and verify they are running
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>
2026-07-26 05:19:27 +01:00
brunorezioandClaude Opus 5 7556c9ed00 fix /desktop: break the VNC password deadlock, drop the vncpasswd dependency
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>
2026-07-26 05:15:46 +01:00
brunorezioandClaude Opus 5 96dd1bfe9e disable Bun's fetch timeout when calling whisper
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>
2026-07-26 05:07:38 +01:00
pastilhasandClaude Opus 4.8 4331882693 docs: MUSIC_API.md — /api/music/* contract for the app team
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>
2026-07-26 03:27:53 +00:00
brunorezioandClaude Opus 5 f08179606b replace the stale AGENTS.md with a pointer to CLAUDE.md
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>
2026-07-26 04:25:10 +01:00
pastilhasandClaude Opus 4.8 fe6f1fc095 music: document the full /api/music/* contract at the source of truth
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>
2026-07-26 03:23:09 +00:00
brunorezioandClaude Opus 5 1192aa23fc docs: add the working-on-officer guide
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>
2026-07-26 04:19:46 +01:00
pastilhasandClaude Opus 4.8 6224f6be51 music: SSE reindex progress stream + reindex-music CLI
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>
2026-07-26 03:18:41 +00:00
brunorezioandClaude Opus 5 b6b9e21b72 drop the built-in Extract Text (OCR) entry from the context menu
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>
2026-07-26 04:08:24 +01:00
pastilhasandClaude Opus 4.8 49b4773457 music: server-side library indexer + sync surface
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>
2026-07-26 02:59:23 +00:00
brunorezioandClaude Opus 5 0c7c015fc3 drop the built-in Extract Audio entry, and its cache
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>
2026-07-26 03:50:47 +01:00
pastilhasandClaude Opus 4.8 9d01000578 music: officer-music sidecar + /api/music streaming proxy
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>
2026-07-26 02:39:50 +00:00
brunorezioandClaude Opus 5 d6b4b900ff stop caching transcriptions and OCR
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>
2026-07-26 03:23:30 +01:00
brunorezioandClaude Opus 5 1263a4f851 drop the built-in Transcribe entry from the file browser context menu
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>
2026-07-26 03:12:58 +01:00
brunorezioandClaude Opus 5 06aac5478f always transcribe in the source language, and let tasks call the API
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>
2026-07-26 03:03:49 +01:00
brunorezioandClaude Opus 5 5f7d574dec read the task category order from the items store
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>
2026-07-26 02:41:33 +01:00
brunorezioandClaude Opus 5 6a77ec22df group file-browser tasks into category submenus
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>
2026-07-26 02:26:48 +01:00
brunorezioandClaude Opus 5 1d0842cc01 setup.sh: generate index.gen.html and apply the schema
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>
2026-07-26 01:49:17 +01:00
brunorezioandClaude Opus 5 52ddf7df0e generate index.html's absolute URLs from PUBLIC_URL
index.html hardcoded the deployment's domain in eight places, so every instance
had to carry its own edit of the file — the only thing separating the rezio
branch from master.

The tags genuinely need absolute URLs. OpenGraph is fetched standalone by
crawlers, and Bun's HTML bundler treats a root-relative href as an asset to
resolve on disk, failing the build with "Could not resolve: /favicon.ico" —
external URLs are the only form it passes through untouched.

Bun's HTML import offers no substitution hook, so scripts/gen-index.ts swaps
__PUBLIC_URL__ for the value in .env and writes index.gen.html, which the server
imports. index.html is the tracked template and is now identical on every
deployment; index.gen.html is gitignored. predev/prestart run the generator, and
it is idempotent so --watch does not loop.

Substituting also fixes the manifest: an absolute URL puts its fetch in CORS
mode, which failed whenever the hardcoded domain was not the serving origin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:49:17 +01:00
brunorezioandClaude Opus 5 7ef0e90cc4 let Whisper's language detection win when it is confident
Language selection ranked only the user's spoken languages, so a user who speaks
one language always got that language. With spoken=["en"], Portuguese audio
detected at 0.968 pt was sent as language=en, and Whisper answered with a rough
English rendering rather than a transcript. `translated` stayed false, so nothing
downstream could tell a translation had happened, and detectedLanguage reported
"en" for audio that was not English.

Take Whisper's top language when it clears 0.5, and fall back to the spoken
languages only when the clip really is ambiguous — which is the case the bias was
written for.

Verified against the local whisper server with pt/en/es recordings:
  spoken=["en"]      pt -> translated English, es -> translated English, en -> as-is
  spoken=["en","pt"] pt -> Portuguese transcript, no translation
  spoken=[]          pt -> Portuguese transcript

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 01:49:04 +01:00
brunorezioandClaude Opus 5 c4033e5392 normalize TTS voice lists to strings
OpenAI-compatible TTS servers disagree on the /v1/audio/voices payload: some
return plain strings, Kokoro returns objects like { id, name }. The handler cast
the response to { voices: string[] } without checking, so the objects reached
the voice <Select>, which renders each entry directly — React error #31, and the
whole system settings page unmounted.

Flatten to ids at the boundary, preferring id then voice_id then name, and drop
entries that yield neither. An empty result now falls through to the HuggingFace
lookup instead of returning an empty list. The ElevenLabs branch goes through the
same helper so a shape change there cannot throw either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:36:36 +01:00
brunorezioandClaude Opus 5 1ac79f9c68 remove the onboarding flow and the accountMode leftover
Onboarding was dead in three layers:

- The OnboardingAdmin screen was only reachable from a route block in App.tsx
  that has been commented out, so it never rendered. Its ServerTypeCard carried
  accountMode ('organization' | 'single'), inherited from the codebase this was
  based on and meaningless for a single-user platform.
- Two /onboarding-complete endpoints, one public and one protected, that no
  frontend code called. Both read a server_config key that was never written, so
  both answered false while the app's own path defaulted to true.
- HomeScreen gated on settings.onboarding.complete to show a welcome panel, and
  seedHomeDir created an Onboarding folder from DATA_PATH/Onboarding and
  /Onboarding_Admin — neither seed directory exists, so it only ever produced an
  empty folder.

Also drops the onboarding key from UserSettings and the now-empty home-header
panel from the default home layout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 10400310c5 drop the unapplied migration history
The seven files in officer_db/migrations/ were never applied — this database has
only ever been managed with db:push, so there is no __drizzle_migrations table
and the numbered history had drifted from the real schema (0001 creates a
saved_sessions table the database does not have).

Deleted so there is one story about how the schema gets applied. db:gen can
write a fresh baseline from the current schema.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 0e71d87f75 rewrite CLAUDE.md against the current codebase
The old version described a repo that no longer exists: apps/dashboard and
apps/editor, a tracking server on port 5001, an ephemeral_db, and a dashboard
and API running as separate processes. None of that is true.

Replaces it with what the code actually does — one Bun process serving the SPA,
the API and eight WebSocket providers; sidecars for the privileged work; the
split between Postgres and the file-backed items store; and the single-user
invariant stated as an invariant rather than a migration in progress.

Also records two things that are easy to get wrong from reading alone: the
database is maintained with db:push and has never had a migration applied, and
agents run unsandboxed with permissions bypassed on purpose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 0d67e2af26 clear the remaining type errors
- DiscordAccount seeded DiscordStatus without its two nullable fields.
- bug-report typed reporter.name as string, but users.name is nullable; and the
  Discord upload wrapped a Buffer directly in a Blob.
- Lucide icons take no `title` prop, so the sync spinner's tooltip moved to a
  wrapping span.
- DesktopView cast its dynamic import to a type that included `| null`.
- dock PUT cast the request body straight to string[]; it now rejects anything
  that is not an array of strings instead of writing it to the database.
- buildZodSchema assembles a mutable record, since z.ZodRawShape is readonly in
  zod v4.
- The dev-server proxy forwards Bun's `string | Buffer` frames through a helper
  that satisfies WebSocket.send without copying.

bunx tsgo is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 0041fcbd47 make useClient actually return its type parameter
Every verb ended in `return data as T | any`, and `T | any` collapses to `any` —
so client.get<Foo>() handed back `any` and no annotation downstream meant
anything. That was the source of most of the implicit-any errors: the callbacks
had nothing to infer from.

Returning `as T` drops the whole class (19 errors to 9) rather than annotating
each parameter. Two calls in useAuth were relying on the looseness and now
declare their response shapes.

signin also no longer stores `undefined` as the bearer token when the server
withholds one; that path returns early.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 1fa3a659bc restore the code editor screen, drop the dead theme picker, unshadow AppRegistry
Three unrelated type errors that each pointed at something actually broken:

- Dashboard.CodeEditor was deleted in ab03b17 ("Projects") while the
  /code-editor route and the dock's Editor item kept pointing at it, so the
  route rendered undefined. Screen restored.

- Appearance.tsx imported 'themes', a workspace deleted in 0746844. Nothing
  reads settings.appearance.colorTheme and no theme CSS survives, so the picker
  was writing a value with no consumer. Removed it and the setting; colorMode
  stays, it is live.

- officerdev exported both a component and a type named AppRegistry, so
  `import { AppRegistry }` resolved to the type and <AppRegistry /> failed to
  typecheck. Renamed the Record type to AppRegistryMap.

Also declares "*.css" so side-effect stylesheet imports resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 78130f21ce require a separator when checking a path is inside its root
resolveUserPath and five sibling checks used startsWith(rootDir), which also
accepts a sibling directory whose name begins with the root's: from a root of
/home/br, "../br-backup/secret" resolves to /home/br-backup/secret and passed.
Compare against root + sep (or the root itself) via a shared isInside helper.

Verified the escape cases now deny while "", ".", and ordinary relative paths
still resolve.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 c13875cef8 return 400 for an unparseable body, and cover origin validation
bodyParser caught parse failures and did nothing — the throw was commented out
and `body` was never set, so handlers destructured undefined and the client got
a 500 for a malformed request. Throw BAD_REQUEST instead.

Also adds the regression tests for the Host suffix match fixed in 2ca850c.

Verified against a running server: malformed JSON now 400, valid credentials
path still 401, spoofed Host still 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 1eb8cfd273 compare the Host header against the origin authority, not as a suffix
The no-Origin branch asked whether the configured origin ends with the
client-supplied Host, so `Host: dev` matched https://rezio.pastilhas.dev — as
did `pastilhas.dev` and `o.pastilhas.dev`. Match the URL authority exactly
instead. Officer always runs behind an HTTPS reverse proxy, so the forwarded
Host is expected to equal PUBLIC_URL's authority.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 8163f04420 fail closed when PUBLIC_BUILD_ENV is unset
Origin validation, every rate limiter and the password-strength check each
treated an unset PUBLIC_BUILD_ENV as "relaxed", so a deployment that forgot the
variable silently ran with CORS reflecting any origin, no brute-force limit on
the sole account, and no password rules. setup.sh writes it, but .env.example
never mentioned it.

The three now share IS_DEV_BUILD, which is true only when PUBLIC_BUILD_ENV is
explicitly "dev" or "development". Anything else, including unset, is hardened.
Documented in .env.example.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00