Commit Graph
54 Commits
Author SHA1 Message Date
pastilhasandClaude Opus 5 2eda855551 persist the wallet's view of the chain
Opening a wallet meant waiting for a full gap-limit scan before any number appeared, and a
restart threw that work away. Worse, an unreachable Esplora rendered identically to an empty
wallet — as a zero balance — which is alarming for the one case where it is not true.

The backend now keeps a snapshot and serves it stale-while-revalidate: a snapshot inside the
TTL is served as-is, an older one is served immediately with a refresh started behind it, and
only a wallet that has genuinely never been read blocks on the network. wallet_chain_cache
holds one row per wallet so a refresh is a single atomic upsert.

The snapshot, not the endpoint, is the unit of caching. Balances, UTXOs and history were
three fetches over a shared scan, so the three queries a wallet screen fires on mount could
each observe a different moment; building them together costs the same requests and fixes
that incidentally.

Only the chain's own facts are stored. Addresses, scripts and pubkeys are re-derived from the
account xpub on load — cheaper than persisting them, and it means a restored snapshot cannot
disagree with the wallet's actual keys. Stored coordinates are validated rather than trusted,
and a snapshot at an unknown version is discarded, not migrated.

Two reads deliberately opt out. sendCoins takes a fresh snapshot because selecting coins from
a cached UTXO set builds a transaction spending outputs that may already be gone, and that
failure arrives as a broadcast rejection after signing. nextUnused does too, because handing
out an address whose stale record says "unused" is silent address reuse — a privacy leak the
owner cannot see or undo. Receive-address generation is therefore the one read that stops
working while the upstream is down, on purpose.

Failures are recorded alongside the last good snapshot rather than replacing it; wiping data
on failure would reproduce the exact bug this exists to fix. Every cache operation is
best-effort, so a database problem degrades to a slow load and can never fail a wallet
request. A sync block on balances, transactions and utxos carries the age to the UI, which
now distinguishes "empty" from "never read".

Verified against three live mainnet wallets: snapshots persisted and reloaded, and two
wallets kept their data and age through a real Esplora rate-limit failure while recording the
error separately.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 15:12:57 +00:00
pastilhasandClaude Opus 5 f9a07bb8ee schema: constrain integration providers to the ones the code supports
`provider` was free text on both integration tables, which is how rows for telegram,
whatsapp and discord went on holding bot tokens long after the code that read them was
deleted: nothing structural said they had stopped being legal, so nothing noticed.

A CHECK constraint on each table now lists what may exist — google/apify for the server,
google/browser-relay for the user. Adding an integration means adding it to the list, which
is the point: the schema is the source of truth for what the database may contain, so a
provider the code no longer supports cannot sit there unnoticed.

This does NOT delete the existing rows, and no schema change can — drizzle-kit push diffs
structure, never data. What it does instead is refuse: push will fail to add the constraint
while violating rows exist. That is the enforcement working rather than a problem to route
around; the three rows have to go first, and then the structure guarantees they cannot come
back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:08:55 +00:00
pastilhasandClaude Opus 5 f8826e4c24 add the bitcoin wallet sidecar and ui
the owner's work, committed as one unit rather than split: the registration
files (App.tsx, Dock, AppRegistry, hono.ts, the schema and db barrels,
ecosystem.config.cjs) all reference modules under src/servers/{api,sidecar}/wallet
and src/workspaces/officerdev/src/apps/Wallet, so committing the shared plumbing
on its own would leave a commit that does not build.

officer-wallet is a new pm2 peer holding seed material sealed under an owner
passphrase on top of VAULT_STORE_KEY, with an unlock ttl after which the root key
is wiped from memory. five backends: on-chain via esplora, and lnd, clnrest,
lndhub and nwc for lightning. bolt11 encode/decode is implemented in-tree.

no secrets in the diff — the key-shaped literals under sidecar/wallet are the
bolt11 spec vectors and the bip39 "abandon … about" vector. .env.example gains
placeholders only. bun test src/servers/sidecar/wallet: 38 pass, 0 fail.

