Commit Graph
100 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 35142df5a7 add a macos setup script and a trimmed pm2 ecosystem
setup.sh targets an ubuntu server and is left untouched. this is the
laptop equivalent: file browser, claude/opencode chat, terminal. no go,
rust, cliamp, pulseaudio, neovim, shell dotfiles, vnc desktop, sudoers
grant or power management — 510 lines against 1033.

every step is optional, prompted, and presettable non-interactively with
SETUP_* variables, so it also works as a repair tool for one piece.

nothing calls sudo. node@22 goes on PATH with brew link --force, pm2 into
~/.local, and the claude cli no longer needs a /usr/local/bin symlink now
that the sidecar resolves it.

postgres is detected before anything is installed — a server already
listening on 5432 (docker) is used as-is.

notes on the differences from setup.sh:
- set -e is on, but every optional step is guarded, so a failure warns and
  the run continues to a summary instead of aborting mid-way.
- .env is written 0600 and the generated JWT_SECRET is length-checked
  before use, since jwt.ts throws on anything under 32 chars.
- PUBLIC_BUILD_ENV=development, which is what lets plain http://localhost
  work with no reverse proxy in front.
- xcode command line tools are not required to build: node-pty and argon2
  both ship darwin prebuilds. they still matter for git on a fresh mac.

ecosystem.mac.config.cjs drops officer-vnc (x11vnc needs xorg),
officer-email and officer-music, and pins cwd on every app so bun picks
up .env wherever pm2 is started from.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 14:15:01 +01:00
pastilhasandClaude Opus 5 75ce4ad44f resolve the claude cli instead of hardcoding /usr/local/bin/claude
the path was pinned to /usr/local/bin/claude for the bwrap-sandboxed
architecture: the jail ro-bound /usr and saw nothing else, so the
installer's real target (~/.local/bin/claude) had to be symlinked
somewhere the sandbox could reach. that sandbox is gone, and the constant
outlived it — the sidecar could not run anywhere the symlink was absent.
a stock macos host has no /usr/local/bin at all.

resolve at module load instead: CLAUDE_BIN pins it explicitly, else
whatever is on PATH, else the locations the installer writes to. same
shape as OPENCODE_BIN in the opencode sidecar. the resolved path is
logged at startup.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:33:31 +00:00
pastilhasandClaude Opus 4.8 0ef8e9fbd2 web /music: sample-accurate gapless playback (Web Audio)
The player used a single <audio> element and advanced by swapping .src, which
re-fetches + re-buffers the next file — an audible gap between tracks. For a
continuous DJ mix (silence already trimmed at the edges) that's the whole
problem. Replace the <audio> element with a Web Audio engine that decodes each
track to an AudioBuffer and schedules the NEXT track's source to start() at the
exact AudioContext time the current track ends → sample-accurate, zero gap on
auto-advance.

- gapless-engine.ts (new): AudioContext + gain, LRU-capped decoded-buffer cache,
  fetch-whole-file → decodeAudioData, boundary scheduling, seek/skip/play-pause
  (pause = ctx.suspend so the clock + scheduled next freeze together), a
  generation counter to invalidate stale onended/async, and a gesture unlock for
  autoplay policy. Callbacks: onIndex/onTime/onEndOfQueue/onLoadingChange.
