Compare commits
67
Commits
fe0012635a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
027b10bd6e | ||
|
|
f9fd002ff4 | ||
|
|
5afa2d832e | ||
|
|
5ec354cfcb | ||
|
|
2e8ec845c8 | ||
|
|
b0fcd8b81b | ||
|
|
9b1c0a75b2 | ||
|
|
f78abbe05a | ||
|
|
e11e0b6475 | ||
|
|
0a55964db5 | ||
|
|
f1bd75853d | ||
|
|
9af52fd754 | ||
|
|
1e79b4effd | ||
|
|
a9bf51407e | ||
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 |
+30
@@ -63,3 +63,33 @@ scripts/setup/officer-setup/.setup-progress
|
||||
# the repository has no ecosystem file at all any more, and the next machine
|
||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||
ecosystem.config.cjs
|
||||
|
||||
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||
# rewritten on every install. See servers/plugins/generate.ts.
|
||||
build/
|
||||
build.next/
|
||||
src/apps/officer-web/Plugins.gen.tsx
|
||||
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||
|
||||
# drizzle-kit's generated migrations. Nothing applies them — there is no __drizzle_migrations table and
|
||||
# `drizzle-kit migrate` has never been run here; `bun db:push` diffs the schema code against the live
|
||||
# database and alters it directly. The schema code is the source of truth (src/databases/CLAUDE.md).
|
||||
#
|
||||
# Ignored rather than merely unused, because `bun db:gen` reads src/schema.ts — whose last line imports
|
||||
# plugin-schemas.gen.ts, itself generated from the plugin DIRECTORIES on this machine. So a generated
|
||||
# migration describes whichever plugins happen to be checked out here, and committing one would launder
|
||||
# per-machine state into the repository: run it with music installed and history gains music_*; run it on
|
||||
# a fresh clone and the next commit deletes them again.
|
||||
#
|
||||
# The old 0000_new_princess_powerful.sql is deleted from the WORKING TREE but left in history. It had 36
|
||||
# tables and described a schema from before capabilities, api_keys, the app store and the plugin system
|
||||
# existed, including three (task_logs, terminal_containers, queue_jobs) that no longer exist at all.
|
||||
#
|
||||
# Purging it from history was tried on 2026-08-15 and deliberately undone. This codebase cites 76 commit
|
||||
# SHAs in comments and docs as evidence — `totality.ts` points at 2873948 for the websocket incident,
|
||||
# `registry.ts` at 044aacf4, CLAUDE.md at f35c145 — and a filter-repo run rewrites every one of them.
|
||||
# Three stale files nobody reads in old commits are not worth 76 dangling citations in a codebase whose
|
||||
# documentation works by pointing at the commit that proves the claim.
|
||||
#
|
||||
# Generate one locally whenever a diff is useful to read. It stays local.
|
||||
src/databases/officer_db/migrations/
|
||||
|
||||
@@ -12,22 +12,22 @@ look for `CLAUDE.md`, without the two drifting apart.
|
||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
|
||||
per-role capability grants, resolved on every request.
|
||||
per-role permission grants, resolved on every request.
|
||||
|
||||
If a design question turns on "which user", the answer depends on the surface: real for the **app**
|
||||
capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything
|
||||
permissions (gitea, music, photos, email, calendar…), and still always **the owner** for anything
|
||||
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
|
||||
`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority.
|
||||
`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
|
||||
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities"
|
||||
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
|
||||
section of `CLAUDE.md` before adding one.
|
||||
|
||||
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
||||
management". That was written to correct an *older* drift in the opposite direction — a fictional
|
||||
management". That was written to correct an _older_ drift in the opposite direction — a fictional
|
||||
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
|
||||
paragraph above; treat the capability registry as the source of truth over either.
|
||||
paragraph above; treat the permission registry as the source of truth over either.
|
||||
|
||||
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as
|
||||
This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
|
||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||
and no restart.
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ written: `users` holds six rows. The accurate statement is narrower and more use
|
||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
||||
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
|
||||
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
|
||||
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
|
||||
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
|
||||
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
|
||||
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
|
||||
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
|
||||
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
|
||||
and still run processes — but not the *owner's*, because the account has its own Linux user, its own
|
||||
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
|
||||
home, and the kernel refusing everything above it.
|
||||
|
||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
||||
@@ -34,8 +34,8 @@ So "which user is this" has a real answer for the **app** surface (gitea, music,
|
||||
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||
anything under `execution`.
|
||||
|
||||
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
|
||||
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
|
||||
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
@@ -84,7 +84,7 @@ src/
|
||||
│ └── landing/ # marketing landing page
|
||||
├── servers/
|
||||
│ ├── hono.ts # router composition; everything under /api
|
||||
│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting
|
||||
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
|
||||
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||
│ ├── queue/ # background job engine
|
||||
@@ -106,13 +106,14 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
|
||||
|
||||
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
|
||||
|
||||
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
|
||||
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
|
||||
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
|
||||
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
|
||||
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
|
||||
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
|
||||
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
|
||||
first. The source build also needs `build-essential` and `python3`.
|
||||
|
||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
||||
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||
- **Backend**: Hono
|
||||
@@ -135,7 +136,7 @@ per-account email SQLite stores — those are the **email sidecar's**, and nothi
|
||||
them.
|
||||
|
||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That
|
||||
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
|
||||
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
|
||||
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
|
||||
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
|
||||
@@ -172,30 +173,30 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
||||
what came out was documented defence in depth that was already switched off. Origin was never
|
||||
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
|
||||
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
|
||||
is a valid token on every protected route plus the capability gate below.
|
||||
is a valid token on every protected route plus the permission gate below.
|
||||
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
|
||||
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
|
||||
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
|
||||
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||
authenticated request until the server restarts.
|
||||
|
||||
### Capabilities — read this before mounting a router
|
||||
### Permissions — read this before mounting a router
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`, mounted globally in `hono.ts`
|
||||
valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts`
|
||||
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||
`userMiddleware`.
|
||||
|
||||
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
|
||||
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
|
||||
never grantable at any level). 27 entries as of 2026-08-13.
|
||||
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
|
||||
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
|
||||
and `confined` stripped for an account with no `osUser`.
|
||||
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
|
||||
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
||||
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||
- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and
|
||||
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
|
||||
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
|
||||
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
|
||||
@@ -206,7 +207,7 @@ So **adding a router means adding one line to `CAPABILITIES`**. If the surface g
|
||||
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
|
||||
is how the hole happened the first time.
|
||||
|
||||
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
|
||||
+20
-13
@@ -6,13 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th
|
||||
|
||||
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
|
||||
(required for the SSE endpoints — `EventSource` can't set headers).
|
||||
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
|
||||
- **Owner-only.** These routes belong to the `server-admin` permission, which is `kind: 'admin'` and
|
||||
therefore never grantable — a non-owner account gets `403` here whatever its role. The full
|
||||
**officer-mobile** client (which authenticates as the owner) has access; the music app does not.
|
||||
- Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
|
||||
confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role
|
||||
capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is
|
||||
now the capability's kind, not a two-element array.
|
||||
permission grants. The _outcome_ for these routes is unchanged — still owner-only — but the reason is
|
||||
now the permission's kind, not a two-element array.
|
||||
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
|
||||
|
||||
---
|
||||
@@ -20,7 +20,7 @@ Everything the `/system-monitor` web screen renders, for building the same in th
|
||||
## `GET /api/system-monitor/stats`
|
||||
|
||||
One full snapshot. Poll it on a steady interval (the web client uses **2 s**) — a few fields are rates
|
||||
computed from the delta since your *previous* call (see notes), so a steady cadence matters.
|
||||
computed from the delta since your _previous_ call (see notes), so a steady cadence matters.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -65,6 +65,7 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```
|
||||
|
||||
**Notes**
|
||||
|
||||
- `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first**
|
||||
call returns `0`/`null` for these; steady-interval polling gives stable numbers.
|
||||
- `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works.
|
||||
@@ -77,16 +78,18 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```jsonc
|
||||
{
|
||||
"processes": [
|
||||
{ "id": 0, // pm2 id (pm_id) — use this for the logs endpoint
|
||||
{
|
||||
"id": 0, // pm2 id (pm_id) — use this for the logs endpoint
|
||||
"name": "officer",
|
||||
"status": "online", // online | stopped | errored | …
|
||||
"pid": 3339851, // OS pid, or null
|
||||
"status": "online", // online | stopped | errored | …
|
||||
"pid": 3339851, // OS pid, or null
|
||||
"cpuPct": 0,
|
||||
"memBytes": 10354688,
|
||||
"restarts": 44,
|
||||
"uptimeMs": 420000 } // 0 unless status === "online"
|
||||
"uptimeMs": 420000,
|
||||
}, // 0 unless status === "online"
|
||||
],
|
||||
"error": "…" // present only if pm2 couldn't be read
|
||||
"error": "…", // present only if pm2 couldn't be read
|
||||
}
|
||||
```
|
||||
|
||||
@@ -95,14 +98,16 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
||||
```jsonc
|
||||
{
|
||||
"containers": [
|
||||
{ "id": "abc123def456", // short id (12 chars) — use for the logs endpoint
|
||||
{
|
||||
"id": "abc123def456", // short id (12 chars) — use for the logs endpoint
|
||||
"name": "jellyfin",
|
||||
"image": "jellyfin/jellyfin",
|
||||
"state": "running", // running | exited | …
|
||||
"state": "running", // running | exited | …
|
||||
"status": "Up 3 hours",
|
||||
"ports": "0.0.0.0:9301->8096/tcp" }
|
||||
"ports": "0.0.0.0:9301->8096/tcp",
|
||||
},
|
||||
],
|
||||
"error": "…"
|
||||
"error": "…",
|
||||
}
|
||||
```
|
||||
|
||||
@@ -114,12 +119,14 @@ Both stream one **`data: <log line>`** frame per line, plus `: hb` heartbeat com
|
||||
server kills the underlying tail when the connection closes. Open with `EventSource` using `?token=`.
|
||||
|
||||
### `GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>`
|
||||
|
||||
- `id` — **numeric** pm2 id from `/pm2` (required).
|
||||
- `lines` — initial backlog, default `100`, max `1000`.
|
||||
- Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short
|
||||
pm2 `[TAILING] …` header.
|
||||
|
||||
### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>`
|
||||
|
||||
- `id` — container id or name from `/docker` (charset-validated).
|
||||
- `lines` — initial backlog (`--tail`), default `100`, max `1000`.
|
||||
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).
|
||||
|
||||
@@ -4,12 +4,12 @@ Deferred work.
|
||||
|
||||
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
|
||||
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
||||
indirection as accidental complexity. **That direction was reversed.** The capability permission model
|
||||
indirection as accidental complexity. **That direction was reversed.** The permission permission model
|
||||
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
|
||||
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
|
||||
below that proposed deleting it have been removed rather than left to mislead the next reader.
|
||||
|
||||
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
|
||||
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
|
||||
## Multi-user
|
||||
@@ -75,24 +75,34 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
||||
`docs/workspace-panel-todo.md` §3.
|
||||
|
||||
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
||||
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
||||
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
||||
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
||||
standing between a Member and a shell.
|
||||
|
||||
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
|
||||
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
|
||||
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||
which is why nobody noticed. Found 2026-08-14.
|
||||
|
||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||
broken panel or an endless spinner rather than a clean refusal.
|
||||
|
||||
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
|
||||
every caller resolves to the owner's real login home. Safe only because all seven callers sit
|
||||
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the
|
||||
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
|
||||
function to start from.
|
||||
|
||||
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
|
||||
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
|
||||
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
|
||||
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
|
||||
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
|
||||
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
|
||||
thing to fix first if any of them is ever granted.
|
||||
|
||||
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
|
||||
@@ -110,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
||||
|
||||
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
|
||||
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
|
||||
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request
|
||||
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
|
||||
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
|
||||
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
|
||||
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
|
||||
|
||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
||||
coverage = true
|
||||
coverageDir = "coverage"
|
||||
preload = ["./test-setup.ts"]
|
||||
root = "./src"
|
||||
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||
#
|
||||
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||
# extraction would have taken its tests out of the suite the same way.
|
||||
#
|
||||
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||
root = "."
|
||||
|
||||
+129
-129
@@ -6,7 +6,7 @@ a human authoring the workflow at the top. Written live during the conversation
|
||||
owner's own words; where a section records a decision, that decision is his, not a proposal.
|
||||
|
||||
**Read this before ranking, deferring or starting any workspace/panel item.** It is the thing every
|
||||
other workspace/panel document is ranked *against*:
|
||||
other workspace/panel document is ranked _against_:
|
||||
|
||||
- `docs/workspace-panels.md` — how the framework works today (descriptive, no opinions)
|
||||
- `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity
|
||||
@@ -25,11 +25,11 @@ until this conversation is finished — it is not to be started early.
|
||||
Everything that follows is about **`/chat`** and **`/dashboards`**. Verified against
|
||||
`src/apps/officer-web/App.tsx`:
|
||||
|
||||
| route | element | line |
|
||||
|---|---|---|
|
||||
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 |
|
||||
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
|
||||
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
|
||||
| route | element | line |
|
||||
| ---------------------------------------------------------------------- | ---------------------------- | ----- |
|
||||
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 |
|
||||
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
|
||||
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
|
||||
|
||||
One structural fact worth stating early, because it shapes everything: **`/dashboards/:id` is the only
|
||||
place in the app where a workspace is not `locked`.** Every other screen ships a layout as the product
|
||||
@@ -58,15 +58,15 @@ keeping it would distort the design, favour Claude and note the assumption here.
|
||||
|
||||
### 1.3 The dashboards scenario — the live example
|
||||
|
||||
The owner's chosen illustration is **what he is doing at this moment**: running *two Claude agents in
|
||||
parallel, in two different chat windows, both working on the platform.*
|
||||
The owner's chosen illustration is **what he is doing at this moment**: running _two Claude agents in
|
||||
parallel, in two different chat windows, both working on the platform._
|
||||
|
||||
**Stated as fact by the owner** (not inferred):
|
||||
|
||||
- Two agents, two chat windows, same platform, at the same time.
|
||||
- This is precisely why the standing "never restart the server yourself" rule exists: a
|
||||
`pm2 restart officer` is a **shared, destructive-ish event** across every agent working on the
|
||||
platform, so it must be *timed* by the owner rather than triggered by whichever agent happens to
|
||||
platform, so it must be _timed_ by the owner rather than triggered by whichever agent happens to
|
||||
finish first.
|
||||
|
||||
**Observed by me during this same session**, as corroborating detail — the frictions this arrangement
|
||||
@@ -74,11 +74,11 @@ actually produces:
|
||||
|
||||
1. **The owner is the scheduler.** Each agent independently reaches a point where it needs a restart and
|
||||
asks. Nothing in the system knows another agent exists, so the owner is the only thing that can
|
||||
serialise it. (He also had to tell me, separately, to stop *repeating* the request once made.)
|
||||
serialise it. (He also had to tell me, separately, to stop _repeating_ the request once made.)
|
||||
2. **The owner is the message bus.** Neither agent can see the other's work, so anything one needs to
|
||||
know about the other has to be relayed by hand.
|
||||
3. **Shared tree, shared `master`.** Two agents, one working copy. This produced the session's sharpest
|
||||
instruction — *"The problem is committing each other's work. Like, that can't happen, man."* — and
|
||||
instruction — _"The problem is committing each other's work. Like, that can't happen, man."_ — and
|
||||
the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths
|
||||
and never `git add -A`. Nothing enforces it.
|
||||
4. **Uncertain ownership of a failure.** I hit a real typecheck error (`CodeBlock.tsx:138`) and could not
|
||||
@@ -86,7 +86,7 @@ actually produces:
|
||||
|
||||
**Unconfirmed inference — to be confirmed or corrected by the owner before it is treated as the
|
||||
objective:** that the dashboards half of the holy grail is a surface where these parallel agent sessions
|
||||
are *visible together and manageable together* — one screen, multiple live agents as panels, with the
|
||||
are _visible together and manageable together_ — one screen, multiple live agents as panels, with the
|
||||
state they contend over (restarts, the git tree, who is touching what) legible — so the human stops
|
||||
being both the scheduler and the message bus between them.
|
||||
|
||||
@@ -96,7 +96,7 @@ being both the scheduler and the message bus between them.
|
||||
> path I want, to continue or start a new session from a specific path. Each chat panel gets attributed
|
||||
> some kind of persistent ID related to that dashboard.
|
||||
|
||||
And the behaviour that PoC is *for*:
|
||||
And the behaviour that PoC is _for_:
|
||||
|
||||
> I can let you both work, and at the end of your turn you ask the other agent "can I restart?", wait for
|
||||
> his output, restart yourself. And the same from the other side — the other agent, when he finishes his
|
||||
@@ -105,15 +105,15 @@ And the behaviour that PoC is *for*:
|
||||
|
||||
> If we get this to work, the sky is the limit.
|
||||
|
||||
Decomposed into the five capabilities it actually requires:
|
||||
Decomposed into the five permissions it actually requires:
|
||||
|
||||
| # | capability | exists today? |
|
||||
|---|---|---|
|
||||
| P1 | Two chat panels in one dashboard, each an **independent** session | **No** |
|
||||
| P2 | Each panel pointed at **its own path** (cwd) | **No** |
|
||||
| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly |
|
||||
| P4 | Agent-to-agent **messaging** between two live sessions | **No** |
|
||||
| P5 | An agent **surviving the restart** it triggers | **Yes** |
|
||||
| # | permission | exists today? |
|
||||
| --- | --------------------------------------------------------------------------------- | ------------- |
|
||||
| P1 | Two chat panels in one dashboard, each an **independent** session | **No** |
|
||||
| P2 | Each panel pointed at **its own path** (cwd) | **No** |
|
||||
| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly |
|
||||
| P4 | Agent-to-agent **messaging** between two live sessions | **No** |
|
||||
| P5 | An agent **surviving the restart** it triggers | **Yes** |
|
||||
|
||||
### 1.5 Verified state of the code against those five
|
||||
|
||||
@@ -121,7 +121,7 @@ Read from source on 2026-08-07, not assumed:
|
||||
|
||||
**P1 — the blocker.** `ChatPanelWrapper` (`apps/Chat/ChatPanelWrapper.tsx:45`) is declared
|
||||
`() => {…}` — **it takes no props at all, not even `panelId`.** Everything it uses comes from
|
||||
`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are *per screen*. Two
|
||||
`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are _per screen_. Two
|
||||
chat panels dropped into one dashboard today are therefore **byte-for-byte identical**: same cwd, same
|
||||
context, same session-resolution path. There is no per-panel anything. It also calls
|
||||
`useChat(undefined, undefined, …)`, so no session id is passed in — a panel cannot be told which session
|
||||
@@ -132,12 +132,12 @@ through `WorkspaceContext`. `scoped = cwd !== '~'`. Every panel on a screen nece
|
||||
|
||||
**P3 — the good news, with one sharp edge.** Panel ids (`layout-utils.ts:4`,
|
||||
`` uid = () => `p-${Date.now()}-${++counter}` ``) are generated once and **persisted inside the layout
|
||||
`jsonb`**, so a panel id *is* already stable across reloads. That makes panel id a viable durable key —
|
||||
`jsonb`**, so a panel id _is_ already stable across reloads. That makes panel id a viable durable key —
|
||||
which is the single most load-bearing fact for this PoC. The edge: `movePanel` mints a **new** id
|
||||
(`layout-utils.ts:192`, `:207`) rather than carrying the old one, so dragging a panel would silently
|
||||
sever its session binding. That is gap **G2** in the analysis, and it is now on the critical path.
|
||||
|
||||
Also already half-built, and worth knowing: for a *user* dashboard the wrapper already derives
|
||||
Also already half-built, and worth knowing: for a _user_ dashboard the wrapper already derives
|
||||
`{ context: 'dashboard', contextId: dashboardId }` (`ChatPanelWrapper.tsx:49-55`) — a notion of
|
||||
dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly
|
||||
one level too coarse for this.
|
||||
@@ -160,7 +160,7 @@ PoC has the shape it has.**
|
||||
A lot of work landed today and over the last few days: **a session now survives a server restart with no
|
||||
refresh and no user action.** One case remains broken, and the owner has **decided not to solve it**:
|
||||
|
||||
> *unless the agent is currently outputting — the restart of the server interrupts that output.*
|
||||
> _unless the agent is currently outputting — the restart of the server interrupts that output._
|
||||
|
||||
This reframes the PoC entirely. **The by-turn handshake is not merely coordination; it is a deliberate
|
||||
route around the one failure mode that is not going to be fixed.** Restarts are made safe by
|
||||
@@ -190,7 +190,7 @@ signal the direction is right, since it falls out of the PoC at no extra cost.
|
||||
### 1.7 The actual objective — the software factory
|
||||
|
||||
**The restart problem is not the goal, and is barely even a problem.** It exists only because the owner
|
||||
is currently using the platform to fix the live platform, for velocity. It is a *dogfooding artifact*.
|
||||
is currently using the platform to fix the live platform, for velocity. It is a _dogfooding artifact_.
|
||||
It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it
|
||||
is the target.
|
||||
|
||||
@@ -204,7 +204,7 @@ The target:
|
||||
So the north star is: **several specialised agents, working concurrently on one codebase, coordinating
|
||||
with each other rather than through the human, with quality gates between them and the mainline.**
|
||||
|
||||
The dashboards surface is how a human *watches and steers* that factory. The chat panels are the
|
||||
The dashboards surface is how a human _watches and steers_ that factory. The chat panels are the
|
||||
workers. The restart handshake is the first, smallest instance of the general primitive: agents
|
||||
negotiating a shared resource without a human in the middle.
|
||||
|
||||
@@ -226,7 +226,7 @@ plainly and early — the cost of a late correction here is much higher than the
|
||||
risk profile, and it is recorded here because it is the strongest single argument in the whole
|
||||
conversation.
|
||||
|
||||
**Constraint, binding:** *there will always be a human orchestrator* — the owner, or whoever later runs
|
||||
**Constraint, binding:** _there will always be a human orchestrator_ — the owner, or whoever later runs
|
||||
the platform. **The goal is explicitly not agents ping-ponging inputs and outputs with no structure.**
|
||||
Any design that removes the human from the top of the loop is wrong, not ambitious.
|
||||
|
||||
@@ -240,14 +240,14 @@ computers. The owner's worked example, verbatim in substance:
|
||||
well-documented, and the documentation keeps being updated with new learnings.
|
||||
2. The owner **shifts focus entirely** to other work — mobile monorepo, platform architecture — for one
|
||||
to two hours, without having to hold Soulseek in his head.
|
||||
3. The platform agent reports: *"Soulseek is up, give it a try, here is how to test it."*
|
||||
3. The platform agent reports: _"Soulseek is up, give it a try, here is how to test it."_
|
||||
4. The owner restarts, enters credentials, confirms it works, and the agent pushes.
|
||||
5. The owner pulls on the MacBook and tells the **mobile agent** — which already knows the mobile
|
||||
infrastructure — "create me a Soulseek app based on everything the platform has today." It works.
|
||||
|
||||
**So the pattern is proven by human execution.** What is being automated is not "can agents collaborate"
|
||||
— it is the *bridging role*, which the owner currently performs and describes as: *stressful, a lot to
|
||||
keep in my head*, though enjoyable and exciting.
|
||||
— it is the _bridging role_, which the owner currently performs and describes as: _stressful, a lot to
|
||||
keep in my head_, though enjoyable and exciting.
|
||||
|
||||
### 1.10 The midnight scenario — the shape of the target
|
||||
|
||||
@@ -257,8 +257,8 @@ keep in my head*, though enjoyable and exciting.
|
||||
> "this is not according to spec", to mobile "maybe change this" — and in the end be **responsible for
|
||||
> the joining of everything, which is currently the work that I'm doing.**
|
||||
|
||||
The owner's own framing: *a holy grail by its nature doesn't exist — but I really think we can get
|
||||
there.*
|
||||
The owner's own framing: _a holy grail by its nature doesn't exist — but I really think we can get
|
||||
there._
|
||||
|
||||
Structural requirements this adds, beyond the two-panel PoC:
|
||||
|
||||
@@ -267,38 +267,38 @@ Structural requirements this adds, beyond the two-panel PoC:
|
||||
- **Panels are aware of each other** — an agent must be able to enumerate its peers.
|
||||
- **Panels span repositories** — platform and `monorepo-mobile` are different repos with different
|
||||
remotes.
|
||||
- **The fourth role is different in kind from the first three.** Roles 1–3 are *do the work*, and are
|
||||
already proven by the manual flow. Role 4 is *hold the whole picture and judge* — the role the owner
|
||||
- **The fourth role is different in kind from the first three.** Roles 1–3 are _do the work_, and are
|
||||
already proven by the manual flow. Role 4 is _hold the whole picture and judge_ — the role the owner
|
||||
performs today with human judgement. See §5 for why this is flagged as the research risk rather than
|
||||
an engineering task.
|
||||
|
||||
### 1.11 Do not design for the examples — the owner's counterpoints
|
||||
|
||||
Recorded because every one of these is a correction of *my* over-constraining, and the same mistake will
|
||||
Recorded because every one of these is a correction of _my_ over-constraining, and the same mistake will
|
||||
be easy to repeat later.
|
||||
|
||||
- **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only
|
||||
two agents. *"This coordination is the point I want to ultimately reach."*
|
||||
two agents. _"This coordination is the point I want to ultimately reach."_
|
||||
- **Roles are malleable.** Not every run involves four agents, and not with those roles. Fixing
|
||||
"front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner
|
||||
does not have.
|
||||
- **There is no paradigm.** *"It's whatever we want it to be."*
|
||||
- **There is no paradigm.** _"It's whatever we want it to be."_
|
||||
- **The owner's current needs are not the end state.** He has a day job unrelated to mobile that would
|
||||
benefit from the same coordination. Designing narrowly around platform+mobile development is a trap.
|
||||
- **Cross-machine is NOT the hard problem, and I was wrong to raise it as a fork.** The owner has already
|
||||
solved it at small scale: a second Claude on the MacBook with a 15-minute timer pulling the latest
|
||||
platform changes and replicating them for the mobile apps. Git hooks or cron do the same.
|
||||
*"That's the least painful point of all this."*
|
||||
_"That's the least painful point of all this."_
|
||||
|
||||
**The painful point, in the owner's words:** *panel communication inside a single web page, or a single
|
||||
workspace, on our platform Web UI.* That is the problem to solve. Everything else is downstream.
|
||||
**The painful point, in the owner's words:** _panel communication inside a single web page, or a single
|
||||
workspace, on our platform Web UI._ That is the problem to solve. Everything else is downstream.
|
||||
|
||||
This yields a natural two-tier split, which the design should respect rather than unify:
|
||||
|
||||
| tier | mechanism | status |
|
||||
|---|---|---|
|
||||
| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** |
|
||||
| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem |
|
||||
| tier | mechanism | status |
|
||||
| --------------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||
| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** |
|
||||
| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem |
|
||||
|
||||
### 1.12 The minimal protocol — and why the PoC is not a toy
|
||||
|
||||
@@ -311,8 +311,8 @@ Compare it to the restart handshake:
|
||||
|
||||
> I finished my output, you can restart the server, and tell me when you're done so I can continue.
|
||||
|
||||
**These are the same protocol with a different payload.** Both are: *declare turn-end → hand off →
|
||||
await the peer's completion → resume.* The restart PoC is therefore not a toy standing in for the real
|
||||
**These are the same protocol with a different payload.** Both are: _declare turn-end → hand off →
|
||||
await the peer's completion → resume._ The restart PoC is therefore not a toy standing in for the real
|
||||
thing; it is the real protocol, exercised on the smallest possible payload.
|
||||
|
||||
The design consequence: **build the primitive general and keep the roles as configuration.** A named,
|
||||
@@ -348,30 +348,30 @@ What he expects instead:
|
||||
- The **workflow graph lives in the prompts**, authored by the human at dashboard setup.
|
||||
- The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages
|
||||
between them at turn boundaries**. That is it.
|
||||
- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding *what* to
|
||||
do and *who* should do it. Here, the human decides both, up front, once.
|
||||
- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding _what_ to
|
||||
do and _who_ should do it. Here, the human decides both, up front, once.
|
||||
|
||||
**One consequence worth stating** (observation, not a decision taken): if roles are prompts, then
|
||||
*addressing* must still resolve. "Pass that work to the front end developer" needs a destination. The
|
||||
_addressing_ must still resolve. "Pass that work to the front end developer" needs a destination. The
|
||||
consistent answer is that the **human names each panel at setup** and tells each agent the names of its
|
||||
peers — so addressing is a string the human chose, and the system merely routes it. A system-maintained
|
||||
roster of roles would re-import the paradigm through the back door.
|
||||
|
||||
**Also note:** *not long lived* lowers the persistence bar for a dashboard's workflow configuration —
|
||||
**Also note:** _not long lived_ lowers the persistence bar for a dashboard's workflow configuration —
|
||||
but **not** for panel identity, which must still survive a reload for the whole PoC to work (§1.5, P3).
|
||||
|
||||
### 1.14 The charter — and what is explicitly *not* mine
|
||||
### 1.14 The charter — and what is explicitly _not_ mine
|
||||
|
||||
**Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be
|
||||
re-litigated.**
|
||||
|
||||
- It has been working in practice: three agents at a time on the platform, and *the way the platform was
|
||||
modularised means they don't step on each other's toes ~90% of the time.* Nothing is or will be
|
||||
- It has been working in practice: three agents at a time on the platform, and _the way the platform was
|
||||
modularised means they don't step on each other's toes ~90% of the time._ Nothing is or will be
|
||||
perfect.
|
||||
- Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional
|
||||
experience, has never used a git worktree, and is willing to adopt one when it becomes necessary.
|
||||
- Explicit division of labour, verbatim: *"that's my problem as a software engineer, as an architect, to
|
||||
solve."*
|
||||
- Explicit division of labour, verbatim: _"that's my problem as a software engineer, as an architect, to
|
||||
solve."_
|
||||
|
||||
So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so
|
||||
it is not lost, not so it gets picked up. Raising it once was welcomed; raising it again is noise.
|
||||
@@ -379,7 +379,7 @@ it is not lost, not so it gets picked up. Raising it once was welcomed; raising
|
||||
**Important clarification from the owner — this is not a narrowing of the push-back instruction (§1.8):**
|
||||
|
||||
> Don't take what I said as a restriction on you to push back on things that you think might come up that
|
||||
> are maybe not directly related with your particular mission. I just want you to understand that I *do*
|
||||
> are maybe not directly related with your particular mission. I just want you to understand that I _do_
|
||||
> know what I'm doing — I've been through all those things my whole career. Those stones in my shoe are
|
||||
> mine to bear, not yours.
|
||||
|
||||
@@ -395,12 +395,12 @@ settled by the ruling; the right to raise the next one is not affected.
|
||||
And the expectations around it:
|
||||
|
||||
- **Learn to walk first.** The owner does not expect that the night after this works he creates a
|
||||
dashboard with four windows and builds a project. The *practice* of using it will be perfected over
|
||||
dashboard with four windows and builds a project. The _practice_ of using it will be perfected over
|
||||
time, separately from the mechanism.
|
||||
- **This mission will take some time to reach an initial state.** It is not a quick change.
|
||||
|
||||
**The problems the owner explicitly wants thought about and documented** — these are the real design
|
||||
work, and they come *after* the basics are proven:
|
||||
work, and they come _after_ the basics are proven:
|
||||
|
||||
1. Does the system survive a **server restart**?
|
||||
2. Does it survive a **page refresh**?
|
||||
@@ -413,7 +413,7 @@ Survey of the chat/agent commits since 2026-08-01, read from diffs and source. *
|
||||
further along than assumed.** Load-bearing findings:
|
||||
|
||||
**The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called
|
||||
on an *existing* `sessionKey` does **not** spawn anything — it pushes a user message onto the live input
|
||||
on an _existing_ `sessionKey` does **not** spawn anything — it pushes a user message onto the live input
|
||||
queue (`claude-manager.ts:380-391` → `pushTurn` → `input.push`). **No browser involved.** Existing
|
||||
callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent
|
||||
delivers a message to another.
|
||||
@@ -421,7 +421,7 @@ delivers a message to another.
|
||||
**The turn-boundary signal already exists.** `result` is the explicit terminal event
|
||||
(`chat/types.ts:143-153`, emitted `stream-parser.ts:144`), and `onTurnComplete(hadToolCalls)` is already
|
||||
a public option on `useChat` (`useChat.ts:28`, fired at `:286-314`). Terminal set is
|
||||
**`result` | `error` | `stopped` | `cut-off`**. Note: the *session outlives the turn* — `task:started` /
|
||||
**`result` | `error` | `stopped` | `cut-off`**. Note: the _session outlives the turn_ — `task:started` /
|
||||
`task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle".
|
||||
|
||||
**Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor,
|
||||
@@ -429,20 +429,20 @@ per-session index, `prevSeq` continuity chain, at-least-once replay from a clien
|
||||
**Caveat: 7-day retention** (`api/chat/retention.ts`) — a replay buffer, not an archive.
|
||||
|
||||
**Peer-restart notification:** `onClaudeSidecarStarted` (`sidecar-registry.ts:69-91`) — keyed off the
|
||||
agent *registering*, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by
|
||||
agent _registering_, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by
|
||||
the process that owns the session, **failing toward alive**.
|
||||
|
||||
**Principles this codebase has already paid for — adopt, don't re-derive:**
|
||||
|
||||
1. *Never route the durability guarantee over the link expected to break.* The agent writes to
|
||||
1. _Never route the durability guarantee over the link expected to break._ The agent writes to
|
||||
`chat_session_events` itself, then notifies; officer relays. Write durable, then notify.
|
||||
2. *Infer liveness from the birth of the new process, not the death of the socket* — socket death fires
|
||||
2. _Infer liveness from the birth of the new process, not the death of the socket_ — socket death fires
|
||||
on the innocent case (`pm2 restart officer`).
|
||||
3. *An availability check must fail toward the less-alarming answer.*
|
||||
4. *Classify by recoverability, not severity* — `cut-off` (seam + Retry) is a different object from
|
||||
3. _An availability check must fail toward the less-alarming answer._
|
||||
4. _Classify by recoverability, not severity_ — `cut-off` (seam + Retry) is a different object from
|
||||
`error` (red bubble).
|
||||
5. *An id that never crosses the process boundary is not an address* (`47d03de`).
|
||||
6. *Disambiguate at the only site holding the extra bit*, and set the flag **before** the await that can
|
||||
5. _An id that never crosses the process boundary is not an address_ (`47d03de`).
|
||||
6. _Disambiguate at the only site holding the extra bit_, and set the flag **before** the await that can
|
||||
race it.
|
||||
|
||||
**⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key
|
||||
@@ -454,7 +454,7 @@ path for agent-to-agent messaging.** To be written up in `COMMS/` and handed off
|
||||
|
||||
### 1.16 The real optimisation target: unattended continuity, not parallelism
|
||||
|
||||
**Correcting a wrong assumption of mine.** The owner does *not* want three or four agents running flat
|
||||
**Correcting a wrong assumption of mine.** The owner does _not_ want three or four agents running flat
|
||||
out at once:
|
||||
|
||||
> I don't expect to have three or four agents running at the same time like crazy. **What I want is to be
|
||||
@@ -470,12 +470,12 @@ are large:
|
||||
between then and morning.
|
||||
|
||||
**And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the
|
||||
owner is *asleep*, **the dashboard page is closed.** A handoff must therefore work with **no browser
|
||||
owner is _asleep_, **the dashboard page is closed.** A handoff must therefore work with **no browser
|
||||
open**. That rules out every browser-resident mechanism — `usePanelChannel`, React state, anything in the
|
||||
document — not on elegance grounds but because the document will not exist when the message is sent.
|
||||
|
||||
This does not contradict the owner's framing of the problem as *"panel communication inside a single web
|
||||
page"*; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how
|
||||
This does not contradict the owner's framing of the problem as _"panel communication inside a single web
|
||||
page"_; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how
|
||||
a human watches and steers a conversation that continues without it — which is also precisely what
|
||||
§1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer,
|
||||
session outliving the socket).
|
||||
@@ -484,25 +484,25 @@ session outliving the socket).
|
||||
against the running system, deliberately looking for the break. Method and raw evidence:
|
||||
`COMMS/handoff-durability-2026-08-07.md`.
|
||||
|
||||
| what was done to it mid-handoff | turn completed | narration durable |
|
||||
|---|---|---|
|
||||
| nothing (control) | ✅ | ✅ |
|
||||
| `pm2 restart officer` | ✅ | ✅ |
|
||||
| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** |
|
||||
| target's `claude` process SIGKILLed | ❌ lost | ⚠️ partial, with an explicit error row |
|
||||
| next handoff to that same agent | ✅ | ✅ same Claude session resumed |
|
||||
| handoff POSTed while `officer` is down | ❌ lost | ❌ no queue, no retry |
|
||||
| what was done to it mid-handoff | turn completed | narration durable |
|
||||
| -------------------------------------- | -------------- | ------------------------------------------------- |
|
||||
| nothing (control) | ✅ | ✅ |
|
||||
| `pm2 restart officer` | ✅ | ✅ |
|
||||
| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** |
|
||||
| target's `claude` process SIGKILLed | ❌ lost | ⚠️ partial, with an explicit error row |
|
||||
| next handoff to that same agent | ✅ | ✅ same Claude session resumed |
|
||||
| handoff POSTed while `officer` is down | ❌ lost | ❌ no queue, no retry |
|
||||
|
||||
Three things follow, and they change how the outstanding work should be read:
|
||||
|
||||
- **The "no browser open" requirement is satisfied, and so is the harder one.** Not one of these runs had
|
||||
a page open, and the middle row is the proof that officer is genuinely off the delivery path: the agent
|
||||
sidecar committed the model's own words to Postgres during a 25-second server outage.
|
||||
- **A restart costs a *turn*, not an *agent*.** After being killed mid-turn, the receiver resumed the
|
||||
- **A restart costs a _turn_, not an _agent_.** After being killed mid-turn, the receiver resumed the
|
||||
identical Claude session on the next handoff and volunteered which work had been lost. Continuity —
|
||||
`sessionKey` minted once, write-through map on disk — does the job it was built for. That makes the
|
||||
outstanding claude **stage 5** a smaller problem than its position on the list suggests.
|
||||
- **The remaining hole is on the *sending* side.** A handoff POSTed while officer is down is refused and
|
||||
- **The remaining hole is on the _sending_ side.** A handoff POSTed while officer is down is refused and
|
||||
dropped, and nothing in the introduction text tells the agent to retry — so the sender can believe it
|
||||
handed off when it did not. That, not the receiving side, is where store-and-forward would earn its
|
||||
keep.
|
||||
@@ -510,7 +510,7 @@ Three things follow, and they change how the outstanding work should be read:
|
||||
One topology fact found while setting this up, worth stating here because it is the practical limit on
|
||||
working unattended: **`officer-agent` is `sidecar/claude/user-instance.ts`, and every `claude` process on
|
||||
the machine is its direct child.** `pm2 restart officer-agent` therefore kills every agent on every
|
||||
dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting *officer* is safe — it is, and that is
|
||||
dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting _officer_ is safe — it is, and that is
|
||||
verified above — but is silent on this one.
|
||||
|
||||
### 1.17 The restart payload is temporary — the protocol is not
|
||||
@@ -518,17 +518,17 @@ verified above — but is silent on this one.
|
||||
> The restart thing is giving me pain right now. Pretty soon that won't be a problem, because I won't
|
||||
> have the necessity of editing the platform in real time from the platform as I'm doing today.
|
||||
|
||||
Further confirmation that the PoC is **scaffolding**: the *payload* is disposable, the *protocol* is the
|
||||
Further confirmation that the PoC is **scaffolding**: the _payload_ is disposable, the _protocol_ is the
|
||||
deliverable. Reinforces §1.12 — build message passing, not a restart-negotiation feature. If the restart
|
||||
case disappeared tomorrow, nothing built should need to be deleted.
|
||||
|
||||
### 1.18 Ruling on push-back #3 (agents reviewing agents)
|
||||
|
||||
Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — *that is
|
||||
assuming the owner is dumb, which is important sometimes, but not for this mission.*
|
||||
Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — _that is
|
||||
assuming the owner is dumb, which is important sometimes, but not for this mission._
|
||||
|
||||
Correct, and worth stating why so the boundary is understood rather than merely obeyed: **the quality of
|
||||
an agent's review is a *usage* concern, downstream of the mechanism.** Whether the reviewer is any good
|
||||
an agent's review is a _usage_ concern, downstream of the mechanism.** Whether the reviewer is any good
|
||||
is a property of the prompt the human wrote, not of the transport. The mechanism is the postal service;
|
||||
it is not accountable for what is in the envelopes.
|
||||
|
||||
@@ -542,15 +542,15 @@ Derived from §1. These are the constraints the design must satisfy.
|
||||
|
||||
1. **A stable, addressable identity per chat panel**, persisted, surviving reload. Panel id is the
|
||||
natural key (§1.5, P3) — subject to the `movePanel` hazard.
|
||||
2. **Message passing between named sessions at turn boundaries.** Messages carry *content* (a handoff of
|
||||
work), not just signals (§1.12, and the owner's escalation: work handoff is *the whole crux*).
|
||||
2. **Message passing between named sessions at turn boundaries.** Messages carry _content_ (a handoff of
|
||||
work), not just signals (§1.12, and the owner's escalation: work handoff is _the whole crux_).
|
||||
3. **Loud failure** when a message is dropped, a peer does not exist, or a handoff never lands (§1.8 as
|
||||
refined; already a value in this codebase — `9eb8fa1`).
|
||||
|
||||
**Do not build:**
|
||||
|
||||
- No role registry, orchestration engine, planner, or task allocator (§1.13).
|
||||
- No restart-negotiation feature — restart is a *payload* (§1.17).
|
||||
- No restart-negotiation feature — restart is a _payload_ (§1.17).
|
||||
- No git, repo, branch, build, or test awareness. **No knowledge of software at all** (§1.11 + the
|
||||
domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is
|
||||
of what is in the envelope.
|
||||
@@ -561,7 +561,7 @@ Derived from §1. These are the constraints the design must satisfy.
|
||||
|
||||
- **Turn-boundary only.** The safety property comes from negotiation, not robustness (§1.6).
|
||||
- **Must work with no browser open.** The owner's goal is to sleep; the page will be closed. The
|
||||
mechanism is server-side; panels are the view (§1.16). *Pending owner confirmation — see §5.*
|
||||
mechanism is server-side; panels are the view (§1.16). _Pending owner confirmation — see §5._
|
||||
- **N-way from day one.** Parallelism is not the current target but must not be foreclosed — no "the
|
||||
other agent" singular anywhere, no single-writer ordering assumptions (§1.16 correction).
|
||||
- **Durability over latency.** Seconds or minutes between handoffs is fine; a lost 03:00 handoff is not.
|
||||
@@ -578,35 +578,35 @@ primitive is the right one.
|
||||
- **Automatic handoff of cross-domain findings** — e.g. this document's own §1.15 chat defect, which the
|
||||
owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same
|
||||
shape.
|
||||
- **The software factory** (§1.7) — several specialised agents with quality gates, as *usage* built on
|
||||
- **The software factory** (§1.7) — several specialised agents with quality gates, as _usage_ built on
|
||||
the primitive rather than as features of it.
|
||||
- **Non-software domains entirely**, and other users with unrelated goals.
|
||||
- **True parallelism**, later — *"the literal definition of heaven on earth."*
|
||||
- **True parallelism**, later — _"the literal definition of heaven on earth."_
|
||||
|
||||
## 4. Constraints and rules laid down
|
||||
|
||||
*(ground rules stated by the owner for this body of work, verbatim in substance)*
|
||||
_(ground rules stated by the owner for this body of work, verbatim in substance)_
|
||||
|
||||
- Nothing is started — including trivial fixes — until the picture is complete and played back to the
|
||||
owner, and the owner has confirmed it is correct.
|
||||
- This document is kept live *during* the conversation, not written up afterwards.
|
||||
- This document is kept live _during_ the conversation, not written up afterwards.
|
||||
- Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here
|
||||
and in the documents that already exist.
|
||||
|
||||
### 4.1 Explicitly de-scoped — not wrong, just not now
|
||||
|
||||
Stated by the owner before the objective itself, and it is a *priority* judgement, not a correctness
|
||||
Stated by the owner before the objective itself, and it is a _priority_ judgement, not a correctness
|
||||
one. These are acknowledged as poor architecture and are nonetheless **not to be worked on**:
|
||||
|
||||
- **Everything downstream of the file browser at `/files`** — the ephemeral-panel machinery
|
||||
(`ephemeral` prop, `useFileViewerPanels`, the search-param-driven viewer/player/side-chat that opens
|
||||
beside the browser without entering your saved layout). The owner's words: *horrible architecture*,
|
||||
and *everything is working as much as I need it*.
|
||||
beside the browser without entering your saved layout). The owner's words: _horrible architecture_,
|
||||
and _everything is working as much as I need it_.
|
||||
- The query-string-driven sub-panel approach generally.
|
||||
|
||||
The rule that follows: **do not open these as work items, and do not let a fix wander into them.** If
|
||||
one of them is genuinely blocking the objective, that is a finding to raise with the owner — not a
|
||||
licence to start. Some of them will likely improve *inadvertently*, as a side effect of work done for
|
||||
licence to start. Some of them will likely improve _inadvertently_, as a side effect of work done for
|
||||
the objective, and that is the expected and acceptable way for them to get better.
|
||||
|
||||
This section is a live list. Anything else the owner de-scopes gets added here rather than being
|
||||
@@ -635,14 +635,14 @@ The chain, verified:
|
||||
calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has
|
||||
pending tasks.
|
||||
- **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes
|
||||
the input queue and drops the in-memory entry — and deliberately does *not* call
|
||||
the input queue and drops the in-memory entry — and deliberately does _not_ call
|
||||
`clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect
|
||||
path.
|
||||
- The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at
|
||||
`DATA_PATH/<email>/sidecar/claude-state.json`), specifically so it survives a crash or SIGKILL —
|
||||
commit `f4be4fd`.
|
||||
- On a fresh spawn, `createSession` reads it back: `resumeId = getClaudeSession(sessionKey) ??
|
||||
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
||||
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
||||
|
||||
**Therefore `spawnClaudeStreaming` on a reaped `sessionKey` transparently re-creates the session with
|
||||
`resume: <claudeSessionId>`, context intact from the on-disk transcript.** This is the good answer, and
|
||||
@@ -658,17 +658,17 @@ it is better than a heartbeat on every axis:
|
||||
Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible
|
||||
durable object. Delivery is "resume that transcript and push a turn."
|
||||
|
||||
⚠ **The one hazard to respect:** the explicit `disconnect` path *does* call `clearClaudeSession`, which
|
||||
⚠ **The one hazard to respect:** the explicit `disconnect` path _does_ call `clearClaudeSession`, which
|
||||
destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and
|
||||
whatever closes a panel must not trigger it.
|
||||
|
||||
**Q3 — RESOLVED by the owner, 2026-08-07: *"Yes, we can do that. I name them all."*** The human assigns
|
||||
**Q3 — RESOLVED by the owner, 2026-08-07: _"Yes, we can do that. I name them all."_** The human assigns
|
||||
each panel a name at setup; the system routes a string the human chose and knows nothing about its
|
||||
meaning. **The name is the address; the panel id is merely where it currently lives** — which also
|
||||
disarms the `movePanel` hazard (§1.5, P3), since dragging changes position, not identity.
|
||||
|
||||
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to *how a panel
|
||||
acquires its Claude session id*:
|
||||
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to _how a panel
|
||||
acquires its Claude session id_:
|
||||
|
||||
> We can wait for the first conversation with a certain agent to start and get the first output, so we
|
||||
> get the session id from Claude and add it to our session key. Or basically we **fire up each session
|
||||
@@ -677,7 +677,7 @@ acquires its Claude session id*:
|
||||
> dashboard creation or session creation.
|
||||
|
||||
**Adopt the second.** It is strictly better, because it collapses two problems into one act: the role
|
||||
prompt the human must write anyway *is* the message that brings the session into existence. Consequences:
|
||||
prompt the human must write anyway _is_ the message that brings the session into existence. Consequences:
|
||||
|
||||
- The **address book is fully populated at dashboard-creation time** — no lazy state, no "panel exists
|
||||
but has no session yet" hole, no first-handoff race.
|
||||
@@ -698,7 +698,7 @@ the rule — but it **does not block this work.**
|
||||
> agent will be instructed to write at the end of its work, having in mind to whom that prompt is going
|
||||
> to be delivered. **For proof of concept it could just be a dot character.**
|
||||
|
||||
So: the sending agent *composes* the message; the system carries it and does not parse it. Same rule as
|
||||
So: the sending agent _composes_ the message; the system carries it and does not parse it. Same rule as
|
||||
roles — semantics in the prose, mechanism dumb. **PoC success criterion collapses to: did a turn land in
|
||||
the other panel.** A single `.` is a sufficient payload to prove the mechanism.
|
||||
|
||||
@@ -720,31 +720,31 @@ Owner, 2026-08-07:
|
||||
> sequence that worked from start to finish, what were the prompts passed from one to another.
|
||||
> **But this is something for version 2.**
|
||||
|
||||
Shape: one row per *dashboard run*, holding the roster of Claude session ids and an ordered list of
|
||||
Shape: one row per _dashboard run_, holding the roster of Claude session ids and an ordered list of
|
||||
handoffs (from, to, prompt, timestamp). Deliberately **not** an output log — Claude's own transcripts and
|
||||
`chat_session_events` already hold the content, and duplicating them is the mistake to avoid.
|
||||
|
||||
**Do not build this in v1.** But do not preclude it either: v1 must emit enough that the ledger is purely
|
||||
*additive* later.
|
||||
_additive_ later.
|
||||
|
||||
### 5.2 The distinction that keeps v1 small: address book vs ledger
|
||||
|
||||
These are two different things and conflating them would inflate v1 into v2:
|
||||
|
||||
| | what it is | when |
|
||||
|---|---|---|
|
||||
| **Address book** | the durable mapping *panel → session*, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. |
|
||||
| **Ledger** | the durable *history* of who handed what to whom | **v2 — deferred** (§5.1). |
|
||||
| | what it is | when |
|
||||
| ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- |
|
||||
| **Address book** | the durable mapping _panel → session_, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. |
|
||||
| **Ledger** | the durable _history_ of who handed what to whom | **v2 — deferred** (§5.1). |
|
||||
|
||||
v1 needs the address book and nothing more. Provenance recorded in v1 should be the minimum that makes a
|
||||
handoff *visible and its failure loud* (§2), not a history feature.
|
||||
handoff _visible and its failure loud_ (§2), not a history feature.
|
||||
|
||||
## 6. How the found defects map onto the path
|
||||
|
||||
*(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against
|
||||
_(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against
|
||||
what the mechanism turned out to need, not against what it was predicted to need. `workspace-panel-todo.md`
|
||||
is ordered by defect severity; this section says which of those defects the **objective** actually cares
|
||||
about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)*
|
||||
about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)_
|
||||
|
||||
### 6.1 The headline: most of the panel defect list is not on this path
|
||||
|
||||
@@ -754,7 +754,7 @@ Postgres and on the sidecar's disk. So a panel can remount, re-render, lose its
|
||||
across the dashboard, or not be rendered at all — and the work continues. Whole sections of the todo that
|
||||
rank high on severity rank near-zero here.
|
||||
|
||||
The corollary, and it is the useful half: the defects that *do* matter are almost all the same defect
|
||||
The corollary, and it is the useful half: the defects that _do_ matter are almost all the same defect
|
||||
wearing four hats — **a write that silently does not persist.** Panel identity is the one piece of
|
||||
coordination state that lives in the layout jsonb rather than in a table of its own, so every silent
|
||||
persistence failure in this list is now a path by which a panel forgets which agent it is.
|
||||
@@ -762,15 +762,15 @@ persistence failure in this list is now a path by which a panel forgets which ag
|
||||
### 6.2 Tier A — on the critical path
|
||||
|
||||
**A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`,
|
||||
`.catch(() => {})`.) *The single highest-value item in the whole list against this objective.* The
|
||||
`.catch(() => {})`.) _The single highest-value item in the whole list against this objective._ The
|
||||
panel's agent name is written through this path. A swallowed 500 leaves the optimistic cache correct, so
|
||||
the panel shows its name, answers to its name, and **forgets it on the next reload** — the failure is
|
||||
invisible for exactly as long as the human is not looking, which is the entire window this project
|
||||
exists to serve. §2 requires *loud failure*; this is the loudest silence in the codebase.
|
||||
exists to serve. §2 requires _loud failure_; this is the loudest silence in the codebase.
|
||||
|
||||
**A2. The PATCH dispatcher's missing `else`.** (§2, first item.) The server half of A1. `ws-layout-*` is
|
||||
matched today so panel `config` does persist — verified, the demo dashboard round-tripped with
|
||||
`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the *next* key
|
||||
`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the _next_ key
|
||||
family added for coordination is a silent no-op that returns 200. Add the 400.
|
||||
|
||||
**A3. Validate the layout on read, and fix the `'[]'` default.** (§4, items 2 and 3.) Panel `config` is
|
||||
@@ -799,7 +799,7 @@ functions over a serialisable tree; there is no excuse.
|
||||
|
||||
**A7. Two windows must not disagree about the roster.** (§5.5, "the cache is never invalidated" —
|
||||
`staleTime: Infinity`, no `invalidateQueries` anywhere, and every PATCH already returns a fresh state
|
||||
blob the client throws away.) Q1 makes the dashboard *a window onto server-side work*. Two windows onto
|
||||
blob the client throws away.) Q1 makes the dashboard _a window onto server-side work_. Two windows onto
|
||||
the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap:
|
||||
consume the response that is already being computed.
|
||||
|
||||
@@ -815,22 +815,22 @@ rather than tidy. Not Tier A only because it is stable today and the failure req
|
||||
the derivation.
|
||||
|
||||
**B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was
|
||||
"the highest-value change here." Against *this* objective the priority is the opposite one: **closing a
|
||||
"the highest-value change here." Against _this_ objective the priority is the opposite one: **closing a
|
||||
chat panel must never destroy the agent.** §5 Q2 records the hazard precisely — the explicit `disconnect`
|
||||
path calls `clearClaudeSession`, which destroys the `sessionKey → claudeSessionId` pointer and orphans the
|
||||
transcript, whereas idle reaping deliberately does not. So what is wanted from `onClose` here is a
|
||||
*guarantee that nothing rides that path*, not an eager cleanup hook. Build the hook for the terminal by
|
||||
_guarantee that nothing rides that path_, not an eager cleanup hook. Build the hook for the terminal by
|
||||
all means; do not let a chat panel be wired into it without deciding that question first. A panel is a
|
||||
pointer, and closing a window should not delete what it points at.
|
||||
|
||||
**B3. `normalizeLayout` as framework, not convention.** (§5.4.) Matters for one consequence: a panel
|
||||
whose appType is allow-listed but no longer in the registry renders, on a `locked` screen, as an
|
||||
unrecoverable empty box. A chat panel in that state is a *visible* agent the human cannot reach — though
|
||||
unrecoverable empty box. A chat panel in that state is a _visible_ agent the human cannot reach — though
|
||||
note its peers still can, because the mechanism does not go through the browser. Real, but a display
|
||||
failure over a live agent rather than a lost one.
|
||||
|
||||
**B4. The mobile collapse decision.** (§6 of the todo, first item.) Genuinely undecided against this
|
||||
objective, and worth putting to the owner rather than guessing: *"I want to be able to sleep at night"*
|
||||
objective, and worth putting to the owner rather than guessing: _"I want to be able to sleep at night"_
|
||||
raises the obvious question of whether the 03:00 check-in happens on a phone. If yes, a user-created
|
||||
dashboard rendering only its left column forever is a Tier A problem wearing a mobile hat. If the answer
|
||||
is "I check on the laptop, and mobile web is being retired for the native app" — which is what
|
||||
@@ -846,12 +846,12 @@ this project.
|
||||
problem. It is not this one.
|
||||
- **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is
|
||||
unrelated to this path.
|
||||
- **§5.2, the remount table.** *The largest downgrade in this re-rank.* A remount used to threaten
|
||||
- **§5.2, the remount table.** _The largest downgrade in this re-rank._ A remount used to threaten
|
||||
whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs
|
||||
`resume-cursor` from its stored cursor and replays the durable log — it costs latency, and §2 declares
|
||||
latency free. Fix these for the interaction quality they are actually about; do not fix them for this.
|
||||
- **§5.3, drag-to-move.** *The second-largest downgrade, and it was on the critical path when the north
|
||||
star was written* (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things
|
||||
- **§5.3, drag-to-move.** _The second-largest downgrade, and it was on the critical path when the north
|
||||
star was written_ (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things
|
||||
disarmed it. Q3 made the **name** the address and the panel id merely where it currently lives; and
|
||||
`e588524` made `swapPanels`/`movePanel` carry `{appType, config}` as one unit, so the name travels with
|
||||
the panel. `useAgentPanel` resolves by name and re-anchors the row's `panelId` afterwards. The
|
||||
@@ -863,7 +863,7 @@ this project.
|
||||
writes rather than widen the diff. And §5.8 is not a prerequisite here; the mechanism never goes
|
||||
through a channel, because it never goes through the browser at all.
|
||||
|
||||
### 6.5 What the re-rank did *not* find, and that is the result
|
||||
### 6.5 What the re-rank did _not_ find, and that is the result
|
||||
|
||||
No defect in `workspace-panel-todo.md` blocked building the MVP. It was built, and it ran unattended, on
|
||||
the framework as it stands. The framework needed exactly one addition — per-panel config that survives a
|
||||
|
||||
@@ -9,7 +9,7 @@ A team of agents works on this project, sometimes several of them in the same re
|
||||
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
|
||||
having to remember to say so.
|
||||
|
||||
Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS
|
||||
Today it cannot. Every agent commits as the owner, because every agent _is_ the owner as far as the OS
|
||||
is concerned.
|
||||
|
||||
## How git identity can be overridden at all
|
||||
@@ -85,7 +85,7 @@ const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ..
|
||||
That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session
|
||||
guards, and nothing is added per turn.
|
||||
|
||||
**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there
|
||||
**This is the good news.** `env` is _already_ a per-`query()` option. It is built once today, but there
|
||||
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
|
||||
for everything below.
|
||||
|
||||
@@ -96,8 +96,8 @@ Almost none, and none of it at the OS level.
|
||||
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
|
||||
field on the pushed message.
|
||||
- **Agent name and persona are prompt-only.** `buildAgentPrompt`
|
||||
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user
|
||||
message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
|
||||
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the _first user
|
||||
message_. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
|
||||
- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)`
|
||||
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
|
||||
transcripts.
|
||||
@@ -147,7 +147,7 @@ API field. Neither is a small change, and this document does not propose one.
|
||||
|
||||
There is **no filesystem isolation** between agents. They share one real `HOME`
|
||||
(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this
|
||||
outright, and it is the stated reason `chat` is an `execution` capability that can never be granted.
|
||||
outright, and it is the stated reason `chat` is an `execution` permission that can never be granted.
|
||||
`grep -ril worktree src/` returns nothing — worktrees are used nowhere.
|
||||
|
||||
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
|
||||
|
||||
@@ -43,7 +43,7 @@ that is the sidecar running your agent. **It is not.**
|
||||
|
||||
```ts
|
||||
name: 'proxy',
|
||||
capabilities: ['proxy'],
|
||||
permissions: ['proxy'],
|
||||
```
|
||||
|
||||
and its entire job is four things (`index.ts:10-23`): take a PID lock, load state, ensure an Anthropic
|
||||
@@ -73,11 +73,11 @@ The credential path is in roughly the right place; the process topology is not.
|
||||
### Why the process dies — two independent mechanisms
|
||||
|
||||
1. **Process-tree kill.** PM2 signals the whole tree on restart, so the agent gets SIGINT even though
|
||||
nothing in Officer's code asks for it. *(Inferred from PM2's default `treekill: true`;
|
||||
nothing in Officer's code asks for it. _(Inferred from PM2's default `treekill: true`;
|
||||
`ecosystem.config.cjs` sets no `treekill` key, so the default applies. I did not test this in
|
||||
isolation.)*
|
||||
isolation.)_
|
||||
2. **Inherited stdio.** `sidecar-registry.ts:240-241` passes `stdout: 'inherit', stderr: 'inherit'`,
|
||||
so the agent writes into *officer's* PM2 log pipes. When officer restarts those pipes close, and
|
||||
so the agent writes into _officer's_ PM2 log pipes. When officer restarts those pipes close, and
|
||||
subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the
|
||||
parent.
|
||||
|
||||
@@ -85,7 +85,7 @@ Both must be fixed. Fixing only the signal leaves a process writing to a closed
|
||||
|
||||
### Also relevant: the transport direction is inverted
|
||||
|
||||
`user-instance.ts:19` dials *out* to officer:
|
||||
`user-instance.ts:19` dials _out_ to officer:
|
||||
|
||||
```ts
|
||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
||||
@@ -95,8 +95,8 @@ The agent sidecar is a **client** of officer, registering over `/api/sidecar/reg
|
||||
listener, reports no port. That is the exact inverse of the compliant sidecars (slskd, music, vault),
|
||||
which listen on a loopback port, report it on connect, and let officer forward to them.
|
||||
|
||||
This matters for survivability, not just tidiness: when officer restarts, a sidecar that *listens*
|
||||
just sits there with its work intact and waits to be forwarded to again. A sidecar that *dials in* has
|
||||
This matters for survivability, not just tidiness: when officer restarts, a sidecar that _listens_
|
||||
just sits there with its work intact and waits to be forwarded to again. A sidecar that _dials in_ has
|
||||
to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the
|
||||
meantime has nowhere to go.
|
||||
|
||||
@@ -107,13 +107,13 @@ process now survives. Does your session?
|
||||
|
||||
Not yet. Five things have to hold, and only some are about process lifetime:
|
||||
|
||||
| # | Requirement | Status today |
|
||||
|---|---|---|
|
||||
| R1 | The agent process is outside officer's process tree | **broken** — child of officer |
|
||||
| R2 | The agent's stdio does not belong to officer | **broken** — `'inherit'` |
|
||||
| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine** — `connect.ts` has a reconnect backoff table; not tested across a real restart |
|
||||
| R4 | Events emitted while officer is down are not lost | **broken** — see below |
|
||||
| R5 | The browser reconnects and asks for what it missed | **already works** — verified in Pass 2, see below |
|
||||
| # | Requirement | Status today |
|
||||
| --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||
| R1 | The agent process is outside officer's process tree | **broken** — child of officer |
|
||||
| R2 | The agent's stdio does not belong to officer | **broken** — `'inherit'` |
|
||||
| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine** — `connect.ts` has a reconnect backoff table; not tested across a real restart |
|
||||
| R4 | Events emitted while officer is down are not lost | **broken** — see below |
|
||||
| R5 | The browser reconnects and asks for what it missed | **already works** — verified in Pass 2, see below |
|
||||
|
||||
**R5 is done, and that is better news than I expected.** The frontend already auto-reconnects with
|
||||
backoff (`hooks/src/useChatWebSocket.ts:46-58`, `Math.min(5000, 300 * retry)`), already tracks a
|
||||
@@ -130,7 +130,7 @@ The backend half exists too (`chat/websocket.ts:612-629`, `getChatEventsSince`).
|
||||
"Disconnected" indicator in the UI (`ChatHistory/ChatDetailPanel.tsx:38-52`).
|
||||
|
||||
So **the sequence-and-replay protocol I was about to propose building already exists end to end.** The
|
||||
only thing wrong with it is *who writes the events*. That collapses Stage 2 below from "design a
|
||||
only thing wrong with it is _who writes the events_. That collapses Stage 2 below from "design a
|
||||
durable outbox" to "move the writer" — the single biggest simplification in this plan.
|
||||
|
||||
One gap to close while moving it: nothing verifies sequence continuity. `resume-cursor` is only sent
|
||||
@@ -175,7 +175,7 @@ With the data flow inverted to match slskd:
|
||||
`server.tsx:164-228` (dev-server) and `server.tsx:323-326` → `api/vault/websocket.ts` (vault).
|
||||
- The sidecar **writes its own events to Postgres** with a monotonic per-session sequence number. It
|
||||
already imports `officerdb` (`user-instance.ts:10`), so this is established precedent, not a new
|
||||
capability. Officer stops touching `chat_session_events` entirely.
|
||||
permission. Officer stops touching `chat_session_events` entirely.
|
||||
- On reconnect the browser sends `since=<seq>` and the **sidecar** answers the replay. Officer relays
|
||||
the question and the answer, and interprets neither.
|
||||
|
||||
@@ -208,14 +208,14 @@ The minimum fix for R1 + R2. Two routes, and I'd want your view on which:
|
||||
`ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines
|
||||
including the 50ms registration poll). Officer no longer spawns anything.
|
||||
|
||||
- *Pro:* correct, matches every other sidecar, PM2 restarts and logs it properly.
|
||||
- *Con:* the per-email spawn model has to go or change — see the open question below.
|
||||
- _Pro:_ correct, matches every other sidecar, PM2 restarts and logs it properly.
|
||||
- _Con:_ the per-email spawn model has to go or change — see the open question below.
|
||||
|
||||
**1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file,
|
||||
own process group.
|
||||
|
||||
- *Pro:* smallest diff, keeps lazy startup.
|
||||
- *Con:* leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state,
|
||||
- _Pro:_ smallest diff, keeps lazy startup.
|
||||
- _Con:_ leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state,
|
||||
but it might be a legitimate first step if you want the survivability today.
|
||||
|
||||
After this stage: the process survives, the socket reconnects, **but output produced during the
|
||||
@@ -265,7 +265,7 @@ reads its settings from. The whole chain — unauthenticated endpoint, sidecar `
|
||||
`panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the
|
||||
same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip.
|
||||
|
||||
### Stage 5 — the harder question: surviving a *sidecar* restart
|
||||
### Stage 5 — the harder question: surviving a _sidecar_ restart
|
||||
|
||||
Stages 1-4 make the agent survive an **officer** restart. They do not make it survive a restart of the
|
||||
agent sidecar itself — the agent process is that sidecar's child by design.
|
||||
@@ -286,9 +286,9 @@ is a real design decision and I don't have a confident recommendation.
|
||||
|
||||
1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The
|
||||
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
|
||||
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The permission model settled
|
||||
it in the _other_ direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `permissions/registry.ts`, which is **never grantable at any level**,
|
||||
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
|
||||
accounts exist now, and not one of them can ever open a chat.
|
||||
|
||||
@@ -299,7 +299,7 @@ is a real design decision and I don't have a confident recommendation.
|
||||
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
|
||||
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
||||
the browser a short-lived token and the browser connects to the sidecar directly (survives an
|
||||
officer restart *without even a reconnect*, but needs its own TLS/origin story and a second
|
||||
officer restart _without even a reconnect_, but needs its own TLS/origin story and a second
|
||||
exposed port). I lean relay — the reconnect is cheap once Stage 2 makes it lossless — but the
|
||||
direct path is the only one where you genuinely never notice.
|
||||
|
||||
@@ -329,7 +329,7 @@ is a real design decision and I don't have a confident recommendation.
|
||||
## Verified vs not
|
||||
|
||||
**Verified by reading the code or inspecting the running system:** my process ancestry; that
|
||||
`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn capability;
|
||||
`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn permission;
|
||||
that `user-instance.ts` has no PM2 entry and is spawned only at `sidecar-registry.ts:238` with
|
||||
inherited stdio; that it dials out rather than listening; that events leave via `connection.send`;
|
||||
that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the
|
||||
|
||||
+1
-1
@@ -117,7 +117,7 @@ worth serving both from one place.
|
||||
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
||||
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
|
||||
strategies) should be enabled and surfaced in the UI precisely so this is not confused.
|
||||
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
|
||||
- **It is not sharing.** Files is an `execution` permission — the owner's disk, never grantable — so
|
||||
there is still nobody to share with, whatever the account list says since 2026-08-07.
|
||||
|
||||
---
|
||||
|
||||
@@ -6,11 +6,11 @@ this file still exists. Email sync is deliberately NOT part of this any more —
|
||||
sidecar with its own scheduling, so it does not appear in the Jobs list.
|
||||
|
||||
**Goal:** every task run (script, pipeline, later agentic) becomes a persisted, background **job** —
|
||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
|
||||
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop _and_ phone,
|
||||
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
|
||||
|
||||
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
|
||||
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
|
||||
on 2026-08-07 — but because `tasks` is an `execution` permission: running a job means running a script
|
||||
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
|
||||
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
|
||||
Favor power-user affordances over guardrails.
|
||||
@@ -45,9 +45,10 @@ Favor power-user affordances over guardrails.
|
||||
## Plan
|
||||
|
||||
### Phase 1 — Unified jobs backend
|
||||
|
||||
- **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int)
|
||||
to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). *Table/symbol rename
|
||||
`pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working.*
|
||||
to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). _Table/symbol rename
|
||||
`pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working._
|
||||
- **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline`
|
||||
→ existing `executePipeline`; `script` → new `executeScript` (ports task-executor's
|
||||
`materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events +
|
||||
@@ -57,31 +58,35 @@ Favor power-user affordances over guardrails.
|
||||
startup so a queued backlog resumes.
|
||||
|
||||
### Phase 2 — REST job API (decouples creation from the socket; enables the phone)
|
||||
|
||||
- `POST /jobs {taskDirName, inputs, cwd, action}` → `{jobId}` (create + start/queue, background).
|
||||
- `GET /jobs` (+`?live=1`), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
|
||||
- Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list.
|
||||
|
||||
### Phase 3 — Frontend
|
||||
|
||||
- `/jobs/new` → `NewJobScreen`: reads query params, renders the input UI lifted from
|
||||
`TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the
|
||||
concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when
|
||||
idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator.
|
||||
|
||||
### Phase 4 — Notifications (later)
|
||||
|
||||
- One `notifyJobDone(job)` hook at finalize → push to the phone app.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB)
|
||||
- [x] 1b executeScript + manager dispatch — `execute-script.ts` (spawn/sandbox/killTree port, log file,
|
||||
abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now
|
||||
dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until
|
||||
a REST caller + restart exist.*
|
||||
dispatches by `mode` and finalizes script jobs by exit code. _Compiles; runtime-untested until
|
||||
a REST caller + restart exist._
|
||||
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
|
||||
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
|
||||
queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper.
|
||||
- [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`,
|
||||
now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
|
||||
Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.*
|
||||
Router mounted at `/jobs` and `/pipeline-jobs`. _Needs a restart to deploy; then curl/phone-testable._
|
||||
WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live).
|
||||
- [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done.
|
||||
- [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel
|
||||
@@ -93,8 +98,8 @@ Favor power-user affordances over guardrails.
|
||||
an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) →
|
||||
navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button
|
||||
(queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new`
|
||||
page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||
phone creates jobs directly via `POST /jobs`.)*
|
||||
page or FileBrowser change needed. _(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||
phone creates jobs directly via `POST /jobs`.)_
|
||||
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
|
||||
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
|
||||
`GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0.
|
||||
|
||||
@@ -139,7 +139,7 @@ will not meet it.
|
||||
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
|
||||
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |
|
||||
|
||||
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a
|
||||
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing permission into a
|
||||
logout loop they cannot escape, because signing in again produces a credential with the same 403.
|
||||
|
||||
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
|
||||
@@ -153,7 +153,7 @@ decides everything after.
|
||||
|
||||
- **The owner** (user 1) reaches everything.
|
||||
- **Any other account** reaches only what its role has been granted, and **can never** reach the
|
||||
`execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
||||
`execution` permissions — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
|
||||
user in the owner's home; they are refused structurally, not by policy.
|
||||
|
||||
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
|
||||
@@ -233,7 +233,7 @@ both, so the endpoint cannot be used to discover whether an id exists.
|
||||
|
||||
## Not built
|
||||
|
||||
- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check
|
||||
- **Scopes.** A key cannot be narrowed to a subset of its holder's permissions. The column and the check
|
||||
are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
|
||||
there today. Design as if every key is full-authority, because it is.
|
||||
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
|
||||
|
||||
@@ -60,7 +60,7 @@ speaks DAV.
|
||||
|
||||
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
|
||||
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
|
||||
capability, so a member can hold their own app passwords and their own collections.)
|
||||
permission, so a member can hold their own app passwords and their own collections.)
|
||||
- **Password** = a **DAV app password**, not the login password.
|
||||
|
||||
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
|
||||
@@ -106,7 +106,7 @@ row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's stor
|
||||
|
||||
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
|
||||
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
|
||||
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||
`calendar` permission, and a member's id is not 1. Derive it from `/auth/me` or from the collection
|
||||
paths; both work, and both stay correct when the caller is not the owner.
|
||||
|
||||
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
||||
|
||||
+75
-69
@@ -21,14 +21,13 @@
|
||||
> The rules in this file are current and authoritative; the findings table is a snapshot.
|
||||
>
|
||||
> **The runtime click-through has now happened** (2026-08-07, Playwright driving the system Brave against
|
||||
> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing *data*, not
|
||||
> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing _data_, not
|
||||
> regressions — the email account list and the Soulseek room list are both empty on this machine, so there
|
||||
> is nothing to click. Two further "failures" were the *test* being wrong, not the app: the Dock renders a
|
||||
> is nothing to click. Two further "failures" were the _test_ being wrong, not the app: the Dock renders a
|
||||
> user-pinned subset of 13 of 25 items, so `/plans` is absent by config; and `[data-sonner-toaster]` sits on
|
||||
> an inner `<ol>` that only exists while a toast is showing. **Suspect the instrument first.** Individual
|
||||
> "Needs runtime test" notes below may still be true — the sweep covered the routing claims, not every row.
|
||||
|
||||
|
||||
**Date:** 2026-07-30 · **Origin:** written as exploration before any of the routing work was done.
|
||||
|
||||
Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend
|
||||
@@ -43,6 +42,7 @@ imperative `navigate()` / global-channel setter **instead of a real `<Link to>`
|
||||
## The anti-pattern (definition)
|
||||
|
||||
A clickable element selects/opens something that has (or should have) a URL, but:
|
||||
|
||||
- **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure;
|
||||
- **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect);
|
||||
- **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL;
|
||||
@@ -50,7 +50,7 @@ A clickable element selects/opens something that has (or should have) a URL, but
|
||||
|
||||
**Exemplar (already fixed):** the `/chat` session list. Rows were `<button onClick={() => selectById(id)}>`
|
||||
(id only in the closure) → converted to `<Link to={`/chat/${session.id}`}>` (committed to master `f35c145`).
|
||||
That fix is the template for the HIGH items below. **Caveat:** the fix only did the *rows* — the chat
|
||||
That fix is the template for the HIGH items below. **Caveat:** the fix only did the _rows_ — the chat
|
||||
**detail panel** still selects via channel, not the URL (finding **C1**), so `/chat` is the model for both
|
||||
"done right" (rows) and "still to do" (detail).
|
||||
|
||||
@@ -72,7 +72,7 @@ was written.
|
||||
|
||||
**What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet),
|
||||
**UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>`
|
||||
wrapper — note: *not* react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
|
||||
wrapper — note: _not_ react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
|
||||
are good building blocks. The **Workspace/Panel framework** contains **zero** route navigation — it's orthogonal.
|
||||
|
||||
---
|
||||
@@ -81,13 +81,13 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
|
||||
### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth
|
||||
|
||||
| ID | file:line | Entity | Current impl | Fix |
|
||||
|----|-----------|--------|--------------|-----|
|
||||
| H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** |
|
||||
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>` → `useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
|
||||
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>` → `useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. |
|
||||
| H4 | `workspaces/…/apps/ChatHistory/ChatDetailPanel.tsx:136` | which chat to render | reads `usePanelChannel('chat:selected-session')`, **not** `useParams` | make the route the source of truth: read `sessionId` from `useParams`, fetch by id, retire `chat:selected-session` (or make it a derived cache). **Finishes the /chat fix.** |
|
||||
| ~~H5~~ | ~~`Screens/Dashboard/Email/EmailList.tsx:336`~~ | ~~an email~~ | ~~`<button data-email-id onClick={setSelectedId}>` + `EMAIL_SELECTED` channel + a state↔URL sync effect (`EmailScreen.tsx:27-33`)~~ | **Done.** Rows are `<Link to={emailPath(msg.id)}>`, list + reader + mobile-panel all read `useSelectedEmailId()` (`Email/shared.ts`), and `EMAIL_SELECTED` plus both sync effects are deleted. Arrow-key browsing navigates with `replace` so a sweep doesn't stack history. |
|
||||
| ID | file:line | Entity | Current impl | Fix |
|
||||
| ------ | ------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** |
|
||||
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>` → `useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
|
||||
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>` → `useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. |
|
||||
| H4 | `workspaces/…/apps/ChatHistory/ChatDetailPanel.tsx:136` | which chat to render | reads `usePanelChannel('chat:selected-session')`, **not** `useParams` | make the route the source of truth: read `sessionId` from `useParams`, fetch by id, retire `chat:selected-session` (or make it a derived cache). **Finishes the /chat fix.** |
|
||||
| ~~H5~~ | ~~`Screens/Dashboard/Email/EmailList.tsx:336`~~ | ~~an email~~ | ~~`<button data-email-id onClick={setSelectedId}>` + `EMAIL_SELECTED` channel + a state↔URL sync effect (`EmailScreen.tsx:27-33`)~~ | **Done.** Rows are `<Link to={emailPath(msg.id)}>`, list + reader + mobile-panel all read `useSelectedEmailId()` (`Email/shared.ts`), and `EMAIL_SELECTED` plus both sync effects are deleted. Arrow-key browsing navigates with `replace` so a sweep doesn't stack history. |
|
||||
|
||||
> ~~**Verify:**~~ **Verified.** Of the four `navigate('/…/:id')` calls flagged here, the two in
|
||||
> `ProjectPreview` are void (Projects was deleted on 2026-07-30) and one of the two in `DashboardPreview`
|
||||
@@ -96,18 +96,18 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
|
||||
|
||||
### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link)
|
||||
|
||||
| ID | file:line | Entity | Proposed route | Note |
|
||||
|----|-----------|--------|----------------|------|
|
||||
| ~~M1~~ | ~~`Screens/Dashboard/CapabilityPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
|
||||
| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. |
|
||||
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived *string*, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
|
||||
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is *ephemeral* (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder *items* stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
|
||||
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree *file* rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now *opens* it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span *nested inside* the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file *inside* a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
|
||||
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
|
||||
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
|
||||
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
|
||||
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
|
||||
| ~~M10~~ | `SystemMonitor/ScopeList.tsx` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | **Done.** Route pair + `Navigate` guard; the scope buttons are `NavLink`s and `useMonitorScope` reads `useParams` instead of the channel. The Dock's highlight survives the redirect — it was a `startsWith` then and is a `NavLink` ancestor match now, so the conclusion is unchanged. |
|
||||
| ID | file:line | Entity | Proposed route | Note |
|
||||
| ------- | ------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ~~M1~~ | ~~`Screens/Dashboard/PermissionPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
|
||||
| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. |
|
||||
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived _string_, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
|
||||
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is _ephemeral_ (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder _items_ stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
|
||||
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree _file_ rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now _opens_ it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span _nested inside_ the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file _inside_ a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
|
||||
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was _almost_ right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
|
||||
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
|
||||
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
|
||||
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
|
||||
| ~~M10~~ | `SystemMonitor/ScopeList.tsx` | monitor scope (btop/pm2/docker) | `/system-monitor/:scope` | **Done.** Route pair + `Navigate` guard; the scope buttons are `NavLink`s and `useMonitorScope` reads `useParams` instead of the channel. The Dock's highlight survives the redirect — it was a `startsWith` then and is a `NavLink` ancestor match now, so the conclusion is unchanged. |
|
||||
|
||||
**Music** (M-music) and **Soulseek** (M-slsk) are whole-workspace channel apps — pulled out below because each
|
||||
is **one design decision** that cascades across many files:
|
||||
@@ -134,16 +134,16 @@ is **one design decision** that cascades across many files:
|
||||
- ~~**Dock / Header active styling**~~ (`Dock.tsx` · `Header.tsx`) — **done.** Both are react-router
|
||||
`<NavLink>`s now and the two copies of `isActive` are gone, along with the `useLocation` each needed.
|
||||
One behavioural difference, deliberate: the hand-rolled version was a string `startsWith`, so `/task-logs`
|
||||
would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path *segment*, which
|
||||
would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path _segment_, which
|
||||
is what was meant. `end` is set for Home only — without it `NavLink` treats `/` as an ancestor of every
|
||||
route; with it on the others, a detail route (`/plans/x`, `/system-monitor/btop`) would lose its highlight.
|
||||
- ~~**Browser tabs**~~ (`Browser/TabList.tsx:93`) — **done, against this file's own advice.** The objection
|
||||
was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of *bookmarking*,
|
||||
was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of _bookmarking_,
|
||||
and irrelevant to everything else the URL buys: the id was in an onClick closure, three components read a
|
||||
`BROWSER_SELECTED_TAB` global, and the row could not be cmd-clicked. Staleness is handled where it
|
||||
actually shows up — the preview now distinguishes "no tab open" from "that tab is no longer attached"
|
||||
by checking the polled target list, which it gets from the same React Query key the list uses, so it
|
||||
costs no extra request. En route: the row's Focus and Close buttons were nested *inside* the row
|
||||
costs no extra request. En route: the row's Focus and Close buttons were nested _inside_ the row
|
||||
`<button>`, which is invalid HTML and only worked because of two `stopPropagation` calls; they are
|
||||
siblings of the anchor now. And its "Set up in Integrations" was a raw `<a href>` that reloaded the SPA.
|
||||
- **Jobs step/iteration** (`Jobs/JobDetail.tsx`) — **decided: skipped, and it is not an anti-pattern.**
|
||||
@@ -171,20 +171,20 @@ is **one design decision** that cascades across many files:
|
||||
Every place an **addressable entity** is selected through a global channel / global state instead of the URL.
|
||||
This is the primary surface to convert to URL-driven selection.
|
||||
|
||||
| Channel / global key | Entity held | Should map to | Files |
|
||||
|----------------------|-------------|---------------|-------|
|
||||
| `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) |
|
||||
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
|
||||
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
|
||||
| ~~`SELECTED_PROJECT` (`useGlobal`)~~ | ~~selected project~~ | — | **void.** Projects was deleted 2026-07-30; H3 was a finding about code that no longer exists |
|
||||
| ~~`EMAIL_FOLDER` (`useGlobal`)~~ | mailbox folder | `/email?folder=` | **done** — `EmailList.tsx`. Not in the original map; found by sweeping for `useGlobal<` after the listed rows were closed, which is the only reason it is here |
|
||||
| ~~local `useState` selections~~ | search / room / conversation / peer / open file / preview slug | respective nested routes | **done** — `SearchView` (`?search=`), `SoulseekRooms` (`?room=`), `SoulseekChat` (`?peer=`), `SoulseekUsers` (`?user=`), `CodeEditor/useEditorState` (`?file=`). "Preview slug" named `PreviewProvider`, which is not in the repo — the finding was wrong |
|
||||
| Channel / global key | Entity held | Should map to | Files |
|
||||
| -------------------------------------- | -------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) |
|
||||
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
|
||||
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
|
||||
| ~~`SELECTED_PROJECT` (`useGlobal`)~~ | ~~selected project~~ | — | **void.** Projects was deleted 2026-07-30; H3 was a finding about code that no longer exists |
|
||||
| ~~`EMAIL_FOLDER` (`useGlobal`)~~ | mailbox folder | `/email?folder=` | **done** — `EmailList.tsx`. Not in the original map; found by sweeping for `useGlobal<` after the listed rows were closed, which is the only reason it is here |
|
||||
| ~~local `useState` selections~~ | search / room / conversation / peer / open file / preview slug | respective nested routes | **done** — `SearchView` (`?search=`), `SoulseekRooms` (`?room=`), `SoulseekChat` (`?peer=`), `SoulseekUsers` (`?user=`), `CodeEditor/useEditorState` (`?file=`). "Preview slug" named `PreviewProvider`, which is not in the repo — the finding was wrong |
|
||||
|
||||
**Excluded — event-bus / refresh signals, NOT selection:** `files:refresh-signal`,
|
||||
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`.
|
||||
(`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer
|
||||
reads `?view=` from the URL. `preview:refresh` and `chat:active-session` were also listed, and
|
||||
`preview:refresh` was cited above as the exemplar of a *legitimate* channel — but both have a publisher
|
||||
`preview:refresh` was cited above as the exemplar of a _legitimate_ channel — but both have a publisher
|
||||
in `ChatPanelWrapper` and **no subscriber at all**, and `preview:refresh`'s reader, `PreviewProvider`, is
|
||||
no longer in the repo. They are declared in `officerdev/src/channels.ts` with that stated; deleting the
|
||||
publishers means changing the chat panel, which is another agent's, so it is written up in
|
||||
@@ -196,33 +196,33 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
|
||||
"Link?" = a `<Link>`/`<NavLink>` is the right refactor.
|
||||
|
||||
| # | file:line | what | target | Link? | note |
|
||||
|---|-----------|------|--------|-------|------|
|
||||
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
|
||||
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
|
||||
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
|
||||
| ~~4~~ | `Dashboards/DashboardPreview.tsx` (create) | open dashboard | `/dashboards/:id` | no | **stays.** post-create redirect — a link cannot write the row first |
|
||||
| ~~5~~ | `Dashboards/DashboardPreview.tsx` (edit form) | open dashboard | `/dashboards/:id` | YES | **done** — `<Button asChild><Link>` |
|
||||
| 6 | `Projects/ProjectListApp.tsx:116` | project row (off-page) | `/projects/:id` | YES | H3 |
|
||||
| 7 | `Projects/ProjectListApp.tsx:142` | inside "New Project" | `/projects` | ~ | create action |
|
||||
| ~~8~~ | ~~`Projects/ProjectPreview.tsx:430`~~ | — | — | — | void: Projects deleted 2026-07-30 |
|
||||
| ~~9~~ | ~~`Projects/ProjectPreview.tsx:457`~~ | — | — | — | void: Projects deleted 2026-07-30 |
|
||||
| ~~10~~ | `Jobs/JobDetail.tsx` | back button | `/jobs` | YES | **done** — `<Link>`; `BackButton` was dead and is deleted |
|
||||
| 11 | `ChatHistory/SessionList.tsx:70` | "New Chat" | `/chat/new` | ~ | borderline (LOW) |
|
||||
| ~~12~~ | ~~`components/Combobox.tsx:54`~~ | nav combobox option | `option.href` | — | M7 — file deleted, it had no callers |
|
||||
| ~~13~~ | `MusicPlayer/MusicPlayerHost.tsx:251` | open current album (setCwd+nav) | `/music` | ~ | **Done** — the side-effect was the `setCwd`; with the location in the URL the tile is a plain `<Link>` to `musicPath(albumRel)`. |
|
||||
| 14 | `FileBrowserWidget/useFileBrowserWidget.ts:53` | open folder in Files | `/files?view=…` | ~ | query nav |
|
||||
| 15 | `FileBrowserApp/useFileBrowserApp.ts:401` | create dashboard from folder | `/dashboards/new?…` | ~ | action-nav |
|
||||
| 16 | `FileBrowserApp/useFileBrowserApp.ts:427` | create dashboard from files | `/dashboards/new?…` | ~ | action-nav |
|
||||
| 17 | `FileBrowserApp/.../TaskRunnerModal.tsx:1168` | after starting job | `/jobs` `/jobs/:id` | NO | post-submit |
|
||||
| 18 | `FileBrowserApp/.../TaskRunnerModal.tsx:1649` | after starting pipeline | `/jobs/:id` | NO | post-submit |
|
||||
| ~~19~~ | ~~`Email/EmailScreen.tsx:29`~~ | ~~sync selection → URL (effect)~~ | — | — | deleted (H5) |
|
||||
| ~~20~~ | ~~`Email/EmailScreen.tsx:31`~~ | ~~clear selection → URL (effect)~~ | — | — | deleted (H5); the mobile Back button navigates to `/email` instead |
|
||||
| 21 | `ChatHistory/index.tsx:107` | redirect when no id | `/chat` | NO | guard |
|
||||
| 22 | `Projects/ProjectListScreen.tsx:66` | clear/redirect | `/projects` | NO | guard |
|
||||
| 23 | `Authentication/ForgotPassword/useResetPassword.ts:48` | post-reset redirect | `/` | NO | action result |
|
||||
| 24 | `Authentication/LandingPage/Bootstrap.tsx:34` | post-bootstrap redirect | `/` | NO | action result |
|
||||
| 25 | `hooks/useQueryState/useQueryState.ts:39` | generic query-state writer | dynamic | NO | infra hook |
|
||||
| # | file:line | what | target | Link? | note |
|
||||
| ------ | ------------------------------------------------------ | ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
|
||||
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
|
||||
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
|
||||
| ~~4~~ | `Dashboards/DashboardPreview.tsx` (create) | open dashboard | `/dashboards/:id` | no | **stays.** post-create redirect — a link cannot write the row first |
|
||||
| ~~5~~ | `Dashboards/DashboardPreview.tsx` (edit form) | open dashboard | `/dashboards/:id` | YES | **done** — `<Button asChild><Link>` |
|
||||
| 6 | `Projects/ProjectListApp.tsx:116` | project row (off-page) | `/projects/:id` | YES | H3 |
|
||||
| 7 | `Projects/ProjectListApp.tsx:142` | inside "New Project" | `/projects` | ~ | create action |
|
||||
| ~~8~~ | ~~`Projects/ProjectPreview.tsx:430`~~ | — | — | — | void: Projects deleted 2026-07-30 |
|
||||
| ~~9~~ | ~~`Projects/ProjectPreview.tsx:457`~~ | — | — | — | void: Projects deleted 2026-07-30 |
|
||||
| ~~10~~ | `Jobs/JobDetail.tsx` | back button | `/jobs` | YES | **done** — `<Link>`; `BackButton` was dead and is deleted |
|
||||
| 11 | `ChatHistory/SessionList.tsx:70` | "New Chat" | `/chat/new` | ~ | borderline (LOW) |
|
||||
| ~~12~~ | ~~`components/Combobox.tsx:54`~~ | nav combobox option | `option.href` | — | M7 — file deleted, it had no callers |
|
||||
| ~~13~~ | `MusicPlayer/MusicPlayerHost.tsx:251` | open current album (setCwd+nav) | `/music` | ~ | **Done** — the side-effect was the `setCwd`; with the location in the URL the tile is a plain `<Link>` to `musicPath(albumRel)`. |
|
||||
| 14 | `FileBrowserWidget/useFileBrowserWidget.ts:53` | open folder in Files | `/files?view=…` | ~ | query nav |
|
||||
| 15 | `FileBrowserApp/useFileBrowserApp.ts:401` | create dashboard from folder | `/dashboards/new?…` | ~ | action-nav |
|
||||
| 16 | `FileBrowserApp/useFileBrowserApp.ts:427` | create dashboard from files | `/dashboards/new?…` | ~ | action-nav |
|
||||
| 17 | `FileBrowserApp/.../TaskRunnerModal.tsx:1168` | after starting job | `/jobs` `/jobs/:id` | NO | post-submit |
|
||||
| 18 | `FileBrowserApp/.../TaskRunnerModal.tsx:1649` | after starting pipeline | `/jobs/:id` | NO | post-submit |
|
||||
| ~~19~~ | ~~`Email/EmailScreen.tsx:29`~~ | ~~sync selection → URL (effect)~~ | — | — | deleted (H5) |
|
||||
| ~~20~~ | ~~`Email/EmailScreen.tsx:31`~~ | ~~clear selection → URL (effect)~~ | — | — | deleted (H5); the mobile Back button navigates to `/email` instead |
|
||||
| 21 | `ChatHistory/index.tsx:107` | redirect when no id | `/chat` | NO | guard |
|
||||
| 22 | `Projects/ProjectListScreen.tsx:66` | clear/redirect | `/projects` | NO | guard |
|
||||
| 23 | `Authentication/ForgotPassword/useResetPassword.ts:48` | post-reset redirect | `/` | NO | action result |
|
||||
| 24 | `Authentication/LandingPage/Bootstrap.tsx:34` | post-bootstrap redirect | `/` | NO | action result |
|
||||
| 25 | `hooks/useQueryState/useQueryState.ts:39` | generic query-state writer | dynamic | NO | infra hook |
|
||||
|
||||
---
|
||||
|
||||
@@ -230,7 +230,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
|
||||
> **Progress — 2026-07-30, branch `navigation-refactor` (off master; NOT yet runtime-tested):**
|
||||
> H1, H2, H3 implemented and tsgo-clean. **Design correction for H2/H3:** the naive "row → `<Link to="/dashboards/:id">`"
|
||||
> would destroy the *preview-on-list* feature (that route is the full page). The faithful fix — which is what
|
||||
> would destroy the _preview-on-list_ feature (that route is the full page). The faithful fix — which is what
|
||||
> was implemented — moves selection out of the `SELECTED_*` global into a **`?selected=<id>` URL param** read by
|
||||
> the list, the screen (mobile panel), and the preview; rows are real `<Link>`s (`/…?selected=id` on-page,
|
||||
> `/…/:id` off-page) with the action buttons kept as **siblings** of the anchor, not nested inside it. Same
|
||||
@@ -238,10 +238,11 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
> create/edit/delete(/publish), and mobile-panel flows on `/dashboards` and `/projects`.
|
||||
|
||||
### Phase 1 — Quick wins (routes already exist; mechanical, high value)
|
||||
|
||||
- [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.)
|
||||
- [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.)
|
||||
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
|
||||
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.*
|
||||
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. _Deferred: overlaps the in-flight `sidecars-_` chat-comms work; do after that lands.\*
|
||||
- [x] **H5** Email rows → `<Link>` driven by `useParams().emailId`; the `EMAIL_SELECTED` global and both state↔URL sync effects are gone. **Needs runtime test.** (`EMAIL_FOLDER` stays a `useGlobal` for now — it is read in one component and is view state, not selection; putting the folder in `?folder=` is a separate, smaller item.)
|
||||
- [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.**
|
||||
- [x] Verified + converted the preview "open" navigates. Four were listed; **one** was real. The two
|
||||
@@ -250,24 +251,27 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
post-mutation redirect and stays; only the "Open Dashboard" button in the edit form was pure
|
||||
navigation, and it is now `<Button asChild><Link …>`. The big click-through overlay on the preview
|
||||
was already a `<Link>`. `DashboardListApp`'s "New Dashboard" also stays a button: it sets six pieces
|
||||
of form state and only *then* conditionally navigates.
|
||||
of form state and only _then_ conditionally navigates.
|
||||
|
||||
### Phase 2 — Add a route, then link (per-entity, medium effort)
|
||||
|
||||
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
|
||||
- [x] **M1** Capabilities → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `CapabilityPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
|
||||
- [x] **M1** Permissions → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `PermissionPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
|
||||
- [x] **M2** TaskLogs → `/task-logs/:id`; rows are `<Link>`s, `showDetail` deleted. **Needs runtime test.**
|
||||
- [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.**
|
||||
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder *rows* deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
|
||||
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab *set* stays local state, on purpose — see the findings row.
|
||||
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder _rows_ deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
|
||||
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab _set_ stays local state, on purpose — see the findings row.
|
||||
- [x] **M7** Combobox — **deleted instead**. Zero callers since the initial commit; `href` on `SelectOption` was never set by anything, so the whole branch was unreachable.
|
||||
|
||||
### Phase 3 — Whole-workspace routing decisions (needs a design call first)
|
||||
|
||||
- [x] **Music** — `/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
|
||||
- [x] **Soulseek** — `/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
|
||||
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
|
||||
- [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.**
|
||||
|
||||
### Phase 4 — Polish + borderline decisions
|
||||
|
||||
- [x] Dock + Header + mobile sheet → react-router `<NavLink>`; both `isActive` helpers and their `useLocation`s deleted. `end` on Home only. **Needs runtime test.**
|
||||
- [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands.
|
||||
- [x] Jobs back button → `<Link to="/jobs">`, and `useNavigate` dropped from `PipelineJobDetail` (it had no
|
||||
@@ -279,6 +283,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
- [x] Decided/skipped: Jobs step deep-link (a feature, not a fix — owner's call), Preview slug (**void**: no such app), FileBrowser widget (stays local, on M4's rule). Reasoning for each in the LOW section. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle. Music favorites: **decided** — stays a channel, reasoning in the LOW section.)
|
||||
|
||||
### Cross-cutting for the refactor itself
|
||||
|
||||
- [x] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
|
||||
selection channels in the map above with `useParams`/`useSearchParams`, keeping channels only for
|
||||
genuine signals/refresh buses). Done except `chat:selected-session` (H4), which is the chat agent's.
|
||||
@@ -332,6 +337,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
|
||||
unimplemented. **The general lesson: cross-app intent that is not pure navigation should not be
|
||||
encoded as a URL.** Creating a dashboard is five ordered state writes; expressing that as a link was
|
||||
what made it silently breakable in the first place. See the status note §24 for the full write-up.
|
||||
|
||||
- [x] Adopt `<NavLink>` (real react-router) for all nav chrome so active state stops being JS-derived.
|
||||
Done — `39125b5`. Note `end={item.to === '/'}`: without it NavLink treats `/` as an ancestor of
|
||||
every route, and with it on the rest a detail route would lose its tile.
|
||||
|
||||
@@ -136,7 +136,7 @@ account manager in recoverable form, and synced to whatever backs that phone up.
|
||||
device, revocable per device, is the whole point.
|
||||
|
||||
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
||||
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
|
||||
since 2026-08-07 `calendar` is a **grantable** permission, so an app password can belong to a member
|
||||
and the column decides whose collection tree Radicale serves. It is load-bearing.
|
||||
|
||||
---
|
||||
|
||||
@@ -25,11 +25,11 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
|
||||
**The two surfaces are mutually blind.** Measured, both directions, on a session created via `/api` and
|
||||
run to completion with a real model reply:
|
||||
|
||||
| read | api-created session | legacy-created session |
|
||||
|---|---|---|
|
||||
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
||||
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
||||
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
||||
| read | api-created session | legacy-created session |
|
||||
| ------------------------------------------ | ---------------------- | ---------------------- |
|
||||
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
||||
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
||||
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
||||
|
||||
So the row appears in the list with its title and directory, and opens with nothing in it. And the
|
||||
inverse is equally true: switching the reader to `/api` without keeping the old one would empty every
|
||||
@@ -42,7 +42,7 @@ rather than erroring.
|
||||
### 1b. The session list silently truncates at 50
|
||||
|
||||
`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in
|
||||
the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200`
|
||||
the store the list returns 50 _and still offers a next cursor_; adding a 51st and asking `?limit=200`
|
||||
returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100
|
||||
with `Expected a value less than or equal to 100`).
|
||||
|
||||
@@ -61,21 +61,21 @@ Pushing the filter down fixes the normal case and brings `search=`, `order=`, `p
|
||||
There is no version string "2.0" in the running server. `GET /doc` self-reports
|
||||
`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists:
|
||||
|
||||
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
||||
|---|---|---|---|
|
||||
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
||||
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
||||
| operationIds | `session.list` | **`v2.session.list`** | — |
|
||||
| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all |
|
||||
| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs |
|
||||
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
||||
| ------------ | --------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
|
||||
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
||||
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
||||
| operationIds | `session.list` | **`v2.session.list`** | — |
|
||||
| we use it | reads: transcript, delete, rename | writes: every turn since 2026-08-10 | not at all |
|
||||
| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs |
|
||||
|
||||
So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its
|
||||
operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*.
|
||||
operation ids are literally `v2.*`. It is not something to adopt; it is something to _finish_.
|
||||
|
||||
Two qualifications, both from the source at tag `v1.18.16`:
|
||||
|
||||
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
|
||||
version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with
|
||||
version `"0.0.1"`, described as _"Experimental HttpApi surface for selected instance routes"_, with
|
||||
every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually
|
||||
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
|
||||
nil.
|
||||
@@ -88,15 +88,15 @@ Two qualifications, both from the source at tag `v1.18.16`:
|
||||
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to
|
||||
change wholesale.
|
||||
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."* Both halves of today's naming are transitional.
|
||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: _"V1 coexistence is temporary… delete the
|
||||
V1 subtree when the legacy runtime is retired"_ and _"Do not preserve `V2` as the permanent name for the
|
||||
replacement architecture."_ Both halves of today's naming are transitional.
|
||||
|
||||
**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs
|
||||
carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
|
||||
may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes
|
||||
(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must
|
||||
migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere.
|
||||
carry the banner _"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
|
||||
may change"_, releases ship ~6/day, and the migration guide states three intentional breaking changes
|
||||
(plugin API, server API contracts, TUI config), with _"Integrations that call the V1 server API must
|
||||
migrate to the V2 API"_. No deprecation date for the legacy surface is published anywhere.
|
||||
|
||||
Two facts worth knowing regardless:
|
||||
|
||||
@@ -130,7 +130,7 @@ plus `GET /config/providers` for the model list (`list-models.ts:58`). The one e
|
||||
|
||||
### 4a. Adding context to a turn that is already running
|
||||
|
||||
The capability the subprocess path could never have, and the reason the migration happened.
|
||||
The permission the subprocess path could never have, and the reason the migration happened.
|
||||
|
||||
```
|
||||
POST /api/session/{id}/prompt
|
||||
@@ -139,12 +139,12 @@ POST /api/session/{id}/prompt
|
||||
"delivery": "steer" | "queue", "resume": true|false }
|
||||
```
|
||||
|
||||
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||
false."*
|
||||
Spec description: _"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||
false."_
|
||||
|
||||
- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the
|
||||
work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but
|
||||
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for
|
||||
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI _asks_ for
|
||||
it, and nothing distinguishes "add this to what you're doing" from "here's my next message".
|
||||
- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field
|
||||
defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`).
|
||||
@@ -188,13 +188,13 @@ that matter for building on it:
|
||||
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
|
||||
Omitting it replays the session from 0.
|
||||
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
|
||||
advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a
|
||||
advances its cursor to the last row, and on every wake re-reads _the database_ rather than draining a
|
||||
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
|
||||
`Sequence mismatch` / `Replay diverged` errors.
|
||||
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||
|
||||
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor
|
||||
Note the two cursor kinds are unrelated: the session _list_ uses an opaque base64url cursor
|
||||
(`cursor.previous` / `cursor.next`), this one is a plain integer.
|
||||
|
||||
**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a
|
||||
@@ -233,14 +233,14 @@ The two "v2"s are not the same kind of change, which matters if we implement one
|
||||
`{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
|
||||
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
|
||||
`source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public
|
||||
V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."*
|
||||
V2 docs say the same in config terms: _"Do not use `permission`, `bash`, or `task` in V2
|
||||
configuration."_
|
||||
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
|
||||
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
|
||||
event names changed.
|
||||
|
||||
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
|
||||
`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest
|
||||
`/api` protocol is _built_ from excludes the v1 families, but the server wires the **full** manifest
|
||||
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
|
||||
union on our own `/doc`.
|
||||
|
||||
@@ -276,17 +276,17 @@ Our mapper recognises 18 names and maps 7. The server emits **130 event type str
|
||||
`session.next.*` family plus eight plain `session.*` (`idle`, `status`, `error`, `compacted`,
|
||||
`created`, `deleted`, `updated`, `diff`).
|
||||
|
||||
| dropped | what it would give |
|
||||
|---|---|
|
||||
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
|
||||
| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
|
||||
| `tool.progress` | long tools reporting instead of appearing hung |
|
||||
| `shell.started/ended` | shell commands as a first-class thing |
|
||||
| `compaction.*` | telling the user the context was compacted |
|
||||
| `revert.*` | §4e |
|
||||
| `retried` | a retry that currently looks like a stall |
|
||||
| `prompt.admitted` / `prompted` | acknowledgement — the exact window where silence has twice cost an afternoon |
|
||||
| `session.idle` | the real turn-end signal (see below) |
|
||||
| dropped | what it would give |
|
||||
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
|
||||
| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
|
||||
| `tool.progress` | long tools reporting instead of appearing hung |
|
||||
| `shell.started/ended` | shell commands as a first-class thing |
|
||||
| `compaction.*` | telling the user the context was compacted |
|
||||
| `revert.*` | §4e |
|
||||
| `retried` | a retry that currently looks like a stall |
|
||||
| `prompt.admitted` / `prompted` | acknowledgement — the exact window where silence has twice cost an afternoon |
|
||||
| `session.idle` | the real turn-end signal (see below) |
|
||||
|
||||
We end a turn on `step.ended` with `finish !== 'tool-calls'` (`serve-runner.ts:139`), because there is
|
||||
no turn-ended event in what we read. `session.idle` looks like what that rule approximates, and it is
|
||||
@@ -318,7 +318,7 @@ with no model, against a serve with no configured default, hangs silently.** Wor
|
||||
- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what
|
||||
`opencode-sessions.ts:81` parses. `/api` items are
|
||||
`{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}],
|
||||
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||
- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no
|
||||
comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream
|
||||
must resume at `?after=<last seq>`, not restart.
|
||||
@@ -352,7 +352,9 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
||||
const client = createOpencodeClient({ baseUrl });
|
||||
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
||||
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
|
||||
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ }
|
||||
for await (const ev of events.stream) {
|
||||
/* ev.type, ev.durable.seq */
|
||||
}
|
||||
```
|
||||
|
||||
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
|
||||
@@ -402,7 +404,7 @@ Then the two that are real features needing UI: **permissions/questions** (§4d)
|
||||
- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes**
|
||||
`/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on
|
||||
— replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path,
|
||||
`experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function.
|
||||
`experimental/` prefix. So item 6 is worth doing _and_ worth writing behind one function.
|
||||
|
||||
---
|
||||
|
||||
@@ -449,7 +451,7 @@ was published hours before this file was written.
|
||||
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
||||
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
||||
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||
(the renames).
|
||||
- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`.
|
||||
- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`,
|
||||
|
||||
@@ -86,13 +86,13 @@ Also fixed after the review, and not in this table because it was found by revie
|
||||
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
||||
`docs/opencode-phase1-review.md`.
|
||||
|
||||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly*
|
||||
is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does not — and most of the
|
||||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave _wrongly_
|
||||
is gone. What remains is bucket 1 — permissions Claude has and OpenCode does not — and most of the
|
||||
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||||
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
||||
|
||||
**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering
|
||||
those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||||
those permissions natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
|
||||
verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable
|
||||
per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface
|
||||
(it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open
|
||||
@@ -102,7 +102,7 @@ passed that one model.
|
||||
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
|
||||
1.18.16.
|
||||
|
||||
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries
|
||||
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` permission and carries
|
||||
`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` /
|
||||
`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode
|
||||
against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar
|
||||
@@ -110,7 +110,7 @@ restart stops in-flight turns and writes the reason to `chat_session_events`, an
|
||||
enumerates what is running.
|
||||
|
||||
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
|
||||
and `opencode` receive no identity and are covered today only because those capabilities are owner-only —
|
||||
and `opencode` receive no identity and are covered today only because those permissions are owner-only —
|
||||
"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses
|
||||
to share at any level, so this cannot be reached by a member. It is latent by construction.
|
||||
|
||||
@@ -123,7 +123,7 @@ something nothing renders. Left alone deliberately.
|
||||
put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path
|
||||
carries them today: the sidecar spills each image to a temp file for the turn and removes it in
|
||||
`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back
|
||||
"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded
|
||||
"Red". `list-models` now reports each model's own `permissions.input.image` instead of a hardcoded
|
||||
`false`, so the composer gate became load-bearing in the right direction.
|
||||
|
||||
---
|
||||
@@ -132,7 +132,7 @@ carries them today: the sidecar spills each image to a temp file for the turn an
|
||||
|
||||
Ordered roughly by user-visible value.
|
||||
|
||||
| Capability | Claude | OpenCode | Depends on the fork? |
|
||||
| Permission | Claude | OpenCode | Depends on the fork? |
|
||||
| --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- |
|
||||
| Token streaming | `delta` events from `stream_event` | **No** — `run` emits complete text parts (`runner.ts:176-177`) | **Yes** |
|
||||
| Mid-turn injection / queue-into-turn | streaming input queue | **No** — `stdin: 'ignore'` | **Yes** |
|
||||
|
||||
@@ -36,7 +36,7 @@ for a follow-up that touches the socket contract, is the right split.
|
||||
`013e629` flipped `images: true` → `false` for OpenCode models, and the commit says "61 OpenCode models
|
||||
now decline, the three Claude ones still accept".
|
||||
|
||||
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that capability — the composer's
|
||||
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that permission — the composer's
|
||||
image affordances are ungated. Grep for a consumer of the model's `images` field returns nothing:
|
||||
`InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and
|
||||
`useAttachments` collects them regardless.
|
||||
@@ -47,14 +47,14 @@ which is worth having, but B4 described a user-visible lie and that lie is still
|
||||
|
||||
Two ways to close it, and they are not equivalent:
|
||||
|
||||
1. **Gate the composer on the capability.** Read the selected model's `images` flag and hide the drop
|
||||
1. **Gate the composer on the permission.** Read the selected model's `images` flag and hide the drop
|
||||
zone, the paste path and the attach-image button when it is false. Cheap. Makes the flag load-bearing,
|
||||
so the flip in `013e629` starts doing something.
|
||||
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
|
||||
surfacing it.
|
||||
|
||||
(1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2)
|
||||
happens later — the gate simply stops firing once the capability is true.
|
||||
happens later — the gate simply stops firing once the permission is true.
|
||||
|
||||
---
|
||||
|
||||
@@ -99,7 +99,7 @@ finishes and had no effect on either test. Worth knowing it exists; not worth ch
|
||||
|
||||
## Suggested next work, in order
|
||||
|
||||
1. **B4 properly** — gate the composer on the model's `images` capability (above).
|
||||
1. **B4 properly** — gate the composer on the model's `images` permission (above).
|
||||
2. **Delete the `AGENTS.md` injection and the stale one-project comment**, now that `--dir` is verified.
|
||||
This is Phase 1 work and it is the thing Andre most wanted gone.
|
||||
3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in
|
||||
|
||||
@@ -17,7 +17,7 @@ it_ — earned its place three separate times, detailed below.
|
||||
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
|
||||
| `492509a` | B2 — route a resumed OpenCode session to OpenCode |
|
||||
| `013e629` | B4 (first attempt), B5, B6, thinking selector |
|
||||
| `7774a25` | B4 properly — gate the composer on the capability |
|
||||
| `7774a25` | B4 properly — gate the composer on the permission |
|
||||
| `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment |
|
||||
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
|
||||
| `8b409e8` | Phase 1 finish — stale comments, version pin, first tests |
|
||||
@@ -91,8 +91,8 @@ conclusion independently, which was reassuring to read afterwards.)
|
||||
|
||||
**B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip
|
||||
as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because
|
||||
no code read the capability. The composer now gates on it — drop zone, paste path, attach menu — so the
|
||||
flag is load-bearing. Unknown model still allows images: a missing capability should not remove a
|
||||
no code read the permission. The composer now gates on it — drop zone, paste path, attach menu — so the
|
||||
flag is load-bearing. Unknown model still allows images: a missing permission should not remove a
|
||||
working control.
|
||||
|
||||
**Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still
|
||||
@@ -135,7 +135,7 @@ Testing was explicitly de-prioritised for this pass, so these are recorded rathe
|
||||
|
||||
## Suggested next, if you are writing the following spec
|
||||
|
||||
1. **Exercise `opencode:list`** — it is the only new capability whose happy path is unproven.
|
||||
1. **Exercise `opencode:list`** — it is the only new permission whose happy path is unproven.
|
||||
2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not
|
||||
yet", say so in the parity doc so it stops reading as pending work.
|
||||
3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and
|
||||
|
||||
@@ -15,7 +15,7 @@ works, it is verified end to end, and its limits are all consequences of that on
|
||||
|
||||
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
|
||||
|
||||
| Capability | How | Verified |
|
||||
| Permission | How | Verified |
|
||||
| ------------------------ | --------------------------------------------------- | --------------------------------------------------- |
|
||||
| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn |
|
||||
| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors |
|
||||
@@ -86,7 +86,7 @@ written and tested, and it is the only phase with no user-visible risk.
|
||||
`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the
|
||||
known-good path. The switch is the deliverable, not a detail.
|
||||
|
||||
**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now"
|
||||
**Phase C — the permissions that motivated it.** `delivery: "steer"` wired to the existing "send now"
|
||||
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
|
||||
and they are cheap once B holds.
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ behind what some machines run.
|
||||
|
||||
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
|
||||
|
||||
- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a
|
||||
- Unblocks the whole of parity Phase 3 at once — those six permissions are all downstream of a
|
||||
persistent, addressable session.
|
||||
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
|
||||
exists in the deleted code, so the cost is smaller than it looks.
|
||||
|
||||
@@ -5,11 +5,11 @@ Agents are explicitly out of scope for the first pass.
|
||||
|
||||
## What this is for
|
||||
|
||||
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
||||
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them
|
||||
Today every `execution` permission — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
||||
**owner's OS user in the owner's home**. That is why `permissions/registry.ts` declares them
|
||||
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
|
||||
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a
|
||||
project, not a checkbox."*
|
||||
The registry says so out loud: _"revisit only if per-user home confinement is ever solved — and that is a
|
||||
project, not a checkbox."_
|
||||
|
||||
This is that project. A member gets a real Linux account whose home is the directory the platform already
|
||||
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
|
||||
@@ -63,22 +63,22 @@ code has ever had for a non-owner home.
|
||||
|
||||
On this machine, verified 2026-08-11:
|
||||
|
||||
| path | mode | consequence |
|
||||
| --- | --- | --- |
|
||||
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
||||
| `…/officer.dev` | 775 | listable by anyone |
|
||||
| `…/platform/.env` | **664** | **world-readable** |
|
||||
| path | mode | consequence |
|
||||
| ----------------- | ------- | ---------------------------------- |
|
||||
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
||||
| `…/officer.dev` | 775 | listable by anyone |
|
||||
| `…/platform/.env` | **664** | **world-readable** |
|
||||
|
||||
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
|
||||
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
|
||||
not doing it — the capability model would be intact and completely bypassed.
|
||||
not doing it — the permission model would be intact and completely bypassed.
|
||||
|
||||
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
|
||||
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
|
||||
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
|
||||
prerequisite that gets skipped.
|
||||
|
||||
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the
|
||||
The same applies to `permissions/` (775 today) and to the repo checkout itself: a member can read the
|
||||
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
|
||||
|
||||
## The mechanism, and the trap in it
|
||||
@@ -88,7 +88,7 @@ platform source. That is acceptable — it is not secret — but anything creden
|
||||
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
|
||||
|
||||
```js
|
||||
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000"
|
||||
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }); // exit 0, prints "1000"
|
||||
```
|
||||
|
||||
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
|
||||
@@ -98,7 +98,7 @@ Two honest qualifications, because the danger is narrower than it first looks:
|
||||
|
||||
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
|
||||
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
|
||||
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
|
||||
- What _can_ reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
|
||||
sidecars are `.mjs`.
|
||||
|
||||
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
|
||||
@@ -115,7 +115,7 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
|
||||
```
|
||||
|
||||
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
|
||||
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's*
|
||||
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the _owner's_
|
||||
groups, which is a quiet way to retain access we just took away.
|
||||
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
|
||||
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
|
||||
@@ -123,8 +123,8 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
|
||||
`.env`.
|
||||
|
||||
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
|
||||
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own
|
||||
account* — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
|
||||
`initgroups failed: Operation not permitted` for an unprivileged caller _even when reuid'ing to its own
|
||||
account_ — `setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
|
||||
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
|
||||
user will ever see.
|
||||
|
||||
@@ -143,7 +143,7 @@ That last line is the whole security property, demonstrated rather than asserted
|
||||
test (`os-user.test.ts` → "does not pass the platform environment through").
|
||||
|
||||
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
|
||||
sudoers *policy* — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||
sudoers _policy_ — `env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
|
||||
shell" must not depend on a config file someone may have edited.
|
||||
|
||||
Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
|
||||
@@ -227,7 +227,7 @@ crossed the ancestor that mattered.
|
||||
2026-08-11; the superseded text is in the git history of this file, and the working state is
|
||||
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
|
||||
|
||||
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a
|
||||
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen _outside_ it, making a
|
||||
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
|
||||
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
|
||||
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
|
||||
@@ -245,8 +245,9 @@ crossed the ancestor that mattered.
|
||||
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
|
||||
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
|
||||
harness stays the service user's; only `claude` itself drops privileges.
|
||||
|
||||
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
|
||||
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every*
|
||||
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill _every_
|
||||
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
|
||||
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
|
||||
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
|
||||
@@ -257,14 +258,14 @@ crossed the ancestor that mattered.
|
||||
Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `useradd`. Every property
|
||||
below was **observed**, not reasoned about:
|
||||
|
||||
| attempted, as the member | result |
|
||||
| --- | --- |
|
||||
| write in own home | OK |
|
||||
| read `…/<email>/attachments/private.txt` | Permission denied |
|
||||
| `ls …/<email>/` (their own account dir) | Permission denied |
|
||||
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
|
||||
| `ls …/other-member@example.com/home` | Permission denied |
|
||||
| `cd $HOME/..` | **succeeds** — see below |
|
||||
| attempted, as the member | result |
|
||||
| ---------------------------------------- | ------------------------ |
|
||||
| write in own home | OK |
|
||||
| read `…/<email>/attachments/private.txt` | Permission denied |
|
||||
| `ls …/<email>/` (their own account dir) | Permission denied |
|
||||
| `ls $DATA_PATH` (enumerate the members) | Permission denied |
|
||||
| `ls …/other-member@example.com/home` | Permission denied |
|
||||
| `cd $HOME/..` | **succeeds** — see below |
|
||||
|
||||
Three bugs surfaced only by running it:
|
||||
|
||||
@@ -275,8 +276,8 @@ Three bugs surfaced only by running it:
|
||||
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
|
||||
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
|
||||
unlistable is not protection when the child is world-readable and the attacker knows an email address.
|
||||
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is
|
||||
also *unreachable*.
|
||||
The skeleton is now created closed — `711` on the account directory, `700` inside — so _unconfined_ is
|
||||
also _unreachable_.
|
||||
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
||||
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
|
||||
start while any `.env` in the project root is group- or world-readable.
|
||||
@@ -293,7 +294,7 @@ shell is. So:
|
||||
|
||||
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
|
||||
enforced by the platform;
|
||||
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would
|
||||
- the **terminal** cannot _read_ anything above the home, but is not confined to it. Confining it would
|
||||
mean a namespace or a chroot, which is a different and much larger feature.
|
||||
|
||||
Say "cannot see behind it", not "cannot leave it".
|
||||
@@ -304,10 +305,10 @@ A member is meant to behave like a real user on the machine — reachable over S
|
||||
themselves, able to have an agent do the same on their behalf. That needs two keys, and they are **not**
|
||||
alternatives:
|
||||
|
||||
| | where | who holds the private half | what it is for |
|
||||
| --- | --- | --- | --- |
|
||||
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
|
||||
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them |
|
||||
| | where | who holds the private half | what it is for |
|
||||
| ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
|
||||
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | _they_ SSH into this machine |
|
||||
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | _the machine_ authenticates to Gitea as them |
|
||||
|
||||
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
|
||||
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
|
||||
@@ -316,14 +317,14 @@ inbound key is optional — an account without one is simply platform-only — a
|
||||
generated regardless.
|
||||
|
||||
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
|
||||
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
|
||||
_password_ login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
|
||||
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
|
||||
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
|
||||
Linux password would be a second door that changing the platform password does not close and deleting the
|
||||
platform account does not lock.
|
||||
|
||||
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
|
||||
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey`
|
||||
pasted value containing a newline would silently install a _second_ authorized key. `validatePublicKey`
|
||||
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
|
||||
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
|
||||
force a command.
|
||||
@@ -335,9 +336,9 @@ shell text, so nothing has to reason about quoting a value that came from a form
|
||||
|
||||
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
|
||||
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
|
||||
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in
|
||||
The failure this avoids is specific: the default setting makes a first connection _prompt_, and a prompt in
|
||||
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
|
||||
*changed* host key, which is the attack that matters.
|
||||
_changed_ host key, which is the attack that matters.
|
||||
|
||||
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
|
||||
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
|
||||
@@ -446,5 +447,5 @@ Two consequences worth knowing:
|
||||
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
|
||||
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
|
||||
security code.
|
||||
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened.
|
||||
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` permission reopened.
|
||||
5. **Agents.** Separately, later, with the SDK problem solved first.
|
||||
|
||||
@@ -16,7 +16,7 @@ Three things are already true, which is why "nothing exactly blocks it":
|
||||
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
|
||||
states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
|
||||
So the app store never needs to mount or unmount routes.
|
||||
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability.
|
||||
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by permission.
|
||||
Installing one is starting a process, not teaching officer about it.
|
||||
- **`service_connections` already solves the multi-user case**, including the part nobody would get
|
||||
right independently — see below.
|
||||
@@ -88,7 +88,7 @@ health checks already correct, so "install Gitea" does not become a tutorial.
|
||||
platform/ the app
|
||||
data/ DATA_PATH
|
||||
dockers/ services the app store provisioned <- exclusively ours
|
||||
capabilities/ the file-based item store
|
||||
permissions/ the file-based item store
|
||||
```
|
||||
|
||||
`OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second
|
||||
@@ -239,7 +239,7 @@ Two things it needs before third parties touch it:
|
||||
|
||||
What a plugin author is promised, and bound by. To be written properly; the shape is:
|
||||
|
||||
- **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability.
|
||||
- **Register** by name + permissions over `/api/sidecar/register`; be reachable by permission.
|
||||
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
|
||||
schema.
|
||||
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
||||
|
||||
+140
-140
@@ -31,10 +31,10 @@ Three consequences worth stating explicitly, because the audit turned on the thi
|
||||
|
||||
`src/servers/api/slskd/` is **70 lines total** and does exactly the two things it should:
|
||||
|
||||
| File | Lines | Role |
|
||||
|---|---|---|
|
||||
| `router.ts` | 51 | `all('/*')` catch-all. Forwards subpath + query + body, injects `X-Officer-User`, streams the response back. No routes of its own. |
|
||||
| `sidecar-server.ts` | 19 | Remembers the port the sidecar reports on connect (`slskd:server`). Nothing else. |
|
||||
| File | Lines | Role |
|
||||
| ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `router.ts` | 51 | `all('/*')` catch-all. Forwards subpath + query + body, injects `X-Officer-User`, streams the response back. No routes of its own. |
|
||||
| `sidecar-server.ts` | 19 | Remembers the port the sidecar reports on connect (`slskd:server`). Nothing else. |
|
||||
|
||||
- `SLSKD_URL` / `SLSKD_API_KEY` are read in **exactly one file**: `src/servers/sidecar/slskd/upstream.ts`.
|
||||
The platform never sees either.
|
||||
@@ -49,24 +49,24 @@ Today's three commits (`8032c8b`, `b7b91a2`, `dea9ee2`) touched **zero** platfor
|
||||
The `soulseek_*` tables live in the shared `officer_db` package (`schema/soulseek.ts`,
|
||||
`queries/soulseek.ts`) rather than in the sidecar. Only the sidecar reads them — this was a
|
||||
deliberate call (one database, schema isolated in its own file, `soulseek_` prefix) and it stands.
|
||||
The cost to remember: `bun db:push` diffs the *whole* schema, which is why soulseek DDL is
|
||||
The cost to remember: `bun db:push` diffs the _whole_ schema, which is why soulseek DDL is
|
||||
hand-applied.
|
||||
|
||||
## The smell: the frontend speaks slskd
|
||||
|
||||
**37 raw `/slskd/api/v0/…` calls from React, against 10 `/slskd/_officer/…` calls.**
|
||||
|
||||
| File | Raw slskd calls |
|
||||
|---|---|
|
||||
| `SoulseekTransfers.tsx` | 8 |
|
||||
| `SoulseekRooms.tsx` | 6 |
|
||||
| `SoulseekChat.tsx` | 5 |
|
||||
| `SoulseekDashboard.tsx` | 4 |
|
||||
| `SearchView.tsx` | 4 |
|
||||
| `SoulseekSystem.tsx` | 3 |
|
||||
| `SearchResults.tsx` | 3 |
|
||||
| `useSoulseekUser.ts` | 2 |
|
||||
| `SoulseekUploads.tsx` | 2 |
|
||||
| File | Raw slskd calls |
|
||||
| ----------------------- | --------------- |
|
||||
| `SoulseekTransfers.tsx` | 8 |
|
||||
| `SoulseekRooms.tsx` | 6 |
|
||||
| `SoulseekChat.tsx` | 5 |
|
||||
| `SoulseekDashboard.tsx` | 4 |
|
||||
| `SearchView.tsx` | 4 |
|
||||
| `SoulseekSystem.tsx` | 3 |
|
||||
| `SearchResults.tsx` | 3 |
|
||||
| `useSoulseekUser.ts` | 2 |
|
||||
| `SoulseekUploads.tsx` | 2 |
|
||||
|
||||
The proxy stays thin, so rule (1) and (2) hold. Rule (3) does not: `shared.ts` defines slskd's wire
|
||||
types (`SlskdUserStatus`, `SlskdUserInfo`, `SlskdTransferUser`, …), and the panels know slskd's URL
|
||||
@@ -117,13 +117,13 @@ Under that line, the three files above are the work. The other six are a naming/
|
||||
|
||||
`src/servers/sidecar/slskd/` — what "the sidecar owns its job" already looks like:
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `index.ts` | Reverse proxy to slskd on a random loopback port; documents the whole `/api/slskd/*` contract; reports its port to the platform. |
|
||||
| `upstream.ts` | The only holder of `SLSKD_URL` / `SLSKD_API_KEY`. |
|
||||
| `officer.ts` | The `/_officer/*` routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. |
|
||||
| `browse.ts` | Multi-minute background share-tree fetch, tree built at ingest. Outlives any request. |
|
||||
| `download.ts` | Expands a browsed folder into a file list from cache and enqueues it. `MAX_ENQUEUE` guard. |
|
||||
| File | Role |
|
||||
| ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `index.ts` | Reverse proxy to slskd on a random loopback port; documents the whole `/api/slskd/*` contract; reports its port to the platform. |
|
||||
| `upstream.ts` | The only holder of `SLSKD_URL` / `SLSKD_API_KEY`. |
|
||||
| `officer.ts` | The `/_officer/*` routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. |
|
||||
| `browse.ts` | Multi-minute background share-tree fetch, tree built at ingest. Outlives any request. |
|
||||
| `download.ts` | Expands a browsed folder into a file list from cache and enqueues it. `MAX_ENQUEUE` guard. |
|
||||
|
||||
Other sidecars for comparison: `claude`, `email`, `music`, `opencode`, `vault`, `vnc`.
|
||||
|
||||
@@ -137,16 +137,16 @@ re-derived later.
|
||||
|
||||
The eight sidecars, from `ecosystem.config.cjs`:
|
||||
|
||||
| PM2 process | Entry point |
|
||||
|---|---|
|
||||
| `officer-claude` | `src/servers/sidecar/claude/index.ts` |
|
||||
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
|
||||
| `officer-email` | `src/servers/sidecar/email/index.ts` |
|
||||
| `officer-pty` | `src/servers/api/terminal/pty-sidecar.mjs` ← note the path |
|
||||
| `officer-vnc` | `src/servers/sidecar/vnc/index.ts` |
|
||||
| `officer-music` | `src/servers/sidecar/music/index.ts` |
|
||||
| `officer-vault` | `src/servers/sidecar/vault/index.ts` |
|
||||
| `officer-slskd` | `src/servers/sidecar/slskd/index.ts` |
|
||||
| PM2 process | Entry point |
|
||||
| ------------------ | ---------------------------------------------------------- |
|
||||
| `officer-claude` | `src/servers/sidecar/claude/index.ts` |
|
||||
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
|
||||
| `officer-email` | `src/servers/sidecar/email/index.ts` |
|
||||
| `officer-pty` | `src/servers/api/terminal/pty-sidecar.mjs` ← note the path |
|
||||
| `officer-vnc` | `src/servers/sidecar/vnc/index.ts` |
|
||||
| `officer-music` | `src/servers/sidecar/music/index.ts` |
|
||||
| `officer-vault` | `src/servers/sidecar/vault/index.ts` |
|
||||
| `officer-slskd` | `src/servers/sidecar/slskd/index.ts` |
|
||||
|
||||
## music — partially compliant
|
||||
|
||||
@@ -154,15 +154,15 @@ The eight sidecars, from `ecosystem.config.cjs`:
|
||||
**cliamp audio subsystem**: the same domain — playing local audio to the browser — implemented
|
||||
entirely in the main process, with no sidecar owning any of it.
|
||||
|
||||
| Surface | Lines |
|
||||
|---|---|
|
||||
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
|
||||
| `hono.ts` (3) + `protocol.ts` (1) | 4 |
|
||||
| `api/cliamp/websocket.ts` | 201 |
|
||||
| `api/cliamp/audio-ws.ts` | 91 |
|
||||
| `server.tsx` PulseAudio bootstrap | 46 |
|
||||
| `api/cliamp/asoundrc` + `server.tsx` cliamp wiring | 18 |
|
||||
| **Audio domain in the main process** | **~356** |
|
||||
| Surface | Lines |
|
||||
| ---------------------------------------------------------------------- | -------- |
|
||||
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
|
||||
| `hono.ts` (3) + `protocol.ts` (1) | 4 |
|
||||
| `api/cliamp/websocket.ts` | 201 |
|
||||
| `api/cliamp/audio-ws.ts` | 91 |
|
||||
| `server.tsx` PulseAudio bootstrap | 46 |
|
||||
| `api/cliamp/asoundrc` + `server.tsx` cliamp wiring | 18 |
|
||||
| **Audio domain in the main process** | **~356** |
|
||||
|
||||
1. **cliamp player process management** — `api/cliamp/websocket.ts:1-201`. Locates the `cliamp` binary
|
||||
by probing `Bun.which` plus three GOPATH candidates (`:48-66`), resolves and traversal-validates the
|
||||
@@ -171,20 +171,20 @@ entirely in the main process, with no sidecar owning any of it.
|
||||
`PULSE_SINK: 'virtual_out'` and `ALSA_CONFIG_PATH` injected. Pumps stdout/stderr into JSON frames
|
||||
(`:124-160`), forwards `{type:'input'}` to stdin (`:173-185`), kills the child on close (`:187-198`).
|
||||
Child processes are held in a module-level `Map` (`:22`).
|
||||
*Belongs in* `sidecar/music/`, which already runs its own loopback HTTP server
|
||||
(`sidecar/music/index.ts:140`). *Obstacle:* a browser-held WebSocket with bidirectional keystroke
|
||||
_Belongs in_ `sidecar/music/`, which already runs its own loopback HTTP server
|
||||
(`sidecar/music/index.ts:140`). _Obstacle:_ a browser-held WebSocket with bidirectional keystroke
|
||||
traffic — but the relay pattern already exists twice (`server.tsx:164-228` for dev-server,
|
||||
`server.tsx:323-326` for vault).
|
||||
2. **PulseAudio host-daemon bootstrap** — `server.tsx:391-436`. A startup IIFE that locates
|
||||
`pulseaudio`/`pactl`, runs `pulseaudio --start -D` if the daemon is down (`:401-411`), then greps
|
||||
`pactl list short sinks` and loads `module-null-sink sink_name=virtual_out` if absent (`:414-435`).
|
||||
Runs unconditionally at every boot even if nobody opens the player.
|
||||
*Belongs in* the music sidecar's startup. *Obstacle:* none technical — same host, `pactl` works
|
||||
_Belongs in_ the music sidecar's startup. _Obstacle:_ none technical — same host, `pactl` works
|
||||
identically. Must move together with (1) and (3), since the sink must exist before they start.
|
||||
3. **Host audio capture → browser PCM** — `api/cliamp/audio-ws.ts:1-91`. Spawns
|
||||
`parec --format=s16le --rate=44100 --channels=2 -d virtual_out.monitor` (`:28-33`) and pushes each
|
||||
chunk to the browser as a binary frame (`:44-73`). Hardcoded format, sample rate, channel count and
|
||||
monitor device name — pipeline domain knowledge. *Obstacle:* continuous binary PCM, so a relay hop
|
||||
monitor device name — pipeline domain knowledge. _Obstacle:_ continuous binary PCM, so a relay hop
|
||||
costs a copy per chunk.
|
||||
4. **ALSA config shipped inside the API tree** — `api/cliamp/asoundrc:1-9`, passed via
|
||||
`ALSA_CONFIG_PATH` (`websocket.ts:8`, `:112`). Upstream config in the thin-proxy process. Moves for
|
||||
@@ -231,25 +231,26 @@ sidecar owns, but it belongs to the file-browser contract.
|
||||
The transport proxy is right; the platform owns the entire Vaultwarden **auth/session/key-custody**
|
||||
domain. This is the worst offender of the eight, and the one where placement has real consequences.
|
||||
|
||||
| Surface | Lines |
|
||||
|---|---|
|
||||
| `api/vault/router.ts` | 169 |
|
||||
| `api/vault/websocket.ts` | 164 |
|
||||
| `api/vault/broker.ts` | 79 |
|
||||
| `api/vault/token-store.ts` | 34 |
|
||||
| `api/vault/proxy-util.ts` | 29 |
|
||||
| `api/vault/sidecar-server.ts` | 24 |
|
||||
| **`api/vault/` total** | **499** |
|
||||
| `hono.ts` (7) + `server.tsx` (8) + `protocol.ts` (2) + auth handlers (8) | 25 |
|
||||
| `officerdb` vault layer the platform calls: `queries/vault.ts` + `crypto.ts` + `schema/vault.ts` | 176 |
|
||||
| **Total** | **~702** (~525 excluding the shared DB package) |
|
||||
| Surface | Lines |
|
||||
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
||||
| `api/vault/router.ts` | 169 |
|
||||
| `api/vault/websocket.ts` | 164 |
|
||||
| `api/vault/broker.ts` | 79 |
|
||||
| `api/vault/token-store.ts` | 34 |
|
||||
| `api/vault/proxy-util.ts` | 29 |
|
||||
| `api/vault/sidecar-server.ts` | 24 |
|
||||
| **`api/vault/` total** | **499** |
|
||||
| `hono.ts` (7) + `server.tsx` (8) + `protocol.ts` (2) + auth handlers (8) | 25 |
|
||||
| `officerdb` vault layer the platform calls: `queries/vault.ts` + `crypto.ts` + `schema/vault.ts` | 176 |
|
||||
| **Total** | **~702** (~525 excluding the shared DB package) |
|
||||
|
||||
For scale: the sidecar itself is 295 lines and is a genuine dumb pass-through
|
||||
(`sidecar/vault/index.ts:153-183` streams bodies verbatim; `upstream.ts:12-22` is the only reader of
|
||||
`VAULTWARDEN_URL`).
|
||||
|
||||
Two structural notes before the findings:
|
||||
- `api/vault/router.ts:123` *is* an `all('/*')` catch-all, but it is not thin — it **replaces** the
|
||||
|
||||
- `api/vault/router.ts:123` _is_ an `all('/*')` catch-all, but it is not thin — it **replaces** the
|
||||
`Authorization` header with a platform-held upstream credential (`:137-141`) and implements
|
||||
401-refresh-retry (`:158-165`).
|
||||
- It does **not** inject `X-Officer-User` (contrast `api/slskd/router.ts:34`,
|
||||
@@ -274,10 +275,10 @@ Two structural notes before the findings:
|
||||
every proxied request.
|
||||
3. **Notifications WebSocket proxied twice, with token injection** — `api/vault/websocket.ts:1-164`.
|
||||
`injectToken` (`:45-52`) rewrites the SignalR query string: drops `token`, sets `access_token=<vw
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||
`ws://127.0.0.1:<sidecarPort>` and runs a full buffered bidirectional pipe — **which the sidecar
|
||||
already implements** (`sidecar/vault/index.ts:45-114`, `:143-151`). Frames are relayed twice.
|
||||
*Obstacle:* Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that
|
||||
_Obstacle:_ Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that
|
||||
pattern stays, the token lookup at `:73` should not.
|
||||
4. **The platform is the vault's key escrow** — `api/vault/router.ts:107-120`. `PUT /unlock-key`
|
||||
persists a `wrappedKey` (`:111`); `GET /unlock-key` hands it back to any owner session (`:117-119`).
|
||||
@@ -290,7 +291,7 @@ Two structural notes before the findings:
|
||||
`queries/vault.ts:22-23,34-35,67-68,83,88`. Derives an AES-256-GCM key as
|
||||
`SHA-256(VAULT_STORE_KEY)` (`:12-20`) and runs `createCipheriv`/`createDecipheriv` (`:23-39`).
|
||||
Because the vault router imports `officerdb` (`router.ts:10`, `token-store.ts:1`), all of this runs
|
||||
inside `officer`. *Obstacle:* `crypto.ts` lives in the shared package, so it is importable from
|
||||
inside `officer`. _Obstacle:_ `crypto.ts` lives in the shared package, so it is importable from
|
||||
anywhere; moving it means moving the vault queries out of the shared package or enforcing a
|
||||
sidecar-only import boundary. No config obstacle — both processes read the same `.env`.
|
||||
6. **Vault tables are read/written by the platform, not the sidecar** — `queries/vault.ts:18-101`,
|
||||
@@ -299,7 +300,7 @@ Two structural notes before the findings:
|
||||
imports **no** DB module at all. Exact inverse of the intended ownership.
|
||||
7. **Auth flows reach into vault storage directly** — `api/auth/signout.ts:10`,
|
||||
`revoke-handler.ts:18-19`, `panic-handler.ts:15-16`. Signout deletes the token row; distress and
|
||||
panic also burn the protector key. The *policy* is platform-level; the *mechanism* — direct DELETEs
|
||||
panic also burn the protector key. The _policy_ is platform-level; the _mechanism_ — direct DELETEs
|
||||
against the sidecar's tables — is not. All three are already best-effort `.catch(() => {})`, so
|
||||
failure semantics wouldn't worsen behind a sidecar call.
|
||||
8. **Dead weight** — `router.ts:38-48` is a hand-written `GET /_health` passthrough the catch-all
|
||||
@@ -309,13 +310,13 @@ Two structural notes before the findings:
|
||||
strings — fix (2) and it's unnecessary.
|
||||
9. **Mounted outside the protected tree** — `hono.ts:73-77`. `route('/api/vault', …)` sits outside
|
||||
`protectedRouter`, so the router re-implements its own stack (`router.ts:31-33`: `originMiddleware`,
|
||||
`userMiddleware`, `ownerGate`). *Real constraint, probably why:* it deliberately avoids
|
||||
`userMiddleware`, `ownerGate`). _Real constraint, probably why:_ it deliberately avoids
|
||||
`bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from
|
||||
`hono.ts:88` and buffer vault attachments.
|
||||
10. **Stale comments on a security boundary** — `hono.ts:73-76` and
|
||||
`origin-validation.ts:36-39,61-64` both claim vault requests "carry their own Bitwarden bearer
|
||||
token, not a platform session JWT" and that `userMiddleware` would 401 them. Untrue since
|
||||
`router.ts:32-33` requires a valid platform JWT *and* owner status on every request. Also,
|
||||
`router.ts:32-33` requires a valid platform JWT _and_ owner status on every request. Also,
|
||||
`VAULT_AUTH_SPEC.md` (cited at `router.ts:12`, `schema/vault.ts:4`) and
|
||||
`BITWARDEN_SIDECAR_PROMPT.md` (cited at `sidecar/vault/upstream.ts:3`) **do not exist** in the repo.
|
||||
Not logic, but exactly the drift that makes someone loosen a gate by mistake.
|
||||
@@ -327,7 +328,7 @@ every `officerdb` vault export across `src/servers` and `src/databases`; the onl
|
||||
User-key derivation is genuinely client-side (the platform only relays `Kdf*` params at
|
||||
`router.ts:96-101`) — the one credential decision that is correctly placed.
|
||||
|
||||
*Shortest path to compliance (inferred, not attempted):* move `broker.ts`, `token-store.ts`,
|
||||
_Shortest path to compliance (inferred, not attempted):_ move `broker.ts`, `token-store.ts`,
|
||||
`/session/login`, `/unlock-key`, the vault queries and `crypto.ts` into `sidecar/vault/`; have the
|
||||
router inject `X-Officer-User` instead of `Authorization`; reduce `websocket.ts` to
|
||||
origin-check + verify + upgrade + dumb pipe; delete `/_health` and `proxy-util.ts`; replace the three
|
||||
@@ -342,7 +343,7 @@ The largest violation after email, and the one with the worst consequences, beca
|
||||
for survivability. They overlap deliberately.
|
||||
|
||||
There is **no `/api/claude` mount, no proxy router, and no `X-Officer-User` anywhere on this path.**
|
||||
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it *drives* it,
|
||||
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it _drives_ it,
|
||||
over a typed RPC vocabulary, and interprets everything that comes back.
|
||||
|
||||
A structural fact worth stating before the list, because it inverts the usual reading: **the sidecar
|
||||
@@ -363,7 +364,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
the machinery that makes a restart lossy — it is the durable writer, and it sits on the far side
|
||||
of the socket from the process producing the events.
|
||||
3. **`api/chat/claude-sessions.ts:1-361` — a reimplementation of Claude's transcript format.** The
|
||||
platform reads and *writes* `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding
|
||||
platform reads and _writes_ `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding
|
||||
(`:39`), the entry schema (`:69-78`), content-block decoding (`:143-227`), listing (`:350-361`),
|
||||
delete-by-unlink (`:253-258`), a 32KB `readSync` plus a `"cwd":"…"` regex to recover a session's
|
||||
directory (`:294-306`), and — the sharpest example — **rename implemented by appending a
|
||||
@@ -378,7 +379,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with
|
||||
`stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters`
|
||||
Map, a 15s timeout and a 50ms registration poll (`:259-267`). Plus the claude verbs at `:306-358`
|
||||
and a broadcast fallback at `:339-346`. `:88-90` uses `capabilities.includes('proxy')` as a
|
||||
and a broadcast fallback at `:339-346`. `:88-90` uses `permissions.includes('proxy')` as a
|
||||
stand-in for "is this the claude sidecar", which is only true by accident of naming.
|
||||
6. **`generate-container-context.ts:135-182` (+ `:50-133`) — the platform writes the CLI's config.**
|
||||
It authors `~/.claude/settings.json`: a `Stop` hook curling
|
||||
@@ -387,7 +388,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
(`:163-179`). Called from `users/provision.ts:28-38`. Two notes: the hook points at the platform,
|
||||
so it fails during exactly the restart window that matters; and the permission posture is a
|
||||
deliberate documented choice (`platform/CLAUDE.md`: agents run unsandboxed as the owner) that is
|
||||
being *implemented in the wrong process*, not a mistake.
|
||||
being _implemented in the wrong process_, not a mistake.
|
||||
7. **`api/activity/router.ts:1-191` — the platform walks the agent's scratch tree.** Reads
|
||||
`/tmp/claude-<uid>/<encoded-cwd>/tasks/<id>.output` (`:24-61`, keyed on
|
||||
`startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the
|
||||
@@ -419,7 +420,7 @@ than the owner of it. Every other item below is downstream of that.
|
||||
`sk-ant-api03-<uuid>` keys (`:10`) — the live copy is `sidecar/claude/proxy.ts:135`. A stale second
|
||||
implementation of the credential path is worth deleting on security grounds alone, not just tidiness.
|
||||
|
||||
*What the sidecar already has right:* the Anthropic proxy genuinely lives in the PM2-managed sidecar
|
||||
_What the sidecar already has right:_ the Anthropic proxy genuinely lives in the PM2-managed sidecar
|
||||
(`sidecar/claude/index.ts:20`), so the platform never holds an API key at rest, and `ANTHROPIC_BASE_URL`
|
||||
points at the sidecar (`sidecar-registry.ts:234`). The credential path is roughly correct. It is the
|
||||
process topology, the transport direction and the domain logic that are not.
|
||||
@@ -428,7 +429,7 @@ process topology, the transport direction and the domain logic that are not.
|
||||
|
||||
The worst of the eight by volume, and the only one where the arrow points backwards end to end:
|
||||
**≈3,238 platform lines** (2,875 of them in seven files) against a **314-line sidecar** — and the
|
||||
sidecar *imports platform code back out* (`sidecar/email/email-idle.ts:3` imports
|
||||
sidecar _imports platform code back out_ (`sidecar/email/email-idle.ts:3` imports
|
||||
`../../api/email/resync`). There is no proxy router, no `email:server` port event, and no forwarding of
|
||||
any kind. `emailRouter` implements 18 concrete endpoints itself.
|
||||
|
||||
@@ -460,7 +461,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
work.** `gmailResync` (`:43-63`), `imapResync` (`:148-264`), `resolveImapAuth` (`:118-146`),
|
||||
`refreshCredentials` (`:21-41`), and `performResync` (`:276-284`) which coalesces concurrent
|
||||
resyncs through an **in-process Map**. It is imported by both `sidecar/email/email-cron.ts:2` and
|
||||
`email-idle.ts:3` *and* by `accounts.ts:159` — i.e. by two different processes. Each gets its own
|
||||
`email-idle.ts:3` _and_ by `accounts.ts:159` — i.e. by two different processes. Each gets its own
|
||||
copy of the Map, so the coalescing silently does nothing across the boundary. This is what
|
||||
"importing platform code back out" costs.
|
||||
6. **`api/email/accounts.ts:1-259` — account setup does live IMAP.** Validation by real connection on
|
||||
@@ -486,7 +487,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
|
||||
12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is
|
||||
`sidecar/email/email-cron.ts`).
|
||||
|
||||
*Shortest path (inferred):* this one is a rewrite, not a move. The realistic first step is not
|
||||
_Shortest path (inferred):_ this one is a rewrite, not a move. The realistic first step is not
|
||||
relocating `email-db.ts` — it is deleting the duplicate clients (items 7 and 8) and moving the two
|
||||
queue handlers (items 2 and 3) into the sidecar so sync stops dying with `officer`. The store itself
|
||||
can follow later, behind a proxy router.
|
||||
@@ -521,7 +522,7 @@ already does the same job. That makes this the cheapest of the non-compliant sur
|
||||
6. **`channels/send-opencode.ts:29-66` — the terminal-event set (`:33-37`) and a resume policy keyed
|
||||
on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the
|
||||
platform.
|
||||
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents capability
|
||||
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents permission
|
||||
metadata for the results (`:42-45`).**
|
||||
8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`**
|
||||
(`opencode-sessions.ts:112`, a `startsWith('ses_')` test).
|
||||
@@ -573,11 +574,11 @@ already exists in the same codebase.
|
||||
backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is
|
||||
unenforced against it. The actual blocker to moving it is mundane: sibling `templates/` files on
|
||||
disk (`:27-29, 61, 66, 71` — `.zshrc`, `.tmux.conf`, `starship-officer.toml`, and an unused
|
||||
`.zshenv`). So a `git mv`, not a rewrite. *(Inferred: the `.mjs`/node choice is probably a
|
||||
node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)*
|
||||
`.zshenv`). So a `git mv`, not a rewrite. _(Inferred: the `.mjs`/node choice is probably a
|
||||
node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)_
|
||||
3. **Every PTY byte transits the main process, double-JSON-encoded.** Plus terminal-specific query
|
||||
parsing in the shared upgrade handler (`server.tsx:248-252`, `WSData:51-52`) and wiring at `:7, 38,
|
||||
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
||||
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
||||
`userLabel` / `sessionId` (`websocket.ts:56, 65`) instead of as `X-Officer-User`.
|
||||
4. **`sidecar-registry.ts:395-407` plus PTY types threaded through generic plumbing** at
|
||||
`:12-13, 35, 93, 125, 153, 171, 180, 194`. One subtlety to preserve: the 30s
|
||||
@@ -588,7 +589,7 @@ already exists in the same codebase.
|
||||
than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same
|
||||
result on this machine (`HOME_DIR` is set and equals `HOME`), divergent anywhere it isn't.
|
||||
|
||||
*Shortest path (inferred):* `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates,
|
||||
_Shortest path (inferred):_ `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates,
|
||||
switch it to `connect.ts`, move the `PtyInitConfig` construction and cwd resolution into it, and
|
||||
replace `websocket.ts` with the `devServerWebsocket` relay shape. The detach-on-disconnect policy moves
|
||||
with it.
|
||||
@@ -625,7 +626,7 @@ pile of leaked logic.
|
||||
7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is already
|
||||
reference-shaped** (two lines).
|
||||
|
||||
*Adjacent, and its own domain rather than a vnc violation:* the browser relay —
|
||||
_Adjacent, and its own domain rather than a vnc violation:_ the browser relay —
|
||||
`server.tsx:369, 371` plus `api/browser/relay.ts` (677 lines), `api/browser/router.ts` (198, including
|
||||
`Bun.spawn(['zip', …])` at `:26-30`), `cdp.ts` (99) and `relay-auth.ts` (42); and
|
||||
`api/scrape/scrape.ts:9-19, 49+` launches chromium in-process. Noted for a future pass; not counted
|
||||
@@ -640,19 +641,19 @@ eight times.
|
||||
|
||||
Sorted by how far each is from the reference. This is the whole audit in one view:
|
||||
|
||||
| sidecar | platform lines | verdict |
|
||||
|---|---:|---|
|
||||
| slskd | 70 | ✅ reference |
|
||||
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
|
||||
| pty | 169 | ✗ ~all of it is sidecar logic |
|
||||
| vnc | 170 | ✗ blocked on a missing listener |
|
||||
| vault | 499 | ◐ partial, security-relevant |
|
||||
| opencode | 478 | ✗ (~330 lines deletable as dead) |
|
||||
| claude | ~2,300 | ✗ |
|
||||
| email | ~2,875 | ✗ no proxy exists at all |
|
||||
| sidecar | platform lines | verdict |
|
||||
| -------- | -------------: | ---------------------------------------------------- |
|
||||
| slskd | 70 | ✅ reference |
|
||||
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
|
||||
| pty | 169 | ✗ ~all of it is sidecar logic |
|
||||
| vnc | 170 | ✗ blocked on a missing listener |
|
||||
| vault | 499 | ◐ partial, security-relevant |
|
||||
| opencode | 478 | ✗ (~330 lines deletable as dead) |
|
||||
| claude | ~2,300 | ✗ |
|
||||
| email | ~2,875 | ✗ no proxy exists at all |
|
||||
|
||||
`hono.ts` mounts **36 routers. Three are thin sidecar proxies** — `:106` (music), `:107` (slskd), and
|
||||
`:77` (vault, mounted *outside* `protectedRouter`).
|
||||
`:77` (vault, mounted _outside_ `protectedRouter`).
|
||||
|
||||
For contrast, sidecar-side LOC: music 1,630 · claude 1,523 · slskd 653 · opencode 427 · vnc 326 ·
|
||||
email 314 · vault 295. Note the inversion on email: 314 sidecar lines to 2,875 platform lines.
|
||||
@@ -664,7 +665,7 @@ file-browser 1,465 · server-settings 1,452 · browser 1,105 · auth 607 · syst
|
||||
### 2. The protocol is not a transport
|
||||
|
||||
`sidecar/protocol.ts` is a **closed union of ~34 message types: 7 transport, 25+ domain.** Every new
|
||||
sidecar capability requires editing a shared platform file — which is why domain knowledge keeps
|
||||
sidecar permission requires editing a shared platform file — which is why domain knowledge keeps
|
||||
landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params).
|
||||
|
||||
Two specific consequences:
|
||||
@@ -675,7 +676,7 @@ Two specific consequences:
|
||||
string test (`server.tsx:80`, `sidecar/email/index.ts:31, 38`). So the "closed" union is already
|
||||
being bypassed where it was inconvenient — evidence that the closed shape is the wrong shape.
|
||||
|
||||
By contrast `registration-protocol.ts` (16 lines: `name` + `capabilities: string[]`) is genuinely
|
||||
By contrast `registration-protocol.ts` (16 lines: `name` + `permissions: string[]`) is genuinely
|
||||
generic. The registration handshake got this right; the command channel did not.
|
||||
|
||||
### 3. Ten WebSocket providers, and only three are tunnels
|
||||
@@ -708,10 +709,10 @@ is item 2 of the email section arriving from a different direction.
|
||||
### 5. Registry bugs that will bite during any migration
|
||||
|
||||
- **`unregisterSidecar` (`sidecar-registry.ts:80-85`) rejects the entire global pending-command map
|
||||
when *any single* sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and
|
||||
when _any single_ sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and
|
||||
vault commands. This will look like random unrelated breakage the moment sidecars restart
|
||||
independently — which is the entire goal.
|
||||
- **`:88-90` treats `capabilities.includes('proxy')` as "is this claude"** — true only by accident of
|
||||
- **`:88-90` treats `permissions.includes('proxy')` as "is this claude"** — true only by accident of
|
||||
the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`.
|
||||
|
||||
### 6. What the database says (the clearest signal in the audit)
|
||||
@@ -726,8 +727,8 @@ Table ownership tracks compliance exactly:
|
||||
- **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching across
|
||||
domains into it.
|
||||
|
||||
**A useful rule falls out of this:** *if a table is read by exactly one sidecar and nothing else, that
|
||||
sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't.*
|
||||
**A useful rule falls out of this:** _if a table is read by exactly one sidecar and nothing else, that
|
||||
sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't._
|
||||
Cheaper to check than reading 3,000 lines.
|
||||
|
||||
---
|
||||
@@ -739,7 +740,7 @@ mirroring the slskd findings at the top of this document.
|
||||
|
||||
Same rule, applied one layer out. The question here is not "what logic runs in `officer`" but **"does
|
||||
the browser know things only the sidecar should know?"** — upstream URL shapes, wire formats, session-id
|
||||
conventions, retry and reconnect policy, capability catalogues.
|
||||
conventions, retry and reconnect policy, permission catalogues.
|
||||
|
||||
The slskd case at the top of this document is the template: **37 raw `/slskd/api/v0/…` calls against 10
|
||||
`/slskd/_officer/…` calls**, meaning the browser is a second client of the upstream API rather than a
|
||||
@@ -785,7 +786,7 @@ disconnected UI — a red "Disconnected" indicator (`ChatDetailPanel.tsx:38-52`)
|
||||
(`InputArea.tsx:84`), model switching locked (`ModelSelector.tsx:67`).
|
||||
|
||||
**So `seq` + `resume-cursor` already exist end to end.** Pass 1 found the matching backend half at
|
||||
`chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the *writer* is simply
|
||||
`chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the _writer_ is simply
|
||||
on the wrong side of the socket. That makes the durability stage of `CLAUDE_SIDECAR_ISOLATION.md`
|
||||
substantially smaller than I estimated — a relocation, not a new mechanism.
|
||||
|
||||
@@ -807,16 +808,16 @@ check when the writer moves.
|
||||
string, in the task runner. This one silently goes stale.
|
||||
3. **The CLI invocation string is in the browser.** `apps/Terminal/index.tsx:32-33` —
|
||||
`command="claude --dangerously-skip-permissions"`, `statePrefix="claude-code"`. The browser decides
|
||||
how the agent binary is invoked, including its permission flag. *(The unsandboxed posture is
|
||||
how the agent binary is invoked, including its permission flag. _(The unsandboxed posture is
|
||||
deliberate per `platform/CLAUDE.md`; the objection is only to where the decision lives — the
|
||||
browser is the furthest possible place from the sidecar that owns it.)*
|
||||
4. **Capability metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`,
|
||||
browser is the furthest possible place from the sidecar that owns it.)_
|
||||
4. **Permission metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`,
|
||||
`maxTokens` and `reasoning?`, and `ModelSelector.tsx:116` branches the UI on `reasoning`. The
|
||||
browser doesn't compute these, so this is acceptable *if* they come from the sidecar — but Pass 1
|
||||
browser doesn't compute these, so this is acceptable _if_ they come from the sidecar — but Pass 1
|
||||
found them hardcoded in the platform at `api/chat/list-models.ts:5-9`, so today the numbers
|
||||
originate two layers away from the thing they describe.
|
||||
5. **Claude CLI session conventions are documented in the browser.**
|
||||
`state/src/useClaudeSessions.ts:8-9` comments that the id *is* the transcript filename;
|
||||
`state/src/useClaudeSessions.ts:8-9` comments that the id _is_ the transcript filename;
|
||||
`SessionList.tsx:10-11` explains that clicking a session continues it "via --resume"; `:15` types
|
||||
`harness?: 'claude' | 'opencode'`. And the magic string **`'general_chat_sessions'`** — Claude's
|
||||
own default directory bucket — appears as a literal in `PwdSelector.tsx:7, 22, 62`,
|
||||
@@ -851,7 +852,7 @@ check when the writer moves.
|
||||
## email — routes are compliant, payloads and realtime are not
|
||||
|
||||
The mirror image of chat: **every one of the 20 API paths is Officer-shaped** — there is no
|
||||
`/imap/uid/…` anywhere — but the request *bodies* carry IMAP configuration, the compose path builds
|
||||
`/imap/uid/…` anywhere — but the request _bodies_ carry IMAP configuration, the compose path builds
|
||||
MIME, and the realtime channel cannot recover from a restart at all.
|
||||
|
||||
There is no windowed panel app; the email UI is screen-level under
|
||||
@@ -884,9 +885,10 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- `GoogleOAuthConfig.tsx:197-210` — `GET /integrations/google/config` returns `clientSecret` in
|
||||
plaintext; held in `useState` (`:184`), shown at `:255-262`.
|
||||
|
||||
Neither is a mail credential *the sidecar owns*, and both are the owner's own secrets on the
|
||||
Neither is a mail credential _the sidecar owns_, and both are the owner's own secrets on the
|
||||
owner's own machine — but "GET returns the secret so the form can prefill" is the pattern worth
|
||||
changing, since a write-only field would work identically.
|
||||
|
||||
5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds
|
||||
`new EventSource('/api/email/events?token=' + …)` from `localStorage`. Unavoidable for `EventSource`
|
||||
(it can't set headers), but it puts the JWT into browser history and any proxy access log. Worth
|
||||
@@ -895,7 +897,7 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
`EmailList.tsx:109-125` opens the SSE stream, expects `{ type: 'new-mail' }`, invalidates three
|
||||
query keys, and closes on unmount. There is **no `es.onerror`, no backoff, no reconnect, and no
|
||||
`Last-Event-ID` handling.** And the server never sends an `id:` field — Pass 1's
|
||||
`api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's *native*
|
||||
`api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's _native_
|
||||
`EventSource` retry cannot request replay. Any `new-mail` event emitted during a restart is lost
|
||||
silently until the next event arrives or the user hits Sync manually (`:134-154`).
|
||||
**Direct contrast with chat, in the same codebase: one channel has cursor-based replay, the other
|
||||
@@ -925,18 +927,18 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
|
||||
- **No charset, quoted-printable, base64 or RFC-2047 decoding in the browser** — it receives decoded
|
||||
`text`/`html`/`snippet`. Reading is compliant; only composing leaks.
|
||||
- **No Gmail label ids and no Gmail query syntax constructed client-side.** The search box passes `q=`
|
||||
through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only *hints* at the syntax.
|
||||
through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only _hints_ at the syntax.
|
||||
- **Mail credentials are write-only.** The password is POSTed at `EmailAccounts.tsx:132` and never read
|
||||
back — `GET /email/accounts` returns no credential field. OAuth tokens never reach the browser at
|
||||
all: `:88-107` either redirects the page to `/api/integrations/google/authorize` or POSTs
|
||||
`credentials: { userIntegrationId: true }`, a boolean. This is the right shape, and it is worth
|
||||
noting that the *account* credential path is stricter than the *settings* ones in item 4.
|
||||
noting that the _account_ credential path is stricter than the _settings_ ones in item 4.
|
||||
- **No Message-Id handling** — and `Compose.tsx:382-384` documents the absence, noting `m.id` is a local
|
||||
hash and that threading currently leans on `Re:` + participants.
|
||||
|
||||
## opencode — the most compliant frontend of the eight
|
||||
|
||||
Genuinely surprising given Pass 1 found ≈792 non-compliant *backend* lines. **The string `opencode`
|
||||
Genuinely surprising given Pass 1 found ≈792 non-compliant _backend_ lines. **The string `opencode`
|
||||
appears in exactly four frontend files, and only one of those is logic.** Everything the backend leaks
|
||||
— the `ses_` prefix, the `opencode/<modelID>` id shape, `metadata.officer`, `auth.json`,
|
||||
`models.json`, the version pin — stops at the server. Verified by exhaustive grep: **zero frontend hits
|
||||
@@ -971,7 +973,7 @@ it just always sends `cwd`.
|
||||
(`value.slice(0,3) + '...' + value.slice(-3)`), and the browser uses the result only as a
|
||||
placeholder (`AIHarnessesSection.tsx:467`). A freshly typed key lives transiently in
|
||||
`keyInputs` state (`:74`) and is **deleted after the PUT** (`:121-125`). Never in `localStorage`,
|
||||
`sessionStorage`, or the query cache. Local-provider config returns the auth *type* only, never key
|
||||
`sessionStorage`, or the query cache. Local-provider config returns the auth _type_ only, never key
|
||||
material. **This is the pattern the email settings surface (Pass 2, email item 4) should copy.**
|
||||
5. **No hardcoded model catalogue.** `state/src/useModels.ts:30-51` fetches everything from
|
||||
`/chat/models`. The only hardcoded data is display-name maps — `ModelSelector.tsx:7-23` (14 pairs)
|
||||
@@ -987,16 +989,16 @@ it just always sends `cwd`.
|
||||
(routed at `App.tsx:38-40`), `useClaudeSessions.ts`, `useEmbeddableChat.ts`. All reachable.
|
||||
|
||||
**One thing the frontend displays that isn't real, and the cause is in the backend.**
|
||||
`api/chat/list-models.ts:24` stubs *every* opencode-routed model with constant metadata —
|
||||
`api/chat/list-models.ts:24` stubs _every_ opencode-routed model with constant metadata —
|
||||
`contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true`, with the comment "metadata is
|
||||
left at neutral defaults for now". The browser faithfully renders these (`ModelSelector.tsx:116`
|
||||
branches the thinking toggle on `reasoning`). So the capability numbers shown to the user for opencode
|
||||
branches the thinking toggle on `reasoning`). So the permission numbers shown to the user for opencode
|
||||
models are placeholders, and `reasoning: false` will suppress the thinking toggle for models that do
|
||||
support it. A backend defect, surfaced by a compliant frontend.
|
||||
|
||||
## terminal / pty — the browser reconnects, and then loses the session anyway
|
||||
|
||||
The mirror of the backend result. Pass 1 called pty the least compliant *backend* surface; the frontend
|
||||
The mirror of the backend result. Pass 1 called pty the least compliant _backend_ surface; the frontend
|
||||
is mostly well-behaved, has real reconnect logic, and yet contains **one bug that defeats the entire
|
||||
detach-not-kill design.**
|
||||
|
||||
@@ -1056,9 +1058,9 @@ between `pty-sidecar.mjs:37` and `connect.ts:22` (Pass 1). Four backoff policies
|
||||
sidecar keeps a capped 50KB buffer (`pty-sidecar.mjs:36`, `BUFFER_MAX`) and re-emits it on re-init
|
||||
(`:99-101`); the bridge forwards it as an ordinary `output` frame
|
||||
(`api/terminal/websocket.ts:71-79`), and `Terminal.tsx:181` `term.write()`s it indistinguishably from
|
||||
live output. No dedup, no historical marker. It works, passively. *(INFERRED: survival across a hard
|
||||
live output. No dedup, no historical marker. It works, passively. _(INFERRED: survival across a hard
|
||||
page reload depends on React cleanup not running during navigation teardown — standard behaviour, but
|
||||
not verified against `pagehide` here.)*
|
||||
not verified against `pagehide` here.)_
|
||||
|
||||
Session ids are **chosen by the browser** and persisted server-side through `useDashboardState` →
|
||||
`GET/PATCH /dashboards` (React Query key `['DASHBOARD_STATE']`, `staleTime: Infinity`), so they survive
|
||||
@@ -1072,9 +1074,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
1. **The browser composes shell commands by string concatenation, unescaped.**
|
||||
```ts
|
||||
// Terminal.tsx:187-190
|
||||
const wrapped = onCommandDoneRef.current
|
||||
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
|
||||
: commandRef.current;
|
||||
const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current;
|
||||
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
||||
```
|
||||
That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at
|
||||
@@ -1104,7 +1104,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
|
||||
- **The backend's `cwd` handler is unreachable.** Pass 1 flagged
|
||||
`api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``.
|
||||
Repo-wide grep finds **zero** frontend senders of `{type:'cwd'}` — the browser does its own `cd`
|
||||
composition instead (item 1 above). So that branch is dead, and the capability it implements is
|
||||
composition instead (item 1 above). So that branch is dead, and the permission it implements is
|
||||
duplicated in the client.
|
||||
- **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message
|
||||
and writes `[Session taken over]`, but **no backend code ever emits it** — the only `detached` in
|
||||
@@ -1266,16 +1266,16 @@ settled before any of that code is moved: who is actually meant to talk to the v
|
||||
|
||||
The single most useful thing in this pass. Ranked by frontend compliance:
|
||||
|
||||
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|
||||
|---|---|---|
|
||||
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
|
||||
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
|
||||
| opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic |
|
||||
| claude | ✗ ≈2,300 lines | ✅ mostly clean; vocabulary leaks only |
|
||||
| email | ✗ ≈2,875 lines, no proxy | ◐ routes clean, payloads and realtime not |
|
||||
| pty | ✗ ~all of 169 lines | ◐ clean boundary, but shell composition + a session leak |
|
||||
| vnc | ✗ blocked on a missing listener | ✅ 149 lines, one route, no internals |
|
||||
| vault | ◐ partial | — no frontend exists |
|
||||
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|
||||
| -------- | ------------------------------- | -------------------------------------------------------- |
|
||||
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
|
||||
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
|
||||
| opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic |
|
||||
| claude | ✗ ≈2,300 lines | ✅ mostly clean; vocabulary leaks only |
|
||||
| email | ✗ ≈2,875 lines, no proxy | ◐ routes clean, payloads and realtime not |
|
||||
| pty | ✗ ~all of 169 lines | ◐ clean boundary, but shell composition + a session leak |
|
||||
| vnc | ✗ blocked on a missing listener | ✅ 149 lines, one route, no internals |
|
||||
| vault | ◐ partial | — no frontend exists |
|
||||
|
||||
**A thin proxy alone does not produce a clean frontend — it relocates the problem.** When the platform
|
||||
translates (claude, opencode, email), the browser receives Officer-shaped data and stays clean at the
|
||||
@@ -1285,7 +1285,7 @@ because **the sidecar exposes Officer-shaped routes** — the `/_officer/*` name
|
||||
proxying the upstream one.
|
||||
|
||||
So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete
|
||||
version is: *the sidecar owns the contract the browser consumes.* Thinning a router without adding
|
||||
version is: _the sidecar owns the contract the browser consumes._ Thinning a router without adding
|
||||
`/_officer/*` routes to the sidecar just moves domain logic from the platform into the browser, which is
|
||||
strictly worse — it is further from the data and unversioned.
|
||||
|
||||
@@ -1294,13 +1294,13 @@ strictly worse — it is further from the data and unversioned.
|
||||
Pass 1 found an architecture problem. Pass 2 mostly finds a **resilience** problem — and it is
|
||||
per-socket rather than systemic:
|
||||
|
||||
| channel | reconnect | replay |
|
||||
|---|---|---|
|
||||
| chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) |
|
||||
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
|
||||
| cliamp audio WS | ✗ none | — n/a (live capture) |
|
||||
| desktop / VNC WS | ✗ **none at all** | — n/a |
|
||||
| email SSE | ✗ none, and no `id:` field server-side | ✗ so native `EventSource` retry can't replay either |
|
||||
| channel | reconnect | replay |
|
||||
| ---------------------------- | -------------------------------------- | --------------------------------------------------- |
|
||||
| chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) |
|
||||
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
|
||||
| cliamp audio WS | ✗ none | — n/a (live capture) |
|
||||
| desktop / VNC WS | ✗ **none at all** | — n/a |
|
||||
| email SSE | ✗ none, and no `id:` field server-side | ✗ so native `EventSource` retry can't replay either |
|
||||
|
||||
Two channels have thought-through recovery; three have none. The chat implementation is the template
|
||||
and it already works — see the isolation document, where this finding shortened the plan.
|
||||
@@ -1368,9 +1368,9 @@ Recorded because they surfaced during the audit, not because they're in scope:
|
||||
2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()`
|
||||
(`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves
|
||||
the pty on stale dimensions until the next reconnect.
|
||||
3. **opencode model capabilities shown to the user are placeholder constants.**
|
||||
3. **opencode model permissions shown to the user are placeholder constants.**
|
||||
`api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192,
|
||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ A sidecar is a **PM2 peer of `officer`** — never a child. It dials _in_; offic
|
||||
```
|
||||
PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||
→ it opens a WS to officer at /api/sidecar/register
|
||||
→ it sends { type:'register', name, capabilities[] }
|
||||
→ it sends { type:'register', name, permissions[] }
|
||||
→ officer replies { type:'registered', id }
|
||||
→ it sends { type:'<name>:server', port } (HTTP sidecars only)
|
||||
→ officer remembers the port and proxies <prefix>/* to it
|
||||
@@ -26,10 +26,10 @@ PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||
Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is
|
||||
`src/servers/sidecar/connect.ts`.
|
||||
|
||||
**Nothing in this path is officer starting a process.** `waitForCapability` in the registry says so
|
||||
**Nothing in this path is officer starting a process.** `waitForPermission` in the registry says so
|
||||
explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`,
|
||||
`spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_,
|
||||
handled by waiting up to 15s for a capability to appear rather than failing the first request after boot.
|
||||
handled by waiting up to 15s for a permission to appear rather than failing the first request after boot.
|
||||
|
||||
---
|
||||
|
||||
@@ -56,11 +56,11 @@ the reconnect loop. The ecosystem file says so in a comment, which is the right
|
||||
Four things, and three of them fail loudly if missed.
|
||||
|
||||
1. **A PM2 entry** in `ecosystem.config.cjs` (`script: 'bun'`, `args: 'run src/servers/sidecar/<n>/index.ts'`).
|
||||
2. **A registration** with a `name` and `capabilities[]`. Officer indexes by capability, not by name —
|
||||
`findSidecarByCapability` is how every caller reaches one.
|
||||
2. **A registration** with a `name` and `permissions[]`. Officer indexes by permission, not by name —
|
||||
`findSidecarByPermission` is how every caller reaches one.
|
||||
3. **A `'<name>:server'` event in `protocol.ts`**, if it serves HTTP. Without it the type does not exist
|
||||
and `createSidecarProxy`'s listener never matches.
|
||||
4. **A capability-registry entry**, if it mounts a router. `assertCapabilityTotality` runs in
|
||||
4. **A permission-registry entry**, if it mounts a router. `assertPermissionTotality` runs in
|
||||
`server.tsx` _before_ `serve()` and **throws**, so a missing entry means the server refuses to boot,
|
||||
naming what is missing. Alternatively an `EXEMPT_API_PREFIXES` entry _with a stated reason_.
|
||||
|
||||
@@ -102,19 +102,19 @@ contains two entrypoints that register as _different sidecars_:
|
||||
|
||||
| File | PM2 entry | Registers as | What it is |
|
||||
| ------------------------- | ------------------------- | ------------------------------------ | ---------------------------------------------------- |
|
||||
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, capability `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||
| `claude/user-instance.ts` | `officer-agent` | capability `['claude']` | The process that actually spawns `claude` |
|
||||
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, permission `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||
| `claude/user-instance.ts` | `officer-agent` | permission `['claude']` | The process that actually spawns `claude` |
|
||||
|
||||
So **capability `proxy` is the Anthropic proxy, and capability `claude` is the agent.** Nothing named
|
||||
"claude" registers the `claude` capability from `claude/index.ts`, which is exactly the sort of thing
|
||||
So **permission `proxy` is the Anthropic proxy, and permission `claude` is the agent.** Nothing named
|
||||
"claude" registers the `claude` permission from `claude/index.ts`, which is exactly the sort of thing
|
||||
that reads as a bug in a grep and is not one.
|
||||
|
||||
That resolves the special-casing: `isConnected()` returns "a sidecar with capability `proxy` exists" —
|
||||
That resolves the special-casing: `isConnected()` returns "a sidecar with permission `proxy` exists" —
|
||||
i.e. **the Anthropic proxy is up**, which is _not_ the same as "the agent is up", though the name reads
|
||||
that way. `[verified]` It currently has **no callers** outside the registry itself, so nothing is
|
||||
misreading it today. Worth either renaming or deleting before something starts trusting the name.
|
||||
|
||||
`registerSidecar` also fires a notification when a registration includes capability `claude`
|
||||
`registerSidecar` also fires a notification when a registration includes permission `claude`
|
||||
(`sidecar-registry.ts:75`) — "a new agent process has come up". That one is correctly aimed at the agent.
|
||||
|
||||
---
|
||||
@@ -135,11 +135,11 @@ lines?
|
||||
|
||||
## Open questions, in the order I would answer them
|
||||
|
||||
1. ~~What provides the `proxy` capability~~ — **answered above**: the Anthropic proxy, not the agent.
|
||||
1. ~~What provides the `proxy` permission~~ — **answered above**: the Anthropic proxy, not the agent.
|
||||
`isConnected()` has no callers; rename or delete it before its name misleads someone.
|
||||
2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the
|
||||
officer side was?
|
||||
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForCapability`
|
||||
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForPermission`
|
||||
throws after 15s; who catches it, and what does the user see?
|
||||
4. **Is the `PORT ?? '5000'` fallback reachable**, and should it fail loudly instead?
|
||||
5. **`sweepStaleServes` is `/proc`-based and a no-op on macOS** (already noted in the OpenCode parity
|
||||
@@ -149,13 +149,13 @@ lines?
|
||||
|
||||
## Verified facts this document rests on
|
||||
|
||||
| Claim | How |
|
||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
|
||||
| 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` |
|
||||
| `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` |
|
||||
| Officer spawns nothing | `waitForCapability` comment; no spawn call in the registry |
|
||||
| Ports change across restarts and officer follows | observed live tonight across five photos restarts |
|
||||
| Boot fails on a missing capability entry | `assertCapabilityTotality` throws before `serve()` |
|
||||
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
||||
| `isConnected()` has no callers outside the registry | grep across `src/servers` |
|
||||
| Claim | How |
|
||||
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
|
||||
| 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` |
|
||||
| `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` |
|
||||
| Officer spawns nothing | `waitForPermission` comment; no spawn call in the registry |
|
||||
| Ports change across restarts and officer follows | observed live tonight across five photos restarts |
|
||||
| Boot fails on a missing permission entry | `assertPermissionTotality` throws before `serve()` |
|
||||
| `sidecar/claude/` is two processes with different permissions | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
||||
| `isConnected()` has no callers outside the registry | grep across `src/servers` |
|
||||
|
||||
@@ -8,13 +8,13 @@ vocabulary left between them. Every HTTP sidecar shares one `createSidecarProxy`
|
||||
happened is the part this document is actually about — fixed ports, the platform reading a table instead
|
||||
of being told at runtime, and `.env` feature toggles. Ports are still ephemeral and still announced.
|
||||
|
||||
Not to be confused with `sidecar-audit-2026-07.md`, which is the *audit* of the
|
||||
Not to be confused with `sidecar-audit-2026-07.md`, which is the _audit_ of the
|
||||
current state (what's misplaced, and where). This is where it's going.
|
||||
|
||||
## The premise that makes it simple
|
||||
|
||||
**The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand —
|
||||
friends and family included. Authentication *inside* that boundary is solving a problem we don't have, so
|
||||
friends and family included. Authentication _inside_ that boundary is solving a problem we don't have, so
|
||||
this design has no token work in it at all. Sidecars trust their caller exactly as they do today; the trust
|
||||
boundary just moves from loopback to the tailnet.
|
||||
|
||||
@@ -22,7 +22,7 @@ Until that lands, things stay exposed as they are now. The security model is del
|
||||
|
||||
## The design
|
||||
|
||||
1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay *peers* of
|
||||
1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay _peers_ of
|
||||
`officer` — never children. This is not a style preference: officer used to spawn the agent itself,
|
||||
which made it a grandchild, and PM2's tree-kill took the owner's chat session down on every restart.
|
||||
That was the worst thing about working on the platform, and it is fixed. Don't reintroduce it.
|
||||
@@ -43,7 +43,7 @@ The point of the exercise, and the reason it's worth doing:
|
||||
- `sidecar/connect.ts` — the dial-out-and-register loop, plus its per-sidecar reconnect backoff copies
|
||||
- every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`,
|
||||
`pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started`
|
||||
- most of `sidecar-registry.ts` — discovery, the pending-command map, capability lookup
|
||||
- most of `sidecar-registry.ts` — discovery, the pending-command map, permission lookup
|
||||
- officer's proxying for anything that isn't auth or layout state
|
||||
|
||||
## Migration order
|
||||
@@ -60,14 +60,14 @@ least urgent anyway.
|
||||
|
||||
- **Ecosystem file, or a shared table?** The platform parsing `ecosystem.config.cjs` couples the app to
|
||||
PM2 being the thing that started it, which matters for containerising this later and for `bun dev`.
|
||||
The alternative is one plain TypeScript table (name, script, port, capability, enabled) that
|
||||
The alternative is one plain TypeScript table (name, script, port, permission, enabled) that
|
||||
`ecosystem.config.cjs` generates its `apps:` array from and the platform imports directly — same single
|
||||
source of truth, no supervisor coupling. **Recommended, not yet decided.**
|
||||
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the capabilities/items
|
||||
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the permissions/items
|
||||
store, the chat session list, the file browser. Each needs a named home or officer quietly stays fat.
|
||||
- **Does the registration socket survive?** Not needed for discovery once ports are static. Possibly worth
|
||||
keeping for liveness — or replace it with a health probe on the known port.
|
||||
- **Capabilities.** Today a sidecar announces `capabilities: ['music']` and officer looks up by capability,
|
||||
- **Permissions.** Today a sidecar announces `permissions: ['music']` and officer looks up by permission,
|
||||
not by name — which is what let the agent's PM2 name change from `officer-claude` to `officer-agent`
|
||||
without touching a caller. In a static table it collapses to a column. Keep it; it's cheap.
|
||||
- **The non-owner account class may become dead weight.** `NON_OWNER_PATHS`, the music-only account
|
||||
@@ -81,7 +81,7 @@ Recorded so they aren't re-litigated:
|
||||
- **Platform spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the
|
||||
platform only reads the topology.
|
||||
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
|
||||
the original points 6–8. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its
|
||||
the original points 6–8. Dropped with the tailnet decision. Worth knowing _why_ it was weak even on its
|
||||
own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down
|
||||
at login, or has to be told about a logout.
|
||||
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
|
||||
|
||||
+31
-31
@@ -1,7 +1,7 @@
|
||||
# Working on Officer
|
||||
|
||||
The guide for anyone — human or agent — changing this deployment. It assumes you are working from the
|
||||
root of the install (the directory holding `platform/`, `capabilities/` and `data/`), which is where
|
||||
root of the install (the directory holding `platform/`, `permissions/` and `data/`), which is where
|
||||
agent sessions start.
|
||||
|
||||
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
||||
@@ -9,7 +9,7 @@ Three directories sit there, and knowing which one a change belongs in is most o
|
||||
```
|
||||
$OFFICER_ROOT/
|
||||
├── platform/ the application — a git repo
|
||||
├── capabilities/ what the agent can do — a separate git repo
|
||||
├── permissions/ what the agent can do — a separate git repo
|
||||
├── data/ runtime state — NOT version controlled
|
||||
├── dockers/ containers the app store provisioned
|
||||
└── secrets/ the key store — 0600, and NOT in your data backup
|
||||
@@ -22,9 +22,9 @@ why `assertInstallLayout` refuses to boot from the wrong directory.
|
||||
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
|
||||
admits **additional accounts holding a strict subset of it**, governed by per-role permission grants.
|
||||
|
||||
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea,
|
||||
So "which user" has three answers depending on the surface. For the **app** permissions (gitea,
|
||||
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
|
||||
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
|
||||
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
|
||||
@@ -35,15 +35,15 @@ That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. T
|
||||
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
|
||||
|
||||
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
|
||||
and five non-owner accounts are live; treat the capability registry as the source of truth over any
|
||||
and five non-owner accounts are live; treat the permission registry as the source of truth over any
|
||||
prose, here or elsewhere.
|
||||
|
||||
## What is switched off (2026-08-13)
|
||||
|
||||
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
||||
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
|
||||
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim
|
||||
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot.
|
||||
plugin, and every plugin router is commented out in `hono.ts` with its permission's `api` claim
|
||||
commented beside it — they must move together or `assertPermissionTotality` refuses to boot.
|
||||
|
||||
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
|
||||
extraction into the plugin system.
|
||||
@@ -54,7 +54,7 @@ Also gone: the four ecosystem files (generated now, at setup, and gitignored), o
|
||||
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
|
||||
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
|
||||
|
||||
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
`platform/` and `permissions/` each have their own `CLAUDE.md` with detail. This file is the layer
|
||||
above them: where things live, how to change them safely, and the things that are true of the running
|
||||
system but written down nowhere else.
|
||||
|
||||
@@ -62,19 +62,19 @@ system but written down nowhere else.
|
||||
|
||||
## Which directory does this change belong in?
|
||||
|
||||
**`capabilities/` — almost always start here.** Tasks, tools, skills, processes. It is *data*: plain
|
||||
**`permissions/` — almost always start here.** Tasks, tools, skills, processes. It is _data_: plain
|
||||
directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task
|
||||
does, renaming a category — none of that needs a code change or a restart.
|
||||
|
||||
**`platform/` — only when the mechanism itself is missing.** If a task needs a form control that
|
||||
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a *capability* is not.
|
||||
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a _permission_ is not.
|
||||
|
||||
**`data/` — never edit by hand.** `DATA_PATH`. Holds the owner's managed home, per-account email
|
||||
SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here
|
||||
destroys the only copy.
|
||||
|
||||
A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`.
|
||||
Which tasks exist and what they're called → `capabilities/`. Everything else → `platform/`.
|
||||
Which tasks exist and what they're called → `permissions/`. Everything else → `platform/`.
|
||||
|
||||
## Git
|
||||
|
||||
@@ -87,7 +87,7 @@ support it needs, and a half-pushed pair leaves the deployment inconsistent.
|
||||
Keep history linear: `git pull --rebase`, not `git merge`. The remote moves — the owner develops on
|
||||
this box too — so expect to rebase before pushing. Say so before force-pushing anything.
|
||||
|
||||
Commit messages: simple lowercase, no prefixes, explaining *why*.
|
||||
Commit messages: simple lowercase, no prefixes, explaining _why_.
|
||||
|
||||
---
|
||||
|
||||
@@ -137,13 +137,13 @@ This trips people up repeatedly. It is also why script tasks are handed `OFFICER
|
||||
|
||||
### Services this box depends on
|
||||
|
||||
| port | what | used by |
|
||||
|------|------|---------|
|
||||
| 9010 | Officer itself | — |
|
||||
| 9002 | Kokoro TTS | text-to-speech |
|
||||
| 8178 | whisper.cpp | transcription |
|
||||
| 1234 | vision model (Qwen2.5-VL) | OCR |
|
||||
| 5432 | Postgres | the database |
|
||||
| port | what | used by |
|
||||
| ---- | ------------------------- | -------------- |
|
||||
| 9010 | Officer itself | — |
|
||||
| 9002 | Kokoro TTS | text-to-speech |
|
||||
| 8178 | whisper.cpp | transcription |
|
||||
| 1234 | vision model (Qwen2.5-VL) | OCR |
|
||||
| 5432 | Postgres | the database |
|
||||
|
||||
URLs live in Settings → System, stored in the `server_config` table — **not** in `.env`. If
|
||||
transcription or OCR fails, check the service is up before reading any code.
|
||||
@@ -155,22 +155,22 @@ transcription or OCR fails, check the service is up before reading any code.
|
||||
This is what most requests will be about. Tasks appear in the file browser's right-click menu under
|
||||
**Run Task**, grouped into submenus by category.
|
||||
|
||||
A task is a directory under `capabilities/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
|
||||
A task is a directory under `permissions/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
|
||||
and, for script mode, a sibling `run.sh` / `run.py` / `index.ts`. **The directory name is the task's
|
||||
identity**; renaming it breaks every reference to it.
|
||||
|
||||
`capabilities/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
|
||||
`permissions/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
|
||||
used heavily by real tasks and appear nowhere in it:
|
||||
|
||||
| convention | what it does |
|
||||
|---|---|
|
||||
| `category: Video` | which submenu the task appears in. Order comes from `capabilities/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
|
||||
| `inline: true` | runs ephemerally in the modal instead of becoming a job |
|
||||
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
||||
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
|
||||
| `INPUT_GROUP_CONFIG` | per-track-layout config for folder-wide video runs |
|
||||
| input types `audio_tracks`, `subtitle_tracks`, `subtitle_edit` | render real track pickers, driven by probing the file |
|
||||
| `config.perGroupTracks`, `config.folderKeepAll`, `config.perGroupAllFiles` | opt into the folder track-picker behaviour — on **script** tasks, though the docs describe `config` as pipeline-only |
|
||||
| convention | what it does |
|
||||
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `category: Video` | which submenu the task appears in. Order comes from `permissions/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
|
||||
| `inline: true` | runs ephemerally in the modal instead of becoming a job |
|
||||
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
||||
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
|
||||
| `INPUT_GROUP_CONFIG` | per-track-layout config for folder-wide video runs |
|
||||
| input types `audio_tracks`, `subtitle_tracks`, `subtitle_edit` | render real track pickers, driven by probing the file |
|
||||
| `config.perGroupTracks`, `config.folderKeepAll`, `config.perGroupAllFiles` | opt into the folder track-picker behaviour — on **script** tasks, though the docs describe `config` as pipeline-only |
|
||||
|
||||
Inputs reach a script twice: positionally in `args` order, and as `INPUT_<NAME>` environment
|
||||
variables.
|
||||
@@ -190,7 +190,7 @@ Two ways a task does work:
|
||||
House style for file-processing tasks, worth keeping consistent:
|
||||
|
||||
- Never delete or modify the source; write output beside it.
|
||||
- Handle a single file *and* a directory, recursively.
|
||||
- Handle a single file _and_ a directory, recursively.
|
||||
- Honour `INPUT_INCLUDE`.
|
||||
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
|
||||
overwrites edits.
|
||||
|
||||
@@ -165,7 +165,7 @@ and the rename sequence leaves `workspaces` with no zombie.
|
||||
sequence after fixing.
|
||||
**Resolved, and verified as the specific sequence:** create `zombie-test`, then PATCH
|
||||
`{ws-layout-renamed: …, ws-layout-zombie-test: null, ws-terminals-zombie-test: null,
|
||||
ws-host-terminals-zombie-test: null}` in one body. `workspaces` comes back as
|
||||
ws-host-terminals-zombie-test: null}` in one body. `workspaces` comes back as
|
||||
`['agent-mvp', 'renamed']`.
|
||||
|
||||
---
|
||||
@@ -174,7 +174,7 @@ and the rename sequence leaves `workspaces` with no zombie.
|
||||
|
||||
`dashboards` is **empty (0 rows)** today, so none of this has fired yet. Members can now sign in
|
||||
(`d8ee678`), so it is a matter of time. Note `TODO.md`'s preamble still says the platform is collapsing
|
||||
to single-user — that predates the capability permission model and should not be used to deprioritise
|
||||
to single-user — that predates the permission permission model and should not be used to deprioritise
|
||||
these.
|
||||
|
||||
> **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical.
|
||||
@@ -184,7 +184,7 @@ these.
|
||||
>
|
||||
> It also puts this section in **direct contradiction with `CLAUDE.md`**, which opens by calling
|
||||
> single-user "a hard invariant, not a stage" — no roles, no per-user isolation, and "if a change seems
|
||||
> to need *which user is this*, the answer is always the owner." Five rows in `users` says otherwise.
|
||||
> to need _which user is this_, the answer is always the owner." Five rows in `users` says otherwise.
|
||||
> One of the two documents is wrong about what this platform is, and that is a **product question, not a
|
||||
> defect**: the answer decides whether the item below is urgent or should be deleted along with the rest
|
||||
> of the section.
|
||||
@@ -371,7 +371,7 @@ playback, transcodes — not as a prerequisite for agent coordination._
|
||||
|
||||
**Measured `56ca411`.** `WorkspaceRenderer.test.tsx` mounts the real renderer against a mount-counting
|
||||
probe app and lets the real `layout-utils` mutators produce the "after" tree. The table below was written
|
||||
from reading the code; the test disagrees with its *diagnosis* in every row, and found one row it had
|
||||
from reading the code; the test disagrees with its _diagnosis_ in every row, and found one row it had
|
||||
missed entirely. Read this paragraph before acting on the bullets underneath it.
|
||||
|
||||
- **The key is not the cause.** A panel's React identity is its position plus `key={child.node.id}` on its
|
||||
@@ -391,7 +391,7 @@ missed entirely. Read this paragraph before acting on the bullets underneath it.
|
||||
host that does not move when the tree reshapes, the way maximize is a CSS toggle on the same element.
|
||||
That is a redesign, not a patch, and it is still Tier C.
|
||||
|
||||
The original table, kept because its *observations* hold even where its explanation did not:
|
||||
The original table, kept because its _observations_ hold even where its explanation did not:
|
||||
|
||||
| operation | remounts? | why |
|
||||
| ------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
||||
@@ -410,8 +410,7 @@ The original table, kept because its *observations* hold even where its explanat
|
||||
Kills rows 2 and 3.~~ **Withdrawn `56ca411`** — measured, and it kills neither. The element type at
|
||||
that position changes too, which React acts on first. It would also collide a panel id with a group
|
||||
id, and a panel id is an agent's address now.
|
||||
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row
|
||||
4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
|
||||
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row 4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
|
||||
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
|
||||
(`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`.
|
||||
- [ ] **Reference for how it should feel:** maximize (`PanelSlot.tsx:430-457`) is a CSS state toggle on
|
||||
@@ -463,6 +462,7 @@ The original table, kept because its *observations* hold even where its explanat
|
||||
that passes and reaches `PanelSlot.tsx:311-317`, which on a `locked` screen renders an empty
|
||||
teal-bordered box with no picker and no way for the user to recover. - `screens/QrTransferScreen.tsx:19-39` has the guard but no persist-back, so it re-normalises on
|
||||
every mount forever and never heals the row.
|
||||
|
||||
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
|
||||
`defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()`
|
||||
in the core, and the 6-entry template array at `DashboardPreview.tsx:33-142`.
|
||||
@@ -520,7 +520,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client
|
||||
**discarded** — 3 SELECTs per splitter release, thrown away.
|
||||
**Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that
|
||||
body, and a caller that did would be reading state assembled *before* whatever concurrent write it
|
||||
body, and a caller that did would be reading state assembled _before_ whatever concurrent write it
|
||||
raced. The client refetches **on focus**, with three non-default guards, because this cache is
|
||||
optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value
|
||||
already on screen — the same lost-update shape as the two items above, and self-healing only until
|
||||
@@ -529,7 +529,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
and `refetchOnWindowFocus` gated on a module-level in-flight count plus a 2 s quiet period.
|
||||
- [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly
|
||||
(`100 / newChildren.length`), so one split discards carefully tuned proportions.
|
||||
**Resolved `abea7a3`** — the new sibling takes half of the *target's* size and nothing else moves.
|
||||
**Resolved `abea7a3`** — the new sibling takes half of the _target's_ size and nothing else moves.
|
||||
One helper serves both call sites, because the drop path (`movePanel` → `insertPanel`) carried the
|
||||
identical bug. Two of the three tests were already in `layout-utils.test.ts` asserting the even
|
||||
split, written to the old behaviour deliberately; they now assert the new one. The third documents
|
||||
@@ -539,11 +539,11 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
`let tplCounter = 0; const tplUid = () => \`tpl-${++tplCounter}\`` — module-level, no entropy,
|
||||
resets every page load. Two dashboards created from templates after a reload hold panels with
|
||||
**identical ids**, and panel id keys `terminal-conn-${panelId}`and`file-viewer:${panelId}`.
|
||||
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
|
||||
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
|
||||
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
|
||||
this happened, and the collision was no longer only a settings mix-up — `agent_panels` addresses a
|
||||
panel by `(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
||||
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
|
||||
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
|
||||
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
|
||||
this happened, and the collision was no longer only a settings mix-up — `agent_panels`addresses a
|
||||
panel by`(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
||||
could hand two different agents the same address.
|
||||
|
||||
### 5.6 Registry
|
||||
@@ -557,7 +557,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
(`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships
|
||||
and named if it somehow does. Confirmed: all 44 keys are unique today, and the test says so.
|
||||
Getting the real list into a test needed one thing beyond exporting it: `test-setup.ts` was not
|
||||
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at *import* time, so the
|
||||
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at _import_ time, so the
|
||||
whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything
|
||||
else that pulls in a panel app.
|
||||
- [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with
|
||||
@@ -581,7 +581,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
||||
`availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say
|
||||
"No file selected" forever.
|
||||
**Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file
|
||||
viewer that users actually see is mounted by `useFileViewerPanels` as an *ephemeral* panel, which
|
||||
viewer that users actually see is mounted by `useFileViewerPanels` as an _ephemeral_ panel, which
|
||||
supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from
|
||||
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
|
||||
(checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero
|
||||
@@ -624,7 +624,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
`TaskRunnerModal.tsx:1320` renders a Stop button while `phase === 'running'`. And a bare `ws.close()`
|
||||
is not abandonment: `task-executor.ts:303-309` kills the process tree on socket close, the same
|
||||
`killTree` the Stop button reaches. What is true is the last clause: there is no re-attach, so an
|
||||
inline run dies with its modal. That is defensible — inline is the *ephemeral* mode and the job path
|
||||
inline run dies with its modal. That is defensible — inline is the _ephemeral_ mode and the job path
|
||||
exists for everything else — so this is left alone deliberately rather than left undone.
|
||||
- [ ] **`VideoPlayer` kills the transcode on incidental unmount.** `apps/Jellyfin/VideoPlayer.tsx:217-223`
|
||||
POSTs `stopped`, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2 —
|
||||
@@ -639,7 +639,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
- [x] **`PanelSlot` defines a component inside render.** — _resolved `c0fae47`_. `DefaultHeader` is gone: the
|
||||
header is now an element, not a component type, so there is nothing for React to fail to match.
|
||||
- [x] **The context value is a fresh literal.** — _resolved `c0fae47`_. `useMemo` over the eighteen members.
|
||||
Note what it does *not* buy: the value still changes whenever `layout` does, because half the
|
||||
Note what it does _not_ buy: the value still changes whenever `layout` does, because half the
|
||||
callbacks close over it. What it stops is the renders that change nothing a panel can see — the
|
||||
ephemeral pane opening, a mobile panel switch, every frame of a maximize animation.
|
||||
|
||||
@@ -736,7 +736,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
|
||||
touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound.
|
||||
|
||||
**§5.9 is closed as of 2026-08-07.** The context is 15 fields, and the outbound half is `workspace`,
|
||||
`cwd`, `root` — all three facts about *where the panel is*, which is the one thing a framework of this
|
||||
`cwd`, `root` — all three facts about _where the panel is_, which is the one thing a framework of this
|
||||
shape genuinely owes an app. Nothing left on it is an app's vocabulary: the file-browser pair is
|
||||
deleted, the chat's system prompt is a prop on the chat, and the key three apps used to parse is a
|
||||
parsed identity. The two hand-written copies of the inert half are one named constant.
|
||||
@@ -759,7 +759,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
|
||||
Email can supply a pre-configured chat by panel id.
|
||||
**Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in
|
||||
`components` under the chat panel's id and pass the prefix as a prop. `PanelSlot` prefers a
|
||||
`components` entry over the registry for the *body* only, so the panel keeps its registry header —
|
||||
`components` entry over the registry for the _body_ only, so the panel keeps its registry header —
|
||||
the screens did not have to reproduce any chrome. `ChatPanelWrapper` is exported from the barrel
|
||||
for it. The same prop came off `WorkspaceLayout`, where it had no callers at all: every settings
|
||||
pane and job detail rendering through it had always been passing its chat panels `undefined`.
|
||||
@@ -793,7 +793,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
|
||||
workspace, plus the state those interactions run on — are one exported `inertInteraction`, spread
|
||||
by `WorkspaceLayout` and by the `createContext` default. `root` stays omitted, and that is now a
|
||||
stated decision rather than an oversight: it is only ever read when `cwd` is scoped, and no caller
|
||||
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root *of*.
|
||||
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root _of_.
|
||||
|
||||
### 5.10 Channel hygiene — _(found 2026-08-07)_
|
||||
|
||||
@@ -953,7 +953,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
- [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never
|
||||
written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it
|
||||
wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that
|
||||
needs `bun db:push`, which diffs the *whole* schema, and this tree currently holds another agent's
|
||||
needs `bun db:push`, which diffs the _whole_ schema, and this tree currently holds another agent's
|
||||
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
|
||||
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
|
||||
claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the
|
||||
@@ -1009,17 +1009,17 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
tests in `56ca411`. The Workspace directory is 76 tests across three files and green.
|
||||
**Genuinely still untested: `WorkspaceView` and `PanelSlot`.** But note §5.5's lost updates are no
|
||||
longer what makes that urgent — every mutation in `WorkspaceView` now goes through `onLayoutChange`
|
||||
as an *updater*, never as a computed tree, which is the structural fix; a test there would be
|
||||
as an _updater_, never as a computed tree, which is the structural fix; a test there would be
|
||||
guarding the fix rather than finding the bug. Checked, not assumed — `bun test src/workspaces/officerdev/src/components/Workspace/`.
|
||||
|
||||
- [x] **`useDashboardState`, and the strongest argument this section has for itself.** _(`4f8046d`,
|
||||
branch `agent-coordination-mvp`)_ — 14 tests over the store every layout and every
|
||||
`config.agentName` is persisted through. They found a live Tier-A-class defect on the first run,
|
||||
in code written three days earlier to *stop* silent write loss: `revert` decided whether to roll
|
||||
in code written three days earlier to _stop_ silent write loss: `revert` decided whether to roll
|
||||
back by asking "does the cache still hold exactly what I wrote?" **by reference**, and
|
||||
`setQueryData` runs React Query's structural sharing, which rebuilds the object it stores rather
|
||||
than keeping the one it was handed. Measured against @tanstack/react-query 5.101.4 — an object
|
||||
value comes back `!==`, a string comes back `===`. So the guard was false for every *container*
|
||||
value comes back `!==`, a string comes back `===`. So the guard was false for every _container_
|
||||
the store exists to hold, and a refused write kept its optimistic value in the cache while the
|
||||
toast said it had been rolled back; the change then vanished at the next reload. Only primitives
|
||||
ever reverted, which is exactly why nobody saw it. Replaced with a per-key write sequence, which
|
||||
@@ -1029,7 +1029,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
been read carefully twice — which is the case for §9 stated better than any argument. And
|
||||
`mock.module` is **process-wide and permanent** in Bun: a stub that does not spread the real
|
||||
module deletes exports out from under files that never heard of it. Likewise
|
||||
`@testing-library/react` auto-registers `afterEach(cleanup)` at *import* time, so it lands in
|
||||
`@testing-library/react` auto-registers `afterEach(cleanup)` at _import_ time, so it lands in
|
||||
whichever test file imports the library first and every later file silently gets none — that is
|
||||
now registered in `test-setup.ts`, where preload's lack of a file scope makes it global. Adding
|
||||
one test file broke fourteen assertions in `DataTable.test.tsx` before both were understood.
|
||||
@@ -1037,10 +1037,10 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
- [x] **`WorkspaceView`, and the second consecutive bug a test found that review had not.** _(`bfa9967`,
|
||||
branch `agent-coordination-mvp`)_ — 11 tests over the last untested mutator, driving the real
|
||||
`WorkspaceView` through the real `WorkspaceRenderer` and `PanelSlot`, so the buttons under test
|
||||
are the buttons. Two properties: every layout write is an *updater* rather than a computed tree
|
||||
are the buttons. Two properties: every layout write is an _updater_ rather than a computed tree
|
||||
(two of the paths are deferred — the 500 ms resize debounce, and a window resize firing `onLayout`
|
||||
on every group at once — so a computed tree silently undoes the write before it and resurrects an
|
||||
older `config`); and `usePanelClose` fires on close *intent* only, never on the unmounts a drag,
|
||||
older `config`); and `usePanelClose` fires on close _intent_ only, never on the unmounts a drag,
|
||||
a swap or a mobile switch cause.
|
||||
Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove`
|
||||
**and** `isLastPanel` and used `isLastPanel` only to pick the tooltip: the red button read "Close
|
||||
@@ -1103,7 +1103,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
|
||||
branch `agent-coordination-mvp`)_ — the first two items of §5.1, and the terminal orphan leak with
|
||||
them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from
|
||||
`handleSetApp` when the app actually changes, and from nowhere else.
|
||||
The interesting part is what it is *not*. This item used to propose diffing the layout before and
|
||||
The interesting part is what it is _not_. This item used to propose diffing the layout before and
|
||||
after; two tests now stand in `layout-utils.test.ts` to stop anyone trying it, because `movePanel`
|
||||
mints a fresh panel id on the way and `swapPanels` exchanges contents between stationary ones — so
|
||||
a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||
"db:push": "cd src/databases/officer_db && bun run push",
|
||||
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# Extracting a feature into a plugin
|
||||
|
||||
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||
|
||||
**Read first, in this order:**
|
||||
|
||||
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||
3. `plugins/offscale/` — the worked example, all four parts
|
||||
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||
|
||||
---
|
||||
|
||||
## The rules. These are not preferences
|
||||
|
||||
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||
|
||||
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||
|
||||
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||
|
||||
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||
structural is convention — presence is the declaration:
|
||||
|
||||
```
|
||||
manifest.ts required
|
||||
api/router.ts a backend router, mounted at mountPrefix()
|
||||
db/schema.ts tables, prefixed <app-name>_
|
||||
sidecar/index.ts a process (.mjs instead means node)
|
||||
web/panels.ts panels — REQUIRED with web/
|
||||
web/layout.ts layout — REQUIRED with web/
|
||||
```
|
||||
|
||||
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||
|
||||
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||
|
||||
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||
anywhere else means two systems, and only one gets tested.
|
||||
|
||||
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||
on uninstall. Only deleting a plugin's source can lose its data.
|
||||
|
||||
---
|
||||
|
||||
## The order that worked
|
||||
|
||||
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||
`plugins/<name>/db/`.
|
||||
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||
reference a plugin may make.
|
||||
4. **Write `manifest.ts`.**
|
||||
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||
and `helpers/clipboard` stay as they are.
|
||||
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||
import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||
**both** database barrels (`index.ts` and `schema.ts`).
|
||||
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||
8. **Verify on the live server** — see below.
|
||||
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||
|
||||
---
|
||||
|
||||
## Verification — run all of it
|
||||
|
||||
```
|
||||
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||
|
||||
| Step | Expect |
|
||||
| ------------------------------- | ----------------------------------------------------------- |
|
||||
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||
| the plugin's API | answers |
|
||||
| the plugin's screen | renders as a Workspace |
|
||||
| dock | tile appears |
|
||||
| permissions page | its permission is listed, read/write/none |
|
||||
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||
| enable | comes back |
|
||||
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||
| install again | identical to the first install |
|
||||
|
||||
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||
|
||||
---
|
||||
|
||||
## Traps, all of which cost real time once
|
||||
|
||||
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||
`install.ts`; do not reorder it.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||
pending for the whole protocol.
|
||||
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||
contract change. Regenerate rather than hand-edit.
|
||||
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
|
||||
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
|
||||
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||
`catalogue.ts` is the one to read.
|
||||
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||
it. Find those before you plan the split; they decide it for you.
|
||||
|
||||
---
|
||||
|
||||
## Music is done. What it changed about this runbook
|
||||
|
||||
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||
|
||||
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||
none, so it came out whole and made the job look cleaner than it is.
|
||||
|
||||
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||
not tidy them into a plugin as a warm-up.
|
||||
|
||||
### The global-overlay question is answered, and the answer is no
|
||||
|
||||
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||
gated on its plugin's permission so it switches itself off with the plugin.
|
||||
|
||||
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||
|
||||
---
|
||||
|
||||
## Which one next
|
||||
|
||||
No decision has been made. What the tree says, for whoever picks it:
|
||||
|
||||
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||
is exactly what its extraction needs.
|
||||
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||
|
||||
## Still open, platform-wide. Do not rediscover these
|
||||
|
||||
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||
music would have been the first and cliamp being out of scope is what let it pass.
|
||||
- **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
|
||||
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||
owner's. Its own job, not the platform's.
|
||||
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
|
||||
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||
//
|
||||
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||
export const router = createRouter();
|
||||
|
||||
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||
// the smallest thing that is still a real one: a manifest and one route.
|
||||
//
|
||||
// Everything structural is convention, so this directory IS the documentation:
|
||||
//
|
||||
// manifest.ts you are here — only what a directory listing cannot say
|
||||
// api/router.ts exports `router`; mounted at /api/example
|
||||
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||
// web/Router.tsx a frontend, if it had one
|
||||
//
|
||||
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||
// code sits.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Example',
|
||||
summary: 'The reference plugin — one route, nothing else',
|
||||
icon: 'Puzzle',
|
||||
color: '#94a3b8',
|
||||
|
||||
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||
permissions: [
|
||||
{
|
||||
key: 'example',
|
||||
label: 'Example',
|
||||
description: 'The reference plugin',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// The reference sidecar: a long-lived process PM2 supervises.
|
||||
//
|
||||
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||
//
|
||||
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||
// it by permission (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||
// installer, and adding a socket here would test Bun rather than that.
|
||||
|
||||
const name = 'officer-example';
|
||||
console.log(`[${name}] started (pid ${process.pid})`);
|
||||
|
||||
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||
|
||||
const shutdown = (signal: string) => {
|
||||
console.log(`[${name}] ${signal} — exiting`);
|
||||
clearInterval(beat);
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
// The second panel, reading the URL rather than being told by its sibling.
|
||||
//
|
||||
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||
// independently; nothing is passed between them, so they cannot disagree.
|
||||
export const ExampleDetail = () => {
|
||||
const { section } = useParams();
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-duck-dark/40">
|
||||
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||
the panel beside it.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||
//
|
||||
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||
export const ExampleOverview = () => {
|
||||
const client = useClient();
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['example', 'ping'],
|
||||
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||
<p className="mt-1 text-sm text-duck-dark/60">
|
||||
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||
</p>
|
||||
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { LayoutNode } from 'officerdev';
|
||||
|
||||
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||
//
|
||||
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||
export const defaultLayout: LayoutNode = {
|
||||
type: 'group',
|
||||
id: 'example-root',
|
||||
direction: 'horizontal',
|
||||
children: [
|
||||
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Puzzle, ListTree } from 'lucide-react';
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { ExampleOverview } from './ExampleOverview';
|
||||
import { ExampleDetail } from './ExampleDetail';
|
||||
|
||||
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||
//
|
||||
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||
// a rule someone has to remember.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -3,7 +3,7 @@
|
||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||
|
||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
||||
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||
@@ -28,13 +28,13 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
||||
|
||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||
|
||||
| Case | Status | Headers |
|
||||
|---|---|---|
|
||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
| Case | Status | Headers |
|
||||
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||
|
||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
||||
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||
VBR MP3s. No need to scan the file.
|
||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||
|
||||
@@ -51,26 +51,28 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
||||
this instead of walking + ID3-parsing the library itself.
|
||||
|
||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||
|
||||
### 2.1 Manifest — one call, whole library
|
||||
|
||||
```
|
||||
GET /api/music/manifest
|
||||
```
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"version": 1,
|
||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||
"generatedAt": 1785034701973, // ms; when the index was last built
|
||||
"albums": {
|
||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||
// …
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||
@@ -82,14 +84,16 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
||||
```
|
||||
GET /api/music/meta?path=<rel>
|
||||
```
|
||||
|
||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||
"cover": "cover.jpg", // present only if a cover exists
|
||||
"cover": "cover.jpg", // present only if a cover exists
|
||||
"tracks": [
|
||||
{
|
||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||
"file": "01 Hells Bells.mp3", // filename within the album folder
|
||||
"title": "Hells Bells",
|
||||
"artist": "AC/DC",
|
||||
"albumArtist": "AC/DC",
|
||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
||||
"track": "1",
|
||||
"year": "1980",
|
||||
"durationSec": 312,
|
||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
}
|
||||
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||
},
|
||||
// …
|
||||
],
|
||||
"videos": [ // present only for folders that contain video files
|
||||
"videos": [
|
||||
// present only for folders that contain video files
|
||||
{
|
||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||
"durationSec": 8130,
|
||||
"width": 1280,
|
||||
"height": 720,
|
||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
||||
}
|
||||
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||
},
|
||||
// …
|
||||
]
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||
omitted entirely when the folder has none.
|
||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
||||
```
|
||||
GET /api/music/cover?path=<rel>
|
||||
```
|
||||
|
||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
Only meaningful when the manifest entry has `"cover": true`.
|
||||
|
||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
||||
```
|
||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||
```
|
||||
|
||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
||||
```
|
||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||
```
|
||||
|
||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
||||
```
|
||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||
```
|
||||
|
||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"artist": "Anthrax",
|
||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||
"[1984] Fistful Of Metal": "Studio",
|
||||
"[1985] Armed And Dangerous": "EP",
|
||||
"[1994] The Island Years": "Live",
|
||||
"[1991] Attack Of The Killer B's": "Compilation"
|
||||
"[1991] Attack Of The Killer B's": "Compilation",
|
||||
// …
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
||||
```
|
||||
|
||||
`IndexStatus`:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"running": true,
|
||||
"startedAt": 1785034701973, "finishedAt": null,
|
||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
||||
"startedAt": 1785034701973,
|
||||
"finishedAt": null,
|
||||
"foldersScanned": 45,
|
||||
"albumsBuilt": 12,
|
||||
"albumsSkipped": 3,
|
||||
"tracksIndexed": 320,
|
||||
"videosIndexed": 4,
|
||||
"coversSaved": 12,
|
||||
"postersSaved": 4,
|
||||
"lyricsIndexed": 45,
|
||||
"discographies": 3,
|
||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||
"error": null
|
||||
"error": null,
|
||||
}
|
||||
```
|
||||
|
||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
||||
```
|
||||
GET /api/music/reindex/stream
|
||||
```
|
||||
|
||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||
`IndexReport`) and **closes** the stream.
|
||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
||||
```
|
||||
|
||||
`IndexReport` (the `done` payload):
|
||||
|
||||
```jsonc
|
||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
||||
{
|
||||
"albums": 15,
|
||||
"built": 12,
|
||||
"skipped": 3,
|
||||
"foldersScanned": 45,
|
||||
"tracksIndexed": 320,
|
||||
"coversSaved": 12,
|
||||
"discographies": 3,
|
||||
"elapsedSec": 37.2,
|
||||
"error": null,
|
||||
}
|
||||
```
|
||||
|
||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||
@@ -256,17 +288,21 @@ Unlike everything above (library data served by the sidecar), these are **per-us
|
||||
platform straight from Postgres — same `/api/music` prefix and same auth. Keys are opaque paths the app
|
||||
supplies; the server never interprets them:
|
||||
|
||||
| kind | key |
|
||||
|---|---|
|
||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||
| `artist` | music-rel — `Albums/AC-DC` |
|
||||
| kind | key |
|
||||
| -------- | --------------------------------------------------------------------- |
|
||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||
| `artist` | music-rel — `Albums/AC-DC` |
|
||||
|
||||
### Favorites
|
||||
|
||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||
```json
|
||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
||||
{
|
||||
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||
"artists": ["Albums/AC-DC"]
|
||||
}
|
||||
```
|
||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||
(a repeat add is a no-op).
|
||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
||||
|
||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||
```json
|
||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
||||
{
|
||||
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||
"title": "Hells Bells",
|
||||
"artist": "AC/DC",
|
||||
"album": "Back in Black",
|
||||
"durationSec": 312.5,
|
||||
"positionSec": 140,
|
||||
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||
}
|
||||
```
|
||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||
@@ -299,15 +342,15 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
||||
`<albumRel>/<file>` strings favorites uses — so a playlist survives a reindex as long as the file stays
|
||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||
|
||||
| method | path | body | returns |
|
||||
|---|---|---|---|
|
||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||
| method | path | body | returns |
|
||||
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||
| `PATCH` | `/api/music/playlists/:id` | `{ name }` | rename; `409` if taken |
|
||||
| `DELETE` | `/api/music/playlists/:id` | — | deletes it, items cascade |
|
||||
| `POST` | `/api/music/playlists/:id/items` | `{ keys: [] }` | append → `{ count }` |
|
||||
| `PUT` | `/api/music/playlists/:id/items` | `{ keys: [] }` | replace the whole list → `{ count }` |
|
||||
|
||||
`PUT` is how you reorder or remove: send the list you want, in order. There is no per-item delete.
|
||||
|
||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
||||
|
||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
||||
audio files* is a separate, later feature.)
|
||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||
audio files_ is a separate, later feature.)
|
||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||
@@ -0,0 +1,284 @@
|
||||
# Music — the second plugin
|
||||
|
||||
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||
|
||||
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||
worked second case, and it is interesting mainly for being the messy one.
|
||||
|
||||
---
|
||||
|
||||
## What music is
|
||||
|
||||
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||
free to change.
|
||||
|
||||
```
|
||||
manifest.ts identity, one permission
|
||||
api/router.ts re-exports the platform's proxy — see below
|
||||
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||
web/ two panels and a layout; the shell renders the Workspace
|
||||
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The three things that stayed, and why
|
||||
|
||||
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||
means each one is written down with what would close it.
|
||||
|
||||
### 1. cliamp — parked in the plugin, not left in the platform
|
||||
|
||||
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||
|
||||
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||
|
||||
```
|
||||
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||
api/cliamp/relay.ts → cliamp/relay.ts
|
||||
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||
```
|
||||
|
||||
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||
|
||||
Two things went with it that were live rather than inert:
|
||||
|
||||
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||
its two menu entries are removed; the components are parked here.
|
||||
- **The two socket routes.** They now 404. Verified live.
|
||||
|
||||
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||
|
||||
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||
and never called. A platform gap, not a music one.
|
||||
|
||||
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||
|
||||
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||
`getMusicServerWsUrl` from it, so it could not move.
|
||||
|
||||
That reason was three layers of nothing:
|
||||
|
||||
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||
|
||||
A dead import held a music-named file in the platform. The proxy is now built in
|
||||
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||
|
||||
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||
to know about provenance. It now calls `mountPrefix`.
|
||||
|
||||
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||
|
||||
### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
|
||||
|
||||
The first version of this document said the player stayed in the platform and called the decision
|
||||
settled by a hard constraint:
|
||||
|
||||
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||
> decided about the UI.
|
||||
|
||||
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||
|
||||
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
|
||||
|
||||
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||
|
||||
```tsx
|
||||
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
|
||||
```
|
||||
|
||||
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
|
||||
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
|
||||
|
||||
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
|
||||
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
|
||||
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
|
||||
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
|
||||
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
|
||||
|
||||
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
|
||||
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
|
||||
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
|
||||
to `/music` remounts the host and reloads it.
|
||||
|
||||
## Two bugs, neither visible from reading
|
||||
|
||||
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||
`permissionAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||
installed, enabled and healthy.
|
||||
|
||||
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||
it cost minutes instead of an evening. Entry removed.
|
||||
|
||||
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||
every existing `role_permissions` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||
for the overlay. Renaming it would have been a silent data change.
|
||||
|
||||
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||
|
||||
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||
|
||||
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||
change inside `db/queries.ts`, not a flag on the manifest.
|
||||
|
||||
---
|
||||
|
||||
## Host dependencies — the field music created
|
||||
|
||||
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||
that. That was the wrong answer, because of HOW music fails without them.
|
||||
|
||||
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||
nobody reads. A refusal wearing the costume of a normal result.
|
||||
|
||||
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||
|
||||
```ts
|
||||
osDependencies: [
|
||||
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||
]
|
||||
```
|
||||
|
||||
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||
a single `ffmpeg` before anything reaches a command line.
|
||||
|
||||
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||
recorded why that indirection was rejected.
|
||||
|
||||
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||
named — nothing had happened, so there was nothing to undo.
|
||||
|
||||
`~/Music` still does not exist, so there is no library to index.
|
||||
|
||||
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||
|
||||
---
|
||||
|
||||
## Verified on the live server, 2026-08-15
|
||||
|
||||
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||
|
||||
| Step | Result |
|
||||
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||
| dock | tile present in `/api/user/permissions`; `/music` in `routes`, not in `deniedRoutes` |
|
||||
| permissions page | `music` listed among the grantable |
|
||||
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||
| enable | 200 again, sidecar online, favourites still there |
|
||||
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||
|
||||
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||
exist before.
|
||||
|
||||
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||
|
||||
---
|
||||
|
||||
## Still open
|
||||
|
||||
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||
|
||||
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||
|
||||
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** permission, and `files` is
|
||||
**`confined`** — so:
|
||||
|
||||
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||
library**, because every listing 403s;
|
||||
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||
|
||||
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||
|
||||
Three possible shapes, none chosen:
|
||||
|
||||
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||
Most self-contained, and the most work.
|
||||
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||
but it makes one plugin's grant conditional on another permission, which is new machinery.
|
||||
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||
|
||||
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||
Not tonight's call.
|
||||
|
||||
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||
wire — noted rather than fixed.
|
||||
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||
- **The cliamp sockets are claimed by no permission**, and are served. Now pinned by a test in
|
||||
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||
@@ -0,0 +1,46 @@
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
import { mountPrefix } from '@@/plugins/manifest';
|
||||
import { manifest } from '../manifest';
|
||||
|
||||
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||
//
|
||||
// ── This used to live in the platform, and that was the bug ──
|
||||
//
|
||||
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||
//
|
||||
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||
//
|
||||
// ── The prefix is DERIVED, not written ──
|
||||
//
|
||||
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||
//
|
||||
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||
// become two systems with only one of them tested.
|
||||
//
|
||||
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||
// factory the installer calls instead. Recorded rather than worked around.
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'music',
|
||||
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||
timeoutSeconds: 1800,
|
||||
});
|
||||
|
||||
export const router = proxy.router;
|
||||
|
||||
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+19
-4
@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
ctxRef.current = audioCtx;
|
||||
|
||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||
if (disposed) { audioCtx.close(); return; }
|
||||
if (disposed) {
|
||||
audioCtx.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||
outputChannelCount: [CHANNELS],
|
||||
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
try { wsRef.current?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
wsRef.current?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
wsRef.current = null;
|
||||
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
|
||||
try {
|
||||
nodeRef.current?.disconnect();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
nodeRef.current = null;
|
||||
try { audioCtx?.close(); } catch { /* ignore */ }
|
||||
try {
|
||||
audioCtx?.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
ctxRef.current = null;
|
||||
gainRef.current = null;
|
||||
};
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { Music } from 'lucide-react';
|
||||
import { TerminalView } from '../Terminal/Terminal';
|
||||
import { TerminalView } from 'officerdev';
|
||||
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||
|
||||
export const CliampPanelHeader = () => {
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { ServerWebSocket } from 'bun';
|
||||
import { getMusicServerWsUrl } from '../music/router';
|
||||
import { getMusicServerWsUrl } from '../api/router';
|
||||
|
||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { db } from 'officerdb/db';
|
||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||
|
||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||
@@ -1,5 +1,5 @@
|
||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { users } from '../auth/schema';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||
//
|
||||
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||
//
|
||||
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||
// cliamp/ the second playback path, parked
|
||||
// widgets/ the dashboard widget, parked
|
||||
// assets/icon.png the dock tile, published to /plugins/music/ on install
|
||||
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||
// web/ the library panels; the shell renders the Workspace
|
||||
//
|
||||
// ── What stayed in the platform, and why ── (nothing. All three moved here.)
|
||||
//
|
||||
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||
//
|
||||
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
|
||||
// Parked: plugins cannot contribute widgets and that mechanism is not built.
|
||||
//
|
||||
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
|
||||
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
|
||||
// in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the
|
||||
// audio engine and renders nothing — which is what it already did on /music.
|
||||
//
|
||||
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
|
||||
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
|
||||
// breaks meanwhile.
|
||||
//
|
||||
// ── Host dependencies ──
|
||||
//
|
||||
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Music',
|
||||
summary: 'The music library — browse, play, favourites and playlists',
|
||||
// No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only
|
||||
// ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a
|
||||
// marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph.
|
||||
color: '#22c55e',
|
||||
|
||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||
//
|
||||
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||
// this extraction, so every existing `role_permissions` grant keeps meaning what it meant, and the
|
||||
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||
//
|
||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||
permissions: [
|
||||
{
|
||||
key: 'music',
|
||||
label: 'Music',
|
||||
description: 'The music library, playback, and your own favourites and playlists',
|
||||
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||
//
|
||||
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||
//
|
||||
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||
},
|
||||
],
|
||||
|
||||
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||
//
|
||||
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||
osDependencies: [
|
||||
{
|
||||
binary: 'ffprobe',
|
||||
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||
},
|
||||
{
|
||||
binary: 'ffmpeg',
|
||||
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, basename } from 'node:path';
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { streamAudioFile } from './stream-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
||||
import { ensurePulseAudio } from './pulse-audio';
|
||||
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||
import {
|
||||
reindexNow,
|
||||
@@ -37,10 +37,9 @@ import {
|
||||
addPlaylistItems,
|
||||
setPlaylistItems,
|
||||
type FavoriteKind,
|
||||
} from 'officerdb';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
|
||||
} from '../db/queries';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// ── Per-user state validation ──
|
||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
|
||||
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
// ── Audio-streaming HTTP server ──
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
@@ -476,7 +474,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'music',
|
||||
capabilities: ['music'],
|
||||
handles: ['music'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||
// phone's resync diff (fetch only changed `v`s).
|
||||
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
|
||||
const HOME = homedir();
|
||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
||||
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
||||
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||
if (prev.version !== next.version) {
|
||||
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
|
||||
console.log(
|
||||
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
|
||||
);
|
||||
}
|
||||
|
||||
const { added, removed, changed } = diffManifest(prev, next);
|
||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
|
||||
console.log(
|
||||
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
|
||||
);
|
||||
const sample = (label: string, rels: string[]) => {
|
||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
||||
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||
+3
-1
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
||||
const schedule = () => {
|
||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||
const at = new Date(Date.now() + ms);
|
||||
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
|
||||
console.log(
|
||||
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||
);
|
||||
timer = setTimeout(async () => {
|
||||
console.log('[music] nightly full reindex starting');
|
||||
try {
|
||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
||||
if (cached !== undefined) return cached;
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
|
||||
[
|
||||
'ffprobe',
|
||||
'-v',
|
||||
'error',
|
||||
'-show_entries',
|
||||
'format=duration',
|
||||
'-of',
|
||||
'default=noprint_wrappers=1:nokey=1',
|
||||
absPath,
|
||||
],
|
||||
{ stdout: 'pipe', stderr: 'ignore' },
|
||||
);
|
||||
const out = (await new Response(proc.stdout).text()).trim();
|
||||
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
|
||||
}
|
||||
return new Response(file.slice(start, end + 1), {
|
||||
status: 206,
|
||||
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
|
||||
headers: {
|
||||
...baseHeaders,
|
||||
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||
'Content-Length': String(end - start + 1),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
+17
-4
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { useMusicFavorites } from './useMusicFavorites';
|
||||
import {
|
||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
||||
const albumRel = toRel(albumHome);
|
||||
try {
|
||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||
albumRel,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
player.playQueue(
|
||||
q,
|
||||
Math.max(
|
||||
0,
|
||||
q.findIndex((t) => t.file === file),
|
||||
),
|
||||
);
|
||||
} catch {
|
||||
player.playQueue([{ albumRel, file }], 0);
|
||||
}
|
||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
||||
{empty ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<Heart size={44} className="text-muted-foreground/30" />
|
||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No favorites yet. Click the heart on any artist, album or track.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-6">
|
||||
+18
-7
@@ -4,15 +4,16 @@ import { Link, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
||||
import { WorkspaceLayout } from '../../components/Workspace';
|
||||
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||
import { WorkspaceLayout } from 'officerdev';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { FavoritesView } from './FavoritesView';
|
||||
import { useMusicPlayer } from '../../MusicPlayer';
|
||||
import type { PlayerTrack } from '../../MusicPlayer';
|
||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
import type { PlayerTrack } from './useMusicPlayer';
|
||||
import { LyricsPanel } from './LyricsPanel';
|
||||
import { MusicMiniBar } from './MusicMiniBar';
|
||||
import { MusicPlayerHost } from './MusicPlayerHost';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import {
|
||||
MUSIC_ROOT,
|
||||
MUSIC_FAV_CHANNEL,
|
||||
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
|
||||
<div className="flex h-full flex-col">
|
||||
{content}
|
||||
<MusicMiniBar />
|
||||
{/* The audio engine, mounted HERE rather than by the shell.
|
||||
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
|
||||
nothing while the route is /music — the mini bar above is the transport — so this is purely
|
||||
"something owns the GaplessEngine while the screen is open".
|
||||
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
|
||||
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
|
||||
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
|
||||
registrations are optional-chained and the queue lives in global state, so returning to /music
|
||||
remounts the host and reloads it. */}
|
||||
<MusicPlayerHost />
|
||||
</div>
|
||||
);
|
||||
|
||||
+2
-2
@@ -1,8 +1,8 @@
|
||||
import { useRef } from 'react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { coverUrl, fmtClock } from './shared';
|
||||
import { seekPlayer } from './player-time';
|
||||
import { useLyricsOpen } from './useLyricsOpen';
|
||||
import { useMusicPlayer } from './useMusicPlayer';
|
||||
+6
-6
@@ -1,11 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
|
||||
|
||||
export const MusicPlayerHost = () => {
|
||||
const { token, get, put, delete: del } = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const canUseMusic = can('music');
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
@@ -123,7 +123,7 @@ export const MusicPlayerHost = () => {
|
||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
||||
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
|
||||
//
|
||||
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it
|
||||
// Also skipped without the `music` permission. This host is mounted by the shell for every account, so it
|
||||
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||
useEffect(() => {
|
||||
if (restoredRef.current) return;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { AppRegistryMeta } from 'officerdev';
|
||||
import { Music, ListMusic } from 'lucide-react';
|
||||
import { MusicBrowser } from './MusicBrowser';
|
||||
import { MusicDetail } from './MusicDetail';
|
||||
|
||||
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||
// `layout.ts` — a plugin never renders the screen.
|
||||
//
|
||||
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||
// screen.
|
||||
//
|
||||
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||
];
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
||||
import { useMusicPlayer } from 'officerdev';
|
||||
import type { WidgetRegistryMeta } from 'officerdev';
|
||||
// The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
|
||||
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
|
||||
import type { PlayerTrack } from '../web/useMusicPlayer';
|
||||
import { useMusicPlayer } from '../web/useMusicPlayer';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { Widget } from '../Widget';
|
||||
import { Widget } from 'widgets/Widget';
|
||||
|
||||
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||
@@ -36,7 +39,14 @@ export const MusicPlayer = () => {
|
||||
// Top-level libraries (once).
|
||||
useEffect(() => {
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
||||
.then((r) =>
|
||||
setLibraries(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory')
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
),
|
||||
)
|
||||
.catch(() => setLibraries([]));
|
||||
}, []);
|
||||
|
||||
@@ -50,7 +60,12 @@ export const MusicPlayer = () => {
|
||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||
.then(async (r) => {
|
||||
if (cancelled) return;
|
||||
setDirs(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
||||
setDirs(
|
||||
r.entries
|
||||
.filter((e) => e.type === 'directory')
|
||||
.map((e) => e.name)
|
||||
.sort(),
|
||||
);
|
||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||
if (audio.length) {
|
||||
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
||||
@@ -92,7 +107,12 @@ export const MusicPlayer = () => {
|
||||
};
|
||||
|
||||
const play = (i: number) => {
|
||||
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist }));
|
||||
const queue: PlayerTrack[] = songs.map((t) => ({
|
||||
albumRel: relToMusic,
|
||||
file: t.file,
|
||||
title: t.title,
|
||||
artist: t.artist,
|
||||
}));
|
||||
player.playQueue(queue, i);
|
||||
};
|
||||
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||
@@ -107,7 +127,9 @@ export const MusicPlayer = () => {
|
||||
type="button"
|
||||
onClick={() => selectLibrary(lib)}
|
||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
library === lib
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{lib}
|
||||
@@ -144,7 +166,9 @@ export const MusicPlayer = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-semibold text-foreground">{breadcrumb[breadcrumb.length - 1] ?? library}</p>
|
||||
<p className="truncate text-sm font-semibold text-foreground">
|
||||
{breadcrumb[breadcrumb.length - 1] ?? library}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -0,0 +1,733 @@
|
||||
# Offscale — the first real plugin
|
||||
|
||||
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||
|
||||
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||
history of something it no longer knows exists.
|
||||
|
||||
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
|
||||
|
||||
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||
|
||||
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||
|
||||
---
|
||||
|
||||
## The reframe
|
||||
|
||||
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||
intuition is that it is one plugin requiring two sidecars.
|
||||
|
||||
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||
written on runs five.
|
||||
|
||||
### Two words, because "core" was doing two jobs
|
||||
|
||||
- **baseline** — what a fresh install actually runs
|
||||
- **first-party** — what Officer Dev publishes
|
||||
|
||||
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||
"is X core?" a question with two answers.
|
||||
|
||||
---
|
||||
|
||||
## What a plugin is made of
|
||||
|
||||
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||
|
||||
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||
- an **ID** (see below)
|
||||
- a **sidecar**
|
||||
- a **backend router** and its routes
|
||||
- a **db schema**
|
||||
- **default permissions per user group**
|
||||
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||
- a **frontend router**, its routes, and the frontend code
|
||||
- how it **mounts into the file browser context menu**
|
||||
- a set of **permissions added to officer-items**
|
||||
- **plugin settings page** definitions
|
||||
- an accompanying **mobile app**
|
||||
|
||||
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||
|
||||
### What offscale needs
|
||||
|
||||
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||
|
||||
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
|
||||
|
||||
---
|
||||
|
||||
## Identity and routing
|
||||
|
||||
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||
|
||||
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||
|
||||
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||
Needed because a private plugin never asks the marketplace anything.
|
||||
|
||||
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||
restrictions; publishing is what invites review.
|
||||
|
||||
### Mount prefixes
|
||||
|
||||
```
|
||||
first-party /api/<app-name> e.g. /api/offscale
|
||||
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||
```
|
||||
|
||||
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||
|
||||
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||
become two systems, and only one of them gets tested.
|
||||
|
||||
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||
adding core routes forever without breaking installs.
|
||||
|
||||
---
|
||||
|
||||
## The database
|
||||
|
||||
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||
|
||||
### A Postgres schema per plugin was tested and rejected
|
||||
|
||||
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||
earns. Recorded so nobody re-runs the experiment:
|
||||
|
||||
| Property | Result |
|
||||
| ----------------------------------------------------------------- | ------------------------- |
|
||||
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||
| Cross-schema FK to `public.users` | works |
|
||||
| Partial unique index preserved | works |
|
||||
| Push is idempotent, no spurious re-creation | works |
|
||||
| Cascade delete across the schema boundary | works |
|
||||
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||
|
||||
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||
naming the schema in `schemaFilter` made the identical push work.
|
||||
|
||||
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||
costume of a normal result.
|
||||
|
||||
---
|
||||
|
||||
## Mounting — rebuild and swap, at runtime
|
||||
|
||||
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||
|
||||
### What was actually tested
|
||||
|
||||
| Router | `app.route()` after serving has begun |
|
||||
| -------------------------------- | --------------------------------------------------------------------- |
|
||||
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||
| `RegExpRouter` | **throws**, same reason |
|
||||
| `TrieRouter` | works |
|
||||
| `PatternRouter` | works |
|
||||
|
||||
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||
remove a route**, which uninstall needs.
|
||||
|
||||
### The approach that solves both
|
||||
|
||||
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||
|
||||
```ts
|
||||
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||
|
||||
// install: app = buildApp([...installed, 'offscale'])
|
||||
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||
```
|
||||
|
||||
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||
|
||||
```
|
||||
no plugins /offscale/x -> 404 | /core -> 200
|
||||
installed /offscale/x -> 200 | /core -> 200
|
||||
uninstalled /offscale/x -> 404 | /core -> 200
|
||||
```
|
||||
|
||||
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||
|
||||
### The one line that has to change
|
||||
|
||||
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||
This is the whole mechanical cost.
|
||||
|
||||
### Websockets are a separate table, and they reload
|
||||
|
||||
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||
|
||||
```
|
||||
before reload /api/offscale/ws -> refused | /core -> 200
|
||||
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||
after remove /api/offscale/ws -> refused | /core -> 200
|
||||
```
|
||||
|
||||
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||
|
||||
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||
before a plugin install can interrupt somebody's terminal.
|
||||
|
||||
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||
|
||||
### What this means for `assertPermissionTotality`
|
||||
|
||||
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||
where mounting actually happens instead of once at start-up.
|
||||
|
||||
Two things it must survive, both live today:
|
||||
|
||||
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||
still needs pointing at reality.
|
||||
|
||||
---
|
||||
|
||||
## Permissions
|
||||
|
||||
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
|
||||
|
||||
`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||
platform's to assign.
|
||||
|
||||
### The platform grants read or write. Everything richer is the plugin's own job
|
||||
|
||||
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||
permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in
|
||||
the surface; `write` permits everything.
|
||||
|
||||
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||
means; the platform only knows whether this account got through the door.
|
||||
|
||||
### Offscale v1 uses that model exactly, with nothing added
|
||||
|
||||
One shared resource, role-gated:
|
||||
|
||||
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||
- **write** — can change them, including deleting a server the owner registered
|
||||
|
||||
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||
|
||||
Two implementation consequences, both inside the plugin:
|
||||
|
||||
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||
|
||||
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||
|
||||
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||
delete, policy `PUT` — is a genuine write.
|
||||
|
||||
### Music is where the richer model gets designed
|
||||
|
||||
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||
where offscale's is just "the owner's".
|
||||
|
||||
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||
|
||||
### Several different things were called "capability" here
|
||||
|
||||
A manifest needs three names, not one:
|
||||
|
||||
1. `permissions/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||
3. `sidecar-registry` `handles: ['music']` — **routing keys** for `sendCommand`, renamed from
|
||||
`capabilities` on 2026-08-15
|
||||
|
||||
Offscale needs (1) and (3), and not (2).
|
||||
|
||||
---
|
||||
|
||||
## Secrets
|
||||
|
||||
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||
|
||||
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||
parties touch it.
|
||||
|
||||
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||
the host:
|
||||
|
||||
```ts
|
||||
import { db } from '../db'; // the connection
|
||||
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||
```
|
||||
|
||||
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||
the pilot rather than being invented.
|
||||
|
||||
---
|
||||
|
||||
## `/api/vpn` is being deleted
|
||||
|
||||
Officer had two headscale surfaces:
|
||||
|
||||
| | `/api/vpn` | `/api/headscale` |
|
||||
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||
| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||
| surface | one route, `POST /enroll` | the whole admin API |
|
||||
|
||||
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||
anywhere.** Verified against the mobile monorepo:
|
||||
|
||||
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||
3. `embedded` is optional and defaults to `false`
|
||||
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||
|
||||
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||
does the Officer web app. The live database holds no `vpn` grants.
|
||||
|
||||
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||
|
||||
### Everything collapses to one namespace
|
||||
|
||||
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||
contract has no counterparty.
|
||||
|
||||
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||
|
||||
- **phone → Companion** — untouched by anything here
|
||||
- **web admin → Officer → sidecar** — ours to rename freely
|
||||
|
||||
### There are THREE components, not two
|
||||
|
||||
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||
|
||||
| Component | Repo | Enrolment surface |
|
||||
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||
|
||||
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||
|
||||
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||
in the invite (`https://<domain>/officer-api`).
|
||||
|
||||
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||
`POST /_officer/enroll` handler below it is dead.
|
||||
|
||||
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||
|
||||
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||
is correct. The invite flow supersedes it completely:
|
||||
|
||||
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||
3. the Companion turns that into the redirect the phone app claims
|
||||
4. the device joins
|
||||
|
||||
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||
`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was
|
||||
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||
|
||||
---
|
||||
|
||||
## What headscale actually is — the inventory
|
||||
|
||||
Read end to end on 2026-08-14. This is what has to move.
|
||||
|
||||
### Backend — 2,406 lines
|
||||
|
||||
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||
|
||||
Three things worth knowing before touching it:
|
||||
|
||||
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||
undo.
|
||||
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||
branches on whether the body parses.
|
||||
|
||||
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||
|
||||
### Frontend — 29 files, 27 endpoints
|
||||
|
||||
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||
Diagnostics · Console.
|
||||
|
||||
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||
|
||||
The whole frontend↔host coupling, which becomes the plugin API:
|
||||
|
||||
| Import | Why it matters |
|
||||
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||
| `AppRegistryMeta` | the panel-contribution contract |
|
||||
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
|
||||
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||
|
||||
### `assist.ts` travels, but stays unwired
|
||||
|
||||
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||
later. Do not tidy it away as unused code.
|
||||
|
||||
---
|
||||
|
||||
## The manifest — proposal
|
||||
|
||||
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||
is the floor, not the ceiling.
|
||||
|
||||
```ts
|
||||
// plugins/offscale/manifest.ts
|
||||
export const manifest = {
|
||||
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||
publisher: 'officerdev',
|
||||
/** The plugin's own semver. Updates compare against this. */
|
||||
version: '1.0.0',
|
||||
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||
platform: '>=1.0.0 <2.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// Named `permissions`, NOT `capabilities`. That word meant several different things here — the
|
||||
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes and ACLs',
|
||||
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||
ownerOnly: true,
|
||||
},
|
||||
],
|
||||
} as const;
|
||||
```
|
||||
|
||||
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||
|
||||
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||
|
||||
```
|
||||
web/panels.ts exports appRegistryMetas — at least one panel
|
||||
web/layout.ts exports defaultLayout — how they are arranged
|
||||
```
|
||||
|
||||
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||
name and with the reason:
|
||||
|
||||
```
|
||||
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||
```
|
||||
|
||||
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||
is nowhere to put a screen.
|
||||
|
||||
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||
than rendering another plugin's panel inside this one.
|
||||
|
||||
### Everything the tree can say, the tree says
|
||||
|
||||
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||
|
||||
| Path | Means |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||
|
||||
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||
duplication that could only ever drift.
|
||||
|
||||
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||
field that can contradict the file it describes.
|
||||
|
||||
### Install asks nothing, and that is the default
|
||||
|
||||
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||
and no external service to point at.
|
||||
|
||||
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||
how it works today.
|
||||
|
||||
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||
|
||||
### Dropped from the first draft
|
||||
|
||||
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||
when something consumes it.
|
||||
- **`kind`** — see below.
|
||||
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||
|
||||
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||
|
||||
### `admin` has to be allowed, and the pilot proved it immediately
|
||||
|
||||
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||
the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||
|
||||
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||
nothing is gained by forbidding it.
|
||||
|
||||
Corrected rule:
|
||||
|
||||
| Kind | May a plugin declare it? | Why |
|
||||
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `app` | yes | the ordinary grantable surface |
|
||||
| `admin` | yes | self-restriction, never an escalation |
|
||||
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||
|
||||
### One function decides the prefix
|
||||
|
||||
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||
|
||||
```ts
|
||||
const mountPrefix = (m: Manifest) =>
|
||||
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||
```
|
||||
|
||||
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||
|
||||
### Notes on the fields
|
||||
|
||||
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||
degrade. This is for the human reading the store.
|
||||
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||
|
||||
---
|
||||
|
||||
## What is built — complete, as of 2026-08-15
|
||||
|
||||
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||
time.
|
||||
|
||||
| Piece | Where |
|
||||
| --------------------------------------- | -------------------------------------------------- |
|
||||
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||
| Permission registration | `permissions/registry.ts` → `setPluginPermissions` |
|
||||
| Install state | `plugin_installs` |
|
||||
| The screen | `/plugins`, two panels, SSE log |
|
||||
| The reference plugin | `plugins/example/` |
|
||||
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||
|
||||
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||
frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the
|
||||
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||
|
||||
### Three bugs the extraction found
|
||||
|
||||
Worth recording because none were visible from reading:
|
||||
|
||||
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||
has no listener to announce. Install and enable now mount first.
|
||||
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||
|
||||
### Still open
|
||||
|
||||
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||
still the hardcoded providers. No plugin owns a socket yet.
|
||||
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||
`buildHonoApp`, which is now the single place routes are mounted.
|
||||
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||
plugin system. One when the app store is rebuilt on this.
|
||||
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||
|
||||
---
|
||||
|
||||
## The state of the app store, as found
|
||||
|
||||
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||
|
||||
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||
|
||||
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||
> and it is not built."_
|
||||
|
||||
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||
|
||||
Also found:
|
||||
|
||||
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||
`bun db:push`.
|
||||
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||
that the directory exists.
|
||||
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||
means editing two files and rebuilding.
|
||||
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||
renders against it. Not to be confused with any of the above.
|
||||
|
||||
---
|
||||
|
||||
## Where the code lives
|
||||
|
||||
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||
|
||||
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||
`extensions/` may become plugins later.
|
||||
|
||||
---
|
||||
|
||||
## Open questions
|
||||
|
||||
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||
platform checkout, so dev-time and build-time are the same mechanism.
|
||||
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||
future, not before.
|
||||
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||
Console section does not travel with the plugin.
|
||||
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||
|
||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||
// this file must never grow app logic.
|
||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
||||
|
||||
const proxy = createSidecarProxy({
|
||||
name: 'headscale',
|
||||
prefix: '/api/headscale',
|
||||
prefix: '/api/offscale',
|
||||
});
|
||||
|
||||
export const headscaleRouter = proxy.router;
|
||||
export const router = proxy.router;
|
||||
|
||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq, and, desc } from 'drizzle-orm';
|
||||
import { db } from '../db';
|
||||
import { db } from 'officerdb/db';
|
||||
import { headscaleServers } from './schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||
|
||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||
@@ -1,6 +1,6 @@
|
||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { users } from '../auth/schema';
|
||||
import { users } from 'officerdb/auth/schema';
|
||||
|
||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { PluginManifest } from '@@/plugins/manifest';
|
||||
|
||||
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||
//
|
||||
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||
// distinct product rather than a badge on someone else's.
|
||||
//
|
||||
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||
//
|
||||
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||
// db/ offscale_servers, and the only table this plugin owns
|
||||
// web/ panels and a layout; the shell renders the Workspace
|
||||
export const manifest: PluginManifest = {
|
||||
publisher: 'officerdev',
|
||||
version: '1.0.0',
|
||||
platform: '>=1.0.0',
|
||||
|
||||
label: 'Offscale',
|
||||
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||
icon: 'Network',
|
||||
color: '#818cf8',
|
||||
|
||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||
//
|
||||
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||
// That is a change inside these queries, not a flag on the manifest.
|
||||
//
|
||||
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||
permissions: [
|
||||
{
|
||||
key: 'offscale',
|
||||
label: 'Offscale',
|
||||
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
|
||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { DATA_PATH } from '../../data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
||||
import { DATA_PATH } from '@@/data-path';
|
||||
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||
//
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
|
||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||
// wire-level quirks are handled once:
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||
|
||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { OfficerContext } from './routes';
|
||||
import type { OfficerUser } from './normalize';
|
||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
||||
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||
import { createClient, type HeadscaleClient } from './client';
|
||||
import { arrayField, toUser } from './normalize';
|
||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||
//
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
||||
import { createSidecarConnector } from '../connect';
|
||||
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||
import { handleOfficerRoute } from './routes';
|
||||
import { MIN_VERSION_LABEL } from './version';
|
||||
import { API_URL } from '../../officer-url.mjs';
|
||||
import { API_URL } from '@@/officer-url.mjs';
|
||||
|
||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// DELETE /_officer/keys/:id delete outright
|
||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||
// for a joining device. userId is only required when the server
|
||||
// has more than one user; reached via /api/vpn/enroll.
|
||||
// has more than one user.
|
||||
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||
// which is deleted. Kept because it is the handler a route under
|
||||
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||
// IS live — dispatches through the same function.
|
||||
// anything else 404
|
||||
//
|
||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||
function getFreePort(): number {
|
||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||
@@ -118,7 +121,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
||||
const connection = createSidecarConnector({
|
||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||
name: 'headscale',
|
||||
capabilities: ['headscale'],
|
||||
handles: ['headscale'],
|
||||
onCommand(cmd, reply) {
|
||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||
},
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
||||
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
deleteHeadscaleServer,
|
||||
getHeadscaleCredentials,
|
||||
recordHeadscaleProbe,
|
||||
} from 'officerdb';
|
||||
} from '../db/queries';
|
||||
import { createClient, HeadscaleError } from './client';
|
||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user