first two steps of docs/nextcloud-replacement.md — the half that has to work on
a phone, because that is the half that cannot be faked.
radicale is supervised by the sidecar rather than reimplemented. nextcloud does
not implement caldav either; it vendors sabre/dav. icalendar and vcard are a
weekend, but sync-collection, rrule expansion, vtimezone and ctag/etag are not,
and when they are subtly wrong a phone does not error — it silently stops
syncing, or silently duplicates every event.
two doors, because a browser should not speak dav:
/dav/* top-level, http basic against a scoped app password, every
verb and every dav header forwarded verbatim. this is what
davx5 and ios talk to. same reasoning as /api/vault being
mounted outside protectedRouter.
/api/caldav/* the ordinary sidecar proxy, for officer's own ui. json.
the shared proxy factory could not carry the dav door: it forwards three
headers and dav dies without Depth, and it derives the user from a jwt a phone
cannot hold. so it is a separate file, per that factory's own instruction never
to grow per-app logic.
new `dav_app_passwords` — a phone cannot do jwt, and the alternative is the
account password living in a phone's account manager. argon2, shown once,
revocable per device, and accepted ONLY by /dav.
.well-known/caldav and carddav redirect to the dav root. they are most of what
makes adding an account feel transparent, and they need naming explicitly in
server.tsx or the SPA `/*` fallback answers the phone with html.
verified end to end against the running stack: 401 + WWW-Authenticate
unauthenticated; 207 with calendar-access and addressbook advertised; MKCALENDAR,
PUT and GET of a real VEVENT; calendar-query and sync-collection REPORTs; MKCOL,
PUT and GET of a real vCard. X-Script-Name is set because radicale otherwise
generates hrefs at / and the client follows them into the SPA.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Email was the one sidecar built inside out. The platform held ~1,800 lines — the per-account
SQLite store, all 14 HTTP routes, account CRUD, resync, IMAP validation — while the 314-line
sidecar was a scheduler that reached BACK into the platform to do anything
(`import { performResync } from '../../api/email/resync'`).
The sidecar now serves its own HTTP listener and announces `email:server`, and
/api/email/* on the platform is createSidecarProxy like every other one: 1,801 lines down
to 22, with no mail knowledge left in it — not a message, not a folder, not a credential.
The routes moved verbatim, Hono and all. http.ts only reconstructs what the platform's
middleware used to provide: `user` on the context, from the X-Officer-User header the proxy
injects (trusted because this server binds loopback), and an error handler that turns
custom-errors into status codes.
The /email/events SSE stream went with them, which removes a whole round trip: the IDLE
watcher used to send `email:new` over the registration socket so the platform could push to
its SSE clients. Those clients are here now, so it calls broadcastEmailNew in-process and
`email:new` is gone from the wire protocol.
DELIBERATELY NOT DONE YET, and left backwards on purpose rather than half-moved:
- The two sync handlers (email-sync 381 lines, gmail-sync 712) still run in the platform's
queue and now import the store from its new home — a platform → sidecar import, which is
the wrong direction and is temporary. Moving them is option (A) from the plan: the sidecar
schedules its own syncs, independent of the platform Jobs list.
- accounts.ts still imports queue/init to enqueue a sync and to report sync status, and
index.ts still carries the queue-over-WS shim that inversion needs.
- The three channel handlers still open the mail store directly rather than asking over HTTP.
Two things worth knowing while testing: a from-scratch sync holds a proxied request open
well past the 60s idle default, hence timeoutSeconds on the proxy; and `gmail-sync` is
hardcoded in all three channel handlers even though the only account is provider=gmail with
auth_type=password, which routes to IMAP — so "sync emails" from a chat channel is
almost certainly already broken, and folds into the next stage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deletes the last of the projects/apps cluster: the published-app store
(/api/apps + /api/app-serve), the project dev-server and its websocket
proxy (/api/dev-server + /api/dev-server-proxy), the shared html-rewrite
they were the only consumers of, and their frontend — the Preview panel,
the UserApp panel/header, useUserApps and the /settings/apps screen.
also drops getUserProjectsDir and getUserAppsDir, the ProjectType and
ProjectDefinition types, and the 'dev-server' websocket provider from
server.tsx. nothing on disk is touched.
1440 deletions, 31 insertions. tsgo clean.
cliamp playback was implemented entirely in officer: it located the cliamp binary,
validated the requested path against the owner's home, faked a PTY with `script`,
injected PULSE_SINK and an ALSA config shipped inside the API tree, spawned parec to
capture the sink, and set up the pulseaudio daemon and the virtual_out null sink at
every boot — about 356 lines of audio-pipeline knowledge in a process that is meant to
be a proxy, and none of it owned by the sidecar whose whole job is music.
all of it now lives in sidecar/music: cliamp-ws.ts serves both sockets (/cliamp/ws for
the player, /cliamp/audio/ws for the PCM capture) on the loopback server it already
runs, pulse-audio.ts does the daemon + sink setup at sidecar startup instead of at
officer's, and the asoundrc moved next to the code that passes it. officer keeps the
part that is actually its job — authenticating the browser — and relays frames both
ways without reading them (api/cliamp/relay.ts, same dumb-pipe shape as the vault
notifications relay). the browser's frame contract is unchanged, so the frontend is not
touched.
two things fixed on the way: the traversal check now requires a separator after the
home path, so a sibling directory whose name merely starts with it can no longer pass;
and the music proxy no longer special-cases /reindex and /reindex/stream by name to
extend the idle timeout — it extends the whole prefix, because a proxy should not know
which of the sidecar's routes are slow.
the music-specific `files` query param is out of the shared WS envelope too: upgradeWs
now carries the raw query string, which any relayed provider can use.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Transparent pass-through fronting a self-hosted Vaultwarden so the OffVault
(Bitwarden-SDK) app reaches it through the platform's per-app origin gate. True
out-of-process sidecar (officer-vault): it owns all Vaultwarden knowledge (URL,
paths, notifications WebSocket) on a random loopback port and registers via the
sidecar connector; the platform is a thin origin-gated forwarder that knows only
the sidecar's port. Never decrypts/parses/rewrites/logs bodies.
- sidecar/vault: HTTP + notifications-WS proxy to VAULTWARDEN_URL, /_health
- api/vault: sidecar-port discovery + thin forwarder + WS pipe + origin gate
- origin: OFFICER_VAULT_ORIGIN allow-listed, scoped to /api/vault
- mounted top-level (not protected) so the Bitwarden bearer token isn't 401'd
- protocol: vault:server event; ecosystem: officer-vault app
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
index.html hardcoded the deployment's domain in eight places, so every instance
had to carry its own edit of the file — the only thing separating the rezio
branch from master.
The tags genuinely need absolute URLs. OpenGraph is fetched standalone by
crawlers, and Bun's HTML bundler treats a root-relative href as an asset to
resolve on disk, failing the build with "Could not resolve: /favicon.ico" —
external URLs are the only form it passes through untouched.
Bun's HTML import offers no substitution hook, so scripts/gen-index.ts swaps
__PUBLIC_URL__ for the value in .env and writes index.gen.html, which the server
imports. index.html is the tracked template and is now identical on every
deployment; index.gen.html is gitignored. predev/prestart run the generator, and
it is idempotent so --watch does not loop.
Substituting also fixes the manifest: an absolute URL puts its fetch in CORS
mode, which failed whenever the hardcoded domain was not the serving origin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- DiscordAccount seeded DiscordStatus without its two nullable fields.
- bug-report typed reporter.name as string, but users.name is nullable; and the
Discord upload wrapped a Buffer directly in a Blob.
- Lucide icons take no `title` prop, so the sync spinner's tooltip moved to a
wrapping span.
- DesktopView cast its dynamic import to a type that included `| null`.
- dock PUT cast the request body straight to string[]; it now rejects anything
that is not an array of strings instead of writing it to the database.
- buildZodSchema assembles a mutable record, since z.ZodRawShape is readonly in
zod v4.
- The dev-server proxy forwards Bun's `string | Buffer` frames through a helper
that satisfies WebSocket.send without copying.
bunx tsgo is now clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
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>
- 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>
- 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>
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>
Flips the connection model so sidecars register themselves with the API
server via WebSocket at /api/sidecar/register, enabling dynamic discovery,
location independence, and automatic reconnection from either side.
- Add registration protocol types and PTY command/event types
- Create sidecar-registry.ts (replaces sidecar-client.ts) as passive registry
- Create sidecar connector (connect.ts) with exponential backoff reconnect
- Convert process sidecar from WS server to WS client
- Convert PTY sidecar from WS server to multiplexed WS client
- Simplify terminal bridge to thin adapter using registry
- Add PTY sidecar as PM2-managed process
- Update all consumer imports
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Introduces a separate Bun process (port 5100) that owns all spawned
processes and long-running work, so the API server can restart freely
without disrupting active sessions.
The sidecar owns:
- Anthropic proxy (port 5051) with persisted secret across restarts
- Claude Code process spawning and session tracking (--resume support)
- Pi agent spawning and RPC lifecycle (prompt/abort/thinking)
- Job queue engine (lane processing, retries, notifications)
The API server becomes a thin client that forwards commands over a
single WebSocket connection with auto-reconnect. send-claude-code.ts
goes from 550 lines of spawn logic to 73 lines of sidecar delegation.
State persisted to data/sidecar/state.json every 30s and on shutdown.
Lockfile prevents duplicate instances. See SIDECAR.md for full docs
and manual testing procedures.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Local HTTP proxy on 127.0.0.1:5051 intercepts Claude Code API requests
from sandboxed member users, injects the real OAuth token server-side,
and forwards to Anthropic. Users only see a proxy secret, never the
real credentials.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Added detailed error logging to detect snap node compatibility issues
- When Pi process exits with code 1, log helpful diagnostic info including node path
- Add hint to check for snap node and reinstall via apt/nvm
- Create SNAP_NODE_COMPATIBILITY.md with full troubleshooting guide
- Document root cause: snap node has file descriptor incompatibility with Bun.spawn stdin pipes
- Provide clear installation instructions for NodeSource and nvm alternatives
- 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>