- MusicPlayerHost.tsx: drives the engine instead of an <audio> element. React
  keeps the queue/index (useMusicPlayer); user actions (new album, jump, prev/
  next) command the engine, and the engine's own natural advance mirrors back
  via syncIndex WITHOUT restarting playback (that's what keeps the seam gapless).
  Preserves restore/persist/heartbeat/album-nav/volume/heart; adds a decode
  spinner on the play button (startup/skip has fetch+decode latency by nature).
- useMusicPlayer.ts: syncIndex() — set index without touching `playing`.

Trade-off (chosen deliberately over near-gapless preloading): true gapless
needs the whole next file decoded to PCM ahead of time (~200MB per 10-min
track), so the buffer cache is capped at 3. Verified the scheduler state
machine with a mocked AudioContext: next track scheduled at the current's exact
end sample, advance/promote/seek/skip/end-of-queue all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 12:18:09 +00:00
pastilhasandClaude Opus 4.8 e2c9905885 move all music per-user state into the sidecar; platform = auth + proxy only
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>
2026-07-28 01:05:52 +00:00
pastilhasandClaude Opus 4.8 7a966c780e add per-user named music playlists (server-side infra)
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>
2026-07-28 00:37:29 +00:00
pastilhasandClaude Opus 4.8 e559c6c884 make the localized music reindex recursive + prune stale descendants
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>
2026-07-27 23:12:15 +00:00
pastilhasandClaude Opus 4.8 8980cfe717 music: POST /reindex?full=1 — on-demand full staged rebuild
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>
2026-07-27 22:38:18 +00:00
pastilhasandClaude Opus 4.8 40080f7f1e music indexer: add disc number to each track (from ID3 TPOS)
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>
2026-07-27 21:49:30 +00:00
pastilhasandClaude Opus 4.8 0e711d281c music: index + serve loose folder images (band photos, booklet scans)
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>
2026-07-27 19:25:19 +00:00
pastilhasandClaude Opus 4.8 c99b9c6d77 music: embedded lyrics are canonical (quality-aware precedence)
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>
2026-07-27 17:34:58 +00:00
pastilhasandClaude Opus 4.8 11263ce120 task modal: audio info panel for Get Lyrics (title/artist/length/lyrics)
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>
2026-07-27 17:07:18 +00:00
pastilhasandClaude Opus 4.8 55bf3aecca music: recursive watcher → localized reindex on any change
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>
2026-07-27 16:41:16 +00:00
pastilhasandClaude Opus 4.8 1f21768c4c music: nightly 3am full reindex (staged + atomic swap)
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>
2026-07-27 16:30:42 +00:00
pastilhasandClaude Opus 4.8 b1d1c91968 music: staging-slot cache with atomic symlink swap for full reindex
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>
2026-07-27 16:29:41 +00:00
pastilhasandClaude Opus 4.8 b84f0b0b18 music: /manifest is a pure read — never triggers a (re)build
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>
2026-07-27 16:07:39 +00:00
pastilhasandClaude Opus 4.8 48d24544f3 task modal: add multi-line 'text' input type (textarea)
`type: string` renders a single-line <input>; the new `type: text` renders a
resizable multi-line <textarea> (6 rows) — for pasted multi-line values like
lyrics. Checked before options/default so `text` always means free-form text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:54:27 +00:00
pastilhasandClaude Opus 4.8 3de22a3166 rescan: refetch inactive item lists too (works from any page)
invalidateQueries only refetches mounted queries, so a rescan pressed anywhere
but the Tasks/Skills/… page just marked those lists stale — the new item wasn't
picked up until you opened that page. Pass refetchType: 'all' so every item
cache refreshes immediately, wherever the button is pressed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:47:46 +00:00
pastilhasandClaude Opus 4.8 de0642766c music: 30-min per-request idle timeout for reindex/manifest (from-scratch builds)
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>
2026-07-27 15:23:27 +00:00
pastilhasandClaude Opus 4.8 43135865e2 music indexer: 3s progress heartbeat log during a build
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>
2026-07-27 15:15:35 +00:00
pastilhasandClaude Opus 4.8 219f93a831 music sidecar: raise Bun.serve idleTimeout (blocking reindex outran 10s default)
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>
2026-07-27 15:08:26 +00:00
pastilhasandClaude Opus 4.8 f408e4dd6f music indexer: pick video poster via ffmpeg thumbnail filter (dodge black frames)
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>
2026-07-27 15:05:14 +00:00
pastilhasandClaude Opus 4.8 4d3c17b2df music indexer: cache-format version → full rebuild for posters/lyrics migration
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>
2026-07-27 14:59:08 +00:00
pastilhasandClaude Opus 4.8 b3a4da4b97 music indexer: index track lyrics (.lrc/.txt sidecars + embedded)
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>
2026-07-27 14:51:58 +00:00
pastilhasandClaude Opus 4.8 10d7b6a425 music indexer: generate video poster thumbnails
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>
2026-07-27 14:38:15 +00:00
pastilhasandClaude Opus 4.8 d5b3dbb473 music indexer: index videos (concerts/clips) in artist/album dirs
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>
2026-07-27 14:16:45 +00:00
pastilhasandClaude Opus 4.8 d317bfc983 dock: lift the nav-dock hide threshold when the music dock is present
The nav dock rides MUSIC_DOCK_HEIGHT higher while the music dock is up, but the
hide threshold was still measured from the bottom — so hovering the raised dock
read as past HIDE_THRESHOLD and it slid away under the cursor. Lift hideAt (and
the magnify gate) by MUSIC_DOCK_HEIGHT; the reveal trigger stays at the edge.
Also read musicDockPresent via a ref so the once-registered mousemove handler
reacts to music starting/stopping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:56:51 +00:00
pastilhasandClaude Opus 4.8 ee6c7b0fdd music (web): Favorites view (inspired by the app's FavoritesScreen)
A heart button in the left panel header opens a Favorites view in the right
panel (coordinated via a new music:favorites panel channel). Grouped
Artists / Albums / Tracks, each row: cover thumb (indexed cover, icon fallback)
+ title/subtitle + a heart to un-favorite. Click an album/artist to navigate
the library there; click a track to play it in album context. Navigating
anywhere closes the view (cwd-change effect). Empty state prompts to heart
something.

useMusicFavorites now also returns the grouped `favorites`; shared gains the
channel + parseAlbumName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:40:33 +00:00
pastilhasandClaude Opus 4.8 f887fb4679 music (web): cursor-pointer on dock controls (Tailwind v4 no longer defaults it)
Add cursor-pointer to the player dock's buttons — track-info, prev/next (with
disabled:cursor-default), play/pause, mute, and close — and to MusicHeart (so
every heart across the player/tracklists gets it). The volume slider and SeekBar
already had it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:58:59 +00:00
pastilhasandClaude Opus 4.8 3f2efbf852 music (web): click the dock track info to open its album in /music
The bottom player dock's cover + title/artist is now a button — clicking it sets
the music:cwd channel to the playing album and navigates to /music, landing on
that album's tracklist (current track highlighted). Uses the global panel
channel + react-router navigate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:55:53 +00:00
pastilhasandClaude Opus 4.8 5b17121de8 music (web): browser filter + reindex; richer album tracklist
Left panel (MusicBrowser):
- Fuzzy filter input (case-insensitive subsequence match) over the current
  library/folder list; resets on navigation, with a clear button + "No matches".
- Reindex button (spins while running) → POST /music/reindex, then refreshes the
  manifest and current listing.

Right panel album view (MusicDetail):
- Current track clearly highlighted: primary tint background + a Volume2 marker
  replacing the track number + medium weight.
- Every track shows its artist (falling back to album artist) under the title,
  and its duration on the right. Web Track type gains albumArtist + durationSec;
  fmtDuration/fuzzyMatch added to shared.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:52:34 +00:00
pastilhasandClaude Opus 4.8 3924155bfb music (web): larger, spaced browser rows with cover thumbs; hide dotfiles
Left panel (MusicBrowser) polish + a shared fix:
- Larger row text (text-base; "Music" heading text-lg).
- More space between rows (gap-1.5 + py-2).
- Leading thumbnail is the folder's indexed cover (its folder.jpg/cover.jpg,
  server-compressed) with a Folder/Library icon fallback when there's none or
  the image fails.
