Compare commits
69
Commits
fe0012635a
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb8fe4320 | ||
|
|
9c2d6a97f7 | ||
|
|
027b10bd6e | ||
|
|
f9fd002ff4 | ||
|
|
5afa2d832e | ||
|
|
5ec354cfcb | ||
|
|
2e8ec845c8 | ||
|
|
b0fcd8b81b | ||
|
|
9b1c0a75b2 | ||
|
|
f78abbe05a | ||
|
|
e11e0b6475 | ||
|
|
0a55964db5 | ||
|
|
f1bd75853d | ||
|
|
9af52fd754 | ||
|
|
1e79b4effd | ||
|
|
a9bf51407e | ||
|
|
8bfcd40bd2 | ||
|
|
e930586878 | ||
|
|
05eb947bd1 | ||
|
|
de3340398c | ||
|
|
18c4ebd0b4 | ||
|
|
7d65732f77 | ||
|
|
b5db3c47e1 | ||
|
|
8545b427dd | ||
|
|
965ced52a6 | ||
|
|
4a9f23c759 | ||
|
|
b4dab16d2a | ||
|
|
2c89281bfc | ||
|
|
585c046a64 | ||
|
|
8cc51cfb40 | ||
|
|
8b6cb34ae0 | ||
|
|
8587ae20b7 | ||
|
|
e13128846b | ||
|
|
0e24aa3d52 | ||
|
|
543e88a9a6 | ||
|
|
2e3c935da6 | ||
|
|
7b4137ccca | ||
|
|
a00116b2c0 | ||
|
|
62ee0d1e60 | ||
|
|
4d4606d4a2 | ||
|
|
a220342b22 | ||
|
|
02e049cae8 | ||
|
|
2634df7a04 | ||
|
|
ed195e0904 | ||
|
|
98c400bf33 | ||
|
|
282a64a637 | ||
|
|
0701aba902 | ||
|
|
2e6c263751 | ||
|
|
b2349b5480 | ||
|
|
0ae0a5dc58 | ||
|
|
acd51c969c | ||
|
|
4c3682dae6 | ||
|
|
56bb383c6d | ||
|
|
327783532e | ||
|
|
9f903479ce | ||
|
|
6ab838c77f | ||
|
|
13437e0e48 | ||
|
|
7befaf032a | ||
|
|
7ebc4d0ccd | ||
|
|
1292a5c5ab | ||
|
|
4dc7cd90c2 | ||
|
|
b18601530f | ||
|
|
f6b2905cc7 | ||
|
|
7f26f0b4b8 | ||
|
|
01a20fff4e | ||
|
|
88a44ec4a7 | ||
|
|
bbc60b34ac | ||
|
|
d000cedf2f | ||
|
|
336e718463 |
+30
@@ -63,3 +63,33 @@ scripts/setup/officer-setup/.setup-progress
|
|||||||
# the repository has no ecosystem file at all any more, and the next machine
|
# the repository has no ecosystem file at all any more, and the next machine
|
||||||
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
# generates its own. See scripts/setup/officer-setup/lib/services.sh.
|
||||||
ecosystem.config.cjs
|
ecosystem.config.cjs
|
||||||
|
|
||||||
|
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
|
||||||
|
# rewritten on every install. See servers/plugins/generate.ts.
|
||||||
|
build/
|
||||||
|
build.next/
|
||||||
|
src/apps/officer-web/Plugins.gen.tsx
|
||||||
|
src/databases/officer_db/src/plugin-schemas.gen.ts
|
||||||
|
|
||||||
|
# drizzle-kit's generated migrations. Nothing applies them — there is no __drizzle_migrations table and
|
||||||
|
# `drizzle-kit migrate` has never been run here; `bun db:push` diffs the schema code against the live
|
||||||
|
# database and alters it directly. The schema code is the source of truth (src/databases/CLAUDE.md).
|
||||||
|
#
|
||||||
|
# Ignored rather than merely unused, because `bun db:gen` reads src/schema.ts — whose last line imports
|
||||||
|
# plugin-schemas.gen.ts, itself generated from the plugin DIRECTORIES on this machine. So a generated
|
||||||
|
# migration describes whichever plugins happen to be checked out here, and committing one would launder
|
||||||
|
# per-machine state into the repository: run it with music installed and history gains music_*; run it on
|
||||||
|
# a fresh clone and the next commit deletes them again.
|
||||||
|
#
|
||||||
|
# The old 0000_new_princess_powerful.sql is deleted from the WORKING TREE but left in history. It had 36
|
||||||
|
# tables and described a schema from before capabilities, api_keys, the app store and the plugin system
|
||||||
|
# existed, including three (task_logs, terminal_containers, queue_jobs) that no longer exist at all.
|
||||||
|
#
|
||||||
|
# Purging it from history was tried on 2026-08-15 and deliberately undone. This codebase cites 76 commit
|
||||||
|
# SHAs in comments and docs as evidence — `totality.ts` points at 2873948 for the websocket incident,
|
||||||
|
# `registry.ts` at 044aacf4, CLAUDE.md at f35c145 — and a filter-repo run rewrites every one of them.
|
||||||
|
# Three stale files nobody reads in old commits are not worth 76 dangling citations in a codebase whose
|
||||||
|
# documentation works by pointing at the commit that proves the claim.
|
||||||
|
#
|
||||||
|
# Generate one locally whenever a diff is useful to read. It stays local.
|
||||||
|
src/databases/officer_db/migrations/
|
||||||
|
|||||||
@@ -12,22 +12,22 @@ look for `CLAUDE.md`, without the two drifting apart.
|
|||||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
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,24 +75,34 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
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.
|
||||||
|
|
||||||
|
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
|
||||||
|
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
|
||||||
|
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
|
||||||
|
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
|
||||||
|
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
|
||||||
|
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
|
||||||
|
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
|
||||||
|
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
|
||||||
|
which is why nobody noticed. Found 2026-08-14.
|
||||||
|
|
||||||
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
|
||||||
broken panel or an endless spinner rather than a clean refusal.
|
broken panel or an endless spinner rather than a clean refusal.
|
||||||
|
|
||||||
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
|
- [ ] **`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
|
||||||
@@ -110,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
|
|||||||
|
|
||||||
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
|
- [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.
|
||||||
|
|||||||
+10
-1
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
|
|||||||
coverage = true
|
coverage = true
|
||||||
coverageDir = "coverage"
|
coverageDir = "coverage"
|
||||||
preload = ["./test-setup.ts"]
|
preload = ["./test-setup.ts"]
|
||||||
root = "./src"
|
# The repo, not just `src` — a plugin's tests are the platform's tests.
|
||||||
|
#
|
||||||
|
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
|
||||||
|
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
|
||||||
|
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
|
||||||
|
# extraction would have taken its tests out of the suite the same way.
|
||||||
|
#
|
||||||
|
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
|
||||||
|
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
|
||||||
|
root = "."
|
||||||
|
|||||||
+109
-109
@@ -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,14 +635,14 @@ 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
|
||||||
`DATA_PATH/<email>/sidecar/claude-state.json`), specifically so it survives a crash or SIGKILL —
|
`DATA_PATH/<email>/sidecar/claude-state.json`), specifically so it survives a crash or SIGKILL —
|
||||||
commit `f4be4fd`.
|
commit `f4be4fd`.
|
||||||
- On a fresh spawn, `createSession` reads it back: `resumeId = getClaudeSession(sessionKey) ??
|
- On a fresh spawn, `createSession` reads it back: `resumeId = getClaudeSession(sessionKey) ??
|
||||||
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`).
|
||||||
|
|
||||||
**Therefore `spawnClaudeStreaming` on a reaped `sessionKey` transparently re-creates the session with
|
**Therefore `spawnClaudeStreaming` on a reaped `sessionKey` transparently re-creates the session with
|
||||||
`resume: <claudeSessionId>`, context intact from the on-disk transcript.** This is the good answer, and
|
`resume: <claudeSessionId>`, context intact from the on-disk transcript.** This is the good answer, and
|
||||||
@@ -658,17 +658,17 @@ it is better than a heartbeat on every axis:
|
|||||||
Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible
|
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 |
|
||||||
@@ -318,7 +318,7 @@ with no model, against a serve with no configured default, hangs silently.** Wor
|
|||||||
- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what
|
- **The transcript shape differs.** Legacy items are `{info:{role,…}, parts:[…]}` — what
|
||||||
`opencode-sessions.ts:81` parses. `/api` items are
|
`opencode-sessions.ts:81` parses. `/api` items are
|
||||||
`{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}],
|
`{id, time, type:'assistant', agent, model:{id,providerID,variant}, content:[{type:'text',id,text}],
|
||||||
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
finish, cost, tokens}`. A second mapper, or a shared normaliser.
|
||||||
- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no
|
- **The SSE parser needs to grow up.** `serve-runner.ts:89` is `data:`-only: no `event:`, no `id:`, no
|
||||||
comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream
|
comments, no `retry:`, no multi-line frames, fixed 1 s reconnect with no backoff. A cursored stream
|
||||||
must resume at `?after=<last seq>`, not restart.
|
must resume at `?after=<last seq>`, not restart.
|
||||||
@@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -449,7 +451,7 @@ was published hours before this file was written.
|
|||||||
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
vs `Definitions` — the delta exclusion), `packages/schema/src/{permission,question}.ts` and their
|
||||||
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
`v1/` counterparts, `packages/schema/src/session-input.ts` (`admittedSeq`), `packages/schema/AGENTS.md`
|
||||||
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
(the V1/V2 naming intent), `packages/core/src/event.ts` (replay-then-live), `packages/sdk/js/script/
|
||||||
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
build.ts` and `src/v2/client.ts`. PRs #27415 (the engine landing in 1.15.0), #33993, #35217, #35229
|
||||||
(the renames).
|
(the renames).
|
||||||
- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`.
|
- npm: `@opencode-ai/sdk` 1.18.16, `@opencode-ai/client@next`.
|
||||||
- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`,
|
- Prior art in this repo: `docs/opencode-parity.md`, `-fork-decision.md`, `-serve-migration-plan.md`,
|
||||||
|
|||||||
@@ -86,13 +86,13 @@ Also fixed after the review, and not in this table because it was found by revie
|
|||||||
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
|
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`,
|
||||||
@@ -274,10 +275,10 @@ Two structural notes before the findings:
|
|||||||
every proxied request.
|
every proxied request.
|
||||||
3. **Notifications WebSocket proxied twice, with token injection** — `api/vault/websocket.ts:1-164`.
|
3. **Notifications WebSocket proxied twice, with token injection** — `api/vault/websocket.ts:1-164`.
|
||||||
`injectToken` (`:45-52`) rewrites the SignalR query string: drops `token`, sets `access_token=<vw
|
`injectToken` (`:45-52`) rewrites the SignalR query string: drops `token`, sets `access_token=<vw
|
||||||
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
|
||||||
`ws://127.0.0.1:<sidecarPort>` and runs a full buffered bidirectional pipe — **which the sidecar
|
`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,11 +574,11 @@ already exists in the same codebase.
|
|||||||
backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is
|
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
|
||||||
`userLabel` / `sessionId` (`websocket.ts:56, 65`) instead of as `X-Officer-User`.
|
`userLabel` / `sessionId` (`websocket.ts:56, 65`) instead of as `X-Officer-User`.
|
||||||
4. **`sidecar-registry.ts:395-407` plus PTY types threaded through generic plumbing** at
|
4. **`sidecar-registry.ts:395-407` plus PTY types threaded through generic plumbing** at
|
||||||
`:12-13, 35, 93, 125, 153, 171, 180, 194`. One subtlety to preserve: the 30s
|
`:12-13, 35, 93, 125, 153, 171, 180, 194`. One subtlety to preserve: the 30s
|
||||||
@@ -588,7 +589,7 @@ already exists in the same codebase.
|
|||||||
than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same
|
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,9 +1368,9 @@ Recorded because they surfaced during the audit, not because they're in scope:
|
|||||||
2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()`
|
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
|
||||||
@@ -542,8 +542,8 @@ work disagree permanently about the roster, with neither told — a direct contr
|
|||||||
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
|
**Resolved `6fd60e5`** — the templates now call the core's `uid()`, which is exported from the
|
||||||
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
|
Workspace barrel for the first time so that there is exactly one way to mint a panel id. The
|
||||||
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
|
duplicate minter is deleted rather than fixed: a second implementation of "make me an id" is how
|
||||||
this happened, and the collision was no longer only a settings mix-up — `agent_panels` addresses a
|
this happened, and the collision was no longer only a settings mix-up — `agent_panels`addresses a
|
||||||
panel by `(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
panel by`(dashboardId, panelId)`, so two dashboards built from templates in the same page load
|
||||||
could hand two different agents the same address.
|
could hand two different agents the same address.
|
||||||
|
|
||||||
### 5.6 Registry
|
### 5.6 Registry
|
||||||
@@ -557,7 +557,7 @@ work disagree permanently about the roster, with neither told — a direct contr
|
|||||||
(`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships
|
(`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
|
||||||
|
|||||||
+1
-1
@@ -19,7 +19,7 @@
|
|||||||
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
"build:dashboard": "bun run ./scripts/build/dashboard.ts",
|
||||||
"build:landing": "bun run ./scripts/build/landing.ts",
|
"build:landing": "bun run ./scripts/build/landing.ts",
|
||||||
"db:gen": "cd src/databases/officer_db && bun run generate",
|
"db:gen": "cd src/databases/officer_db && bun run generate",
|
||||||
"db:push": "cd src/databases/officer_db && bun run push",
|
"db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
|
||||||
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
"db:migrate": "cd src/databases/officer_db && bun run migrate",
|
||||||
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
"dev:emailer": "cd src/workspaces/emailer && bun run dev",
|
||||||
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
# Extracting a feature into a plugin
|
||||||
|
|
||||||
|
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
|
||||||
|
|
||||||
|
**Read first, in this order:**
|
||||||
|
|
||||||
|
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
|
||||||
|
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
|
||||||
|
3. `plugins/offscale/` — the worked example, all four parts
|
||||||
|
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
|
||||||
|
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
|
||||||
|
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The rules. These are not preferences
|
||||||
|
|
||||||
|
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
|
||||||
|
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
|
||||||
|
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
|
||||||
|
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
|
||||||
|
|
||||||
|
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
|
||||||
|
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
|
||||||
|
resource is shared or per-user — is the plugin's own job, in its own queries.
|
||||||
|
|
||||||
|
**Say `permissions`, never the other word.** It already means three things in this codebase.
|
||||||
|
|
||||||
|
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
|
||||||
|
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
|
||||||
|
structural is convention — presence is the declaration:
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts required
|
||||||
|
api/router.ts a backend router, mounted at mountPrefix()
|
||||||
|
db/schema.ts tables, prefixed <app-name>_
|
||||||
|
sidecar/index.ts a process (.mjs instead means node)
|
||||||
|
web/panels.ts panels — REQUIRED with web/
|
||||||
|
web/layout.ts layout — REQUIRED with web/
|
||||||
|
```
|
||||||
|
|
||||||
|
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
|
||||||
|
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
|
||||||
|
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
|
||||||
|
Music added the field; see its PLUGIN.md for what it is guarding against.
|
||||||
|
|
||||||
|
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
|
||||||
|
|
||||||
|
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
|
||||||
|
anywhere else means two systems, and only one gets tested.
|
||||||
|
|
||||||
|
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
|
||||||
|
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
|
||||||
|
on uninstall. Only deleting a plugin's source can lose its data.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The order that worked
|
||||||
|
|
||||||
|
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
|
||||||
|
2. **Move the backend**: `sidecar/` → `plugins/<name>/sidecar/`, `api/<name>/router.ts` →
|
||||||
|
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*` →
|
||||||
|
`plugins/<name>/db/`.
|
||||||
|
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
|
||||||
|
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema` — `users.id` is the one
|
||||||
|
reference a plugin may make.
|
||||||
|
4. **Write `manifest.ts`.**
|
||||||
|
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
|
||||||
|
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
|
||||||
|
and `helpers/clipboard` stay as they are.
|
||||||
|
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
|
||||||
|
import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
|
||||||
|
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
|
||||||
|
**both** database barrels (`index.ts` and `schema.ts`).
|
||||||
|
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
|
||||||
|
8. **Verify on the live server** — see below.
|
||||||
|
9. **Commit and push.** Message says what moved, what it found, and what is still open.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verification — run all of it
|
||||||
|
|
||||||
|
```
|
||||||
|
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
|
||||||
|
pm2 restart officer
|
||||||
|
```
|
||||||
|
|
||||||
|
Then through `/plugins`, watching PM2 and the browser at each step:
|
||||||
|
|
||||||
|
| Step | Expect |
|
||||||
|
| ------------------------------- | ----------------------------------------------------------- |
|
||||||
|
| install | streamed log; schema applied; sidecar online; route mounted |
|
||||||
|
| the plugin's API | answers |
|
||||||
|
| the plugin's screen | renders as a Workspace |
|
||||||
|
| dock | tile appears |
|
||||||
|
| permissions page | its permission is listed, read/write/none |
|
||||||
|
| disable | route 404s, sidecar stops, **tables and rows survive** |
|
||||||
|
| enable | comes back |
|
||||||
|
| uninstall | route gone, `pm2 list` loses it, **data still there** |
|
||||||
|
| `bun db:push` while uninstalled | `No changes detected` — data survives |
|
||||||
|
| install again | identical to the first install |
|
||||||
|
|
||||||
|
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Traps, all of which cost real time once
|
||||||
|
|
||||||
|
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
|
||||||
|
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
|
||||||
|
`install.ts`; do not reorder it.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
|
||||||
|
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
|
||||||
|
pending for the whole protocol.
|
||||||
|
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
|
||||||
|
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
|
||||||
|
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
|
||||||
|
contract change. Regenerate rather than hand-edit.
|
||||||
|
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
|
||||||
|
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
|
||||||
|
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
|
||||||
|
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
|
||||||
|
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
|
||||||
|
`catalogue.ts` is the one to read.
|
||||||
|
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
|
||||||
|
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
|
||||||
|
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
|
||||||
|
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
|
||||||
|
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
|
||||||
|
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
|
||||||
|
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
|
||||||
|
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
|
||||||
|
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
|
||||||
|
it. Find those before you plan the split; they decide it for you.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Music is done. What it changed about this runbook
|
||||||
|
|
||||||
|
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
|
||||||
|
record; the parts worth carrying forward are already folded into the rules and traps above.
|
||||||
|
|
||||||
|
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
|
||||||
|
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
|
||||||
|
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
|
||||||
|
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
|
||||||
|
none, so it came out whole and made the job look cleaner than it is.
|
||||||
|
|
||||||
|
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
|
||||||
|
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
|
||||||
|
not tidy them into a plugin as a warm-up.
|
||||||
|
|
||||||
|
### The global-overlay question is answered, and the answer is no
|
||||||
|
|
||||||
|
Music was the first feature wanting to render on every route. It does not get to, and neither will the
|
||||||
|
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
|
||||||
|
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
|
||||||
|
gated on its plugin's permission so it switches itself off with the plugin.
|
||||||
|
|
||||||
|
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Which one next
|
||||||
|
|
||||||
|
No decision has been made. What the tree says, for whoever picks it:
|
||||||
|
|
||||||
|
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
|
||||||
|
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
|
||||||
|
is exactly what its extraction needs.
|
||||||
|
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
|
||||||
|
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
|
||||||
|
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
|
||||||
|
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
|
||||||
|
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
|
||||||
|
way neither of the first two did. Read `docs/secret-store.md` first.
|
||||||
|
|
||||||
|
## Still open, platform-wide. Do not rediscover these
|
||||||
|
|
||||||
|
- **Websocket providers** — `server.reload({ routes })` proven, never called. No plugin owns a socket yet;
|
||||||
|
music would have been the first and cliamp being out of scope is what let it pass.
|
||||||
|
- **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
|
||||||
|
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
|
||||||
|
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
|
||||||
|
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
|
||||||
|
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
|
||||||
|
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
|
||||||
|
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
|
||||||
|
owner's. Its own job, not the platform's.
|
||||||
|
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
|
||||||
|
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
|
||||||
|
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createRouter } from '@@/create-router';
|
||||||
|
|
||||||
|
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
|
||||||
|
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
|
||||||
|
//
|
||||||
|
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
|
||||||
|
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
|
||||||
|
export const router = createRouter();
|
||||||
|
|
||||||
|
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
|
||||||
|
// the smallest thing that is still a real one: a manifest and one route.
|
||||||
|
//
|
||||||
|
// Everything structural is convention, so this directory IS the documentation:
|
||||||
|
//
|
||||||
|
// manifest.ts you are here — only what a directory listing cannot say
|
||||||
|
// api/router.ts exports `router`; mounted at /api/example
|
||||||
|
// db/schema.ts tables, if it had any (every name prefixed `example_`)
|
||||||
|
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
|
||||||
|
// web/Router.tsx a frontend, if it had one
|
||||||
|
//
|
||||||
|
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
|
||||||
|
// code sits.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Example',
|
||||||
|
summary: 'The reference plugin — one route, nothing else',
|
||||||
|
icon: 'Puzzle',
|
||||||
|
color: '#94a3b8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
|
||||||
|
// the interesting case, because it is the one the permission gate actually has to resolve.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'example',
|
||||||
|
label: 'Example',
|
||||||
|
description: 'The reference plugin',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The reference sidecar: a long-lived process PM2 supervises.
|
||||||
|
//
|
||||||
|
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
|
||||||
|
// it, and it is the property that makes install-without-restart possible on the platform side too.
|
||||||
|
//
|
||||||
|
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
|
||||||
|
// it by permission (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
|
||||||
|
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
|
||||||
|
// installer, and adding a socket here would test Bun rather than that.
|
||||||
|
|
||||||
|
const name = 'officer-example';
|
||||||
|
console.log(`[${name}] started (pid ${process.pid})`);
|
||||||
|
|
||||||
|
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
|
||||||
|
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
|
||||||
|
|
||||||
|
const shutdown = (signal: string) => {
|
||||||
|
console.log(`[${name}] ${signal} — exiting`);
|
||||||
|
clearInterval(beat);
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||||
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { useParams } from 'react-router';
|
||||||
|
|
||||||
|
// The second panel, reading the URL rather than being told by its sibling.
|
||||||
|
//
|
||||||
|
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
|
||||||
|
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
|
||||||
|
// independently; nothing is passed between them, so they cannot disagree.
|
||||||
|
export const ExampleDetail = () => {
|
||||||
|
const { section } = useParams();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
Section from the URL: <code>{section ?? '(none)'}</code>
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-xs text-duck-dark/40">
|
||||||
|
Try <code>/example/anything</code> — this panel reads it from <code>useParams</code>, with no state passed from
|
||||||
|
the panel beside it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { useClient } from 'hooks/useClient';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
|
||||||
|
//
|
||||||
|
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
|
||||||
|
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
|
||||||
|
export const ExampleOverview = () => {
|
||||||
|
const client = useClient();
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['example', 'ping'],
|
||||||
|
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full overflow-auto p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
|
||||||
|
<p className="mt-1 text-sm text-duck-dark/60">
|
||||||
|
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
|
||||||
|
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
|
||||||
|
{isLoading ? <span className="text-duck-dark/40">…</span> : <span>{JSON.stringify(data)}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { LayoutNode } from 'officerdev';
|
||||||
|
|
||||||
|
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
|
||||||
|
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
|
||||||
|
//
|
||||||
|
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
|
||||||
|
// mismatch falls back rather than rendering another plugin's panel inside this screen.
|
||||||
|
export const defaultLayout: LayoutNode = {
|
||||||
|
type: 'group',
|
||||||
|
id: 'example-root',
|
||||||
|
direction: 'horizontal',
|
||||||
|
children: [
|
||||||
|
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
|
||||||
|
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Puzzle, ListTree } from 'lucide-react';
|
||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { ExampleOverview } from './ExampleOverview';
|
||||||
|
import { ExampleDetail } from './ExampleDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
|
||||||
|
//
|
||||||
|
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
|
||||||
|
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
|
||||||
|
// a rule someone has to remember.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
|
||||||
|
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
|
||||||
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
|
||||||
|
|
||||||
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns
|
- **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
|
||||||
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
|
||||||
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
|
||||||
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
|
||||||
@@ -29,12 +29,12 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
|
|||||||
Byte-range streaming so the player can **seek without downloading the whole file**.
|
Byte-range streaming so the player can **seek without downloading the whole file**.
|
||||||
|
|
||||||
| Case | Status | Headers |
|
| Case | Status | Headers |
|
||||||
|---|---|---|
|
| --------------------- | ------ | --------------------------------------------------------------------------------------------- |
|
||||||
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
|
||||||
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
|
||||||
|
|
||||||
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
|
||||||
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed
|
duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
|
||||||
VBR MP3s. No need to scan the file.
|
VBR MP3s. No need to scan the file.
|
||||||
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
- Errors: `400` invalid/missing path · `404` not found · `416` bad range.
|
||||||
|
|
||||||
@@ -51,13 +51,14 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
|
|||||||
this instead of walking + ID3-parsing the library itself.
|
this instead of walking + ID3-parsing the library itself.
|
||||||
|
|
||||||
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
|
||||||
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*.
|
`v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
|
||||||
|
|
||||||
### 2.1 Manifest — one call, whole library
|
### 2.1 Manifest — one call, whole library
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/music/manifest
|
GET /api/music/manifest
|
||||||
```
|
```
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
@@ -66,11 +67,12 @@ GET /api/music/manifest
|
|||||||
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
|
||||||
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
|
||||||
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
|
||||||
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true }
|
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
|
||||||
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
|
||||||
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
|
||||||
@@ -82,7 +84,9 @@ may have any mix of `tracks`, `videos`, and `disco`.
|
|||||||
```
|
```
|
||||||
GET /api/music/meta?path=<rel>
|
GET /api/music/meta?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"path": "Albums/AC-DC/[1980] Back in Black",
|
"path": "Albums/AC-DC/[1980] Back in Black",
|
||||||
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
|
|||||||
"track": "1",
|
"track": "1",
|
||||||
"year": "1980",
|
"year": "1980",
|
||||||
"durationSec": 312,
|
"durationSec": 312,
|
||||||
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
"lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
],
|
],
|
||||||
"videos": [ // present only for folders that contain video files
|
"videos": [
|
||||||
|
// present only for folders that contain video files
|
||||||
{
|
{
|
||||||
"file": "1989 - Seattle.mp4", // filename within the folder
|
"file": "1989 - Seattle.mp4", // filename within the folder
|
||||||
"title": "Live Shit: Seattle", // from the container title tag, if any
|
"title": "Live Shit: Seattle", // from the container title tag, if any
|
||||||
"durationSec": 8130,
|
"durationSec": 8130,
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"height": 720,
|
"height": 720,
|
||||||
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1)
|
"poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
|
||||||
}
|
},
|
||||||
// …
|
// …
|
||||||
]
|
],
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
|
||||||
omitted entirely when the folder has none.
|
omitted entirely when the folder has none.
|
||||||
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
|
||||||
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
|
|||||||
```
|
```
|
||||||
GET /api/music/cover?path=<rel>
|
GET /api/music/cover?path=<rel>
|
||||||
```
|
```
|
||||||
|
|
||||||
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Compressed JPEG (≤600px on the long edge, ~30–80 KB). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
Only meaningful when the manifest entry has `"cover": true`.
|
Only meaningful when the manifest entry has `"cover": true`.
|
||||||
|
|
||||||
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
|
|||||||
```
|
```
|
||||||
GET /api/music/poster?path=<rel>&file=<video filename>
|
GET /api/music/poster?path=<rel>&file=<video filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
|
||||||
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404`
|
||||||
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
|
||||||
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
|
|||||||
```
|
```
|
||||||
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
GET /api/music/lyrics?path=<rel>&file=<track filename>
|
||||||
```
|
```
|
||||||
|
|
||||||
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
|
||||||
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>` → `304`; `404` when the track has no lyrics. Only
|
||||||
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
|
||||||
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
|
|||||||
```
|
```
|
||||||
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
|
||||||
```
|
```
|
||||||
|
|
||||||
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"artist": "Anthrax",
|
"artist": "Anthrax",
|
||||||
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
|
|||||||
"[1984] Fistful Of Metal": "Studio",
|
"[1984] Fistful Of Metal": "Studio",
|
||||||
"[1985] Armed And Dangerous": "EP",
|
"[1985] Armed And Dangerous": "EP",
|
||||||
"[1994] The Island Years": "Live",
|
"[1994] The Island Years": "Live",
|
||||||
"[1991] Attack Of The Killer B's": "Compilation"
|
"[1991] Attack Of The Killer B's": "Compilation",
|
||||||
// …
|
// …
|
||||||
}
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
|
||||||
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
|
||||||
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
|
||||||
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexStatus`:
|
`IndexStatus`:
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
"running": true,
|
"running": true,
|
||||||
"startedAt": 1785034701973, "finishedAt": null,
|
"startedAt": 1785034701973,
|
||||||
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3,
|
"finishedAt": null,
|
||||||
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3,
|
"foldersScanned": 45,
|
||||||
|
"albumsBuilt": 12,
|
||||||
|
"albumsSkipped": 3,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"videosIndexed": 4,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"postersSaved": 4,
|
||||||
|
"lyricsIndexed": 45,
|
||||||
|
"discographies": 3,
|
||||||
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
"currentPath": "Albums/AC-DC/[1980] Back in Black",
|
||||||
"error": null
|
"error": null,
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
|
|||||||
```
|
```
|
||||||
GET /api/music/reindex/stream
|
GET /api/music/reindex/stream
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
|
||||||
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
|
||||||
`IndexReport`) and **closes** the stream.
|
`IndexReport`) and **closes** the stream.
|
||||||
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
|
|||||||
```
|
```
|
||||||
|
|
||||||
`IndexReport` (the `done` payload):
|
`IndexReport` (the `done` payload):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45,
|
{
|
||||||
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null }
|
"albums": 15,
|
||||||
|
"built": 12,
|
||||||
|
"skipped": 3,
|
||||||
|
"foldersScanned": 45,
|
||||||
|
"tracksIndexed": 320,
|
||||||
|
"coversSaved": 12,
|
||||||
|
"discographies": 3,
|
||||||
|
"elapsedSec": 37.2,
|
||||||
|
"error": null,
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
|
||||||
@@ -257,7 +289,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
|
|||||||
supplies; the server never interprets them:
|
supplies; the server never interprets them:
|
||||||
|
|
||||||
| kind | key |
|
| kind | key |
|
||||||
|---|---|
|
| -------- | --------------------------------------------------------------------- |
|
||||||
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
|
||||||
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
|
||||||
| `artist` | music-rel — `Albums/AC-DC` |
|
| `artist` | music-rel — `Albums/AC-DC` |
|
||||||
@@ -266,7 +298,11 @@ supplies; the server never interprets them:
|
|||||||
|
|
||||||
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
- **`GET /api/music/favorites`** → grouped keys, newest first:
|
||||||
```json
|
```json
|
||||||
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] }
|
{
|
||||||
|
"tracks": ["Music/…/01 Hells Bells.mp3"],
|
||||||
|
"albums": ["Albums/AC-DC/[1980] Back in Black"],
|
||||||
|
"artists": ["Albums/AC-DC"]
|
||||||
|
}
|
||||||
```
|
```
|
||||||
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }` → `{ ok: true }`. Idempotent
|
||||||
(a repeat add is a no-op).
|
(a repeat add is a no-op).
|
||||||
@@ -281,9 +317,16 @@ launch to offer "resume".
|
|||||||
|
|
||||||
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
- **`GET /api/music/now-playing`** → the snapshot or `null`:
|
||||||
```json
|
```json
|
||||||
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
{
|
||||||
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black",
|
"homePath": "Music/…/01 Hells Bells.mp3",
|
||||||
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" }
|
"dir": "Music/Albums/AC-DC/[1980] Back in Black",
|
||||||
|
"title": "Hells Bells",
|
||||||
|
"artist": "AC/DC",
|
||||||
|
"album": "Back in Black",
|
||||||
|
"durationSec": 312.5,
|
||||||
|
"positionSec": 140,
|
||||||
|
"updatedAt": "2026-07-27T11:27:54.441Z"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
|
||||||
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
|
||||||
@@ -300,7 +343,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
|
|||||||
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
|
||||||
|
|
||||||
| method | path | body | returns |
|
| method | path | body | returns |
|
||||||
|---|---|---|---|
|
| -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
|
||||||
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
|
||||||
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
|
||||||
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
|
||||||
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
|
|||||||
|
|
||||||
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
|
||||||
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
|
||||||
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline
|
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
|
||||||
audio files* is a separate, later feature.)
|
audio files_ is a separate, later feature.)
|
||||||
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# Music — the second plugin
|
||||||
|
|
||||||
|
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
|
||||||
|
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
|
||||||
|
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
|
||||||
|
|
||||||
|
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
|
||||||
|
worked second case, and it is interesting mainly for being the messy one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What music is
|
||||||
|
|
||||||
|
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
|
||||||
|
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
|
||||||
|
free to change.
|
||||||
|
|
||||||
|
```
|
||||||
|
manifest.ts identity, one permission
|
||||||
|
api/router.ts re-exports the platform's proxy — see below
|
||||||
|
sidecar/index.ts the whole /api/music contract (503 lines)
|
||||||
|
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
|
||||||
|
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
|
||||||
|
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
|
||||||
|
db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
web/ two panels and a layout; the shell renders the Workspace
|
||||||
|
scripts/ the reindex CLI, which talks to the sidecar port directly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The three things that stayed, and why
|
||||||
|
|
||||||
|
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
|
||||||
|
means each one is written down with what would close it.
|
||||||
|
|
||||||
|
### 1. cliamp — parked in the plugin, not left in the platform
|
||||||
|
|
||||||
|
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
|
||||||
|
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
|
||||||
|
|
||||||
|
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
|
||||||
|
|
||||||
|
```
|
||||||
|
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
|
||||||
|
api/cliamp/relay.ts → cliamp/relay.ts
|
||||||
|
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
|
||||||
|
```
|
||||||
|
|
||||||
|
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
|
||||||
|
`server.tsx` has no cliamp import, provider name, handler entry or route left.
|
||||||
|
|
||||||
|
Two things went with it that were live rather than inert:
|
||||||
|
|
||||||
|
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
|
||||||
|
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
|
||||||
|
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
|
||||||
|
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
|
||||||
|
its two menu entries are removed; the components are parked here.
|
||||||
|
- **The two socket routes.** They now 404. Verified live.
|
||||||
|
|
||||||
|
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
|
||||||
|
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
|
||||||
|
|
||||||
|
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
|
||||||
|
and never called. A platform gap, not a music one.
|
||||||
|
|
||||||
|
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
|
||||||
|
|
||||||
|
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
|
||||||
|
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
|
||||||
|
`getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
|
||||||
|
That reason was three layers of nothing:
|
||||||
|
|
||||||
|
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
|
||||||
|
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
|
||||||
|
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
|
||||||
|
|
||||||
|
A dead import held a music-named file in the platform. The proxy is now built in
|
||||||
|
`plugins/music/api/router.ts`; the relay takes its URL from there.
|
||||||
|
|
||||||
|
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
|
||||||
|
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
|
||||||
|
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
|
||||||
|
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
|
||||||
|
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
|
||||||
|
to know about provenance. It now calls `mountPrefix`.
|
||||||
|
|
||||||
|
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
|
||||||
|
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
|
||||||
|
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
|
||||||
|
|
||||||
|
### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
|
||||||
|
|
||||||
|
The first version of this document said the player stayed in the platform and called the decision
|
||||||
|
settled by a hard constraint:
|
||||||
|
|
||||||
|
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
|
||||||
|
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
|
||||||
|
> decided about the UI.
|
||||||
|
|
||||||
|
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
|
||||||
|
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
|
||||||
|
|
||||||
|
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
|
||||||
|
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
|
||||||
|
|
||||||
|
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
|
||||||
|
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
|
||||||
|
```
|
||||||
|
|
||||||
|
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
|
||||||
|
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
|
||||||
|
|
||||||
|
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
|
||||||
|
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
|
||||||
|
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
|
||||||
|
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
|
||||||
|
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
|
||||||
|
|
||||||
|
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
|
||||||
|
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
|
||||||
|
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
|
||||||
|
to `/music` remounts the host and reloads it.
|
||||||
|
|
||||||
|
## Two bugs, neither visible from reading
|
||||||
|
|
||||||
|
**The app-store catalogue still listed music, and that would have blanked the screen.**
|
||||||
|
`permissionAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
|
||||||
|
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
|
||||||
|
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
|
||||||
|
installed, enabled and healthy.
|
||||||
|
|
||||||
|
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
|
||||||
|
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
|
||||||
|
it cost minutes instead of an evening. Entry removed.
|
||||||
|
|
||||||
|
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
|
||||||
|
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
|
||||||
|
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
|
||||||
|
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
|
||||||
|
so it finds `src/servers/plugins/` and not `plugins/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
|
||||||
|
every existing `role_permissions` grant keeps meaning what it meant, and `can('music')` keeps resolving
|
||||||
|
for the overlay. Renaming it would have been a silent data change.
|
||||||
|
|
||||||
|
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
|
||||||
|
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
|
||||||
|
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
|
||||||
|
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
|
||||||
|
therefore permits exactly the four paths it permitted yesterday, and no field was added.
|
||||||
|
|
||||||
|
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
|
||||||
|
|
||||||
|
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
|
||||||
|
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
|
||||||
|
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
|
||||||
|
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
|
||||||
|
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
|
||||||
|
change inside `db/queries.ts`, not a flag on the manifest.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host dependencies — the field music created
|
||||||
|
|
||||||
|
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
|
||||||
|
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
|
||||||
|
that. That was the wrong answer, because of HOW music fails without them.
|
||||||
|
|
||||||
|
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
|
||||||
|
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
|
||||||
|
whole library, writes a complete cache tree and reports success. Five swallowed catches in
|
||||||
|
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
|
||||||
|
nobody reads. A refusal wearing the costume of a normal result.
|
||||||
|
|
||||||
|
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
osDependencies: [
|
||||||
|
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
|
||||||
|
{ binary: 'ffmpeg', reason: '…', packages: { … } },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are declared even though one package provides both, because the platform probes BINARIES and these
|
||||||
|
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
|
||||||
|
a single `ffmpeg` before anything reaches a command line.
|
||||||
|
|
||||||
|
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
|
||||||
|
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
|
||||||
|
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
|
||||||
|
recorded why that indirection was rejected.
|
||||||
|
|
||||||
|
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
|
||||||
|
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
|
||||||
|
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
|
||||||
|
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
|
||||||
|
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
|
||||||
|
named — nothing had happened, so there was nothing to undo.
|
||||||
|
|
||||||
|
`~/Music` still does not exist, so there is no library to index.
|
||||||
|
|
||||||
|
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
|
||||||
|
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified on the live server, 2026-08-15
|
||||||
|
|
||||||
|
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
|
||||||
|
|
||||||
|
| Step | Result |
|
||||||
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
|
||||||
|
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
|
||||||
|
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199` → **206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd` → **400** |
|
||||||
|
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
|
||||||
|
| dock | tile present in `/api/user/permissions`; `/music` in `routes`, not in `deniedRoutes` |
|
||||||
|
| permissions page | `music` listed among the grantable |
|
||||||
|
| disable | route 404s, sidecar `stopped`, **rows survive** |
|
||||||
|
| enable | 200 again, sidecar online, favourites still there |
|
||||||
|
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
|
||||||
|
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
|
||||||
|
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
|
||||||
|
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
|
||||||
|
|
||||||
|
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
|
||||||
|
exist before.
|
||||||
|
|
||||||
|
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Still open
|
||||||
|
|
||||||
|
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
|
||||||
|
|
||||||
|
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
|
||||||
|
|
||||||
|
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
|
||||||
|
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** permission, and `files` is
|
||||||
|
**`confined`** — so:
|
||||||
|
|
||||||
|
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
|
||||||
|
library**, because every listing 403s;
|
||||||
|
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
|
||||||
|
account with no `osUser`, so it means nothing without a per-user Linux account.
|
||||||
|
|
||||||
|
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
|
||||||
|
the one offscale has. Offscale's `ConsoleView` → `TerminalView` is a CODE dependency: it resolves at build
|
||||||
|
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
|
||||||
|
account, and its failure mode is a screen that renders perfectly and shows nothing.
|
||||||
|
|
||||||
|
Three possible shapes, none chosen:
|
||||||
|
|
||||||
|
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
|
||||||
|
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
|
||||||
|
Most self-contained, and the most work.
|
||||||
|
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
|
||||||
|
but it makes one plugin's grant conditional on another permission, which is new machinery.
|
||||||
|
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
|
||||||
|
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
|
||||||
|
|
||||||
|
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
|
||||||
|
Not tonight's call.
|
||||||
|
|
||||||
|
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
|
||||||
|
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
|
||||||
|
wire — noted rather than fixed.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
|
||||||
|
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
|
||||||
|
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
|
||||||
|
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
|
||||||
|
- **The cliamp sockets are claimed by no permission**, and are served. Now pinned by a test in
|
||||||
|
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
import { mountPrefix } from '@@/plugins/manifest';
|
||||||
|
import { manifest } from '../manifest';
|
||||||
|
|
||||||
|
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
|
||||||
|
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
|
||||||
|
//
|
||||||
|
// ── This used to live in the platform, and that was the bug ──
|
||||||
|
//
|
||||||
|
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
|
||||||
|
// knew the string 'music' — and this file merely re-exported it. The justification was that
|
||||||
|
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
|
||||||
|
//
|
||||||
|
// That justification was three layers of nothing. The relay's functions were only reachable through
|
||||||
|
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
|
||||||
|
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
|
||||||
|
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
|
||||||
|
//
|
||||||
|
// ── The prefix is DERIVED, not written ──
|
||||||
|
//
|
||||||
|
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
|
||||||
|
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
|
||||||
|
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
|
||||||
|
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
|
||||||
|
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
|
||||||
|
//
|
||||||
|
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
|
||||||
|
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
|
||||||
|
// become two systems with only one of them tested.
|
||||||
|
//
|
||||||
|
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
|
||||||
|
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
|
||||||
|
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
|
||||||
|
// factory the installer calls instead. Recorded rather than worked around.
|
||||||
|
const proxy = createSidecarProxy({
|
||||||
|
name: 'music',
|
||||||
|
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
|
||||||
|
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
|
||||||
|
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
|
||||||
|
timeoutSeconds: 1800,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const router = proxy.router;
|
||||||
|
|
||||||
|
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
|
||||||
|
export const getMusicServerWsUrl = proxy.getWsUrl;
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
+19
-4
@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
|||||||
ctxRef.current = audioCtx;
|
ctxRef.current = audioCtx;
|
||||||
|
|
||||||
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
await audioCtx.audioWorklet.addModule(workletBlobUrl);
|
||||||
if (disposed) { audioCtx.close(); return; }
|
if (disposed) {
|
||||||
|
audioCtx.close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
|
||||||
outputChannelCount: [CHANNELS],
|
outputChannelCount: [CHANNELS],
|
||||||
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
try { wsRef.current?.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
wsRef.current?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
wsRef.current = null;
|
wsRef.current = null;
|
||||||
try { nodeRef.current?.disconnect(); } catch { /* ignore */ }
|
try {
|
||||||
|
nodeRef.current?.disconnect();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
nodeRef.current = null;
|
nodeRef.current = null;
|
||||||
try { audioCtx?.close(); } catch { /* ignore */ }
|
try {
|
||||||
|
audioCtx?.close();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
ctxRef.current = null;
|
ctxRef.current = null;
|
||||||
gainRef.current = null;
|
gainRef.current = null;
|
||||||
};
|
};
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback } from 'react';
|
import { useCallback } from 'react';
|
||||||
import { useSearchParams } from 'react-router';
|
import { useSearchParams } from 'react-router';
|
||||||
import { Music } from 'lucide-react';
|
import { Music } from 'lucide-react';
|
||||||
import { TerminalView } from '../Terminal/Terminal';
|
import { TerminalView } from 'officerdev';
|
||||||
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
import { AudioStreamPlayer } from './AudioStreamPlayer';
|
||||||
|
|
||||||
export const CliampPanelHeader = () => {
|
export const CliampPanelHeader = () => {
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { ServerWebSocket } from 'bun';
|
import type { ServerWebSocket } from 'bun';
|
||||||
import { getMusicServerWsUrl } from '../music/router';
|
import { getMusicServerWsUrl } from '../api/router';
|
||||||
|
|
||||||
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
|
||||||
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
|
||||||
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
// music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
|
||||||
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
|
||||||
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
import { eq, and, desc, asc, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
|
||||||
|
|
||||||
export type FavoriteKind = 'track' | 'album' | 'artist';
|
export type FavoriteKind = 'track' | 'album' | 'artist';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
|
||||||
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Music — the library, the player, and the phone and tablet apps that stream from it.
|
||||||
|
//
|
||||||
|
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
|
||||||
|
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
|
||||||
|
// of them here after the owner read the code and asked why the platform still had files named for music.
|
||||||
|
// He was right — one of the three "seams" turned out to be dead code holding the door open.
|
||||||
|
//
|
||||||
|
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
|
||||||
|
// cliamp/ the second playback path, parked
|
||||||
|
// widgets/ the dashboard widget, parked
|
||||||
|
// assets/icon.png the dock tile, published to /plugins/music/ on install
|
||||||
|
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
|
||||||
|
// db/ music_favorites, _playlists, _playlist_items, _now_playing
|
||||||
|
// web/ the library panels; the shell renders the Workspace
|
||||||
|
//
|
||||||
|
// ── What stayed in the platform, and why ── (nothing. All three moved here.)
|
||||||
|
//
|
||||||
|
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
|
||||||
|
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
|
||||||
|
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
|
||||||
|
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
|
||||||
|
//
|
||||||
|
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
|
||||||
|
// Parked: plugins cannot contribute widgets and that mechanism is not built.
|
||||||
|
//
|
||||||
|
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
|
||||||
|
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
|
||||||
|
// in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the
|
||||||
|
// audio engine and renders nothing — which is what it already did on /music.
|
||||||
|
//
|
||||||
|
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
|
||||||
|
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
|
||||||
|
// breaks meanwhile.
|
||||||
|
//
|
||||||
|
// ── Host dependencies ──
|
||||||
|
//
|
||||||
|
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
|
||||||
|
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Music',
|
||||||
|
summary: 'The music library — browse, play, favourites and playlists',
|
||||||
|
// No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only
|
||||||
|
// ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a
|
||||||
|
// marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph.
|
||||||
|
color: '#22c55e',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
|
||||||
|
// this extraction, so every existing `role_permissions` grant keeps meaning what it meant, and the
|
||||||
|
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
|
||||||
|
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
|
||||||
|
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
|
||||||
|
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
|
||||||
|
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'music',
|
||||||
|
label: 'Music',
|
||||||
|
description: 'The music library, playback, and your own favourites and playlists',
|
||||||
|
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
|
||||||
|
//
|
||||||
|
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
|
||||||
|
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
|
||||||
|
// be designing the per-user visibility model that is explicitly not this extraction's work. It
|
||||||
|
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
|
||||||
|
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
|
||||||
|
// a read grant permits exactly the same four paths it permitted yesterday.
|
||||||
|
//
|
||||||
|
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
|
||||||
|
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
|
||||||
|
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
|
||||||
|
//
|
||||||
|
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
|
||||||
|
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
|
||||||
|
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
|
||||||
|
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
|
||||||
|
// least visible. Naming both means the owner is told which of the two they are missing.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'ffprobe',
|
||||||
|
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'ffmpeg',
|
||||||
|
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
|
||||||
|
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||||
import { join, basename } from 'node:path';
|
import { join, basename } from 'node:path';
|
||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { streamAudioFile } from './stream-audio';
|
import { streamAudioFile } from './stream-audio';
|
||||||
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws';
|
import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
|
||||||
import { ensurePulseAudio } from './pulse-audio';
|
import { ensurePulseAudio } from '../cliamp/pulse-audio';
|
||||||
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
|
||||||
import {
|
import {
|
||||||
reindexNow,
|
reindexNow,
|
||||||
@@ -37,10 +37,9 @@ import {
|
|||||||
addPlaylistItems,
|
addPlaylistItems,
|
||||||
setPlaylistItems,
|
setPlaylistItems,
|
||||||
type FavoriteKind,
|
type FavoriteKind,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
|
|
||||||
// ── Per-user state validation ──
|
// ── Per-user state validation ──
|
||||||
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
|
||||||
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
|
|||||||
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
// ── Audio-streaming HTTP server ──
|
// ── Audio-streaming HTTP server ──
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
@@ -476,7 +474,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'music',
|
name: 'music',
|
||||||
capabilities: ['music'],
|
handles: ['music'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
|
|||||||
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
|
||||||
// phone's resync diff (fetch only changed `v`s).
|
// phone's resync diff (fetch only changed `v`s).
|
||||||
|
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
|
|
||||||
const HOME = homedir();
|
const HOME = homedir();
|
||||||
export const MUSIC_ROOT = join(HOME, 'Music');
|
export const MUSIC_ROOT = join(HOME, 'Music');
|
||||||
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
|
||||||
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
// nothing about drift. Label it rather than let it read as 6k albums of rot.
|
||||||
if (prev.version !== next.version) {
|
if (prev.version !== next.version) {
|
||||||
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`);
|
console.log(
|
||||||
|
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { added, removed, changed } = diffManifest(prev, next);
|
const { added, removed, changed } = diffManifest(prev, next);
|
||||||
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`);
|
console.log(
|
||||||
|
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
|
||||||
|
);
|
||||||
const sample = (label: string, rels: string[]) => {
|
const sample = (label: string, rels: string[]) => {
|
||||||
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
|
||||||
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
|
||||||
+3
-1
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
|
|||||||
const schedule = () => {
|
const schedule = () => {
|
||||||
const ms = msUntilNextHour(REINDEX_HOUR);
|
const ms = msUntilNextHour(REINDEX_HOUR);
|
||||||
const at = new Date(Date.now() + ms);
|
const at = new Date(Date.now() + ms);
|
||||||
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`);
|
console.log(
|
||||||
|
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
|
||||||
|
);
|
||||||
timer = setTimeout(async () => {
|
timer = setTimeout(async () => {
|
||||||
console.log('[music] nightly full reindex starting');
|
console.log('[music] nightly full reindex starting');
|
||||||
try {
|
try {
|
||||||
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
|
|||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
try {
|
try {
|
||||||
const proc = Bun.spawn(
|
const proc = Bun.spawn(
|
||||||
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath],
|
[
|
||||||
|
'ffprobe',
|
||||||
|
'-v',
|
||||||
|
'error',
|
||||||
|
'-show_entries',
|
||||||
|
'format=duration',
|
||||||
|
'-of',
|
||||||
|
'default=noprint_wrappers=1:nokey=1',
|
||||||
|
absPath,
|
||||||
|
],
|
||||||
{ stdout: 'pipe', stderr: 'ignore' },
|
{ stdout: 'pipe', stderr: 'ignore' },
|
||||||
);
|
);
|
||||||
const out = (await new Response(proc.stdout).text()).trim();
|
const out = (await new Response(proc.stdout).text()).trim();
|
||||||
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
|
|||||||
}
|
}
|
||||||
return new Response(file.slice(start, end + 1), {
|
return new Response(file.slice(start, end + 1), {
|
||||||
status: 206,
|
status: 206,
|
||||||
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) },
|
headers: {
|
||||||
|
...baseHeaders,
|
||||||
|
'Content-Range': `bytes ${start}-${end}/${total}`,
|
||||||
|
'Content-Length': String(end - start + 1),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+17
-4
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
|
||||||
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { useMusicFavorites } from './useMusicFavorites';
|
import { useMusicFavorites } from './useMusicFavorites';
|
||||||
import {
|
import {
|
||||||
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
|
|||||||
const albumRel = toRel(albumHome);
|
const albumRel = toRel(albumHome);
|
||||||
try {
|
try {
|
||||||
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
|
||||||
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist }));
|
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
|
||||||
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file)));
|
albumRel,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
|
player.playQueue(
|
||||||
|
q,
|
||||||
|
Math.max(
|
||||||
|
0,
|
||||||
|
q.findIndex((t) => t.file === file),
|
||||||
|
),
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
player.playQueue([{ albumRel, file }], 0);
|
player.playQueue([{ albumRel, file }], 0);
|
||||||
}
|
}
|
||||||
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
|
|||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||||
<Heart size={44} className="text-muted-foreground/30" />
|
<Heart size={44} className="text-muted-foreground/30" />
|
||||||
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No favorites yet. Click the heart on any artist, album or track.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
+18
-7
@@ -4,15 +4,16 @@ import { Link, useNavigate } from 'react-router';
|
|||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { usePanelChannel } from 'hooks/usePanelChannel';
|
import { usePanelChannel } from 'hooks/usePanelChannel';
|
||||||
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
|
||||||
import type { LayoutNode, PanelComponents } from '../../components/Workspace';
|
import type { LayoutNode, PanelComponents } from 'officerdev';
|
||||||
import { WorkspaceLayout } from '../../components/Workspace';
|
import { WorkspaceLayout } from 'officerdev';
|
||||||
import { MusicHeart } from './MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { FavoritesView } from './FavoritesView';
|
import { FavoritesView } from './FavoritesView';
|
||||||
import { useMusicPlayer } from '../../MusicPlayer';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
import type { PlayerTrack } from '../../MusicPlayer';
|
import type { PlayerTrack } from './useMusicPlayer';
|
||||||
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel';
|
import { LyricsPanel } from './LyricsPanel';
|
||||||
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar';
|
import { MusicMiniBar } from './MusicMiniBar';
|
||||||
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen';
|
import { MusicPlayerHost } from './MusicPlayerHost';
|
||||||
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import {
|
import {
|
||||||
MUSIC_ROOT,
|
MUSIC_ROOT,
|
||||||
MUSIC_FAV_CHANNEL,
|
MUSIC_FAV_CHANNEL,
|
||||||
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
|
|||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col">
|
||||||
{content}
|
{content}
|
||||||
<MusicMiniBar />
|
<MusicMiniBar />
|
||||||
|
{/* The audio engine, mounted HERE rather than by the shell.
|
||||||
|
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
|
||||||
|
nothing while the route is /music — the mini bar above is the transport — so this is purely
|
||||||
|
"something owns the GaplessEngine while the screen is open".
|
||||||
|
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
|
||||||
|
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
|
||||||
|
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
|
||||||
|
registrations are optional-chained and the queue lives in global state, so returning to /music
|
||||||
|
remounts the host and reloads it. */}
|
||||||
|
<MusicPlayerHost />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { MicVocal, Pause, Play } from 'lucide-react';
|
import { MicVocal, Pause, Play } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { coverUrl, fmtClock } from '../apps/Music/shared';
|
import { coverUrl, fmtClock } from './shared';
|
||||||
import { seekPlayer } from './player-time';
|
import { seekPlayer } from './player-time';
|
||||||
import { useLyricsOpen } from './useLyricsOpen';
|
import { useLyricsOpen } from './useLyricsOpen';
|
||||||
import { useMusicPlayer } from './useMusicPlayer';
|
import { useMusicPlayer } from './useMusicPlayer';
|
||||||
+6
-6
@@ -1,11 +1,11 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router';
|
import { Link, useLocation, useNavigate } from 'react-router';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { useCapabilities } from 'hooks/useCapabilities';
|
import { usePermissions } from 'hooks/usePermissions';
|
||||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||||
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar';
|
import { SeekBar } from 'officerdev';
|
||||||
import { MusicHeart } from '../apps/Music/MusicHeart';
|
import { MusicHeart } from './MusicHeart';
|
||||||
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared';
|
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
|
||||||
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
|
||||||
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
import { GaplessEngine, type EngineTrack } from './gapless-engine';
|
||||||
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
import { publishPlayerTime, registerPlayerSeek } from './player-time';
|
||||||
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
|
|||||||
|
|
||||||
export const MusicPlayerHost = () => {
|
export const MusicPlayerHost = () => {
|
||||||
const { token, get, put, delete: del } = useClient();
|
const { token, get, put, delete: del } = useClient();
|
||||||
const { can } = useCapabilities();
|
const { can } = usePermissions();
|
||||||
const canUseMusic = can('music');
|
const canUseMusic = can('music');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -123,7 +123,7 @@ export const MusicPlayerHost = () => {
|
|||||||
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
|
// 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;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { AppRegistryMeta } from 'officerdev';
|
||||||
|
import { Music, ListMusic } from 'lucide-react';
|
||||||
|
import { MusicBrowser } from './MusicBrowser';
|
||||||
|
import { MusicDetail } from './MusicDetail';
|
||||||
|
|
||||||
|
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
|
||||||
|
// `layout.ts` — a plugin never renders the screen.
|
||||||
|
//
|
||||||
|
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
|
||||||
|
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
|
||||||
|
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
|
||||||
|
// screen.
|
||||||
|
//
|
||||||
|
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
|
||||||
|
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||||
|
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
|
||||||
|
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
|
||||||
|
];
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev';
|
import type { WidgetRegistryMeta } from 'officerdev';
|
||||||
import { useMusicPlayer } from 'officerdev';
|
// The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
|
||||||
|
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
|
||||||
|
import type { PlayerTrack } from '../web/useMusicPlayer';
|
||||||
|
import { useMusicPlayer } from '../web/useMusicPlayer';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
|
||||||
import { useClient } from 'hooks/useClient';
|
import { useClient } from 'hooks/useClient';
|
||||||
import { Widget } from '../Widget';
|
import { Widget } from 'widgets/Widget';
|
||||||
|
|
||||||
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
|
||||||
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
|
||||||
@@ -36,7 +39,14 @@ export const MusicPlayer = () => {
|
|||||||
// Top-level libraries (once).
|
// Top-level libraries (once).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
|
||||||
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()))
|
.then((r) =>
|
||||||
|
setLibraries(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
),
|
||||||
|
)
|
||||||
.catch(() => setLibraries([]));
|
.catch(() => setLibraries([]));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -50,7 +60,12 @@ export const MusicPlayer = () => {
|
|||||||
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
|
||||||
.then(async (r) => {
|
.then(async (r) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setDirs(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort());
|
setDirs(
|
||||||
|
r.entries
|
||||||
|
.filter((e) => e.type === 'directory')
|
||||||
|
.map((e) => e.name)
|
||||||
|
.sort(),
|
||||||
|
);
|
||||||
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
|
||||||
if (audio.length) {
|
if (audio.length) {
|
||||||
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
|
||||||
@@ -92,7 +107,12 @@ export const MusicPlayer = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const play = (i: number) => {
|
const play = (i: number) => {
|
||||||
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist }));
|
const queue: PlayerTrack[] = songs.map((t) => ({
|
||||||
|
albumRel: relToMusic,
|
||||||
|
file: t.file,
|
||||||
|
title: t.title,
|
||||||
|
artist: t.artist,
|
||||||
|
}));
|
||||||
player.playQueue(queue, i);
|
player.playQueue(queue, i);
|
||||||
};
|
};
|
||||||
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
|
||||||
@@ -107,7 +127,9 @@ export const MusicPlayer = () => {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => selectLibrary(lib)}
|
onClick={() => selectLibrary(lib)}
|
||||||
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
|
||||||
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground'
|
library === lib
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground hover:text-foreground'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{lib}
|
{lib}
|
||||||
@@ -144,7 +166,9 @@ export const MusicPlayer = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-foreground">{breadcrumb[breadcrumb.length - 1] ?? library}</p>
|
<p className="truncate text-sm font-semibold text-foreground">
|
||||||
|
{breadcrumb[breadcrumb.length - 1] ?? library}
|
||||||
|
</p>
|
||||||
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -0,0 +1,733 @@
|
|||||||
|
# Offscale — the first real plugin
|
||||||
|
|
||||||
|
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
|
||||||
|
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
|
||||||
|
|
||||||
|
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
|
||||||
|
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
|
||||||
|
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
|
||||||
|
history of something it no longer knows exists.
|
||||||
|
|
||||||
|
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
|
||||||
|
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
|
||||||
|
|
||||||
|
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
|
||||||
|
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
|
||||||
|
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
|
||||||
|
stock. The distinct name marks a distinct product, not a badge on someone else's.
|
||||||
|
|
||||||
|
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
|
||||||
|
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The reframe
|
||||||
|
|
||||||
|
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
|
||||||
|
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
|
||||||
|
intuition is that it is one plugin requiring two sidecars.
|
||||||
|
|
||||||
|
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
|
||||||
|
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
|
||||||
|
written on runs five.
|
||||||
|
|
||||||
|
### Two words, because "core" was doing two jobs
|
||||||
|
|
||||||
|
- **baseline** — what a fresh install actually runs
|
||||||
|
- **first-party** — what Officer Dev publishes
|
||||||
|
|
||||||
|
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
|
||||||
|
"is X core?" a question with two answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What a plugin is made of
|
||||||
|
|
||||||
|
Combined per plugin as needed. **Only `meta` and the ID are always required.**
|
||||||
|
|
||||||
|
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
|
||||||
|
- an **ID** (see below)
|
||||||
|
- a **sidecar**
|
||||||
|
- a **backend router** and its routes
|
||||||
|
- a **db schema**
|
||||||
|
- **default permissions per user group**
|
||||||
|
- what it stores in the **secret store**, and whether that is per-user or plugin-global
|
||||||
|
- a **frontend router**, its routes, and the frontend code
|
||||||
|
- how it **mounts into the file browser context menu**
|
||||||
|
- a set of **permissions added to officer-items**
|
||||||
|
- **plugin settings page** definitions
|
||||||
|
- an accompanying **mobile app**
|
||||||
|
|
||||||
|
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
|
||||||
|
mount, whether its sidecar is in the ecosystem file, and so on.
|
||||||
|
|
||||||
|
### What offscale needs
|
||||||
|
|
||||||
|
db schema · backend router + routes · frontend router + routes · sidecar.
|
||||||
|
|
||||||
|
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Identity and routing
|
||||||
|
|
||||||
|
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
|
||||||
|
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
|
||||||
|
across two namespaces means whichever is weaker becomes the real attack surface.
|
||||||
|
|
||||||
|
**Uniqueness comes from two mechanisms**, because one is not enough:
|
||||||
|
|
||||||
|
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
|
||||||
|
generic as `notes` gets refused: it is a name Officer Dev may want later.
|
||||||
|
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
|
||||||
|
Needed because a private plugin never asks the marketplace anything.
|
||||||
|
|
||||||
|
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
|
||||||
|
restrictions; publishing is what invites review.
|
||||||
|
|
||||||
|
### Mount prefixes
|
||||||
|
|
||||||
|
```
|
||||||
|
first-party /api/<app-name> e.g. /api/offscale
|
||||||
|
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
|
||||||
|
```
|
||||||
|
|
||||||
|
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
|
||||||
|
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
|
||||||
|
|
||||||
|
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
|
||||||
|
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
|
||||||
|
special case in the router, a bypassed check, a different install branch — first-party and third-party
|
||||||
|
become two systems, and only one of them gets tested.
|
||||||
|
|
||||||
|
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
|
||||||
|
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
|
||||||
|
adding core routes forever without breaking installs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The database
|
||||||
|
|
||||||
|
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
|
||||||
|
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
|
||||||
|
|
||||||
|
### A Postgres schema per plugin was tested and rejected
|
||||||
|
|
||||||
|
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
|
||||||
|
earns. Recorded so nobody re-runs the experiment:
|
||||||
|
|
||||||
|
| Property | Result |
|
||||||
|
| ----------------------------------------------------------------- | ------------------------- |
|
||||||
|
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
|
||||||
|
| Cross-schema FK to `public.users` | works |
|
||||||
|
| Partial unique index preserved | works |
|
||||||
|
| Push is idempotent, no spurious re-creation | works |
|
||||||
|
| Cascade delete across the schema boundary | works |
|
||||||
|
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
|
||||||
|
|
||||||
|
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
|
||||||
|
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
|
||||||
|
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
|
||||||
|
naming the schema in `schemaFilter` made the identical push work.
|
||||||
|
|
||||||
|
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
|
||||||
|
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
|
||||||
|
costume of a normal result.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mounting — rebuild and swap, at runtime
|
||||||
|
|
||||||
|
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
|
||||||
|
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
|
||||||
|
|
||||||
|
### What was actually tested
|
||||||
|
|
||||||
|
| Router | `app.route()` after serving has begun |
|
||||||
|
| -------------------------------- | --------------------------------------------------------------------- |
|
||||||
|
| `SmartRouter` _(Hono's default)_ | **throws** — `Can not add a route since the matcher is already built` |
|
||||||
|
| `RegExpRouter` | **throws**, same reason |
|
||||||
|
| `TrieRouter` | works |
|
||||||
|
| `PatternRouter` | works |
|
||||||
|
|
||||||
|
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
|
||||||
|
remove a route**, which uninstall needs.
|
||||||
|
|
||||||
|
### The approach that solves both
|
||||||
|
|
||||||
|
Rebuild the whole app from the current plugin set and **reassign the variable**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
|
||||||
|
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
|
||||||
|
|
||||||
|
// install: app = buildApp([...installed, 'offscale'])
|
||||||
|
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
|
||||||
|
```
|
||||||
|
|
||||||
|
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
|
||||||
|
|
||||||
|
```
|
||||||
|
no plugins /offscale/x -> 404 | /core -> 200
|
||||||
|
installed /offscale/x -> 200 | /core -> 200
|
||||||
|
uninstalled /offscale/x -> 404 | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
|
||||||
|
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
|
||||||
|
|
||||||
|
### The one line that has to change
|
||||||
|
|
||||||
|
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
|
||||||
|
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
|
||||||
|
This is the whole mechanical cost.
|
||||||
|
|
||||||
|
### Websockets are a separate table, and they reload
|
||||||
|
|
||||||
|
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
|
||||||
|
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
|
||||||
|
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
|
||||||
|
|
||||||
|
```
|
||||||
|
before reload /api/offscale/ws -> refused | /core -> 200
|
||||||
|
after reload /api/offscale/ws -> CONNECTED | /core -> 200
|
||||||
|
after remove /api/offscale/ws -> refused | /core -> 200
|
||||||
|
```
|
||||||
|
|
||||||
|
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
|
||||||
|
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
|
||||||
|
|
||||||
|
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
|
||||||
|
before a plugin install can interrupt somebody's terminal.
|
||||||
|
|
||||||
|
The two tables remain two lists, which is the same seam as the totality bug below.
|
||||||
|
|
||||||
|
### What this means for `assertPermissionTotality`
|
||||||
|
|
||||||
|
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
|
||||||
|
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
|
||||||
|
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
|
||||||
|
where mounting actually happens instead of once at start-up.
|
||||||
|
|
||||||
|
Two things it must survive, both live today:
|
||||||
|
|
||||||
|
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
|
||||||
|
uninstalled plugin's routes are not mounted, so nothing can reach them.
|
||||||
|
- The check is currently **fed the wrong list** — `Object.keys(handlers)` from `server.tsx`, while Bun
|
||||||
|
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
|
||||||
|
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
|
||||||
|
still needs pointing at reality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
|
||||||
|
|
||||||
|
`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not
|
||||||
|
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
|
||||||
|
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
|
||||||
|
platform's to assign.
|
||||||
|
|
||||||
|
### The platform grants read or write. Everything richer is the plugin's own job
|
||||||
|
|
||||||
|
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
|
||||||
|
permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in
|
||||||
|
the surface; `write` permits everything.
|
||||||
|
|
||||||
|
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
|
||||||
|
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
|
||||||
|
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
|
||||||
|
means; the platform only knows whether this account got through the door.
|
||||||
|
|
||||||
|
### Offscale v1 uses that model exactly, with nothing added
|
||||||
|
|
||||||
|
One shared resource, role-gated:
|
||||||
|
|
||||||
|
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
|
||||||
|
- **write** — can change them, including deleting a server the owner registered
|
||||||
|
|
||||||
|
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
|
||||||
|
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
|
||||||
|
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
|
||||||
|
use is read for most roles. Say Developers get `read` and nobody gets `write`.
|
||||||
|
|
||||||
|
Two implementation consequences, both inside the plugin:
|
||||||
|
|
||||||
|
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
|
||||||
|
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
|
||||||
|
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
|
||||||
|
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
|
||||||
|
|
||||||
|
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
|
||||||
|
- `POST /ssh-test` — a reachability probe that mutates nothing
|
||||||
|
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
|
||||||
|
|
||||||
|
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
|
||||||
|
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
|
||||||
|
delete, policy `PUT` — is a genuine write.
|
||||||
|
|
||||||
|
### Music is where the richer model gets designed
|
||||||
|
|
||||||
|
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
|
||||||
|
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
|
||||||
|
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
|
||||||
|
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
|
||||||
|
where offscale's is just "the owner's".
|
||||||
|
|
||||||
|
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
|
||||||
|
|
||||||
|
### Several different things were called "capability" here
|
||||||
|
|
||||||
|
A manifest needs three names, not one:
|
||||||
|
|
||||||
|
1. `permissions/registry.ts` — **permissions** (`headscale`, `vpn`)
|
||||||
|
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
|
||||||
|
3. `sidecar-registry` `handles: ['music']` — **routing keys** for `sendCommand`, renamed from
|
||||||
|
`capabilities` on 2026-08-15
|
||||||
|
|
||||||
|
Offscale needs (1) and (3), and not (2).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Two stores, and a plugin author will reach for the wrong one unless told:
|
||||||
|
|
||||||
|
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
|
||||||
|
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
|
||||||
|
- **per-user credentials** → `service_connections`, which already does the hard part: the row is keyed
|
||||||
|
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
|
||||||
|
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
|
||||||
|
parties touch it.
|
||||||
|
|
||||||
|
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
|
||||||
|
the host:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { db } from '../db'; // the connection
|
||||||
|
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
|
||||||
|
```
|
||||||
|
|
||||||
|
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
|
||||||
|
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
|
||||||
|
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
|
||||||
|
the pilot rather than being invented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## `/api/vpn` is being deleted
|
||||||
|
|
||||||
|
Officer had two headscale surfaces:
|
||||||
|
|
||||||
|
| | `/api/vpn` | `/api/headscale` |
|
||||||
|
| ---------- | ---------------------------------------- | -------------------------------------- |
|
||||||
|
| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
|
||||||
|
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
|
||||||
|
| surface | one route, `POST /enroll` | the whole admin API |
|
||||||
|
|
||||||
|
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
|
||||||
|
anywhere.** Verified against the mobile monorepo:
|
||||||
|
|
||||||
|
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
|
||||||
|
2. `enroll()` is reached only via `if (embedded) await enroll()`
|
||||||
|
3. `embedded` is optional and defaults to `false`
|
||||||
|
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
|
||||||
|
|
||||||
|
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
|
||||||
|
does the Officer web app. The live database holds no `vpn` grants.
|
||||||
|
|
||||||
|
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
|
||||||
|
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
|
||||||
|
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
|
||||||
|
|
||||||
|
### Everything collapses to one namespace
|
||||||
|
|
||||||
|
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
|
||||||
|
contract has no counterparty.
|
||||||
|
|
||||||
|
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
|
||||||
|
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
|
||||||
|
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
|
||||||
|
|
||||||
|
- **phone → Companion** — untouched by anything here
|
||||||
|
- **web admin → Officer → sidecar** — ours to rename freely
|
||||||
|
|
||||||
|
### There are THREE components, not two
|
||||||
|
|
||||||
|
Easy to miss, and worth stating because two of them contain the word "enroll":
|
||||||
|
|
||||||
|
| Component | Repo | Enrolment surface |
|
||||||
|
| ---------------- | ---------------------------- | ------------------------------------------------ |
|
||||||
|
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
|
||||||
|
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
|
||||||
|
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
|
||||||
|
|
||||||
|
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
|
||||||
|
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
|
||||||
|
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
|
||||||
|
|
||||||
|
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
|
||||||
|
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
|
||||||
|
in the invite (`https://<domain>/officer-api`).
|
||||||
|
|
||||||
|
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
|
||||||
|
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
|
||||||
|
`POST /_officer/enroll` handler below it is dead.
|
||||||
|
|
||||||
|
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
|
||||||
|
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
|
||||||
|
The exemption must be declared with a reason or the boot check refuses. Not needed today.
|
||||||
|
|
||||||
|
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
|
||||||
|
is correct. The invite flow supersedes it completely:
|
||||||
|
|
||||||
|
1. the Officer headscale app holds an admin API key for the Headscale server
|
||||||
|
2. from it the owner mints an **invite** — a URL pointing at the Companion
|
||||||
|
3. the Companion turns that into the redirect the phone app claims
|
||||||
|
4. the device joins
|
||||||
|
|
||||||
|
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
|
||||||
|
`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was
|
||||||
|
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What headscale actually is — the inventory
|
||||||
|
|
||||||
|
Read end to end on 2026-08-14. This is what has to move.
|
||||||
|
|
||||||
|
### Backend — 2,406 lines
|
||||||
|
|
||||||
|
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
|
||||||
|
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
|
||||||
|
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
|
||||||
|
|
||||||
|
Three things worth knowing before touching it:
|
||||||
|
|
||||||
|
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
|
||||||
|
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
|
||||||
|
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
|
||||||
|
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
|
||||||
|
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
|
||||||
|
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
|
||||||
|
object back. One file containing all of it is the model for a plugin's client layer, not something to
|
||||||
|
undo.
|
||||||
|
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
|
||||||
|
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
|
||||||
|
branches on whether the body parses.
|
||||||
|
|
||||||
|
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
|
||||||
|
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
|
||||||
|
|
||||||
|
### Frontend — 29 files, 27 endpoints
|
||||||
|
|
||||||
|
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
|
||||||
|
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
|
||||||
|
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
|
||||||
|
Diagnostics · Console.
|
||||||
|
|
||||||
|
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
|
||||||
|
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
|
||||||
|
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
|
||||||
|
|
||||||
|
The whole frontend↔host coupling, which becomes the plugin API:
|
||||||
|
|
||||||
|
| Import | Why it matters |
|
||||||
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `hooks/useClient` → `useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
|
||||||
|
| `helpers/clipboard` → `copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
|
||||||
|
| `AppRegistryMeta` | the panel-contribution contract |
|
||||||
|
| `officerdev` → `WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
|
||||||
|
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
|
||||||
|
| `../Terminal/Terminal` → `TerminalView` | **the awkward one** — a code dependency on another panel app |
|
||||||
|
|
||||||
|
### `assist.ts` travels, but stays unwired
|
||||||
|
|
||||||
|
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
|
||||||
|
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
|
||||||
|
later. Do not tidy it away as unused code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The manifest — proposal
|
||||||
|
|
||||||
|
Written against offscale rather than invented in the abstract, on the principle that a field list designed
|
||||||
|
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
|
||||||
|
is the floor, not the ceiling.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// plugins/offscale/manifest.ts
|
||||||
|
export const manifest = {
|
||||||
|
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
|
||||||
|
publisher: 'officerdev',
|
||||||
|
/** The plugin's own semver. Updates compare against this. */
|
||||||
|
version: '1.0.0',
|
||||||
|
/** Which platforms this build is good for. Refused at install when it does not match. */
|
||||||
|
platform: '>=1.0.0 <2.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// Named `permissions`, NOT `capabilities`. That word meant several different things here — the
|
||||||
|
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
|
||||||
|
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes and ACLs',
|
||||||
|
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
|
||||||
|
ownerOnly: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
```
|
||||||
|
|
||||||
|
### THE RULE: every plugin route renders a Workspace with at least one panel
|
||||||
|
|
||||||
|
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
|
||||||
|
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
|
||||||
|
|
||||||
|
```
|
||||||
|
web/panels.ts exports appRegistryMetas — at least one panel
|
||||||
|
web/layout.ts exports defaultLayout — how they are arranged
|
||||||
|
```
|
||||||
|
|
||||||
|
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
|
||||||
|
name and with the reason:
|
||||||
|
|
||||||
|
```
|
||||||
|
probeplug: has a web/ directory but is missing web/layout.ts.
|
||||||
|
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
|
||||||
|
```
|
||||||
|
|
||||||
|
There is deliberately no way to export a component. A plugin that could would be free to render a bare
|
||||||
|
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
|
||||||
|
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
|
||||||
|
is nowhere to put a screen.
|
||||||
|
|
||||||
|
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
|
||||||
|
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
|
||||||
|
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
|
||||||
|
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
|
||||||
|
than rendering another plugin's panel inside this one.
|
||||||
|
|
||||||
|
### Everything the tree can say, the tree says
|
||||||
|
|
||||||
|
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
|
||||||
|
a human chose. Everything structural is convention, and **presence is the declaration**:
|
||||||
|
|
||||||
|
| Path | Means |
|
||||||
|
| -------------------- | --------------------------------------------------------------------------------------------- |
|
||||||
|
| _the directory name_ | `appName` — `plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
|
||||||
|
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
|
||||||
|
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
|
||||||
|
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
|
||||||
|
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
|
||||||
|
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
|
||||||
|
|
||||||
|
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
|
||||||
|
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
|
||||||
|
duplication that could only ever drift.
|
||||||
|
|
||||||
|
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
|
||||||
|
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
|
||||||
|
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
|
||||||
|
field that can contradict the file it describes.
|
||||||
|
|
||||||
|
### Install asks nothing, and that is the default
|
||||||
|
|
||||||
|
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
|
||||||
|
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
|
||||||
|
and no external service to point at.
|
||||||
|
|
||||||
|
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
|
||||||
|
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
|
||||||
|
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
|
||||||
|
how it works today.
|
||||||
|
|
||||||
|
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
|
||||||
|
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
|
||||||
|
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
|
||||||
|
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
|
||||||
|
|
||||||
|
### Dropped from the first draft
|
||||||
|
|
||||||
|
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
|
||||||
|
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
|
||||||
|
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
|
||||||
|
when something consumes it.
|
||||||
|
- **`kind`** — see below.
|
||||||
|
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
|
||||||
|
|
||||||
|
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
|
||||||
|
means a tile. Fine for offscale; add a flag the first time something needs it.
|
||||||
|
|
||||||
|
### `admin` has to be allowed, and the pilot proved it immediately
|
||||||
|
|
||||||
|
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
|
||||||
|
the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way.
|
||||||
|
|
||||||
|
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
|
||||||
|
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
|
||||||
|
nothing is gained by forbidding it.
|
||||||
|
|
||||||
|
Corrected rule:
|
||||||
|
|
||||||
|
| Kind | May a plugin declare it? | Why |
|
||||||
|
| ----------- | ------------------------ | ---------------------------------------------------------- |
|
||||||
|
| `app` | yes | the ordinary grantable surface |
|
||||||
|
| `admin` | yes | self-restriction, never an escalation |
|
||||||
|
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
|
||||||
|
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
|
||||||
|
| `confined` | **no** | implies a Linux identity the platform provisions |
|
||||||
|
|
||||||
|
### One function decides the prefix
|
||||||
|
|
||||||
|
`publisher` is the only input, so first-party and third-party cannot become two code paths:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const mountPrefix = (m: Manifest) =>
|
||||||
|
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
|
||||||
|
|
||||||
|
### Notes on the fields
|
||||||
|
|
||||||
|
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
|
||||||
|
everything else is bun. One plugin already needs it, so it is not speculative generality.
|
||||||
|
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
|
||||||
|
1.0 is expected before anyone outside Officer Dev writes a plugin.
|
||||||
|
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
|
||||||
|
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
|
||||||
|
degrade. This is for the human reading the store.
|
||||||
|
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
|
||||||
|
- **No `migrations`.** Deferred; a field can be added without redesign.
|
||||||
|
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is built — complete, as of 2026-08-15
|
||||||
|
|
||||||
|
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
|
||||||
|
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
|
||||||
|
time.
|
||||||
|
|
||||||
|
| Piece | Where |
|
||||||
|
| --------------------------------------- | -------------------------------------------------- |
|
||||||
|
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
|
||||||
|
| Discovery by convention | `servers/plugins/discover.ts` |
|
||||||
|
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
|
||||||
|
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
|
||||||
|
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
|
||||||
|
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
|
||||||
|
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
|
||||||
|
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
|
||||||
|
| Permission registration | `permissions/registry.ts` → `setPluginPermissions` |
|
||||||
|
| Install state | `plugin_installs` |
|
||||||
|
| The screen | `/plugins`, two panels, SSE log |
|
||||||
|
| The reference plugin | `plugins/example/` |
|
||||||
|
| **The first real plugin** | `plugins/offscale/` — 45 files |
|
||||||
|
|
||||||
|
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
|
||||||
|
frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the
|
||||||
|
whole thing survives a restart because boot regenerates and mounts before `serve()`.
|
||||||
|
|
||||||
|
### Three bugs the extraction found
|
||||||
|
|
||||||
|
Worth recording because none were visible from reading:
|
||||||
|
|
||||||
|
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
|
||||||
|
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
|
||||||
|
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
|
||||||
|
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
|
||||||
|
has no listener to announce. Install and enable now mount first.
|
||||||
|
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
|
||||||
|
applies to Bun's static serving and not to a programmatic `Bun.build()`.
|
||||||
|
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
|
||||||
|
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
|
||||||
|
|
||||||
|
### Still open
|
||||||
|
|
||||||
|
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
|
||||||
|
still the hardcoded providers. No plugin owns a socket yet.
|
||||||
|
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
|
||||||
|
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
|
||||||
|
`buildHonoApp`, which is now the single place routes are mounted.
|
||||||
|
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
|
||||||
|
plugin system. One when the app store is rebuilt on this.
|
||||||
|
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
|
||||||
|
OWNER's rows rather than the caller's, which is a change inside the plugin.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The state of the app store, as found
|
||||||
|
|
||||||
|
It **is** the plugin system, roughly 90% built, with one structural hole.
|
||||||
|
|
||||||
|
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
|
||||||
|
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
|
||||||
|
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
|
||||||
|
|
||||||
|
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
|
||||||
|
> and it is not built."_
|
||||||
|
|
||||||
|
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
|
||||||
|
written, assets publish, the dock tile appears — and the sidecar never starts.
|
||||||
|
|
||||||
|
Also found:
|
||||||
|
|
||||||
|
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
|
||||||
|
`bun db:push`.
|
||||||
|
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
|
||||||
|
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
|
||||||
|
that the directory exists.
|
||||||
|
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
|
||||||
|
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
|
||||||
|
means editing two files and rebuilding.
|
||||||
|
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
|
||||||
|
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
|
||||||
|
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
|
||||||
|
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
|
||||||
|
renders against it. Not to be confused with any of the above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where the code lives
|
||||||
|
|
||||||
|
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
|
||||||
|
|
||||||
|
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
|
||||||
|
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
|
||||||
|
used in addition rather than instead — they span orgs, which matters because browser extensions under
|
||||||
|
`extensions/` may become plugins later.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
|
||||||
|
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
|
||||||
|
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
|
||||||
|
platform checkout, so dev-time and build-time are the same mechanism.
|
||||||
|
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
|
||||||
|
something has to apply schema changes over time. Cheap now, miserable to retrofit.
|
||||||
|
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
|
||||||
|
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
|
||||||
|
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
|
||||||
|
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
|
||||||
|
future, not before.
|
||||||
|
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
|
||||||
|
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
|
||||||
|
→ `TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
|
||||||
|
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
|
||||||
|
Console section does not travel with the plugin.
|
||||||
|
5. **`service_connections.service` namespacing** before third parties touch it.
|
||||||
|
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
|
||||||
|
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
|
||||||
|
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
|
||||||
|
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { createSidecarProxy } from '../../sidecar/create-proxy';
|
import { createSidecarProxy } from '@@/sidecar/create-proxy';
|
||||||
|
|
||||||
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
|
||||||
// this file must never grow app logic.
|
// this file must never grow app logic.
|
||||||
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
|
|||||||
|
|
||||||
const proxy = createSidecarProxy({
|
const proxy = createSidecarProxy({
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
prefix: '/api/headscale',
|
prefix: '/api/offscale',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const headscaleRouter = proxy.router;
|
export const router = proxy.router;
|
||||||
|
|
||||||
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
|
||||||
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
export const getHeadscaleServerUrl = proxy.getHttpUrl;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { eq, and, desc } from 'drizzle-orm';
|
import { eq, and, desc } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from 'officerdb/db';
|
||||||
import { headscaleServers } from './schema';
|
import { headscaleServers } from './schema';
|
||||||
import { encryptSecret, decryptSecret } from '../crypto';
|
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
|
||||||
|
|
||||||
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
|
||||||
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { users } from '../auth/schema';
|
import { users } from 'officerdb/auth/schema';
|
||||||
|
|
||||||
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
|
||||||
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type { PluginManifest } from '@@/plugins/manifest';
|
||||||
|
|
||||||
|
// Offscale — Headscale, plus the Companion that ships beside it.
|
||||||
|
//
|
||||||
|
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
|
||||||
|
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
|
||||||
|
// distinct product rather than a badge on someone else's.
|
||||||
|
//
|
||||||
|
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
|
||||||
|
//
|
||||||
|
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
|
||||||
|
// sidecar/ the whole Headscale contract, holding the admin API keys
|
||||||
|
// db/ offscale_servers, and the only table this plugin owns
|
||||||
|
// web/ panels and a layout; the shell renders the Workspace
|
||||||
|
export const manifest: PluginManifest = {
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
|
||||||
|
label: 'Offscale',
|
||||||
|
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
|
||||||
|
icon: 'Network',
|
||||||
|
color: '#818cf8',
|
||||||
|
|
||||||
|
// One permission gating the whole surface, grantable per role at read or write like every other.
|
||||||
|
//
|
||||||
|
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
|
||||||
|
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
|
||||||
|
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
|
||||||
|
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
|
||||||
|
// That is a change inside these queries, not a flag on the manifest.
|
||||||
|
//
|
||||||
|
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
|
||||||
|
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
|
||||||
|
// full control of the tailnet, which is the owner's decision to make deliberately.
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'offscale',
|
||||||
|
label: 'Offscale',
|
||||||
|
description: 'The tailnet: machines, routes, keys and ACLs',
|
||||||
|
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
|
||||||
|
// Without declaring them a read-level account meets a broken feature where a withheld permission
|
||||||
|
// should be. Inert while ownerOnly, and correct the moment that changes.
|
||||||
|
readOnlyWrites: ['/ssh-test', '/policy/assist'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
|
|
||||||
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import { existsSync, readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { DATA_PATH } from '../../data-path';
|
import { DATA_PATH } from '@@/data-path';
|
||||||
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs';
|
import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
|
||||||
//
|
//
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
|
|
||||||
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
|
||||||
// wire-level quirks are handled once:
|
// wire-level quirks are handled once:
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
|
||||||
|
|
||||||
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { OfficerContext } from './routes';
|
import type { OfficerContext } from './routes';
|
||||||
import type { OfficerUser } from './normalize';
|
import type { OfficerUser } from './normalize';
|
||||||
import { getActiveHeadscaleCredentials } from 'officerdb';
|
import { getActiveHeadscaleCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, readJson } from './routes';
|
import { badRequest, methodNotAllowed, readJson } from './routes';
|
||||||
import { createClient, type HeadscaleClient } from './client';
|
import { createClient, type HeadscaleClient } from './client';
|
||||||
import { arrayField, toUser } from './normalize';
|
import { arrayField, toUser } from './normalize';
|
||||||
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
|
|||||||
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
|
||||||
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
|
||||||
//
|
//
|
||||||
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY
|
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
|
||||||
|
// HEADSCALE_URL, HEADSCALE_API_KEY
|
||||||
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
|
||||||
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
|
||||||
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SidecarCommand, SidecarEvent } from '../protocol';
|
import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
|
||||||
import { createSidecarConnector } from '../connect';
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
||||||
import { handleOfficerRoute } from './routes';
|
import { handleOfficerRoute } from './routes';
|
||||||
import { MIN_VERSION_LABEL } from './version';
|
import { MIN_VERSION_LABEL } from './version';
|
||||||
import { API_URL } from '../../officer-url.mjs';
|
import { API_URL } from '@@/officer-url.mjs';
|
||||||
|
|
||||||
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
|
||||||
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
|
||||||
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// DELETE /_officer/keys/:id delete outright
|
// DELETE /_officer/keys/:id delete outright
|
||||||
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
|
||||||
// for a joining device. userId is only required when the server
|
// for a joining device. userId is only required when the server
|
||||||
// has more than one user; reached via /api/vpn/enroll.
|
// has more than one user.
|
||||||
|
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
|
||||||
|
// which is deleted. Kept because it is the handler a route under
|
||||||
|
// /api/offscale would reuse, and because `/enroll/invites` — which
|
||||||
|
// IS live — dispatches through the same function.
|
||||||
// anything else 404
|
// anything else 404
|
||||||
//
|
//
|
||||||
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
|
||||||
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
|
|||||||
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
|
||||||
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
/** Grab an ephemeral free port by briefly binding one and releasing it. */
|
||||||
function getFreePort(): number {
|
function getFreePort(): number {
|
||||||
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
||||||
@@ -118,7 +121,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
|
|||||||
const connection = createSidecarConnector({
|
const connection = createSidecarConnector({
|
||||||
apiUrl: `${API_URL}/api/sidecar/register`,
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
||||||
name: 'headscale',
|
name: 'headscale',
|
||||||
capabilities: ['headscale'],
|
handles: ['headscale'],
|
||||||
onCommand(cmd, reply) {
|
onCommand(cmd, reply) {
|
||||||
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
handleCommand(cmd as SidecarCommand, reply as ReplyFn);
|
||||||
},
|
},
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { HeadscaleServerCredentials } from 'officerdb';
|
import type { HeadscaleServerCredentials } from '../db/queries';
|
||||||
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
|
||||||
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
import { activeCreds, callCompanion, readBody, unavailable } from './companion';
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
deleteHeadscaleServer,
|
deleteHeadscaleServer,
|
||||||
getHeadscaleCredentials,
|
getHeadscaleCredentials,
|
||||||
recordHeadscaleProbe,
|
recordHeadscaleProbe,
|
||||||
} from 'officerdb';
|
} from '../db/queries';
|
||||||
import { createClient, HeadscaleError } from './client';
|
import { createClient, HeadscaleError } from './client';
|
||||||
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
import { probeVersion, MIN_VERSION_LABEL } from './version';
|
||||||
import { badRequest, notFound, methodNotAllowed } from './routes';
|
import { badRequest, notFound, methodNotAllowed } from './routes';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user