step 4/4: the docs say permissions too, and capability means one thing again

44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+7 -7
View File
@@ -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.
+16 -15
View File
@@ -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.
+17 -10
View File
@@ -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
"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 | …
"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).
+9 -9
View File
@@ -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,16 +75,16 @@ 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.
- [ ] **`assertCapabilityTotality` 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*.
- [ ] **`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 capability and 403s a member; the owner upgrades onto
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,
@@ -95,14 +95,14 @@ the owner's OS user and can never be granted. Indirection there really is accide
- [ ] **`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
@@ -120,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.
+108 -108
View File
@@ -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
@@ -26,7 +26,7 @@ Everything that follows is about **`/chat`** and **`/dashboards`**. Verified aga
`src/apps/officer-web/App.tsx`:
| route | element | line |
|---|---|---|
| ---------------------------------------------------------------------- | ---------------------------- | ----- |
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 4246 |
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
@@ -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,10 +105,10 @@ 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? |
|---|---|---|
| # | 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 |
@@ -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,36 +267,36 @@ 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 13 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 13 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 |
@@ -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).
@@ -485,7 +485,7 @@ against the running system, deliberately looking for the break. Method and raw e
`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** |
@@ -498,11 +498,11 @@ 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,7 +635,7 @@ 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
@@ -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). |
| ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- |
| **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
+5 -5
View File
@@ -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.
+20 -20
View File
@@ -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.
@@ -108,7 +108,7 @@ 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 |
@@ -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
View File
@@ -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.
---
+14 -9
View File
@@ -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.
+3 -3
View File
@@ -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 —
+2 -2
View File
@@ -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
+30 -24
View File
@@ -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.
---
@@ -82,7 +82,7 @@ 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. |
@@ -97,13 +97,13 @@ 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. |
| ------- | ------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ~~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. |
| ~~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. |
@@ -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.**
@@ -172,7 +172,7 @@ Every place an **addressable entity** is selected through a global channel / glo
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) |
@@ -184,7 +184,7 @@ This is the primary surface to convert to URL-driven selection.
`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
@@ -197,7 +197,7 @@ 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 |
@@ -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.
+1 -1
View File
@@ -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.
---
+26 -24
View File
@@ -26,7 +26,7 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
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 |
@@ -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`).
@@ -62,7 +62,7 @@ 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`** | — |
@@ -70,12 +70,12 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports
| 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`.
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`.
@@ -277,7 +277,7 @@ Our mapper recognises 18 names and maps 7. The server emits **130 event type str
`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 |
@@ -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.
---
+7 -7
View File
@@ -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** |
+4 -4
View File
@@ -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
+4 -4
View File
@@ -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
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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.
+28 -27
View File
@@ -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
@@ -64,21 +64,21 @@ 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** |
`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
@@ -258,7 +259,7 @@ Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `us
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 |
@@ -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".
@@ -305,9 +306,9 @@ themselves, able to have an agent do the same on their behalf. That needs two ke
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 |
| ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
| **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.
+3 -3
View File
@@ -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.
+66 -66
View File
@@ -32,7 +32,7 @@ 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. |
@@ -49,7 +49,7 @@ 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
@@ -57,7 +57,7 @@ hand-applied.
**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 |
@@ -118,7 +118,7 @@ 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. |
@@ -138,7 +138,7 @@ 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` |
@@ -155,7 +155,7 @@ The eight sidecars, from `ecosystem.config.cjs`:
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 |
@@ -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
@@ -232,7 +232,7 @@ The transport proxy is right; the platform owns the entire Vaultwarden **auth/se
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 |
@@ -249,7 +249,8 @@ For scale: the sidecar itself is 295 lines and is a genuine dumb pass-through
`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`,
@@ -277,7 +278,7 @@ Two structural notes before the findings:
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,8 +574,8 @@ 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
@@ -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
@@ -641,7 +642,7 @@ 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 |
@@ -652,7 +653,7 @@ Sorted by how far each is from the reference. This is the whole audit in one vie
| 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
@@ -1267,7 +1267,7 @@ 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 |
@@ -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.
@@ -1295,7 +1295,7 @@ Pass 1 found an architecture problem. Pass 2 mostly finds a **resilience** probl
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) |
@@ -1368,7 +1368,7 @@ 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`.
+18 -18
View File
@@ -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
@@ -150,12 +150,12 @@ 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 |
| 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 capability entry | `assertCapabilityTotality` throws before `serve()` |
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
| 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 -8
View File
@@ -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 68. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its
the original points 68. 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
+18 -18
View File
@@ -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_.
---
@@ -138,7 +138,7 @@ 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 |
@@ -155,16 +155,16 @@ 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. |
| -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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. |
@@ -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.
+23 -23
View File
@@ -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
@@ -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
+5 -5
View File
@@ -69,7 +69,7 @@ on uninstall. Only deleting a plugin's source can lose its data.
`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 `capabilities/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
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.
@@ -117,9 +117,9 @@ A normal refresh is enough; the shell is `no-store`. When the log's last line ap
- **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.** `capabilityAvailability`
- **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 capability permanently `unavailable`,
`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.
@@ -180,10 +180,10 @@ No decision has been made. What the tree says, for whoever picks it:
- **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.
- **`assertCapabilityTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
- **`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 capability, and
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
+1 -1
View File
@@ -4,7 +4,7 @@
// 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 capability (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// 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.
+6 -6
View File
@@ -130,7 +130,7 @@ 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.**
`capabilityAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
`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.
@@ -150,7 +150,7 @@ 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_capabilities` grant keeps meaning what it meant, and `can('music')` keeps resolving
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
@@ -224,7 +224,7 @@ The runbook's table, run against `platform.officer.dev` rather than reasoned abo
| 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/capabilities`; `/music` in `routes`, not in `deniedRoutes` |
| 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 |
@@ -247,7 +247,7 @@ exist before.
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`** capability, and `files` is
(`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
@@ -266,7 +266,7 @@ Three possible shapes, none chosen:
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 capability, which is new machinery.
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.
@@ -280,5 +280,5 @@ Not tonight's call.
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 capability**, and are served. Now pinned by a test in
- **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.
+1 -1
View File
@@ -53,7 +53,7 @@ export const manifest: PluginManifest = {
// 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_capabilities` grant keeps meaning what it meant, and the
// 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,
+1 -1
View File
@@ -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;
+19 -18
View File
@@ -9,7 +9,7 @@ reasoning is most useful next to the code it produced. The platform's own docs s
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 + capabilities) without being pathological.
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.
@@ -54,7 +54,7 @@ Combined per plugin as needed. **Only `meta` and the ID are always required.**
- 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 **capabilities added to officer-items**
- a set of **permissions added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
@@ -65,7 +65,7 @@ mount, whether its sidecar is in the ecosystem file, and so on.
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items capabilities, and (probably) **not** a settings page.
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
---
@@ -202,7 +202,7 @@ 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 `assertCapabilityTotality`
### 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
@@ -222,9 +222,9 @@ Two things it must survive, both live today:
## Permissions
A plugin declares capabilities. **A plugin may declare `app`, and nothing else.**
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
`CapabilityKind` is `core | app | confined | execution | admin`. `core` means _every account, not
`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.
@@ -232,7 +232,7 @@ 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
capability**, stored in `role_capabilities`, enforced by the gate. `read` permits safe methods anywhere in
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,
@@ -277,13 +277,14 @@ where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Three different things are called "capability" here
### Several different things were called "capability" here
A manifest needs three names, not one:
1. `capabilities/registry.ts`**permissions** (`headscale`, `vpn`)
1. `permissions/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `capabilities: ['music']`**routing keys** for `sendCommand`
3. `sidecar-registry` `handles: ['music']`**routing keys** for `sendCommand`, renamed from
`capabilities` on 2026-08-15
Offscale needs (1) and (3), and not (2).
@@ -321,7 +322,7 @@ Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| capability | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| 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 |
@@ -387,7 +388,7 @@ is correct. The invite flow supersedes it completely:
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 capability still waiting for a UI — there never was
`/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.
---
@@ -438,7 +439,7 @@ The whole frontend↔host coupling, which becomes the plugin API:
| `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` capability — stays host-provided |
| `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
@@ -470,7 +471,7 @@ export const manifest = {
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word already means three different things here — the
// 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: [
@@ -568,7 +569,7 @@ 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 capability is `kind: 'admin'` — owner-only — and it should stay that way.
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
@@ -617,7 +618,7 @@ server across repeated install / enable / disable / uninstall cycles, checking P
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` |
@@ -626,14 +627,14 @@ time.
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Capability registration | `capabilities/registry.ts``setPluginCapabilities` |
| 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, capabilities are registered before routes mount, and 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
@@ -23,7 +23,7 @@ const LOG_POLL_MS = 1500;
const isTerminal = (s: string) => s === 'completed' || s === 'failed' || s === 'stopped' || s === 'interrupted';
// A script may publish counter-style progress via the `@@officer:progress@@` sentinel (e.g. the
// download-media capability). When shaped like that, render the two phase bars above the log.
// download-media permission). When shaped like that, render the two phase bars above the log.
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
@@ -160,7 +160,7 @@ import {
* The dock items that belong to the SHELL present on every install, with no sidecar behind them.
*
* Everything else is contributed by an installed sidecar's UI manifest and arrives from
* `/capabilities` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
* `/permissions` at runtime (see `dockItemsFromPlugins`). The split is the point: a feature that can be
* installed and uninstalled must not be hardcoded here, or the dock would list things this server does
* not have and the shell would need editing every time a sidecar is added.
*
@@ -13,7 +13,7 @@ import { pgTable, serial, text, boolean, timestamp, jsonb, uniqueIndex } from 'd
// Gitea is the worked example. The owner installs it once, and the row here says the process runs; each
// member then holds their own credential in `service_connections`, inheriting the instance URL from the
// owner's row. A member can therefore use a service without being able to install, uninstall or point it
// somewhere else — which is the same split `capabilities/registry.ts` already draws between `app` and
// somewhere else — which is the same split `permissions/registry.ts` already draws between `app` and
// `admin` kinds.
//
// ── Why `installed` and `enabled` are separate ──
+1 -1
View File
@@ -109,7 +109,7 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
// database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
// The column is `permission` now too, so this is a straight pass-through again. It was briefly a
// mapping — the wire said `permission` while the column still said `capability` — which is what let
// mapping — the wire said `permission` while the column still said `permission` — which is what let
// the table be renamed without any client noticing.
grants: await getAllRoleGrants(),
});
+8 -4
View File
@@ -16,10 +16,14 @@
/**
* A permission the plugin adds to the platform's permission system.
*
* Called `permissions`, and that word is used throughout the plugin system deliberately. The other one
* already means three different things here the permission registry, the file-based item store under
* `$OFFICER_ROOT/permissions`, and the routing keys a sidecar registers with and a fourth meaning
* would be one too many. Nothing in this system uses it.
* Called `permissions`, and the plugin system used that word first before the platform did. On
* 2026-08-15 the rest of the codebase followed: the registry moved to `permissions/`, its table to
* `role_permissions`, and the sidecar's routing keys became `handles`.
*
* `capability` now means exactly one thing, the file-based item store under `$OFFICER_ROOT/capabilities`
* skills, tools, tasks. Three other uses survive because they are somebody else's vocabulary rather
* than ours: the wallet's Lightning backend features, the pty sidecar's terminfo queries, and
* InvoiceShelf's per-resource flags.
*/
export type PluginPermission = {
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
+1 -1
View File
@@ -28,7 +28,7 @@ afterEach(() => {
while (registered.length) unregisterSidecar(registered.pop()!);
});
/** Wait until a request has actually reached the socket — spawnClaude awaits waitForCapability first. */
/** Wait until a request has actually reached the socket — spawnClaude awaits waitForPermission first. */
async function awaitSend(socket: { sent: string[] }): Promise<void> {
for (let i = 0; i < 50 && socket.sent.length === 0; i++) await Bun.sleep(1);
expect(socket.sent).not.toHaveLength(0);
+2 -2
View File
@@ -25,7 +25,7 @@ import { API_URL, OFFICER_API_URL, ANTHROPIC_PROXY_URL } from '../../officer-url
// this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs.
//
// "The owner" is not a simplification that multi-user will later invalidate. `chat` is an `execution`
// capability (capabilities/registry.ts) and is never grantable at any level, so no account other than
// permission (permissions/registry.ts) and is never grantable at any level, so no account other than
// the owner can ever reach this sidecar, however many accounts exist.
//
// ── Why this retries rather than throws ──
@@ -79,7 +79,7 @@ const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.u
// The owner runs Claude with no isolation — real HOME, real ~/.claude — so platform sessions have
// perfect parity with terminal sessions (same config, credentials and transcript store,
// interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an
// `execution` capability and can never be granted: this is a shell, not a feature flag.
// `execution` permission and can never be granted: this is a shell, not a feature flag.
// Evaluated here, ABOVE the `process.env.HOME = homeDir` below: homedir() reads $HOME, so a
// read placed after that assignment would return whatever was last spawned into.
const homeDir = homedir();
@@ -11,7 +11,7 @@ describe('resolveNotifyUser', () => {
});
// The regression this module exists for: `body.userId ?? header` let any account holding the
// `notify` capability push to any other account's devices by naming them in a JSON body.
// `notify` permission push to any other account's devices by naming them in a JSON body.
test('the header beats a conflicting body userId', () => {
expect(resolveNotifyUser({ header: '13', bodyUserId: 1 })).toBe(13);
expect(resolveNotifyUser({ header: '1', bodyUserId: 13 })).toBe(1);
+1 -1
View File
@@ -10,7 +10,7 @@
//
// So the header wins wherever it is present. This lives in its own module, away from the Bun.serve
// entrypoint, purely so it can be tested — it is an access-control decision, and the version that read
// `body.userId ?? header` let any account holding the `notify` capability push to any other account's
// `body.userId ?? header` let any account holding the `notify` permission push to any other account's
// devices by naming them in a JSON body.
type ResolveUserParams = {
+5 -5
View File
@@ -6,7 +6,7 @@ import { useClient } from './useClient';
//
// THIS IS NOT ACCESS CONTROL. Every answer here is a courtesy: it stops the app offering a member a
// Terminal icon that would 403, and stops a screen mounting a panel whose every request will fail. The
// lock is server-side, in the capability gate and the websocket gate — both of which
// lock is server-side, in the permission gate and the websocket gate — both of which
// hold regardless of what this hook returns, including when it returns nothing because the request failed.
//
// Which is why the failure mode below is deliberately generous rather than restrictive: if this request
@@ -42,7 +42,7 @@ export type SelfPermissions = {
/** What an installed sidecar says about how it should appear. Mirrors UiManifest on the server. */
export type PluginManifest = {
sidecarId: string;
capability: string | null;
permission: string | null;
name: string;
icon?: string;
image?: string;
@@ -88,7 +88,7 @@ export function usePermissions() {
);
/**
* Whether a frontend route is reachable. Only routes a capability actually claims are ever denied
* Whether a frontend route is reachable. Only routes a permission actually claims are ever denied
* `/`, the settings shell and anything else unclaimed stays open, because denying by default here would
* blank the app for everyone rather than restrict it for anyone.
*/
@@ -115,8 +115,8 @@ export function usePermissions() {
if (!data) return null;
if (!data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`))) return null;
// Held but unavailable → the sidecar is missing. Checked against the capability that claims the route,
// which is why `unavailable` is returned as capability keys rather than routes.
// Held but unavailable → the sidecar is missing. Checked against the permission that claims the route,
// which is why `unavailable` is returned as permission keys rather than routes.
const unavailable = new Set(data.unavailable ?? []);
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
if (data.isOwner || heldAndUnavailable) return 'not-installed';
@@ -49,7 +49,7 @@ export function useAppStore() {
queryFn: () => client.get<{ items: StoreItem[] }>('/app-store'),
});
// Every verb invalidates the same key, and also the capability answer: installing changes which dock
// Every verb invalidates the same key, and also the permission answer: installing changes which dock
// tiles exist, and a store that updated while the dock kept the old list would be visibly wrong on
// the same screen.
const invalidate = () => {
@@ -18,7 +18,7 @@ export type AgentGroup = { category: string; agents: AgentSummary[] };
// wrong thing about what happens when you click.
export const useAgents = () => {
const client = useClient();
// Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one
// Agents are the `items` permission — skills, tools and agents on the owner's disk — and running one
// starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu.
const { can } = usePermissions();
const allowed = can('items');
@@ -19,7 +19,7 @@ export const useServerSettings = () => {
const client = useClient();
const queryClient = useQueryClient();
// `/server-settings` is the `server-admin` capability: owner only, and not grantable at any level. This
// `/server-settings` is the `server-admin` permission: owner only, and not grantable at any level. This
// hook is mounted by shell components that every account loads, so without the guard a member's first
// paint fired a 403 at it.
const { isOwner } = usePermissions();