- Hide hidden files/folders (dotfiles like .claude) in the listing — applied to
  MusicBrowser and the MusicDetail libraries/grid so neither panel shows them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:38:09 +00:00
pastilhasandClaude Opus 4.8 0995aaa8af music (web): resume currently-playing on reload/return
The web player kept no durable state, so a reload (and a return to /music,
whose cwd resets) lost your place. Wire it to the platform's per-user
/api/music/now-playing (same endpoints the app uses):

- MusicPlayerHost persists a snapshot (track + position) on play/pause + track
  change + a 10s heartbeat (position read via ref so the heartbeat stays live).
- On first load with an empty queue it restores that snapshot: rebuilds the
  album queue (sortTracks), loads it PAUSED (browsers block autoplay on reload),
  and seeks to the saved position once metadata is in. A restore guard stops the
  load from clobbering the saved position with 0.
- MusicDetail auto-opens the currently-playing album once on mount (when it has
  no location yet), so /music lands on the track — without yanking you back
  after you navigate away.

Adds loadQueue() (paused) to useMusicPlayer + a NowPlaying type. tsgo clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:24:47 +00:00
pastilhasandClaude Opus 4.8 759dcb8b5b music (web): artist heart on non-discography artist pages too
The artist heart only rendered in the discography view (disco && !album), which
needs a _discography.md — so artists without one (plain grid of album folders)
had no heart. Add an artist header + heart to the grid view when on an artist
folder (crumbs>=2), matching the app's inArtist rule. favKey = artist music-rel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:17:17 +00:00
pastilhasandClaude Opus 4.8 5af3119096 music (web): Favorites hearts in the /music player
Wires the web player to the platform's per-user /api/music/favorites (same
endpoints the app uses). New useMusicFavorites hook (react-query, shared
optimistic cache) + a reusable MusicHeart toggle, placed at:
- album header (album) + each track row (track, reveals on row hover, filled
  favorites stay shown)
- album cards in the artist/grid views (album)
- artist discography header (artist)
- the now-playing player bar (current track)

