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:
@@ -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
|
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
|
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
|
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**
|
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
|
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.
|
section of `CLAUDE.md` before adding one.
|
||||||
|
|
||||||
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
|
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
|
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
|
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||||
and no restart.
|
and no restart.
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ written: `users` holds six rows. The accurate statement is narrower and more use
|
|||||||
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
|
- **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.
|
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`;
|
- **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.
|
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
|
- **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
|
`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.
|
"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
|
- **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
|
`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.
|
home, and the kernel refusing everything above it.
|
||||||
|
|
||||||
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
|
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
|
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
|
||||||
anything under `execution`.
|
anything under `execution`.
|
||||||
|
|
||||||
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
|
`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 "Capabilities"
|
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
|
||||||
below before adding one.
|
below before adding one.
|
||||||
|
|
||||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||||
@@ -84,7 +84,7 @@ src/
|
|||||||
│ └── landing/ # marketing landing page
|
│ └── landing/ # marketing landing page
|
||||||
├── servers/
|
├── servers/
|
||||||
│ ├── hono.ts # router composition; everything under /api
|
│ ├── 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
|
│ ├── api/<feature>/ # one folder per feature, each exporting a router
|
||||||
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
|
||||||
│ ├── queue/ # background job engine
|
│ ├── 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)
|
- **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`
|
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
|
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
|
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
|
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
|
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`.
|
first. The source build also needs `build-essential` and `python3`.
|
||||||
|
|
||||||
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
|
- **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
|
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
|
||||||
- **Backend**: Hono
|
- **Backend**: Hono
|
||||||
@@ -135,7 +136,7 @@ per-account email SQLite stores — those are the **email sidecar's**, and nothi
|
|||||||
them.
|
them.
|
||||||
|
|
||||||
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
|
**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
|
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
|
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
|
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
|
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
|
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
|
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`).
|
- 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
|
**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.
|
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
|
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
|
||||||
authenticated request until the server restarts.
|
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
|
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
|
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
|
||||||
`userMiddleware`.
|
`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
|
`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
|
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.
|
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,
|
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`.
|
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
|
**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`.
|
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
|
||||||
- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
|
- `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,
|
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
|
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
|
`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
|
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.
|
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
|
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||||
into a refusal.
|
into a refusal.
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -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
|
- 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).
|
(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
|
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.
|
**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
|
- 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
|
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
|
permission 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.
|
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`.
|
- 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`
|
## `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
|
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
|
```jsonc
|
||||||
{
|
{
|
||||||
@@ -65,6 +65,7 @@ computed from the delta since your *previous* call (see notes), so a steady cade
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Notes**
|
**Notes**
|
||||||
|
|
||||||
- `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first**
|
- `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.
|
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.
|
- `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
|
```jsonc
|
||||||
{
|
{
|
||||||
"processes": [
|
"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",
|
"name": "officer",
|
||||||
"status": "online", // online | stopped | errored | …
|
"status": "online", // online | stopped | errored | …
|
||||||
"pid": 3339851, // OS pid, or null
|
"pid": 3339851, // OS pid, or null
|
||||||
"cpuPct": 0,
|
"cpuPct": 0,
|
||||||
"memBytes": 10354688,
|
"memBytes": 10354688,
|
||||||
"restarts": 44,
|
"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
|
```jsonc
|
||||||
{
|
{
|
||||||
"containers": [
|
"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",
|
"name": "jellyfin",
|
||||||
"image": "jellyfin/jellyfin",
|
"image": "jellyfin/jellyfin",
|
||||||
"state": "running", // running | exited | …
|
"state": "running", // running | exited | …
|
||||||
"status": "Up 3 hours",
|
"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=`.
|
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>`
|
### `GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>`
|
||||||
|
|
||||||
- `id` — **numeric** pm2 id from `/pm2` (required).
|
- `id` — **numeric** pm2 id from `/pm2` (required).
|
||||||
- `lines` — initial backlog, default `100`, max `1000`.
|
- `lines` — initial backlog, default `100`, max `1000`.
|
||||||
- Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short
|
- Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short
|
||||||
pm2 `[TAILING] …` header.
|
pm2 `[TAILING] …` header.
|
||||||
|
|
||||||
### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>`
|
### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>`
|
||||||
|
|
||||||
- `id` — container id or name from `/docker` (charset-validated).
|
- `id` — container id or name from `/docker` (charset-validated).
|
||||||
- `lines` — initial backlog (`--tail`), default `100`, max `1000`.
|
- `lines` — initial backlog (`--tail`), default `100`, max `1000`.
|
||||||
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).
|
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ Deferred work.
|
|||||||
|
|
||||||
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
|
**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
|
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
|
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
|
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.
|
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.
|
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||||
|
|
||||||
## Multi-user
|
## 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
|
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
||||||
`docs/workspace-panel-todo.md` §3.
|
`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
|
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
|
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.
|
standing between a Member and a shell.
|
||||||
|
|
||||||
- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since
|
- [ ] **`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*.
|
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
|
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.
|
`/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
|
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
|
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,
|
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 —
|
- [ ] **`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
|
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.
|
function to start from.
|
||||||
|
|
||||||
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
|
- [ ] **`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
|
`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
|
`/_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
|
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.
|
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
|
- [ ] **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).
|
- [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
|
`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
|
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
|
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.
|
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
|
||||||
|
|||||||
+108
-108
@@ -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.
|
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
|
**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-panels.md` — how the framework works today (descriptive, no opinions)
|
||||||
- `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity
|
- `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`:
|
`src/apps/officer-web/App.tsx`:
|
||||||
|
|
||||||
| route | element | line |
|
| route | element | line |
|
||||||
|---|---|---|
|
| ---------------------------------------------------------------------- | ---------------------------- | ----- |
|
||||||
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 |
|
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 |
|
||||||
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
|
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
|
||||||
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
|
| `/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
|
### 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
|
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.*
|
parallel, in two different chat windows, both working on the platform._
|
||||||
|
|
||||||
**Stated as fact by the owner** (not inferred):
|
**Stated as fact by the owner** (not inferred):
|
||||||
|
|
||||||
- Two agents, two chat windows, same platform, at the same time.
|
- 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
|
- 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
|
`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.
|
finish first.
|
||||||
|
|
||||||
**Observed by me during this same session**, as corroborating detail — the frictions this arrangement
|
**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
|
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
|
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
|
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.
|
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
|
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
|
the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths
|
||||||
and never `git add -A`. Nothing enforces it.
|
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
|
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
|
**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
|
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
|
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.
|
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
|
> 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.
|
> 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
|
> 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
|
> 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.
|
> 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** |
|
| P1 | Two chat panels in one dashboard, each an **independent** session | **No** |
|
||||||
| P2 | Each panel pointed at **its own path** (cwd) | **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 |
|
| 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
|
**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
|
`() => {…}` — **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
|
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
|
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
|
`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`,
|
**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
|
`` 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
|
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
|
(`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.
|
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
|
`{ 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
|
dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly
|
||||||
one level too coarse for this.
|
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
|
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**:
|
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
|
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
|
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
|
### 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
|
**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
|
It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it
|
||||||
is the target.
|
is the target.
|
||||||
|
|
||||||
@@ -204,7 +204,7 @@ The target:
|
|||||||
So the north star is: **several specialised agents, working concurrently on one codebase, coordinating
|
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.**
|
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
|
workers. The restart handshake is the first, smallest instance of the general primitive: agents
|
||||||
negotiating a shared resource without a human in the middle.
|
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
|
risk profile, and it is recorded here because it is the strongest single argument in the whole
|
||||||
conversation.
|
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.**
|
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.
|
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.
|
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
|
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.
|
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.
|
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
|
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.
|
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"
|
**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
|
— 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.
|
keep in my head_, though enjoyable and exciting.
|
||||||
|
|
||||||
### 1.10 The midnight scenario — the shape of the target
|
### 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
|
> "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 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
|
The owner's own framing: _a holy grail by its nature doesn't exist — but I really think we can get
|
||||||
there.*
|
there._
|
||||||
|
|
||||||
Structural requirements this adds, beyond the two-panel PoC:
|
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 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
|
- **Panels span repositories** — platform and `monorepo-mobile` are different repos with different
|
||||||
remotes.
|
remotes.
|
||||||
- **The fourth role is different in kind from the first three.** Roles 1–3 are *do the work*, and are
|
- **The fourth role is different in kind from the first three.** Roles 1–3 are _do the work_, and are
|
||||||
already proven by the manual flow. Role 4 is *hold the whole picture and judge* — the role the owner
|
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
|
performs today with human judgement. See §5 for why this is flagged as the research risk rather than
|
||||||
an engineering task.
|
an engineering task.
|
||||||
|
|
||||||
### 1.11 Do not design for the examples — the owner's counterpoints
|
### 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.
|
be easy to repeat later.
|
||||||
|
|
||||||
- **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only
|
- **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
|
- **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
|
"front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner
|
||||||
does not have.
|
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
|
- **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.
|
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
|
- **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
|
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.
|
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
|
**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.
|
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:
|
This yields a natural two-tier split, which the design should respect rather than unify:
|
||||||
|
|
||||||
| tier | mechanism | status |
|
| tier | mechanism | status |
|
||||||
|---|---|---|
|
| --------------------------- | ---------------------------------------------------- | -------------------------------------- |
|
||||||
| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** |
|
| 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 |
|
| 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.
|
> 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 →
|
**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
|
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.
|
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,
|
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 **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
|
- The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages
|
||||||
between them at turn boundaries**. That is it.
|
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
|
- 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.
|
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
|
**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
|
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
|
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.
|
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).
|
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
|
**Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be
|
||||||
re-litigated.**
|
re-litigated.**
|
||||||
|
|
||||||
- It has been working in practice: three agents at a time on the platform, and *the way the platform was
|
- 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
|
modularised means they don't step on each other's toes ~90% of the time._ Nothing is or will be
|
||||||
perfect.
|
perfect.
|
||||||
- Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional
|
- 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.
|
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
|
- Explicit division of labour, verbatim: _"that's my problem as a software engineer, as an architect, to
|
||||||
solve."*
|
solve."_
|
||||||
|
|
||||||
So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so
|
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.
|
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):**
|
**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
|
> 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
|
> 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.
|
> 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:
|
And the expectations around it:
|
||||||
|
|
||||||
- **Learn to walk first.** The owner does not expect that the night after this works he creates a
|
- **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.
|
time, separately from the mechanism.
|
||||||
- **This mission will take some time to reach an initial state.** It is not a quick change.
|
- **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
|
**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**?
|
1. Does the system survive a **server restart**?
|
||||||
2. Does it survive a **page refresh**?
|
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:
|
further along than assumed.** Load-bearing findings:
|
||||||
|
|
||||||
**The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called
|
**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
|
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
|
callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent
|
||||||
delivers a message to another.
|
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
|
**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
|
(`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
|
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".
|
`task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle".
|
||||||
|
|
||||||
**Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor,
|
**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.
|
**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
|
**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**.
|
the process that owns the session, **failing toward alive**.
|
||||||
|
|
||||||
**Principles this codebase has already paid for — adopt, don't re-derive:**
|
**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.
|
`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`).
|
on the innocent case (`pm2 restart officer`).
|
||||||
3. *An availability check must fail toward the less-alarming answer.*
|
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
|
4. _Classify by recoverability, not severity_ — `cut-off` (seam + Retry) is a different object from
|
||||||
`error` (red bubble).
|
`error` (red bubble).
|
||||||
5. *An id that never crosses the process boundary is not an address* (`47d03de`).
|
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
|
6. _Disambiguate at the only site holding the extra bit_, and set the flag **before** the await that can
|
||||||
race it.
|
race it.
|
||||||
|
|
||||||
**⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key
|
**⚠ 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
|
### 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:
|
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
|
> 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.
|
between then and morning.
|
||||||
|
|
||||||
**And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the
|
**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
|
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.
|
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
|
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
|
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
|
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,
|
§1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer,
|
||||||
session outliving the socket).
|
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`.
|
`COMMS/handoff-durability-2026-08-07.md`.
|
||||||
|
|
||||||
| what was done to it mid-handoff | turn completed | narration durable |
|
| what was done to it mid-handoff | turn completed | narration durable |
|
||||||
|---|---|---|
|
| -------------------------------------- | -------------- | ------------------------------------------------- |
|
||||||
| nothing (control) | ✅ | ✅ |
|
| nothing (control) | ✅ | ✅ |
|
||||||
| `pm2 restart officer` | ✅ | ✅ |
|
| `pm2 restart officer` | ✅ | ✅ |
|
||||||
| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** |
|
| **`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
|
- **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
|
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.
|
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 —
|
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
|
`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.
|
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
|
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
|
handed off when it did not. That, not the receiving side, is where store-and-forward would earn its
|
||||||
keep.
|
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
|
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
|
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
|
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.
|
verified above — but is silent on this one.
|
||||||
|
|
||||||
### 1.17 The restart payload is temporary — the protocol is not
|
### 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
|
> 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.
|
> 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
|
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.
|
case disappeared tomorrow, nothing built should need to be deleted.
|
||||||
|
|
||||||
### 1.18 Ruling on push-back #3 (agents reviewing agents)
|
### 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
|
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.*
|
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
|
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;
|
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.
|
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
|
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.
|
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
|
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*).
|
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
|
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`).
|
refined; already a value in this codebase — `9eb8fa1`).
|
||||||
|
|
||||||
**Do not build:**
|
**Do not build:**
|
||||||
|
|
||||||
- No role registry, orchestration engine, planner, or task allocator (§1.13).
|
- 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
|
- 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
|
domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is
|
||||||
of what is in the envelope.
|
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).
|
- **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
|
- **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
|
- **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).
|
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.
|
- **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
|
- **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
|
owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same
|
||||||
shape.
|
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.
|
the primitive rather than as features of it.
|
||||||
- **Non-software domains entirely**, and other users with unrelated goals.
|
- **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
|
## 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
|
- 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.
|
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
|
- Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here
|
||||||
and in the documents that already exist.
|
and in the documents that already exist.
|
||||||
|
|
||||||
### 4.1 Explicitly de-scoped — not wrong, just not now
|
### 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**:
|
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
|
- **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
|
(`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*,
|
beside the browser without entering your saved layout). The owner's words: _horrible architecture_,
|
||||||
and *everything is working as much as I need it*.
|
and _everything is working as much as I need it_.
|
||||||
- The query-string-driven sub-panel approach generally.
|
- 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
|
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
|
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.
|
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
|
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
|
calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has
|
||||||
pending tasks.
|
pending tasks.
|
||||||
- **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes
|
- **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
|
`clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect
|
||||||
path.
|
path.
|
||||||
- The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at
|
- 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
|
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."
|
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
|
destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and
|
||||||
whatever closes a panel must not trigger it.
|
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
|
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
|
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.
|
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
|
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to _how a panel
|
||||||
acquires its Claude session id*:
|
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
|
> 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
|
> 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.
|
> dashboard creation or session creation.
|
||||||
|
|
||||||
**Adopt the second.** It is strictly better, because it collapses two problems into one act: the role
|
**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
|
- 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.
|
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
|
> 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.**
|
> 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
|
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.
|
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.
|
> sequence that worked from start to finish, what were the prompts passed from one to another.
|
||||||
> **But this is something for version 2.**
|
> **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
|
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.
|
`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
|
**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
|
### 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:
|
These are two different things and conflating them would inflate v1 into v2:
|
||||||
|
|
||||||
| | what it is | when |
|
| | 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. |
|
| **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). |
|
| **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
|
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
|
## 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`
|
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
|
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
|
### 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
|
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.
|
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
|
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
|
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.
|
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
|
### 6.2 Tier A — on the critical path
|
||||||
|
|
||||||
**A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`,
|
**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
|
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
|
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
|
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
|
**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
|
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.
|
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
|
**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" —
|
**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
|
`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:
|
the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap:
|
||||||
consume the response that is already being computed.
|
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.
|
the derivation.
|
||||||
|
|
||||||
**B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was
|
**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`
|
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
|
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
|
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
|
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.
|
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
|
**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
|
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
|
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.
|
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
|
**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
|
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
|
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
|
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.
|
problem. It is not this one.
|
||||||
- **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is
|
- **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is
|
||||||
unrelated to this path.
|
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
|
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
|
`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.
|
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
|
- **§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
|
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
|
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
|
`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
|
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
|
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.
|
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
|
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
|
the framework as it stands. The framework needed exactly one addition — per-panel config that survives a
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ A team of agents works on this project, sometimes several of them in the same re
|
|||||||
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
|
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
|
||||||
having to remember to say so.
|
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.
|
is concerned.
|
||||||
|
|
||||||
## How git identity can be overridden at all
|
## 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
|
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.
|
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
|
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
|
||||||
for everything below.
|
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
|
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
|
||||||
field on the pushed message.
|
field on the pushed message.
|
||||||
- **Agent name and persona are prompt-only.** `buildAgentPrompt`
|
- **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
|
(`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.
|
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)`
|
- 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
|
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
|
||||||
transcripts.
|
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`
|
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
|
(`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.
|
`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.
|
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ that is the sidecar running your agent. **It is not.**
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
name: 'proxy',
|
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
|
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
|
### 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
|
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
|
`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'`,
|
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
|
subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the
|
||||||
parent.
|
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
|
### Also relevant: the transport direction is inverted
|
||||||
|
|
||||||
`user-instance.ts:19` dials *out* to officer:
|
`user-instance.ts:19` dials _out_ to officer:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
|
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),
|
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.
|
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*
|
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
|
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
|
to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the
|
||||||
meantime has nowhere to go.
|
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:
|
Not yet. Five things have to hold, and only some are about process lifetime:
|
||||||
|
|
||||||
| # | Requirement | Status today |
|
| # | Requirement | Status today |
|
||||||
|---|---|---|
|
| --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
||||||
| R1 | The agent process is outside officer's process tree | **broken** — child of officer |
|
| 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'` |
|
| 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 |
|
| 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`).
|
"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
|
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.
|
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
|
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).
|
`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
|
- 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
|
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
|
- On reconnect the browser sends `since=<seq>` and the **sidecar** answers the replay. Officer relays
|
||||||
the question and the answer, and interprets neither.
|
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
|
`ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines
|
||||||
including the 50ms registration poll). Officer no longer spawns anything.
|
including the 50ms registration poll). Officer no longer spawns anything.
|
||||||
|
|
||||||
- *Pro:* correct, matches every other sidecar, PM2 restarts and logs it properly.
|
- _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.
|
- _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,
|
**1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file,
|
||||||
own process group.
|
own process group.
|
||||||
|
|
||||||
- *Pro:* smallest diff, keeps lazy startup.
|
- _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,
|
- _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.
|
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
|
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
|
`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.
|
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
|
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.
|
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
|
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
|
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
|
`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
|
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**,
|
`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
|
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.
|
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
|
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
|
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
|
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
|
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.
|
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 vs not
|
||||||
|
|
||||||
**Verified by reading the code or inspecting the running system:** my process ancestry; that
|
**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
|
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`;
|
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
|
that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the
|
||||||
|
|||||||
+1
-1
@@ -117,7 +117,7 @@ worth serving both from one place.
|
|||||||
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
|
- **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
|
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.
|
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.
|
there is still nobody to share with, whatever the account list says since 2026-08-07.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -6,11 +6,11 @@ this file still exists. Email sync is deliberately NOT part of this any more —
|
|||||||
sidecar with its own scheduling, so it does not appear in the Jobs list.
|
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** —
|
**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.
|
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
|
**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
|
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.
|
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
|
||||||
Favor power-user affordances over guardrails.
|
Favor power-user affordances over guardrails.
|
||||||
@@ -45,9 +45,10 @@ Favor power-user affordances over guardrails.
|
|||||||
## Plan
|
## Plan
|
||||||
|
|
||||||
### Phase 1 — Unified jobs backend
|
### Phase 1 — Unified jobs backend
|
||||||
|
|
||||||
- **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int)
|
- **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
|
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.*
|
`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`
|
- **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline`
|
||||||
→ existing `executePipeline`; `script` → new `executeScript` (ports task-executor's
|
→ existing `executePipeline`; `script` → new `executeScript` (ports task-executor's
|
||||||
`materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events +
|
`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.
|
startup so a queued backlog resumes.
|
||||||
|
|
||||||
### Phase 2 — REST job API (decouples creation from the socket; enables the phone)
|
### Phase 2 — REST job API (decouples creation from the socket; enables the phone)
|
||||||
|
|
||||||
- `POST /jobs {taskDirName, inputs, cwd, action}` → `{jobId}` (create + start/queue, background).
|
- `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`.
|
- `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.
|
- Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list.
|
||||||
|
|
||||||
### Phase 3 — Frontend
|
### Phase 3 — Frontend
|
||||||
|
|
||||||
- `/jobs/new` → `NewJobScreen`: reads query params, renders the input UI lifted from
|
- `/jobs/new` → `NewJobScreen`: reads query params, renders the input UI lifted from
|
||||||
`TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the
|
`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
|
concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when
|
||||||
idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator.
|
idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator.
|
||||||
|
|
||||||
### Phase 4 — Notifications (later)
|
### Phase 4 — Notifications (later)
|
||||||
|
|
||||||
- One `notifyJobDone(job)` hook at finalize → push to the phone app.
|
- One `notifyJobDone(job)` hook at finalize → push to the phone app.
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB)
|
- [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,
|
- [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
|
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
|
dispatches by `mode` and finalizes script jobs by exit code. _Compiles; runtime-untested until
|
||||||
a REST caller + restart exist.*
|
a REST caller + restart exist._
|
||||||
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
|
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
|
||||||
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
|
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
|
||||||
queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper.
|
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`,
|
- [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`.
|
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).
|
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] 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
|
- [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) →
|
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
|
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`
|
(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
|
page or FileBrowser change needed. _(A standalone deep-linkable `/jobs/new` is deferred; the
|
||||||
phone creates jobs directly via `POST /jobs`.)*
|
phone creates jobs directly via `POST /jobs`.)_
|
||||||
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
|
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
|
||||||
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
|
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
|
||||||
`GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0.
|
`GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0.
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ will not meet it.
|
|||||||
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
|
| **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. |
|
| **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.
|
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.
|
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.
|
- **The owner** (user 1) reaches everything.
|
||||||
- **Any other account** reaches only what its role has been granted, and **can never** reach the
|
- **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.
|
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
|
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
|
## 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
|
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.
|
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 —
|
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ speaks DAV.
|
|||||||
|
|
||||||
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said
|
- **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
|
"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.
|
- **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
|
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
|
**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
|
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.
|
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
|
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
|
||||||
|
|||||||
+30
-24
@@ -21,14 +21,13 @@
|
|||||||
> The rules in this file are current and authoritative; the findings table is a snapshot.
|
> 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 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
|
> 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
|
> 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
|
> 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.
|
> "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.
|
**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
|
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)
|
## The anti-pattern (definition)
|
||||||
|
|
||||||
A clickable element selects/opens something that has (or should have) a URL, but:
|
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;
|
- **(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);
|
- **(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;
|
- **(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)}>`
|
**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`).
|
(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
|
**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).
|
"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),
|
**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>`
|
**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.
|
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
|
### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth
|
||||||
|
|
||||||
| ID | file:line | Entity | Current impl | Fix |
|
| 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.** |
|
| 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. |
|
| 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. |
|
| 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)
|
### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link)
|
||||||
|
|
||||||
| ID | file:line | Entity | Proposed route | Note |
|
| 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. |
|
| ~~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". |
|
| ~~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. |
|
| ~~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. |
|
| ~~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. |
|
| ~~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. |
|
| ~~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. |
|
| ~~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. |
|
| ~~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
|
- ~~**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.
|
`<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`
|
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
|
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.
|
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
|
- ~~**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
|
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
|
`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"
|
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
|
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
|
`<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.
|
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.**
|
- **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.
|
This is the primary surface to convert to URL-driven selection.
|
||||||
|
|
||||||
| Channel / global key | Entity held | Should map to | Files |
|
| 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: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` |
|
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
|
||||||
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
|
| `SELECTED_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`.
|
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`.
|
||||||
(`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer
|
(`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
|
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
|
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
|
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
|
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.
|
"Link?" = a `<Link>`/`<NavLink>` is the right refactor.
|
||||||
|
|
||||||
| # | file:line | what | target | Link? | note |
|
| # | file:line | what | target | Link? | note |
|
||||||
|---|-----------|------|--------|-------|------|
|
| ------ | ------------------------------------------------------ | ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
|
| 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 |
|
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
|
||||||
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
|
| 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):**
|
> **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">`"
|
> 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
|
> 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,
|
> 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
|
> `/…/: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`.
|
> create/edit/delete(/publish), and mobile-panel flows on `/dashboards` and `/projects`.
|
||||||
|
|
||||||
### Phase 1 — Quick wins (routes already exist; mechanical, high value)
|
### 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] **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] **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.**
|
- [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] **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] **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
|
- [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
|
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
|
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
|
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)
|
### 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] **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] **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] **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] **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] **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.
|
- [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)
|
### 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] **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] **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] **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.**
|
- [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
|
### 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.**
|
- [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.
|
- [ ] "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
|
- [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.)
|
- [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
|
### Cross-cutting for the refactor itself
|
||||||
|
|
||||||
- [x] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
|
- [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
|
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.
|
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
|
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
|
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.
|
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.
|
- [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
|
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.
|
every route, and with it on the rest a detail route would lose its tile.
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ account manager in recoverable form, and synced to whatever backs that phone up.
|
|||||||
device, revocable per device, is the whole point.
|
device, revocable per device, is the whole point.
|
||||||
|
|
||||||
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
`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.
|
and the column decides whose collection tree Radicale serves. It is load-bearing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
|
|||||||
run to completion with a real model reply:
|
run to completion with a real model reply:
|
||||||
|
|
||||||
| read | api-created session | legacy-created session |
|
| read | api-created session | legacy-created session |
|
||||||
|---|---|---|
|
| ------------------------------------------ | ---------------------- | ---------------------- |
|
||||||
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
|
||||||
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
| `GET /api/session/{id}/message` | 200, 3 messages | **500** |
|
||||||
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
| `GET /session/{id}` (the record) | 200, title + directory | 200 |
|
||||||
@@ -42,7 +42,7 @@ rather than erroring.
|
|||||||
### 1b. The session list silently truncates at 50
|
### 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
|
`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
|
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`).
|
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:
|
`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists:
|
||||||
|
|
||||||
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|
||||||
|---|---|---|---|
|
| ------------ | --------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
|
||||||
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
|
||||||
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
| routes | 111 paths | 51 paths | ~100 paths, still moving |
|
||||||
| operationIds | `session.list` | **`v2.session.list`** | — |
|
| 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 |
|
| 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
|
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`:
|
Two qualifications, both from the source at tag `v1.18.16`:
|
||||||
|
|
||||||
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
|
- **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
|
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
|
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
|
||||||
nil.
|
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
|
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to
|
||||||
change wholesale.
|
change wholesale.
|
||||||
|
|
||||||
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the
|
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
|
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.
|
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
|
**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
|
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
|
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
|
(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.
|
migrate to the V2 API"_. No deprecation date for the legacy surface is published anywhere.
|
||||||
|
|
||||||
Two facts worth knowing regardless:
|
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
|
### 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
|
POST /api/session/{id}/prompt
|
||||||
@@ -139,12 +139,12 @@ POST /api/session/{id}/prompt
|
|||||||
"delivery": "steer" | "queue", "resume": true|false }
|
"delivery": "steer" | "queue", "resume": true|false }
|
||||||
```
|
```
|
||||||
|
|
||||||
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is
|
Spec description: _"Durably admit one session input and schedule agent-loop execution unless resume is
|
||||||
false."*
|
false."_
|
||||||
|
|
||||||
- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the
|
- **`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
|
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".
|
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
|
- **`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`).
|
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.
|
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
|
||||||
Omitting it replays the session from 0.
|
Omitting it replays the session from 0.
|
||||||
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
|
- **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
|
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
|
||||||
`Sequence mismatch` / `Replay diverged` errors.
|
`Sequence mismatch` / `Replay diverged` errors.
|
||||||
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
|
||||||
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
|
||||||
|
|
||||||
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor
|
Note the two cursor kinds are unrelated: the session _list_ uses an opaque base64url cursor
|
||||||
(`cursor.previous` / `cursor.next`), this one is a plain integer.
|
(`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
|
**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, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
|
||||||
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
|
`{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
|
`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
|
V2 docs say the same in config terms: _"Do not use `permission`, `bash`, or `task` in V2
|
||||||
configuration."*
|
configuration."_
|
||||||
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
|
- **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
|
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
|
||||||
event names changed.
|
event names changed.
|
||||||
|
|
||||||
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
|
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`
|
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
|
||||||
union on our own `/doc`.
|
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`).
|
`created`, `deleted`, `updated`, `diff`).
|
||||||
|
|
||||||
| dropped | what it would give |
|
| dropped | what it would give |
|
||||||
|---|---|
|
| ------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||||
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
|
| `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.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
|
||||||
| `tool.progress` | long tools reporting instead of appearing hung |
|
| `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 client = createOpencodeClient({ baseUrl });
|
||||||
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
|
||||||
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
|
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/
|
`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**
|
- 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
|
`/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,
|
— 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
||||||
`docs/opencode-phase1-review.md`.
|
`docs/opencode-phase1-review.md`.
|
||||||
|
|
||||||
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly*
|
**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
|
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
|
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
|
||||||
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
|
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
|
**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
|
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
|
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
|
(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
|
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
|
||||||
1.18.16.
|
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` /
|
`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
|
`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
|
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.
|
enumerates what is running.
|
||||||
|
|
||||||
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
|
**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
|
"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.
|
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
|
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
|
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
|
`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.
|
`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.
|
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** |
|
| 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** |
|
| Mid-turn injection / queue-into-turn | streaming input queue | **No** — `stdin: 'ignore'` | **Yes** |
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ for a follow-up that touches the socket contract, is the right split.
|
|||||||
`013e629` flipped `images: true` → `false` for OpenCode models, and the commit says "61 OpenCode models
|
`013e629` flipped `images: true` → `false` for OpenCode models, and the commit says "61 OpenCode models
|
||||||
now decline, the three Claude ones still accept".
|
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:
|
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
|
`InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and
|
||||||
`useAttachments` collects them regardless.
|
`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:
|
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,
|
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.
|
so the flip in `013e629` starts doing something.
|
||||||
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
|
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
|
||||||
surfacing it.
|
surfacing it.
|
||||||
|
|
||||||
(1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2)
|
(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
|
## 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.
|
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.
|
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
|
3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ it_ — earned its place three separate times, detailed below.
|
|||||||
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
|
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
|
||||||
| `492509a` | B2 — route a resumed OpenCode session to OpenCode |
|
| `492509a` | B2 — route a resumed OpenCode session to OpenCode |
|
||||||
| `013e629` | B4 (first attempt), B5, B6, thinking selector |
|
| `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 |
|
| `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment |
|
||||||
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
|
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
|
||||||
| `8b409e8` | Phase 1 finish — stale comments, version pin, first tests |
|
| `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
|
**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
|
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
|
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 capability should not remove a
|
flag is load-bearing. Unknown model still allows images: a missing permission should not remove a
|
||||||
working control.
|
working control.
|
||||||
|
|
||||||
**Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still
|
**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
|
## 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
|
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.
|
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
|
3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ works, it is verified end to end, and its limits are all consequences of that on
|
|||||||
|
|
||||||
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
|
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 |
|
| 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 |
|
| 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
|
`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.
|
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
|
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
|
||||||
and they are cheap once B holds.
|
and they are cheap once B holds.
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ behind what some machines run.
|
|||||||
|
|
||||||
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
|
**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.
|
persistent, addressable session.
|
||||||
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
|
- 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.
|
exists in the deleted code, so the cost is smaller than it looks.
|
||||||
|
|||||||
@@ -5,11 +5,11 @@ Agents are explicitly out of scope for the first pass.
|
|||||||
|
|
||||||
## What this is for
|
## What this is for
|
||||||
|
|
||||||
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the
|
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 `capabilities/registry.ts` declares them
|
**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.
|
`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
|
The registry says so out loud: _"revisit only if per-user home confinement is ever solved — and that is a
|
||||||
project, not a checkbox."*
|
project, not a checkbox."_
|
||||||
|
|
||||||
This is that project. A member gets a real Linux account whose home is the directory the platform already
|
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
|
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:
|
On this machine, verified 2026-08-11:
|
||||||
|
|
||||||
| path | mode | consequence |
|
| path | mode | consequence |
|
||||||
| --- | --- | --- |
|
| ----------------- | ------- | ---------------------------------- |
|
||||||
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
| `/home/pastilhas` | 751 | traversable by anyone (no listing) |
|
||||||
| `…/officer.dev` | 775 | listable by anyone |
|
| `…/officer.dev` | 775 | listable by anyone |
|
||||||
| `…/platform/.env` | **664** | **world-readable** |
|
| `…/platform/.env` | **664** | **world-readable** |
|
||||||
|
|
||||||
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
|
`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
|
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
|
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`
|
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
|
under the project root is group- or world-readable.** A prerequisite that is merely written down is a
|
||||||
prerequisite that gets skipped.
|
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.
|
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
|
||||||
|
|
||||||
## The mechanism, and the trap in it
|
## 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:
|
Verified on bun 1.3.10, 2026-08-11. From uid 1000:
|
||||||
|
|
||||||
```js
|
```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
|
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
|
- **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.
|
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`.
|
sidecars are `.mjs`.
|
||||||
|
|
||||||
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
|
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.
|
- `--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.
|
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
|
- `--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`,
|
`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`.
|
`.env`.
|
||||||
|
|
||||||
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
|
**`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
|
`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`
|
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
|
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
|
||||||
user will ever see.
|
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").
|
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
|
`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.
|
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
|
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
|
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`.
|
`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`
|
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
|
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
|
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
|
`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
|
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.
|
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
|
- **`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
|
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.
|
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
|
- **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:
|
below was **observed**, not reasoned about:
|
||||||
|
|
||||||
| attempted, as the member | result |
|
| attempted, as the member | result |
|
||||||
| --- | --- |
|
| ---------------------------------------- | ------------------------ |
|
||||||
| write in own home | OK |
|
| write in own home | OK |
|
||||||
| read `…/<email>/attachments/private.txt` | Permission denied |
|
| read `…/<email>/attachments/private.txt` | Permission denied |
|
||||||
| `ls …/<email>/` (their own account dir) | 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
|
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
|
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.
|
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
|
The skeleton is now created closed — `711` on the account directory, `700` inside — so _unconfined_ is
|
||||||
also *unreachable*.
|
also _unreachable_.
|
||||||
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
|
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
|
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.
|
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`,
|
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
|
||||||
enforced by the platform;
|
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.
|
mean a namespace or a chroot, which is a different and much larger feature.
|
||||||
|
|
||||||
Say "cannot see behind it", not "cannot leave it".
|
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:
|
alternatives:
|
||||||
|
|
||||||
| | where | who holds the private half | what it is for |
|
| | where | who holds the private half | what it is for |
|
||||||
| --- | --- | --- | --- |
|
| ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
|
||||||
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine |
|
| **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 |
|
| **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.
|
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
|
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.
|
generated regardless.
|
||||||
|
|
||||||
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
|
**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
|
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
|
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
|
Linux password would be a second door that changing the platform password does not close and deleting the
|
||||||
platform account does not lock.
|
platform account does not lock.
|
||||||
|
|
||||||
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
|
**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
|
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
|
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
|
||||||
force a command.
|
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
|
**`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.
|
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
|
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
|
**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
|
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` +
|
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
|
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
|
||||||
security code.
|
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.
|
5. **Agents.** Separately, later, with the SDK problem solved first.
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ Three things are already true, which is why "nothing exactly blocks it":
|
|||||||
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
|
- **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.
|
states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
|
||||||
So the app store never needs to mount or unmount routes.
|
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.
|
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
|
- **`service_connections` already solves the multi-user case**, including the part nobody would get
|
||||||
right independently — see below.
|
right independently — see below.
|
||||||
@@ -88,7 +88,7 @@ health checks already correct, so "install Gitea" does not become a tutorial.
|
|||||||
platform/ the app
|
platform/ the app
|
||||||
data/ DATA_PATH
|
data/ DATA_PATH
|
||||||
dockers/ services the app store provisioned <- exclusively ours
|
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
|
`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:
|
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
|
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
|
||||||
schema.
|
schema.
|
||||||
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
- **May reference** `users.id`, and use `service_connections` under its own ID.
|
||||||
|
|||||||
@@ -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:
|
`src/servers/api/slskd/` is **70 lines total** and does exactly the two things it should:
|
||||||
|
|
||||||
| File | Lines | Role |
|
| 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. |
|
| `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. |
|
| `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`,
|
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
|
`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.
|
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.
|
hand-applied.
|
||||||
|
|
||||||
## The smell: the frontend speaks slskd
|
## 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.**
|
**37 raw `/slskd/api/v0/…` calls from React, against 10 `/slskd/_officer/…` calls.**
|
||||||
|
|
||||||
| File | Raw slskd calls |
|
| File | Raw slskd calls |
|
||||||
|---|---|
|
| ----------------------- | --------------- |
|
||||||
| `SoulseekTransfers.tsx` | 8 |
|
| `SoulseekTransfers.tsx` | 8 |
|
||||||
| `SoulseekRooms.tsx` | 6 |
|
| `SoulseekRooms.tsx` | 6 |
|
||||||
| `SoulseekChat.tsx` | 5 |
|
| `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:
|
`src/servers/sidecar/slskd/` — what "the sidecar owns its job" already looks like:
|
||||||
|
|
||||||
| File | Role |
|
| 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. |
|
| `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`. |
|
| `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. |
|
| `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`:
|
The eight sidecars, from `ecosystem.config.cjs`:
|
||||||
|
|
||||||
| PM2 process | Entry point |
|
| PM2 process | Entry point |
|
||||||
|---|---|
|
| ------------------ | ---------------------------------------------------------- |
|
||||||
| `officer-claude` | `src/servers/sidecar/claude/index.ts` |
|
| `officer-claude` | `src/servers/sidecar/claude/index.ts` |
|
||||||
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
|
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
|
||||||
| `officer-email` | `src/servers/sidecar/email/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.
|
entirely in the main process, with no sidecar owning any of it.
|
||||||
|
|
||||||
| Surface | Lines |
|
| Surface | Lines |
|
||||||
|---|---|
|
| ---------------------------------------------------------------------- | -------- |
|
||||||
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
|
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
|
||||||
| `hono.ts` (3) + `protocol.ts` (1) | 4 |
|
| `hono.ts` (3) + `protocol.ts` (1) | 4 |
|
||||||
| `api/cliamp/websocket.ts` | 201 |
|
| `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
|
`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`).
|
(`: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`).
|
Child processes are held in a module-level `Map` (`:22`).
|
||||||
*Belongs in* `sidecar/music/`, which already runs its own loopback HTTP server
|
_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
|
(`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,
|
traffic — but the relay pattern already exists twice (`server.tsx:164-228` for dev-server,
|
||||||
`server.tsx:323-326` for vault).
|
`server.tsx:323-326` for vault).
|
||||||
2. **PulseAudio host-daemon bootstrap** — `server.tsx:391-436`. A startup IIFE that locates
|
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
|
`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`).
|
`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.
|
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.
|
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
|
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
|
`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
|
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.
|
costs a copy per chunk.
|
||||||
4. **ALSA config shipped inside the API tree** — `api/cliamp/asoundrc:1-9`, passed via
|
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
|
`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.
|
domain. This is the worst offender of the eight, and the one where placement has real consequences.
|
||||||
|
|
||||||
| Surface | Lines |
|
| Surface | Lines |
|
||||||
|---|---|
|
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
|
||||||
| `api/vault/router.ts` | 169 |
|
| `api/vault/router.ts` | 169 |
|
||||||
| `api/vault/websocket.ts` | 164 |
|
| `api/vault/websocket.ts` | 164 |
|
||||||
| `api/vault/broker.ts` | 79 |
|
| `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`).
|
`VAULTWARDEN_URL`).
|
||||||
|
|
||||||
Two structural notes before the findings:
|
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
|
`Authorization` header with a platform-held upstream credential (`:137-141`) and implements
|
||||||
401-refresh-retry (`:158-165`).
|
401-refresh-retry (`:158-165`).
|
||||||
- It does **not** inject `X-Officer-User` (contrast `api/slskd/router.ts:34`,
|
- 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
|
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
|
`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.
|
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.
|
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`
|
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`).
|
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
|
`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`).
|
`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
|
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
|
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`.
|
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`,
|
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.
|
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`,
|
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
|
`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
|
against the sidecar's tables — is not. All three are already best-effort `.catch(() => {})`, so
|
||||||
failure semantics wouldn't worsen behind a sidecar call.
|
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
|
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.
|
strings — fix (2) and it's unnecessary.
|
||||||
9. **Mounted outside the protected tree** — `hono.ts:73-77`. `route('/api/vault', …)` sits outside
|
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`,
|
`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
|
`bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from
|
||||||
`hono.ts:88` and buffer vault attachments.
|
`hono.ts:88` and buffer vault attachments.
|
||||||
10. **Stale comments on a security boundary** — `hono.ts:73-76` and
|
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
|
`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
|
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
|
`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.
|
`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.
|
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
|
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.
|
`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
|
`/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
|
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
|
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.
|
for survivability. They overlap deliberately.
|
||||||
|
|
||||||
There is **no `/api/claude` mount, no proxy router, and no `X-Officer-User` anywhere on this path.**
|
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.
|
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
|
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
|
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.
|
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
|
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`),
|
(`: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
|
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
|
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
|
`spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with
|
||||||
`stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters`
|
`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`
|
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.
|
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.**
|
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
|
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,
|
(`: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
|
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
|
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
|
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
|
`/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
|
`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
|
`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.
|
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`
|
(`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
|
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.
|
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:
|
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
|
**≈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
|
`../../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.
|
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`),
|
work.** `gmailResync` (`:43-63`), `imapResync` (`:148-264`), `resolveImapAuth` (`:118-146`),
|
||||||
`refreshCredentials` (`:21-41`), and `performResync` (`:276-284`) which coalesces concurrent
|
`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
|
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
|
copy of the Map, so the coalescing silently does nothing across the boundary. This is what
|
||||||
"importing platform code back out" costs.
|
"importing platform code back out" costs.
|
||||||
6. **`api/email/accounts.ts:1-259` — account setup does live IMAP.** Validation by real connection on
|
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
|
12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is
|
||||||
`sidecar/email/email-cron.ts`).
|
`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
|
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
|
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.
|
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
|
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
|
on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the
|
||||||
platform.
|
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`).**
|
metadata for the results (`:42-45`).**
|
||||||
8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`**
|
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).
|
(`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
|
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
|
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
|
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
|
`.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`.)*
|
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
|
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,
|
parsing in the shared upgrade handler (`server.tsx:248-252`, `WSData:51-52`) and wiring at `:7, 38,
|
||||||
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
|
||||||
@@ -588,7 +589,7 @@ already exists in the same codebase.
|
|||||||
than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same
|
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.
|
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
|
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
|
replace `websocket.ts` with the `devServerWebsocket` relay shape. The detach-on-disconnect policy moves
|
||||||
with it.
|
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
|
7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is already
|
||||||
reference-shaped** (two lines).
|
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
|
`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
|
`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
|
`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:
|
Sorted by how far each is from the reference. This is the whole audit in one view:
|
||||||
|
|
||||||
| sidecar | platform lines | verdict |
|
| sidecar | platform lines | verdict |
|
||||||
|---|---:|---|
|
| -------- | -------------: | ---------------------------------------------------- |
|
||||||
| slskd | 70 | ✅ reference |
|
| slskd | 70 | ✅ reference |
|
||||||
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
|
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
|
||||||
| pty | 169 | ✗ ~all of it is sidecar logic |
|
| 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 |
|
| email | ~2,875 | ✗ no proxy exists at all |
|
||||||
|
|
||||||
`hono.ts` mounts **36 routers. Three are thin sidecar proxies** — `:106` (music), `:107` (slskd), and
|
`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 ·
|
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.
|
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
|
### 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/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).
|
landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params).
|
||||||
|
|
||||||
Two specific consequences:
|
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
|
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.
|
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.
|
generic. The registration handshake got this right; the command channel did not.
|
||||||
|
|
||||||
### 3. Ten WebSocket providers, and only three are tunnels
|
### 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
|
### 5. Registry bugs that will bite during any migration
|
||||||
|
|
||||||
- **`unregisterSidecar` (`sidecar-registry.ts:80-85`) rejects the entire global pending-command map
|
- **`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
|
vault commands. This will look like random unrelated breakage the moment sidecars restart
|
||||||
independently — which is the entire goal.
|
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`.
|
the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`.
|
||||||
|
|
||||||
### 6. What the database says (the clearest signal in the audit)
|
### 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
|
- **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching across
|
||||||
domains into it.
|
domains into it.
|
||||||
|
|
||||||
**A useful rule falls out of this:** *if a table is read by exactly one sidecar and nothing else, that
|
**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.*
|
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.
|
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
|
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
|
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
|
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
|
`/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`).
|
(`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
|
**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`
|
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.
|
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.
|
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` —
|
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
|
`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
|
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.)*
|
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`,
|
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
|
`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
|
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.
|
originate two layers away from the thing they describe.
|
||||||
5. **Claude CLI session conventions are documented in the browser.**
|
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
|
`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
|
`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`,
|
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
|
## 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
|
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.
|
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
|
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
|
- `GoogleOAuthConfig.tsx:197-210` — `GET /integrations/google/config` returns `clientSecret` in
|
||||||
plaintext; held in `useState` (`:184`), shown at `:255-262`.
|
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
|
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.
|
changing, since a write-only field would work identically.
|
||||||
|
|
||||||
5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds
|
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`
|
`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
|
(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
|
`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
|
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
|
`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
|
`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`).
|
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
|
**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
|
- **No charset, quoted-printable, base64 or RFC-2047 decoding in the browser** — it receives decoded
|
||||||
`text`/`html`/`snippet`. Reading is compliant; only composing leaks.
|
`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=`
|
- **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
|
- **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
|
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
|
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
|
`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
|
- **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.
|
hash and that threading currently leans on `Re:` + participants.
|
||||||
|
|
||||||
## opencode — the most compliant frontend of the eight
|
## 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
|
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`,
|
— 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
|
`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
|
(`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
|
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`,
|
`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.**
|
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
|
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)
|
`/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.
|
(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.**
|
**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
|
`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`
|
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
|
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.
|
support it. A backend defect, surfaced by a compliant frontend.
|
||||||
|
|
||||||
## terminal / pty — the browser reconnects, and then loses the session anyway
|
## 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
|
is mostly well-behaved, has real reconnect logic, and yet contains **one bug that defeats the entire
|
||||||
detach-not-kill design.**
|
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
|
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
|
(`: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
|
(`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
|
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` →
|
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
|
`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.**
|
1. **The browser composes shell commands by string concatenation, unescaped.**
|
||||||
```ts
|
```ts
|
||||||
// Terminal.tsx:187-190
|
// Terminal.tsx:187-190
|
||||||
const wrapped = onCommandDoneRef.current
|
const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current;
|
||||||
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
|
|
||||||
: commandRef.current;
|
|
||||||
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
|
||||||
```
|
```
|
||||||
That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at
|
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
|
- **The backend's `cwd` handler is unreachable.** Pass 1 flagged
|
||||||
`api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``.
|
`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`
|
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.
|
duplicated in the client.
|
||||||
- **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message
|
- **`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
|
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:
|
The single most useful thing in this pass. Ranked by frontend compliance:
|
||||||
|
|
||||||
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|
||||||
|---|---|---|
|
| -------- | ------------------------------- | -------------------------------------------------------- |
|
||||||
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
|
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
|
||||||
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
|
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
|
||||||
| opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic |
|
| 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.
|
proxying the upstream one.
|
||||||
|
|
||||||
So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete
|
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
|
`/_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.
|
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:
|
per-socket rather than systemic:
|
||||||
|
|
||||||
| channel | reconnect | replay |
|
| channel | reconnect | replay |
|
||||||
|---|---|---|
|
| ---------------------------- | -------------------------------------- | --------------------------------------------------- |
|
||||||
| chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) |
|
| 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 |
|
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
|
||||||
| cliamp audio WS | ✗ none | — n/a (live capture) |
|
| 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()`
|
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
|
(`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves
|
||||||
the pty on stale dimensions until the next reconnect.
|
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,
|
`api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192,
|
||||||
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ A sidecar is a **PM2 peer of `officer`** — never a child. It dials _in_; offic
|
|||||||
```
|
```
|
||||||
PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
|
||||||
→ it opens a WS to officer at /api/sidecar/register
|
→ 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 }
|
→ officer replies { type:'registered', id }
|
||||||
→ it sends { type:'<name>:server', port } (HTTP sidecars only)
|
→ it sends { type:'<name>:server', port } (HTTP sidecars only)
|
||||||
→ officer remembers the port and proxies <prefix>/* to it
|
→ 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
|
Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is
|
||||||
`src/servers/sidecar/connect.ts`.
|
`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`,
|
explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`,
|
||||||
`spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_,
|
`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.
|
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'`).
|
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 —
|
2. **A registration** with a `name` and `permissions[]`. Officer indexes by permission, not by name —
|
||||||
`findSidecarByCapability` is how every caller reaches one.
|
`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
|
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.
|
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,
|
`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_.
|
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 |
|
| 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/index.ts` | `officer-anthropic-proxy` | name `proxy`, permission `['proxy']` | Holds the Anthropic credential, forwards API traffic |
|
||||||
| `claude/user-instance.ts` | `officer-agent` | capability `['claude']` | The process that actually spawns `claude` |
|
| `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
|
So **permission `proxy` is the Anthropic proxy, and permission `claude` is the agent.** Nothing named
|
||||||
"claude" registers the `claude` capability from `claude/index.ts`, which is exactly the sort of thing
|
"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 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
|
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
|
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.
|
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.
|
(`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
|
## 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.
|
`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
|
2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the
|
||||||
officer side was?
|
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?
|
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?
|
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
|
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
|
## Verified facts this document rests on
|
||||||
|
|
||||||
| Claim | How |
|
| Claim | How |
|
||||||
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
| ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
||||||
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
|
| 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` |
|
| 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/` |
|
| `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 |
|
| 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()` |
|
| Boot fails on a missing permission entry | `assertPermissionTotality` throws before `serve()` |
|
||||||
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
|
| `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` |
|
| `isConnected()` has no callers outside the registry | grep across `src/servers` |
|
||||||
|
|||||||
@@ -8,13 +8,13 @@ vocabulary left between them. Every HTTP sidecar shares one `createSidecarProxy`
|
|||||||
happened is the part this document is actually about — fixed ports, the platform reading a table instead
|
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.
|
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.
|
current state (what's misplaced, and where). This is where it's going.
|
||||||
|
|
||||||
## The premise that makes it simple
|
## The premise that makes it simple
|
||||||
|
|
||||||
**The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand —
|
**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
|
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.
|
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
|
## 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,
|
`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.
|
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.
|
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
|
- `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`,
|
- every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`,
|
||||||
`pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started`
|
`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
|
- officer's proxying for anything that isn't auth or layout state
|
||||||
|
|
||||||
## Migration order
|
## 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
|
- **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`.
|
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
|
`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.**
|
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.
|
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
|
- **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.
|
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`
|
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.
|
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
|
- **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 spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the
|
||||||
platform only reads the topology.
|
platform only reads the topology.
|
||||||
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
|
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
|
||||||
the original points 6–8. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its
|
the original points 6–8. Dropped with the tailnet decision. Worth knowing _why_ it was weak even on its
|
||||||
own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down
|
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.
|
at login, or has to be told about a logout.
|
||||||
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
|
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
|
||||||
|
|||||||
+18
-18
@@ -1,7 +1,7 @@
|
|||||||
# Working on Officer
|
# Working on Officer
|
||||||
|
|
||||||
The guide for anyone — human or agent — changing this deployment. It assumes you are working from the
|
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.
|
agent sessions start.
|
||||||
|
|
||||||
Three directories sit there, and knowing which one a change belongs in is most of the job:
|
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/
|
$OFFICER_ROOT/
|
||||||
├── platform/ the application — a git repo
|
├── 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
|
├── data/ runtime state — NOT version controlled
|
||||||
├── dockers/ containers the app store provisioned
|
├── dockers/ containers the app store provisioned
|
||||||
└── secrets/ the key store — 0600, and NOT in your data backup
|
└── 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
|
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**
|
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
|
— 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 —
|
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
|
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.
|
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.
|
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
|
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.
|
prose, here or elsewhere.
|
||||||
|
|
||||||
## What is switched off (2026-08-13)
|
## What is switched off (2026-08-13)
|
||||||
|
|
||||||
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
|
||||||
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
|
`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
|
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 `assertCapabilityTotality` refuses to boot.
|
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
|
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
|
||||||
extraction into the plugin system.
|
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
|
`.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`.
|
`$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
|
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.
|
system but written down nowhere else.
|
||||||
|
|
||||||
@@ -62,19 +62,19 @@ system but written down nowhere else.
|
|||||||
|
|
||||||
## Which directory does this change belong in?
|
## 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
|
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.
|
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
|
**`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
|
**`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
|
SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here
|
||||||
destroys the only copy.
|
destroys the only copy.
|
||||||
|
|
||||||
A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`.
|
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
|
## 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
|
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.
|
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
|
### Services this box depends on
|
||||||
|
|
||||||
| port | what | used by |
|
| port | what | used by |
|
||||||
|------|------|---------|
|
| ---- | ------------------------- | -------------- |
|
||||||
| 9010 | Officer itself | — |
|
| 9010 | Officer itself | — |
|
||||||
| 9002 | Kokoro TTS | text-to-speech |
|
| 9002 | Kokoro TTS | text-to-speech |
|
||||||
| 8178 | whisper.cpp | transcription |
|
| 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
|
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.
|
**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
|
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.
|
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:
|
used heavily by real tasks and appear nowhere in it:
|
||||||
|
|
||||||
| convention | what it does |
|
| 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: true` | runs ephemerally in the modal instead of becoming a job |
|
||||||
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
| `inline: ask` | offers both — **Run here** and **Run as job** |
|
||||||
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
|
| `INPUT_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:
|
House style for file-processing tasks, worth keeping consistent:
|
||||||
|
|
||||||
- Never delete or modify the source; write output beside it.
|
- 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`.
|
- Honour `INPUT_INCLUDE`.
|
||||||
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
|
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
|
||||||
overwrites edits.
|
overwrites edits.
|
||||||
|
|||||||
@@ -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
|
`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
|
(`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.
|
these.
|
||||||
|
|
||||||
> **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical.
|
> **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
|
> 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
|
> 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
|
> 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
|
> defect**: the answer decides whether the item below is urgent or should be deleted along with the rest
|
||||||
> of the section.
|
> 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
|
**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
|
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.
|
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
|
- **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.
|
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.
|
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 |
|
| 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
|
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
|
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.
|
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
|
- [ ] ~~**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.
|
||||||
4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
|
|
||||||
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
|
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
|
||||||
(`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`.
|
(`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
|
- [ ] **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
|
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
|
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.
|
every mount forever and never heals the row.
|
||||||
|
|
||||||
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
|
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
|
||||||
`defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()`
|
`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`.
|
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
|
the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client
|
||||||
**discarded** — 3 SELECTs per splitter release, thrown away.
|
**discarded** — 3 SELECTs per splitter release, thrown away.
|
||||||
**Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that
|
**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
|
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
|
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
|
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.
|
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
|
- [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly
|
||||||
(`100 / newChildren.length`), so one split discards carefully tuned proportions.
|
(`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
|
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
|
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
|
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
|
(`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.
|
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
|
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
|
whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything
|
||||||
else that pulls in a panel app.
|
else that pulls in a panel app.
|
||||||
- [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with
|
- [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
|
`availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say
|
||||||
"No file selected" forever.
|
"No file selected" forever.
|
||||||
**Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file
|
**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
|
supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from
|
||||||
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
|
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
|
||||||
(checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero
|
(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()`
|
`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
|
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
|
`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.
|
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`
|
- [ ] **`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 —
|
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
|
- [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.
|
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.
|
- [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
|
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.
|
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.
|
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`,
|
**§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
|
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
|
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.
|
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.
|
Email can supply a pre-configured chat by panel id.
|
||||||
**Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in
|
**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` 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
|
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
|
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`.
|
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
|
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
|
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
|
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)_
|
### 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
|
- [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
|
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
|
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.
|
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
|
||||||
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
|
- [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
|
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.
|
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
|
**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`
|
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/`.
|
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`,
|
- [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
|
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,
|
`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
|
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
|
`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
|
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
|
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
|
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
|
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
|
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
|
`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
|
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
|
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
|
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.
|
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`,
|
- [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
|
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
|
`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`
|
(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
|
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.
|
a swap or a mobile switch cause.
|
||||||
Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove`
|
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
|
**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
|
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
|
them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from
|
||||||
`handleSetApp` when the app actually changes, and from nowhere else.
|
`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`
|
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
|
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
|
a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app
|
||||||
|
|||||||
@@ -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`
|
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||||
and `helpers/clipboard` stay as they are.
|
and `helpers/clipboard` stay as they are.
|
||||||
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
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
|
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||||
**both** database barrels (`index.ts` and `schema.ts`).
|
**both** database barrels (`index.ts` and `schema.ts`).
|
||||||
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
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.
|
- **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
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||||
contract change. Regenerate rather than hand-edit.
|
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
|
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
|
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
|
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.
|
`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;
|
- **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.
|
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()`,
|
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.
|
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.
|
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
|
- **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
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
// 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
|
// 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
|
// 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.
|
// installer, and adding a socket here would test Bun rather than that.
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ to `/music` remounts the host and reloads it.
|
|||||||
## Two bugs, neither visible from reading
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
**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
|
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
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
installed, enabled and healthy.
|
installed, enabled and healthy.
|
||||||
@@ -150,7 +150,7 @@ so it finds `src/servers/plugins/` and not `plugins/`.
|
|||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
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.
|
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
|
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 |
|
| 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** |
|
| 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 |
|
| 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 |
|
| permissions page | `music` listed among the grantable |
|
||||||
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
| enable | 200 again, sidecar online, favourites still there |
|
| 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.
|
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` 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:
|
**`confined`** — so:
|
||||||
|
|
||||||
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
- 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.
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||||
Most self-contained, and the most work.
|
Most self-contained, and the most work.
|
||||||
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
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
|
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.
|
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.
|
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
|
- **`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.
|
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.
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export const manifest: PluginManifest = {
|
|||||||
// One permission gating the whole surface, grantable per role at read or write like every other.
|
// 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
|
// 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.
|
// 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,
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ export const MusicPlayerHost = () => {
|
|||||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
// 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).
|
// 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.
|
// used to reach for `/music/now-playing` on a member's very first paint and 403.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (restoredRef.current) return;
|
if (restoredRef.current) return;
|
||||||
|
|||||||
+19
-18
@@ -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.
|
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
|
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 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.
|
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
|
- 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
|
- a **frontend router**, its routes, and the frontend code
|
||||||
- how it **mounts into the file browser context menu**
|
- 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
|
- **plugin settings page** definitions
|
||||||
- an accompanying **mobile app**
|
- 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.
|
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.
|
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
|
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
|
**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
|
## 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
|
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
|
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||||
platform's to assign.
|
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 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
|
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.
|
the surface; `write` permits everything.
|
||||||
|
|
||||||
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
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.
|
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:
|
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)
|
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).
|
Offscale needs (1) and (3), and not (2).
|
||||||
|
|
||||||
@@ -321,7 +322,7 @@ Officer had two headscale surfaces:
|
|||||||
|
|
||||||
| | `/api/vpn` | `/api/headscale` |
|
| | `/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 |
|
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||||
| surface | one route, `POST /enroll` | the whole admin API |
|
| 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
|
4. the device joins
|
||||||
|
|
||||||
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
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.
|
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 |
|
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||||
| `AppRegistryMeta` | the panel-contribution contract |
|
| `AppRegistryMeta` | the panel-contribution contract |
|
||||||
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
| `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 |
|
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||||
|
|
||||||
### `assist.ts` travels, but stays unwired
|
### `assist.ts` travels, but stays unwired
|
||||||
@@ -470,7 +471,7 @@ export const manifest = {
|
|||||||
icon: 'Network',
|
icon: 'Network',
|
||||||
color: '#818cf8',
|
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
|
// 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.
|
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||||
permissions: [
|
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
|
### `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 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
|
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
|
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.
|
time.
|
||||||
|
|
||||||
| Piece | Where |
|
| Piece | Where |
|
||||||
| --------------------------------------- | ---------------------------------------------------- |
|
| --------------------------------------- | -------------------------------------------------- |
|
||||||
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||||
| Discovery by convention | `servers/plugins/discover.ts` |
|
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||||
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||||
@@ -626,14 +627,14 @@ time.
|
|||||||
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||||
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||||
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||||
| Capability registration | `capabilities/registry.ts` → `setPluginCapabilities` |
|
| Permission registration | `permissions/registry.ts` → `setPluginPermissions` |
|
||||||
| Install state | `plugin_installs` |
|
| Install state | `plugin_installs` |
|
||||||
| The screen | `/plugins`, two panels, SSE log |
|
| The screen | `/plugins`, two panels, SSE log |
|
||||||
| The reference plugin | `plugins/example/` |
|
| The reference plugin | `plugins/example/` |
|
||||||
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
| **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
|
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()`.
|
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||||
|
|
||||||
### Three bugs the extraction found
|
### 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';
|
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
|
// 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 =>
|
const isDownloadProgress = (p: unknown): p is DownloadProgress =>
|
||||||
!!p && typeof p === 'object' && 'meta' in p && 'dl' in p;
|
!!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.
|
* 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
|
* 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
|
* 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.
|
* 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
|
// 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
|
// 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
|
// 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.
|
// `admin` kinds.
|
||||||
//
|
//
|
||||||
// ── Why `installed` and `enabled` are separate ──
|
// ── Why `installed` and `enabled` are separate ──
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
|||||||
// database refuses a row for that role.
|
// database refuses a row for that role.
|
||||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
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
|
// 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.
|
// the table be renamed without any client noticing.
|
||||||
grants: await getAllRoleGrants(),
|
grants: await getAllRoleGrants(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,10 +16,14 @@
|
|||||||
/**
|
/**
|
||||||
* A permission the plugin adds to the platform's permission system.
|
* 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
|
* Called `permissions`, and the plugin system used that word first — before the platform did. On
|
||||||
* already means three different things here — the permission registry, the file-based item store under
|
* 2026-08-15 the rest of the codebase followed: the registry moved to `permissions/`, its table to
|
||||||
* `$OFFICER_ROOT/permissions`, and the routing keys a sidecar registers with — and a fourth meaning
|
* `role_permissions`, and the sidecar's routing keys became `handles`.
|
||||||
* would be one too many. Nothing in this system uses it.
|
*
|
||||||
|
* `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 = {
|
export type PluginPermission = {
|
||||||
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
|
/** Stable identifier, stored as the grant's subject. Renaming one is a data change. */
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ afterEach(() => {
|
|||||||
while (registered.length) unregisterSidecar(registered.pop()!);
|
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> {
|
async function awaitSend(socket: { sent: string[] }): Promise<void> {
|
||||||
for (let i = 0; i < 50 && socket.sent.length === 0; i++) await Bun.sleep(1);
|
for (let i = 0; i < 50 && socket.sent.length === 0; i++) await Bun.sleep(1);
|
||||||
expect(socket.sent).not.toHaveLength(0);
|
expect(socket.sent).not.toHaveLength(0);
|
||||||
|
|||||||
@@ -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.
|
// 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`
|
// "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.
|
// the owner can ever reach this sidecar, however many accounts exist.
|
||||||
//
|
//
|
||||||
// ── Why this retries rather than throws ──
|
// ── 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
|
// 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,
|
// 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
|
// 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
|
// 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.
|
// read placed after that assignment would return whatever was last spawned into.
|
||||||
const homeDir = homedir();
|
const homeDir = homedir();
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ describe('resolveNotifyUser', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// The regression this module exists for: `body.userId ?? header` let any account holding the
|
// 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', () => {
|
test('the header beats a conflicting body userId', () => {
|
||||||
expect(resolveNotifyUser({ header: '13', bodyUserId: 1 })).toBe(13);
|
expect(resolveNotifyUser({ header: '13', bodyUserId: 1 })).toBe(13);
|
||||||
expect(resolveNotifyUser({ header: '1', bodyUserId: 13 })).toBe(1);
|
expect(resolveNotifyUser({ header: '1', bodyUserId: 13 })).toBe(1);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//
|
//
|
||||||
// So the header wins wherever it is present. This lives in its own module, away from the Bun.serve
|
// 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
|
// 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.
|
// devices by naming them in a JSON body.
|
||||||
|
|
||||||
type ResolveUserParams = {
|
type ResolveUserParams = {
|
||||||
|
|||||||
@@ -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
|
// 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
|
// 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.
|
// 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
|
// 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. */
|
/** What an installed sidecar says about how it should appear. Mirrors UiManifest on the server. */
|
||||||
export type PluginManifest = {
|
export type PluginManifest = {
|
||||||
sidecarId: string;
|
sidecarId: string;
|
||||||
capability: string | null;
|
permission: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
image?: 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
|
* `/`, 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.
|
* blank the app for everyone rather than restrict it for anyone.
|
||||||
*/
|
*/
|
||||||
@@ -115,8 +115,8 @@ export function usePermissions() {
|
|||||||
if (!data) return null;
|
if (!data) return null;
|
||||||
if (!data.deniedRoutes.some((route) => path === route || path.startsWith(`${route}/`))) 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,
|
// Held but unavailable → the sidecar is missing. Checked against the permission that claims the route,
|
||||||
// which is why `unavailable` is returned as capability keys rather than routes.
|
// which is why `unavailable` is returned as permission keys rather than routes.
|
||||||
const unavailable = new Set(data.unavailable ?? []);
|
const unavailable = new Set(data.unavailable ?? []);
|
||||||
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
|
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
|
||||||
if (data.isOwner || heldAndUnavailable) return 'not-installed';
|
if (data.isOwner || heldAndUnavailable) return 'not-installed';
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export function useAppStore() {
|
|||||||
queryFn: () => client.get<{ items: StoreItem[] }>('/app-store'),
|
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
|
// tiles exist, and a store that updated while the dock kept the old list would be visibly wrong on
|
||||||
// the same screen.
|
// the same screen.
|
||||||
const invalidate = () => {
|
const invalidate = () => {
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export type AgentGroup = { category: string; agents: AgentSummary[] };
|
|||||||
// wrong thing about what happens when you click.
|
// wrong thing about what happens when you click.
|
||||||
export const useAgents = () => {
|
export const useAgents = () => {
|
||||||
const client = useClient();
|
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.
|
// starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu.
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const allowed = can('items');
|
const allowed = can('items');
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export const useServerSettings = () => {
|
|||||||
const client = useClient();
|
const client = useClient();
|
||||||
const queryClient = useQueryClient();
|
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
|
// hook is mounted by shell components that every account loads, so without the guard a member's first
|
||||||
// paint fired a 403 at it.
|
// paint fired a 403 at it.
|
||||||
const { isOwner } = usePermissions();
|
const { isOwner } = usePermissions();
|
||||||
|
|||||||
Reference in New Issue
Block a user