diff --git a/AGENTS.md b/AGENTS.md index 08d5a5ec..4411ac11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,22 +12,22 @@ look for `CLAUDE.md`, without the two drifting apart. Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by -per-role capability grants, resolved on every request. +per-role permission grants, resolved on every request. If a design question turns on "which user", the answer depends on the surface: real for the **app** -capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything +permissions (gitea, music, photos, email, calendar…), and still always **the owner** for anything that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are -`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority. +`kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority. -**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities" +**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions" section of `CLAUDE.md` before adding one. This file previously described Officer as strictly single-user with "no tenancy, no roles, no user -management". That was written to correct an *older* drift in the opposite direction — a fictional +management". That was written to correct an _older_ drift in the opposite direction — a fictional multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the -paragraph above; treat the capability registry as the source of truth over either. +paragraph above; treat the permission registry as the source of truth over either. -This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as +This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as plain files, and is where most changes belong — adding or changing a task needs no code change here and no restart. diff --git a/CLAUDE.md b/CLAUDE.md index b60bb8b7..787016b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,14 +14,14 @@ written: `users` holds six rows. The accurate statement is narrower and more use - **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is empty, pinned there by a CHECK constraint. The owner bypasses every permission check. - **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`; - grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row + grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row meaning "no", so an empty table is a server where members reach nothing but their own profile. - **Some things can never be shared, structurally.** Tasks, items, desktop and browser are `kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of "read" that makes them safe. They have no level at all and the grants API refuses to store one. - **And some are shared only because the kernel enforces it.** Terminal, chat and files are `kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem - and still run processes — but not the *owner's*, because the account has its own Linux user, its own + and still run processes — but not the _owner's_, because the account has its own Linux user, its own home, and the kernel refusing everything above it. The distinction earns its keep in one place: **a confined grant means nothing without that Linux @@ -34,8 +34,8 @@ So "which user is this" has a real answer for the **app** surface (gitea, music, calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for anything under `execution`. -`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this. -**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities" +`src/servers/permissions/registry.ts` is the authority and reads as the design document for this. +**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions" below before adding one. **Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`, @@ -84,7 +84,7 @@ src/ │ └── landing/ # marketing landing page ├── servers/ │ ├── hono.ts # router composition; everything under /api -│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting +│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting │ ├── api// # one folder per feature, each exporting a router │ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn │ ├── queue/ # background job engine @@ -106,13 +106,14 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper - **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check) - That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build + That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build failure some months earlier, whose details were not recorded. It was relaxed to `>= 22` after confirming node-pty ships **no Linux prebuilds** — its install script always falls through to `node-gyp rebuild`, so it compiles against whatever Node is present and there is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails building node-pty, or `officer-pty` cannot load its native module, restore the exact pin first. The source build also needs `build-essential` and `python3`. + - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Backend**: Hono @@ -135,7 +136,7 @@ per-account email SQLite stores — those are the **email sidecar's**, and nothi them. **None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install -root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That +root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than @@ -172,30 +173,30 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da what came out was documented defence in depth that was already switched off. Origin was never authentication here anyway: an app's `officer://` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock - is a valid token on every protected route plus the capability gate below. + is a valid token on every protected route plus the permission gate below. - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`). **The role is deliberately not a claim** — every authorization decision re-reads `users.role` from Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in. - A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every authenticated request until the server restarts. -### Capabilities — read this before mounting a router +### Permissions — read this before mounting a router Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token -valid"). It is `_middlewares/capability-gate.ts` → `capabilities/authorize.ts`, mounted globally in `hono.ts` +valid"). It is `_middlewares/permission-gate.ts` → `permissions/authorize.ts`, mounted globally in `hono.ts` ahead of everything, and it re-verifies the token itself so it covers routes that never mount `userMiddleware`. -- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds: +- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds: `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is never grantable at any level). 27 entries as of 2026-08-13. -- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every +- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, and `confined` stripped for an account with no `osUser`. **Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract - is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`. -- `capabilities/totality.ts` — `assertCapabilityTotality` runs in `server.tsx` **before `serve()` and + is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`. +- `permissions/totality.ts` — `assertPermissionTotality` runs in `server.tsx` **before `serve()` and throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails, naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on `GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's @@ -206,7 +207,7 @@ So **adding a router means adding one line to `CAPABILITIES`**. If the surface g user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption is how the hole happened the first time. -The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the +The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the 403 is the lock, and an owner locked out by a transient network error is worse than a member clicking into a refusal. diff --git a/SYSTEM_MONITOR_API.md b/SYSTEM_MONITOR_API.md index f165bcf3..93fa2876 100644 --- a/SYSTEM_MONITOR_API.md +++ b/SYSTEM_MONITOR_API.md @@ -6,13 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th - Send the JWT as **`Authorization: Bearer `**, or as **`?token=`** in the query string (required for the SSE endpoints — `EventSource` can't set headers). -- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and +- **Owner-only.** These routes belong to the `server-admin` permission, which is `kind: 'admin'` and therefore never grantable — a non-owner account gets `403` here whatever its role. The full **officer-mobile** client (which authenticates as the owner) has access; the music app does not. - Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role - capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is - now the capability's kind, not a two-element array. + permission grants. The _outcome_ for these routes is unchanged — still owner-only — but the reason is + now the permission's kind, not a two-element array. - All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`. --- @@ -20,7 +20,7 @@ Everything the `/system-monitor` web screen renders, for building the same in th ## `GET /api/system-monitor/stats` One full snapshot. Poll it on a steady interval (the web client uses **2 s**) — a few fields are rates -computed from the delta since your *previous* call (see notes), so a steady cadence matters. +computed from the delta since your _previous_ call (see notes), so a steady cadence matters. ```jsonc { @@ -65,6 +65,7 @@ computed from the delta since your *previous* call (see notes), so a steady cade ``` **Notes** + - `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first** call returns `0`/`null` for these; steady-interval polling gives stable numbers. - `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works. @@ -77,16 +78,18 @@ computed from the delta since your *previous* call (see notes), so a steady cade ```jsonc { "processes": [ - { "id": 0, // pm2 id (pm_id) — use this for the logs endpoint + { + "id": 0, // pm2 id (pm_id) — use this for the logs endpoint "name": "officer", - "status": "online", // online | stopped | errored | … - "pid": 3339851, // OS pid, or null + "status": "online", // online | stopped | errored | … + "pid": 3339851, // OS pid, or null "cpuPct": 0, "memBytes": 10354688, "restarts": 44, - "uptimeMs": 420000 } // 0 unless status === "online" + "uptimeMs": 420000, + }, // 0 unless status === "online" ], - "error": "…" // present only if pm2 couldn't be read + "error": "…", // present only if pm2 couldn't be read } ``` @@ -95,14 +98,16 @@ computed from the delta since your *previous* call (see notes), so a steady cade ```jsonc { "containers": [ - { "id": "abc123def456", // short id (12 chars) — use for the logs endpoint + { + "id": "abc123def456", // short id (12 chars) — use for the logs endpoint "name": "jellyfin", "image": "jellyfin/jellyfin", - "state": "running", // running | exited | … + "state": "running", // running | exited | … "status": "Up 3 hours", - "ports": "0.0.0.0:9301->8096/tcp" } + "ports": "0.0.0.0:9301->8096/tcp", + }, ], - "error": "…" + "error": "…", } ``` @@ -114,12 +119,14 @@ Both stream one **`data: `** frame per line, plus `: hb` heartbeat com server kills the underlying tail when the connection closes. Open with `EventSource` using `?token=`. ### `GET /api/system-monitor/pm2/logs?id=&lines=` + - `id` — **numeric** pm2 id from `/pm2` (required). - `lines` — initial backlog, default `100`, max `1000`. - Source: `pm2 logs --raw` (combined stdout+stderr, follows live). The first frames include a short pm2 `[TAILING] …` header. ### `GET /api/system-monitor/docker/logs?id=&lines=` + - `id` — container id or name from `/docker` (charset-validated). - `lines` — initial backlog (`--tail`), default `100`, max `1000`. - Source: `docker logs -f --tail ` (combined stdout+stderr). diff --git a/TODO.md b/TODO.md index edd4c8aa..34ff1a24 100644 --- a/TODO.md +++ b/TODO.md @@ -4,12 +4,12 @@ Deferred work. **Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant -indirection as accidental complexity. **That direction was reversed.** The capability permission model +indirection as accidental complexity. **That direction was reversed.** The permission permission model shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items below that proposed deleting it have been removed rather than left to mislead the next reader. -What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as +What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as the owner's OS user and can never be granted. Indirection there really is accidental complexity. ## Multi-user @@ -75,16 +75,16 @@ the owner's OS user and can never be granted. Indirection there really is accide Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in `docs/workspace-panel-todo.md` §3. -- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure +- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure registry functions and the totality check; the resolver that does the owner bypass, the grant lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file standing between a Member and a shell. -- [ ] **`assertCapabilityTotality` checks the wrong list, and `registry.test.ts` has been red since - 2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the *route table*. +- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since + 2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_. Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and `/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out. - Not exploitable — `isWsProviderAllowed` finds no capability and 403s a member; the owner upgrades onto + Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point totality at the route table, and either delete the dead routes or restore their claims. The 8 failing tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served, @@ -95,14 +95,14 @@ the owner's OS user and can never be granted. Indirection there really is accide - [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here — every caller resolves to the owner's real login home. Safe only because all seven callers sit - behind `execution` capabilities. If per-user home confinement is ever attempted, this is the + behind `execution` permissions. If per-user home confinement is ever attempted, this is the function to start from. - [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates `X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its `/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take no user argument. All three are covered today only because `terminal`, `vault` and the agent are - owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the + owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the thing to fix first if any of them is ever granted. - [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav @@ -120,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide - [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session). `DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the - `notify` capability could deregister another's device; and `POST /_officer/notify` let a request + `notify` permission could deregister another's device; and `POST /_officer/notify` let a request body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the APNs/FCM dead-token paths deliberately do not) and the header now wins over the body. diff --git a/docs/agent-coordination.md b/docs/agent-coordination.md index 38455ac7..e715ad29 100644 --- a/docs/agent-coordination.md +++ b/docs/agent-coordination.md @@ -6,7 +6,7 @@ a human authoring the workflow at the top. Written live during the conversation owner's own words; where a section records a decision, that decision is his, not a proposal. **Read this before ranking, deferring or starting any workspace/panel item.** It is the thing every -other workspace/panel document is ranked *against*: +other workspace/panel document is ranked _against_: - `docs/workspace-panels.md` — how the framework works today (descriptive, no opinions) - `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity @@ -25,11 +25,11 @@ until this conversation is finished — it is not to be started early. Everything that follows is about **`/chat`** and **`/dashboards`**. Verified against `src/apps/officer-web/App.tsx`: -| route | element | line | -|---|---|---| -| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 | -| `/dashboards` | `Dashboard.DashboardsScreen` | 82 | -| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 | +| route | element | line | +| ---------------------------------------------------------------------- | ---------------------------- | ----- | +| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 42–46 | +| `/dashboards` | `Dashboard.DashboardsScreen` | 82 | +| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 | One structural fact worth stating early, because it shapes everything: **`/dashboards/:id` is the only place in the app where a workspace is not `locked`.** Every other screen ships a layout as the product @@ -58,15 +58,15 @@ keeping it would distort the design, favour Claude and note the assumption here. ### 1.3 The dashboards scenario — the live example -The owner's chosen illustration is **what he is doing at this moment**: running *two Claude agents in -parallel, in two different chat windows, both working on the platform.* +The owner's chosen illustration is **what he is doing at this moment**: running _two Claude agents in +parallel, in two different chat windows, both working on the platform._ **Stated as fact by the owner** (not inferred): - Two agents, two chat windows, same platform, at the same time. - This is precisely why the standing "never restart the server yourself" rule exists: a `pm2 restart officer` is a **shared, destructive-ish event** across every agent working on the - platform, so it must be *timed* by the owner rather than triggered by whichever agent happens to + platform, so it must be _timed_ by the owner rather than triggered by whichever agent happens to finish first. **Observed by me during this same session**, as corroborating detail — the frictions this arrangement @@ -74,11 +74,11 @@ actually produces: 1. **The owner is the scheduler.** Each agent independently reaches a point where it needs a restart and asks. Nothing in the system knows another agent exists, so the owner is the only thing that can - serialise it. (He also had to tell me, separately, to stop *repeating* the request once made.) + serialise it. (He also had to tell me, separately, to stop _repeating_ the request once made.) 2. **The owner is the message bus.** Neither agent can see the other's work, so anything one needs to know about the other has to be relayed by hand. 3. **Shared tree, shared `master`.** Two agents, one working copy. This produced the session's sharpest - instruction — *"The problem is committing each other's work. Like, that can't happen, man."* — and + instruction — _"The problem is committing each other's work. Like, that can't happen, man."_ — and the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths and never `git add -A`. Nothing enforces it. 4. **Uncertain ownership of a failure.** I hit a real typecheck error (`CodeBlock.tsx:138`) and could not @@ -86,7 +86,7 @@ actually produces: **Unconfirmed inference — to be confirmed or corrected by the owner before it is treated as the objective:** that the dashboards half of the holy grail is a surface where these parallel agent sessions -are *visible together and manageable together* — one screen, multiple live agents as panels, with the +are _visible together and manageable together_ — one screen, multiple live agents as panels, with the state they contend over (restarts, the git tree, who is touching what) legible — so the human stops being both the scheduler and the message bus between them. @@ -96,7 +96,7 @@ being both the scheduler and the message bus between them. > path I want, to continue or start a new session from a specific path. Each chat panel gets attributed > some kind of persistent ID related to that dashboard. -And the behaviour that PoC is *for*: +And the behaviour that PoC is _for_: > I can let you both work, and at the end of your turn you ask the other agent "can I restart?", wait for > his output, restart yourself. And the same from the other side — the other agent, when he finishes his @@ -105,15 +105,15 @@ And the behaviour that PoC is *for*: > If we get this to work, the sky is the limit. -Decomposed into the five capabilities it actually requires: +Decomposed into the five permissions it actually requires: -| # | capability | exists today? | -|---|---|---| -| P1 | Two chat panels in one dashboard, each an **independent** session | **No** | -| P2 | Each panel pointed at **its own path** (cwd) | **No** | -| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly | -| P4 | Agent-to-agent **messaging** between two live sessions | **No** | -| P5 | An agent **surviving the restart** it triggers | **Yes** | +| # | permission | exists today? | +| --- | --------------------------------------------------------------------------------- | ------------- | +| P1 | Two chat panels in one dashboard, each an **independent** session | **No** | +| P2 | Each panel pointed at **its own path** (cwd) | **No** | +| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly | +| P4 | Agent-to-agent **messaging** between two live sessions | **No** | +| P5 | An agent **surviving the restart** it triggers | **Yes** | ### 1.5 Verified state of the code against those five @@ -121,7 +121,7 @@ Read from source on 2026-08-07, not assumed: **P1 — the blocker.** `ChatPanelWrapper` (`apps/Chat/ChatPanelWrapper.tsx:45`) is declared `() => {…}` — **it takes no props at all, not even `panelId`.** Everything it uses comes from -`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are *per screen*. Two +`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are _per screen_. Two chat panels dropped into one dashboard today are therefore **byte-for-byte identical**: same cwd, same context, same session-resolution path. There is no per-panel anything. It also calls `useChat(undefined, undefined, …)`, so no session id is passed in — a panel cannot be told which session @@ -132,12 +132,12 @@ through `WorkspaceContext`. `scoped = cwd !== '~'`. Every panel on a screen nece **P3 — the good news, with one sharp edge.** Panel ids (`layout-utils.ts:4`, `` uid = () => `p-${Date.now()}-${++counter}` ``) are generated once and **persisted inside the layout -`jsonb`**, so a panel id *is* already stable across reloads. That makes panel id a viable durable key — +`jsonb`**, so a panel id _is_ already stable across reloads. That makes panel id a viable durable key — which is the single most load-bearing fact for this PoC. The edge: `movePanel` mints a **new** id (`layout-utils.ts:192`, `:207`) rather than carrying the old one, so dragging a panel would silently sever its session binding. That is gap **G2** in the analysis, and it is now on the critical path. -Also already half-built, and worth knowing: for a *user* dashboard the wrapper already derives +Also already half-built, and worth knowing: for a _user_ dashboard the wrapper already derives `{ context: 'dashboard', contextId: dashboardId }` (`ChatPanelWrapper.tsx:49-55`) — a notion of dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly one level too coarse for this. @@ -160,7 +160,7 @@ PoC has the shape it has.** A lot of work landed today and over the last few days: **a session now survives a server restart with no refresh and no user action.** One case remains broken, and the owner has **decided not to solve it**: -> *unless the agent is currently outputting — the restart of the server interrupts that output.* +> _unless the agent is currently outputting — the restart of the server interrupts that output._ This reframes the PoC entirely. **The by-turn handshake is not merely coordination; it is a deliberate route around the one failure mode that is not going to be fixed.** Restarts are made safe by @@ -190,7 +190,7 @@ signal the direction is right, since it falls out of the PoC at no extra cost. ### 1.7 The actual objective — the software factory **The restart problem is not the goal, and is barely even a problem.** It exists only because the owner -is currently using the platform to fix the live platform, for velocity. It is a *dogfooding artifact*. +is currently using the platform to fix the live platform, for velocity. It is a _dogfooding artifact_. It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it is the target. @@ -204,7 +204,7 @@ The target: So the north star is: **several specialised agents, working concurrently on one codebase, coordinating with each other rather than through the human, with quality gates between them and the mainline.** -The dashboards surface is how a human *watches and steers* that factory. The chat panels are the +The dashboards surface is how a human _watches and steers_ that factory. The chat panels are the workers. The restart handshake is the first, smallest instance of the general primitive: agents negotiating a shared resource without a human in the middle. @@ -226,7 +226,7 @@ plainly and early — the cost of a late correction here is much higher than the risk profile, and it is recorded here because it is the strongest single argument in the whole conversation. -**Constraint, binding:** *there will always be a human orchestrator* — the owner, or whoever later runs +**Constraint, binding:** _there will always be a human orchestrator_ — the owner, or whoever later runs the platform. **The goal is explicitly not agents ping-ponging inputs and outputs with no structure.** Any design that removes the human from the top of the loop is wrong, not ambitious. @@ -240,14 +240,14 @@ computers. The owner's worked example, verbatim in substance: well-documented, and the documentation keeps being updated with new learnings. 2. The owner **shifts focus entirely** to other work — mobile monorepo, platform architecture — for one to two hours, without having to hold Soulseek in his head. -3. The platform agent reports: *"Soulseek is up, give it a try, here is how to test it."* +3. The platform agent reports: _"Soulseek is up, give it a try, here is how to test it."_ 4. The owner restarts, enters credentials, confirms it works, and the agent pushes. 5. The owner pulls on the MacBook and tells the **mobile agent** — which already knows the mobile infrastructure — "create me a Soulseek app based on everything the platform has today." It works. **So the pattern is proven by human execution.** What is being automated is not "can agents collaborate" -— it is the *bridging role*, which the owner currently performs and describes as: *stressful, a lot to -keep in my head*, though enjoyable and exciting. +— it is the _bridging role_, which the owner currently performs and describes as: _stressful, a lot to +keep in my head_, though enjoyable and exciting. ### 1.10 The midnight scenario — the shape of the target @@ -257,8 +257,8 @@ keep in my head*, though enjoyable and exciting. > "this is not according to spec", to mobile "maybe change this" — and in the end be **responsible for > the joining of everything, which is currently the work that I'm doing.** -The owner's own framing: *a holy grail by its nature doesn't exist — but I really think we can get -there.* +The owner's own framing: _a holy grail by its nature doesn't exist — but I really think we can get +there._ Structural requirements this adds, beyond the two-panel PoC: @@ -267,38 +267,38 @@ Structural requirements this adds, beyond the two-panel PoC: - **Panels are aware of each other** — an agent must be able to enumerate its peers. - **Panels span repositories** — platform and `monorepo-mobile` are different repos with different remotes. -- **The fourth role is different in kind from the first three.** Roles 1–3 are *do the work*, and are - already proven by the manual flow. Role 4 is *hold the whole picture and judge* — the role the owner +- **The fourth role is different in kind from the first three.** Roles 1–3 are _do the work_, and are + already proven by the manual flow. Role 4 is _hold the whole picture and judge_ — the role the owner performs today with human judgement. See §5 for why this is flagged as the research risk rather than an engineering task. ### 1.11 Do not design for the examples — the owner's counterpoints -Recorded because every one of these is a correction of *my* over-constraining, and the same mistake will +Recorded because every one of these is a correction of _my_ over-constraining, and the same mistake will be easy to repeat later. - **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only - two agents. *"This coordination is the point I want to ultimately reach."* + two agents. _"This coordination is the point I want to ultimately reach."_ - **Roles are malleable.** Not every run involves four agents, and not with those roles. Fixing "front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner does not have. -- **There is no paradigm.** *"It's whatever we want it to be."* +- **There is no paradigm.** _"It's whatever we want it to be."_ - **The owner's current needs are not the end state.** He has a day job unrelated to mobile that would benefit from the same coordination. Designing narrowly around platform+mobile development is a trap. - **Cross-machine is NOT the hard problem, and I was wrong to raise it as a fork.** The owner has already solved it at small scale: a second Claude on the MacBook with a 15-minute timer pulling the latest platform changes and replicating them for the mobile apps. Git hooks or cron do the same. - *"That's the least painful point of all this."* + _"That's the least painful point of all this."_ -**The painful point, in the owner's words:** *panel communication inside a single web page, or a single -workspace, on our platform Web UI.* That is the problem to solve. Everything else is downstream. +**The painful point, in the owner's words:** _panel communication inside a single web page, or a single +workspace, on our platform Web UI._ That is the problem to solve. Everything else is downstream. This yields a natural two-tier split, which the design should respect rather than unify: -| tier | mechanism | status | -|---|---|---| -| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** | -| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem | +| tier | mechanism | status | +| --------------------------- | ---------------------------------------------------- | -------------------------------------- | +| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** | +| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem | ### 1.12 The minimal protocol — and why the PoC is not a toy @@ -311,8 +311,8 @@ Compare it to the restart handshake: > I finished my output, you can restart the server, and tell me when you're done so I can continue. -**These are the same protocol with a different payload.** Both are: *declare turn-end → hand off → -await the peer's completion → resume.* The restart PoC is therefore not a toy standing in for the real +**These are the same protocol with a different payload.** Both are: _declare turn-end → hand off → +await the peer's completion → resume._ The restart PoC is therefore not a toy standing in for the real thing; it is the real protocol, exercised on the smallest possible payload. The design consequence: **build the primitive general and keep the roles as configuration.** A named, @@ -348,30 +348,30 @@ What he expects instead: - The **workflow graph lives in the prompts**, authored by the human at dashboard setup. - The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages between them at turn boundaries**. That is it. -- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding *what* to - do and *who* should do it. Here, the human decides both, up front, once. +- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding _what_ to + do and _who_ should do it. Here, the human decides both, up front, once. **One consequence worth stating** (observation, not a decision taken): if roles are prompts, then -*addressing* must still resolve. "Pass that work to the front end developer" needs a destination. The +_addressing_ must still resolve. "Pass that work to the front end developer" needs a destination. The consistent answer is that the **human names each panel at setup** and tells each agent the names of its peers — so addressing is a string the human chose, and the system merely routes it. A system-maintained roster of roles would re-import the paradigm through the back door. -**Also note:** *not long lived* lowers the persistence bar for a dashboard's workflow configuration — +**Also note:** _not long lived_ lowers the persistence bar for a dashboard's workflow configuration — but **not** for panel identity, which must still survive a reload for the whole PoC to work (§1.5, P3). -### 1.14 The charter — and what is explicitly *not* mine +### 1.14 The charter — and what is explicitly _not_ mine **Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be re-litigated.** -- It has been working in practice: three agents at a time on the platform, and *the way the platform was - modularised means they don't step on each other's toes ~90% of the time.* Nothing is or will be +- It has been working in practice: three agents at a time on the platform, and _the way the platform was + modularised means they don't step on each other's toes ~90% of the time._ Nothing is or will be perfect. - Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional experience, has never used a git worktree, and is willing to adopt one when it becomes necessary. -- Explicit division of labour, verbatim: *"that's my problem as a software engineer, as an architect, to - solve."* +- Explicit division of labour, verbatim: _"that's my problem as a software engineer, as an architect, to + solve."_ So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so it is not lost, not so it gets picked up. Raising it once was welcomed; raising it again is noise. @@ -379,7 +379,7 @@ it is not lost, not so it gets picked up. Raising it once was welcomed; raising **Important clarification from the owner — this is not a narrowing of the push-back instruction (§1.8):** > Don't take what I said as a restriction on you to push back on things that you think might come up that -> are maybe not directly related with your particular mission. I just want you to understand that I *do* +> are maybe not directly related with your particular mission. I just want you to understand that I _do_ > know what I'm doing — I've been through all those things my whole career. Those stones in my shoe are > mine to bear, not yours. @@ -395,12 +395,12 @@ settled by the ruling; the right to raise the next one is not affected. And the expectations around it: - **Learn to walk first.** The owner does not expect that the night after this works he creates a - dashboard with four windows and builds a project. The *practice* of using it will be perfected over + dashboard with four windows and builds a project. The _practice_ of using it will be perfected over time, separately from the mechanism. - **This mission will take some time to reach an initial state.** It is not a quick change. **The problems the owner explicitly wants thought about and documented** — these are the real design -work, and they come *after* the basics are proven: +work, and they come _after_ the basics are proven: 1. Does the system survive a **server restart**? 2. Does it survive a **page refresh**? @@ -413,7 +413,7 @@ Survey of the chat/agent commits since 2026-08-01, read from diffs and source. * further along than assumed.** Load-bearing findings: **The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called -on an *existing* `sessionKey` does **not** spawn anything — it pushes a user message onto the live input +on an _existing_ `sessionKey` does **not** spawn anything — it pushes a user message onto the live input queue (`claude-manager.ts:380-391` → `pushTurn` → `input.push`). **No browser involved.** Existing callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent delivers a message to another. @@ -421,7 +421,7 @@ delivers a message to another. **The turn-boundary signal already exists.** `result` is the explicit terminal event (`chat/types.ts:143-153`, emitted `stream-parser.ts:144`), and `onTurnComplete(hadToolCalls)` is already a public option on `useChat` (`useChat.ts:28`, fired at `:286-314`). Terminal set is -**`result` | `error` | `stopped` | `cut-off`**. Note: the *session outlives the turn* — `task:started` / +**`result` | `error` | `stopped` | `cut-off`**. Note: the _session outlives the turn_ — `task:started` / `task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle". **Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor, @@ -429,20 +429,20 @@ per-session index, `prevSeq` continuity chain, at-least-once replay from a clien **Caveat: 7-day retention** (`api/chat/retention.ts`) — a replay buffer, not an archive. **Peer-restart notification:** `onClaudeSidecarStarted` (`sidecar-registry.ts:69-91`) — keyed off the -agent *registering*, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by +agent _registering_, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by the process that owns the session, **failing toward alive**. **Principles this codebase has already paid for — adopt, don't re-derive:** -1. *Never route the durability guarantee over the link expected to break.* The agent writes to +1. _Never route the durability guarantee over the link expected to break._ The agent writes to `chat_session_events` itself, then notifies; officer relays. Write durable, then notify. -2. *Infer liveness from the birth of the new process, not the death of the socket* — socket death fires +2. _Infer liveness from the birth of the new process, not the death of the socket_ — socket death fires on the innocent case (`pm2 restart officer`). -3. *An availability check must fail toward the less-alarming answer.* -4. *Classify by recoverability, not severity* — `cut-off` (seam + Retry) is a different object from +3. _An availability check must fail toward the less-alarming answer._ +4. _Classify by recoverability, not severity_ — `cut-off` (seam + Retry) is a different object from `error` (red bubble). -5. *An id that never crosses the process boundary is not an address* (`47d03de`). -6. *Disambiguate at the only site holding the extra bit*, and set the flag **before** the await that can +5. _An id that never crosses the process boundary is not an address_ (`47d03de`). +6. _Disambiguate at the only site holding the extra bit_, and set the flag **before** the await that can race it. **⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key @@ -454,7 +454,7 @@ path for agent-to-agent messaging.** To be written up in `COMMS/` and handed off ### 1.16 The real optimisation target: unattended continuity, not parallelism -**Correcting a wrong assumption of mine.** The owner does *not* want three or four agents running flat +**Correcting a wrong assumption of mine.** The owner does _not_ want three or four agents running flat out at once: > I don't expect to have three or four agents running at the same time like crazy. **What I want is to be @@ -470,12 +470,12 @@ are large: between then and morning. **And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the -owner is *asleep*, **the dashboard page is closed.** A handoff must therefore work with **no browser +owner is _asleep_, **the dashboard page is closed.** A handoff must therefore work with **no browser open**. That rules out every browser-resident mechanism — `usePanelChannel`, React state, anything in the document — not on elegance grounds but because the document will not exist when the message is sent. -This does not contradict the owner's framing of the problem as *"panel communication inside a single web -page"*; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how +This does not contradict the owner's framing of the problem as _"panel communication inside a single web +page"_; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how a human watches and steers a conversation that continues without it — which is also precisely what §1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer, session outliving the socket). @@ -484,25 +484,25 @@ session outliving the socket). against the running system, deliberately looking for the break. Method and raw evidence: `COMMS/handoff-durability-2026-08-07.md`. -| what was done to it mid-handoff | turn completed | narration durable | -|---|---|---| -| nothing (control) | ✅ | ✅ | -| `pm2 restart officer` | ✅ | ✅ | -| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** | -| target's `claude` process SIGKILLed | ❌ lost | ⚠️ partial, with an explicit error row | -| next handoff to that same agent | ✅ | ✅ same Claude session resumed | -| handoff POSTed while `officer` is down | ❌ lost | ❌ no queue, no retry | +| what was done to it mid-handoff | turn completed | narration durable | +| -------------------------------------- | -------------- | ------------------------------------------------- | +| nothing (control) | ✅ | ✅ | +| `pm2 restart officer` | ✅ | ✅ | +| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** | +| target's `claude` process SIGKILLed | ❌ lost | ⚠️ partial, with an explicit error row | +| next handoff to that same agent | ✅ | ✅ same Claude session resumed | +| handoff POSTed while `officer` is down | ❌ lost | ❌ no queue, no retry | Three things follow, and they change how the outstanding work should be read: - **The "no browser open" requirement is satisfied, and so is the harder one.** Not one of these runs had a page open, and the middle row is the proof that officer is genuinely off the delivery path: the agent sidecar committed the model's own words to Postgres during a 25-second server outage. -- **A restart costs a *turn*, not an *agent*.** After being killed mid-turn, the receiver resumed the +- **A restart costs a _turn_, not an _agent_.** After being killed mid-turn, the receiver resumed the identical Claude session on the next handoff and volunteered which work had been lost. Continuity — `sessionKey` minted once, write-through map on disk — does the job it was built for. That makes the outstanding claude **stage 5** a smaller problem than its position on the list suggests. -- **The remaining hole is on the *sending* side.** A handoff POSTed while officer is down is refused and +- **The remaining hole is on the _sending_ side.** A handoff POSTed while officer is down is refused and dropped, and nothing in the introduction text tells the agent to retry — so the sender can believe it handed off when it did not. That, not the receiving side, is where store-and-forward would earn its keep. @@ -510,7 +510,7 @@ Three things follow, and they change how the outstanding work should be read: One topology fact found while setting this up, worth stating here because it is the practical limit on working unattended: **`officer-agent` is `sidecar/claude/user-instance.ts`, and every `claude` process on the machine is its direct child.** `pm2 restart officer-agent` therefore kills every agent on every -dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting *officer* is safe — it is, and that is +dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting _officer_ is safe — it is, and that is verified above — but is silent on this one. ### 1.17 The restart payload is temporary — the protocol is not @@ -518,17 +518,17 @@ verified above — but is silent on this one. > The restart thing is giving me pain right now. Pretty soon that won't be a problem, because I won't > have the necessity of editing the platform in real time from the platform as I'm doing today. -Further confirmation that the PoC is **scaffolding**: the *payload* is disposable, the *protocol* is the +Further confirmation that the PoC is **scaffolding**: the _payload_ is disposable, the _protocol_ is the deliverable. Reinforces §1.12 — build message passing, not a restart-negotiation feature. If the restart case disappeared tomorrow, nothing built should need to be deleted. ### 1.18 Ruling on push-back #3 (agents reviewing agents) -Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — *that is -assuming the owner is dumb, which is important sometimes, but not for this mission.* +Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — _that is +assuming the owner is dumb, which is important sometimes, but not for this mission._ Correct, and worth stating why so the boundary is understood rather than merely obeyed: **the quality of -an agent's review is a *usage* concern, downstream of the mechanism.** Whether the reviewer is any good +an agent's review is a _usage_ concern, downstream of the mechanism.** Whether the reviewer is any good is a property of the prompt the human wrote, not of the transport. The mechanism is the postal service; it is not accountable for what is in the envelopes. @@ -542,15 +542,15 @@ Derived from §1. These are the constraints the design must satisfy. 1. **A stable, addressable identity per chat panel**, persisted, surviving reload. Panel id is the natural key (§1.5, P3) — subject to the `movePanel` hazard. -2. **Message passing between named sessions at turn boundaries.** Messages carry *content* (a handoff of - work), not just signals (§1.12, and the owner's escalation: work handoff is *the whole crux*). +2. **Message passing between named sessions at turn boundaries.** Messages carry _content_ (a handoff of + work), not just signals (§1.12, and the owner's escalation: work handoff is _the whole crux_). 3. **Loud failure** when a message is dropped, a peer does not exist, or a handoff never lands (§1.8 as refined; already a value in this codebase — `9eb8fa1`). **Do not build:** - No role registry, orchestration engine, planner, or task allocator (§1.13). -- No restart-negotiation feature — restart is a *payload* (§1.17). +- No restart-negotiation feature — restart is a _payload_ (§1.17). - No git, repo, branch, build, or test awareness. **No knowledge of software at all** (§1.11 + the domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is of what is in the envelope. @@ -561,7 +561,7 @@ Derived from §1. These are the constraints the design must satisfy. - **Turn-boundary only.** The safety property comes from negotiation, not robustness (§1.6). - **Must work with no browser open.** The owner's goal is to sleep; the page will be closed. The - mechanism is server-side; panels are the view (§1.16). *Pending owner confirmation — see §5.* + mechanism is server-side; panels are the view (§1.16). _Pending owner confirmation — see §5._ - **N-way from day one.** Parallelism is not the current target but must not be foreclosed — no "the other agent" singular anywhere, no single-writer ordering assumptions (§1.16 correction). - **Durability over latency.** Seconds or minutes between handoffs is fine; a lost 03:00 handoff is not. @@ -578,35 +578,35 @@ primitive is the right one. - **Automatic handoff of cross-domain findings** — e.g. this document's own §1.15 chat defect, which the owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same shape. -- **The software factory** (§1.7) — several specialised agents with quality gates, as *usage* built on +- **The software factory** (§1.7) — several specialised agents with quality gates, as _usage_ built on the primitive rather than as features of it. - **Non-software domains entirely**, and other users with unrelated goals. -- **True parallelism**, later — *"the literal definition of heaven on earth."* +- **True parallelism**, later — _"the literal definition of heaven on earth."_ ## 4. Constraints and rules laid down -*(ground rules stated by the owner for this body of work, verbatim in substance)* +_(ground rules stated by the owner for this body of work, verbatim in substance)_ - Nothing is started — including trivial fixes — until the picture is complete and played back to the owner, and the owner has confirmed it is correct. -- This document is kept live *during* the conversation, not written up afterwards. +- This document is kept live _during_ the conversation, not written up afterwards. - Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here and in the documents that already exist. ### 4.1 Explicitly de-scoped — not wrong, just not now -Stated by the owner before the objective itself, and it is a *priority* judgement, not a correctness +Stated by the owner before the objective itself, and it is a _priority_ judgement, not a correctness one. These are acknowledged as poor architecture and are nonetheless **not to be worked on**: - **Everything downstream of the file browser at `/files`** — the ephemeral-panel machinery (`ephemeral` prop, `useFileViewerPanels`, the search-param-driven viewer/player/side-chat that opens - beside the browser without entering your saved layout). The owner's words: *horrible architecture*, - and *everything is working as much as I need it*. + beside the browser without entering your saved layout). The owner's words: _horrible architecture_, + and _everything is working as much as I need it_. - The query-string-driven sub-panel approach generally. The rule that follows: **do not open these as work items, and do not let a fix wander into them.** If one of them is genuinely blocking the objective, that is a finding to raise with the owner — not a -licence to start. Some of them will likely improve *inadvertently*, as a side effect of work done for +licence to start. Some of them will likely improve _inadvertently_, as a side effect of work done for the objective, and that is the expected and acceptable way for them to get better. This section is a live list. Anything else the owner de-scopes gets added here rather than being @@ -635,14 +635,14 @@ The chain, verified: calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has pending tasks. - **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes - the input queue and drops the in-memory entry — and deliberately does *not* call + the input queue and drops the in-memory entry — and deliberately does _not_ call `clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect path. - The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at `DATA_PATH//sidecar/claude-state.json`), specifically so it survives a crash or SIGKILL — commit `f4be4fd`. - On a fresh spawn, `createSession` reads it back: `resumeId = getClaudeSession(sessionKey) ?? - params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`). +params.resumeSessionId` (`:258`), passed as `resume` to the query (`:295`). **Therefore `spawnClaudeStreaming` on a reaped `sessionKey` transparently re-creates the session with `resume: `, context intact from the on-disk transcript.** This is the good answer, and @@ -658,17 +658,17 @@ it is better than a heartbeat on every axis: Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible durable object. Delivery is "resume that transcript and push a turn." -⚠ **The one hazard to respect:** the explicit `disconnect` path *does* call `clearClaudeSession`, which +⚠ **The one hazard to respect:** the explicit `disconnect` path _does_ call `clearClaudeSession`, which destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and whatever closes a panel must not trigger it. -**Q3 — RESOLVED by the owner, 2026-08-07: *"Yes, we can do that. I name them all."*** The human assigns +**Q3 — RESOLVED by the owner, 2026-08-07: _"Yes, we can do that. I name them all."_** The human assigns each panel a name at setup; the system routes a string the human chose and knows nothing about its meaning. **The name is the address; the panel id is merely where it currently lives** — which also disarms the `movePanel` hazard (§1.5, P3), since dragging changes position, not identity. -**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to *how a panel -acquires its Claude session id*: +**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to _how a panel +acquires its Claude session id_: > We can wait for the first conversation with a certain agent to start and get the first output, so we > get the session id from Claude and add it to our session key. Or basically we **fire up each session @@ -677,7 +677,7 @@ acquires its Claude session id*: > dashboard creation or session creation. **Adopt the second.** It is strictly better, because it collapses two problems into one act: the role -prompt the human must write anyway *is* the message that brings the session into existence. Consequences: +prompt the human must write anyway _is_ the message that brings the session into existence. Consequences: - The **address book is fully populated at dashboard-creation time** — no lazy state, no "panel exists but has no session yet" hole, no first-handoff race. @@ -698,7 +698,7 @@ the rule — but it **does not block this work.** > agent will be instructed to write at the end of its work, having in mind to whom that prompt is going > to be delivered. **For proof of concept it could just be a dot character.** -So: the sending agent *composes* the message; the system carries it and does not parse it. Same rule as +So: the sending agent _composes_ the message; the system carries it and does not parse it. Same rule as roles — semantics in the prose, mechanism dumb. **PoC success criterion collapses to: did a turn land in the other panel.** A single `.` is a sufficient payload to prove the mechanism. @@ -720,31 +720,31 @@ Owner, 2026-08-07: > sequence that worked from start to finish, what were the prompts passed from one to another. > **But this is something for version 2.** -Shape: one row per *dashboard run*, holding the roster of Claude session ids and an ordered list of +Shape: one row per _dashboard run_, holding the roster of Claude session ids and an ordered list of handoffs (from, to, prompt, timestamp). Deliberately **not** an output log — Claude's own transcripts and `chat_session_events` already hold the content, and duplicating them is the mistake to avoid. **Do not build this in v1.** But do not preclude it either: v1 must emit enough that the ledger is purely -*additive* later. +_additive_ later. ### 5.2 The distinction that keeps v1 small: address book vs ledger These are two different things and conflating them would inflate v1 into v2: -| | what it is | when | -|---|---|---| -| **Address book** | the durable mapping *panel → session*, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. | -| **Ledger** | the durable *history* of who handed what to whom | **v2 — deferred** (§5.1). | +| | what it is | when | +| ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- | +| **Address book** | the durable mapping _panel → session_, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. | +| **Ledger** | the durable _history_ of who handed what to whom | **v2 — deferred** (§5.1). | v1 needs the address book and nothing more. Provenance recorded in v1 should be the minimum that makes a -handoff *visible and its failure loud* (§2), not a history feature. +handoff _visible and its failure loud_ (§2), not a history feature. ## 6. How the found defects map onto the path -*(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against +_(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against what the mechanism turned out to need, not against what it was predicted to need. `workspace-panel-todo.md` is ordered by defect severity; this section says which of those defects the **objective** actually cares -about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)* +about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)_ ### 6.1 The headline: most of the panel defect list is not on this path @@ -754,7 +754,7 @@ Postgres and on the sidecar's disk. So a panel can remount, re-render, lose its across the dashboard, or not be rendered at all — and the work continues. Whole sections of the todo that rank high on severity rank near-zero here. -The corollary, and it is the useful half: the defects that *do* matter are almost all the same defect +The corollary, and it is the useful half: the defects that _do_ matter are almost all the same defect wearing four hats — **a write that silently does not persist.** Panel identity is the one piece of coordination state that lives in the layout jsonb rather than in a table of its own, so every silent persistence failure in this list is now a path by which a panel forgets which agent it is. @@ -762,15 +762,15 @@ persistence failure in this list is now a path by which a panel forgets which ag ### 6.2 Tier A — on the critical path **A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`, -`.catch(() => {})`.) *The single highest-value item in the whole list against this objective.* The +`.catch(() => {})`.) _The single highest-value item in the whole list against this objective._ The panel's agent name is written through this path. A swallowed 500 leaves the optimistic cache correct, so the panel shows its name, answers to its name, and **forgets it on the next reload** — the failure is invisible for exactly as long as the human is not looking, which is the entire window this project -exists to serve. §2 requires *loud failure*; this is the loudest silence in the codebase. +exists to serve. §2 requires _loud failure_; this is the loudest silence in the codebase. **A2. The PATCH dispatcher's missing `else`.** (§2, first item.) The server half of A1. `ws-layout-*` is matched today so panel `config` does persist — verified, the demo dashboard round-tripped with -`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the *next* key +`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the _next_ key family added for coordination is a silent no-op that returns 200. Add the 400. **A3. Validate the layout on read, and fix the `'[]'` default.** (§4, items 2 and 3.) Panel `config` is @@ -799,7 +799,7 @@ functions over a serialisable tree; there is no excuse. **A7. Two windows must not disagree about the roster.** (§5.5, "the cache is never invalidated" — `staleTime: Infinity`, no `invalidateQueries` anywhere, and every PATCH already returns a fresh state -blob the client throws away.) Q1 makes the dashboard *a window onto server-side work*. Two windows onto +blob the client throws away.) Q1 makes the dashboard _a window onto server-side work_. Two windows onto the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap: consume the response that is already being computed. @@ -815,22 +815,22 @@ rather than tidy. Not Tier A only because it is stable today and the failure req the derivation. **B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was -"the highest-value change here." Against *this* objective the priority is the opposite one: **closing a +"the highest-value change here." Against _this_ objective the priority is the opposite one: **closing a chat panel must never destroy the agent.** §5 Q2 records the hazard precisely — the explicit `disconnect` path calls `clearClaudeSession`, which destroys the `sessionKey → claudeSessionId` pointer and orphans the transcript, whereas idle reaping deliberately does not. So what is wanted from `onClose` here is a -*guarantee that nothing rides that path*, not an eager cleanup hook. Build the hook for the terminal by +_guarantee that nothing rides that path_, not an eager cleanup hook. Build the hook for the terminal by all means; do not let a chat panel be wired into it without deciding that question first. A panel is a pointer, and closing a window should not delete what it points at. **B3. `normalizeLayout` as framework, not convention.** (§5.4.) Matters for one consequence: a panel whose appType is allow-listed but no longer in the registry renders, on a `locked` screen, as an -unrecoverable empty box. A chat panel in that state is a *visible* agent the human cannot reach — though +unrecoverable empty box. A chat panel in that state is a _visible_ agent the human cannot reach — though note its peers still can, because the mechanism does not go through the browser. Real, but a display failure over a live agent rather than a lost one. **B4. The mobile collapse decision.** (§6 of the todo, first item.) Genuinely undecided against this -objective, and worth putting to the owner rather than guessing: *"I want to be able to sleep at night"* +objective, and worth putting to the owner rather than guessing: _"I want to be able to sleep at night"_ raises the obvious question of whether the 03:00 check-in happens on a phone. If yes, a user-created dashboard rendering only its left column forever is a Tier A problem wearing a mobile hat. If the answer is "I check on the laptop, and mobile web is being retired for the native app" — which is what @@ -846,12 +846,12 @@ this project. problem. It is not this one. - **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is unrelated to this path. -- **§5.2, the remount table.** *The largest downgrade in this re-rank.* A remount used to threaten +- **§5.2, the remount table.** _The largest downgrade in this re-rank._ A remount used to threaten whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs `resume-cursor` from its stored cursor and replays the durable log — it costs latency, and §2 declares latency free. Fix these for the interaction quality they are actually about; do not fix them for this. -- **§5.3, drag-to-move.** *The second-largest downgrade, and it was on the critical path when the north - star was written* (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things +- **§5.3, drag-to-move.** _The second-largest downgrade, and it was on the critical path when the north + star was written_ (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things disarmed it. Q3 made the **name** the address and the panel id merely where it currently lives; and `e588524` made `swapPanels`/`movePanel` carry `{appType, config}` as one unit, so the name travels with the panel. `useAgentPanel` resolves by name and re-anchors the row's `panelId` afterwards. The @@ -863,7 +863,7 @@ this project. writes rather than widen the diff. And §5.8 is not a prerequisite here; the mechanism never goes through a channel, because it never goes through the browser at all. -### 6.5 What the re-rank did *not* find, and that is the result +### 6.5 What the re-rank did _not_ find, and that is the result No defect in `workspace-panel-todo.md` blocked building the MVP. It was built, and it ran unattended, on the framework as it stands. The framework needed exactly one addition — per-panel config that survives a diff --git a/docs/agent-git-identity.md b/docs/agent-git-identity.md index c660e38d..d6963f5f 100644 --- a/docs/agent-git-identity.md +++ b/docs/agent-git-identity.md @@ -9,7 +9,7 @@ A team of agents works on this project, sometimes several of them in the same re one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody having to remember to say so. -Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS +Today it cannot. Every agent commits as the owner, because every agent _is_ the owner as far as the OS is concerned. ## How git identity can be overridden at all @@ -85,7 +85,7 @@ const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, .. That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session guards, and nothing is added per turn. -**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there +**This is the good news.** `env` is _already_ a per-`query()` option. It is built once today, but there is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point for everything below. @@ -96,8 +96,8 @@ Almost none, and none of it at the OS level. - `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport field on the pushed message. - **Agent name and persona are prompt-only.** `buildAgentPrompt` - (`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user - message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file. + (`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the _first user + message_. There is no `systemPrompt`, no `--agents`, no per-agent settings file. - The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)` (`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their transcripts. @@ -147,7 +147,7 @@ API field. Neither is a small change, and this document does not propose one. There is **no filesystem isolation** between agents. They share one real `HOME` (`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this -outright, and it is the stated reason `chat` is an `execution` capability that can never be granted. +outright, and it is the stated reason `chat` is an `execution` permission that can never be granted. `grep -ril worktree src/` returns nothing — worktrees are used nowhere. cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely. diff --git a/docs/claude-sidecar-isolation.md b/docs/claude-sidecar-isolation.md index 45c9b00b..134c7127 100644 --- a/docs/claude-sidecar-isolation.md +++ b/docs/claude-sidecar-isolation.md @@ -43,7 +43,7 @@ that is the sidecar running your agent. **It is not.** ```ts name: 'proxy', -capabilities: ['proxy'], +permissions: ['proxy'], ``` and its entire job is four things (`index.ts:10-23`): take a PID lock, load state, ensure an Anthropic @@ -73,11 +73,11 @@ The credential path is in roughly the right place; the process topology is not. ### Why the process dies — two independent mechanisms 1. **Process-tree kill.** PM2 signals the whole tree on restart, so the agent gets SIGINT even though - nothing in Officer's code asks for it. *(Inferred from PM2's default `treekill: true`; + nothing in Officer's code asks for it. _(Inferred from PM2's default `treekill: true`; `ecosystem.config.cjs` sets no `treekill` key, so the default applies. I did not test this in - isolation.)* + isolation.)_ 2. **Inherited stdio.** `sidecar-registry.ts:240-241` passes `stdout: 'inherit', stderr: 'inherit'`, - so the agent writes into *officer's* PM2 log pipes. When officer restarts those pipes close, and + so the agent writes into _officer's_ PM2 log pipes. When officer restarts those pipes close, and subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the parent. @@ -85,7 +85,7 @@ Both must be fixed. Fixing only the signal leaves a process writing to a closed ### Also relevant: the transport direction is inverted -`user-instance.ts:19` dials *out* to officer: +`user-instance.ts:19` dials _out_ to officer: ```ts const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; @@ -95,8 +95,8 @@ The agent sidecar is a **client** of officer, registering over `/api/sidecar/reg listener, reports no port. That is the exact inverse of the compliant sidecars (slskd, music, vault), which listen on a loopback port, report it on connect, and let officer forward to them. -This matters for survivability, not just tidiness: when officer restarts, a sidecar that *listens* -just sits there with its work intact and waits to be forwarded to again. A sidecar that *dials in* has +This matters for survivability, not just tidiness: when officer restarts, a sidecar that _listens_ +just sits there with its work intact and waits to be forwarded to again. A sidecar that _dials in_ has to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the meantime has nowhere to go. @@ -107,13 +107,13 @@ process now survives. Does your session? Not yet. Five things have to hold, and only some are about process lifetime: -| # | Requirement | Status today | -|---|---|---| -| R1 | The agent process is outside officer's process tree | **broken** — child of officer | -| R2 | The agent's stdio does not belong to officer | **broken** — `'inherit'` | -| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine** — `connect.ts` has a reconnect backoff table; not tested across a real restart | -| R4 | Events emitted while officer is down are not lost | **broken** — see below | -| R5 | The browser reconnects and asks for what it missed | **already works** — verified in Pass 2, see below | +| # | Requirement | Status today | +| --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| R1 | The agent process is outside officer's process tree | **broken** — child of officer | +| R2 | The agent's stdio does not belong to officer | **broken** — `'inherit'` | +| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine** — `connect.ts` has a reconnect backoff table; not tested across a real restart | +| R4 | Events emitted while officer is down are not lost | **broken** — see below | +| R5 | The browser reconnects and asks for what it missed | **already works** — verified in Pass 2, see below | **R5 is done, and that is better news than I expected.** The frontend already auto-reconnects with backoff (`hooks/src/useChatWebSocket.ts:46-58`, `Math.min(5000, 300 * retry)`), already tracks a @@ -130,7 +130,7 @@ The backend half exists too (`chat/websocket.ts:612-629`, `getChatEventsSince`). "Disconnected" indicator in the UI (`ChatHistory/ChatDetailPanel.tsx:38-52`). So **the sequence-and-replay protocol I was about to propose building already exists end to end.** The -only thing wrong with it is *who writes the events*. That collapses Stage 2 below from "design a +only thing wrong with it is _who writes the events_. That collapses Stage 2 below from "design a durable outbox" to "move the writer" — the single biggest simplification in this plan. One gap to close while moving it: nothing verifies sequence continuity. `resume-cursor` is only sent @@ -175,7 +175,7 @@ With the data flow inverted to match slskd: `server.tsx:164-228` (dev-server) and `server.tsx:323-326` → `api/vault/websocket.ts` (vault). - The sidecar **writes its own events to Postgres** with a monotonic per-session sequence number. It already imports `officerdb` (`user-instance.ts:10`), so this is established precedent, not a new - capability. Officer stops touching `chat_session_events` entirely. + permission. Officer stops touching `chat_session_events` entirely. - On reconnect the browser sends `since=` and the **sidecar** answers the replay. Officer relays the question and the answer, and interprets neither. @@ -208,14 +208,14 @@ The minimum fix for R1 + R2. Two routes, and I'd want your view on which: `ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines including the 50ms registration poll). Officer no longer spawns anything. -- *Pro:* correct, matches every other sidecar, PM2 restarts and logs it properly. -- *Con:* the per-email spawn model has to go or change — see the open question below. +- _Pro:_ correct, matches every other sidecar, PM2 restarts and logs it properly. +- _Con:_ the per-email spawn model has to go or change — see the open question below. **1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file, own process group. -- *Pro:* smallest diff, keeps lazy startup. -- *Con:* leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state, +- _Pro:_ smallest diff, keeps lazy startup. +- _Con:_ leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state, but it might be a legitimate first step if you want the survivability today. After this stage: the process survives, the socket reconnects, **but output produced during the @@ -265,7 +265,7 @@ reads its settings from. The whole chain — unauthenticated endpoint, sidecar ` `panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip. -### Stage 5 — the harder question: surviving a *sidecar* restart +### Stage 5 — the harder question: surviving a _sidecar_ restart Stages 1-4 make the agent survive an **officer** restart. They do not make it survive a restart of the agent sidecar itself — the agent process is that sidecar's child by design. @@ -286,9 +286,9 @@ is a real design decision and I don't have a confident recommendation. 1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the - `claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled - it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is - `kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**, + `claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The permission model settled + it in the _other_ direction from what "the platform is going multi-user" would suggest: `chat` is + `kind: 'execution'` in `permissions/registry.ts`, which is **never grantable at any level**, because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional accounts exist now, and not one of them can ever open a chat. @@ -299,7 +299,7 @@ is a real design decision and I don't have a confident recommendation. 2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands the browser a short-lived token and the browser connects to the sidecar directly (survives an - officer restart *without even a reconnect*, but needs its own TLS/origin story and a second + officer restart _without even a reconnect_, but needs its own TLS/origin story and a second exposed port). I lean relay — the reconnect is cheap once Stage 2 makes it lossless — but the direct path is the only one where you genuinely never notice. @@ -329,7 +329,7 @@ is a real design decision and I don't have a confident recommendation. ## Verified vs not **Verified by reading the code or inspecting the running system:** my process ancestry; that -`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn capability; +`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn permission; that `user-instance.ts` has no PM2 entry and is spawned only at `sidecar-registry.ts:238` with inherited stdio; that it dials out rather than listening; that events leave via `connection.send`; that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the diff --git a/docs/file-sync.md b/docs/file-sync.md index 662ec3a7..a1b1198e 100644 --- a/docs/file-sync.md +++ b/docs/file-sync.md @@ -117,7 +117,7 @@ worth serving both from one place. - **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several strategies) should be enabled and surfaced in the UI precisely so this is not confused. -- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so +- **It is not sharing.** Files is an `execution` permission — the owner's disk, never grantable — so there is still nobody to share with, whatever the account list says since 2026-08-07. --- diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index 91b5cc24..c7db1b73 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -6,11 +6,11 @@ this file still exists. Email sync is deliberately NOT part of this any more — sidecar with its own scheduling, so it does not appear in the Jobs list. **Goal:** every task run (script, pipeline, later agentic) becomes a persisted, background **job** — -created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone, +created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop _and_ phone, and ending in a push notification. Replaces today's ephemeral script-task WebSocket path. **Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that -on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script +on 2026-08-07 — but because `tasks` is an `execution` permission: running a job means running a script as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore still a global check, and the conclusion below is unchanged even though the premise was rewritten. Favor power-user affordances over guardrails. @@ -45,9 +45,10 @@ Favor power-user affordances over guardrails. ## Plan ### Phase 1 — Unified jobs backend + - **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int) - to the jobs table. Log at `DATA_PATH/jobs/.log` (derived from id). *Table/symbol rename - `pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working.* + to the jobs table. Log at `DATA_PATH/jobs/.log` (derived from id). _Table/symbol rename + `pipeline_jobs`→`jobs` is deferred as a cosmetic cleanup — add columns first, keep it working._ - **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline` → existing `executePipeline`; `script` → new `executeScript` (ports task-executor's `materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events + @@ -57,31 +58,35 @@ Favor power-user affordances over guardrails. startup so a queued backlog resumes. ### Phase 2 — REST job API (decouples creation from the socket; enables the phone) + - `POST /jobs {taskDirName, inputs, cwd, action}` → `{jobId}` (create + start/queue, background). - `GET /jobs` (+`?live=1`), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`. - Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list. ### Phase 3 — Frontend + - `/jobs/new` → `NewJobScreen`: reads query params, renders the input UI lifted from `TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator. ### Phase 4 — Notifications (later) + - One `notifyJobDone(job)` hook at finalize → push to the phone app. ## Progress + - [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB) - [x] 1b executeScript + manager dispatch — `execute-script.ts` (spawn/sandbox/killTree port, log file, abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now - dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until - a REST caller + restart exist.* + dispatches by `mode` and finalizes script jobs by exit code. _Compiles; runtime-untested until + a REST caller + restart exist._ - [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()` on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper. - [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`, now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`. - Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.* + Router mounted at `/jobs` and `/pipeline-jobs`. _Needs a restart to deploy; then curl/phone-testable._ WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live). - [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done. - [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel @@ -93,8 +98,8 @@ Favor power-user affordances over guardrails. an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) → navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button (queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new` - page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the - phone creates jobs directly via `POST /jobs`.)* + page or FileBrowser change needed. _(A standalone deep-linkable `/jobs/new` is deferred; the + phone creates jobs directly via `POST /jobs`.)_ - [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton + UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling `GET /jobs/counts` → `{ running, runningJobId, queued }` every 3s; dim at 0. diff --git a/docs/mobile-api-keys.md b/docs/mobile-api-keys.md index 0f7deb96..7d8cbf83 100644 --- a/docs/mobile-api-keys.md +++ b/docs/mobile-api-keys.md @@ -139,7 +139,7 @@ will not meet it. | **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. | | **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. | -Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a +Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing permission into a logout loop they cannot escape, because signing in again produces a credential with the same 403. A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached. @@ -153,7 +153,7 @@ decides everything after. - **The owner** (user 1) reaches everything. - **Any other account** reaches only what its role has been granted, and **can never** reach the - `execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS + `execution` permissions — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS user in the owner's home; they are refused structurally, not by policy. Verified: a member's key returns the same status as that member's JWT on every route tried, 403s @@ -233,7 +233,7 @@ both, so the endpoint cannot be used to discover whether an id exists. ## Not built -- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check +- **Scopes.** A key cannot be narrowed to a subset of its holder's permissions. The column and the check are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is there today. Design as if every key is full-authority, because it is. - **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave — diff --git a/docs/mobile-dav-provisioning.md b/docs/mobile-dav-provisioning.md index 15d750fb..840dac3f 100644 --- a/docs/mobile-dav-provisioning.md +++ b/docs/mobile-dav-provisioning.md @@ -60,7 +60,7 @@ speaks DAV. - **Username** = the account's email address — the signed-in account's own, not a constant. (This said "Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable - capability, so a member can hold their own app passwords and their own collections.) + permission, so a member can hold their own app passwords and their own collections.) - **Password** = a **DAV app password**, not the login password. DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is @@ -106,7 +106,7 @@ row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's stor **Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the -`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection +`calendar` permission, and a member's id is not 1. Derive it from `/auth/me` or from the collection paths; both work, and both stay correct when the caller is not the owner. **A collection cannot live outside `/dav//`.** Two independent guards: the sidecar rejects any diff --git a/docs/navigation-audit.md b/docs/navigation-audit.md index 49ac76de..7efb9af4 100644 --- a/docs/navigation-audit.md +++ b/docs/navigation-audit.md @@ -21,14 +21,13 @@ > The rules in this file are current and authoritative; the findings table is a snapshot. > > **The runtime click-through has now happened** (2026-08-07, Playwright driving the system Brave against -> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing *data*, not +> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing _data_, not > regressions — the email account list and the Soulseek room list are both empty on this machine, so there -> is nothing to click. Two further "failures" were the *test* being wrong, not the app: the Dock renders a +> is nothing to click. Two further "failures" were the _test_ being wrong, not the app: the Dock renders a > user-pinned subset of 13 of 25 items, so `/plans` is absent by config; and `[data-sonner-toaster]` sits on > an inner `
    ` that only exists while a toast is showing. **Suspect the instrument first.** Individual > "Needs runtime test" notes below may still be true — the sweep covered the routing claims, not every row. - **Date:** 2026-07-30 · **Origin:** written as exploration before any of the routing work was done. Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend @@ -43,6 +42,7 @@ imperative `navigate()` / global-channel setter **instead of a real `` ## The anti-pattern (definition) A clickable element selects/opens something that has (or should have) a URL, but: + - **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure; - **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect); - **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL; @@ -50,7 +50,7 @@ A clickable element selects/opens something that has (or should have) a URL, but **Exemplar (already fixed):** the `/chat` session list. Rows were `