Track key = homePath "Music/<rel>/<file>"; album/artist keys = music-rel.
No dedicated Favorites browsing view yet (hearts only), matching the app.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:58:02 +00:00
pastilhasandClaude Opus 4.8 6fc34363c8 music (web): order album tracks by track number, not readdir order
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>
2026-07-27 11:50:34 +00:00
pastilhasandClaude Opus 4.8 21cb3489a7 music: per-user Favorites + Currently-playing (platform/Postgres)
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>
2026-07-27 11:28:28 +00:00
pastilhasandClaude Opus 4.8 54fa21dd46 workspace: panel-level responsiveness via CSS container queries
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>
2026-07-27 11:16:17 +00:00
pastilhasandClaude Opus 4.8 de8f70562b docs: SYSTEM_MONITOR_API.md contract for the app + drop dead Pm2Logs.tsx
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>
2026-07-27 11:03:10 +00:00
pastilhasandClaude Opus 4.8 f9752d2868 system-monitor: GPU, Network, and Power cards in bTop
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>
2026-07-27 10:59:27 +00:00
pastilhasandClaude Opus 4.8 75a978d2e2 system-monitor: CPU temperature in bTop (Temperature card under Memory)
- 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>
2026-07-27 10:51:54 +00:00
pastilhasandClaude Opus 4.8 41ebd73c2d system-monitor: docker grid view + name/image filter
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:45:36 +00:00
pastilhasandClaude Opus 4.8 f3108f6d83 system-monitor: black log pane + live docker container logs
- 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>
2026-07-27 10:42:02 +00:00
pastilhasandClaude Opus 4.8 67e6646bca system-monitor: live pm2 log streaming — click a process name to tail its logs
- 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>
2026-07-27 10:32:53 +00:00
pastilhasandClaude Opus 4.8 7f91c4f9bf system-monitor: sortable columns in the pm2 view (click headers; asc/desc)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:25:37 +00:00
pastilhasandClaude Opus 4.8 1b7a5feeea system-monitor: show pm2 id (#) column in the pm2 processes view
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:24:20 +00:00
pastilhasandClaude Opus 4.8 059929aa59 system-monitor: Workspace/Panel layout with a scope list (bTop / pm2 / dockers)
/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>
2026-07-27 10:17:15 +00:00
pastilhasandClaude Opus 4.8 621f93b884 chat: don't idle-GC a session while background tasks are still running
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>
2026-07-27 09:34:59 +00:00
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
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
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
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
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
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
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
pastilhasandClaude Opus 4.8 92de996412 setup.sh: symlink claude into /usr/local/bin
The Claude sidecar execs /usr/local/bin/claude (claude-manager.ts), but the
Anthropic installer only puts the CLI in ~/.local/bin — so on a fresh host that
path doesn't exist and claude chat fails with
"ENOENT … posix_spawn '/usr/local/bin/claude'". Symlink ~/.local/bin/claude →
/usr/local/bin/claude after install (idempotent; tracks Claude's self-updates).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 17:22:26 +00:00
pastilhasandClaude Opus 4.8 f226542de3 setup.sh: prompt for OFFICER_ITEMS_DIR in .env generation
The item store location wasn't written to .env, so a fresh server fell back to
<repo>/officer-items and booted with an empty store. Prompt for it (default: a
sibling of the repo) and write it to .env.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:45:59 +00:00
pastilhasandClaude Opus 4.8 f7b8cbe3f3 setup-desktop: install tigervnc-common for vncpasswd
The VNC sidecar builds its .vnc/passwd rfbauth file with `vncpasswd -f`
(vnc-manager.ts) — x11vnc alone doesn't ship vncpasswd. The GNOME-on-Xorg
rewrite dropped tigervnc, so the mirror couldn't create its password and
/desktop failed with "VNC password not configured". Add tigervnc-common back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:26:31 +00:00
pastilhasandClaude Opus 4.8 97f6d7fecc auth: single-step super-admin bootstrap (no verification email)
Collapse the two-phase bootstrap (email a verification link → verify screen) into
one direct step: the Bootstrap form collects name/email/username/password and posts
once to /bootstrap, which creates the first user directly as an active Super Admin
(+ provisions DATA_PATH/<email>). Still gated to an empty user table.

The invite flow (/verify, /verify-token) is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:11:37 +00:00
pastilhasandClaude Opus 4.8 d604b1e722 provisioning: drop multi-user remnants, provision the super admin on bootstrap
Remove the multi-user provisioning leftovers (the provision-existing-users.sh
migration was already deleted in the prior commit):
- gut provisionVncEnv from provision.ts (per-user startxfce4 virtual desktop, dead
  since the switch to mirroring :0 — vnc-manager.ts self-provisions its own passwd)
- drop the Pi `.pi/agent/sessions` seed and the now-orphaned `run` helper

Wire provisioning into bootstrapHandler: the super admin (first user) is created via
createUser, which never called provisionUserEnvironment — only the invite/verify flow
did. So the single user's DATA_PATH/<email> was never provisioned up front. Now it is.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:32:53 +00:00
pastilhasandClaude Opus 4.8 1e789b4c44 setup: Ubuntu GNOME-on-Xorg desktop + setup.sh hardening
setup-desktop.sh now installs ubuntu-desktop + gdm3 + x11vnc and forces the
Xorg session (WaylandEnable=false) with auto-login — x11vnc can only mirror an
Xorg :0, not Wayland. vnc-manager.ts resolves the X authority from the GDM
per-session path (/run/user/<uid>/gdm/Xauthority) with a ~/.Xauthority fallback.

setup.sh fixes:
- desktop step gates on `dpkg -s ubuntu-desktop` (was the decommissioned
  officer-vnc service, which never matched so setup-desktop re-ran every time)
- remove Pi (install, --list-models validation, verification check)
- export GOPATH before the cliamp build so `go install` lands where it's checked
  even when Go was already present this run
- write PUBLIC_BUILD_ENV=production and quote all .env values
- guard the interactive .env block behind a TTY check so non-interactive runs
  skip cleanly instead of aborting on read EOF under set -e
- restart systemd-logind only when a key actually changed
- sed prefix-strip instead of `tr -d` (which deletes characters, not a prefix)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:32:28 +00:00
pastilhasandClaude Opus 4.8 5d077a4a54 route OpenCode chat through the officer-opencode sidecar
Turns now run in the sidecar via `opencode run --dir <cwd> --format json
--dangerously-skip-permissions [-s <ses_>]` instead of the serve's
`POST /session/{id}/message` path. That path was unreliable at reporting
tool completion — tools finished but the turn stayed status=running,
wedging the UI at "Working…". `run` re-anchors tools to the chat cwd via
--dir, reports completion faithfully, and exits when done.

- runner.ts (new): spawn `run`, map its JSON events (text/tool_use/
  step_finish) to ChatEvent, report the `ses_` id for resume, accumulate
  cost; inactivity (120s) + hard-cap (10min) watchdogs kill a hung turn
  and emit a clean error instead of hanging forever.
- protocol.ts: opencode:run-streaming/kill commands; opencode:spawned/
  event/session events; OpenCodeRunParams.
- sidecar index.ts: wire run/kill; sweepStaleServes() on startup kills
  only an `opencode serve` whose resolved /proc/<pid>/cwd == SERVE_CWD,
  so an unclean prior exit can't leave two.
- sidecar-registry.ts: spawnOpenCodeStreaming/killOpenCode/onOpenCodeEvent/
  onOpenCodeSession helpers.
- send-opencode.ts: rewritten to mirror send-claude-code (subscribe →
  resolve resume id → spawn → kill handle).
- sidecar-server.ts: persist reported ses_ id into state for resume.
- list-models/server-manager: route to the sidecar's reported serve URL.

The serve stays up only for read-only calls that never hung (model
listing, session history).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 14:54:52 +00:00
pastilhasandClaude Opus 4.8 dcb23b0a86 server: allowed origin from PUBLIC_URL; repoint web assets to the new domain
origin-validation: the production web origin now reads from PUBLIC_URL (.env), e.g.
https://officer.pastilhas.dev, instead of a hardcoded domain; drop alpha.officer.dev.
officer-web/index.html: point og:image/favicon/manifest/etc. at the new domain (served
locally from public/).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:43:57 +00:00
pastilhasandClaude Opus 4.8 203a8b0708 opencode: add the OpenCode sidecar (step 1 — serve lifecycle + port report)
New officer-opencode sidecar (same philosophy as officer-claude): a singleton that owns
an `opencode serve` running from DATA_PATH/opencode-sidecar (created if missing) on a
random port, registers with the API as capability 'opencode', and reports its port via a
new `opencode:server` protocol event. The API stores it (sidecar-server.ts, wired in
server.tsx via getOpenCodeServerUrl). ecosystem.config.cjs runs the sidecar instead of a
bare pm2 serve. Turn-running + API rewiring come in later steps.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 13:43:57 +00:00
pastilhasandClaude Opus 4.8 6fb48af402 chat: strengthen OpenCode cwd prompt — demand absolute paths on every tool call
The prior "treat this as your cwd" wording didn't stop the model from using bare
globs/relative paths, which OpenCode resolves against the server cwd. Reworded to
explicitly require absolute paths under the target dir on every tool call (and `cd`
for bash). Still a soft override; a stronger attempt before considering `opencode run --dir`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:46:02 +00:00
pastilhasandClaude Opus 4.8 d8e0dc79ef chat: send cwd on every message instead of reading it back from session metadata
Simpler + more efficient than the previous per-turn GET /session: the client already
has the selected cwd, so it now sends it on every message (it's constant for a session).
The server uses msg.cwd directly for OpenCode's per-turn working-directory system prompt,
and still tags the cwd at creation for listing. Drops the getSession round-trip.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:26:41 +00:00
pastilhasandClaude Opus 4.8 2a940de76a chat: keep OpenCode session cwd consistent across turns + strengthen the override
msg.cwd only rides the first message, but OpenCode's system prompt is rebuilt every
turn — so turn 2+ reverted to general_chat_sessions and the model fell back to the
server's real cwd. Fix: the cwd is bound at creation (metadata.officer.cwd), so for an
existing/resumed session read it back (new client.getSession) and reuse it for the
system prompt every turn. Also make the prompt explicit that it overrides any other
working directory the environment reports.

Verified: a session created with a cwd reads the same cwd back on a later turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:21:28 +00:00
pastilhasandClaude Opus 4.8 88b74b2888 chat: OpenCode /chat cwd = general_chat_sessions (match Claude), drop home special-case
Both harnesses now use the resolved chat cwd as their working directory: Claude runs
in it natively, and OpenCode is told the same via its system prompt. Removes the
earlier special-case that pointed OpenCode at the user's home for /chat, so `workingDir`
collapses into `cwd` — which now both tags the session (metadata.officer.cwd) and drives
the Officer system prompt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 12:00:34 +00:00
pastilhasandClaude Opus 4.8 0c3f270419 chat: rename claude_sessions → general_chat_sessions; drop dead chat_sessions
The default /chat working directory is used by both the Claude and OpenCode harnesses
now, so its Claude-specific name was misleading.

- Rename the dir + accessors: getClaudeSessionsCwd → getGeneralChatSessionsCwd,
  ensureClaudeSessionsCwd → ensureGeneralChatSessionsCwd, path segment claude_sessions
  → general_chat_sessions (data-path on disk + code + UI labels/comments). No history
  migration — the old Claude transcript slug is orphaned (intentionally).

- Remove the vestigial chat_sessions dir (leftover from the retired session store):
  it only ever held empty claude/archived/ dirs, recreated by a signin hook. Drop that
  hook (+ its dead imports) and the 4 unused data-path accessors (getUserSessionsDir,
  getClaudeDir, getSessionDir, getArchivedSessionDir), and delete the dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:50:48 +00:00
pastilhasandClaude Opus 4.8 9a79a76b95 chat: inject an Officer system prompt telling OpenCode its working directory (step 2)
OpenCode can't set a real per-session cwd (every session runs in the fixed server's
dir), so we tell the model its working directory via a system prompt appended to
OpenCode's own — sent as a system message, so it never appears in the visible chat
(verified against source + live). Claude doesn't need this (it honors cwd natively).

- client.postMessage(…, system?) forwards a `system` string on the message.
- send-opencode builds the Officer prompt from `workingDir` and sends it every turn.
- websocket: workingDir = the resolved cwd, except the general /chat (whose cwd is the
  claude_sessions grouping placeholder) uses the user's home.

Verified live: with the prompt, the model reports the injected dir as its cwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 11:06:17 +00:00
pastilhasandClaude Opus 4.8 7cd2be9fb9 chat: tag OpenCode sessions with officer metadata (cwd) + filter lists by it
OpenCode has no per-session directory (every session runs in the fixed server's cwd),
so sessions from every context (/chat pwd, email account, project) all landed in one
list. Now each session is tagged on creation with its logical cwd via the free-form
session `metadata`: { officer: { cwd } } — API-settable, round-trips on list+detail,
never touched by opencode core (confirmed by source dive + live test).

- client.createSession(metadata?) sends `metadata`; adds OfficerSessionMeta + officerMeta() helper.
- send-opencode tags new sessions with { officer: { cwd } } (the resolved chat cwd).
- websocket: pass the full resolved cwd for every context (not just non-/chat).
- listOpenCodeSessions(cwd?) filters by metadata.officer.cwd; chat.ts passes the request cwd.

Verified live: sessions tagged with distinct cwds list only under their own cwd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 10:50:48 +00:00
pastilhas bab0d1b7f8 Revert "chat: root the OpenCode server at the chat cwd (email chat now runs in the account dir)"
This reverts commit d60c73b3a3.
2026-07-25 10:08:48 +00:00
pastilhas 9b9938aaa9 Revert "chat: reap orphaned per-cwd opencode serves on startup"
This reverts commit 1f6eff3614.
2026-07-25 10:08:48 +00:00
pastilhasandClaude Opus 4.8 1f6eff3614 chat: reap orphaned per-cwd opencode serves on startup
Each pooled per-cwd server is a ~0.5GB process; across officer restarts the previous
instance's servers would orphan and pile up. On module load, kill any opencode serve on
a non-fixed port (the fixed OPENCODE_SERVER_URL port is preserved).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:21:57 +00:00
pastilhasandClaude Opus 4.8 d60c73b3a3 chat: root the OpenCode server at the chat cwd (email chat now runs in the account dir)
OpenCode has no per-session `directory` — a session inherits the server's cwd (POST
/session ignores extra fields). So the previous "pass directory on create" was a no-op
and every OpenCode chat ran in the fixed server's dir (~), including the email chat.

Fix: hybrid server model. server-manager.ensureServer(cwd?, home?) returns the fixed
pm2 server (OPENCODE_SERVER_URL, :4096) for the general /chat, but for a context-scoped
cwd (email account dir, project dir) it spawns/pools an `opencode serve` rooted at that
directory — so the session's agent actually operates there. send-opencode passes the
resolved cwd + the user's home; createSession drops the ignored directory param.

Verified live: a cwd-scoped server reports the session directory as the target dir
(not ~), while /chat still uses :4096.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 20:20:15 +00:00
pastilhasandClaude Opus 4.8 cbe83f39a9 chat: run the /email chat from the email account's storage dir
The email chat's working directory now resolves to
DATA_PATH/<owner>/email_accounts/<accountEmail> (created if missing), so the agent
operates in the selected account's dir (emails.db, attachment_cache, …).

- websocket.ts: new resolveChatCwd — context 'email' → the account dir (via a new
  resolveEmailCwd), 'chat' → the pwd/claude_sessions dir, else the given cwd. Both the
  Claude and OpenCode handlers use it. The account defaults to the owner's first enabled
  account for now; the account selector will pass it as contextId later.
- OpenCode honors the cwd again: send-opencode passes it as the session `directory`
  (client.createSession(directory?)) for context-scoped chats; the general /chat still
  omits it and uses the fixed server's default project. Verified against the live server
  that directory-bound sessions create + list.

No frontend change — the /email panel already sends context:'email'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:37:48 +00:00
pastilhasandClaude Opus 4.8 7a60f2cb0e chat: fix chat WebSocket URL (double /chat) — was disconnecting every chat
useChat connected to `/api/chat/chat/ws` while the server listens on `/api/chat/ws`.
The double `chat` was a de-Pi rename artifact: the global `api/pi/` → `api/chat/` sed
rewrote `/api/pi/chat/ws` to `/api/chat/chat/ws`, and the targeted fix ran too late to
catch it. Result: the chat socket never connected, so the UI showed "Disconnected" and
the model selector — locked while disconnected — displayed only the active (Claude)
provider, hiding OpenCode. One-character path fix restores all chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:23:31 +00:00
pastilhasandClaude Opus 4.8 ab134aa02b chat: surface all OpenCode models (drop the curated allow-list)
Removes the Big Pickle + Claude Haiku allow-list; the picker now lists every model
the fixed server reports from GET /config/providers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:15:10 +00:00
pastilhasandClaude Opus 4.8 7b16f3bc4c chat: point OpenCode at a fixed pm2-managed server (fixes empty model picker)
The per-cwd `opencode serve` spawning is replaced by a single fixed server
(http://127.0.0.1:4096, OPENCODE_SERVER_URL) managed by pm2 — added as
`officer-opencode` in ecosystem.config.cjs (cwd = home).

Root-cause fix for the empty model selector: list-models shelled out to
`opencode models`, which failed at runtime on the deployed server (the picker got
only Claude tiers). It now reads the fixed server's GET /config/providers over HTTP —
11ms and reliable — so the curated OpenCode models (Big Pickle, Claude Haiku) show up.

- server-manager.ts — drops spawning; exposes OPENCODE_SERVER_URL + a health check.
- client.ts — createSession no longer binds a directory (sessions live in the one
  server's project).
- send-opencode.ts / opencode-sessions.ts / chat.ts — use the fixed server; drop the
  cwd/home plumbing. Session list/load/delete/rename now hit :4096.

Verified end-to-end against the live server: model list, streaming turn, session
list, and transcript load all work with no spawning.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 17:12:53 +00:00
pastilhasandClaude Opus 4.8 059539a64a chat: unified session history across Claude + OpenCode (Phase 3)
/chat's session list, transcript load, delete, and rename now span both harnesses.

- opencode-sessions.ts — REST-backed reader (OpenCode's SQLite via its HTTP API, never
  the DB): listOpenCodeSessions / loadOpenCodeSession / delete / rename, returning the
  same shapes as the Claude reader, tagged harness:'opencode'. A serve is directory-
  scoped, so listing a cwd = asking the serve rooted there. Transcript rebuild maps
  user/assistant/tool parts and drops reasoning (parity with the delta filter).
- client.ts — adds listSessions/getMessages/deleteSession/renameSession over /session/*.
- chat.ts — /sessions merges both (newest first); /sessions/:id, DELETE, and
  /title route by id shape (ses_ = OpenCode). ClaudeSessionSummary gains an optional
  `harness` tag.
- send-opencode.ts — resuming from history: when the sessionKey is itself a ses_ id,
  reuse that OpenCode session instead of creating a new one.
- SessionList.tsx — shows an "OpenCode" badge for OpenCode sessions.

Verified end-to-end against a live serve: list (5 sessions, tagged), load (transcript
rebuilt, reasoning filtered), and rename all work. Phases 1-3 complete; needs a restart.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:56:20 +00:00
pastilhasandClaude Opus 4.8 3a2f3317b2 chat: curate OpenCode models to Big Pickle + Claude Haiku
The full `opencode models` catalog is ~58 entries; surface only opencode/big-pickle
and opencode/claude-haiku-4-5 in the picker for now via an allow-list. Easy to extend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:45:43 +00:00
pastilhasandClaude Opus 4.8 025c5dd38c chat: surface OpenCode models in the picker (Phase 2)
list-models.ts now merges the OpenCode catalog (from `opencode models`, cached;
ids are providerID/modelID) with the static Claude tiers, so /chat/models returns
both. invalidateModelCache clears the OpenCode cache for real now.

Adds the 'opencode' → 'OpenCode Zen' provider label in the /models response and the
ModelSelector's PROVIDER_DISPLAY. The existing useModels visibility gates already
pass non-claude-code providers through, so no gate changes are needed — Super Admin
sees all models. Selecting any non-claude-code model routes the turn to the OpenCode
harness (Phase 1).

Default model stays 'claude-code'. 58 OpenCode Zen models currently list; curating to
a flagship subset is an easy follow-up if the full catalog is unwieldy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:32:12 +00:00
pastilhasandClaude Opus 4.8 ad32c7516e chat: add OpenCode as a second harness — live turn (Phase 1)
Introduces an OpenCode chat harness alongside Claude, driven over HTTP + SSE against
a persistent `opencode serve`, emitting the same ChatEvent contract so the entire
chat UI and createEventHandler pipeline are unchanged.

New servers/api/chat/opencode/:
- server-manager.ts — one warm `opencode serve` per cwd (free port, health-gated,
  respawn on exit; HOME set so it reads the user's ~/.local/share/opencode auth).
  Binary pinned via OPENCODE_BIN (installed is 1.17.9; the 1.18.4 upgrade never landed).
- client.ts — per-server HTTP calls (/session create, /message, /abort) + a single
  reconnecting `/event` SSE stream demuxed to per-session listeners.
- event-mapper.ts — SSE → ChatEvent. Verified live against 1.17.9: message.part.delta
  → delta, tool parts → tool:start/tool:result, message.updated → cost, session.idle
  → result. Crucially, deltas are gated on partID being a `text` part (declared before
  its deltas) so the model's reasoning — which also streams as field:'text' — is
  dropped, matching the Claude harness hiding thinking.
- state.ts — sessionKey ↔ opencode ses_ id map for resume.

channels/send-opencode.ts — the OpenCode analog of send-claude-code: ensure serve,
create/reuse session, subscribe, post the message, forward mapped events; kill = abort.

websocket.ts — replaces the Claude-only coercion with harness routing:
provider 'claude-code' → Claude sidecar, everything else → handleOpenCodeChat.
handleStop aborts the right harness.

Verified end-to-end (streaming text, tool call/result, cost, abort) against a
throwaway serve using the free deepseek model — no prod restart involved. UI-level
model selection + session history follow in Phases 2–3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 16:29:55 +00:00
pastilhasandClaude Opus 4.8 669692355d chat: rename the pi-mono provider router + purge residual pi names (Stage 4b/2)
Renames the AI-harness/provider settings router into the chat namespace and
clears the remaining "pi" identifiers from the chat stack.

- server-settings/pi-mono.ts → chat-providers.ts; piMonoRouter → chatProvidersRouter;
  route /server-settings/pi-mono → /server-settings/chat-providers (+ all callers)
- piId → providerId (PROVIDERS map + AIHarnessesSection UI), PiProvider → ChatProvider,
  PI_MONO_* query keys → CHAT_PROVIDERS_*, installPiMono → installAgent
- data-path: PI_CONFIG_DIR → AGENT_CONFIG_DIR (path ~/.pi/agent unchanged);
  drop dead getPiMonoDir/getPiMonoSessionDir exports
- settings: flip the vestigial defaultProvider literal 'pi' → 'chat' (never read;
  only defaultModel drives behavior); access-policy config key 'pi-access-policy'
  → 'chat-access-policy'
- misc: ModelSelector fallback label, TaskDefaults model grouping, CapabilityPage
  chat var, a stale stream-parser comment

Intentionally left (genuine external `pi`/opencode references, not ours to rename):
the `pi` binary install/version flow (@mariozechner/pi-coding-agent, `which pi`),
the ~/.pi/agent config path, PI_TOOLS_DIRS/PI_SEARXNG_URL runtime env-var contract,
TOOL.md `targets: pi` metadata, and the "Pi Mono" installer UI label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:52:58 +00:00
pastilhasandClaude Opus 4.8 49df1c0b0c chat: rename the pi chat transport + model list to chat (Stage 4b/1)
Pure rename, no behavior change. Moves the misnamed "pi" chat harness into the
chat namespace:

- api/pi/{websocket,session-manager,types,logger,list-models} → api/chat/
- merge api/pi/rest.ts into api/chat/chat.ts (/pi/models → /chat/models,
  /pi/stt → /chat/stt); drop the piRestRouter mount
- PiEvent → ChatEvent, piWebsocket → chatWebsocket, listPiModels → listChatModels
- WS route /api/pi/chat/ws → /api/chat/ws, provider tag 'pi' → 'chat'
- frontend: useChat/useAudioRecording URLs, usePiModels→useModels /
  useVisiblePiModels→useVisibleModels / useEnabledPiModels→useEnabledModels,
  'PI_MODELS' query key → 'CHAT_MODELS', attachments provider 'pi-mono' → 'chat'

The /pi-mono provider/harness settings router is renamed separately (next commit).
Note: the WS route change requires the mobile app to point at /api/chat/ws.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:47:40 +00:00