not reviewed line by line; assembled and verified to build, not audited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 06:48:06 +00:00
pastilhasandClaude Opus 5 adf922de30 add the officer-headscale sidecar and its server registry ui
officer-headscale owns the whole Headscale contract: the registered servers and
their admin api keys, the >=0.29 version floor, and every multi-call composition
the ui needs. the platform side is auth+forward only and holds no headscale
credentials, so the existing /api/vpn/enroll route and its HEADSCALE_* env vars
are untouched and unrelated.

officer manages many servers rather than one. the owner registers each with a url
and a key generated on that server and switches between them; exactly one is
active, enforced by a partial unique index rather than by convention. keys are
encrypted at rest and never leave the sidecar — the list projection cannot return
one. registration validates before it saves: an unauthenticated GET /version to
prove something headscale-shaped is there and meets the floor, then an
authenticated call to prove the key works. an edit that moves either half
re-validates.

there is deliberately no transparent /api/v1/* passthrough. headscale serialises
every uint64 as a json string and its rest shape moved repeatedly below 0.29;
proxying raw would push all of that into the browser, which is the mistake the
soulseek panels made with 37 raw upstream calls.

the /headscale workspace is nav + view over the panel system. only the servers
section is implemented — nodes, users and pre-auth keys say so plainly rather
than rendering an empty table that reads as a failed fetch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:55:32 +00:00
pastilhasandClaude Opus 5 5a1e3d7e0b remove the projects feature
Being retired, so it is deleted rather than fixed — it had both defects dashboards just
had (edit dropping ?selected=, an abandoned edit following you onto the next item) and
repairing them was work for something on its way out.

Gone: the two screens and the panel apps, the three routes, the dock item and its entry
in the default dock paths (client and the three server-side copies), the page-title rule,
the app-registry entries, the barrel exports, the `projects` table with its queries and
row types, and the proj-meta/proj-layout/proj-terminals/proj-host-terminals branches in
the dashboards endpoint. The chat context and terminal state-key special cases for
`proj-layout-` went with them.

Deliberately kept: `getUserProjectsDir` in data-path.ts — the apps and dev-server routers
resolve user apps under the same on-disk Projects/ directory and are unrelated to this
feature. Nothing on disk is touched.

Needs `bun db:push` to drop the table; the schema change is the only thing standing
between the code and the database. One row was in it.

tsgo clean, 56/56 tests, no references left in src. Not yet exercised at runtime — the
restart is what will prove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 08:16:27 +00:00
pastilhasandClaude Opus 4.8 238c3b8097 make the agent sidecar the writer of record for chat output
officer's registration socket silently drops sends when it isn't OPEN
(sidecar/connect.ts:send — no queue, no error, no return value). the agent pushed
raw parser events over that socket and officer translated and persisted them, so
everything a turn produced while officer was restarting went nowhere: the turn kept
running, the output was gone, and a reconnecting client replayed a log that simply
had no rows for those seconds. stage 1 kept the agent alive across a restart; this
is what makes its output survive one too.

move the translation and the write into the sidecar:

- turn-stream.ts is the stateful ChatEvent -> browser-message translator lifted out
  of websocket.ts (delta buffering, flush before tool:start and result). pure and
  synchronous, so it is unit tested — 12 tests, 100% lines.
- session-log.ts commits each message to chat_session_events and only then hands it
  to officer, with its cursor id attached. per-session promise chain: translation is
  synchronous and therefore in arrival order, and only the commit is queued, so
  cursor ids are assigned in the order events actually happened. a delta that
  overtook the assistant:text in front of it would make the client commit its stream
  buffer at the wrong point, so deltas go through the same queue even though they are
  never written.
- claude:event on the wire becomes claude:message: a finished browser-facing message
  plus its seq. officer relays it verbatim and folds it into the in-memory session
  for sync:messages. it no longer builds or persists chat messages for this harness.

gap detection, which is what the durable log is for. chat_session_events.id is a
global bigserial, so two consecutive events of one session are not consecutive ids
and a client cannot tell a contiguous replay from one with a hole in it. each durable
message now carries prevSeq — the cursor of the previous message in the same session —
which is inside the persisted payload, so it survives replay. useChat compares it
against the cursor it holds before advancing, and surfaces a visible marker on a
mismatch: a conversation that silently skips a tool call or half an answer reads as
the assistant having done something inexplicable. only checked once a cursor exists,
because opening a session from history legitimately starts mid-chain (events are swept
after 7 days, the transcript is not).

a failed write delivers live with no seq, so the client sees the message but does not
advance past something it cannot replay, and the next successful write chains from the
cursor the client still holds.

pipeline steps pass durable: false. their sessionKey is a throwaway uuid no browser
will ever replay and the job's own event log is its record, so writing those rows only
grows the table.

opencode still goes through officer's createEventHandler, now labelled as such. that
is the sidecars-opencode branch.

this fixes R4 from CLAUDE_SIDECAR_ISOLATION.md. R3 and R5 already worked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:51:52 +00:00
pastilhasandClaude Opus 4.8 62dc4c1a5c run the agent as a pm2 peer instead of a child of officer
the process that runs claude (sidecar/claude/user-instance.ts) had no pm2 entry
and was spawned on demand by the main server, with stdout/stderr inherited. that
made every agent session a grandchild of officer, so pm2's tree-kill took the
session down on every `pm2 restart officer` — the single thing that makes it
impossible to work on the platform while an agent is running.

give it its own entry (officer-agent) and delete the spawn machinery:
ensureClaudeSidecar, spawnAndWaitForRegistration, the 50ms registration poll and
the per-email claudeProcs/claudeSpawnWaiters maps, ~77 lines. officer now spawns
no sidecar at all.

for that to work the sidecar had to stop needing officer to start:

- it resolves the owner from the database (getOwnerUser) instead of reading
  CLAUDE_USER_EMAIL out of the env officer built. single-user is a hard
  invariant, so there is nothing to fan out over. CLAUDE_USER_EMAIL still wins
  when set, for manual runs, and a fresh install waits for bootstrap rather
  than exiting into a restart loop.
- it reads the anthropic proxy secret from the proxy sidecar's own state file
  rather than being handed it in env. lazily, because ensureProxySecret
  persists on a 30s debounce and pm2 starts both processes together.

it registers as 'agent' with capability 'claude', so the registry finds it the
way it finds every other sidecar. that removes the email argument from
killClaude, interruptClaude and clearClaudeSession, which only ever existed to
locate a per-email sidecar by name.

what officer keeps is a short wait-for-capability, because pm2 brings peers up
together and the first request after a boot can beat the sidecar's registration.

also align the two officer port fallbacks in the sidecar (5000 for the socket,
9010 for the rest base) — same instance, so they cannot disagree.

this fixes R1 and R2 from CLAUDE_SIDECAR_ISOLATION.md. events produced while
officer is down are still lost; that is stage 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 04:34:01 +00:00
pastilhasandClaude Opus 4.8 fc00d3861c soulseek: match share filter word by word
A folder's name is rarely something you can type in full — you remember the band and
one word of the album, not the year, the format tag or where the apostrophe went.
Whole-string matching made those two halves useless together: "pogues hell" matched
nothing, because no path has them adjacent. Each word is now its own substring test,
AND-ed and order-independent, so the two ends you do remember narrow the tree between them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 02:11:09 +00:00
pastilhasandClaude Opus 4.8 184adc0e8e soulseek: queue downloads straight out of the cached tree
a browsed folder is a path, not a file list, and what clicking one means is
"everything under here" — so the expansion happens in the sidecar, off the
cache, rather than making the browser walk the tree a level at a time and
rebuild paths it only half knows.

browse reports file names as basenames, unlike search, so the peer's real
path is rejoined from the folder row. sizes come from the cache too: slskd
matches a queued download on filename AND size, so a number supplied by the
client would be a transfer that silently never starts.

a subtree can be the peer's whole share (284k files on one measured peer), so
an over-limit request is refused with its count rather than truncated into a
partial download nobody asked for.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:57:01 +00:00
pastilhasandClaude Opus 4.8 1159c0787d soulseek: render cached shares as a tree, not a path list
slskd's browse response is flat: every folder is a full backslash-delimited
path. Rendered as-is, a filter for "pogues" gave 26 rows that all began with
the same 31 characters, and the real hierarchy — which is the only way to tell
an artist folder from an album folder — was invisible.

The shape is now derived once, at ingest, in the sidecar: buildTree() links
each path to its parent, synthesizes any ancestor slskd omitted (measured:
exactly one missing across ~30k folders on two real peers, but a single gap
would strand a whole subtree), and rolls subtree file counts and sizes up
bottom-up. A parent's own files are usually just cover art, so the number
worth showing on a collapsed row is the subtree's.

Storing the shape rather than recomputing it is what lets the UI open one
level at a time. Levels are still paged, because fan-out is brutal — the
widest folder measured has 1,181 children.

Filtering keeps the tree instead of falling back to a list: the search route
returns matches plus every ancestor, and the UI renders that skeleton
pre-expanded, so you see where a hit lives. Matching runs against the whole
path, so a matched folder implies its descendants match too and a matched
subtree arrives complete. The match cap is reported in the payload and shown
in the UI rather than passed off as the whole answer.

The two existing snapshots were backfilled by scripts/rebuild-soulseek-tree.ts,
which runs the same buildTree + finishSoulseekBrowse the ingest path runs — no
second implementation to drift, and no peer contact needed. Kept for the next
time the tree shape changes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 01:05:22 +00:00
pastilhasandClaude Opus 4.8 12b9641adc soulseek: make the folder filter match what you can type
Two ways the filter lied about the cache. First, input went into ILIKE raw, so its
metacharacters were live: a typed '_' matched all 18,295 folders, '50%' matched 126
unrelated ones, and — worst — a pasted path fragment matched nothing at all, since
backslash is LIKE's escape character and every name here is a backslash-delimited
remote path. Second, these names come off strangers' filesystems and are full of
punctuation no keyboard produces, so "Hell's Ditch", "1984-1985" and "Say... Pogue"
each returned zero against stored U+2019, U+2013 and U+2026. The U+2010 HYPHEN is
the nastiest of those: identical to '-' on screen, so "B-Sides" quietly found 15 of
23 folders and looked like it had worked.

Both sides are now folded to ASCII and the term is escaped. The fold rides on the
existing scan — a leading wildcard already ruled out the btree — but translate()
over ~18k rows does cost something: a filtered page went 16ms to 88ms. Still well
under the input debounce, and a normalized column with a trigram index is there if
it ever stops being true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:42:27 +00:00
pastilhasandClaude Opus 4.8 80ca9aa9b6 soulseek: scope cached folder reads to their peer
Probing the live routes turned up that /browse/<peer>/dirs/<id>/files only checked
the owner, not the peer: dir ids are global, so asking for one peer's folder id
under a different peer's name returned the other peer's files with a 200. The UI
always sends a matching pair so nothing misbehaved, but the URL was asserting a
relationship the query never verified — a mismatched or stale request would show
the wrong peer's contents rather than a 404.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 00:27:39 +00:00
pastilhasandClaude Opus 4.8 1ac5bffb6c soulseek: cache peer share trees server-side
Browsing a peer inline could never work. slskd answers GET /users/{u}/browse with
the entire tree in one blocking response — measured at 59 MB / 18k folders / 284k
files for a single real peer — and it takes minutes because it round-trips to that
peer. The browser was made to wait for that, so navigating away threw the whole
thing out and the panel showed an error more often than a tree.

So the fetch moves into the sidecar and the result into Postgres. Clicking "fetch
shares" returns 202 and the job keeps running without the tab; the UI polls the
snapshot row and reads back pages. Folders are rows and files ride along as jsonb
on their folder, because folders are what you filter and page through while files
are only ever read for the one folder you opened — a row per file would be 284k
rows per peer for no gain.

A failed or in-flight refresh deliberately leaves the previous folders in place: a
peer going offline shouldn't cost you a good cache, so the panel drives off rows
existing rather than off status. Interrupted 'pending' snapshots are failed at
sidecar boot, since the job died with the process and would otherwise spin forever.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 23:59:45 +00:00
pastilhasandClaude Opus 4.8 b00608f3b3 soulseek: peer menu with browse and favorites
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>
2026-07-29 23:36:37 +00:00
pastilhasandClaude Opus 4.8 0325f14770 db: collapse migrations into a single baseline
Squash the incremental 0000–0004 migrations into one baseline generated from the
schema, which drizzle-kit confirms reproduces the current DB (26/26 tables, incl.
the hand-applied music_now_playing (user_id,device) PK and vault_tokens.client_id).
The Drizzle schema is the source of truth; this repo uses db:push, so a single
baseline is all that's needed to recreate the structure from scratch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:28:21 +00:00
pastilhasandClaude Opus 4.8 f3dc4415bb music: per-device now-playing (browser vs phone)
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>
2026-07-29 03:13:16 +00:00
pastilhasandClaude Opus 4.8 ccc86cca6d vault: session-gated auth-injecting proxy + token broker
Implements the platform half of VAULT_AUTH_SPEC.md. /api/vault is now gated on
an owner platform session (userMiddleware, no bodyParser → streaming preserved)
and origin-scoped as before; the device holds no Vaultwarden token.

- POST /session/login {email, authHash, kdf, device*} → broker calls Vaultwarden
  /identity/connect/token via the sidecar, stores the encrypted token set tied to
  the owner, and returns {protectedUserKey, privateKey, kdf} (ciphertext to us).
- GET/PUT /unlock-key → store/release the Officer-app protector key (owner only).
- Catch-all proxy swaps the incoming platform JWT for the stored Vaultwarden
  access token, proactively refreshes near expiry, and retries once on a 401 for
  replayable requests. Bodies are never parsed.

client_id column added to vault_tokens (needed to refresh). Broker error text is
read across Vaultwarden's message/errorModel/error fields.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 02:40:21 +00:00
pastilhasandClaude Opus 4.8 192337cd1b vault: encrypted at-rest storage for brokered tokens + unlock key
Foundation for the platform-brokered vault auth (VAULT_AUTH_SPEC.md). Two
owner-keyed tables: vault_tokens (the brokered Vaultwarden access/refresh set)
and vault_unlock_keys (the Officer-app protector key). All secret columns are
AES-256-GCM encrypted via a VAULT_STORE_KEY-derived key (crypto.ts, lazy-loaded
so the platform still boots without it); queries encrypt/decrypt transparently.

Migration SQL is applied via db:push/psql (schema is source of truth); the
generated files are left out to avoid the shared-journal coupling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 02:33:28 +00:00
pastilhasandClaude Opus 4.8 b2fb6f148c replace hardcoded download job with download-media script capability
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>
2026-07-29 01:10:32 +00:00
pastilhasandClaude Opus 4.8 4e78986e39 jobs: video/audio download as a two-phase job (backend)
Turns the downloader into a server-side job on the existing jobs spine (Postgres
persistence, live WS viewers + replay, abort, /jobs UI) — but with its own
executor and its own lane, since it's deterministic scripting, not an agent, and
a multi-hour playlist mustn't block agentic jobs.

- reclip-client.ts (new, shared): reclipInfo / reclipPlaylist / reclipDownloadOne
  (single download → streams the file to a dir, abort-aware). Extracted so both
  the file-browser endpoints and the job executor use one client.
- execute-download.ts (new): the two-phase executor —
  phase 1 metadata (expand playlist, fetch each info, keep survivors, skip
  errors), phase 2 download (each survivor in the chosen format; skip download
  errors). Emits a compact `download:progress` snapshot (counters, not per-item
  events — playlists are thousands of items). Throws on abort / fatal.
- job manager: `download` mode dispatch → executeDownload; persists
  download:progress; adds execution LANES (download vs default) so the two run
  independently and each serializes on its own; promoteNext fills both lanes.
- POST /api/tasks/jobs/download { url, format, dir, root?, label? } — enqueues a
  download job (own lane, no capability task needed; traversal-guarded target).
- schema: `download` added to the mode enum (drizzle text-enum — no DB migration);
  getPendingJobs() query for lane filling.

Verified the executor with a mocked ReClip client: two-phase filtering, skip-on-
error counts, and abort-throws all correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 18:09:37 +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 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 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
brunorezioandClaude Opus 5 1ac79f9c68 remove the onboarding flow and the accountMode leftover
Onboarding was dead in three layers:

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
brunorezioandClaude Opus 5 044aacf4d5 remove the dead multi-user surface
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.

Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.

Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.

Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.

getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 23:30:20 +01:00
pastilhasandClaude Opus 4.8 70555c8d71 chat: retire the old saved-session store (Stage 4a)
Deletes all session persistence that isn't Claude's native transcript store, per
the "only harness-native session management survives" rule.

Backend: delete api/pi/storage.ts (meta.json+messages.json file store), the
api/saved-sessions router (+ unmount), the /pi/sessions REST endpoints, and the
storage.save/loadSession calls in the chat WS handler (in-memory session-manager
stays for live turns; no disk persistence — Claude's transcript is the record).
Also drops the Postgres saved_sessions layer: schema/chat.ts, queries/saved-sessions.ts,
its types and re-exports.

Frontend: delete state/useSavedSessions, ChatList, and the ChatHistory Widget
(all pure saved-session UI); slim ChatHeader to a label; strip the auto-load-latest
+ Save wiring from ChatPanelWrapper and ChatDetailPanel; drop the old resume path
from useChat and SessionListPage; remove the /chat/saved/:id route and the
useInitialData prefetch.

Behavior removed (intended): the Save-session button, email/project panels
auto-resuming the last chat, and /chat/saved/:id. /chat itself is unchanged —
already fully on Claude transcripts. The orphaned saved_sessions Postgres table
is dropped on the next `bun db:push`.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 15:29:26 +00:00
pastilhasandClaude Opus 4.8 32caa67cab jobs: show target path in list rows; delete queued/finished jobs + clear-all history
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 16:31:52 +00:00
pastilhasandClaude Opus 4.8 f77ce3fb96 jobs: header running/queued badges + GET /jobs/counts (phase 3d)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 15:48:52 +00:00
pastilhasandClaude Opus 4.8 6d7d928806 jobs: queue scheduler + REST job API (phase 1c + 2)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:58:42 +00:00
pastilhasandClaude Opus 4.8 843580afff jobs: add mode and exit_code columns (phase 1a)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 13:38:05 +00:00
pastilhasandClaude Opus 4.8 f3492512ba unify agent items into a flat file-based store, drop the marketplace
Replace the marketplace service dependency and the native/global/user
scope tiers with a single external directory ($OFFICER_ITEMS_DIR) holding
skills, tools, tasks, processes and extensions as plain files.

- tasks move from Postgres to TASK.md files (new file-backed task layer);
  task editing now works, which the DB path never supported
- skills/tools/processes collapse into one shared file router (single dir)
- remove the marketplace client (sync-marketplace/sync-version) and the
  boot-time sync; pi-bridge/pi-manager/sandbox point at the flat store
- drop the dead tasks + vestigial skills/tools/processes/extensions +
  item_chats tables (migration 0004)
- one-time migration script exports DB tasks and consolidates disk items

Migration verified: all 6 tasks round-trip through the runtime parser
identically to their DB rows (pipeline steps, triggers, script impls and
agentic bodies all intact).

NOTE: not yet functionally tested end-to-end — every item (each task mode,
tool, skill, extension) still needs to be run/exercised in the app before
this is trusted. To be done manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 00:39:17 +00:00
pastilhasandClaude Opus 4.6 039ea7ae71 remove stale native tasks on marketplace sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:55:14 +00:00
pastilhasandClaude Opus 4.6 ee1b816d66 move tasks to marketplace, sync native tasks on bootstrap
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 13:57:40 +00:00
pastilhasandClaude Opus 4.6 ef6402a827 fix pipeline stop, jobs link navigation, and seed quote stripping
- Fix stop: abort poll now rejects the promise after killing the agent process
- Fix jobs link: close modal before navigating, use react-router navigate
- Fix seed parser: strip quotes from step input values (delete_source: "true")

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 05:32:35 +00:00
pastilhasandClaude Opus 4.6 5e38343f44 add pipeline job management with /jobs pages and per-step output viewer
- Pipeline jobs now persist to DB with progress tracking and cost accumulation
- Jobs survive WebSocket disconnects with in-memory event buffer replay
- New /jobs list page with search, status badges, and cost display
- New /jobs/:id detail page with live WebSocket attachment and REST fallback
- Two-column layout using WorkspaceLayout for resizable steps/output panels
- Streaming messages tagged with stepIndex/iterationLabel for per-step output grouping
- TaskRunnerModal links to job detail page once job is created
- Dock entry added for Jobs page

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-09 08:48:50 +00:00
pastilhasandClaude Opus 4.6 22c60d4a57 add pipeline task system, agentic music tasks, copy buttons, and pi-bridge cleanup
- Pipeline mode: new task mode that chains agentic tasks sequentially with
  foreach/subdirectory iteration and skip_if conditions
- Pipeline executor backend (WebSocket at /api/tasks/pipeline/ws) with
  support for both Pi and Claude Code models
- Frontend PipelineRunner component with step progress, streaming output,
  and aggregate cost tracking
- New agentic tasks: prepare-discography, fetch-album-info, build-discography
  (pipeline combining both)
- Seed parser extended to handle pipeline steps in frontmatter config
- CopyButton component added to assistant bubbles, error bubbles, and
  tool input/output sections
- Removed obsolete SearXNG/Apify/browser relay code from pi-bridge

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 20:20:20 +00:00
pastilhasandClaude Opus 4.6 2caccc183c generalize convert-to-mp3 into convert-audio with selectable target format
- rename task to convert-audio, support mp3/flac/wav/ogg/aac/opus targets
- add options input type with selectable pill buttons in UI
- extend seed script parser to handle YAML list properties (options)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:42:44 +00:00
pastilhasandClaude Opus 4.6 f32f427972 script-mode task execution — database-backed tasks with direct script runner
Tasks now live in the database (mode: script or agentic). Script-mode tasks
bypass the agent entirely — the implementation is materialized to a temp file
and executed directly, with stdout/stderr streamed to the UI via WebSocket.

Includes convert-to-mp3 as the first native script task.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:07:59 +00:00
pastilhasandClaude Opus 4.6 2a8df0def1 saved sessions: replace transient history with persistent DB storage and auto-save
- Add saved_sessions table and CRUD endpoints (save, list, resume, update, delete)
- Save is instant (no LLM summarization), stores exact conversation with tool calls
- Resume loads full message history into chat UI, sends transcript to agent on first message
- Auto-save updates DB after every agent response once a session is saved
- Delete old filesystem-based session/group management (sessions router, useChatSessions, useChatGroups)
- Clean up ChatHeader, SessionList, ChatDetailPanel for saved sessions flow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:36:25 +00:00
pastilhasandClaude Opus 4.6 5129f7827f dashboards: migrate from filesystem to postgresql
Replace JSON file storage with DB tables for dashboard layouts,
screens, projects, and terminal defaults. Fresh drizzle migration
with dashboardDefaults table and new columns on screens/projects.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 11:35:11 +00:00
pastilhas 6cfa40bad1 Merge remote-tracking branch 'origin/email-imap' 2026-03-06 07:27:45 +00:00
pastilhasandClaude Opus 4.6 7bbcccabf1 wip: remove opencode, searxng, resources; fix user settings read
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 07:27:19 +00:00
pastilhasandClaude Opus 4.6 170bd6d41b wip: email sync via imap with status tracking and auto cron
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 04:57:30 +00:00
pastilhasandClaude Opus 4.6 927267e041 workspaces to dashboards, imap email sync, ffmpeg tool, tts fix, file browser refresh, automation sidebar reorder
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 20:00:45 +00:00
pastilhasandClaude Opus 4.6 4d30672adb browser relay: multi-session override, stale session recovery, better error feedback
- relay accepts new extension connections by closing old one (last wins, code 4000)
- extension recognizes code 4000 and stops auto-reconnect (shows "replaced" badge)
- fix ping interval race where old WS close handler killed new connection's pings
- retry CDP commands on "Session with given id not found" by re-attaching debugger
- describeError() maps known errors to user-friendly badge tooltips
- persisted relay tokens restored on server start
- user-scoped token salts, token regeneration/deletion endpoints
- extension download endpoint, integrations UI for browser relay setup
- browser relay env vars passed to pi-bridge sandboxes
- browser screen layout with chat panel and prompt prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 15:22:15 +00:00
pastilhas bc4c20929c Discord 2026-02-27 08:27:43 +00:00
pastilhasandClaude Opus 4.6 e0f470137b persist model access policy to database
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 20:22:42 +00:00
pastilhasandClaude Opus 4.6 500a70910e migration to postgres
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:42:59 +00:00
pastilhasandClaude Opus 4.6 7f04ecd644 dock config to postgres, auto-add /email on google oauth, improved email empty state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-26 17:42:27 +00:00