slskd has no favorites or buddy-list concept — 0.26.0's UsersController exposes
only endpoint/browse/directory/info/status — so Officer owns that data itself:
a soulseek_favorites table served by the slskd sidecar under a /_officer/*
namespace, which can never collide with slskd's /api/v0/*. The main server
gains exactly one line, injecting X-Officer-User on the proxy hop, so it stays
a thin auth proxy and grows no Soulseek logic. The route is handled before the
upstream check, so favorites keep working with slskd down.
Usernames in search results and downloads become a dropdown (browse shares,
toggle favorite). Browsing publishes to a nonce-stamped, consumed-once channel
so the Users section looks the peer up without re-running the expensive browse
on every remount, and favorites get their own section at the top of that panel,
which doubles as its landing content. CardHeader had to split its toggle row to
host the dropdown, since a trigger can't live inside the collapse button.
The schema file is deliberately self-contained so it can move wholesale into
the sidecar directory when sidecars start owning their own schema. Its DDL was
applied by hand, matching drizzle's constraint naming, rather than running a
whole-schema push.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The results cache was write-once, read-forever: opening a search before its
first responses landed cached the empty grouping, and every later visit
short-circuited on it — the history row showed results while the detail view
stayed empty, permanently.
Serve the cache as a first paint only and always re-fetch behind it, poll
while the search is still running so responses fill in live, and add a manual
refresh. Collapse state now survives a revalidation (only newly arrived users
auto-expand), the empty state distinguishes "still searching" from "no
results", and deleting a search drops its cache entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Every verb ended in an unconditional res.json(), which throws
"Unexpected end of JSON input" on 201/204 responses that carry no body —
so a request that actually succeeded still surfaced as an error (e.g. a
sent chat message toasting a failure). Read text first and only parse
when non-empty, otherwise resolve undefined. Non-empty JSON is unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- render dashboard/uploads/rooms/chat/users/system from the section channel
- nav + view both default to the dashboard section
- register the view panel's zoom header; read the persisted zoom via useDashboardState
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- dashboard: connection health, live download/upload tallies, recent searches
- uploads: mirror of downloads (what peers pull from us), per-row cancel/clear
- rooms: join chat rooms, transcript + composer, available-room datalist
- chat: private 1:1 conversations with unread badges and self-aligned bubbles
- users: look up a peer's presence/profile and browse their shared folders
- system: server connection state with connect/disconnect and version info
- view header: +/- zoom controls persisted per panel
- search view: restyle history to the shared dark (zinc) theme
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- downloads: split into active/completed with a bulk toolbar (retry errored,
cancel all, remove completed) mirroring slskd's downloads page
- downloads: queued rows hover to show place-in-queue and click to force a refresh
- search results: add a per-folder "Download folder (N)" button alongside the
per-user download-all
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- add types for transfers, private conversations, users, rooms, and server state
- add formatClock / folderLabel / formatDuration helpers and the groupResponses shaper
- move the per-panel zoom key into the persisted screens/ namespace
- extract shared dark-theme Card / SubCard / RowList primitives used by the panels
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-flow music dock reserves its height whenever a track is loaded (current),
even paused — so after a queue finished (onEndOfQueue set playing=false but kept
the queue) the dock lingered, shifting the layout up with a faint idle bar. On
natural end-of-queue, close() the queue so the dock releases its space. The saved
now-playing snapshot is untouched, so reload still resumes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- split into nav (vertical section menu) + view panels via soulseek:section channel
- search view: history list + input, opens results subpanel for a past search
- search results load stored responses (no re-run), session-cached, ranked
- remove client from effect/callback deps (useClient is a fresh object per render)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two panels (search + transfers) via WorkspaceView, coordinating over the
soulseek:refresh channel. Adds the page-title rule and documents the
route conventions in CLAUDE.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reverts the video-download UI from the ephemeral side panel back to the modal
(VideoDownloadDialog) that predated it — the "download video" button opens the
modal again and downloads inline via /download-video, exactly as before. The
six wiring files had no non-download changes since the modal→panel conversion,
so they're restored verbatim from that commit's parent; the panel file is
removed. The inline ReClip routes it uses are untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Key music_now_playing on (user_id, device) instead of user_id alone so the
browser ('web') and phone ('' default) each keep their own resume snapshot
instead of sharing one row. Sidecar /now-playing threads device (?device=,
default '') through get/set/clearNowPlaying; the web client tags its calls
?device=web. Phone unchanged → '' bucket, inherits the existing row.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
move video/audio downloads off the dedicated ReClip download lane onto the
generic script-job path:
- delete execute-download.ts, reclip-client.ts and the POST /jobs/download
endpoint; drop the 'download' mode from the pipeline_jobs enum (legacy rows
tolerated)
- execute-script.ts: strip the @@officer:progress@@ sentinel from the log,
emit progress events, and isolate viewer/log writes (safeEmit/safeLog) so a
broadcast or log throw can't wedge the stdout pump
- pipeline-job-manager.ts: persist latest progress; guard sendToViewer sends
- ScriptJobDetail: render the two progress bars; DownloadJobDetail kept for
legacy history rows
- TaskRunnerModal: ScriptRunner descends into the triggered directory
- VideoDownloadPanel: rewire startJob to the script-job path
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Verified against live ReClip: POST /api/download with title:"" → a hash filename
(b5d04adc86.mp3); with title:"Me at the zoo" → "Me at the zoo.mp3". So ReClip
names the file from the title WE send (falling back to a hash) — it does not
self-name. The title is mandatory, which means a metadata pass is required.
Back to two phases:
1. metadata — fetch each item's /api/info (title + validity), keep survivors, skip
errors.
2. download — download each survivor passing its title, so files land with real
names; skip download errors.
Keeps the exact-urls[] input (Mix playlists can't drift) and the one-request-per-
item download. Progress is two counters again (Titles + Download); UI shows two
bars. ~2 requests/item is inherent to needing the title (per the user's call:
correctness over speed).
Verified two-phase filtering + title passthrough + skip-on-error with a mock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A YouTube Mix/radio playlist (list=RD…) returns a different set of items on every
/api/playlist call (observed 779 / 1485 / 529 for the same URL). The job used to
re-expand the playlist server-side, so it would download a different list than the
count shown on the decision screen.
The panel now passes the already-expanded `urls[]` into the job, and the executor
uses them verbatim (falling back to expanding `url` only when no list is given).
The job downloads exactly what you decided on. Endpoint takes `urls[]` (stored as
inputs.urls) or `url`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The job's two phases were a misread — the "count" phase is the client-side
playlist expansion (for the inline-vs-job decision, already done in the panel).
The job itself is just one download request per item.
Dropped the in-job metadata pass entirely:
- reclip-client: reclipDownloadOne no longer prefetches /api/info for a title —
ReClip names the file from the video title itself, so it's a single request
per item.
- execute-download: one phase — expand the playlist, then /api/download each url,
skip failures. Progress is a single { done, failed, total, current } counter
(no meta/dl split); ~2× faster and downloads start right after expansion.
- UI (DownloadJobDetail + panel JobView): one "Downloaded" bar instead of two.
Verified: every item is attempted directly (no /api/info gate), skip-on-error
counts correct.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Front half of the download-job feature.
Panel (VideoDownloadPanel): after Fetch expands a playlist and the count is known,
a decision screen — "Found N items" → pick Audio/Video + subfolder → "Download all
as a job" (POST /jobs/download), or "fetch inline to pick individually" (the
existing card grid). A single video still goes straight to the inline card. The
job phase shows live two-phase progress (polled from the job) + a "View in Jobs"
link; it notes the job runs server-side so closing the panel is fine, and it
refreshes the browser as each file lands.
/jobs (DownloadJobDetail + JobsPage dispatch): a `download` job renders a compact
two-phase readout — Metadata and Download bars (processed/total, found/skipped and
saved/failed) + the current item — polled from the job's progress, with a Stop.
Executor tweak: phase-1 meta.done now counts kept (not processed) so both phases
read the same `(done+failed)/total`.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the download model around a format choice (video = ReClip video, audio =
audioOnly) instead of a global "extract audio" toggle:
- Input phase is just URL + Fetch (dropped the audio checkbox).
- Each card tracks video + audio download state independently and shows a button
per format (Video only when ReClip reports video formats — audio-only sources
get just Audio).
- Playlist top actions: "All video" (when any item has video), "All audio", and
"Select".
- Select mode: each card shows Video/Audio checkboxes (pick one, both, or none per
item); a "Start download (N)" button runs the chosen set and shows a fake
progress bar (N/M count, no real byte progress). Bulk "All video/audio" reuse
the same progress bar.
- Single item = large card with the two format buttons, no bulk row.
VideoInfo gains `formats` so the client can tell video-capable from audio-only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Uses the resizable panel + container queries instead of a cramped vertical list:
- Single item → one large card (big 16:9 thumbnail, title, uploader·duration,
full-width download button).
- Playlist → a responsive grid (2 cols, 3 at @520px, 4 at @760px of panel width)
of compact cells, each with a position number badge and an overlay download/
status chip.
Loading skeletons, error, and per-cell download states (download → downloading →
saving → saved / retry) carry over.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The music dock was position:fixed, overlaying the bottom of the content, so the
nav dock needed a pile of hacks to dodge it: a hand-measured MUSIC_DOCK_HEIGHT
(72) constant, a presence flag, a translateY lift, and a matching hover-threshold
lift (via a ref) so it wouldn't slide away under the cursor. Fragile the moment
the music dock's height changed.
Now the dock is an in-flow bottom bar that reserves its own height:
- DashboardLayout is a flex column: the content region (flex-1, min-h-0) shrinks
when the music dock takes its space; MusicPlayerHost renders an in-flow bar
(shrink-0) instead of a fixed overlay.
- The nav Dock is absolute within the content region and measures its reveal/hide
boundary from that region's bottom edge (a boundaryRef) — so it always sits just
above whatever's at the bottom, music dock or not, with zero knowledge of it.
- Deleted MUSIC_DOCK_HEIGHT, musicDockPresent, the lift, and the transform hack.
Bonus: content at the very bottom is no longer hidden under the fixed dock.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The dock's X only reset local player state; the server's now-playing snapshot
survived, so a reload restored the dock. Closing now also DELETEs /music/now-playing
before clearing the queue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves the video-download UI from a modal into the same ephemeral side-panel slot
the file viewer uses (double-click a video), per request. It's driven by the
`download` search param (the target folder) + `downloadRoot`, exactly like the
existing view/play/chat ephemeral panels.
- layouts: singleDownloadLayout (files-download panel).
- VideoDownloadPanel (new, apps/FileBrowser): self-contained — reads the target
folder/root from the params, does its own useFilesAPI, prefetch + per-entry /
download-all flow (unchanged from the dialog), and bumps the shared
`files:refresh-signal` so the browser re-lists when a file lands. Header shows
a "Saving to <folder>" hint.
- useFileViewerPanels: register files-download (param → layout → panel + close),
add download/downloadRoot to the on-refresh cleanup keys.
- useFileBrowserApp: replace the showVideoDownload modal state with
openVideoDownload(), which sets the download param for the current folder+root.
- Toolbar + FileViewContainer trigger openVideoDownload(); drop the modal mount
and delete VideoDownloadDialog.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reworks the download-video dialog into a prefetch-then-download flow, mirroring
ReClip's own web UI. Still a pure proxy to ReClip (its yt-dlp); no downloader
logic moves to the platform.
Server (thin ReClip proxies alongside /download-video):
- POST /file-browser/video-info { url } → ReClip /api/info → { title, thumbnail, duration, uploader }
- POST /file-browser/video-playlist { url } → ReClip /api/playlist → { urls }
Both return { error } inline (200) so the client can render failures per-card.
UI (VideoDownloadDialog, now self-contained; useFileBrowserApp exposes `files`
and drops the old single-shot state/handler):
- Paste a URL → Fetch. A playlist URL (list=) expands via /video-playlist, then
each entry's /video-info is prefetched sequentially (ReClip does yt-dlp per
video), rendering a card (thumbnail, title, uploader, duration) that fills in
progressively.
- Per-entry Download, plus Download All when there's more than one; per-card
status (downloading → saving → saved / retry-on-error) via the existing
background job + poll.
- Playlists get an optional "subfolder you name" field (ReClip's /api/playlist
carries no playlist title); blank = current folder.
- Quality is always best (matches the mobile Share flow — no picker); the
audio-only toggle applies to the whole batch.
Verified ReClip's contract live: /api/info returns the metadata fields, and
/api/playlist returns { urls } (17 entries in ~1.2s).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The MusicBrowser and MusicDetail panels each kept their OWN local `manifest`
(and folder-listing) state, fetched in their own effects. The reindex (↻) button
lives in MusicBrowser and only refreshed its own copy — MusicDetail (the right
panel showing the tracklist/grid) never heard about it, so newly-indexed content
only appeared after navigating (which re-ran its effects).
Add a shared `music:resync` panel channel: when a reindex completes, MusicBrowser
bumps it to a fresh nonce, and both panels re-run their manifest/libraries/folder
fetches. In MusicDetail the fresh manifest object identity also re-triggers the
[cwd, manifest] listing effect, so the open album's meta/tracklist and any folder
grid refresh in place — no navigation required. Drops the browser's now-redundant
hand-refresh of its own state.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
`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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
Panels are now container contexts (@container → container-type: inline-size)
on both the normal and transparent PanelSlot render paths, so panel content
can respond to the *panel's* width instead of only the viewport's. Non-
breaking: existing screens keep their viewport (lg:/md:) variants untouched.
First adopter: the bTop system-monitor grid reflows on panel drag —
grid-cols-1 → 2 at @min-[600px] → 3 at @min-[960px] (panel width), keeping
cards ≥300px so they no longer squeeze on a narrow panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full /api/system-monitor/* contract (stats snapshot incl. cpu/mem/disks/temp/
gpu/net/power, pm2, docker, and the two SSE log streams) with response shapes,
auth (Bearer or ?token=), owner-only note, and the net/power rate caveats — so
the app can implement the same views. Also removes the orphaned Pm2Logs.tsx
(superseded by LogStream) that a prior commit left tracked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three more same-level grid cards (bTop now 7 cards; Temperature stays under
Memory via natural 3-col flow):
- GPU: gpu_busy_percent + VRAM used/total from /sys/class/drm (instant).
- Network: ↓/↑ throughput (bytes/sec) from /proc/net/dev deltas between /stats
calls, aggregate + top interfaces.
- Power: CPU package watts via RAPL energy delta + GPU watts (amdgpu hwmon).
Note: RAPL energy_uj is root-only by default (Spectre-era lockdown), so CPU
package power shows "—" unless made readable (a udev rule); GPU watts work.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Backend: readTemps() scans /sys/class/hwmon for all temp sensors and picks the
CPU one (k10temp/coretemp/zenpower Tctl/Tdie/Package); added to /stats.temp.
- BtopView: a Temperature card at lg:col-start-2 (under Memory, second row) —
big CPU °C (color-graded), its sensor label, and the other sensors (GPU, NVMe,
wifi…) listed beneath.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Shared LogStream component (solid-black <pre> terminal pane) replaces Pm2Logs;
pm2 and docker both drill into it.
- GET /api/system-monitor/docker/logs?id=<container>&lines=<n> — SSE of
`docker logs -f` (combined stdout+stderr); id charset-validated + spawn arg.
- DockerView: container cards are now clickable → live logs.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n> — SSE that spawns
`pm2 logs <id> --raw` (combined out+err, follows live) and streams each line.
id validated numeric + passed as a spawn arg (no shell); killed on disconnect.
- Pm2View: clicking a process name drills into Pm2Logs (EventSource tail with a
live pulse + back button); autoscrolls, capped at ~1200 lines.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/system-monitor is now a WorkspaceView (like /music): a left ScopeList panel
selects the scope over the 'monitor:scope' channel, the right MonitorMain panel
renders it. Three scopes:
- bTop — the existing system snapshot (CPU/mem/disks/top processes)
- pm2 processes — new GET /api/system-monitor/pm2 (pm2 jlist → name/status/cpu/
mem/restarts/uptime table)
- dockers — new GET /api/system-monitor/docker (docker ps → container cards with
state/status/image/ports)
Both new endpoints degrade gracefully to an error field. Persisted as
screens/system-monitor; owner-only.
Needs a restart (backend routes + rebundle) + hard-refresh to appear.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the turn/session decoupling so nothing is lost across disconnects.
Phase 2 (durability):
- New chat_session_events table (global monotonic id = cursor) + queries
appendChatEvent / getChatEventsSince / pruneChatEventsOlderThan.
- Every durable outbound ServerMessage now goes through emitToSession: appended
to the queue (even while the client is disconnected) and delivered live with
its seq. Streaming deltas stay ephemeral (live-only, never persisted).
Phase 3 (resilient transport):
- New 'resume-cursor' client message → handleResumeCursor re-binds the socket to
the (still-live) session (cancels idle-GC via attachWs) and replays every event
since the client's cursor.
- useChatWebSocket already auto-reconnects; added an onOpen hook. useChat tracks
the max seq and, on every (re)connect with an established session, sends
resume-cursor — so a dropped connection self-heals with no manual navigate
away/back, and background task notifications that landed while offline replay.
Verified end-to-end: disconnect after a turn's result but before a background
task finishes, reconnect with the cursor → the missed task:notification is
replayed from Postgres, no duplicates.
Note: the DB is managed via drizzle push/direct DDL (no __drizzle_migrations
table), so 0001 was applied directly; the generated migration is committed for
the record.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root fix for orphaned background tasks: the platform drove Claude Code as a
one-shot `claude -p` per turn (stdin ignored, process exits at turn end), so
run_in_background/Monitor work — and its task_notification — had no live harness
to return to. Now each chat session runs ONE long-lived Agent SDK query() with
streaming input; turns are user messages pushed onto it, and the session stays
warm between turns.
- claude-manager: persistent `query({ prompt: AsyncIterable, options })` per
sessionKey (bypassPermissions, --resume, mcp via extraArgs, CLAUDECODE stripped).
Single consumer loop maps every SDK message → ChatEvent, incl. post-turn
task_started / task_notification. interrupt() = stop-turn; abort() = kill-session;
30-min idle GC.
- stream-parser: processMessage() (object-level, reused by the SDK loop) + task
message handling. ChatEvent/ServerMessage gain task:started / task:notification.
- API: the sidecar event subscription is now SESSION-scoped (no longer unsubscribes
on 'result'), so background events after turn-end still reach the client. First
turn opens the session; later turns push onto it. handleStop → interrupt (keeps
session warm); disconnect/deleteSession → kill.
- protocol/sidecar-registry/user-instance: claude:interrupt command + interruptClaude.
- client: render task:started / task:notification in the transcript.
Verified end-to-end through the real chat WS: a run_in_background task's completion
arrives ~6s AFTER the turn's result; multi-turn on one warm session works.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>