71 Commits
Author SHA1 Message Date
pastilhas abb8fe4320 the migration script stops needing the app it migrates
It died on its first real use, on a production server, before running a single
statement:

  error: Cannot find module './plugin-schemas.gen' from officer_db/src/schema.ts

It imported `officerdb/db`, which imports `schema.ts`, which imports the
gitignored `plugin-schemas.gen.ts`. That file does not exist on a fresh clone —
which is exactly the state every machine this script is FOR is in. It had been
tested against a scratch database on a machine where the barrel happened to
exist, so the one condition that mattered was the one condition never tested.

A migration issues ALTER statements. It has no business needing the
application's schema barrel, its table objects or its query layer. It now opens
its own `postgres` connection and imports none of them.

Tested the way it failed: barrel moved out of the tree, script run against a
scratch database built in the old shape. Renames the table, the column, both
indexes and all three CHECK constraints, keeps the rows, and the idempotent
path still no-ops. Verified by reading pg_indexes and pg_constraint afterwards
rather than trusting the exit code.

Deployed to edge-pertento today with two real users. Nine grants migrated
intact, old table gone, permissions page confirmed in the browser. The only
casualty was a minute lost to this bug, because the database had not been
touched when it failed — the import blew up before the first query, which is
the one place a crash costs nothing.

`bun db:push` was already immune: it runs scripts/gen-plugin-schemas.ts first.
That fix existed because the same trap was found earlier today in the setup
path. It was not applied here because I did not think of this script as
something that runs on a fresh clone, which is precisely what it is.
2026-08-15 17:03:05 +00:00
pastilhas 9c2d6a97f7 a tested migration script for the capabilities→permissions rename
Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:

  neither table        nothing to do; db:push will create it
  already renamed      no-op, prints the grant count
  BOTH tables present  stops and says a human must decide
  old table only       migrates, counts before and after, prints every grant

Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.

That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.

Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.

Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
2026-08-15 16:40:18 +00:00
pastilhas 027b10bd6e step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

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

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

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

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

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

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

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

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
2026-08-15 16:31:11 +00:00
pastilhas f9fd002ff4 step 3 verified live: the whole estate re-registered with handles
`pm2 restart all`, and all six came back on the new protocol:

  agent     handles=[claude]      opencode  handles=[opencode]
  proxy     handles=[proxy]       headscale handles=[headscale]
  pty       handles=[terminal]    music     handles=[music]

`pty` → `handles=[terminal]` is the case that makes the rename worth it: the
name and the handled kind were never the same string, and calling the list
"capabilities" hid that behind a word already doing three other jobs here.

The strongest evidence is not in the log. The session that wrote this change
runs on officer-claude-code, and `restart all` bounced it — so the conversation
continuing at all means `sendCommand('claude', …)` resolved through
findSidecarHandling against a freshly registered sidecar. The one path that
would have failed silently proved itself by working.

Checked besides: agent-status 200, chat/models 200 (both sendCommand paths),
music manifest 200 and offscale health 200 (port-announcement paths, which the
`<name>:server` generalisation touched earlier today). No "No sidecar handling
X is connected" anywhere in the log.

/api/terminal/* answers 404 rather than 503, which is the distinction that
matters: 503 is the proxy having no port for the sidecar, 404 is the sidecar
answering and having no such route. The pty proxy resolved its port.

Permissions unaffected across all three steps: 9 grants, {role, permission,
level}.

Step 4 — the last of the doc and comment surface — is all that remains.
2026-08-15 16:25:56 +00:00
pastilhas 5afa2d832e step 3/4: sidecar routing keys become handles — NOT YET VERIFIED LIVE
Committed before restarting, deliberately: this changes the registration wire,
and the restart that proves it also bounces officer-claude-code, which is what
the session doing the work runs on. An uncommitted 25-file protocol change is
worse to inherit than one marked unverified.

`capabilities: ['music']` → `handles: ['music']` on the registration message,
across 20 sidecars, both plugins, the connector, the registry and the protocol
type. findSidecarByCapability → findSidecarHandling, waitForCapability →
waitForHandler.

The name: a sidecar already has `handleCommand`, so the list is literally what
it handles. `provider` was rejected — 390 existing uses and it already means
websocket door. `serves` was rejected — collides with HTTP serving, which
sidecars also do.

THIS IS A BREAKING WIRE CHANGE with no compatibility shim. A platform expecting
`handles` reads `undefined` from a sidecar sending `capabilities`, registers it
with an empty list, and every sendCommand finds nobody — chat, terminal and
music all fail with "No sidecar handling X is connected". So the whole estate
has to restart together; there is no rolling upgrade.

If it goes wrong: `git revert HEAD` and `pm2 restart all` again. The registry is
in-memory and nothing about this touches the database, so a revert is complete.

Found a fifth meaning of the word on the way, correctly named and untouched:
the pty sidecar's terminfo capability queries (XTGETTCAP escape sequences).
That is now five — permissions, the item store, routing keys, Lightning wallet
features, and terminfo.

tsgo clean, 797 tests, 787 pass, same 7. Not exercised against a live sidecar.
2026-08-15 16:23:31 +00:00
pastilhas 5ec354cfcb step 2/4: role_capabilities becomes role_permissions, without losing a grant
The dangerous one. `drizzle-kit push` does not understand renames — it sees a
table gone and a table added, and with --force it resolves that by dropping and
creating. The 9 live grants would have vanished silently and every member would
have lost terminal, chat and files until someone noticed and re-granted by hand.

So the database moved FIRST, by explicit ALTER, and the code followed:

  ALTER TABLE role_capabilities RENAME TO role_permissions
  ALTER TABLE role_permissions RENAME COLUMN capability TO permission
  ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission
  ALTER TABLE ... RENAME CONSTRAINT ck_role_capabilities_{role,level,not_owner} TO ck_role_permissions_*
  ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey

Seven objects, not one, and all in a single transaction: a partial rename would
leave drizzle-kit seeing a table it half-recognised, which is the same drop.
The names were read out of pg_indexes and pg_constraint rather than assumed.

`bun db:push` then reported **No changes detected** — which is the whole proof.
It means the ALTERs matched the schema code exactly, so there was nothing for
push to reconcile and nothing for --force to destroy.

Grants verified against a JSON backup taken before the first ALTER: 9 rows,
byte-identical, `role_capabilities` gone from the database.

No client noticed. Step 1 had already moved the wire to `permission` by mapping
in the route while the column still said `capability`, so this step deleted the
mapping rather than changing any response. The two call sites that carried a
`capability` key with a comment explaining why now say `permission` and the
comments are gone.

Also renamed officer_db/src/capabilities/ → permissions/, roleCapabilities →
rolePermissions, CapabilityLevelValue → PermissionLevelValue.

Verified live after restart: self 200, admin list 200, gated route 200, no
token 401, grants read back as {role, permission, level}, and a PUT round trip
left 9 grants intact.

tsgo clean. 797 tests, 787 pass, same 7.
2026-08-15 16:08:05 +00:00
pastilhas 2e8ec845c8 step 1/4: the permission engine is called permissions, not capabilities
The word meant four different things in this repo, not the three the offscale
doc records:

  1. the permission registry              → RENAMED here
  2. $OFFICER_ROOT/capabilities/ items    → kept; this is what capabilities are
  3. sidecar routing keys                 → step 3, becoming `handles`
  4. Lightning wallet features            → kept; a domain term, and on the wire
                                            to the mobile apps

The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.

Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.

Three breaks the typechecker could not see, all found by exercising it live.

The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.

The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.

And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.

The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.

The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.

Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.

tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
2026-08-15 16:03:22 +00:00
pastilhas b0fcd8b81b stop tracking generated migrations, and delete the stale one
Nothing applies these. There is no __drizzle_migrations table, `drizzle-kit
migrate` has never run here, and `bun db:push` diffs the schema code against
the live database directly — the schema code is the source of truth, as
src/databases/CLAUDE.md says twice.

The file was badly stale besides: 36 tables describing a schema from before
capabilities, api_keys, agent_panels, the app store and the plugin system
existed, including task_logs, terminal_containers and queue_jobs, which no
longer exist anywhere in the codebase.

Ignored rather than merely deleted, because `bun db:gen` reads src/schema.ts —
whose last line imports plugin-schemas.gen.ts, generated from the plugin
DIRECTORIES on whichever machine ran it. A committed migration would launder
per-machine plugin state into the repository: generate it here and history
gains music_* and offscale_*; generate it on a fresh clone and the next commit
deletes them again. Two developers would fight over that file forever.
Generating one locally to read a diff still works, and stays local.

── The history purge was tried and undone, deliberately ──

git-filter-repo removed the blobs from all 1326 commits, cleanly: 1848 files
became 1845, only the three migrations gone, every other blob byte-identical,
verified against a bundle.

It was reverted anyway. This codebase cites 76 commit SHAs as evidence —
totality.ts points at 2873948 for the 2026-08-06 websocket incident,
registry.ts at 044aacf4, CLAUDE.md at f35c145 for the /chat exemplar — and a
rewrite changes every one. They survived locally only through 1,328 replace
refs, which are local to one clone and fooled my own verification into
reporting the pre-rewrite commit as already clean.

Three stale files nobody reads in old commits are not worth 76 dangling
citations in a codebase whose documentation works by pointing at the commit
that proves the claim. Untracking gets the entire practical benefit: gone from
the tree, ignored forever, never regenerated into git.

Recorded so nobody spends the afternoon rediscovering it.
2026-08-15 15:32:37 +00:00
pastilhas 9b1c0a75b2 a dock tile can be bare, so artwork is not framed in a swatch
The tile background exists so a white lucide glyph has something to sit on —
on nothing it is invisible. Artwork does not need it, and a logo framed in an
arbitrary coloured square reads as a mistake.

  badge  rounded square filled with `color`, icon inset   (what a glyph needs)
  bare   no background, artwork fills the tile            (what a mark wants)

DEFAULTED from whether the plugin ships assets/icon.png — artwork gets bare, a
glyph gets badge — with `tile` on the manifest to override either way. Presence
is the declaration, as everywhere else here; the field exists only for the
plugin whose artwork genuinely wants a backdrop. Music sets nothing and gets
bare.

`color` is still required either way. It does four jobs and only one of them is
the square: the active glow, the indicator dot and the plugins-list tint all
read it too.

The icon was also smaller than it looked. The source carried ~8% transparent
margin on every side and the artwork is 233x221 inside a square frame, so with
the old `h-7 w-7` inset inside a `w-10` box the duck occupied roughly 39% of
the tile's area. Cropped to its alpha bounding box, padded back to square to
keep aspect, and rescaled: it now fills 95% of the frame instead of 79%, and
`bare` gives it the whole tile instead of half.

Measured rather than eyeballed — the bounding box came from extracting the
alpha plane and scanning it, then the crop was applied to the 1254px original
so nothing was resampled twice.

tsgo clean, 797 tests, 787 pass, same 7. Verified live: music reports
tile=bare, example and offscale still badge.
2026-08-15 15:21:25 +00:00
pastilhas f78abbe05a a plugin ships its own dock icon as a file, not a lucide name
The manifest's `icon` was a lucide NAME, resolved by `resolveIcon` — which
knows 106 glyphs out of lucide's ~1,500. A plugin naming one outside that set
silently rendered a neutral box, and a plugin from a marketplace had no way to
see the ceiling coming.

So the icon is a FILE now: `plugins/<name>/assets/icon.png`, discovered by
presence like everything else here. `manifest.icon` stays as an optional
fallback for a plugin with no artwork — example and offscale still use it — and
the file wins when both exist.

No new mechanism was needed. The app store already published sidecar assets:
`<dir>/assets/` → `public/plugins/<id>/`, served by a dynamic `/plugins/*`
route, with `DockItem.image` rendering an <img>. `pluginDockManifests()` simply
never emitted `image`. Install now publishes and uninstall unpublishes — the
one thing uninstall is allowed to delete, because these are copies whose
originals are still in the plugin's source.

Base64 in the manifest was considered and dropped. It would ride in every
/api/user/capabilities response for every user on every page load, can't be
cached separately, and puts a 5KB string literal in a source file — against the
rule this manifest keeps: what a directory listing can say, it says. It also
needs no support: `image` goes straight into <img src>, so a data: URL already
works for anyone who wants one.

Music ships OffMusic.png, resized 1254² → 256² (1.6MB → 108KB) with alpha
intact, sized for 3× DPI at the dock's 32px render. It also drops `icon` — the
artwork is a voxel duck in headphones, not a glyph.

The plugins page showed a generic Puzzle for every plugin; the list and detail
header now show the plugin's own icon when it has one.

Two bugs found while testing.

Dock.tsx imported 26 lucide icons and used 14. The twelve dead ones — Music,
Bitcoin, Receipt, Images, CalendarDays, Contact, Clapperboard, Mail, Network,
ArrowDownUp, FileText, FolderKanban, MonitorSmartphone — were residue from when
every feature had a hardcoded tile. Deleted.

And uninstalling a plugin left its icon URL answering 500, not 404. server.tsx
globs ./public at BOOT into one exact route per file, each holding a Bun.file
handle, spread into the route table AHEAD of the /plugins/* wildcard. So an
icon present at boot got an exact route that outlived the file, returning
ENOENT on every dock render with the error logged each time — exactly what the
wildcard's own comment says it exists to prevent. The comment covered the ADD
case; this is its mirror. `plugins/` is now excluded from the boot glob, so the
wildcard owns that prefix alone. Verified: 200 installed, 404 uninstalled, 200
reinstalled.

[open] A plugin's icon cannot be seen BEFORE installing it, which is the one
place an app store most wants to — assets are published at install by design,
and an authenticated icon route is no use to an <img>.
2026-08-15 15:10:17 +00:00
pastilhas e11e0b6475 one port announcement instead of sixteen, so a plugin can name itself
protocol.ts declared a separate SidecarEvent member per sidecar — music, vault,
slskd, headscale, transmission, invoiceshelf, jellyfin, photos, memos, gitea,
caldav, wallet, pty, email, notify, opencode — each an identical one-liner. Two
of them were for plugins that had already left the repository, which is the
tell: the platform was declaring event types on behalf of code it no longer
contains.

The cost was that a plugin could not announce its own port. `connection.send`
takes a SidecarEvent, so `{ type: 'notes:server', port }` did not typecheck
until someone added a line to the platform. A third-party plugin needed a pull
request against Officer before its sidecar would compile — for the one message
every HTTP sidecar has to send.

Now:

  export type SidecarPortAnnouncement = { type: `${string}:server`; port: number };

Nothing was lost at the point of use. The only consumer is
`sidecar.on(`${name}:server`, …)` in create-proxy.ts, which already cast to
read `.port`, because a handler typed `(event: SidecarEvent) => void` cannot
narrow from a computed template string.

The proof is a test that already existed: create-proxy.test.ts announces
`testcar:server` — a name never in the union — and now compiles without its
`as never`.

Removing the two casts that escaped the old type found something else. They
were not hiding union membership, they were hiding `port: number | undefined`:
Bun types Server.port as optional because a unix-socket server has none. So
`as SidecarEvent` on the whole object was suppressing a real nullability
warning, and would have suppressed a genuinely wrong event shape too. Replaced
with a narrow assertion on the port alone. Two further `as SidecarEvent` casts
in the same file turned out to be unnecessary altogether and are gone.

[open] The typo check is genuinely gone: a sidecar sending `muzik:server` while
its proxy listens for `music:server` now compiles, and the symptom is every
request answering 503 forever. The fix is not to restore the list — it is for
createSidecarConnector to announce the port itself from the `name` it already
holds, so the string is written once and the typo becomes unrepresentable.
Recorded in protocol.ts.

Verified live: officer and officer-music restarted, `[music] sidecar registered
on port 38803`, /api/music/manifest and /api/offscale/_health both 200.
tsgo clean, 797 tests, 787 pass, same 7.
2026-08-15 14:49:19 +00:00
pastilhas 0a55964db5 the player moves to the plugin, and src/ has no music code left
officerdev/src/MusicPlayer/ → plugins/music/web/. Engine, state, bar,
favourites, lyrics toggle and the library vocabulary — ten files. The barrel
stops exporting a player it no longer has, and DashboardLayout stops rendering
one.

The reasoning that kept it was removed rather than refuted. It stayed because
the dashboard widget imported useMusicPlayer from officerdev and the platform
cannot import from a plugin, so the state had to stay whatever was decided
about the UI. The owner moved the widget into the plugin in the previous
commit, and the constraint went with it: the whole remaining dependency became
one line, DashboardLayout.tsx:66.

MusicPlayerHost is mounted inside the MusicDetail panel. That reads odd until
you notice it already returned null on /music — the mini bar is the transport
there, and the host existed purely to own the GaplessEngine. In the panel it
does exactly that, and the bar code stays intact for whenever there is a slot.

[phase 2] Leaving /music unmounts the host and playback stops. Deferred on the
owner's call; the bar was "navigating away must not break the application", and
that holds: seekPlayer is optional-chained so a call with no host registered is
a no-op, registerPlayerSeek clears only its own registration, the host's
cleanup destroys the engine and nulls its ref, and the queue is global state so
returning to /music remounts and reloads. Solving it properly needs either a
shell slot a plugin can contribute to — which reopens "there is no way to
export a component" — or the engine hoisted to module scope, which keeps the
rule and loses only the off-route controls.

Also: the parked widget now imports the player as a sibling rather than through
officerdev, and shared.ts stopped being a re-export shim now that the real file
is in the plugin.

Verified: tsgo clean, 797 tests / 787 pass / same 7. Server restarts, mounts
/example /music /offscale, / and /music both 200, and the player is in the
built bundle (music.volume, music:lyrics, now-playing?device=web all present —
GaplessEngine is a class name and the production build is minified, so grepping
for it proves nothing).

Not verified by me: what it looks like in a browser. That needs your eyes.
2026-08-15 14:42:13 +00:00
pastilhas f1bd75853d the music widget moves into the plugin, parked like cliamp
src/workspaces/widgets/MusicPlayer/ → plugins/music/widgets/, its export
dropped from the widgets package, and its registration removed from
WidgetRegistry. Plugins cannot contribute widgets and that mechanism is not
being invented now, so it is parked next to cliamp rather than left in a
workspace package the platform ships.

`../Widget` became `widgets/Widget` — the sibling import turned into that
package's declared export, which it already had.

This removes the last non-plugin consumer of officerdev/src/MusicPlayer, and
that matters more than the move. The argument for keeping the player in the
platform was that the widget imported useMusicPlayer and PlayerTrack from
officerdev, and the platform cannot import from a plugin — so the player STATE
had to stay whatever was decided about the UI, and the engine stayed with the
state.

That constraint is now gone. The complete remaining platform dependency on the
player is one line: DashboardLayout.tsx:66, `<MusicPlayerHost />`.

So the seam is no longer "the widget pins it". It is purely the global overlay
question, which is a platform gap about plugins owning a render slot outside
<Routes> — the same shape as plugins owning a websocket. Recorded rather than
acted on; the decision is the owner's and the previous reasoning for it no
longer holds.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures.
2026-08-15 14:33:44 +00:00
pastilhas 9af52fd754 db:push regenerates the plugin schema barrel, so setup works on a clean machine
`officer_db/src/schema.ts` ends with an UNCONDITIONAL
`export * from './plugin-schemas.gen'`. That file is gitignored — it describes
this machine, not the project — and until now its only writer was
installPlugin.

So on a fresh clone the file does not exist and `bun db:push` dies with
MODULE_NOT_FOUND before creating a single table. That is exactly what
officer-setup.sh section 8 runs, so setup failed at the schema step on any
clean machine, and the only thing that would have written the file was
installing a plugin — which needs a working database. A deadlock, shipped with
the plugin system on 2026-08-15.

Reproduced by deleting the file and running push, not inferred.

`db:push` now runs scripts/gen-plugin-schemas.ts first. Wired into the script
rather than added as a setup step because setup is not the only caller:
pushSchema() shells out to the same command, a developer types it by hand, and
`git clean -xfd` removes the file at any time. A step someone has to remember
is one they forget once; push regenerating it means the barrel cannot be stale
when drizzle reads it.

Both paths verified: barrel deleted then push (regenerates, "No changes
detected"), and zero plugins on disk (writes a header-only barrel that
schema.ts still imports cleanly).

Also verified, on a scratch database, the premise the whole barrel design rests
on — that push drops what it cannot see. Full schema pushed, a row seeded into
music_favorites, then an empty barrel pushed: music_* and headscale_servers
were DROPPED along with the row. So "following the directory rather than the
install table" is load-bearing, not decorative.

Worth recording how close that came to being logged as the opposite. An earlier
run with an empty barrel reported "No changes detected", which read as evidence
that push does not drop. It was this fix working — the script had regenerated
the barrel before drizzle ran. The scratch database is what told them apart.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Scratch
database dropped; live plugin tables and rows verified intact.
2026-08-15 14:18:47 +00:00
pastilhas 1e79b4effd revert prettier churn on four files the cliamp move never touched
I ran `bunx prettier --write` over whole directories instead of the files I
edited, so FileViewContainer, SelectionActions, usePipelineRunner and Providers
got rewrapped into a diff about cliamp. Pure whitespace, zero behaviour, and
exactly what CLAUDE.md warns about — unexplained churn in someone else's file.

Worth noting what it revealed rather than just undoing it: those four were not
prettier-clean to begin with, so `bun format` on a clean tree would rewrite
them too. That is a pre-existing inconsistency, not mine to fix in this commit.

bunx tsgo clean.
2026-08-15 13:53:36 +00:00
pastilhas a9bf51407e cliamp moves into the plugin, and the platform loses its last music file
The owner read the code and asked why `plugins/music/api/router.ts` was three
lines importing `@@/api/music/router` — platform code that knows the string
'music'. He was right, and tracing it found the justification was hollow.

The chain: server.tsx:20 imported the cliamp relay's two exports, which are
used only on commented-out lines; so the relay's functions were never invoked;
so its call to getMusicServerWsUrl never ran; and the file's other export,
getMusicServerUrl, had no consumers at all. A dead import held a music-named
file in the platform, and I documented that as a "seam" last night after
checking the import existed and stopping there.

Everything cliamp now lives in plugins/music/cliamp/:

  sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, the test
  api/cliamp/relay.ts
  apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx

src/servers/sidecar/music/, src/servers/api/cliamp/ and src/servers/api/music/
are gone. server.tsx has no cliamp import, provider name, handler entry or
route. The platform contains no file named for music or cliamp.

Two of the things that moved were live, not inert.

The file browser's `Play` context-menu item, on any audio file or folder, set
?play= and rendered a cliamp terminal pointed at /api/cliamp/ws — a route that
upgraded into a handlers entry that was commented out, so handlers[provider]!
asserted non-null on undefined. Using that menu item crashed the socket
handler. Removed: the action, the layout, the panel wiring and both menu
entries. Verified the routes now 404 rather than crash.

That closed the totality drift as a side effect. server.tsx's route table and
its handlers map agree again for the first time since 2026-08-13, and
registry.test.ts now asserts it rather than pinning the hole.

The proxy is built in the plugin now, and its prefix is DERIVED. It was the
literal '/api/music', which the proxy uses to strip characters off the path —
correct only because mountPrefix returns /music for a first-party publisher.
The same plugin published by anyone else mounts at /api/p/<publisher>/music and
would have forwarded /alice/music/stream to a sidecar expecting /stream. A
latent bug only third parties would ever hit, and a quiet violation of the rule
that mountPrefix is the one function allowed to know about provenance. Offscale
has the identical hardcode and still needs it.

Still open there: appName is passed as a literal, because a plugin's router
cannot see its own directory name — the platform imports the module and reads
`router`, so there is nowhere to inject it. The fix is a factory the installer
calls with the plugin's identity.

Plugin backend coupling is down to 7 imports, all of them "a plugin talks to
its host": data-path, sidecar/connect, sidecar/protocol, officer-url, the
manifest type, officerdb/db and the users.id FK. Nothing music-shaped left.

bunx tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures. Verified
live: manifest 200, favorites 200, stream 206, /api/cliamp/ws 404.
2026-08-15 13:52:53 +00:00
pastilhas 8bfcd40bd2 music's library browser depends on another plugin's permission, not just its own
MusicBrowser lists folders with GET /file-browser/ls rather than through the
music sidecar. /file-browser belongs to `files`, which is `confined` — so a
member granted `music` and not `files` gets a working player, working
favourites and an empty library, and `files` is not a grant that can simply be
handed over, since authorize.ts drops a confined grant for an account with no
Linux user.

First cross-plugin PERMISSION dependency in the system, and a different animal
from offscale's. That one is ConsoleView → TerminalView: code, resolved at
build time, worst case a plugin that will not compile. This resolves at request
time, per account, and fails as a screen that renders perfectly and shows
nothing.

Three shapes written down, none chosen. Moving the listing into the sidecar is
probably right — it is the same rule offscale follows, that the sidecar absorbs
everything — but it is tomorrow's call.

Recorded now rather than trusted to memory: it was found by reading, after the
extraction was already verified and pushed, so nothing was going to surface it
again on its own.
2026-08-15 03:09:50 +00:00
pastilhas e930586878 plugins declare the host binaries they need, and the installer checks
Offscale was self-sufficient. Music is not — it shells out to ffmpeg and
ffprobe — and the way it fails without them is the reason this is a check
rather than a line in a README.

It does not fail. Missing ffprobe means the indexer catches the spawn error
and returns a track carrying its filename and nothing else: no title, artist,
album, duration or embedded lyrics. It then walks the whole library, writes a
complete cache tree and reports success. Five swallowed catches, no log, no
counter, and the only tell is coversSaved: 0 in a report nobody reads.

So `osDependencies` is a manifest field: the binary to probe on PATH, why it
is needed, and a package name per package manager. The shape is taken from
scripts/setup-old/setup.sh rather than invented — probe the binary, case on
$PM — and the names are per-manager rather than canonical-with-overrides
because lib/packages.sh already recorded why that indirection was rejected.
Probing the binary is what makes "built-in on this OS" free: on PATH means the
package map is never consulted.

Four decisions worth naming.

Missing and uninstallable REFUSES the install, first, before a table is
created or a row written — so there is nothing to undo, and the alternative is
a plugin that installs, answers 200 and quietly produces nothing.

The status is on GET /api/plugins and rendered before the button, because the
owner is deciding whether to let the server run a package manager as root and
that needs answering first. Installing by hand and watching it flip to present
is the escape hatch on a machine without passwordless sudo.

Package names get a deliberately narrow regex and reach Bun.spawn as an argv
ARRAY, never a shell. Both halves are load-bearing: the regex means a
metacharacter cannot get there, argv means it would be an argument rather than
syntax if it did. Narrower than package managers actually accept — no `:`, no
`+` version pins — because a plugin needing one wants a conversation.

Success is OBSERVED, not inferred: after installing, the binaries are re-probed.
A package manager exiting 0 having installed something that does not provide
the binary is exactly the failure this exists to catch.

installCommand mirrors lib/packages.sh's pkg_install_now exactly, including
apt's non-interactive environment, so there is one definition of "install a
package" rather than two that drift. sudo always gets -n: under PM2 a password
prompt is not a slow path, it is a hang. brew never escalates.

Verified live. ffmpeg and ffprobe were absent on this machine all evening; the
page showed both missing with the exact root command, the install streamed
`dependencies: installing ffmpeg with apt` then `ffprobe, ffmpeg now on PATH`,
and X-Audio-Duration appeared on a stream response for the first time. The
refusal path was exercised against a temporary probe dependency: HTTP 400,
steps: [], reason named.

THIS CHANGED THE MACHINE: ffmpeg 6.1.1-3ubuntu5 is now installed via apt.

Found on the way: a manifest is read once per process. Discovery does
`await import()` and the module cache holds it, so editing a manifest changes
nothing until pm2 restart officer — including `outdated`. Cost ten minutes and
is now in the runbook.

bunx tsgo clean. 797 tests, 787 pass, 7 fail — the same seven, +25 new.
2026-08-15 02:33:18 +00:00
pastilhas 05eb947bd1 music is verified on the live server, and the runbook learns from it
Ran the whole table against platform.officer.dev rather than reasoning about
it. install / API / range requests / dock / permissions / disable / enable /
uninstall / db:push-while-uninstalled / reinstall / pm2 restart officer — all
pass, recorded in plugins/music/PLUGIN.md with the actual numbers.

Two results worth naming. The reinstall was a RESTORE: a favourite and a
playlist seeded before uninstall came back untouched, which is the whole point
of the barrel following directories rather than the install table. And
`bun db:push` while uninstalled said `No changes detected` with the rows still
there — the property that makes running push by hand safe at any moment.

Range survives the proxy hop: 206 with a correct Content-Range and exactly the
bytes asked for, 416 unsatisfiable, 400 on a path escaping the Music root.

Two findings from the machine rather than the code. ffmpeg and ffprobe are not
installed here, so X-Audio-Duration never appears and indexing would produce no
tags or covers — the manifest already says a host binary cannot be declared, and
now says it was checked. And ~/Music did not exist at all, so the library is
empty; the sidecar handles both absences and logs them rather than failing.

Music is left INSTALLED and enabled. It had been switched off since 2026-08-13,
so this restores it.

The runbook gains what generalises: map what the PLATFORM still needs from your
feature before planning the split, because two imports pointing the wrong way
dictated music's boundary rather than any judgement about what music is.
Offscale had none, which made the job look cleaner than it is. Also two new
traps — delete the app-store catalogue entry or the screen goes blank, and
compare test FILE COUNTS across a run, since that is the only thing that shows
a test which stopped being discovered.

The global-overlay question is recorded as answered: no. A shell slot for a
plugin-provided component reopens "there is no way to export a component", and
that rule is what the frontend contract rests on.

Seeded rows and the audio fixture removed; ~/Music deleted again.
bunx tsgo clean. 772 tests, 762 pass, 7 fail — all pre-existing.
2026-08-15 01:56:16 +00:00
pastilhas de3340398c music becomes a plugin, and the player stays behind
The whole of music moves to plugins/music/: the sidecar (index, indexer,
stream-audio, nightly-reindex), the four Postgres tables and their queries,
the /music workspace panels, MUSIC_API.md and the reindex CLI. The platform
keeps no music routes, no music capability entry, no music screen and no
music schema.

Three things stayed, each on purpose.

cliamp and the widget were out of scope by the owner's decision. The plugin's
sidecar still serves the two cliamp sockets, so it imports cliamp-ws.ts and
pulse-audio.ts from @@/sidecar/music/ — the files stay where they were.

The player did not move, and that was the open judgement call. Deciding it
took one fact: the dashboard widget imports useMusicPlayer and PlayerTrack
from officerdev, and the platform cannot import from a plugin. So the player
STATE stays whatever is decided about the UI around it, and two copies would
mean two audio engines. Given that, the engine and the bar stayed with the
state rather than being split from the thing they drive. Moving them would
also have needed a shell slot rendering a plugin-provided component on every
route — the one escape hatch this system deleted on purpose. MusicPlayerHost
gates on can('music'), which is now the plugin's permission, so the seam
switches itself off with the plugin.

api/music/router.ts stays too: api/cliamp/relay.ts imports getMusicServerWsUrl
from it. The plugin's api/router.ts re-exports that proxy rather than building
a second one — two subscribers to the one-shot music:server port announcement
would work today and 503 on the first reconnect where only one was listening.

Two bugs found on the way, neither visible from reading.

The app-store catalogue still listed music. Availability is derived from
sidecar_installs and a PLUGIN never gets a row there, so `music` would have
been permanently unavailable — which puts /music into deniedRoutes and blanks
the screen on a server where the plugin was installed and healthy. Exactly
the headscale bug documented six lines above it in the same file, and it would
have fired on the first install. Entry removed.

[test] root was "./src", so moving lyrics.test.ts into plugins/ stopped it
running and said nothing — the count fell by nine and the suite still read
green. Root is now the repo. Positional filters cannot fix this: `bun test
plugins` matches under root and finds src/servers/plugins/ instead.

registry.test.ts tested the `personal` mechanism THROUGH the music capability.
Re-anchored on a fixture rather than on another entry, because borrowing a
feature only moves the problem to the next extraction — and three of those
four tests had been passing for the wrong reason since music's api was
commented out on 2026-08-13, when everything started resolving to "refused
because nothing is claimed". The cliamp sockets being claimed by nothing is
now pinned by a test instead of being rediscovered.

music's `personal` paths ride across on readOnlyWrites, the one field a
manifest has. isRequestAllowedAtLevel concatenates the two lists, so a read
grant permits exactly the four paths it permitted yesterday, and no field was
added to the manifest to design a per-user model that is not this work.

bunx tsgo clean. 772 tests, 762 pass, 7 fail — all seven pre-existing and
unrelated (cliamp, pty, and five capability tests that other switched-off
plugins break). Baseline was 757/10; the three that went green are the ones
re-anchored above.

Not yet verified on the live server — that is next.
2026-08-15 01:46:43 +00:00
pastilhasandClaude Opus 5 18c4ebd0b4 the per-user model is not tonight's work either
personal, the four user-scoped tables, and what a member's grant means all stay
exactly as they are. it gets built inside the plugin later, which is the entire
reason the platform's answer is a uniform read/write and nothing more.

leaving it alone is safe rather than lazy: the web frontend does not use those
routes at all — favourites, playlists and now-playing are used only by the phone
and tablet apps — so nothing visible in a browser can regress by carrying them
across verbatim. and /queue, which is in that list, has no route anywhere. dead
or aspirational; carried as-is, not investigated.

the one unavoidable consequence stays named: registry.test.ts tests the personal
mechanism through the music entry, so removing it breaks those tests. re-anchor
on another entry that has personal. a test fix, not a redesign.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:24:09 +00:00
pastilhasandClaude Opus 5 7d65732f77 the widget is out of scope too, leaving one open call
widgets/MusicPlayer stays in its third workspace package, registered where it
is. plugins cannot contribute widgets and are not going to learn how tonight.

with cliamp and the widget both cut, exactly one judgement is left in the music
extraction: whether the global MusicPlayerHost overlay gets a contribution slot
in the shell or stays in DashboardLayout gated as it already is. either is fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:21:54 +00:00
pastilhasandClaude Opus 5 b5db3c47e1 cliamp is out of scope, which unblocks the music extraction
the two websocket providers exist for one thing: running the cliamp TUI on the
server and piping its terminal and audio to the browser, via a pulseaudio null
sink tapped by parec. a second, separate playback path — and the least important
part of music. wanted eventually, not tonight.

that removes the hardest of the three homeless parts entirely. no plugin can own
a socket yet, and now none needs to: both sockets are already inert, and
cliamp-ws.ts, pulse-audio.ts, asoundrc, the relay and the two providers all stay
exactly where they are. if the relay's import is the only thing keeping
api/music/router.ts alive, that stays too — a small documented seam.

what music actually is: the /music screen, the library, and the phone and tablet
apps that stream from it. that is what has to work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:19:18 +00:00
pastilhasandClaude Opus 5 8545b427dd the runbook tells the next agent to decide, not to ask
it previously said 'decide before moving a file, and tell me the decision',
which turns an overnight run into a blocked one. every open call here has two
defensible answers, a corrected decision is cheap, and a stalled extraction is
not.

adds a default for each of the three homeless parts — close the platform gap
when there is runway, because every later plugin needs it too, but a working
music with cliamp left in the platform beats a perfect design that did not land.
the bar at the end is unchanged: install, enable, disable, uninstall cleanly. a
piece left in the platform is a documented seam; a piece left dangling is a bug.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:13:49 +00:00
pastilhasandClaude Opus 5 965ced52a6 a runbook for the next extraction, and a warning about music
plugins/EXTRACTING-A-PLUGIN.md: the rules that are not preferences, the order
that worked, the verification cycle the owner actually ran, and the traps that
each cost real time.

the music section is the important half. music is NOT a bigger offscale — three
of its parts have nowhere to go: two websocket providers (and no plugin can own
a socket), a global UI overlay rendered by DashboardLayout on every route, and a
dashboard widget in a third workspace package. its relay is platform code that
imports the music router, so deleting that router breaks the platform. it also
needs six external binaries a manifest cannot declare, ships a non-TS asset, and
is the worked example the capability tests are written through.

recorded before starting rather than discovered halfway, because a half-extracted
music is worse than an unextracted one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:11:14 +00:00
pastilhasandClaude Opus 5 4a9f23c759 the design doc moves into the plugin it produced
docs/offscale-plugin.md becomes plugins/offscale/PLUGIN.md. most of it is about
the plugin system generally rather than about offscale, which is exactly why it
belongs with the worked example — the reasoning is most useful beside the code
it produced, and the platform's docs should not carry the history of something
it no longer knows exists.

every reference updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 01:07:48 +00:00
pastilhasandClaude Opus 5 b4dab16d2a the schema barrel stops describing a world that ended
three things wrong in one paragraph: it named ecosystem.light.config.cjs, which
no longer exists; it called officer-headscale core, which it stopped being on
2026-08-14; and it said installing a plugin 'will have to uncomment its line and
push, and building that is still ahead of us', which was built yesterday.

now it says what is true — a plugin owns its schema at plugins/<name>/db and
install generates the barrel and pushes — and keeps the commented lines for the
sidecars still waiting their turn, which is what they are actually for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:52:37 +00:00
pastilhasandClaude Opus 5 2c89281bfc drop a comment describing an export that left with offscale
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:49:32 +00:00
pastilhasandClaude Opus 5 585c046a64 the plugin system says permissions, not the other word
i named the field permissions and then narrated in the overloaded one anyway,
which is worse than either — the whole reason for the rename was that the word
already means three things here.

renamed what was mine: setPluginCapabilities -> setPluginPermissions,
pluginCapabilities -> pluginPermissions, and the prose throughout.

what remains is the platform's own vocabulary, not the plugin system's: the
 type it imports, and the  field on a dock manifest,
which is the shape the shell already renders. renaming those is a separate
change to a separate system and is the owner's to make.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:47:18 +00:00
pastilhasandClaude Opus 5 8cc51cfb40 every plugin permission is grantable, and there is no field to say otherwise
offscale was missing from the permissions page because it declared ownerOnly,
which mapped to kind admin, and admin capabilities are never offered for
granting. correct by the old rule and wrong by the standard: every plugin
follows the same platform-level permission model, appearing on the same page
with the same read/write/none per role.

so the field is gone rather than flipped. a plugin has no way to say owner-only,
which makes the standard structural instead of remembered — the same move as the
workspace rule. core, execution, confined and admin stay the platform's to
assign and a plugin cannot name any of them, so the escalation question is
removed rather than answered.

this is the second draft of this decision to be deleted: first a full
CapabilityKind with three of five values forbidden, then an ownerOnly boolean,
now nothing. the doc records all three so the reasoning is visible rather than
just the conclusion.

finer visibility stays the plugin's job. offscale is the worked example of the
gap that leaves and its manifest says so: it is grantable now, and its queries
still scope by the caller, so a granted member would see their own empty server
list rather than the owner's. closing that is a change inside the plugin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:42:35 +00:00
pastilhasandClaude Opus 5 8b6cb34ae0 plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a
plugin installed through the plugin system was reachable only by typing its url
or following the link on /plugins.

built at runtime rather than baked into the generated bundle, deliberately —
WHO sees a tile is a permission question, and a grant takes effect on the next
request rather than the next build. presentation comes from the manifest and the
route from mountPrefix, so there is one source for both, and  is the
plugin's first permission so the endpoint can filter a tile out for an account
that cannot reach the screen.

two sources for tiles today, because the app store still has its own catalogue.
one when it is rebuilt on this.

the doc now records the system as complete rather than half-stubbed, including
the three bugs the extraction found — the sidecar-before-mount ordering, the
missing tailwind, and the build that could delete its own shell — and what is
genuinely still open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:31:54 +00:00
pastilhasandClaude Opus 5 8587ae20b7 a plugin creates its own tables, and uninstalling never drops them
the last unwired step. install now generates a drizzle barrel of plugin schemas
and runs db:push, so a plugin with db/schema.ts brings its tables with it.

the barrel follows the plugin DIRECTORIES on disk, not the install table, and
that difference is the entire safety property. push drops what it cannot see, so
a barrel tracking installs would delete a plugin's tables the moment it was
uninstalled — turning "stop running this" into "delete my data", which is the
one thing the install model refuses to do. following the directory means:

  directory present, not installed   in the barrel, tables exist unused
  installed                          in the barrel, tables in use
  uninstalled                        STILL in the barrel, every row survives
  directory deleted                  out of the barrel, a push may drop them

so reinstall is a restore, and losing data requires deliberately deleting a
plugin's source.

proved end to end rather than argued. with offscale uninstalled and its entry
removed from the barrel, db:push DROPPED headscale_servers. installing it
recreated the table in 1882ms — columns, both unique indexes including the
partial one that enforces a single active server, and the fk. a canary row then
survived an uninstall AND a subsequent manual db:push, which reported "No
changes detected".

it shells out to the same `bun db:push` a human runs rather than driving
drizzle-kit in-process: one definition of applying the schema instead of two
that can disagree, and an owner can reproduce exactly what an install did.
--force because the barrel only ever gains entries unless source is deleted, and
a prompt with no terminal would hang an install rather than fail it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:21:11 +00:00
pastilhasandClaude Opus 5 e13128846b offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.

  api/router.ts   the thin auth-gated proxy, now at /api/offscale
  sidecar/        18 files, the whole headscale contract and its admin keys
  db/             schema + queries, offscale_servers
  web/            26 files as panels and a layout — no screen, per the rule

removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.

the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.

AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.

install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.

verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.

757 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:15:38 +00:00
pastilhasandClaude Opus 5 0e24aa3d52 a way into the plugin from its detail panel
installing something and then having to guess its url is a small thing that
makes the whole flow feel unfinished. the detail panel now links to the plugin's
screen.

shown only while installed AND enabled, and only when the plugin has a frontend
at all. a link to an unmounted route lands on the home page, because the shell
redirects an unknown path — which reads as a broken link rather than a plugin
that is switched off. a backend-only plugin has no screen to open and gets no
link rather than a dead one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 00:02:09 +00:00
pastilhasandClaude Opus 5 543e88a9a6 every plugin route renders a workspace, and it is not a rule you can forget
an exclusionary rule, made structural. a plugin does not render a screen: it
contributes panels and says how they are arranged, and the shell renders
WorkspaceView around them.

    web/panels.ts   appRegistryMetas — at least one panel
    web/layout.ts   defaultLayout — how they are arranged

both required the moment web/ exists, and missing either is refused at discovery
by name and with the reason. tested:

    probeplug: has a web/ directory but is missing web/layout.ts.
    Every plugin route renders a Workspace: contribute panels and a layout,
    not a screen.

there is deliberately no way to export a component. one that could would be free
to render a bare div, a full-page form, or its own navigation, and the platform
would become a shell hosting strangers' layouts rather than one application.
non-compliance is not so much refused as unrepresentable — there is nowhere to
put a screen.

the shell registers <prefix> and <prefix>/:section, exactly as the core screens
do, so a plugin's sections stay addressable and cmd-clickable, and panels read
useParams independently rather than passing state between themselves.
appTypes.allowed is pinned to that plugin's own keys, so a persisted layout
naming something else falls back instead of rendering another plugin's panel
inside this screen.

the example plugin is rebuilt to model it — two panels, a layout, one of them
calling its own /api/example/ping through useClient — because the reference
implementation is what everyone copies.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:47:48 +00:00
pastilhasandClaude Opus 5 2e3c935da6 the built SPA had no tailwind, and the build could destroy itself
three fixes to last night's build switch, all found by using it.

THE CSS. bunfig.toml declares the tailwind plugin under [serve.static], which
applies to bun's static SERVING — the html-import path the app used until
yesterday — and not to a programmatic Bun.build(). so the first build emitted
the xterm css and no tailwind at all: layout intact, every utility class
missing. a plugin list is not inherited from bunfig; it has to be passed. css
goes 110KB to 278KB, 1127 --tw- variables, .flex present, --color-duck present.

THE BUILD DIRECTORY. clearing it before building was meant to stop 20MB of
content-hashed chunks accumulating per install, and instead meant a FAILED build
left nothing — the exact opposite of the promise in the comment directly above
it. it was also a race: two builds overlapping had one process's rm delete the
other's shell, leaving js and css with no html and a 503 that read as a build
failure when the build had succeeded.

now it builds into build.next/ and swaps only a complete, successful build into
place, and refuses to swap one that produced no shell at all — a build can
report success and emit no html, and serving that is worse than serving the
previous one.

AND IT SAYS WHICH PATH IS SERVING. "is it serving the build I just made, or the
one bundled at import?" was answerable only by hiding the shell and watching for
a 503, which is how it got answered once. the distinction matters precisely
where it is hardest to see: the html import is fixed when the module graph
loads, so an install would rebuild build/ and serve something else entirely.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:26:28 +00:00
pastilhasandClaude Opus 5 7b4137ccca a plugin's frontend, generated and rebuilt without a restart
the last piece. installing a plugin now brings its UI with it.

a bundler cannot follow import(runtimeString), so which plugins have a frontend
cannot be answered from the database at render time — it has to be written into
source first. Plugins.gen.tsx is that file: concrete imports, generated from
what is installed, gitignored because it describes THIS machine.

App.tsx keeps its core routes and gains one map. the wildcard hands the whole
subtree to the plugin's own router, which react-router nests natively.

serving moved to build/ in production. the html import is bundled once when the
module graph loads and can never change after, which is precisely why a plugin's
frontend needed a restart; Bun.build measures ~900ms for a 25MB bundle, so an
install can just rebuild. development keeps the html import, because that is
what gives HMR and bun --watch restarts on every source change anyway.

verified end to end against a running server, no restart at any point: install
regenerated the module, rebuilt the bundle (chunk hash changed), and the
plugin's own markup was in it; /example and /example/deeper both served; disable
took it back out of both the module and the bundle and 404'd the api; enable put
it back.

three things worth recording because they were found rather than reasoned:

the shell output is named after the ENTRYPOINT — index.gen.html, not index.html
— and naming: { entry: '[name].[ext]' } does not change it because [name] is
'index.gen'. found as a 503 on the first boot after the switch.

App.tsx already destructured a `plugins`, from useServerSettings — the DEAD
plugin system that scans a directory which does not exist and always returns [].
it silently shadowed the import. the new one is `installedPlugins` and says why.

seedAppRegistry takes plugin panels as an argument rather than importing them:
officerdev is a dependency of the shell, so importing upward would invert that.

756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 23:07:18 +00:00
pastilhasandClaude Opus 5 a00116b2c0 a plugin's permissions become real capabilities, and survive a restart
two gaps between "a plugin can mount routes" and "a plugin is part of the
platform". both closed.

FIRST: nothing registered a plugin's declared permissions, so the capability
gate could not resolve a plugin path at all. it resolved to null, and null is
denied — the owner never noticed because isSuperAdmin short-circuits every
check, which is exactly the shape of bug that reaches a member first.

the registry is now rebuildable the same way the hono app is: CORE_REGISTRY
holds the platform's own, CAPABILITIES is core plus whatever the installed
plugins declare, and setPluginCapabilities replaces the plugin half wholesale
rather than diffing it. two invariants hold by construction — DEFAULT_ROLE_-
CAPABILITIES and CORE_CAPABILITIES derive from CORE_REGISTRY, so a plugin can
never put itself in the fresh-install baseline and can never become `core`
(every account, undeniable). a key colliding with a core one is refused and
logged, because a plugin able to redefine `chat` could widen it.

ownerOnly maps to admin, everything else to app. those are the only kinds a
manifest can express, and it has no field for a kind at all.

capabilities are registered BEFORE routes are mounted: the gate runs ahead of
every router, so mounting a route whose permission is not yet registered would
403 the freshly installed plugin until something else happened to refresh.

SECOND: nothing mounted plugins at boot. honoServer is built with none at
import, because discovery reads disk and database and neither can be awaited at
module scope, and every install verb rebuilt — so it tested perfectly and would
have silently unmounted everything on the first restart.

server.tsx now refreshes before serve(), so there is no window where an
installed plugin 404s, and a plugin that will not load is logged rather than
fatal.

verified: after a restart, [plugins] mounted /example, the row survived,
officer-example came back online from its ecosystem entry, capabilityForApiPath
resolves /api/example/ping to the example capability at kind=app, and it appears
in the owner's grantable list. 756 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:43:49 +00:00
pastilhasandClaude Opus 5 62ee0d1e60 the beat between steps is feedback, not decoration
the comment called it cosmetic and worth being honest about, which reads like an
apology and invites the next reader to delete it as a pointless sleep.

the real reason is better. some of this work is genuinely slow — pm2 start
measures ~770ms — and some is effectively instant. without a pause the fast
steps land in one frame, the log jumps from empty to finished, and you cannot
tell 'it worked' from 'nothing happened'. the interval is what makes a step
something you saw happen rather than something you found already done.

only applied when something is listening, so the json path still runs flat out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 22:33:53 +00:00
pastilhasandClaude Opus 5 4d4606d4a2 close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.

measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.

so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.

it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.

two directories are left open on purpose, and both are the same latent bug:

    /usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
    /usr/local/bin/gh  -> /home/pastilhas/.local/bin/gh

system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.

verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:59:45 +00:00
pastilhasandClaude Opus 5 a220342b22 stream the install, so it reads like a log instead of a spinner
each verb now reports its steps as they complete, over server-sent events, and
the detail panel renders them arriving.

POST rather than GET, so EventSource is unavailable — it sends no Authorization
header and these routes are owner-only. The client reads the body and parses
frames by hand, which is what useCompanionLogStream already does for the
headscale container logs; the parser only has to understand what our own
endpoint emits.

the runner does not know whether anyone is listening. it takes an optional
onStep and calls it, so the non-streaming path is the same code with no callback
rather than a second implementation of the same four verbs.

there is a 220ms beat between steps and it is cosmetic — worth saying out loud.
pm2 start genuinely takes ~770ms, measured, but writing a row and rebuilding the
router do not, and four lines landing in one frame look like a stall followed by
a jump. small enough not to matter to a script, long enough to follow.

writing to a closed stream is caught rather than fatal: navigating away
mid-install must not abort the install, because by then it is the server's work
and half an install is the one outcome the ordering was designed to avoid.

verified over the wire with timestamps — frames arrive incrementally, the
sidecar step showing its real duration rather than the beat. afterwards pm2
holds the five core apps, plugin_installs is zero, and ecosystem.config.cjs is
byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:50:50 +00:00
pastilhasandClaude Opus 5 02e049cae8 terminal: stop replaying questions, stop opening two sockets, bind the word keys
three separate faults behind "reconnecting gets weird and the keyboard is not
natural".

── the replay typed into the shell ──

the pty buffer was stored raw and replayed verbatim on every re-attach. anything
in it that ASKS the terminal a question — DSR, DA, DECRQM, XTVERSION, XTGETTCAP,
the OSC colour queries — got asked again, and xterm answered correctly by writing
the reply to its input. the pty receives that as a keystroke nobody typed.

stripped on the way IN, since the buffer is the thing that gets replayed and a
live client already answered them once when they were legitimately asked. only
questions are removed; everything that draws is untouched. where a control shares
its final byte with one that draws, the parameter is enumerated rather than
wildcarded — CSI 18 t asks the window size, CSI 22 t pushes the title, and
stripping the second would change what a replay renders. 36 tests, both
directions, because both fail silently.

── two sockets on one session ──

handleClose armed a reconnect timer; handleVisibilityChange fired on tab focus
whenever readyState was CLOSED — which is exactly what a pending timer leaves.
both ran. every keystroke went twice, two replay frames fought over the screen,
and only one socket was ever cleaned up because __terminalCleanup is overwritten
by whichever connect ran last. connect() is now the single guard, and a stale
socket's close no longer speaks for the session.

── the keyboard ──

alt-arrow was dead for everyone: xterm.js 5 rewrote it into the ctrl-arrow
sequence, xterm.js 6 removed that rewrite and emits the honest ^[[1;3C/D
(verified — the string 1;3D does not appear anywhere in the 6.0 bundle). nothing
bound it. so it broke on a dependency bump, with no shell config changed.

bound in zsh rather than translated in the browser, deliberately: tmux.conf
claims M-Left/M-Right for pane switching, and a client-side rewrite would send
^[b to tmux and break it. the real sequence lets tmux handle it inside a session
and zsh outside.

ctrl-arrow was worse and more embarrassing: it worked for MEMBERS and not for the
OWNER. shell-skel/zshrc has had the bindings all along; the owner's .zshrc is
assembled in machine-setup and never got them. the owner had a strictly worse
shell than the accounts they provision. confirmed with `zsh -i -c bindkey`
before and after.

also: escape-time 10 in tmux.conf. the 500ms default delays every Alt chord and
every Escape, which is most of what "not natural" felt like.

applied to this host by hand — setup only runs at install. cmd+arrow is left
alone: xterm emits nothing for it, so there is no sequence to bind.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:27 +00:00
pastilhasandClaude Opus 5 2634df7a04 the install runner: ecosystem entry, sidecar, row, mounts
closes the hole app-store/pm2.ts has carried since 2026-08-13 — "installing a
plugin has to append its entry here before starting it, that is the plugin
system's job and it is not built". this is that job, and it is why nothing in
the app-store catalogue installs end to end either.

verified against a running server with a sidecar in the tree:

  install    ecosystem added · sidecar started · recorded · mounted /example
             route 200, pm2 online
  disable    sidecar stopped · unmounted
             route 404, pm2 stopped
  enable     sidecar started · mounted
             route 200, pm2 online
  uninstall  record removed · unmounted · sidecar stopped, deleted, entry gone
             route 404, not in pm2, tables untouched

afterwards ecosystem.config.cjs is byte-identical to before, pm2 holds the same
five core apps, and plugin_installs is back to zero rows.

the ecosystem file is edited rather than regenerated: the core entries come from
officer-setup's shell array, so the platform does not know that list and a copy
here would be a second thing to drift. the header above module.exports is
preserved verbatim too — officer-setup's explains that bun auto-loads .env from
the working directory and that data-path derives the install root from its
PARENT, so a wrong cwd relocates the whole install rather than failing. losing
that to a plugin install would be a poor trade.

order is the design. bringing up goes outside-in, taking down goes inside-out,
so the worst intermediate state is "recorded but not running" — visible, and
fixed by a retry — never "running but forgotten", which nothing can see.

each verb returns what it actually did, in order, and the detail panel shows it.
an install that mounted routes but could not start a sidecar is a different
outcome from one that worked, and a spinner that stops cannot say which.

the schema push is still deliberately not wired, and the reason is now in the
code: db:push DROPS tables absent from the schema it is given, so an uninstall
that regenerated the barrel would delete a plugin's data as a side effect of
stopping it. offscale does not need it — headscale_servers already ships in the
platform schema.

720 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:38:21 +00:00
pastilhasandClaude Opus 5 ed195e0904 record what got built tonight
the plugin system works end to end for a plugin with an api/router.ts, at
runtime, with no restart. what is wired, what is not (schema push, the sidecar's
pm2 entry, websocket providers, totality across plugin routes), and what was
deliberately left: offscale is not extracted, because moving it deletes working
code across ~50 files and that wants someone watching.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:28:18 +00:00
pastilhasandClaude Opus 5 98c400bf33 a /plugins screen to install, enable, disable and uninstall
the management surface for what the last commit made possible. two panels either
side of a selection that lives in ?selected= and is read by both independently,
so neither can be telling the other something stale — rows are real Links, not
buttons holding the name in a closure.

the detail panel shows what the tree declared (api, schema, sidecar, web),
because "installed and nothing happened" is otherwise a mystery, and it names
what uninstall does NOT do: neither disable nor uninstall deletes anything the
plugin stored, and the screen says so rather than leaving someone to guess
whether a button destroys their data.

a directory whose manifest will not parse is listed with its error rather than
skipped. a malformed plugin that simply does not appear is indistinguishable
from one nobody wrote.

`outdated` is surfaced as an Update button: the version on disk moving after an
install is the normal state on a developer's machine, and it should be visible
rather than inferred.

the four mutations are written out rather than generated in a loop — useMutation
is a hook, and a hook called from inside a helper is a rules-of-hooks violation
even when the call order happens to be stable. caught before it shipped.

verified against a running server: the spa builds (19.8 MB bundle containing the
new screen), / serves 200, /api/plugins answers authenticated and 401s without a
token. full suite 719 pass, same 10 pre-existing failures. live server and
plugin_installs left untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:27:58 +00:00
pastilhasandClaude Opus 5 282a64a637 plugins install, enable, disable and uninstall at runtime
the rest of the mechanism, and it works end to end. against a real server, with
no restart at any point:

  /api/example/ping BEFORE install   404
  AFTER install                      200  {"plugin":"example","ok":true}
  AFTER disable                      404
  AFTER enable                       200
  AFTER uninstall                    404
  core route throughout              200

plugin_installs is a new table rather than a reuse of sidecar_installs. that one
belongs to the app store's model, where installing means provisioning a
container or pointing at a remote instance, and it carries mode, compose_dir and
completed_steps to say so. a plugin install has none of those, and reusing it
would have meant a `mode` that lies about every plugin. the two models coexist
until the app store is rebuilt on this one.

the row is needed because presence is not installation: plugins live in the
repository, so a developer writing one has the directory there and has installed
nothing. the tree says what could run, the table says what does.

mount.ts joins the two and rebuilds. an install row whose directory has gone is
dropped from the snapshot rather than reported — but the row is left in the
database, because deleting it there would turn "somebody moved the checkout"
into silent data loss. a plugin whose router will not load stays unmounted and
says why, rather than taking the other nine down with it.

/api/plugins is owner-only in its own right, like /api/app-store, and its
capability guards the MANAGEMENT surface only — a plugin's own permissions come
from its manifest, so a member can hold one at read without being able to
install anything.

plugins/example is the reference implementation and is meant to be read: the
smallest thing that is still a real plugin, with the directory layout as its own
documentation.

not wired yet, and marked [open] in the router: the schema push and the
sidecar's pm2 entry. a plugin with db/schema.ts or sidecar/ needs both before it
works end to end.

full suite: 719 pass, same 10 pre-existing failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:22:18 +00:00
pastilhasandClaude Opus 5 0701aba902 brotli in the core package set
installed on this host already; verified round-tripping from a member shell.
same package name on apt, pacman and dnf. on brew it is there because macOS
ships the library but not the CLI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:56 +00:00
pastilhasandClaude Opus 5 2e6c263751 the hono app is built, not assembled once
first piece of the plugin system: the platform can now be rebuilt with a
different set of plugins mounted, at runtime, without restarting.

hono cannot do this the obvious way. its default SmartRouter throws "Can not add
a route since the matcher is already built" the moment a route is added after
serving begins, RegExpRouter does the same, and hono has no api to REMOVE a
route at all — so uninstall was impossible even with TrieRouter, which does
allow adding. tested all four.

so nothing is added to a live app. buildHonoApp(plugins) constructs a fresh one
and honoServer is reassigned, which keeps the default fast router and makes
uninstall expressible. server.tsx now serves it through a closure rather than
the bound honoServer.fetch — that one line is the whole mechanism, since the
bound method would capture whichever app existed at serve() and every rebuild
would silently do nothing.

buildHonoApp is pure: everything it needs arrives as an argument, so an app for
a hypothetical plugin set can be built without a database, a filesystem or a
running server.

alongside it, discovery. plugins live at platform/plugins/<app-name>/ — inside
the repo, because bun links the workspace packages into the root node_modules
and that is what lets a plugin author write `import { useClient } from
'hooks/useClient'` with no publishing and no version negotiation. verified with
Bun.resolveSync from a directory there.

discovery is by convention and presence is the declaration: api/router.ts,
db/schema.ts, sidecar/index.ts, web/Router.tsx. the app name comes from the
directory, so it cannot disagree with where the code sits, and the sidecar
runtime comes from the extension — .mjs is node, .ts is bun — which is already
the rule here and cannot contradict the file it describes.

a broken plugin is collected, never thrown: one unreadable manifest must not
stop the boot or hide the nine beside it that are fine.

verified by booting the refactored server on a spare port — /api answers 200,
protected routes still 401. full suite: 719 pass, and the same 10 failures as
before this change (8 in capabilities, plus cliamp and pty), stash-verified
earlier as pre-existing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:16:11 +00:00
pastilhasandClaude Opus 5 b2349b5480 install the psql client, matching the server it talks to
there was no psql on this machine. the server runs in a container, so nothing
ever put a client on the host, and `docker exec officer-postgres psql` is the
owner's tool — a member has their own Postgres role and no access to the owner's
Docker socket.

the version is derived from PG_IMAGE rather than typed again, because the
pairing is load-bearing: pg_dump refuses a server newer than itself, and Ubuntu
24.04 ships client 16 against this 18 server. so the archive package is not
merely old, it is unusable for dumps. that is also why this sits beside the
server definition instead of in machine-setup's package list — one constant, one
place to bump.

PGDG added the same way docker.sh adds Docker's: key in its own file, one
sources.list.d entry, no add-apt-repository. non-fatal, and the exit status is
not the gate — apt can succeed while holding an older client back, so the check
is that psql is present AND is the major we asked for.

installed by hand on this host already: psql/pg_dump 18.6, verified as green
connecting with their own role.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:13:09 +00:00
pastilhasandClaude Opus 5 0ae0a5dc58 music is where the richer permission model gets designed
offscale is deliberately the simple case — one shared resource, read or write.
music is the next extraction and the right place to build the in-plugin
visibility system, because it has real per-user data (favourites, playlists,
now-playing) on top of a real shared one (a single global library index). so
'whose is this row' has a non-uniform answer there, where offscale's is just
'the owner's'.

not designed yet and deliberately not designed here. recorded so the intent
survives the gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:06:55 +00:00
pastilhasandClaude Opus 5 acd51c969c the platform grants read or write; richer rules belong to the plugin
the platform's contract is what it already has: a role holds read or write on a
capability, stored in role_capabilities and enforced by the gate. anything
beyond — who sees whose rows, per-user isolation, record ownership, visibility
of any kind — is the plugin author's job, inside the plugin. the platform should
not grow machinery for it. a plugin knows what its data means; the platform only
knows whether this account got through the door.

offscale v1 uses that exactly. one shared resource: read sees what the owner
sees, write can change it including deleting a server the owner registered. that
is dangerous on purpose — the stored credential is a headscale admin key with no
read-only equivalent, so write is close to full control of the tailnet, and that
is the owner's call. expected use is read for most roles.

two consequences, both inside the plugin. the queries stop scoping by the caller
and resolve to the owner's id, leaving the per-user shape in the table unused as
the seam if isolation is ever wanted. and two POSTs are really reads —
/ssh-test probes and /policy/assist explicitly never saves — so they need
readOnlyWrites, or a read-level account finds a broken feature where a withheld
permission should be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:04:18 +00:00
pastilhasandClaude Opus 5 4c3682dae6 let a member read the two directories above their own home
`bun run` from anywhere inside a member's home died with

    error loading current directory
    error: An internal error occurred (CouldntReadCurrentDirectory)

before it looked at package.json, bun.lock or .git — all of which were present.

it is not walking up looking for a workspace root. it primes its resolver cache
by walking DOWN from / and opening every component of the cwd for READING:

    openat("/home/pastilhas/officerdev/")              = 6
    openat(".../officerdev/data/")                     = -1 EACCES
    openat(".../officerdev/data/<email>/")             = -1 EACCES

those two are 711 — traversable, not listable — which is enough to cd into a
home and not enough for a program that reads its ancestors. `getcwd` succeeds;
the ancestor read is what fails. `O_PATH` would need only `x`, so this is
arguably bun's bug, but it presents as a member's project being mysteriously
unbuildable and nothing here can fix it from the other side.

so DATA_PATH and the account dir now carry a named ACL entry per member. that
gives up the property the old comment named — a member can now `ls` DATA_PATH
and learn the other accounts' email addresses — and keeps everything that
matters: every home is still 700 and owned by its member, every platform
sibling still 700 and owned by the service user. verified as green: the account
list is visible, and email_accounts, another home, the repo .env, the owner's
ssh key, .pgpass and ~/.claude/.credentials.json are all still denied.

the mask is set explicitly to rx alongside the entry. chmod recomputes the mask
from the group bits, which for 711 is --x, so without that the next member's
provisioning would silently clamp every earlier member back to traverse-only.

applied by hand to the one existing account; provisioning covers new ones.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 20:00:29 +00:00
pastilhasandClaude Opus 5 56bb383c6d a plugin installs with no questions unless it says otherwise
offscale needs none of the install fields the catalogue carries — no modes, no
existingFields, no configFields, no composeTemplate, no members. nothing to
provision, nothing to point at. install is put the code there, push the schema,
start the sidecar, swap the routes, and it is available.

configuration happens afterwards inside the app, which is already how headscale
works: a server is registered at runtime and lands in offscale_servers.

so no-questions is the default rather than offscale's special case, and the
prompting machinery gets designed against the first extracted plugin that
actually needs docker or a remote instance. part of why this was the right
pilot — it exercises mounting, schema and sidecar without install being a
variable at the same time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:15 +00:00
pastilhasandClaude Opus 5 327783532e reach a member's transcripts by listing them, not just by reading them
yesterday's fix routed transcript CONTENT through the member's identity and
stopped there, on the strength of a comment in ChatIdentity saying enumeration
never needed it — "their directories are 775 and the platform holds an ACL
entry, so readdirSync and statSync have always worked".

there are no 775 directories on this path. claude creates ~/.claude/projects/
and every project group at mode 700, and a 700 directory clamps the ACL mask to
--- exactly as a 600 file does:

    user:officer:rwx    #effective:---
    mask::---

measured against a real member home:

    existsSync(projects)        -> true     (stat only needs traverse on .claude)
    readdirSync(projects)       -> EACCES
    existsSync(projects/<slug>) -> false
    statSync(<transcript>)      -> EACCES

existsSync answering false rather than throwing is why this was invisible: every
caller read it as "no such session". one root cause, three reported symptoms —
an empty conversation list, no title on a new chat, and a /chat/<id> deep link
that never restored the conversation. a fourth nobody had reported yet: delete
removed nothing and still answered ok, because unlink needs w+x on the group
directory too.

so enumeration goes through the same door as content, as ONE call rather than a
spawn per entry: listTranscriptsAs runs a single `find` as the member and
returns every transcript with its mtime, which readdir+stat could not do without
dozens of setpriv forks per request and a matching pile of auth.log lines. the
owner keeps a fork-free path — that process already IS the owner. removeAs does
the same for unlink, and readTailAs no longer stats a file it cannot stat.

summarizeTranscript now takes the mtime it is given instead of stat'ing again,
which is both the fix and one less syscall per file.

verified against jg@pertento.ai on this machine: 6 conversations listed with
titles from their first prompts, and a deep link by id alone loads 71 messages.
owner path re-checked and unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:48:06 +00:00
pastilhasandClaude Opus 5 9f903479ce websocket routes reload too, so nothing needs a restart
the last gap. six ws providers live in bun's route table rather than hono's, so
the app swap does not reach them — but server.reload({routes}) does, and in both
directions: refused before, connected after install, refused again after
uninstall, with core routes untouched throughout.

so a plugin can own a socket from the start, and no part of an install needs the
process restarted.

still untested: whether connections already open across a reload survive it.
that matters before an install is allowed to interrupt somebody's terminal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:46:07 +00:00
pastilhasandClaude Opus 5 6ab838c77f mounting at runtime after all: rebuild the app and swap it
this went round twice — runtime dynamic, then generated-plus-restart on the
belief that hono could not mount after serving, then back once that was actually
tested. the doc keeps the route rather than just the destination.

tested: SmartRouter (hono's default) and RegExpRouter both throw 'Can not add a
route since the matcher is already built'. TrieRouter and PatternRouter accept
it. so runtime adding is possible but costs the fast matcher, and hono has no
remove-route api at all, which uninstall needs.

what solves both is not adding routes but rebuilding: construct a fresh app from
the current plugin set and reassign the variable. the fetch closure reads it per
request, so the reassignment is the swap — atomic, no dropped connections, no
server.reload, and the default SmartRouter is kept. verified 404 before install,
200 after, 404 again after uninstall, with core routes unaffected throughout.

the mechanical cost is one line: server.tsx:322 is '/api/*': honoServer.fetch, a
bound method evaluated once at serve(), and has to become a closure or the swap
does nothing.

websockets stay open: six providers live in bun's route table rather than
hono's, so a plugin owning a socket needs server.reload({routes}), untested.
offscale has none.

and totality stops being a boot check — buildApp() is now the single place
routes are mounted, so it is where the assertion belongs, refusing the swap
rather than refusing the boot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:45:22 +00:00
pastilhasandClaude Opus 5 13437e0e48 mounting: generated then restart, reversing the call for runtime dynamic
this reverses the earlier decision for C (mount and unmount at runtime) and says
so rather than quietly overwriting it.

the requirement behind C was that the platform must not need to know a plugin in
advance. that is met either way: what it reads is a generated file listing the
installed routers, analogous to Plugins.tsx on the frontend — nothing hardcoded,
nothing read from a table at boot, the imports made concrete at install. C would
have bought only the absence of a restart.

and a restart is close to free here, because sidecars are pm2 peers rather than
children — a property that was fought for, since officer used to spawn the agent
and pm2's tree-kill took the owner's chat down on every restart. what a restart
costs is websockets, which reconnect, and in-memory session records, which
claude:list already recovers.

the happy consequence is that assertCapabilityTotality stays a boot check
instead of becoming a per-mount transaction. it does need to be fed the route
table rather than Object.keys(handlers) first — generated mounts widen that gap
rather than closing it, so that is a prerequisite and not a tidy-up beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:35:55 +00:00
pastilhasandClaude Opus 5 7befaf032a the manifest holds only what the tree cannot say
lean it to identity facts and human choices: publisher, version, platform range,
the four presentation fields, and permissions. everything structural becomes
convention, where presence is the declaration — sidecar/, api/router.ts,
db/schema.ts, web/Router.tsx, web/panels.ts. appName comes from the directory
name, so the id cannot disagree with where the code sits.

the dock tile and page title needed no fields at all: the tile is label + icon +
color + mountPrefix, and the title is label. writing them again was duplication
that could only drift.

runtime is the file extension. index.mjs is node, index.ts is bun — implicit,
but already the rule here, since officer-pty runs under node for node-pty's abi
and everything else is bun. better than a field that can contradict the file.

dependsOn is gone; nothing read it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:32:07 +00:00
pastilhasandClaude Opus 5 7ebc4d0ccd note the totality/route-table drift for later
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 1292a5c5ab opencode is owner-only until it carries an identity
a turn on the opencode harness ran as the owner, in the owner's home, whoever
asked. handleOpenCodeChat resolves its cwd against getOwnerHomeDir(email), which
discards the email it is given, and the sidecar runs one shared `opencode serve`
as the service user — sendOpenCodeStreaming accepts userId/email/username and
forwards none of them. it carried a comment calling itself owner-only; nothing
enforced it.

reachable by any account with the `chat` grant, which every role holds by
default (DEFAULT_ROLE_CAPABILITIES), and isClaudeModel is a startsWith, so a
typo'd model string landed there too. the model is client-supplied and never
checked against the catalogue.

the same gap on the read side: opencode's session store has no per-user scoping
at all, so loadOpenCodeSession/delete/rename take an id and no identity, and the
list and live routes returned other people's conversations.

so: ChatIdentity carries isOwner as its own fact (not inferred from
osUser === null, which holds only while resolveHomeDir refuses a member without
one), and every opencode door in chat.ts checks it — list, load, live, delete,
rename — plus a refusal on the execution path in handleChat. /chat/models hides
opencode from non-owners as a courtesy; the socket refuses regardless.

a stopgap, not a design. the fix is to thread identity through the opencode
sidecar the way spawnClaudeAsMember does, and TODO.md has been saying so.

not fixed here, and worth knowing: a member's session list is still empty and
/chat/pwds still 500s, because readdirSync on their ~/.claude/projects is EACCES
— claude creates it at mode 700, which zeroes the ACL mask. visible in
officer-error.log right now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:27:54 +00:00
pastilhasandClaude Opus 5 4dc7cd90c2 a plugin declares permissions, not capabilities, and has no kind
'capabilities' already means three things in this codebase — the permission
registry, the officer-items store, and the sidecar's routing keys. a fourth
would be one too many, and the field is really just permissions. the name is
free: the old permissions table went in 044aacf4.

and the kind enum is gone with it. the first draft handed a plugin the
platform's own five-value CapabilityKind and then forbade three of them. those
five exist because the platform has five sorts of surface; a plugin has two —
grantable to members, or owner-only. a boolean says it, and says it without
needing a prohibition: a plugin cannot claim core if core is not a word it can
say.

offscale is ownerOnly: true, which is what kind: 'admin' meant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:25:54 +00:00
pastilhasandClaude Opus 5 b18601530f a manifest for offscale, and the rule it immediately broke
written against the real plugin rather than invented as a field list, on the
theory that an abstract one includes what nothing needs and misses what is
awkward. that paid off on the first field that mattered.

the rule here said a plugin may declare `app` and nothing else. offscale's
capability is `admin` — owner only — and should stay that way, so the rule was
wrong. the distinction is direction, not privilege: `core` means every account
and not deniable, so claiming it grants yourself to everyone; `admin` means
owner only, which is a plugin restricting itself. corrected table in the doc.
core, execution and confined stay the platform's to assign.

`publisher` is the only input to the mount prefix, through one function, so
first-party and third-party cannot drift into two code paths.

sidecar.runtime is a field because officer-pty needs node for node-pty's abi
while everything else is bun — one plugin already needs it, so not speculative.

dependsOn is informational and unenforced. code dependencies need no declaration
now that a plugin builds inside the workspace, and service dependencies already
degrade; this exists so the store can say the console section wants the terminal
plugin, rather than the section silently doing nothing.

health is marked deferred rather than open, with the reasoning, so it does not
get re-raised. migrations likewise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 19:05:15 +00:00
pastilhasandClaude Opus 5 f6b2905cc7 how the frontend ships, and what plugins may depend on
everything moves to the plugin, frontend included, so federation stopped being
a later problem and had to be answered. it is answered by not needing it: bun
builds the spa into build/ at start and rebuilds it on install, serving from
that directory instead of compiling through the html import. Bun.build is a
runtime call, so an install needs no restart — just a refresh. same origin
throughout, which is why there is no cors work and no rewrite of useClient.

App.tsx keeps core routes and gains one map over `plugins`, each mounted at a
wildcard delegating to the plugin's own router. that list comes from a generated
Plugins.tsx, because a bundler cannot follow import(runtimeString) — the
specifier has to be concrete before the build. the six places the shell
currently hardcodes headscale collapse into that one file, dock included; the
runtime dockItemsFromPlugins path follows rather than competing with it.
presentation moves to build time, permission stays runtime.

dependencies turned out to be two different problems wearing one word. a service
dependency (assist → anthropic-proxy) is a wire call and already degrades. a
code dependency (ConsoleView → TerminalView) is in the bundle and cannot. rule:
may depend, must degrade. service calls go through the api carrying the user's
token, with the user's own permissions, which also deletes the state-file read
claude-proxy uses today to lift the proxy's secret.

no per-plugin permission list: a plugin is part of the app and bounded by the
account calling it. that makes marketplace review a security boundary rather
than a naming one, which is worth knowing rather than discovering.

and the developer environment is a platform checkout — clone it, run dev, build
the plugin inside. the 13 workspace packages resolve by name because bun links
them, so `import { useClient } from 'hooks/useClient'` just works with no
registry and no versioning. dev-time and build-time become the same mechanism.

also writes down the headscale inventory now that it has been read end to end,
including that assist.ts travels unwired as a marker and must not be tidied away
as dead code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 18:58:22 +00:00
pastilhasandClaude Opus 5 7f26f0b4b8 offscale is headscale plus the companion, not a rename
the name looks like branding on someone else's project, which is exactly how it
gets 'corrected' back later. it is not: offscale is the stock headscale server
plus the companion that ships beside it, and the invite flow is the first thing
that only exists there.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:44:28 +00:00
pastilhasandClaude Opus 5 01a20fff4e the invite flow replaced device enrolment; it is not a gap
closes the one open item left by deleting /api/vpn. removing the vpn capability
leaves no member-grantable headscale surface and that is correct: the owner
mints an invite from the headscale app, the companion turns it into the redirect
the phone claims, and the device joins. no per-member permission on officer is
involved at any step.

recorded as decided rather than open so nobody reintroduces a member-facing
enrolment route believing something was lost. nothing was — /api/vpn/enroll was
the design the invite flow replaced, and it never had a UI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:43:42 +00:00
pastilhasandClaude Opus 5 88a44ec4a7 delete /api/vpn
it had no caller. verified three ways before removing: nothing in the mobile
monorepo reaches it (enrollVpn's only call site is behind `if (embedded)`, and
the one app rendering VpnScreen never passes embedded), nothing in the officer
web app references it, and the live database holds no vpn grants. the companion
was checked separately by its own author — zero references there either.

and it will not come back. offscale is permanently standalone: the thing that
gets you to the platform cannot itself need the platform, or a broken tailnet
locks you out of both.

gone: api/vpn/router.ts, its mount, and the `vpn` capability. the registry keeps
a comment where the capability was, because its removal has a cost worth
recording — headscale is admin-only, so no member-grantable headscale surface
remains, and reintroducing one is a deliberate act rather than an oversight.

kept: the sidecar's enroll.ts. its bare POST /_officer/enroll handler is now
unreachable, but the file is also the dispatcher for /enroll/invites, which is
live and fundamental. the header comment now says so, so nobody deletes it
looking for dead code.

also records the third component in the doc. two of the three have an "enroll"
surface and only one is ours: /api/v1/enroll/* belongs to the companion, is
where the phone actually goes, and must not be collapsed into /api/offscale/*.

capabilities tests: 17 pass / 8 fail both before and after, stash-verified — the
8 are pre-existing, in totality and path-to-capability, which is precisely the
machinery dynamic mounting will rework.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:40:44 +00:00
pastilhasandClaude Opus 5 bbc60b34ac headscale leaves the baseline, and offscale gets a design doc
first step of extracting headscale into a plugin. CORE_PROCESSES is five now,
and the catalogue.test CORE[] mirror follows it — not optional, since that list
asserts "the catalogue must not offer a core process" and would have blocked
adding offscale to the catalogue later.

the local generated ecosystem file lost its entry too, and officer-headscale was
stopped and deleted from pm2 by hand. the platform still mounts /api/headscale
and still declares the headscale and vpn capabilities, so the feature is
present-but-unavailable rather than gone.

docs/offscale-plugin.md is a live document for the rest of it. what it records
that nothing else does: core is now `officer` alone and everything else is a
plugin; routes are /api/<app-name> for ours and /api/p/<creator>/<app-name> for
third parties, derived by one function so the two can never become two systems;
tables stay in public with an app-name prefix; mounting becomes genuinely
dynamic, which retires the "every route stays mounted" premise and relocates
assertCapabilityTotality from a boot check to a per-mount transaction.

it also records a rejected experiment with evidence — a postgres schema per
plugin works completely, including cross-schema FK, idempotent push and
DROP SCHEMA CASCADE as uninstall — and the reason not to: drizzle-kit 0.31.8
needs schemaFilter naming every schema, contradicting its own docs, and without
it push reports "No changes detected" and creates nothing. a plugin install that
reports success and makes no tables is the exact failure shape we have hit three
times this week.

and /api/vpn is dead: no caller in the mobile monorepo, none in the web app, no
grants in the database. offscale is permanently standalone, so it never comes
back. the invite flow is unaffected — the phone claims from the Companion, not
from officer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 17:37:55 +00:00
pastilhasandClaude Opus 5 d000cedf2f let anyone toggle hidden files again
the show/hide dotfiles button was dead for members. they open the browser at
their own home, that home is `/`, and the toggle is disabled at `/`.

it was never meant to apply to them. 74894b0c wrote it as

    user?.role === 'Super Admin' && currentPath === '/'

to keep the OWNER's home root readable — it is all .bashrc and .ssh and
.claude. then 044aacf4 removed the multi-user surface, dropped users.role, and
noted in its own message that "every role === 'Super Admin' check was
permanently true". so it folded the conjunct away and left `currentPath === '/'`.

correct on a single-user server. multi-user came back on 2026-08-07 and this
line did not come back with it, so a rule about one account's home quietly
became a rule about everyone's — and members feel it constantly, because
members are always at their root.

removed rather than restored to owner-only: the point was a tidy default, not a
prohibition, and `files/showHidden` already defaults to false. so dotfiles stay
hidden until asked for, everywhere, for everyone — and the asking now works.

the server never filtered dotfiles; readdir returns them and always did. this
was only ever the client.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 16:11:05 +00:00
pastilhasandClaude Opus 5 336e718463 read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.

nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.

confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:

    user:officer:rwx    #effective:---
    mask::---

claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.

no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.

so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.

the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.

also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.

verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:36 +00:00
pastilhasandClaude Opus 5 fe0012635a stop leaking the parent claude session into the one we spawn
pm2 inherits the environment of whoever ran pm2 start, so restarting this
sidecar from inside a claude code terminal — which is how it is restarted
most of the time — bakes that terminal's session into the daemon. right now
this process is carrying CLAUDE_CODE_MESSAGING_SOCKET for an unrelated pid
that has been alive for an hour and a half.

three of these were already stripped; the rest arrived with 2.x and were
never added. this is hygiene, not the fix for today's hang — a spawn was
verified to succeed with the whole set present — but a child attaching to a
stranger's ipc socket is not a failure anyone would recognise from the
symptom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:32 +00:00
pastilhasandClaude Opus 5 547662842b a dead claude process no longer hangs the chat forever
the sdk runs two independent tasks per session: the consumer loop
(`for await (const msg of q)`) and an input pump that writes the queue to
the child's stdin. the consumer loop's `finally` is what removes a session
from the map — but when the CHILD dies it is the input pump that fails,
with `ProcessTransport is not ready for writing`, and that rejection
neither ends the consumer loop nor is caught anywhere.

so the loop stayed parked on a stream with no writer, `finally` never ran,
the session stayed in the map, and spawnClaudeStreaming handed every later
turn to the same corpse. each one pushed a message onto a queue nobody
drained: no error, no result, no timeout. the client spun forever and the
only trace was one unhandledRejection line in the sidecar log.

observed on the host today; the only cure was pm2 restart
officer-claude-code.

a member's turn already supplied its own spawn function because it has to
go through setpriv. the owner had none, and therefore no place to observe
the child — which is exactly why its death was invisible. so give the owner
one too, and wrap both in watchChild: on exit or error, drop the session
from the map and, if a turn was in flight, tell the client.

emitting only while generating is deliberate. a child that exits between
turns is invisible to the user, and an error bubble arriving in a chat
nobody is looking at would be noise — dropping the map entry is the whole
repair there, because the next turn builds a fresh session and resumes the
transcript by id.

the stall timer now tears the session down as well. it used to keep it —
"it may still be working, and the next turn resumes it" — which is right
for a slow agent and wrong for a wedged one: the session stayed broken, so
every later turn hung the same way and "send again to continue" was a lie.
sessions with background tasks outstanding are still left alone, since a
job can be silent far longer than ten minutes and still land its
notification.

verified live against the real manager: killed the child mid-turn, saw the
error surface and the next turn rebuild the session.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:36:09 +00:00
335 changed files with 8812 additions and 6136 deletions
+30
View File
@@ -63,3 +63,33 @@ scripts/setup/officer-setup/.setup-progress
# the repository has no ecosystem file at all any more, and the next machine # the repository has no ecosystem file at all any more, and the next machine
# generates its own. See scripts/setup/officer-setup/lib/services.sh. # generates its own. See scripts/setup/officer-setup/lib/services.sh.
ecosystem.config.cjs ecosystem.config.cjs
# The built SPA and the generated plugin module — both describe THIS install's plugin set and are
# rewritten on every install. See servers/plugins/generate.ts.
build/
build.next/
src/apps/officer-web/Plugins.gen.tsx
src/databases/officer_db/src/plugin-schemas.gen.ts
# drizzle-kit's generated migrations. Nothing applies them — there is no __drizzle_migrations table and
# `drizzle-kit migrate` has never been run here; `bun db:push` diffs the schema code against the live
# database and alters it directly. The schema code is the source of truth (src/databases/CLAUDE.md).
#
# Ignored rather than merely unused, because `bun db:gen` reads src/schema.ts — whose last line imports
# plugin-schemas.gen.ts, itself generated from the plugin DIRECTORIES on this machine. So a generated
# migration describes whichever plugins happen to be checked out here, and committing one would launder
# per-machine state into the repository: run it with music installed and history gains music_*; run it on
# a fresh clone and the next commit deletes them again.
#
# The old 0000_new_princess_powerful.sql is deleted from the WORKING TREE but left in history. It had 36
# tables and described a schema from before capabilities, api_keys, the app store and the plugin system
# existed, including three (task_logs, terminal_containers, queue_jobs) that no longer exist at all.
#
# Purging it from history was tried on 2026-08-15 and deliberately undone. This codebase cites 76 commit
# SHAs in comments and docs as evidence — `totality.ts` points at 2873948 for the websocket incident,
# `registry.ts` at 044aacf4, CLAUDE.md at f35c145 — and a filter-repo run rewrites every one of them.
# Three stale files nobody reads in old commits are not worth 76 dangling citations in a codebase whose
# documentation works by pointing at the commit that proves the claim.
#
# Generate one locally whenever a diff is useful to read. It stays local.
src/databases/officer_db/migrations/
+7 -7
View File
@@ -12,22 +12,22 @@ look for `CLAUDE.md`, without the two drifting apart.
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
per-role capability grants, resolved on every request. per-role permission grants, resolved on every request.
If a design question turns on "which user", the answer depends on the surface: real for the **app** If a design question turns on "which user", the answer depends on the surface: real for the **app**
capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything permissions (gitea, music, photos, email, calendar…), and still always **the owner** for anything
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority. `kind: 'execution'` and can never be granted. `src/servers/permissions/registry.ts` is the authority.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities" **Mounting a router without a registry entry makes the server refuse to boot.** Read the "Permissions"
section of `CLAUDE.md` before adding one. section of `CLAUDE.md` before adding one.
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
management". That was written to correct an *older* drift in the opposite direction — a fictional management". That was written to correct an _older_ drift in the opposite direction — a fictional
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
paragraph above; treat the capability registry as the source of truth over either. paragraph above; treat the permission registry as the source of truth over either.
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as This repo is one of two. The other, `permissions/`, holds the agent's tasks, tools and skills as
plain files, and is where most changes belong — adding or changing a task needs no code change here plain files, and is where most changes belong — adding or changing a task needs no code change here
and no restart. and no restart.
+16 -15
View File
@@ -14,14 +14,14 @@ written: `users` holds six rows. The accurate statement is narrower and more use
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is - **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check. empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`; - **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile. meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are - **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of `kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one. "read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are - **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem `kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the *owner's*, because the account has its own Linux user, its own and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it. home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux The distinction earns its keep in one place: **a confined grant means nothing without that Linux
@@ -34,8 +34,8 @@ So "which user is this" has a real answer for the **app** surface (gitea, music,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`. anything under `execution`.
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this. `src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities" **Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one. below before adding one.
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`, **Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
@@ -84,7 +84,7 @@ src/
│ └── landing/ # marketing landing page │ └── landing/ # marketing landing page
├── servers/ ├── servers/
│ ├── hono.ts # router composition; everything under /api │ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting │ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router │ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn │ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine │ ├── queue/ # background job engine
@@ -106,13 +106,14 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check) - **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22` failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`. first. The source build also needs `build-essential` and `python3`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way. - **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components - **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono - **Backend**: Hono
@@ -135,7 +136,7 @@ per-account email SQLite stores — those are the **email sidecar's**, and nothi
them. them.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install **None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
@@ -172,30 +173,30 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
what came out was documented defence in depth that was already switched off. Origin was never what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the capability gate below. is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`). - JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from **The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in. Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every - A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts. authenticated request until the server restarts.
### Capabilities — read this before mounting a router ### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/capability-gate.ts``capabilities/authorize.ts`, mounted globally in `hono.ts` valid"). It is `_middlewares/permission-gate.ts``permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`. `userMiddleware`.
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds: - `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but `core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13. never grantable at any level). 27 entries as of 2026-08-13.
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every - `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them, other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`. and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract **Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`. is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and - `permissions/totality.ts``assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails, throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's `GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
@@ -206,7 +207,7 @@ So **adding a router means adding one line to `CAPABILITIES`**. If the surface g
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time. is how the hole happened the first time.
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking 403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal. into a refusal.
+17 -10
View File
@@ -6,13 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string - Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
(required for the SSE endpoints — `EventSource` can't set headers). (required for the SSE endpoints — `EventSource` can't set headers).
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and - **Owner-only.** These routes belong to the `server-admin` permission, which is `kind: 'admin'` and
therefore never grantable — a non-owner account gets `403` here whatever its role. The full therefore never grantable — a non-owner account gets `403` here whatever its role. The full
**officer-mobile** client (which authenticates as the owner) has access; the music app does not. **officer-mobile** client (which authenticates as the owner) has access; the music app does not.
- Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were - Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role
capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is permission grants. The _outcome_ for these routes is unchanged — still owner-only — but the reason is
now the capability's kind, not a two-element array. now the permission's kind, not a two-element array.
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`. - All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
--- ---
@@ -20,7 +20,7 @@ Everything the `/system-monitor` web screen renders, for building the same in th
## `GET /api/system-monitor/stats` ## `GET /api/system-monitor/stats`
One full snapshot. Poll it on a steady interval (the web client uses **2 s**) — a few fields are rates One full snapshot. Poll it on a steady interval (the web client uses **2 s**) — a few fields are rates
computed from the delta since your *previous* call (see notes), so a steady cadence matters. computed from the delta since your _previous_ call (see notes), so a steady cadence matters.
```jsonc ```jsonc
{ {
@@ -65,6 +65,7 @@ computed from the delta since your *previous* call (see notes), so a steady cade
``` ```
**Notes** **Notes**
- `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first** - `net.*BytesPerSec` and `power.cpuWatts` are **deltas since the previous `/stats` call**. The **first**
call returns `0`/`null` for these; steady-interval polling gives stable numbers. call returns `0`/`null` for these; steady-interval polling gives stable numbers.
- `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works. - `cpuWatts` is usually `null` — RAPL `energy_uj` is root-only unless a udev rule opens it. `gpuWatts` works.
@@ -77,16 +78,18 @@ computed from the delta since your *previous* call (see notes), so a steady cade
```jsonc ```jsonc
{ {
"processes": [ "processes": [
{ "id": 0, // pm2 id (pm_id) — use this for the logs endpoint {
"id": 0, // pm2 id (pm_id) — use this for the logs endpoint
"name": "officer", "name": "officer",
"status": "online", // online | stopped | errored | … "status": "online", // online | stopped | errored | …
"pid": 3339851, // OS pid, or null "pid": 3339851, // OS pid, or null
"cpuPct": 0, "cpuPct": 0,
"memBytes": 10354688, "memBytes": 10354688,
"restarts": 44, "restarts": 44,
"uptimeMs": 420000 } // 0 unless status === "online" "uptimeMs": 420000,
}, // 0 unless status === "online"
], ],
"error": "…" // present only if pm2 couldn't be read "error": "…", // present only if pm2 couldn't be read
} }
``` ```
@@ -95,14 +98,16 @@ computed from the delta since your *previous* call (see notes), so a steady cade
```jsonc ```jsonc
{ {
"containers": [ "containers": [
{ "id": "abc123def456", // short id (12 chars) — use for the logs endpoint {
"id": "abc123def456", // short id (12 chars) — use for the logs endpoint
"name": "jellyfin", "name": "jellyfin",
"image": "jellyfin/jellyfin", "image": "jellyfin/jellyfin",
"state": "running", // running | exited | … "state": "running", // running | exited | …
"status": "Up 3 hours", "status": "Up 3 hours",
"ports": "0.0.0.0:9301->8096/tcp" } "ports": "0.0.0.0:9301->8096/tcp",
},
], ],
"error": "…" "error": "…",
} }
``` ```
@@ -114,12 +119,14 @@ Both stream one **`data: <log line>`** frame per line, plus `: hb` heartbeat com
server kills the underlying tail when the connection closes. Open with `EventSource` using `?token=`. server kills the underlying tail when the connection closes. Open with `EventSource` using `?token=`.
### `GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>` ### `GET /api/system-monitor/pm2/logs?id=<pm_id>&lines=<n>`
- `id`**numeric** pm2 id from `/pm2` (required). - `id`**numeric** pm2 id from `/pm2` (required).
- `lines` — initial backlog, default `100`, max `1000`. - `lines` — initial backlog, default `100`, max `1000`.
- Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short - Source: `pm2 logs <id> --raw` (combined stdout+stderr, follows live). The first frames include a short
pm2 `[TAILING] …` header. pm2 `[TAILING] …` header.
### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>` ### `GET /api/system-monitor/docker/logs?id=<container>&lines=<n>`
- `id` — container id or name from `/docker` (charset-validated). - `id` — container id or name from `/docker` (charset-validated).
- `lines` — initial backlog (`--tail`), default `100`, max `1000`. - `lines` — initial backlog (`--tail`), default `100`, max `1000`.
- Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr). - Source: `docker logs -f --tail <n> <id>` (combined stdout+stderr).
+16 -6
View File
@@ -4,12 +4,12 @@ Deferred work.
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from **Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The capability permission model indirection as accidental complexity. **That direction was reversed.** The permission permission model
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
below that proposed deleting it have been removed rather than left to mislead the next reader. below that proposed deleting it have been removed rather than left to mislead the next reader.
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
the owner's OS user and can never be granted. Indirection there really is accidental complexity. the owner's OS user and can never be granted. Indirection there really is accidental complexity.
## Multi-user ## Multi-user
@@ -75,24 +75,34 @@ the owner's OS user and can never be granted. Indirection there really is accide
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
`docs/workspace-panel-todo.md` §3. `docs/workspace-panel-todo.md` §3.
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure - [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
registry functions and the totality check; the resolver that does the owner bypass, the grant registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell. standing between a Member and a shell.
- [ ] **`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since
2026-08-13.** It is fed `Object.keys(handlers)` from `server.tsx`, but Bun serves the _route table_.
Those diverged when the cliamp/desktop/vault plugins were switched off: `/api/cliamp/ws` and
`/api/cliamp/audio/ws` are still live routes with their handlers and registry claims commented out.
Not exploitable — `isWsProviderAllowed` finds no permission and 403s a member; the owner upgrades onto
a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point
totality at the route table, and either delete the dead routes or restore their claims. The 8 failing
tests in `registry.test.ts` are the same drift — `REAL_WS` still lists all nine providers as served,
which is why nobody noticed. Found 2026-08-14.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a - [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal. broken panel or an endless spinner rather than a clean refusal.
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here — - [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
every caller resolves to the owner's real login home. Safe only because all seven callers sit every caller resolves to the owner's real login home. Safe only because all seven callers sit
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the behind `execution` permissions. If per-user home confinement is ever attempted, this is the
function to start from. function to start from.
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates - [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its `X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take `/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted. thing to fix first if any of them is ever granted.
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav - [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
@@ -110,7 +120,7 @@ the owner's OS user and can never be granted. Indirection there really is accide
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session). - [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the `DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request `notify` permission could deregister another's device; and `POST /_officer/notify` let a request
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body. APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
+10 -1
View File
@@ -22,4 +22,13 @@ env = "BUN_PUBLIC_*"
coverage = true coverage = true
coverageDir = "coverage" coverageDir = "coverage"
preload = ["./test-setup.ts"] preload = ["./test-setup.ts"]
root = "./src" # The repo, not just `src` — a plugin's tests are the platform's tests.
#
# This was "./src" until 2026-08-15, when music became `plugins/music/` and took `lyrics.test.ts` with
# it. `bun test` then stopped running it and said nothing: the count fell by nine and the suite still
# read green-ish. A test that quietly stops running is worse than one that fails, and every future
# extraction would have taken its tests out of the suite the same way.
#
# Positional filters do not help — `bun test plugins` matches paths UNDER root, so it finds
# `src/servers/plugins/` and not `plugins/`. Root is the only lever.
root = "."
+108 -108
View File
@@ -6,7 +6,7 @@ a human authoring the workflow at the top. Written live during the conversation
owner's own words; where a section records a decision, that decision is his, not a proposal. owner's own words; where a section records a decision, that decision is his, not a proposal.
**Read this before ranking, deferring or starting any workspace/panel item.** It is the thing every **Read this before ranking, deferring or starting any workspace/panel item.** It is the thing every
other workspace/panel document is ranked *against*: other workspace/panel document is ranked _against_:
- `docs/workspace-panels.md` — how the framework works today (descriptive, no opinions) - `docs/workspace-panels.md` — how the framework works today (descriptive, no opinions)
- `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity - `docs/workspace-panel-todo.md` — the work queue, currently ordered by defect severity
@@ -26,7 +26,7 @@ Everything that follows is about **`/chat`** and **`/dashboards`**. Verified aga
`src/apps/officer-web/App.tsx`: `src/apps/officer-web/App.tsx`:
| route | element | line | | route | element | line |
|---|---|---| | ---------------------------------------------------------------------- | ---------------------------- | ----- |
| `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 4246 | | `/chat`, `/chat/new`, `/chat/new/g/*`, `/chat/g/*`, `/chat/:sessionId` | `Dashboard.SessionListPage` | 4246 |
| `/dashboards` | `Dashboard.DashboardsScreen` | 82 | | `/dashboards` | `Dashboard.DashboardsScreen` | 82 |
| `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 | | `/dashboards/:id` | `Dashboard.DashboardScreen` | 83 |
@@ -58,15 +58,15 @@ keeping it would distort the design, favour Claude and note the assumption here.
### 1.3 The dashboards scenario — the live example ### 1.3 The dashboards scenario — the live example
The owner's chosen illustration is **what he is doing at this moment**: running *two Claude agents in The owner's chosen illustration is **what he is doing at this moment**: running _two Claude agents in
parallel, in two different chat windows, both working on the platform.* parallel, in two different chat windows, both working on the platform._
**Stated as fact by the owner** (not inferred): **Stated as fact by the owner** (not inferred):
- Two agents, two chat windows, same platform, at the same time. - Two agents, two chat windows, same platform, at the same time.
- This is precisely why the standing "never restart the server yourself" rule exists: a - This is precisely why the standing "never restart the server yourself" rule exists: a
`pm2 restart officer` is a **shared, destructive-ish event** across every agent working on the `pm2 restart officer` is a **shared, destructive-ish event** across every agent working on the
platform, so it must be *timed* by the owner rather than triggered by whichever agent happens to platform, so it must be _timed_ by the owner rather than triggered by whichever agent happens to
finish first. finish first.
**Observed by me during this same session**, as corroborating detail — the frictions this arrangement **Observed by me during this same session**, as corroborating detail — the frictions this arrangement
@@ -74,11 +74,11 @@ actually produces:
1. **The owner is the scheduler.** Each agent independently reaches a point where it needs a restart and 1. **The owner is the scheduler.** Each agent independently reaches a point where it needs a restart and
asks. Nothing in the system knows another agent exists, so the owner is the only thing that can asks. Nothing in the system knows another agent exists, so the owner is the only thing that can
serialise it. (He also had to tell me, separately, to stop *repeating* the request once made.) serialise it. (He also had to tell me, separately, to stop _repeating_ the request once made.)
2. **The owner is the message bus.** Neither agent can see the other's work, so anything one needs to 2. **The owner is the message bus.** Neither agent can see the other's work, so anything one needs to
know about the other has to be relayed by hand. know about the other has to be relayed by hand.
3. **Shared tree, shared `master`.** Two agents, one working copy. This produced the session's sharpest 3. **Shared tree, shared `master`.** Two agents, one working copy. This produced the session's sharpest
instruction — *"The problem is committing each other's work. Like, that can't happen, man."* — and instruction — _"The problem is committing each other's work. Like, that can't happen, man."_ — and
the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths the mitigation is purely behavioural: each agent must be told, separately, to stage explicit paths
and never `git add -A`. Nothing enforces it. and never `git add -A`. Nothing enforces it.
4. **Uncertain ownership of a failure.** I hit a real typecheck error (`CodeBlock.tsx:138`) and could not 4. **Uncertain ownership of a failure.** I hit a real typecheck error (`CodeBlock.tsx:138`) and could not
@@ -86,7 +86,7 @@ actually produces:
**Unconfirmed inference — to be confirmed or corrected by the owner before it is treated as the **Unconfirmed inference — to be confirmed or corrected by the owner before it is treated as the
objective:** that the dashboards half of the holy grail is a surface where these parallel agent sessions objective:** that the dashboards half of the holy grail is a surface where these parallel agent sessions
are *visible together and manageable together* — one screen, multiple live agents as panels, with the are _visible together and manageable together_ — one screen, multiple live agents as panels, with the
state they contend over (restarts, the git tree, who is touching what) legible — so the human stops state they contend over (restarts, the git tree, who is touching what) legible — so the human stops
being both the scheduler and the message bus between them. being both the scheduler and the message bus between them.
@@ -96,7 +96,7 @@ being both the scheduler and the message bus between them.
> path I want, to continue or start a new session from a specific path. Each chat panel gets attributed > path I want, to continue or start a new session from a specific path. Each chat panel gets attributed
> some kind of persistent ID related to that dashboard. > some kind of persistent ID related to that dashboard.
And the behaviour that PoC is *for*: And the behaviour that PoC is _for_:
> I can let you both work, and at the end of your turn you ask the other agent "can I restart?", wait for > I can let you both work, and at the end of your turn you ask the other agent "can I restart?", wait for
> his output, restart yourself. And the same from the other side — the other agent, when he finishes his > his output, restart yourself. And the same from the other side — the other agent, when he finishes his
@@ -105,10 +105,10 @@ And the behaviour that PoC is *for*:
> If we get this to work, the sky is the limit. > If we get this to work, the sky is the limit.
Decomposed into the five capabilities it actually requires: Decomposed into the five permissions it actually requires:
| # | capability | exists today? | | # | permission | exists today? |
|---|---|---| | --- | --------------------------------------------------------------------------------- | ------------- |
| P1 | Two chat panels in one dashboard, each an **independent** session | **No** | | P1 | Two chat panels in one dashboard, each an **independent** session | **No** |
| P2 | Each panel pointed at **its own path** (cwd) | **No** | | P2 | Each panel pointed at **its own path** (cwd) | **No** |
| P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly | | P3 | A **persistent id** per chat panel, scoped to the dashboard, that survives reload | Partly |
@@ -121,7 +121,7 @@ Read from source on 2026-08-07, not assumed:
**P1 — the blocker.** `ChatPanelWrapper` (`apps/Chat/ChatPanelWrapper.tsx:45`) is declared **P1 — the blocker.** `ChatPanelWrapper` (`apps/Chat/ChatPanelWrapper.tsx:45`) is declared
`() => {…}`**it takes no props at all, not even `panelId`.** Everything it uses comes from `() => {…}`**it takes no props at all, not even `panelId`.** Everything it uses comes from
`useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are *per screen*. Two `useWorkspace()`: `dashboardId`, `cwd`, `root`, `promptPrefix` — all of which are _per screen_. Two
chat panels dropped into one dashboard today are therefore **byte-for-byte identical**: same cwd, same chat panels dropped into one dashboard today are therefore **byte-for-byte identical**: same cwd, same
context, same session-resolution path. There is no per-panel anything. It also calls context, same session-resolution path. There is no per-panel anything. It also calls
`useChat(undefined, undefined, …)`, so no session id is passed in — a panel cannot be told which session `useChat(undefined, undefined, …)`, so no session id is passed in — a panel cannot be told which session
@@ -132,12 +132,12 @@ through `WorkspaceContext`. `scoped = cwd !== '~'`. Every panel on a screen nece
**P3 — the good news, with one sharp edge.** Panel ids (`layout-utils.ts:4`, **P3 — the good news, with one sharp edge.** Panel ids (`layout-utils.ts:4`,
`` uid = () => `p-${Date.now()}-${++counter}` ``) are generated once and **persisted inside the layout `` uid = () => `p-${Date.now()}-${++counter}` ``) are generated once and **persisted inside the layout
`jsonb`**, so a panel id *is* already stable across reloads. That makes panel id a viable durable key — `jsonb`**, so a panel id _is_ already stable across reloads. That makes panel id a viable durable key —
which is the single most load-bearing fact for this PoC. The edge: `movePanel` mints a **new** id which is the single most load-bearing fact for this PoC. The edge: `movePanel` mints a **new** id
(`layout-utils.ts:192`, `:207`) rather than carrying the old one, so dragging a panel would silently (`layout-utils.ts:192`, `:207`) rather than carrying the old one, so dragging a panel would silently
sever its session binding. That is gap **G2** in the analysis, and it is now on the critical path. sever its session binding. That is gap **G2** in the analysis, and it is now on the critical path.
Also already half-built, and worth knowing: for a *user* dashboard the wrapper already derives Also already half-built, and worth knowing: for a _user_ dashboard the wrapper already derives
`{ context: 'dashboard', contextId: dashboardId }` (`ChatPanelWrapper.tsx:49-55`) — a notion of `{ context: 'dashboard', contextId: dashboardId }` (`ChatPanelWrapper.tsx:49-55`) — a notion of
dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly dashboard-scoped chat context exists. It is keyed to the **dashboard**, not the panel, which is exactly
one level too coarse for this. one level too coarse for this.
@@ -160,7 +160,7 @@ PoC has the shape it has.**
A lot of work landed today and over the last few days: **a session now survives a server restart with no A lot of work landed today and over the last few days: **a session now survives a server restart with no
refresh and no user action.** One case remains broken, and the owner has **decided not to solve it**: refresh and no user action.** One case remains broken, and the owner has **decided not to solve it**:
> *unless the agent is currently outputting — the restart of the server interrupts that output.* > _unless the agent is currently outputting — the restart of the server interrupts that output._
This reframes the PoC entirely. **The by-turn handshake is not merely coordination; it is a deliberate This reframes the PoC entirely. **The by-turn handshake is not merely coordination; it is a deliberate
route around the one failure mode that is not going to be fixed.** Restarts are made safe by route around the one failure mode that is not going to be fixed.** Restarts are made safe by
@@ -190,7 +190,7 @@ signal the direction is right, since it falls out of the PoC at no extra cost.
### 1.7 The actual objective — the software factory ### 1.7 The actual objective — the software factory
**The restart problem is not the goal, and is barely even a problem.** It exists only because the owner **The restart problem is not the goal, and is barely even a problem.** It exists only because the owner
is currently using the platform to fix the live platform, for velocity. It is a *dogfooding artifact*. is currently using the platform to fix the live platform, for velocity. It is a _dogfooding artifact_.
It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it It has been chosen as the proof of concept because it is small, real, and falsifiable — not because it
is the target. is the target.
@@ -204,7 +204,7 @@ The target:
So the north star is: **several specialised agents, working concurrently on one codebase, coordinating So the north star is: **several specialised agents, working concurrently on one codebase, coordinating
with each other rather than through the human, with quality gates between them and the mainline.** with each other rather than through the human, with quality gates between them and the mainline.**
The dashboards surface is how a human *watches and steers* that factory. The chat panels are the The dashboards surface is how a human _watches and steers_ that factory. The chat panels are the
workers. The restart handshake is the first, smallest instance of the general primitive: agents workers. The restart handshake is the first, smallest instance of the general primitive: agents
negotiating a shared resource without a human in the middle. negotiating a shared resource without a human in the middle.
@@ -226,7 +226,7 @@ plainly and early — the cost of a late correction here is much higher than the
risk profile, and it is recorded here because it is the strongest single argument in the whole risk profile, and it is recorded here because it is the strongest single argument in the whole
conversation. conversation.
**Constraint, binding:** *there will always be a human orchestrator* — the owner, or whoever later runs **Constraint, binding:** _there will always be a human orchestrator_ — the owner, or whoever later runs
the platform. **The goal is explicitly not agents ping-ponging inputs and outputs with no structure.** the platform. **The goal is explicitly not agents ping-ponging inputs and outputs with no structure.**
Any design that removes the human from the top of the loop is wrong, not ambitious. Any design that removes the human from the top of the loop is wrong, not ambitious.
@@ -240,14 +240,14 @@ computers. The owner's worked example, verbatim in substance:
well-documented, and the documentation keeps being updated with new learnings. well-documented, and the documentation keeps being updated with new learnings.
2. The owner **shifts focus entirely** to other work — mobile monorepo, platform architecture — for one 2. The owner **shifts focus entirely** to other work — mobile monorepo, platform architecture — for one
to two hours, without having to hold Soulseek in his head. to two hours, without having to hold Soulseek in his head.
3. The platform agent reports: *"Soulseek is up, give it a try, here is how to test it."* 3. The platform agent reports: _"Soulseek is up, give it a try, here is how to test it."_
4. The owner restarts, enters credentials, confirms it works, and the agent pushes. 4. The owner restarts, enters credentials, confirms it works, and the agent pushes.
5. The owner pulls on the MacBook and tells the **mobile agent** — which already knows the mobile 5. The owner pulls on the MacBook and tells the **mobile agent** — which already knows the mobile
infrastructure — "create me a Soulseek app based on everything the platform has today." It works. infrastructure — "create me a Soulseek app based on everything the platform has today." It works.
**So the pattern is proven by human execution.** What is being automated is not "can agents collaborate" **So the pattern is proven by human execution.** What is being automated is not "can agents collaborate"
— it is the *bridging role*, which the owner currently performs and describes as: *stressful, a lot to — it is the _bridging role_, which the owner currently performs and describes as: _stressful, a lot to
keep in my head*, though enjoyable and exciting. keep in my head_, though enjoyable and exciting.
### 1.10 The midnight scenario — the shape of the target ### 1.10 The midnight scenario — the shape of the target
@@ -257,8 +257,8 @@ keep in my head*, though enjoyable and exciting.
> "this is not according to spec", to mobile "maybe change this" — and in the end be **responsible for > "this is not according to spec", to mobile "maybe change this" — and in the end be **responsible for
> the joining of everything, which is currently the work that I'm doing.** > the joining of everything, which is currently the work that I'm doing.**
The owner's own framing: *a holy grail by its nature doesn't exist — but I really think we can get The owner's own framing: _a holy grail by its nature doesn't exist — but I really think we can get
there.* there._
Structural requirements this adds, beyond the two-panel PoC: Structural requirements this adds, beyond the two-panel PoC:
@@ -267,36 +267,36 @@ Structural requirements this adds, beyond the two-panel PoC:
- **Panels are aware of each other** — an agent must be able to enumerate its peers. - **Panels are aware of each other** — an agent must be able to enumerate its peers.
- **Panels span repositories** — platform and `monorepo-mobile` are different repos with different - **Panels span repositories** — platform and `monorepo-mobile` are different repos with different
remotes. remotes.
- **The fourth role is different in kind from the first three.** Roles 13 are *do the work*, and are - **The fourth role is different in kind from the first three.** Roles 13 are _do the work_, and are
already proven by the manual flow. Role 4 is *hold the whole picture and judge* — the role the owner already proven by the manual flow. Role 4 is _hold the whole picture and judge_ — the role the owner
performs today with human judgement. See §5 for why this is flagged as the research risk rather than performs today with human judgement. See §5 for why this is flagged as the research risk rather than
an engineering task. an engineering task.
### 1.11 Do not design for the examples — the owner's counterpoints ### 1.11 Do not design for the examples — the owner's counterpoints
Recorded because every one of these is a correction of *my* over-constraining, and the same mistake will Recorded because every one of these is a correction of _my_ over-constraining, and the same mistake will
be easy to repeat later. be easy to repeat later.
- **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only - **The Soulseek flow is one example, not the specification.** Other workflows will exist; some need only
two agents. *"This coordination is the point I want to ultimately reach."* two agents. _"This coordination is the point I want to ultimately reach."_
- **Roles are malleable.** Not every run involves four agents, and not with those roles. Fixing - **Roles are malleable.** Not every run involves four agents, and not with those roles. Fixing
"front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner "front end / backend / mobile / reviewer" into the design would be inventing a constraint the owner
does not have. does not have.
- **There is no paradigm.** *"It's whatever we want it to be."* - **There is no paradigm.** _"It's whatever we want it to be."_
- **The owner's current needs are not the end state.** He has a day job unrelated to mobile that would - **The owner's current needs are not the end state.** He has a day job unrelated to mobile that would
benefit from the same coordination. Designing narrowly around platform+mobile development is a trap. benefit from the same coordination. Designing narrowly around platform+mobile development is a trap.
- **Cross-machine is NOT the hard problem, and I was wrong to raise it as a fork.** The owner has already - **Cross-machine is NOT the hard problem, and I was wrong to raise it as a fork.** The owner has already
solved it at small scale: a second Claude on the MacBook with a 15-minute timer pulling the latest solved it at small scale: a second Claude on the MacBook with a 15-minute timer pulling the latest
platform changes and replicating them for the mobile apps. Git hooks or cron do the same. platform changes and replicating them for the mobile apps. Git hooks or cron do the same.
*"That's the least painful point of all this."* _"That's the least painful point of all this."_
**The painful point, in the owner's words:** *panel communication inside a single web page, or a single **The painful point, in the owner's words:** _panel communication inside a single web page, or a single
workspace, on our platform Web UI.* That is the problem to solve. Everything else is downstream. workspace, on our platform Web UI._ That is the problem to solve. Everything else is downstream.
This yields a natural two-tier split, which the design should respect rather than unify: This yields a natural two-tier split, which the design should respect rather than unify:
| tier | mechanism | status | | tier | mechanism | status |
|---|---|---| | --------------------------- | ---------------------------------------------------- | -------------------------------------- |
| Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** | | Agents in **one workspace** | direct, in-page, turn-boundary messaging | **the hard part — this is the work** |
| Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem | | Agents across **machines** | the git repo itself, polled on a timer / hook / cron | already solved, cheap, not our problem |
@@ -311,8 +311,8 @@ Compare it to the restart handshake:
> I finished my output, you can restart the server, and tell me when you're done so I can continue. > I finished my output, you can restart the server, and tell me when you're done so I can continue.
**These are the same protocol with a different payload.** Both are: *declare turn-end → hand off → **These are the same protocol with a different payload.** Both are: _declare turn-end → hand off →
await the peer's completion → resume.* The restart PoC is therefore not a toy standing in for the real await the peer's completion → resume._ The restart PoC is therefore not a toy standing in for the real
thing; it is the real protocol, exercised on the smallest possible payload. thing; it is the real protocol, exercised on the smallest possible payload.
The design consequence: **build the primitive general and keep the roles as configuration.** A named, The design consequence: **build the primitive general and keep the roles as configuration.** A named,
@@ -348,30 +348,30 @@ What he expects instead:
- The **workflow graph lives in the prompts**, authored by the human at dashboard setup. - The **workflow graph lives in the prompts**, authored by the human at dashboard setup.
- The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages - The system's entire job is: give each agent a **stable, addressable identity**, and **deliver messages
between them at turn boundaries**. That is it. between them at turn boundaries**. That is it.
- The failure mode this avoids is the one that kills most multi-agent systems: agents deciding *what* to - The failure mode this avoids is the one that kills most multi-agent systems: agents deciding _what_ to
do and *who* should do it. Here, the human decides both, up front, once. do and _who_ should do it. Here, the human decides both, up front, once.
**One consequence worth stating** (observation, not a decision taken): if roles are prompts, then **One consequence worth stating** (observation, not a decision taken): if roles are prompts, then
*addressing* must still resolve. "Pass that work to the front end developer" needs a destination. The _addressing_ must still resolve. "Pass that work to the front end developer" needs a destination. The
consistent answer is that the **human names each panel at setup** and tells each agent the names of its consistent answer is that the **human names each panel at setup** and tells each agent the names of its
peers — so addressing is a string the human chose, and the system merely routes it. A system-maintained peers — so addressing is a string the human chose, and the system merely routes it. A system-maintained
roster of roles would re-import the paradigm through the back door. roster of roles would re-import the paradigm through the back door.
**Also note:** *not long lived* lowers the persistence bar for a dashboard's workflow configuration — **Also note:** _not long lived_ lowers the persistence bar for a dashboard's workflow configuration —
but **not** for panel identity, which must still survive a reload for the whole PoC to work (§1.5, P3). but **not** for panel identity, which must still survive a reload for the whole PoC to work (§1.5, P3).
### 1.14 The charter — and what is explicitly *not* mine ### 1.14 The charter — and what is explicitly _not_ mine
**Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be **Owner's ruling on the shared-working-tree challenge (my push-back #2). Accepted, not to be
re-litigated.** re-litigated.**
- It has been working in practice: three agents at a time on the platform, and *the way the platform was - It has been working in practice: three agents at a time on the platform, and _the way the platform was
modularised means they don't step on each other's toes ~90% of the time.* Nothing is or will be modularised means they don't step on each other's toes ~90% of the time._ Nothing is or will be
perfect. perfect.
- Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional - Worktrees, branches, everything-on-master: **not the focus.** The owner has ~20 years professional
experience, has never used a git worktree, and is willing to adopt one when it becomes necessary. experience, has never used a git worktree, and is willing to adopt one when it becomes necessary.
- Explicit division of labour, verbatim: *"that's my problem as a software engineer, as an architect, to - Explicit division of labour, verbatim: _"that's my problem as a software engineer, as an architect, to
solve."* solve."_
So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so So git isolation is **owner-owned, deliberately deferred, and not a work item here.** It is recorded so
it is not lost, not so it gets picked up. Raising it once was welcomed; raising it again is noise. it is not lost, not so it gets picked up. Raising it once was welcomed; raising it again is noise.
@@ -379,7 +379,7 @@ it is not lost, not so it gets picked up. Raising it once was welcomed; raising
**Important clarification from the owner — this is not a narrowing of the push-back instruction (§1.8):** **Important clarification from the owner — this is not a narrowing of the push-back instruction (§1.8):**
> Don't take what I said as a restriction on you to push back on things that you think might come up that > Don't take what I said as a restriction on you to push back on things that you think might come up that
> are maybe not directly related with your particular mission. I just want you to understand that I *do* > are maybe not directly related with your particular mission. I just want you to understand that I _do_
> know what I'm doing — I've been through all those things my whole career. Those stones in my shoe are > know what I'm doing — I've been through all those things my whole career. Those stones in my shoe are
> mine to bear, not yours. > mine to bear, not yours.
@@ -395,12 +395,12 @@ settled by the ruling; the right to raise the next one is not affected.
And the expectations around it: And the expectations around it:
- **Learn to walk first.** The owner does not expect that the night after this works he creates a - **Learn to walk first.** The owner does not expect that the night after this works he creates a
dashboard with four windows and builds a project. The *practice* of using it will be perfected over dashboard with four windows and builds a project. The _practice_ of using it will be perfected over
time, separately from the mechanism. time, separately from the mechanism.
- **This mission will take some time to reach an initial state.** It is not a quick change. - **This mission will take some time to reach an initial state.** It is not a quick change.
**The problems the owner explicitly wants thought about and documented** — these are the real design **The problems the owner explicitly wants thought about and documented** — these are the real design
work, and they come *after* the basics are proven: work, and they come _after_ the basics are proven:
1. Does the system survive a **server restart**? 1. Does the system survive a **server restart**?
2. Does it survive a **page refresh**? 2. Does it survive a **page refresh**?
@@ -413,7 +413,7 @@ Survey of the chat/agent commits since 2026-08-01, read from diffs and source. *
further along than assumed.** Load-bearing findings: further along than assumed.** Load-bearing findings:
**The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called **The injection channel already exists.** `sidecar.spawnClaudeStreaming({sessionKey, prompt, …})` called
on an *existing* `sessionKey` does **not** spawn anything — it pushes a user message onto the live input on an _existing_ `sessionKey` does **not** spawn anything — it pushes a user message onto the live input
queue (`claude-manager.ts:380-391` → `pushTurn` → `input.push`). **No browser involved.** Existing queue (`claude-manager.ts:380-391` → `pushTurn` → `input.push`). **No browser involved.** Existing
callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent callers: `websocket.ts:354`, `agent-runner.ts:187`, `pipeline-executor.ts:238`. This is how one agent
delivers a message to another. delivers a message to another.
@@ -421,7 +421,7 @@ delivers a message to another.
**The turn-boundary signal already exists.** `result` is the explicit terminal event **The turn-boundary signal already exists.** `result` is the explicit terminal event
(`chat/types.ts:143-153`, emitted `stream-parser.ts:144`), and `onTurnComplete(hadToolCalls)` is already (`chat/types.ts:143-153`, emitted `stream-parser.ts:144`), and `onTurnComplete(hadToolCalls)` is already
a public option on `useChat` (`useChat.ts:28`, fired at `:286-314`). Terminal set is a public option on `useChat` (`useChat.ts:28`, fired at `:286-314`). Terminal set is
**`result` | `error` | `stopped` | `cut-off`**. Note: the *session outlives the turn* — `task:started` / **`result` | `error` | `stopped` | `cut-off`**. Note: the _session outlives the turn_ — `task:started` /
`task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle". `task:notification` arrive **after** `result`, so "turn ended" ≠ "agent idle".
**Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor, **Durable, cursor-addressed log:** `chat_session_events` — global monotonic `bigserial` cursor,
@@ -429,20 +429,20 @@ per-session index, `prevSeq` continuity chain, at-least-once replay from a clien
**Caveat: 7-day retention** (`api/chat/retention.ts`) — a replay buffer, not an archive. **Caveat: 7-day retention** (`api/chat/retention.ts`) — a replay buffer, not an archive.
**Peer-restart notification:** `onClaudeSidecarStarted` (`sidecar-registry.ts:69-91`) — keyed off the **Peer-restart notification:** `onClaudeSidecarStarted` (`sidecar-registry.ts:69-91`) — keyed off the
agent *registering*, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by agent _registering_, not disconnecting. **Liveness oracle:** `claude:is-generating`, answered only by
the process that owns the session, **failing toward alive**. the process that owns the session, **failing toward alive**.
**Principles this codebase has already paid for — adopt, don't re-derive:** **Principles this codebase has already paid for — adopt, don't re-derive:**
1. *Never route the durability guarantee over the link expected to break.* The agent writes to 1. _Never route the durability guarantee over the link expected to break._ The agent writes to
`chat_session_events` itself, then notifies; officer relays. Write durable, then notify. `chat_session_events` itself, then notifies; officer relays. Write durable, then notify.
2. *Infer liveness from the birth of the new process, not the death of the socket* — socket death fires 2. _Infer liveness from the birth of the new process, not the death of the socket_ — socket death fires
on the innocent case (`pm2 restart officer`). on the innocent case (`pm2 restart officer`).
3. *An availability check must fail toward the less-alarming answer.* 3. _An availability check must fail toward the less-alarming answer._
4. *Classify by recoverability, not severity* — `cut-off` (seam + Retry) is a different object from 4. _Classify by recoverability, not severity_ — `cut-off` (seam + Retry) is a different object from
`error` (red bubble). `error` (red bubble).
5. *An id that never crosses the process boundary is not an address* (`47d03de`). 5. _An id that never crosses the process boundary is not an address_ (`47d03de`).
6. *Disambiguate at the only site holding the extra bit*, and set the flag **before** the await that can 6. _Disambiguate at the only site holding the extra bit_, and set the flag **before** the await that can
race it. race it.
**⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key **⚠ Flagged for the chat owner — NOT mine to fix (§1.14 rule).** `sessionKey` (officer's uuid, the key
@@ -454,7 +454,7 @@ path for agent-to-agent messaging.** To be written up in `COMMS/` and handed off
### 1.16 The real optimisation target: unattended continuity, not parallelism ### 1.16 The real optimisation target: unattended continuity, not parallelism
**Correcting a wrong assumption of mine.** The owner does *not* want three or four agents running flat **Correcting a wrong assumption of mine.** The owner does _not_ want three or four agents running flat
out at once: out at once:
> I don't expect to have three or four agents running at the same time like crazy. **What I want is to be > I don't expect to have three or four agents running at the same time like crazy. **What I want is to be
@@ -470,12 +470,12 @@ are large:
between then and morning. between then and morning.
**And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the **And the consequence that dominates the architecture — flagged for the owner to confirm (§5):** if the
owner is *asleep*, **the dashboard page is closed.** A handoff must therefore work with **no browser owner is _asleep_, **the dashboard page is closed.** A handoff must therefore work with **no browser
open**. That rules out every browser-resident mechanism — `usePanelChannel`, React state, anything in the open**. That rules out every browser-resident mechanism — `usePanelChannel`, React state, anything in the
document — not on elegance grounds but because the document will not exist when the message is sent. document — not on elegance grounds but because the document will not exist when the message is sent.
This does not contradict the owner's framing of the problem as *"panel communication inside a single web This does not contradict the owner's framing of the problem as _"panel communication inside a single web
page"*; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how page"_; it refines it. **The panels are the view; the mechanism must live server-side.** The page is how
a human watches and steers a conversation that continues without it — which is also precisely what a human watches and steers a conversation that continues without it — which is also precisely what
§1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer, §1.15 shows the chat system was rebuilt to support (durable event log, cursor replay, agent-as-writer,
session outliving the socket). session outliving the socket).
@@ -485,7 +485,7 @@ against the running system, deliberately looking for the break. Method and raw e
`COMMS/handoff-durability-2026-08-07.md`. `COMMS/handoff-durability-2026-08-07.md`.
| what was done to it mid-handoff | turn completed | narration durable | | what was done to it mid-handoff | turn completed | narration durable |
|---|---|---| | -------------------------------------- | -------------- | ------------------------------------------------- |
| nothing (control) | ✅ | ✅ | | nothing (control) | ✅ | ✅ |
| `pm2 restart officer` | ✅ | ✅ | | `pm2 restart officer` | ✅ | ✅ |
| **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** | | **`officer` stopped for 25 s** | ✅ | ✅ **6 events written while the server was down** |
@@ -498,11 +498,11 @@ Three things follow, and they change how the outstanding work should be read:
- **The "no browser open" requirement is satisfied, and so is the harder one.** Not one of these runs had - **The "no browser open" requirement is satisfied, and so is the harder one.** Not one of these runs had
a page open, and the middle row is the proof that officer is genuinely off the delivery path: the agent a page open, and the middle row is the proof that officer is genuinely off the delivery path: the agent
sidecar committed the model's own words to Postgres during a 25-second server outage. sidecar committed the model's own words to Postgres during a 25-second server outage.
- **A restart costs a *turn*, not an *agent*.** After being killed mid-turn, the receiver resumed the - **A restart costs a _turn_, not an _agent_.** After being killed mid-turn, the receiver resumed the
identical Claude session on the next handoff and volunteered which work had been lost. Continuity — identical Claude session on the next handoff and volunteered which work had been lost. Continuity —
`sessionKey` minted once, write-through map on disk — does the job it was built for. That makes the `sessionKey` minted once, write-through map on disk — does the job it was built for. That makes the
outstanding claude **stage 5** a smaller problem than its position on the list suggests. outstanding claude **stage 5** a smaller problem than its position on the list suggests.
- **The remaining hole is on the *sending* side.** A handoff POSTed while officer is down is refused and - **The remaining hole is on the _sending_ side.** A handoff POSTed while officer is down is refused and
dropped, and nothing in the introduction text tells the agent to retry — so the sender can believe it dropped, and nothing in the introduction text tells the agent to retry — so the sender can believe it
handed off when it did not. That, not the receiving side, is where store-and-forward would earn its handed off when it did not. That, not the receiving side, is where store-and-forward would earn its
keep. keep.
@@ -510,7 +510,7 @@ Three things follow, and they change how the outstanding work should be read:
One topology fact found while setting this up, worth stating here because it is the practical limit on One topology fact found while setting this up, worth stating here because it is the practical limit on
working unattended: **`officer-agent` is `sidecar/claude/user-instance.ts`, and every `claude` process on working unattended: **`officer-agent` is `sidecar/claude/user-instance.ts`, and every `claude` process on
the machine is its direct child.** `pm2 restart officer-agent` therefore kills every agent on every the machine is its direct child.** `pm2 restart officer-agent` therefore kills every agent on every
dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting *officer* is safe — it is, and that is dashboard at once, mid-turn. `CLAUDE.md` reassures that restarting _officer_ is safe — it is, and that is
verified above — but is silent on this one. verified above — but is silent on this one.
### 1.17 The restart payload is temporary — the protocol is not ### 1.17 The restart payload is temporary — the protocol is not
@@ -518,17 +518,17 @@ verified above — but is silent on this one.
> The restart thing is giving me pain right now. Pretty soon that won't be a problem, because I won't > The restart thing is giving me pain right now. Pretty soon that won't be a problem, because I won't
> have the necessity of editing the platform in real time from the platform as I'm doing today. > have the necessity of editing the platform in real time from the platform as I'm doing today.
Further confirmation that the PoC is **scaffolding**: the *payload* is disposable, the *protocol* is the Further confirmation that the PoC is **scaffolding**: the _payload_ is disposable, the _protocol_ is the
deliverable. Reinforces §1.12 — build message passing, not a restart-negotiation feature. If the restart deliverable. Reinforces §1.12 — build message passing, not a restart-negotiation feature. If the restart
case disappeared tomorrow, nothing built should need to be deleted. case disappeared tomorrow, nothing built should need to be deleted.
### 1.18 Ruling on push-back #3 (agents reviewing agents) ### 1.18 Ruling on push-back #3 (agents reviewing agents)
Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — *that is Same ruling as §1.14: **not our problem, not related to the mission.** The owner's framing — _that is
assuming the owner is dumb, which is important sometimes, but not for this mission.* assuming the owner is dumb, which is important sometimes, but not for this mission._
Correct, and worth stating why so the boundary is understood rather than merely obeyed: **the quality of Correct, and worth stating why so the boundary is understood rather than merely obeyed: **the quality of
an agent's review is a *usage* concern, downstream of the mechanism.** Whether the reviewer is any good an agent's review is a _usage_ concern, downstream of the mechanism.** Whether the reviewer is any good
is a property of the prompt the human wrote, not of the transport. The mechanism is the postal service; is a property of the prompt the human wrote, not of the transport. The mechanism is the postal service;
it is not accountable for what is in the envelopes. it is not accountable for what is in the envelopes.
@@ -542,15 +542,15 @@ Derived from §1. These are the constraints the design must satisfy.
1. **A stable, addressable identity per chat panel**, persisted, surviving reload. Panel id is the 1. **A stable, addressable identity per chat panel**, persisted, surviving reload. Panel id is the
natural key (§1.5, P3) — subject to the `movePanel` hazard. natural key (§1.5, P3) — subject to the `movePanel` hazard.
2. **Message passing between named sessions at turn boundaries.** Messages carry *content* (a handoff of 2. **Message passing between named sessions at turn boundaries.** Messages carry _content_ (a handoff of
work), not just signals (§1.12, and the owner's escalation: work handoff is *the whole crux*). work), not just signals (§1.12, and the owner's escalation: work handoff is _the whole crux_).
3. **Loud failure** when a message is dropped, a peer does not exist, or a handoff never lands (§1.8 as 3. **Loud failure** when a message is dropped, a peer does not exist, or a handoff never lands (§1.8 as
refined; already a value in this codebase — `9eb8fa1`). refined; already a value in this codebase — `9eb8fa1`).
**Do not build:** **Do not build:**
- No role registry, orchestration engine, planner, or task allocator (§1.13). - No role registry, orchestration engine, planner, or task allocator (§1.13).
- No restart-negotiation feature — restart is a *payload* (§1.17). - No restart-negotiation feature — restart is a _payload_ (§1.17).
- No git, repo, branch, build, or test awareness. **No knowledge of software at all** (§1.11 + the - No git, repo, branch, build, or test awareness. **No knowledge of software at all** (§1.11 + the
domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is domain-agnosticism constraint): the mechanism must be as ignorant of the work as a postal service is
of what is in the envelope. of what is in the envelope.
@@ -561,7 +561,7 @@ Derived from §1. These are the constraints the design must satisfy.
- **Turn-boundary only.** The safety property comes from negotiation, not robustness (§1.6). - **Turn-boundary only.** The safety property comes from negotiation, not robustness (§1.6).
- **Must work with no browser open.** The owner's goal is to sleep; the page will be closed. The - **Must work with no browser open.** The owner's goal is to sleep; the page will be closed. The
mechanism is server-side; panels are the view (§1.16). *Pending owner confirmation — see §5.* mechanism is server-side; panels are the view (§1.16). _Pending owner confirmation — see §5._
- **N-way from day one.** Parallelism is not the current target but must not be foreclosed — no "the - **N-way from day one.** Parallelism is not the current target but must not be foreclosed — no "the
other agent" singular anywhere, no single-writer ordering assumptions (§1.16 correction). other agent" singular anywhere, no single-writer ordering assumptions (§1.16 correction).
- **Durability over latency.** Seconds or minutes between handoffs is fine; a lost 03:00 handoff is not. - **Durability over latency.** Seconds or minutes between handoffs is fine; a lost 03:00 handoff is not.
@@ -578,35 +578,35 @@ primitive is the right one.
- **Automatic handoff of cross-domain findings** — e.g. this document's own §1.15 chat defect, which the - **Automatic handoff of cross-domain findings** — e.g. this document's own §1.15 chat defect, which the
owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same owner must currently carry by hand to the chat agent (§1.14). The rule and the mission are the same
shape. shape.
- **The software factory** (§1.7) — several specialised agents with quality gates, as *usage* built on - **The software factory** (§1.7) — several specialised agents with quality gates, as _usage_ built on
the primitive rather than as features of it. the primitive rather than as features of it.
- **Non-software domains entirely**, and other users with unrelated goals. - **Non-software domains entirely**, and other users with unrelated goals.
- **True parallelism**, later — *"the literal definition of heaven on earth."* - **True parallelism**, later — _"the literal definition of heaven on earth."_
## 4. Constraints and rules laid down ## 4. Constraints and rules laid down
*(ground rules stated by the owner for this body of work, verbatim in substance)* _(ground rules stated by the owner for this body of work, verbatim in substance)_
- Nothing is started — including trivial fixes — until the picture is complete and played back to the - Nothing is started — including trivial fixes — until the picture is complete and played back to the
owner, and the owner has confirmed it is correct. owner, and the owner has confirmed it is correct.
- This document is kept live *during* the conversation, not written up afterwards. - This document is kept live _during_ the conversation, not written up afterwards.
- Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here - Re-ranking the existing todo waits until the conversation is finished, and is then reflected both here
and in the documents that already exist. and in the documents that already exist.
### 4.1 Explicitly de-scoped — not wrong, just not now ### 4.1 Explicitly de-scoped — not wrong, just not now
Stated by the owner before the objective itself, and it is a *priority* judgement, not a correctness Stated by the owner before the objective itself, and it is a _priority_ judgement, not a correctness
one. These are acknowledged as poor architecture and are nonetheless **not to be worked on**: one. These are acknowledged as poor architecture and are nonetheless **not to be worked on**:
- **Everything downstream of the file browser at `/files`** — the ephemeral-panel machinery - **Everything downstream of the file browser at `/files`** — the ephemeral-panel machinery
(`ephemeral` prop, `useFileViewerPanels`, the search-param-driven viewer/player/side-chat that opens (`ephemeral` prop, `useFileViewerPanels`, the search-param-driven viewer/player/side-chat that opens
beside the browser without entering your saved layout). The owner's words: *horrible architecture*, beside the browser without entering your saved layout). The owner's words: _horrible architecture_,
and *everything is working as much as I need it*. and _everything is working as much as I need it_.
- The query-string-driven sub-panel approach generally. - The query-string-driven sub-panel approach generally.
The rule that follows: **do not open these as work items, and do not let a fix wander into them.** If The rule that follows: **do not open these as work items, and do not let a fix wander into them.** If
one of them is genuinely blocking the objective, that is a finding to raise with the owner — not a one of them is genuinely blocking the objective, that is a finding to raise with the owner — not a
licence to start. Some of them will likely improve *inadvertently*, as a side effect of work done for licence to start. Some of them will likely improve _inadvertently_, as a side effect of work done for
the objective, and that is the expected and acceptable way for them to get better. the objective, and that is the expected and acceptable way for them to get better.
This section is a live list. Anything else the owner de-scopes gets added here rather than being This section is a live list. Anything else the owner de-scopes gets added here rather than being
@@ -635,7 +635,7 @@ The chain, verified:
calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has calls `killClaudeSession` after 30 idle minutes, sparing only a session that is generating or has
pending tasks. pending tasks.
- **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes - **But `killClaudeSession` (`:411-427`) does not clear the resume pointer.** It aborts the query, closes
the input queue and drops the in-memory entry — and deliberately does *not* call the input queue and drops the in-memory entry — and deliberately does _not_ call
`clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect `clearClaudeSession`. That is a separate function (`clearSession`, `:430`) on the explicit-disconnect
path. path.
- The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at - The `sessionKey → claudeSessionId` map is **write-through to disk** (`state.ts:86-102`, at
@@ -658,17 +658,17 @@ it is better than a heartbeat on every axis:
Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible Design consequence: **a panel is a pointer to a transcript, not a held resource.** The smallest possible
durable object. Delivery is "resume that transcript and push a turn." durable object. Delivery is "resume that transcript and push a turn."
⚠ **The one hazard to respect:** the explicit `disconnect` path *does* call `clearClaudeSession`, which ⚠ **The one hazard to respect:** the explicit `disconnect` path _does_ call `clearClaudeSession`, which
destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and destroys the resume pointer and orphans the transcript. Coordination must never ride that path, and
whatever closes a panel must not trigger it. whatever closes a panel must not trigger it.
**Q3 — RESOLVED by the owner, 2026-08-07: *"Yes, we can do that. I name them all."*** The human assigns **Q3 — RESOLVED by the owner, 2026-08-07: _"Yes, we can do that. I name them all."_** The human assigns
each panel a name at setup; the system routes a string the human chose and knows nothing about its each panel a name at setup; the system routes a string the human chose and knows nothing about its
meaning. **The name is the address; the panel id is merely where it currently lives** — which also meaning. **The name is the address; the panel id is merely where it currently lives** — which also
disarms the `movePanel` hazard (§1.5, P3), since dragging changes position, not identity. disarms the `movePanel` hazard (§1.5, P3), since dragging changes position, not identity.
**Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to *how a panel **Q6 — RESOLVED, and downgraded from blocker to report-only.** The owner's answer to _how a panel
acquires its Claude session id*: acquires its Claude session id_:
> We can wait for the first conversation with a certain agent to start and get the first output, so we > We can wait for the first conversation with a certain agent to start and get the first output, so we
> get the session id from Claude and add it to our session key. Or basically we **fire up each session > get the session id from Claude and add it to our session key. Or basically we **fire up each session
@@ -677,7 +677,7 @@ acquires its Claude session id*:
> dashboard creation or session creation. > dashboard creation or session creation.
**Adopt the second.** It is strictly better, because it collapses two problems into one act: the role **Adopt the second.** It is strictly better, because it collapses two problems into one act: the role
prompt the human must write anyway *is* the message that brings the session into existence. Consequences: prompt the human must write anyway _is_ the message that brings the session into existence. Consequences:
- The **address book is fully populated at dashboard-creation time** — no lazy state, no "panel exists - The **address book is fully populated at dashboard-creation time** — no lazy state, no "panel exists
but has no session yet" hole, no first-handoff race. but has no session yet" hole, no first-handoff race.
@@ -698,7 +698,7 @@ the rule — but it **does not block this work.**
> agent will be instructed to write at the end of its work, having in mind to whom that prompt is going > agent will be instructed to write at the end of its work, having in mind to whom that prompt is going
> to be delivered. **For proof of concept it could just be a dot character.** > to be delivered. **For proof of concept it could just be a dot character.**
So: the sending agent *composes* the message; the system carries it and does not parse it. Same rule as So: the sending agent _composes_ the message; the system carries it and does not parse it. Same rule as
roles — semantics in the prose, mechanism dumb. **PoC success criterion collapses to: did a turn land in roles — semantics in the prose, mechanism dumb. **PoC success criterion collapses to: did a turn land in
the other panel.** A single `.` is a sufficient payload to prove the mechanism. the other panel.** A single `.` is a sufficient payload to prove the mechanism.
@@ -720,31 +720,31 @@ Owner, 2026-08-07:
> sequence that worked from start to finish, what were the prompts passed from one to another. > sequence that worked from start to finish, what were the prompts passed from one to another.
> **But this is something for version 2.** > **But this is something for version 2.**
Shape: one row per *dashboard run*, holding the roster of Claude session ids and an ordered list of Shape: one row per _dashboard run_, holding the roster of Claude session ids and an ordered list of
handoffs (from, to, prompt, timestamp). Deliberately **not** an output log — Claude's own transcripts and handoffs (from, to, prompt, timestamp). Deliberately **not** an output log — Claude's own transcripts and
`chat_session_events` already hold the content, and duplicating them is the mistake to avoid. `chat_session_events` already hold the content, and duplicating them is the mistake to avoid.
**Do not build this in v1.** But do not preclude it either: v1 must emit enough that the ledger is purely **Do not build this in v1.** But do not preclude it either: v1 must emit enough that the ledger is purely
*additive* later. _additive_ later.
### 5.2 The distinction that keeps v1 small: address book vs ledger ### 5.2 The distinction that keeps v1 small: address book vs ledger
These are two different things and conflating them would inflate v1 into v2: These are two different things and conflating them would inflate v1 into v2:
| | what it is | when | | | what it is | when |
|---|---|---| | ---------------- | --------------------------------------------------------------------------- | --------------------------------------------------- |
| **Address book** | the durable mapping *panel → session*, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. | | **Address book** | the durable mapping _panel → session_, so a message can be delivered at all | **v1 — required.** Without it there is no delivery. |
| **Ledger** | the durable *history* of who handed what to whom | **v2 — deferred** (§5.1). | | **Ledger** | the durable _history_ of who handed what to whom | **v2 — deferred** (§5.1). |
v1 needs the address book and nothing more. Provenance recorded in v1 should be the minimum that makes a v1 needs the address book and nothing more. Provenance recorded in v1 should be the minimum that makes a
handoff *visible and its failure loud* (§2), not a history feature. handoff _visible and its failure loud_ (§2), not a history feature.
## 6. How the found defects map onto the path ## 6. How the found defects map onto the path
*(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against _(the re-rank. Written 2026-08-07 after the MVP was built and proven running, so it is ranked against
what the mechanism turned out to need, not against what it was predicted to need. `workspace-panel-todo.md` what the mechanism turned out to need, not against what it was predicted to need. `workspace-panel-todo.md`
is ordered by defect severity; this section says which of those defects the **objective** actually cares is ordered by defect severity; this section says which of those defects the **objective** actually cares
about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)* about. Where the two disagree, this section wins for prioritisation and the todo keeps the severity note.)_
### 6.1 The headline: most of the panel defect list is not on this path ### 6.1 The headline: most of the panel defect list is not on this path
@@ -754,7 +754,7 @@ Postgres and on the sidecar's disk. So a panel can remount, re-render, lose its
across the dashboard, or not be rendered at all — and the work continues. Whole sections of the todo that across the dashboard, or not be rendered at all — and the work continues. Whole sections of the todo that
rank high on severity rank near-zero here. rank high on severity rank near-zero here.
The corollary, and it is the useful half: the defects that *do* matter are almost all the same defect The corollary, and it is the useful half: the defects that _do_ matter are almost all the same defect
wearing four hats — **a write that silently does not persist.** Panel identity is the one piece of wearing four hats — **a write that silently does not persist.** Panel identity is the one piece of
coordination state that lives in the layout jsonb rather than in a table of its own, so every silent coordination state that lives in the layout jsonb rather than in a table of its own, so every silent
persistence failure in this list is now a path by which a panel forgets which agent it is. persistence failure in this list is now a path by which a panel forgets which agent it is.
@@ -762,15 +762,15 @@ persistence failure in this list is now a path by which a panel forgets which ag
### 6.2 Tier A — on the critical path ### 6.2 Tier A — on the critical path
**A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`, **A1. Stop swallowing persist failures.** (§1, third item — `state/src/useDashboardState.ts:46`,
`.catch(() => {})`.) *The single highest-value item in the whole list against this objective.* The `.catch(() => {})`.) _The single highest-value item in the whole list against this objective._ The
panel's agent name is written through this path. A swallowed 500 leaves the optimistic cache correct, so panel's agent name is written through this path. A swallowed 500 leaves the optimistic cache correct, so
the panel shows its name, answers to its name, and **forgets it on the next reload** — the failure is the panel shows its name, answers to its name, and **forgets it on the next reload** — the failure is
invisible for exactly as long as the human is not looking, which is the entire window this project invisible for exactly as long as the human is not looking, which is the entire window this project
exists to serve. §2 requires *loud failure*; this is the loudest silence in the codebase. exists to serve. §2 requires _loud failure_; this is the loudest silence in the codebase.
**A2. The PATCH dispatcher's missing `else`.** (§2, first item.) The server half of A1. `ws-layout-*` is **A2. The PATCH dispatcher's missing `else`.** (§2, first item.) The server half of A1. `ws-layout-*` is
matched today so panel `config` does persist — verified, the demo dashboard round-tripped with matched today so panel `config` does persist — verified, the demo dashboard round-tripped with
`config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the *next* key `config: {agentName: …}` intact — but a chain of `if (…) continue` with no fallback means the _next_ key
family added for coordination is a silent no-op that returns 200. Add the 400. family added for coordination is a silent no-op that returns 200. Add the 400.
**A3. Validate the layout on read, and fix the `'[]'` default.** (§4, items 2 and 3.) Panel `config` is **A3. Validate the layout on read, and fix the `'[]'` default.** (§4, items 2 and 3.) Panel `config` is
@@ -799,7 +799,7 @@ functions over a serialisable tree; there is no excuse.
**A7. Two windows must not disagree about the roster.** (§5.5, "the cache is never invalidated" — **A7. Two windows must not disagree about the roster.** (§5.5, "the cache is never invalidated" —
`staleTime: Infinity`, no `invalidateQueries` anywhere, and every PATCH already returns a fresh state `staleTime: Infinity`, no `invalidateQueries` anywhere, and every PATCH already returns a fresh state
blob the client throws away.) Q1 makes the dashboard *a window onto server-side work*. Two windows onto blob the client throws away.) Q1 makes the dashboard _a window onto server-side work_. Two windows onto
the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap: the same work that permanently diverge, and neither told, is a direct contradiction of that. Cheap:
consume the response that is already being computed. consume the response that is already being computed.
@@ -815,22 +815,22 @@ rather than tidy. Not Tier A only because it is stable today and the failure req
the derivation. the derivation.
**B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was **B2. Panel lifecycle — but the ranking inverts.** (§5.1.) Against the terminal-orphan objective this was
"the highest-value change here." Against *this* objective the priority is the opposite one: **closing a "the highest-value change here." Against _this_ objective the priority is the opposite one: **closing a
chat panel must never destroy the agent.** §5 Q2 records the hazard precisely — the explicit `disconnect` chat panel must never destroy the agent.** §5 Q2 records the hazard precisely — the explicit `disconnect`
path calls `clearClaudeSession`, which destroys the `sessionKey → claudeSessionId` pointer and orphans the path calls `clearClaudeSession`, which destroys the `sessionKey → claudeSessionId` pointer and orphans the
transcript, whereas idle reaping deliberately does not. So what is wanted from `onClose` here is a transcript, whereas idle reaping deliberately does not. So what is wanted from `onClose` here is a
*guarantee that nothing rides that path*, not an eager cleanup hook. Build the hook for the terminal by _guarantee that nothing rides that path_, not an eager cleanup hook. Build the hook for the terminal by
all means; do not let a chat panel be wired into it without deciding that question first. A panel is a all means; do not let a chat panel be wired into it without deciding that question first. A panel is a
pointer, and closing a window should not delete what it points at. pointer, and closing a window should not delete what it points at.
**B3. `normalizeLayout` as framework, not convention.** (§5.4.) Matters for one consequence: a panel **B3. `normalizeLayout` as framework, not convention.** (§5.4.) Matters for one consequence: a panel
whose appType is allow-listed but no longer in the registry renders, on a `locked` screen, as an whose appType is allow-listed but no longer in the registry renders, on a `locked` screen, as an
unrecoverable empty box. A chat panel in that state is a *visible* agent the human cannot reach — though unrecoverable empty box. A chat panel in that state is a _visible_ agent the human cannot reach — though
note its peers still can, because the mechanism does not go through the browser. Real, but a display note its peers still can, because the mechanism does not go through the browser. Real, but a display
failure over a live agent rather than a lost one. failure over a live agent rather than a lost one.
**B4. The mobile collapse decision.** (§6 of the todo, first item.) Genuinely undecided against this **B4. The mobile collapse decision.** (§6 of the todo, first item.) Genuinely undecided against this
objective, and worth putting to the owner rather than guessing: *"I want to be able to sleep at night"* objective, and worth putting to the owner rather than guessing: _"I want to be able to sleep at night"_
raises the obvious question of whether the 03:00 check-in happens on a phone. If yes, a user-created raises the obvious question of whether the 03:00 check-in happens on a phone. If yes, a user-created
dashboard rendering only its left column forever is a Tier A problem wearing a mobile hat. If the answer dashboard rendering only its left column forever is a Tier A problem wearing a mobile hat. If the answer
is "I check on the laptop, and mobile web is being retired for the native app" — which is what is "I check on the laptop, and mobile web is being retired for the native app" — which is what
@@ -846,12 +846,12 @@ this project.
problem. It is not this one. problem. It is not this one.
- **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is - **§3, multi-user correctness.** Ranks on its own timer (a second member creating a dashboard), which is
unrelated to this path. unrelated to this path.
- **§5.2, the remount table.** *The largest downgrade in this re-rank.* A remount used to threaten - **§5.2, the remount table.** _The largest downgrade in this re-rank._ A remount used to threaten
whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs whatever the panel was holding; a panel now holds nothing. A chat panel that remounts re-runs
`resume-cursor` from its stored cursor and replays the durable log — it costs latency, and §2 declares `resume-cursor` from its stored cursor and replays the durable log — it costs latency, and §2 declares
latency free. Fix these for the interaction quality they are actually about; do not fix them for this. latency free. Fix these for the interaction quality they are actually about; do not fix them for this.
- **§5.3, drag-to-move.** *The second-largest downgrade, and it was on the critical path when the north - **§5.3, drag-to-move.** _The second-largest downgrade, and it was on the critical path when the north
star was written* (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things star was written_ (§1.5, P3: "dragging a panel would silently sever its session binding"). Two things
disarmed it. Q3 made the **name** the address and the panel id merely where it currently lives; and disarmed it. Q3 made the **name** the address and the panel id merely where it currently lives; and
`e588524` made `swapPanels`/`movePanel` carry `{appType, config}` as one unit, so the name travels with `e588524` made `swapPanels`/`movePanel` carry `{appType, config}` as one unit, so the name travels with
the panel. `useAgentPanel` resolves by name and re-anchors the row's `panelId` afterwards. The the panel. `useAgentPanel` resolves by name and re-anchors the row's `panelId` afterwards. The
@@ -863,7 +863,7 @@ this project.
writes rather than widen the diff. And §5.8 is not a prerequisite here; the mechanism never goes writes rather than widen the diff. And §5.8 is not a prerequisite here; the mechanism never goes
through a channel, because it never goes through the browser at all. through a channel, because it never goes through the browser at all.
### 6.5 What the re-rank did *not* find, and that is the result ### 6.5 What the re-rank did _not_ find, and that is the result
No defect in `workspace-panel-todo.md` blocked building the MVP. It was built, and it ran unattended, on No defect in `workspace-panel-todo.md` blocked building the MVP. It was built, and it ran unattended, on
the framework as it stands. The framework needed exactly one addition — per-panel config that survives a the framework as it stands. The framework needed exactly one addition — per-panel config that survives a
+5 -5
View File
@@ -9,7 +9,7 @@ A team of agents works on this project, sometimes several of them in the same re
one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody one should commit under its own identity, so `git log` answers "which agent wrote this" without anybody
having to remember to say so. having to remember to say so.
Today it cannot. Every agent commits as the owner, because every agent *is* the owner as far as the OS Today it cannot. Every agent commits as the owner, because every agent _is_ the owner as far as the OS
is concerned. is concerned.
## How git identity can be overridden at all ## How git identity can be overridden at all
@@ -85,7 +85,7 @@ const { CLAUDECODE: _c, CLAUDE_CODE_ENTRYPOINT: _e, CLAUDE_CODE_SSE_PORT: _s, ..
That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session That is the whole story: the child gets the sidecar's full `process.env` minus the three nested-session
guards, and nothing is added per turn. guards, and nothing is added per turn.
**This is the good news.** `env` is *already* a per-`query()` option. It is built once today, but there **This is the good news.** `env` is _already_ a per-`query()` option. It is built once today, but there
is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point is no structural reason it has to be — which makes `claude-manager.ts:315` the single injection point
for everything below. for everything below.
@@ -96,8 +96,8 @@ Almost none, and none of it at the OS level.
- `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport - `sessionKey` — officer's uuid, the key in the `sessions` map. Reaches the child only as a transport
field on the pushed message. field on the pushed message.
- **Agent name and persona are prompt-only.** `buildAgentPrompt` - **Agent name and persona are prompt-only.** `buildAgentPrompt`
(`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the *first user (`src/servers/api/agents/agent-runner.ts:71-79`) inlines the agent's `AGENT.md` into the _first user
message*. There is no `systemPrompt`, no `--agents`, no per-agent settings file. message_. There is no `systemPrompt`, no `--agents`, no per-agent settings file.
- The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)` - The one durable per-agent handle is the working directory: `getAgentRunsDir(agent.dirName)`
(`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their (`agent-runner.ts:144`), deliberately shared across all runs of that agent so the CLI groups their
transcripts. transcripts.
@@ -147,7 +147,7 @@ API field. Neither is a small change, and this document does not propose one.
There is **no filesystem isolation** between agents. They share one real `HOME` There is **no filesystem isolation** between agents. They share one real `HOME`
(`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this (`HOME_DIR=/home/pastilhas`), one `~/.claude`, one credential store; `user-instance.ts:75-78` says this
outright, and it is the stated reason `chat` is an `execution` capability that can never be granted. outright, and it is the stated reason `chat` is an `execution` permission that can never be granted.
`grep -ril worktree src/` returns nothing — worktrees are used nowhere. `grep -ril worktree src/` returns nothing — worktrees are used nowhere.
cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely. cwd is the only per-session variation and it is not a boundary, since absolute paths escape it freely.
+20 -20
View File
@@ -43,7 +43,7 @@ that is the sidecar running your agent. **It is not.**
```ts ```ts
name: 'proxy', name: 'proxy',
capabilities: ['proxy'], permissions: ['proxy'],
``` ```
and its entire job is four things (`index.ts:10-23`): take a PID lock, load state, ensure an Anthropic and its entire job is four things (`index.ts:10-23`): take a PID lock, load state, ensure an Anthropic
@@ -73,11 +73,11 @@ The credential path is in roughly the right place; the process topology is not.
### Why the process dies — two independent mechanisms ### Why the process dies — two independent mechanisms
1. **Process-tree kill.** PM2 signals the whole tree on restart, so the agent gets SIGINT even though 1. **Process-tree kill.** PM2 signals the whole tree on restart, so the agent gets SIGINT even though
nothing in Officer's code asks for it. *(Inferred from PM2's default `treekill: true`; nothing in Officer's code asks for it. _(Inferred from PM2's default `treekill: true`;
`ecosystem.config.cjs` sets no `treekill` key, so the default applies. I did not test this in `ecosystem.config.cjs` sets no `treekill` key, so the default applies. I did not test this in
isolation.)* isolation.)_
2. **Inherited stdio.** `sidecar-registry.ts:240-241` passes `stdout: 'inherit', stderr: 'inherit'`, 2. **Inherited stdio.** `sidecar-registry.ts:240-241` passes `stdout: 'inherit', stderr: 'inherit'`,
so the agent writes into *officer's* PM2 log pipes. When officer restarts those pipes close, and so the agent writes into _officer's_ PM2 log pipes. When officer restarts those pipes close, and
subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the subsequent writes fail. Even if the signal were suppressed, the child's output path dies with the
parent. parent.
@@ -85,7 +85,7 @@ Both must be fixed. Fixing only the signal leaves a process writing to a closed
### Also relevant: the transport direction is inverted ### Also relevant: the transport direction is inverted
`user-instance.ts:19` dials *out* to officer: `user-instance.ts:19` dials _out_ to officer:
```ts ```ts
const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`; const API_URL = process.env.API_URL ?? `ws://127.0.0.1:${process.env.PORT ?? '5000'}`;
@@ -95,8 +95,8 @@ The agent sidecar is a **client** of officer, registering over `/api/sidecar/reg
listener, reports no port. That is the exact inverse of the compliant sidecars (slskd, music, vault), listener, reports no port. That is the exact inverse of the compliant sidecars (slskd, music, vault),
which listen on a loopback port, report it on connect, and let officer forward to them. which listen on a loopback port, report it on connect, and let officer forward to them.
This matters for survivability, not just tidiness: when officer restarts, a sidecar that *listens* This matters for survivability, not just tidiness: when officer restarts, a sidecar that _listens_
just sits there with its work intact and waits to be forwarded to again. A sidecar that *dials in* has just sits there with its work intact and waits to be forwarded to again. A sidecar that _dials in_ has
to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the to notice the drop, reconnect, and re-establish identity — and anything it wanted to emit in the
meantime has nowhere to go. meantime has nowhere to go.
@@ -108,7 +108,7 @@ process now survives. Does your session?
Not yet. Five things have to hold, and only some are about process lifetime: Not yet. Five things have to hold, and only some are about process lifetime:
| # | Requirement | Status today | | # | Requirement | Status today |
|---|---|---| | --- | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| R1 | The agent process is outside officer's process tree | **broken** — child of officer | | R1 | The agent process is outside officer's process tree | **broken** — child of officer |
| R2 | The agent's stdio does not belong to officer | **broken**`'inherit'` | | R2 | The agent's stdio does not belong to officer | **broken**`'inherit'` |
| R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine**`connect.ts` has a reconnect backoff table; not tested across a real restart | | R3 | The sidecar survives its control socket dropping, and reconnects | **probably fine**`connect.ts` has a reconnect backoff table; not tested across a real restart |
@@ -130,7 +130,7 @@ The backend half exists too (`chat/websocket.ts:612-629`, `getChatEventsSince`).
"Disconnected" indicator in the UI (`ChatHistory/ChatDetailPanel.tsx:38-52`). "Disconnected" indicator in the UI (`ChatHistory/ChatDetailPanel.tsx:38-52`).
So **the sequence-and-replay protocol I was about to propose building already exists end to end.** The So **the sequence-and-replay protocol I was about to propose building already exists end to end.** The
only thing wrong with it is *who writes the events*. That collapses Stage 2 below from "design a only thing wrong with it is _who writes the events_. That collapses Stage 2 below from "design a
durable outbox" to "move the writer" — the single biggest simplification in this plan. durable outbox" to "move the writer" — the single biggest simplification in this plan.
One gap to close while moving it: nothing verifies sequence continuity. `resume-cursor` is only sent One gap to close while moving it: nothing verifies sequence continuity. `resume-cursor` is only sent
@@ -175,7 +175,7 @@ With the data flow inverted to match slskd:
`server.tsx:164-228` (dev-server) and `server.tsx:323-326``api/vault/websocket.ts` (vault). `server.tsx:164-228` (dev-server) and `server.tsx:323-326``api/vault/websocket.ts` (vault).
- The sidecar **writes its own events to Postgres** with a monotonic per-session sequence number. It - The sidecar **writes its own events to Postgres** with a monotonic per-session sequence number. It
already imports `officerdb` (`user-instance.ts:10`), so this is established precedent, not a new already imports `officerdb` (`user-instance.ts:10`), so this is established precedent, not a new
capability. Officer stops touching `chat_session_events` entirely. permission. Officer stops touching `chat_session_events` entirely.
- On reconnect the browser sends `since=<seq>` and the **sidecar** answers the replay. Officer relays - On reconnect the browser sends `since=<seq>` and the **sidecar** answers the replay. Officer relays
the question and the answer, and interprets neither. the question and the answer, and interprets neither.
@@ -208,14 +208,14 @@ The minimum fix for R1 + R2. Two routes, and I'd want your view on which:
`ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines `ensureClaudeSidecar` / `spawnAndWaitForRegistration` (`sidecar-registry.ts:198-274`, ~77 lines
including the 50ms registration poll). Officer no longer spawns anything. including the 50ms registration poll). Officer no longer spawns anything.
- *Pro:* correct, matches every other sidecar, PM2 restarts and logs it properly. - _Pro:_ correct, matches every other sidecar, PM2 restarts and logs it properly.
- *Con:* the per-email spawn model has to go or change — see the open question below. - _Con:_ the per-email spawn model has to go or change — see the open question below.
**1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file, **1b. Detach the spawn.** Keep on-demand spawning but `detached: true`, own stdio to its own log file,
own process group. own process group.
- *Pro:* smallest diff, keeps lazy startup. - _Pro:_ smallest diff, keeps lazy startup.
- *Con:* leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state, - _Con:_ leaves an unmanaged process PM2 can't see or restart. I think this is the wrong end state,
but it might be a legitimate first step if you want the survivability today. but it might be a legitimate first step if you want the survivability today.
After this stage: the process survives, the socket reconnects, **but output produced during the After this stage: the process survives, the socket reconnects, **but output produced during the
@@ -265,7 +265,7 @@ reads its settings from. The whole chain — unauthenticated endpoint, sidecar `
`panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the `panel-refresh` frame, `onPanelRefresh` prop — was deleted on 2026-08-04. The Chat panel already does the
same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip. same job from `onTurnComplete`, in-process, with no hook and no HTTP round trip.
### Stage 5 — the harder question: surviving a *sidecar* restart ### Stage 5 — the harder question: surviving a _sidecar_ restart
Stages 1-4 make the agent survive an **officer** restart. They do not make it survive a restart of the Stages 1-4 make the agent survive an **officer** restart. They do not make it survive a restart of the
agent sidecar itself — the agent process is that sidecar's child by design. agent sidecar itself — the agent process is that sidecar's child by design.
@@ -286,9 +286,9 @@ is a real design decision and I don't have a confident recommendation.
1. ~~**Is the per-email spawn model dead weight?**~~**answered 2026-08-07: yes, it is.** The 1. ~~**Is the per-email spawn model dead weight?**~~**answered 2026-08-07: yes, it is.** The
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled `claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The permission model settled
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is it in the _other_ direction from what "the platform is going multi-user" would suggest: `chat` is
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**, `kind: 'execution'` in `permissions/registry.ts`, which is **never grantable at any level**,
because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
accounts exist now, and not one of them can ever open a chat. accounts exist now, and not one of them can ever open a chat.
@@ -299,7 +299,7 @@ is a real design decision and I don't have a confident recommendation.
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse 2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
the browser a short-lived token and the browser connects to the sidecar directly (survives an the browser a short-lived token and the browser connects to the sidecar directly (survives an
officer restart *without even a reconnect*, but needs its own TLS/origin story and a second officer restart _without even a reconnect_, but needs its own TLS/origin story and a second
exposed port). I lean relay — the reconnect is cheap once Stage 2 makes it lossless — but the exposed port). I lean relay — the reconnect is cheap once Stage 2 makes it lossless — but the
direct path is the only one where you genuinely never notice. direct path is the only one where you genuinely never notice.
@@ -329,7 +329,7 @@ is a real design decision and I don't have a confident recommendation.
## Verified vs not ## Verified vs not
**Verified by reading the code or inspecting the running system:** my process ancestry; that **Verified by reading the code or inspecting the running system:** my process ancestry; that
`officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn capability; `officer-claude` runs `sidecar/claude/index.ts` and registers as `proxy` with no spawn permission;
that `user-instance.ts` has no PM2 entry and is spawned only at `sidecar-registry.ts:238` with that `user-instance.ts` has no PM2 entry and is spawned only at `sidecar-registry.ts:238` with
inherited stdio; that it dials out rather than listening; that events leave via `connection.send`; inherited stdio; that it dials out rather than listening; that events leave via `connection.send`;
that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the that officer persists and replays them; that `--resume` is in my own argv; the two port defaults; the
+1 -1
View File
@@ -117,7 +117,7 @@ worth serving both from one place.
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and - **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
strategies) should be enabled and surfaced in the UI precisely so this is not confused. strategies) should be enabled and surfaced in the UI precisely so this is not confused.
- **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so - **It is not sharing.** Files is an `execution` permission — the owner's disk, never grantable — so
there is still nobody to share with, whatever the account list says since 2026-08-07. there is still nobody to share with, whatever the account list says since 2026-08-07.
--- ---
+14 -9
View File
@@ -6,11 +6,11 @@ this file still exists. Email sync is deliberately NOT part of this any more —
sidecar with its own scheduling, so it does not appear in the Jobs list. sidecar with its own scheduling, so it does not appear in the Jobs list.
**Goal:** every task run (script, pipeline, later agentic) becomes a persisted, background **job** **Goal:** every task run (script, pipeline, later agentic) becomes a persisted, background **job**
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone, created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop _and_ phone,
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path. and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that **Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script on 2026-08-07 — but because `tasks` is an `execution` permission: running a job means running a script
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
still a global check, and the conclusion below is unchanged even though the premise was rewritten. still a global check, and the conclusion below is unchanged even though the premise was rewritten.
Favor power-user affordances over guardrails. Favor power-user affordances over guardrails.
@@ -45,9 +45,10 @@ Favor power-user affordances over guardrails.
## Plan ## Plan
### Phase 1 — Unified jobs backend ### Phase 1 — Unified jobs backend
- **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int) - **1a. Data model.** Add `mode` (`pipeline|script|agentic`, default `pipeline`) + `exit_code` (int)
to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). *Table/symbol rename to the jobs table. Log at `DATA_PATH/jobs/<id>.log` (derived from id). _Table/symbol rename
`pipeline_jobs``jobs` is deferred as a cosmetic cleanup — add columns first, keep it working.* `pipeline_jobs``jobs` is deferred as a cosmetic cleanup — add columns first, keep it working._
- **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline` - **1b. Execution.** Generalize the job manager: `startJob` takes `mode` and dispatches — `pipeline`
→ existing `executePipeline`; `script` → new `executeScript` (ports task-executor's → existing `executePipeline`; `script` → new `executeScript` (ports task-executor's
`materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events + `materializeScript`/`buildInputEnv`/bwrap sandbox/`killTree`/keepalive, but emits job events +
@@ -57,31 +58,35 @@ Favor power-user affordances over guardrails.
startup so a queued backlog resumes. startup so a queued backlog resumes.
### Phase 2 — REST job API (decouples creation from the socket; enables the phone) ### Phase 2 — REST job API (decouples creation from the socket; enables the phone)
- `POST /jobs {taskDirName, inputs, cwd, action}``{jobId}` (create + start/queue, background). - `POST /jobs {taskDirName, inputs, cwd, action}``{jobId}` (create + start/queue, background).
- `GET /jobs` (+`?live=1`), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`. - `GET /jobs` (+`?live=1`), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
- Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list. - Consolidate the two WebSockets into one `/api/tasks/jobs/ws` doing only attach/stop/list.
### Phase 3 — Frontend ### Phase 3 — Frontend
- `/jobs/new``NewJobScreen`: reads query params, renders the input UI lifted from - `/jobs/new``NewJobScreen`: reads query params, renders the input UI lifted from
`TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the `TaskRunnerModal` (`TaskInputForm` + per-group config + folder probing). Run/Queue per the
concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when concurrency UX. `JobDetail` gains a script branch (terminal output: live attach, or from log when
idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator. idle). Retire `TaskRunnerModal`/`TaskRunnerDialog`/`useTaskRunner`. Header running-jobs indicator.
### Phase 4 — Notifications (later) ### Phase 4 — Notifications (later)
- One `notifyJobDone(job)` hook at finalize → push to the phone app. - One `notifyJobDone(job)` hook at finalize → push to the phone app.
## Progress ## Progress
- [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB) - [x] 1a data model — `mode` + `exit_code` columns (schema + applied to DB)
- [x] 1b executeScript + manager dispatch — `execute-script.ts` (spawn/sandbox/killTree port, log file, - [x] 1b executeScript + manager dispatch — `execute-script.ts` (spawn/sandbox/killTree port, log file,
abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now abort poll, returns exitCode), `process-tree.ts` (shared killTree), `pipeline-job-manager` now
dispatches by `mode` and finalizes script jobs by exit code. *Compiles; runtime-untested until dispatches by `mode` and finalizes script jobs by exit code. _Compiles; runtime-untested until
a REST caller + restart exist.* a REST caller + restart exist._
- [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()` - [x] 1c scheduler / queue — `enqueueJob(action)` (start now / queue behind running), `promoteNext()`
on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending on finalize + startup, `getOldestPendingJob`, `markInterruptedJobs` now running-only (pending
queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper. queue survives restart). `startJob` kept as a `enqueueJob(...,'start')` wrapper.
- [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`, - [x] 2 REST job API — `POST /jobs` (create script|pipeline, action start/queue), `GET /jobs` (+`?live=1`,
now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`. now returns mode/exitCode/isLive), `GET /jobs/:id`, `GET /jobs/:id/log?offset=`, `POST /jobs/:id/stop`.
Router mounted at `/jobs` and `/pipeline-jobs`. *Needs a restart to deploy; then curl/phone-testable.* Router mounted at `/jobs` and `/pipeline-jobs`. _Needs a restart to deploy; then curl/phone-testable._
WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live). WS consolidation still pending (old `/api/tasks/run/ws` + `/api/tasks/pipeline/ws` still live).
- [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done. - [x] 3 frontend — master-detail `/jobs`, modal-as-creator, split list, header badges. Done.
- [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel - [x] 3a jobs UI — **master-detail** `JobsPage` (like `/chat`): `WorkspaceLayout` with a list panel
@@ -93,8 +98,8 @@ Favor power-user affordances over guardrails.
an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) → an **inline** task runs ephemerally in-modal; a **non-inline** task `POST /jobs` (start) →
navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button navigates to `/jobs/:id`. When a job is already running, a red "Run now" + a "Queue" button
(queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new` (queue → `/jobs`). Reuses the modal's per-group input UI in place — no separate `/jobs/new`
page or FileBrowser change needed. *(A standalone deep-linkable `/jobs/new` is deferred; the page or FileBrowser change needed. _(A standalone deep-linkable `/jobs/new` is deferred; the
phone creates jobs directly via `POST /jobs`.)* phone creates jobs directly via `POST /jobs`.)_
- [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton + - [x] 3d header job indicators — `JobsIndicator` (two always-present badges next to RescanButton +
UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling UserMenu): **running** (→ running job's `/jobs/:id`) + **queued** (→ `/jobs`), polling
`GET /jobs/counts``{ running, runningJobId, queued }` every 3s; dim at 0. `GET /jobs/counts``{ running, runningJobId, queued }` every 3s; dim at 0.
+3 -3
View File
@@ -139,7 +139,7 @@ will not meet it.
| **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. | | **401** | The credential is dead — revoked, expired, or never valid. | Clear it, send the user to the login screen. |
| **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. | | **403** | The credential is **fine**; this account may not reach this feature. | **Do not clear the credential.** Show "not available for your account" and stay signed in. |
Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing capability into a Clearing a good key on a 403 is the failure mode to avoid: it turns a member's missing permission into a
logout loop they cannot escape, because signing in again produces a credential with the same 403. logout loop they cannot escape, because signing in again produces a credential with the same 403.
A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached. A revoked key goes 401 on the very next request — revocation is checked in SQL at lookup, not cached.
@@ -153,7 +153,7 @@ decides everything after.
- **The owner** (user 1) reaches everything. - **The owner** (user 1) reaches everything.
- **Any other account** reaches only what its role has been granted, and **can never** reach the - **Any other account** reaches only what its role has been granted, and **can never** reach the
`execution` capabilities — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS `execution` permissions — terminal, chat, tasks, files, desktop, browser. Those run as the owner's OS
user in the owner's home; they are refused structurally, not by policy. user in the owner's home; they are refused structurally, not by policy.
Verified: a member's key returns the same status as that member's JWT on every route tried, 403s Verified: a member's key returns the same status as that member's JWT on every route tried, 403s
@@ -233,7 +233,7 @@ both, so the endpoint cannot be used to discover whether an id exists.
## Not built ## Not built
- **Scopes.** A key cannot be narrowed to a subset of its holder's capabilities. The column and the check - **Scopes.** A key cannot be narrowed to a subset of its holder's permissions. The column and the check
are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is are a small change (`resolveApiKey` in `src/servers/auth-token.ts` is the one place), but nothing is
there today. Design as if every key is full-authority, because it is. there today. Design as if every key is full-authority, because it is.
- **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave — - **A key-management screen in the mobile apps.** Only the web UI can list and revoke. Fine to leave —
+2 -2
View File
@@ -60,7 +60,7 @@ speaks DAV.
- **Username** = the account's email address — the signed-in account's own, not a constant. (This said - **Username** = the account's email address — the signed-in account's own, not a constant. (This said
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable "Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
capability, so a member can hold their own app passwords and their own collections.) permission, so a member can hold their own app passwords and their own collections.)
- **Password** = a **DAV app password**, not the login password. - **Password** = a **DAV app password**, not the login password.
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
@@ -106,7 +106,7 @@ row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's stor
**Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer **Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection `calendar` permission, and a member's id is not 1. Derive it from `/auth/me` or from the collection
paths; both work, and both stay correct when the caller is not the owner. paths; both work, and both stay correct when the caller is not the owner.
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any **A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
+30 -24
View File
@@ -21,14 +21,13 @@
> The rules in this file are current and authoritative; the findings table is a snapshot. > The rules in this file are current and authoritative; the findings table is a snapshot.
> >
> **The runtime click-through has now happened** (2026-08-07, Playwright driving the system Brave against > **The runtime click-through has now happened** (2026-08-07, Playwright driving the system Brave against
> the live server on 9010): 23 of 25 checks pass, and the two that did not are missing *data*, not > the live server on 9010): 23 of 25 checks pass, and the two that did not are missing _data_, not
> regressions — the email account list and the Soulseek room list are both empty on this machine, so there > regressions — the email account list and the Soulseek room list are both empty on this machine, so there
> is nothing to click. Two further "failures" were the *test* being wrong, not the app: the Dock renders a > is nothing to click. Two further "failures" were the _test_ being wrong, not the app: the Dock renders a
> user-pinned subset of 13 of 25 items, so `/plans` is absent by config; and `[data-sonner-toaster]` sits on > user-pinned subset of 13 of 25 items, so `/plans` is absent by config; and `[data-sonner-toaster]` sits on
> an inner `<ol>` that only exists while a toast is showing. **Suspect the instrument first.** Individual > an inner `<ol>` that only exists while a toast is showing. **Suspect the instrument first.** Individual
> "Needs runtime test" notes below may still be true — the sweep covered the routing claims, not every row. > "Needs runtime test" notes below may still be true — the sweep covered the routing claims, not every row.
**Date:** 2026-07-30 · **Origin:** written as exploration before any of the routing work was done. **Date:** 2026-07-30 · **Origin:** written as exploration before any of the routing work was done.
Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend Prep work for the upcoming **full navigation refactor**. This catalogues every place the frontend
@@ -43,6 +42,7 @@ imperative `navigate()` / global-channel setter **instead of a real `<Link to>`
## The anti-pattern (definition) ## The anti-pattern (definition)
A clickable element selects/opens something that has (or should have) a URL, but: A clickable element selects/opens something that has (or should have) a URL, but:
- **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure; - **(a)** the entity id/slug is **not in the DOM** (no `href`, no `data-*`) — it lives only in an onClick closure;
- **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect); - **(b)** clicking **doesn't change the URL** (or does so only via an indirect state→URL effect);
- **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL; - **(c)** selection is held in **JS state / a global channel** (`usePanelChannel`, `useGlobal`), not the URL;
@@ -50,7 +50,7 @@ A clickable element selects/opens something that has (or should have) a URL, but
**Exemplar (already fixed):** the `/chat` session list. Rows were `<button onClick={() => selectById(id)}>` **Exemplar (already fixed):** the `/chat` session list. Rows were `<button onClick={() => selectById(id)}>`
(id only in the closure) → converted to `<Link to={`/chat/${session.id}`}>` (committed to master `f35c145`). (id only in the closure) → converted to `<Link to={`/chat/${session.id}`}>` (committed to master `f35c145`).
That fix is the template for the HIGH items below. **Caveat:** the fix only did the *rows* — the chat That fix is the template for the HIGH items below. **Caveat:** the fix only did the _rows_ — the chat
**detail panel** still selects via channel, not the URL (finding **C1**), so `/chat` is the model for both **detail panel** still selects via channel, not the URL (finding **C1**), so `/chat` is the model for both
"done right" (rows) and "still to do" (detail). "done right" (rows) and "still to do" (detail).
@@ -72,7 +72,7 @@ was written.
**What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet), **What's already correct** (lean on these in the refactor): the **Dock**, **Header** (logo + mobile sheet),
**UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>` **UserMenu**, **JobsIndicator** are all real `<Link>`s. Shared `NavLink.tsx` (query-string-appending `<Link>`
wrapper — note: *not* react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow) wrapper — note: _not_ react-router's NavLink, gives no active state) and `BackButton.tsx` (`<Link>` back arrow)
are good building blocks. The **Workspace/Panel framework** contains **zero** route navigation — it's orthogonal. are good building blocks. The **Workspace/Panel framework** contains **zero** route navigation — it's orthogonal.
--- ---
@@ -82,7 +82,7 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth ### 🔴 HIGH — addressable route already exists; just needs a `<Link>` / URL-as-source-of-truth
| ID | file:line | Entity | Current impl | Fix | | ID | file:line | Entity | Current impl | Fix |
|----|-----------|--------|--------------|-----| | ------ | ------------------------------------------------------- | -------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** | | H1 | `Screens/Dashboard/Jobs/JobsPage.tsx:108` | a job | `<button onClick={() => navigate(`/jobs/${job.id}`)}>` — id in closure | → `<Link to={`/jobs/${job.id}`}>`. Active-row already keys off `useParams().id`; keep the stop/delete button. **The exact twin of the /chat fix.** |
| H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>``useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. | | H2 | `workspaces/…/apps/Dashboards/DashboardListApp.tsx:133` | a dashboard | `<div onClick={handleClick}>``useGlobal(SELECTED_DASHBOARD_KEY)` on-page (**no URL change**), `navigate()` off-page | rows → `<Link to={`/dashboards/${ws.id}`}>`; drop the global as selection source (derive from `useParams`). Header is already a `<Link>` — app is internally inconsistent. |
| H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>``useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. | | H3 | `workspaces/…/apps/Projects/ProjectListApp.tsx:161` | a project | `<div onClick={handleClick}>``useGlobal(SELECTED_PROJECT)` on-page (**no URL change**), `navigate()` off-page | identical to H2 → `<Link to={`/projects/${p.id}`}>`; retire `SELECTED_PROJECT` as source of truth. |
@@ -97,13 +97,13 @@ are good building blocks. The **Workspace/Panel framework** contains **zero** ro
### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link) ### 🟠 MEDIUM — navigable entity with **no route yet** (add a route, then link)
| ID | file:line | Entity | Proposed route | Note | | ID | file:line | Entity | Proposed route | Note |
|----|-----------|--------|----------------|------| | ------- | ------------------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ~~M1~~ | ~~`Screens/Dashboard/CapabilityPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. | | ~~M1~~ | ~~`Screens/Dashboard/PermissionPage.tsx:431`~~ | task / skill / process | `/tasks/:dirName`, `/skills/:dirName`, `/processes/:dirName` | **Done.** One component backed three screens, so one change covered all of them. The auto-select-`items[0]` effect is gone — the bare route is now the list with an empty detail pane. `editing`/`isNew` moved to `?edit=1` / `?new=1` because a `<Link>` row cannot imperatively reset them. |
| ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. | | ~~M2~~ | ~~`Screens/Dashboard/TaskLogs/index.tsx:104`~~ | a task-log run | `/task-logs/:id` | **Done.** As predicted — the detail fetch already keyed off the id, so only its source changed. `showDetail` is gone; the mobile swap and both back arrows derive from the param. |
| ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived *string*, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". | | ~~M3~~ | ~~`Screens/Dashboard/Activity/ActivityScreen.tsx:63,73`~~ | background task / detached job | `/activity/:id` | **Done.** One param for both row kinds; the screen looks the id up in the polled registry and derives `task=`/`path=` from the row. The SSE effect now depends on that derived _string_, so the 3s poll no longer risks re-opening the stream. An id that has left the registry says so instead of hanging on "waiting for output". |
| ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is *ephemeral* (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder *items* stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. | | ~~M4~~ | ~~`FileBrowser/.../useFileBrowserApp.ts:269`~~, `FileItem.tsx:516`, ~~`Breadcrumb.tsx:16`~~ | a folder | `/files?path=<dir>` | **Partly done — the rest is an owner decision, not a defect.** `currentPath` is `?path=` on `/files`, so back/forward and linking a folder work, and the crumbs are `<Link>`s. Two things the audit line did not know: `?view=` is _ephemeral_ (wiped on mount by `useFileViewerPanels`), so `path` is the screen's first durable param, and four `setSearchParams({…})` calls replaced the whole query string — opening any file would have silently reset the folder. They go through a `setViewerParams` helper now that keeps `path`. Opt-in via the parsed `WorkspaceIdentity` (`screens/files`), because a dashboard can hold two browsers and one shared param would move both. **Folder _items_ stay buttons:** ⌘/Ctrl/Shift-click is already bound to multi-select in `FileItem.tsx` and open is double-click, so anchor semantics collide with an existing gesture. |
| ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree *file* rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now *opens* it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span *nested inside* the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file *inside* a `setFiles` updater, which is exactly the impurity React double-invokes to catch. | | ~~M5~~ | ~~`CodeEditor/FileTree.tsx:59`, `EditorTabs.tsx:33`~~ | open source file / active tab | `/code-editor?file=<path>` | **Done, minus `open=`.** The active file is `?file=`; tree _file_ rows and tabs are `<Link>`s. **The tab set stays local** — it is a working session, not an address: it grows without bound, each entry costs a read on load, and nobody links someone else to a tab bar. A `?file=` naming a file that is not open now _opens_ it, which is what makes a pasted link work; a path that fails to read is remembered so a bad link errors once instead of once per render, and the address is left alone rather than rewritten. **Tree folder rows stay buttons** — unlike the M4 case this needs no owner call, because expanding a directory is disclosure, not navigation. Two things fixed in passing: the tab close control was a `role="button"` span _nested inside_ the tab (invalid then, a nested interactive inside an anchor now) and is a sibling `<button>` with an `aria-label`; and `closeFile` computed the next-active file _inside_ a `setFiles` updater, which is exactly the impurity React double-invokes to catch. |
| ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was *almost* right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. | | ~~M6~~ | `Settings/SettingsPanel.tsx` | a settings sub-section | `/settings/:page/:section` | **Done.** `<NavLink>` + `useParams`, five `*_SELECTED` globals gone, one `SettingsRoute` guard per page. The "one change covers all settings pages" claim was _almost_ right: Integrations builds its own sidebar and did not go through `createSettingsPanelComponents`, and it also held the Enterprise/Personal tab in a second global — derived from the section key now, which is what fixes deep-linking a Personal section. |
| ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. | | ~~M7~~ | ~~`workspaces/components/Combobox.tsx:53`~~ | caller-supplied route | — | **Deleted, not fixed.** "Every caller inherits the opaque click" was the reason this ranked MEDIUM, and it is wrong: `Combobox` has **no callers**. Nothing has imported it since the initial commit, there is no barrel export, and nothing anywhere sets `href` on a `SelectOption` — so the navigate, the separator that only showed for `href` options, and the `href` field on both declarations of the type were all unreachable. Writing anchor semantics into a component that is never rendered is building, not fixing. Its `Command` primitives stay; `AIHarnessesSection` uses them. |
| ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. | | ~~M8~~ | `Layout/Header/UserMenu.tsx` | — | — | **Done.** Removed rather than routed: nothing had ever been built behind `/settings/resources`, so the item was a bounce to `/` dressed as navigation. Its `header.userMenu.resources` locale keys went with it. |
| ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. | | ~~M9~~ | `Screens/Dashboard/Plans/index.tsx` | a plan document | `/plans/:name` | **Done.** Route pair, no `Navigate` guard — the bare route means "no plan open", which is a real state, so the auto-select-first effect was deleted rather than turned into a redirect. The `<select>` navigates instead of setting state; it stays a `<select>` on purpose (chrome for one document, not a master list) and therefore genuinely has no cmd-click — a native `<option>` cannot be an anchor. A name that no longer exists gets the empty pane, not a rewritten URL. Reading the server route for this also turned up a **path traversal**: hono percent-decodes route params, so `GET /api/plans/..%2F..%2Fsecret` reached `join(plansDir, '../../secret.md')`. Now `basename()`d. |
@@ -134,16 +134,16 @@ is **one design decision** that cascades across many files:
- ~~**Dock / Header active styling**~~ (`Dock.tsx` · `Header.tsx`) — **done.** Both are react-router - ~~**Dock / Header active styling**~~ (`Dock.tsx` · `Header.tsx`) — **done.** Both are react-router
`<NavLink>`s now and the two copies of `isActive` are gone, along with the `useLocation` each needed. `<NavLink>`s now and the two copies of `isActive` are gone, along with the `useLocation` each needed.
One behavioural difference, deliberate: the hand-rolled version was a string `startsWith`, so `/task-logs` One behavioural difference, deliberate: the hand-rolled version was a string `startsWith`, so `/task-logs`
would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path *segment*, which would also have matched a hypothetical `/task-logsomething`; `NavLink` matches by path _segment_, which
is what was meant. `end` is set for Home only — without it `NavLink` treats `/` as an ancestor of every is what was meant. `end` is set for Home only — without it `NavLink` treats `/` as an ancestor of every
route; with it on the others, a detail route (`/plans/x`, `/system-monitor/btop`) would lose its highlight. route; with it on the others, a detail route (`/plans/x`, `/system-monitor/btop`) would lose its highlight.
- ~~**Browser tabs**~~ (`Browser/TabList.tsx:93`) — **done, against this file's own advice.** The objection - ~~**Browser tabs**~~ (`Browser/TabList.tsx:93`) — **done, against this file's own advice.** The objection
was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of *bookmarking*, was that a CDP target id is ephemeral, so a durable `/browser/:tabId` is dubious. True of _bookmarking_,
and irrelevant to everything else the URL buys: the id was in an onClick closure, three components read a and irrelevant to everything else the URL buys: the id was in an onClick closure, three components read a
`BROWSER_SELECTED_TAB` global, and the row could not be cmd-clicked. Staleness is handled where it `BROWSER_SELECTED_TAB` global, and the row could not be cmd-clicked. Staleness is handled where it
actually shows up — the preview now distinguishes "no tab open" from "that tab is no longer attached" actually shows up — the preview now distinguishes "no tab open" from "that tab is no longer attached"
by checking the polled target list, which it gets from the same React Query key the list uses, so it by checking the polled target list, which it gets from the same React Query key the list uses, so it
costs no extra request. En route: the row's Focus and Close buttons were nested *inside* the row costs no extra request. En route: the row's Focus and Close buttons were nested _inside_ the row
`<button>`, which is invalid HTML and only worked because of two `stopPropagation` calls; they are `<button>`, which is invalid HTML and only worked because of two `stopPropagation` calls; they are
siblings of the anchor now. And its "Set up in Integrations" was a raw `<a href>` that reloaded the SPA. siblings of the anchor now. And its "Set up in Integrations" was a raw `<a href>` that reloaded the SPA.
- **Jobs step/iteration** (`Jobs/JobDetail.tsx`) — **decided: skipped, and it is not an anti-pattern.** - **Jobs step/iteration** (`Jobs/JobDetail.tsx`) — **decided: skipped, and it is not an anti-pattern.**
@@ -172,7 +172,7 @@ Every place an **addressable entity** is selected through a global channel / glo
This is the primary surface to convert to URL-driven selection. This is the primary surface to convert to URL-driven selection.
| Channel / global key | Entity held | Should map to | Files | | Channel / global key | Entity held | Should map to | Files |
|----------------------|-------------|---------------|-------| | -------------------------------------- | -------------------------------------------------------------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) | | `chat:selected-session` | open chat session | `/chat/:sessionId` | `ChatDetailPanel.tsx:136`, `SessionList.tsx:16` (H4) |
| `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` | | `chat:active-cwd` | chat working dir | query param on `/chat` | `ChatDetailPanel.tsx:102`, `SessionList.tsx:14` |
| `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) | | `SELECTED_DASHBOARD_KEY` (`useGlobal`) | selected dashboard | `/dashboards/:id` | `DashboardListApp.tsx:36`, `DashboardPreview.tsx:287` (H2) |
@@ -184,7 +184,7 @@ This is the primary surface to convert to URL-driven selection.
`SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`. `SLSKD_REFRESH_CHANNEL`, `MUSIC_RESYNC_CHANNEL`.
(`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer (`FILE_VIEWER_CHANNEL` was listed here too; it had no publisher and has been deleted — the file viewer
reads `?view=` from the URL. `preview:refresh` and `chat:active-session` were also listed, and reads `?view=` from the URL. `preview:refresh` and `chat:active-session` were also listed, and
`preview:refresh` was cited above as the exemplar of a *legitimate* channel — but both have a publisher `preview:refresh` was cited above as the exemplar of a _legitimate_ channel — but both have a publisher
in `ChatPanelWrapper` and **no subscriber at all**, and `preview:refresh`'s reader, `PreviewProvider`, is in `ChatPanelWrapper` and **no subscriber at all**, and `preview:refresh`'s reader, `PreviewProvider`, is
no longer in the repo. They are declared in `officerdev/src/channels.ts` with that stated; deleting the no longer in the repo. They are declared in `officerdev/src/channels.ts` with that stated; deleting the
publishers means changing the chat panel, which is another agent's, so it is written up in publishers means changing the chat panel, which is another agent's, so it is written up in
@@ -197,7 +197,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
"Link?" = a `<Link>`/`<NavLink>` is the right refactor. "Link?" = a `<Link>`/`<NavLink>` is the right refactor.
| # | file:line | what | target | Link? | note | | # | file:line | what | target | Link? | note |
|---|-----------|------|--------|-------|------| | ------ | ------------------------------------------------------ | ---------------------------------- | ------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 | | 1 | `Jobs/JobsPage.tsx:109` | job list row | `/jobs/:id` | **YES** | H1 |
| 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 | | 2 | `Dashboards/DashboardListApp.tsx:90` | dashboard row (off-page) | `/dashboards/:id` | YES | H2 |
| 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action | | 3 | `Dashboards/DashboardListApp.tsx:114` | inside "New Dashboard" | `/dashboards` | ~ | create action |
@@ -230,7 +230,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
> **Progress — 2026-07-30, branch `navigation-refactor` (off master; NOT yet runtime-tested):** > **Progress — 2026-07-30, branch `navigation-refactor` (off master; NOT yet runtime-tested):**
> H1, H2, H3 implemented and tsgo-clean. **Design correction for H2/H3:** the naive "row → `<Link to="/dashboards/:id">`" > H1, H2, H3 implemented and tsgo-clean. **Design correction for H2/H3:** the naive "row → `<Link to="/dashboards/:id">`"
> would destroy the *preview-on-list* feature (that route is the full page). The faithful fix — which is what > would destroy the _preview-on-list_ feature (that route is the full page). The faithful fix — which is what
> was implemented — moves selection out of the `SELECTED_*` global into a **`?selected=<id>` URL param** read by > was implemented — moves selection out of the `SELECTED_*` global into a **`?selected=<id>` URL param** read by
> the list, the screen (mobile panel), and the preview; rows are real `<Link>`s (`/…?selected=id` on-page, > the list, the screen (mobile panel), and the preview; rows are real `<Link>`s (`/…?selected=id` on-page,
> `/…/:id` off-page) with the action buttons kept as **siblings** of the anchor, not nested inside it. Same > `/…/:id` off-page) with the action buttons kept as **siblings** of the anchor, not nested inside it. Same
@@ -238,10 +238,11 @@ publishers means changing the chat panel, which is another agent's, so it is wri
> create/edit/delete(/publish), and mobile-panel flows on `/dashboards` and `/projects`. > create/edit/delete(/publish), and mobile-panel flows on `/dashboards` and `/projects`.
### Phase 1 — Quick wins (routes already exist; mechanical, high value) ### Phase 1 — Quick wins (routes already exist; mechanical, high value)
- [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.) - [x] **H1** Jobs rows → `<Link to={`/jobs/${job.id}`}>` (`JobsPage.tsx`). Done — `46482f3`. (active-row highlight already keyed off `useParams().id`.)
- [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.) - [x] **H2** Dashboards rows → `<Link>`; `SELECTED_DASHBOARD_KEY` global replaced by `?selected=` URL param across `DashboardListApp`/`DashboardsScreen`/`DashboardPreview`. Done — `01365cb`. **Needs runtime test.** (The constant itself outlived its last reader by four months and has now been deleted; its siblings in `Dashboards/constants.ts` are dialog form state, not selection, and stay.)
- [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.** - [x] **H3** Projects rows → `<Link>`; `SELECTED_PROJECT` global replaced by `?selected=` URL param across `ProjectListApp`/`ProjectListScreen`/`ProjectPreview`. Done — `2aaacc8`. **Needs runtime test.**
- [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. *Deferred: overlaps the in-flight `sidecars-*` chat-comms work; do after that lands.* - [ ] **H4** Chat detail: read `sessionId` from `useParams`, retire `chat:selected-session` as source of truth (`ChatDetailPanel.tsx:136`) — **finishes the /chat fix**. _Deferred: overlaps the in-flight `sidecars-_` chat-comms work; do after that lands.\*
- [x] **H5** Email rows → `<Link>` driven by `useParams().emailId`; the `EMAIL_SELECTED` global and both state↔URL sync effects are gone. **Needs runtime test.** (`EMAIL_FOLDER` stays a `useGlobal` for now — it is read in one component and is view state, not selection; putting the folder in `?folder=` is a separate, smaller item.) - [x] **H5** Email rows → `<Link>` driven by `useParams().emailId`; the `EMAIL_SELECTED` global and both state↔URL sync effects are gone. **Needs runtime test.** (`EMAIL_FOLDER` stays a `useGlobal` for now — it is read in one component and is view state, not selection; putting the folder in `?folder=` is a separate, smaller item.)
- [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.** - [x] **M8** Dead `/settings/resources` menu item removed from `UserMenu.tsx`, along with its now-orphaned `en`/`pt` locale keys. **Needs runtime test.**
- [x] Verified + converted the preview "open" navigates. Four were listed; **one** was real. The two - [x] Verified + converted the preview "open" navigates. Four were listed; **one** was real. The two
@@ -250,24 +251,27 @@ publishers means changing the chat panel, which is another agent's, so it is wri
post-mutation redirect and stays; only the "Open Dashboard" button in the edit form was pure post-mutation redirect and stays; only the "Open Dashboard" button in the edit form was pure
navigation, and it is now `<Button asChild><Link …>`. The big click-through overlay on the preview navigation, and it is now `<Button asChild><Link …>`. The big click-through overlay on the preview
was already a `<Link>`. `DashboardListApp`'s "New Dashboard" also stays a button: it sets six pieces was already a `<Link>`. `DashboardListApp`'s "New Dashboard" also stays a button: it sets six pieces
of form state and only *then* conditionally navigates. of form state and only _then_ conditionally navigates.
### Phase 2 — Add a route, then link (per-entity, medium effort) ### Phase 2 — Add a route, then link (per-entity, medium effort)
- [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.** - [x] **M6** Settings sub-sections → `/settings/:page/:section`; `SectionButton` is now a `SectionLink` (`<NavLink>`), the five `*_SELECTED` globals and `INTEGRATIONS_SETTINGS_TAB` are gone, and each page renders one `SettingsRoute` guard that canonicalises the bare route and a bogus section. **Needs runtime test.**
- [x] **M1** Capabilities → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `CapabilityPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.** - [x] **M1** Permissions → `/tasks|skills|processes/:dirName`, rows → `<Link>`; `PermissionPage` takes an explicit `basePath` (not reused from `endpoint`, which only happens to match). Selection is `useParams`, the mobile pane swap and back arrow are derived from it, delete navigates to the bare route, and the two per-item modes are `?edit=1` / `?new=1`. No `<Navigate>` guard: an unknown `dirName` gets the empty detail pane. **Needs runtime test.**
- [x] **M2** TaskLogs → `/task-logs/:id`; rows are `<Link>`s, `showDetail` deleted. **Needs runtime test.** - [x] **M2** TaskLogs → `/task-logs/:id`; rows are `<Link>`s, `showDetail` deleted. **Needs runtime test.**
- [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.** - [x] **M3** Activity → `/activity/:id`; the `{label, query}` selection object is gone — the id is the URL and the stream query is derived from the registry row. `/activity` also had no `usePageTitle` rule (it read "Officer"); added. **Needs runtime test.**
- [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder *rows* deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture. - [x] **M4** FileBrowser folders → `/files?path=`; breadcrumbs are `<Link>`s. Folder _rows_ deliberately still buttons — ⌘-click is multi-select, open is double-click; converting them needs an owner call on the gesture.
- [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab *set* stays local state, on purpose — see the findings row. - [x] **M5** CodeEditor active file → `/code-editor?file=`; tree file rows and tabs are `<Link>`s. The open-tab _set_ stays local state, on purpose — see the findings row.
- [x] **M7** Combobox — **deleted instead**. Zero callers since the initial commit; `href` on `SelectOption` was never set by anything, so the whole branch was unreachable. - [x] **M7** Combobox — **deleted instead**. Zero callers since the initial commit; `href` on `SelectOption` was never set by anything, so the whole branch was unreachable.
### Phase 3 — Whole-workspace routing decisions (needs a design call first) ### Phase 3 — Whole-workspace routing decisions (needs a design call first)
- [x] **Music**`/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.** - [x] **Music**`/music?path=<rel>`; `music:cwd` deleted; every drill-in (including the dock's now-playing tile, navigate-site 13) is a `<Link>`. `music:favorites` and `music:resync` stay — a view toggle and a refresh signal. **Needs runtime test.**
- [x] **Soulseek**`/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.** - [x] **Soulseek**`/soulseek/:section` with the peer in `?user=` and the search already in `?search=`; the two selection channels are deleted. Rooms and conversations are still `useState`. **Needs runtime test.**
- [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.** - [x] **M10** SystemMonitor scope → `/system-monitor/:scope`; `monitor:scope` channel deleted. **Needs runtime test.**
- [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.** - [x] **M9** Plans → `/plans/:name`; the auto-select-first effect is gone (the bare route is a real state: no plan open), and the `<select>` navigates instead of setting state. It stays a `<select>` — a native `<option>` cannot be an anchor, so this one has no cmd-click and the doc should not pretend otherwise; it is chrome for a single document, not a master list. Reading the route also turned up a path traversal in `GET /api/plans/:name` (hono percent-decodes params, so `..%2F..%2Fx` walked out of `plansDir`) — fixed with `basename()`. **Needs runtime test.**
### Phase 4 — Polish + borderline decisions ### Phase 4 — Polish + borderline decisions
- [x] Dock + Header + mobile sheet → react-router `<NavLink>`; both `isActive` helpers and their `useLocation`s deleted. `end` on Home only. **Needs runtime test.** - [x] Dock + Header + mobile sheet → react-router `<NavLink>`; both `isActive` helpers and their `useLocation`s deleted. `end` on Home only. **Needs runtime test.**
- [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands. - [ ] "New Chat" → `<Link to="/chat/new">` (`SessionList.tsx:68`) once H4's channel cleanup lands.
- [x] Jobs back button → `<Link to="/jobs">`, and `useNavigate` dropped from `PipelineJobDetail` (it had no - [x] Jobs back button → `<Link to="/jobs">`, and `useNavigate` dropped from `PipelineJobDetail` (it had no
@@ -279,6 +283,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
- [x] Decided/skipped: Jobs step deep-link (a feature, not a fix — owner's call), Preview slug (**void**: no such app), FileBrowser widget (stays local, on M4's rule). Reasoning for each in the LOW section. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle. Music favorites: **decided** — stays a channel, reasoning in the LOW section.) - [x] Decided/skipped: Jobs step deep-link (a feature, not a fix — owner's call), Preview slug (**void**: no such app), FileBrowser widget (stays local, on M4's rule). Reasoning for each in the LOW section. (Browser tabs: **done** — see the LOW section. Monitor scope: **done** as M10, it was not a view toggle. Music favorites: **decided** — stays a channel, reasoning in the LOW section.)
### Cross-cutting for the refactor itself ### Cross-cutting for the refactor itself
- [x] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal` - [x] Standardise a URL-as-source-of-truth pattern for panel selection (replace the `usePanelChannel`/`useGlobal`
selection channels in the map above with `useParams`/`useSearchParams`, keeping channels only for selection channels in the map above with `useParams`/`useSearchParams`, keeping channels only for
genuine signals/refresh buses). Done except `chat:selected-session` (H4), which is the chat agent's. genuine signals/refresh buses). Done except `chat:selected-session` (H4), which is the chat agent's.
@@ -332,6 +337,7 @@ publishers means changing the chat panel, which is another agent's, so it is wri
unimplemented. **The general lesson: cross-app intent that is not pure navigation should not be unimplemented. **The general lesson: cross-app intent that is not pure navigation should not be
encoded as a URL.** Creating a dashboard is five ordered state writes; expressing that as a link was encoded as a URL.** Creating a dashboard is five ordered state writes; expressing that as a link was
what made it silently breakable in the first place. See the status note §24 for the full write-up. what made it silently breakable in the first place. See the status note §24 for the full write-up.
- [x] Adopt `<NavLink>` (real react-router) for all nav chrome so active state stops being JS-derived. - [x] Adopt `<NavLink>` (real react-router) for all nav chrome so active state stops being JS-derived.
Done — `39125b5`. Note `end={item.to === '/'}`: without it NavLink treats `/` as an ancestor of Done — `39125b5`. Note `end={item.to === '/'}`: without it NavLink treats `/` as an ancestor of
every route, and with it on the rest a detail route would lose its tile. every route, and with it on the rest a detail route would lose its tile.
+1 -1
View File
@@ -136,7 +136,7 @@ account manager in recoverable form, and synced to whatever backs that phone up.
device, revocable per device, is the whole point. device, revocable per device, is the whole point.
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true: `user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member since 2026-08-07 `calendar` is a **grantable** permission, so an app password can belong to a member
and the column decides whose collection tree Radicale serves. It is load-bearing. and the column decides whose collection tree Radicale serves. It is load-bearing.
--- ---
+26 -24
View File
@@ -26,7 +26,7 @@ engine. But `loadOpenCodeSession` reads the transcript through the legacy route
run to completion with a real model reply: run to completion with a real model reply:
| read | api-created session | legacy-created session | | read | api-created session | legacy-created session |
|---|---|---| | ------------------------------------------ | ---------------------- | ---------------------- |
| `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript | | `GET /session/{id}/message` (what we call) | **`[]` — 0 messages** | 200, full transcript |
| `GET /api/session/{id}/message` | 200, 3 messages | **500** | | `GET /api/session/{id}/message` | 200, 3 messages | **500** |
| `GET /session/{id}` (the record) | 200, title + directory | 200 | | `GET /session/{id}` (the record) | 200, title + directory | 200 |
@@ -42,7 +42,7 @@ rather than erroring.
### 1b. The session list silently truncates at 50 ### 1b. The session list silently truncates at 50
`GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in `GET /api/session` defaults to **50 rows** and returns a `cursor.next`. Measured: with 50 sessions in
the store the list returns 50 *and still offers a next cursor*; adding a 51st and asking `?limit=200` the store the list returns 50 _and still offers a next cursor_; adding a 51st and asking `?limit=200`
returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100 returns 51 (and `limit` is capped at 100 — 200 is accepted for the list but `/history` rejects >100
with `Expected a value less than or equal to 100`). with `Expected a value less than or equal to 100`).
@@ -62,7 +62,7 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports
`{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists: `{"openapi":"3.1.0","info":{"title":"opencode","version":"1.0.0"}}`. What actually exists:
| | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** | | | **legacy** | **the `/api/*` surface** | **OpenCode 2.0 beta** |
|---|---|---|---| | ------------ | --------------------------------------------------------- | ----------------------------------- | ------------------------------------------------- |
| where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` | | where | in 1.18.16 | in 1.18.16 | separate product, binary `opencode2`, npm `@next` |
| routes | 111 paths | 51 paths | ~100 paths, still moving | | routes | 111 paths | 51 paths | ~100 paths, still moving |
| operationIds | `session.list` | **`v2.session.list`** | — | | operationIds | `session.list` | **`v2.session.list`** | — |
@@ -70,12 +70,12 @@ There is no version string "2.0" in the running server. `GET /doc` self-reports
| docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs | | docs | opencode.ai/docs/server (stale — never mentions `/api/*`) | undocumented publicly | opencode.ai/v2/docs |
So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its So "API 2.0" most likely means **the `/api/*` surface — which we already run on for turns**. Its
operation ids are literally `v2.*`. It is not something to adopt; it is something to *finish*. operation ids are literally `v2.*`. It is not something to adopt; it is something to _finish_.
Two qualifications, both from the source at tag `v1.18.16`: Two qualifications, both from the source at tag `v1.18.16`:
- **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`, - **Upstream calls it experimental.** `packages/protocol/src/api.ts` titles it `"opencode HttpApi"`,
version `"0.0.1"`, described as *"Experimental HttpApi surface for selected instance routes"*, with version `"0.0.1"`, described as _"Experimental HttpApi surface for selected instance routes"_, with
every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually every group annotated the same way. Meanwhile `/session/*` is the surface the public docs actually
document, and it is not deprecated. The internal direction is unambiguous; the external commitment is document, and it is not deprecated. The internal direction is unambiguous; the external commitment is
nil. nil.
@@ -88,15 +88,15 @@ Two qualifications, both from the source at tag `v1.18.16`:
`session.next.*` today, but put the names behind one mapping table, because they are scheduled to `session.next.*` today, but put the names behind one mapping table, because they are scheduled to
change wholesale. change wholesale.
Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: *"V1 coexistence is temporary… delete the Same for the `v2` suffix itself. `packages/schema/AGENTS.md`: _"V1 coexistence is temporary… delete the
V1 subtree when the legacy runtime is retired"* and *"Do not preserve `V2` as the permanent name for the V1 subtree when the legacy runtime is retired"_ and _"Do not preserve `V2` as the permanent name for the
replacement architecture."* Both halves of today's naming are transitional. replacement architecture."_ Both halves of today's naming are transitional.
**OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs **OpenCode 2.0 the product is a different question**, and the answer tonight is not yet: the beta docs
carry the banner *"we may wipe your data, things may break, and APIs, configuration, and plugin APIs carry the banner _"we may wipe your data, things may break, and APIs, configuration, and plugin APIs
may change"*, releases ship ~6/day, and the migration guide states three intentional breaking changes may change"_, releases ship ~6/day, and the migration guide states three intentional breaking changes
(plugin API, server API contracts, TUI config), with *"Integrations that call the V1 server API must (plugin API, server API contracts, TUI config), with _"Integrations that call the V1 server API must
migrate to the V2 API"*. No deprecation date for the legacy surface is published anywhere. migrate to the V2 API"_. No deprecation date for the legacy surface is published anywhere.
Two facts worth knowing regardless: Two facts worth knowing regardless:
@@ -130,7 +130,7 @@ plus `GET /config/providers` for the model list (`list-models.ts:58`). The one e
### 4a. Adding context to a turn that is already running ### 4a. Adding context to a turn that is already running
The capability the subprocess path could never have, and the reason the migration happened. The permission the subprocess path could never have, and the reason the migration happened.
``` ```
POST /api/session/{id}/prompt POST /api/session/{id}/prompt
@@ -139,12 +139,12 @@ POST /api/session/{id}/prompt
"delivery": "steer" | "queue", "resume": true|false } "delivery": "steer" | "queue", "resume": true|false }
``` ```
Spec description: *"Durably admit one session input and schedule agent-loop execution unless resume is Spec description: _"Durably admit one session input and schedule agent-loop execution unless resume is
false."* false."_
- **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the - **`delivery: "steer"` injects into the RUNNING turn** — the model takes the new text as part of the
work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but work in flight. No kill, no restart, no lost context. We already send it (`serve-runner.ts:199`) but
only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI *asks* for only on the accidental path: a message that happens to arrive mid-turn. Nothing in the UI _asks_ for
it, and nothing distinguishes "add this to what you're doing" from "here's my next message". it, and nothing distinguishes "add this to what you're doing" from "here's my next message".
- **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field - **`delivery: "queue"`** runs after the current turn. It must be stated explicitly — **the field
defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`). defaults to `steer`** — or two quick messages merge into one turn (`serve-runner.ts:268`).
@@ -188,13 +188,13 @@ that matter for building on it:
- `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session. - `after` is an **exclusive** lower bound on the durable seq, and the aggregate is the session.
Omitting it replays the session from 0. Omitting it replays the session from 0.
- **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`, - **Replay-then-live is gap-free by construction**: it reads `WHERE seq > after ORDER BY seq ASC`,
advances its cursor to the last row, and on every wake re-reads *the database* rather than draining a advances its cursor to the last row, and on every wake re-reads _the database_ rather than draining a
pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit pubsub buffer. Sequences are strictly monotonic and contiguous per session, enforced with explicit
`Sequence mismatch` / `Replay diverged` errors. `Sequence mismatch` / `Replay diverged` errors.
- **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery, - **The first cursor is free.** `POST …/prompt` returns `{admittedSeq, id, sessionID, prompt, delivery,
timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`. timeCreated, promotedSeq?}` — measured at 22 ms — and `admittedSeq` feeds straight back as `after`.
Note the two cursor kinds are unrelated: the session *list* uses an opaque base64url cursor Note the two cursor kinds are unrelated: the session _list_ uses an opaque base64url cursor
(`cursor.previous` / `cursor.next`), this one is a plain integer. (`cursor.previous` / `cursor.next`), this one is a plain integer.
**But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a **But the two streams are not interchangeable, and the schema says why.** `SessionDurableEvent` is a
@@ -233,14 +233,14 @@ The two "v2"s are not the same kind of change, which matters if we implement one
`{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to `{action, resource, effect}`; a request from `{permission, patterns[], metadata, always[], tool?}` to
`{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union `{action, resources[], save?[], metadata?, source?}`, with the tool linkage becoming a tagged union
`source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public `source: {type:"tool", messageID, callID}`; and the reply loses its free-text `message`. The public
V2 docs say the same in config terms: *"Do not use `permission`, `bash`, or `task` in V2 V2 docs say the same in config terms: _"Do not use `permission`, `bash`, or `task` in V2
configuration."* configuration."_
- **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of - **Questions v2 is a re-homing.** Field shapes are byte-identical to v1 — `questions[]` of
`{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and `{question, header, options[], multiple?, custom?}`, answers as `string[][]`. Only the namespace and
event names changed. event names changed.
Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the Which family a 1.18.16 agent actually emits is worth measuring before building UI: the manifest the
`/api` protocol is *built* from excludes the v1 families, but the server wires the **full** manifest `/api` protocol is _built_ from excludes the v1 families, but the server wires the **full** manifest
(`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event` (`makeApi({definitions: EventManifest.Latest.values()})`), which is why both appear in the `/api/event`
union on our own `/doc`. union on our own `/doc`.
@@ -277,7 +277,7 @@ Our mapper recognises 18 names and maps 7. The server emits **130 event type str
`created`, `deleted`, `updated`, `diff`). `created`, `deleted`, `updated`, `diff`).
| dropped | what it would give | | dropped | what it would give |
|---|---| | ------------------------------------------ | ---------------------------------------------------------------------------- |
| `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode | | `reasoning.started/delta/ended` | thinking, streamed — we show none for opencode |
| `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive | | `tool.input.delta` / `.started` / `.ended` | a tool call rendering as its arguments arrive |
| `tool.progress` | long tools reporting instead of appearing hung | | `tool.progress` | long tools reporting instead of appearing hung |
@@ -352,7 +352,9 @@ import { createOpencodeClient } from '@opencode-ai/sdk/v2';
const client = createOpencodeClient({ baseUrl }); const client = createOpencodeClient({ baseUrl });
const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' }); const admitted = await client.v2.session.prompt({ sessionID, prompt: { text }, delivery: 'steer' });
const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq }); const events = await client.v2.session.events({ sessionID, after: admitted.data.admittedSeq });
for await (const ev of events.stream) { /* ev.type, ev.durable.seq */ } for await (const ev of events.stream) {
/* ev.type, ev.durable.seq */
}
``` ```
`client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/ `client.v2.session.*` covers list/create/active/get/switchAgent/switchModel/prompt/compact/wait/
@@ -402,7 +404,7 @@ Then the two that are real features needing UI: **permissions/questions** (§4d)
- It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes** - It does not put us on OpenCode 2.0. Note the direction of travel there: the beta **removes**
`/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on `/api/session/{id}/history` and `/api/session/{id}/event` — the two durable routes item 6 depends on
— replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path, — replacing them with `GET /api/experimental/session/{id}/log?after=&follow=`. Same idea, new path,
`experimental/` prefix. So item 6 is worth doing *and* worth writing behind one function. `experimental/` prefix. So item 6 is worth doing _and_ worth writing behind one function.
--- ---
+7 -7
View File
@@ -86,13 +86,13 @@ Also fixed after the review, and not in this table because it was found by revie
a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See a superseded OpenCode turn ran its whole completion path against the turn that replaced it. See
`docs/opencode-phase1-review.md`. `docs/opencode-phase1-review.md`.
**What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave *wrongly* **What bucket 0 being closed does and does not mean.** Every defect that made OpenCode behave _wrongly_
is gone. What remains is bucket 1 — capabilities Claude has and OpenCode does not — and most of the is gone. What remains is bucket 1 — permissions Claude has and OpenCode does not — and most of the
visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are visible ones (token streaming, mid-turn injection, background tasks, interrupt-without-teardown) are
downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork. downstream of `stdin: 'ignore'` and therefore of the Phase 2 fork.
**The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering **The fork is REOPENED, unblocked, and worth taking.** The serve publishes a newer `/api/session/*` surface offering
those capabilities natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both those permissions natively, and on 1.18.16 **`delivery: "steer"` and `delivery: "queue"` are both
verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable verified working** — mid-turn injection and queueing, as primitives, plus `/interrupt` and a resumable
per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface per-session event stream. One blocker remains: `claude-sonnet-4-6` silently does not run on that surface
(it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open (it runs fine under `opencode run`). `docs/opencode-fork-decision.md` has the evidence, the open
@@ -102,7 +102,7 @@ passed that one model.
Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on Until the model question is answered, turns stay on `opencode run --dir`, which is verified working on
1.18.16. 1.18.16.
**Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` capability and carries **Crash-recovery state is not a gap either.** `state:sync` is sent to the `proxy` permission and carries
`proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` / `proxySecret` — it is the Anthropic proxy s state, not a chat recovery record — and `syncState` /
`getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode `getCachedState` have **no callers at all** outside `sidecar-registry.ts`. The row compared OpenCode
against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar against a mechanism officer never consults. The real recovery story now exists and is better: a sidecar
@@ -110,7 +110,7 @@ restart stops in-flight turns and writes the reason to `chat_session_events`, an
enumerates what is running. enumerates what is running.
**Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault` **Identity is correctly deferred, not forgotten.** `TODO.md:40-47` already records that `pty`, `vault`
and `opencode` receive no identity and are covered today only because those capabilities are owner-only — and `opencode` receive no identity and are covered today only because those permissions are owner-only —
"a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses "a correct outcome resting on the wrong layer". `chat` is `kind: execution`, which the grants API refuses
to share at any level, so this cannot be reached by a member. It is latent by construction. to share at any level, so this cannot be reached by a member. It is latent by construction.
@@ -123,7 +123,7 @@ something nothing renders. Left alone deliberately.
put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path put them behind the migration). `opencode run` takes attachments with `--file`, so the subprocess path
carries them today: the sidecar spills each image to a temp file for the turn and removes it in carries them today: the sidecar spills each image to a temp file for the turn and removes it in
`settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back `settle`. Verified end to end — a red PNG over the chat socket to `opencode/claude-sonnet-4-6` came back
"Red". `list-models` now reports each model's own `capabilities.input.image` instead of a hardcoded "Red". `list-models` now reports each model's own `permissions.input.image` instead of a hardcoded
`false`, so the composer gate became load-bearing in the right direction. `false`, so the composer gate became load-bearing in the right direction.
--- ---
@@ -132,7 +132,7 @@ carries them today: the sidecar spills each image to a temp file for the turn an
Ordered roughly by user-visible value. Ordered roughly by user-visible value.
| Capability | Claude | OpenCode | Depends on the fork? | | Permission | Claude | OpenCode | Depends on the fork? |
| --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- | | --------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------------------------- | -------------------- |
| Token streaming | `delta` events from `stream_event` | **No**`run` emits complete text parts (`runner.ts:176-177`) | **Yes** | | Token streaming | `delta` events from `stream_event` | **No**`run` emits complete text parts (`runner.ts:176-177`) | **Yes** |
| Mid-turn injection / queue-into-turn | streaming input queue | **No**`stdin: 'ignore'` | **Yes** | | Mid-turn injection / queue-into-turn | streaming input queue | **No**`stdin: 'ignore'` | **Yes** |
+4 -4
View File
@@ -36,7 +36,7 @@ for a follow-up that touches the socket contract, is the right split.
`013e629` flipped `images: true``false` for OpenCode models, and the commit says "61 OpenCode models `013e629` flipped `images: true``false` for OpenCode models, and the commit says "61 OpenCode models
now decline, the three Claude ones still accept". now decline, the three Claude ones still accept".
**Nothing declines.** No code in `src/workspaces` or `src/apps` reads that capability — the composer's **Nothing declines.** No code in `src/workspaces` or `src/apps` reads that permission — the composer's
image affordances are ungated. Grep for a consumer of the model's `images` field returns nothing: image affordances are ungated. Grep for a consumer of the model's `images` field returns nothing:
`InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and `InputArea`'s drop zone, the paste handler, and `AttachButton` all accept images regardless of model, and
`useAttachments` collects them regardless. `useAttachments` collects them regardless.
@@ -47,14 +47,14 @@ which is worth having, but B4 described a user-visible lie and that lie is still
Two ways to close it, and they are not equivalent: Two ways to close it, and they are not equivalent:
1. **Gate the composer on the capability.** Read the selected model's `images` flag and hide the drop 1. **Gate the composer on the permission.** Read the selected model's `images` flag and hide the drop
zone, the paste path and the attach-image button when it is false. Cheap. Makes the flag load-bearing, zone, the paste path and the attach-image button when it is false. Cheap. Makes the flag load-bearing,
so the flip in `013e629` starts doing something. so the flip in `013e629` starts doing something.
2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than 2. **Plumb images through `OpenCodeRunParams`** (currently Phase 4). Removes the limitation rather than
surfacing it. surfacing it.
(1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2) (1) is the honest one-liner Phase 0 was for; (2) is the real fix. Doing (1) now costs nothing if (2)
happens later — the gate simply stops firing once the capability is true. happens later — the gate simply stops firing once the permission is true.
--- ---
@@ -99,7 +99,7 @@ finishes and had no effect on either test. Worth knowing it exists; not worth ch
## Suggested next work, in order ## Suggested next work, in order
1. **B4 properly** — gate the composer on the model's `images` capability (above). 1. **B4 properly** — gate the composer on the model's `images` permission (above).
2. **Delete the `AGENTS.md` injection and the stale one-project comment**, now that `--dir` is verified. 2. **Delete the `AGENTS.md` injection and the stale one-project comment**, now that `--dir` is verified.
This is Phase 1 work and it is the thing Andre most wanted gone. This is Phase 1 work and it is the thing Andre most wanted gone.
3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in 3. **Then the rest of Phase 1** — the dead `event-mapper.ts` and SSE machinery, the wrong path names in
+4 -4
View File
@@ -17,7 +17,7 @@ it_ — earned its place three separate times, detailed below.
| `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory | | `22bcd7d` | B1 + B3 — session listing, and a resumed session's directory |
| `492509a` | B2 — route a resumed OpenCode session to OpenCode | | `492509a` | B2 — route a resumed OpenCode session to OpenCode |
| `013e629` | B4 (first attempt), B5, B6, thinking selector | | `013e629` | B4 (first attempt), B5, B6, thinking selector |
| `7774a25` | B4 properly — gate the composer on the capability | | `7774a25` | B4 properly — gate the composer on the permission |
| `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment | | `cfbf58c` | Delete the `AGENTS.md` injection + the one-project comment |
| `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` | | `d7b2231` | Delete the dead serve-turn client; add `opencode-serve-path.md` |
| `8b409e8` | Phase 1 finish — stale comments, version pin, first tests | | `8b409e8` | Phase 1 finish — stale comments, version pin, first tests |
@@ -91,8 +91,8 @@ conclusion independently, which was reassuring to read afterwards.)
**B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip **B4 — closed the way your review asked, not the way the parity doc did.** The doc offered the flag flip
as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because as "the honest one-liner"; you correctly pointed out that flipping it changed nothing observable because
no code read the capability. The composer now gates on it — drop zone, paste path, attach menu — so the no code read the permission. The composer now gates on it — drop zone, paste path, attach menu — so the
flag is load-bearing. Unknown model still allows images: a missing capability should not remove a flag is load-bearing. Unknown model still allows images: a missing permission should not remove a
working control. working control.
**Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still **Thinking selector — removed, not hidden.** The doc said hide; hiding a control that does nothing still
@@ -135,7 +135,7 @@ Testing was explicitly de-prioritised for this pass, so these are recorded rathe
## Suggested next, if you are writing the following spec ## Suggested next, if you are writing the following spec
1. **Exercise `opencode:list`** — it is the only new capability whose happy path is unproven. 1. **Exercise `opencode:list`** — it is the only new permission whose happy path is unproven.
2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not 2. **Decide the fork.** The blocker is gone; `opencode-serve-path.md` frames it. If the answer is "not
yet", say so in the parity doc so it stops reading as pending work. yet", say so in the parity doc so it stops reading as pending work.
3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and 3. **The remaining Phase 1 residue**: `sweepStaleServes` is `/proc`-based and a no-op on macOS (B8), and
+2 -2
View File
@@ -15,7 +15,7 @@ works, it is verified end to end, and its limits are all consequences of that on
The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16: The serve's `/api/session/*` surface offers, and I have run each of these against 1.18.16:
| Capability | How | Verified | | Permission | How | Verified |
| ------------------------ | --------------------------------------------------- | --------------------------------------------------- | | ------------------------ | --------------------------------------------------- | --------------------------------------------------- |
| Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn | | Mid-turn injection | `POST /prompt` `{delivery: "steer"}` | yes — steered a running turn |
| Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors | | Queue behind a turn | `POST /prompt` `{delivery: "queue"}` | yes — "ONE" then "TWO", no errors |
@@ -86,7 +86,7 @@ written and tested, and it is the only phase with no user-visible risk.
`POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the `POST /interrupt` for stop. Keep `opencode run` reachable by config so a bad day is one restart from the
known-good path. The switch is the deliverable, not a detail. known-good path. The switch is the deliverable, not a detail.
**Phase C — the capabilities that motivated it.** `delivery: "steer"` wired to the existing "send now" **Phase C — the permissions that motivated it.** `delivery: "steer"` wired to the existing "send now"
button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins button, `delivery: "queue"` to the queue, streaming deltas to the composer. These are the visible wins
and they are cheap once B holds. and they are cheap once B holds.
+1 -1
View File
@@ -79,7 +79,7 @@ behind what some machines run.
**Move turns onto the serve (`POST /session/{id}/message?directory=…`)** **Move turns onto the serve (`POST /session/{id}/message?directory=…`)**
- Unblocks the whole of parity Phase 3 at once — those six capabilities are all downstream of a - Unblocks the whole of parity Phase 3 at once — those six permissions are all downstream of a
persistent, addressable session. persistent, addressable session.
- Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already - Re-adopts an SSE stream officer must keep alive, demultiplex and reconnect. That machinery already
exists in the deleted code, so the cost is smaller than it looks. exists in the deleted code, so the cost is smaller than it looks.
+28 -27
View File
@@ -5,11 +5,11 @@ Agents are explicitly out of scope for the first pass.
## What this is for ## What this is for
Today every `execution` capability — terminal, chat, files, tasks, items, desktop, browser — runs as the Today every `execution` permission — terminal, chat, files, tasks, items, desktop, browser — runs as the
**owner's OS user in the owner's home**. That is why `capabilities/registry.ts` declares them **owner's OS user in the owner's home**. That is why `permissions/registry.ts` declares them
`kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one. `kind: 'execution'` and why `authorize.ts` strips them from a grant even if a row somehow contains one.
The registry says so out loud: *"revisit only if per-user home confinement is ever solved — and that is a The registry says so out loud: _"revisit only if per-user home confinement is ever solved — and that is a
project, not a checkbox."* project, not a checkbox."_
This is that project. A member gets a real Linux account whose home is the directory the platform already This is that project. A member gets a real Linux account whose home is the directory the platform already
provisions for them, and the surfaces that execute code run **as that account**. The payoff is three provisions for them, and the surfaces that execute code run **as that account**. The payoff is three
@@ -64,21 +64,21 @@ code has ever had for a non-owner home.
On this machine, verified 2026-08-11: On this machine, verified 2026-08-11:
| path | mode | consequence | | path | mode | consequence |
| --- | --- | --- | | ----------------- | ------- | ---------------------------------- |
| `/home/pastilhas` | 751 | traversable by anyone (no listing) | | `/home/pastilhas` | 751 | traversable by anyone (no listing) |
| `…/officer.dev` | 775 | listable by anyone | | `…/officer.dev` | 775 | listable by anyone |
| `…/platform/.env` | **664** | **world-readable** | | `…/platform/.env` | **664** | **world-readable** |
`platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with `platform/.env` holds `POSTGRES_URL`, the JWT signing secret and every service credential. A member with
a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than a real shell could read it and mint themselves an owner token, which makes the whole exercise worse than
not doing it — the capability model would be intact and completely bypassed. not doing it — the permission model would be intact and completely bypassed.
So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is So stage 1 includes: `chmod 600` on every `.env`, `chmod 751` on the project root so the tree is
traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env` traversable but not listable, and a **boot-time check that refuses to enable OS users while any `.env`
under the project root is group- or world-readable.** A prerequisite that is merely written down is a under the project root is group- or world-readable.** A prerequisite that is merely written down is a
prerequisite that gets skipped. prerequisite that gets skipped.
The same applies to `capabilities/` (775 today) and to the repo checkout itself: a member can read the The same applies to `permissions/` (775 today) and to the repo checkout itself: a member can read the
platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not. platform source. That is acceptable — it is not secret — but anything credential-shaped inside it is not.
## The mechanism, and the trap in it ## The mechanism, and the trap in it
@@ -88,7 +88,7 @@ platform source. That is acceptable — it is not secret — but anything creden
Verified on bun 1.3.10, 2026-08-11. From uid 1000: Verified on bun 1.3.10, 2026-08-11. From uid 1000:
```js ```js
Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }) // exit 0, prints "1000" Bun.spawn(['id', '-u'], { uid: 65534, gid: 65534 }); // exit 0, prints "1000"
``` ```
It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and It does not throw. It does not warn. It accepts the option and runs as the parent. Every agent, task and
@@ -98,7 +98,7 @@ Two honest qualifications, because the danger is narrower than it first looks:
- **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by - **Bun's own types do not declare `uid`**, so `bunx tsgo` rejects it. Typed code cannot reach this by
accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all. accident — confirmed while writing the test, which needs a cast to reproduce the behaviour at all.
- What *can* reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four - What _can_ reach it is a spread of untyped config, an `as any`, or a plain-JS sidecar. Two of the four
sidecars are `.mjs`. sidecars are `.mjs`.
So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through So the exposure is real but bounded, and the mitigation is the same either way: privilege drops go through
@@ -115,7 +115,7 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
``` ```
- `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to. - `--reuid`/`--regid` set the real ids, not just effective — there is nothing to switch back to.
- `--init-groups` applies the account's supplementary groups. Without it the process keeps the *owner's* - `--init-groups` applies the account's supplementary groups. Without it the process keeps the _owner's_
groups, which is a quiet way to retain access we just took away. groups, which is a quiet way to retain access we just took away.
- `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and - `--reset-env` clears the inherited environment and then sets `HOME`, `SHELL`, `USER`, `LOGNAME` and
`PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`, `PATH` from the target's passwd entry. Both halves matter: the parent's env contains the owner's `HOME`,
@@ -123,8 +123,8 @@ sudo -n setpriv --reuid=<user> --regid=<user> --init-groups --reset-env -- <argv
`.env`. `.env`.
**`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with **`sudo` is not optional, and the reason is not the uid.** Measured 2026-08-11: `--init-groups` fails with
`initgroups failed: Operation not permitted` for an unprivileged caller *even when reuid'ing to its own `initgroups failed: Operation not permitted` for an unprivileged caller _even when reuid'ing to its own
account*`setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n` account_`setgroups(2)` is root-only, unconditionally. So there is no unprivileged form of this. `-n`
makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no makes a missing sudoers entry an immediate error rather than a process hanging on a password prompt no
user will ever see. user will ever see.
@@ -143,7 +143,7 @@ That last line is the whole security property, demonstrated rather than asserted
test (`os-user.test.ts` → "does not pass the platform environment through"). test (`os-user.test.ts` → "does not pass the platform environment through").
`sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is `sudo -u <user>` alone would also work and be shorter. It is not used because its environment handling is
sudoers *policy*`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's sudoers _policy_`env_reset`, `env_keep`, `always_set_home` — and "which variables cross into a member's
shell" must not depend on a config file someone may have edited. shell" must not depend on a config file someone may have edited.
Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service Root is available: `scripts/setup/setup.sh` §4 installs `/etc/sudoers.d/officer-service` granting the service
@@ -227,7 +227,7 @@ crossed the ancestor that mattered.
2026-08-11; the superseded text is in the git history of this file, and the working state is 2026-08-11; the superseded text is in the git history of this file, and the working state is
`COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`. `COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md`.
It said the SDK "has nowhere to put a uid", so dropping privileges had to happen *outside* it, making a It said the SDK "has nowhere to put a uid", so dropping privileges had to happen _outside_ it, making a
member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951` member's turn its own process — "a change of shape rather than a flag". It is a flag: `sdk.d.ts:951`
exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote exposes `spawnClaudeCodeProcess`, documented for running Claude Code "in VMs, containers, or remote
environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the environments", and `node:child_process.spawn` already satisfies the `SpawnedProcess` shape it wants. So the
@@ -245,8 +245,9 @@ crossed the ancestor that mattered.
`POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and `POSTGRES_URL` and the JWT signing secret, so a member-uid process holding them could read every account and
sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The sign a token as the owner — more than their shell can do, and already refused by `assertSecretsClosed`. The
harness stays the service user's; only `claude` itself drops privileges. harness stays the service user's; only `claude` itself drops privileges.
- **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely - **`pty`, `vault` and `opencode` receive no identity at all** (`TODO.md` → Multi-user). pty keys purely
on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill *every* on a `sessionId` from the query string, and its `/_officer/sessions` endpoints list and kill _every_
session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell session on the box. Safe today only because terminal is owner-only. **The moment a member has a shell
that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after. that is a cross-user kill switch**, so it is fixed in the same stage as the terminal, not after.
- **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account - **Email change orphans a home.** The on-disk layout is keyed on email everywhere. Renaming an account
@@ -258,7 +259,7 @@ Stage 1 was exercised end to end against a throwaway `DATA_PATH` with a real `us
below was **observed**, not reasoned about: below was **observed**, not reasoned about:
| attempted, as the member | result | | attempted, as the member | result |
| --- | --- | | ---------------------------------------- | ------------------------ |
| write in own home | OK | | write in own home | OK |
| read `…/<email>/attachments/private.txt` | Permission denied | | read `…/<email>/attachments/private.txt` | Permission denied |
| `ls …/<email>/` (their own account dir) | Permission denied | | `ls …/<email>/` (their own account dir) | Permission denied |
@@ -275,8 +276,8 @@ Three bugs surfaced only by running it:
2. **A member could read another member's home.** `provisionUserDirs` created directories at the default 2. **A member could read another member's home.** `provisionUserDirs` created directories at the default
umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being umask (`755`), and the confinement pass only ever ran for the account being created. `DATA_PATH` being
unlistable is not protection when the child is world-readable and the attacker knows an email address. unlistable is not protection when the child is world-readable and the attacker knows an email address.
The skeleton is now created closed — `711` on the account directory, `700` inside — so *unconfined* is The skeleton is now created closed — `711` on the account directory, `700` inside — so _unconfined_ is
also *unreachable*. also _unreachable_.
3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is 3. **`platform/.env` was readable, and printing `JWT_SECRET` from a member's shell was confirmed.** This is
the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to the prerequisite above, demonstrated. It is now a boot check (`assertSecretsClosed`) that refuses to
start while any `.env` in the project root is group- or world-readable. start while any `.env` in the project root is group- or world-readable.
@@ -293,7 +294,7 @@ shell is. So:
- the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`, - the **file browser** genuinely cannot go above the home — that is path containment in `resolveUserPath`,
enforced by the platform; enforced by the platform;
- the **terminal** cannot *read* anything above the home, but is not confined to it. Confining it would - the **terminal** cannot _read_ anything above the home, but is not confined to it. Confining it would
mean a namespace or a chroot, which is a different and much larger feature. mean a namespace or a chroot, which is a different and much larger feature.
Say "cannot see behind it", not "cannot leave it". Say "cannot see behind it", not "cannot leave it".
@@ -305,9 +306,9 @@ themselves, able to have an agent do the same on their behalf. That needs two ke
alternatives: alternatives:
| | where | who holds the private half | what it is for | | | where | who holds the private half | what it is for |
| --- | --- | --- | --- | | ------------ | ------------------------ | ---------------------------- | -------------------------------------------- |
| **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | *they* SSH into this machine | | **inbound** | `~/.ssh/authorized_keys` | the member, on their laptop | _they_ SSH into this machine |
| **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | *the machine* authenticates to Gitea as them | | **outbound** | `~/.ssh/id_ed25519` | this machine, generated here | _the machine_ authenticates to Gitea as them |
The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal. The tempting simplification is "if they pasted a key, skip generating one." It breaks the actual goal.
Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket Agent forwarding covers a human in an interactive session; a **platform-spawned agent has no agent socket
@@ -316,14 +317,14 @@ inbound key is optional — an account without one is simply platform-only — a
generated regardless. generated regardless.
**No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks **No Linux password, ever.** `useradd` is called with none, which leaves `!` in shadow. That blocks
*password* login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere" _password_ login and does **not** block key auth, so "real user, reachable over SSH, no password anywhere"
is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing is the resting state. The privilege drop is `sudo -n setpriv` performed by the platform, so there is nothing
to authenticate. Keeping the platform password and the machine out of each other's business is the point: a to authenticate. Keeping the platform password and the machine out of each other's business is the point: a
Linux password would be a second door that changing the platform password does not close and deleting the Linux password would be a second door that changing the platform password does not close and deleting the
platform account does not lock. platform account does not lock.
**Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a **Validation is about line count, not key shape.** Every line of `authorized_keys` is a credential, so a
pasted value containing a newline would silently install a *second* authorized key. `validatePublicKey` pasted value containing a newline would silently install a _second_ authorized key. `validatePublicKey`
refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix refuses anything multi-line, refuses a private key with a message saying so, and refuses an options prefix
(`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can (`command="…" ssh-ed25519 …`) — legitimate OpenSSH, but not something anyone pastes by accident, and it can
force a command. force a command.
@@ -335,9 +336,9 @@ shell text, so nothing has to reason about quoting a value that came from a form
**`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable **`StrictHostKeyChecking accept-new`, not a seeded `known_hosts`.** The Gitea SSH endpoint is not knowable
at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port. at account-creation time — the platform stores an HTTP base URL, and SSH may be a different host or port.
The failure this avoids is specific: the default setting makes a first connection *prompt*, and a prompt in The failure this avoids is specific: the default setting makes a first connection _prompt_, and a prompt in
a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a a non-interactive agent turn is a hang, not an error. `accept-new` trusts on first use and still refuses a
*changed* host key, which is the attack that matters. _changed_ host key, which is the attack that matters.
**The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation **The generated public key is stored on the user row** (`users.os_ssh_public_key`) and shown after creation
and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing and on the user's row afterwards. It is public by definition, and it has an errand attached that nothing
@@ -446,5 +447,5 @@ Two consequences worth knowing:
3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` + 3. **The file browser**, rooted at the member's home. Containment already exists — `resolveUserPath` +
`isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new `isInside`, which has the `..`-escape fix in it — so this is a root-resolution change, not new
security code. security code.
4. **The terminal**, via `setpriv`, plus pty identity. One `execution` capability reopened. 4. **The terminal**, via `setpriv`, plus pty identity. One `execution` permission reopened.
5. **Agents.** Separately, later, with the SDK problem solved first. 5. **Agents.** Separately, later, with the SDK problem solved first.
+3 -3
View File
@@ -16,7 +16,7 @@ Three things are already true, which is why "nothing exactly blocks it":
- **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment - **Every API route stays mounted regardless of which sidecars run.** The light profile's own comment
states it: features whose sidecars are absent report themselves unavailable rather than disappearing. states it: features whose sidecars are absent report themselves unavailable rather than disappearing.
So the app store never needs to mount or unmount routes. So the app store never needs to mount or unmount routes.
- **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by capability. - **Officer already spawns nothing.** Sidecars are PM2 peers that dial in and register by permission.
Installing one is starting a process, not teaching officer about it. Installing one is starting a process, not teaching officer about it.
- **`service_connections` already solves the multi-user case**, including the part nobody would get - **`service_connections` already solves the multi-user case**, including the part nobody would get
right independently — see below. right independently — see below.
@@ -88,7 +88,7 @@ health checks already correct, so "install Gitea" does not become a tutorial.
platform/ the app platform/ the app
data/ DATA_PATH data/ DATA_PATH
dockers/ services the app store provisioned <- exclusively ours dockers/ services the app store provisioned <- exclusively ours
capabilities/ the file-based item store permissions/ the file-based item store
``` ```
`OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second `OFFICER_ROOT` is derived as the parent of `DATA_PATH` rather than configured separately — a second
@@ -239,7 +239,7 @@ Two things it needs before third parties touch it:
What a plugin author is promised, and bound by. To be written properly; the shape is: What a plugin author is promised, and bound by. To be written properly; the shape is:
- **Register** by name + capabilities over `/api/sidecar/register`; be reachable by capability. - **Register** by name + permissions over `/api/sidecar/register`; be reachable by permission.
- **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a - **Declare** an ID, an install shape, a compose template (if it provisions), a config prompt, and a
schema. schema.
- **May reference** `users.id`, and use `service_connections` under its own ID. - **May reference** `users.id`, and use `service_connections` under its own ID.
+66 -66
View File
@@ -32,7 +32,7 @@ Three consequences worth stating explicitly, because the audit turned on the thi
`src/servers/api/slskd/` is **70 lines total** and does exactly the two things it should: `src/servers/api/slskd/` is **70 lines total** and does exactly the two things it should:
| File | Lines | Role | | File | Lines | Role |
|---|---|---| | ------------------- | ----- | ---------------------------------------------------------------------------------------------------------------------------------- |
| `router.ts` | 51 | `all('/*')` catch-all. Forwards subpath + query + body, injects `X-Officer-User`, streams the response back. No routes of its own. | | `router.ts` | 51 | `all('/*')` catch-all. Forwards subpath + query + body, injects `X-Officer-User`, streams the response back. No routes of its own. |
| `sidecar-server.ts` | 19 | Remembers the port the sidecar reports on connect (`slskd:server`). Nothing else. | | `sidecar-server.ts` | 19 | Remembers the port the sidecar reports on connect (`slskd:server`). Nothing else. |
@@ -49,7 +49,7 @@ Today's three commits (`8032c8b`, `b7b91a2`, `dea9ee2`) touched **zero** platfor
The `soulseek_*` tables live in the shared `officer_db` package (`schema/soulseek.ts`, The `soulseek_*` tables live in the shared `officer_db` package (`schema/soulseek.ts`,
`queries/soulseek.ts`) rather than in the sidecar. Only the sidecar reads them — this was a `queries/soulseek.ts`) rather than in the sidecar. Only the sidecar reads them — this was a
deliberate call (one database, schema isolated in its own file, `soulseek_` prefix) and it stands. deliberate call (one database, schema isolated in its own file, `soulseek_` prefix) and it stands.
The cost to remember: `bun db:push` diffs the *whole* schema, which is why soulseek DDL is The cost to remember: `bun db:push` diffs the _whole_ schema, which is why soulseek DDL is
hand-applied. hand-applied.
## The smell: the frontend speaks slskd ## The smell: the frontend speaks slskd
@@ -57,7 +57,7 @@ hand-applied.
**37 raw `/slskd/api/v0/…` calls from React, against 10 `/slskd/_officer/…` calls.** **37 raw `/slskd/api/v0/…` calls from React, against 10 `/slskd/_officer/…` calls.**
| File | Raw slskd calls | | File | Raw slskd calls |
|---|---| | ----------------------- | --------------- |
| `SoulseekTransfers.tsx` | 8 | | `SoulseekTransfers.tsx` | 8 |
| `SoulseekRooms.tsx` | 6 | | `SoulseekRooms.tsx` | 6 |
| `SoulseekChat.tsx` | 5 | | `SoulseekChat.tsx` | 5 |
@@ -118,7 +118,7 @@ Under that line, the three files above are the work. The other six are a naming/
`src/servers/sidecar/slskd/` — what "the sidecar owns its job" already looks like: `src/servers/sidecar/slskd/` — what "the sidecar owns its job" already looks like:
| File | Role | | File | Role |
|---|---| | ------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `index.ts` | Reverse proxy to slskd on a random loopback port; documents the whole `/api/slskd/*` contract; reports its port to the platform. | | `index.ts` | Reverse proxy to slskd on a random loopback port; documents the whole `/api/slskd/*` contract; reports its port to the platform. |
| `upstream.ts` | The only holder of `SLSKD_URL` / `SLSKD_API_KEY`. | | `upstream.ts` | The only holder of `SLSKD_URL` / `SLSKD_API_KEY`. |
| `officer.ts` | The `/_officer/*` routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. | | `officer.ts` | The `/_officer/*` routes — favourites, browse snapshots, tree levels, filtered search, downloads. Features slskd has no concept of. |
@@ -138,7 +138,7 @@ re-derived later.
The eight sidecars, from `ecosystem.config.cjs`: The eight sidecars, from `ecosystem.config.cjs`:
| PM2 process | Entry point | | PM2 process | Entry point |
|---|---| | ------------------ | ---------------------------------------------------------- |
| `officer-claude` | `src/servers/sidecar/claude/index.ts` | | `officer-claude` | `src/servers/sidecar/claude/index.ts` |
| `officer-opencode` | `src/servers/sidecar/opencode/index.ts` | | `officer-opencode` | `src/servers/sidecar/opencode/index.ts` |
| `officer-email` | `src/servers/sidecar/email/index.ts` | | `officer-email` | `src/servers/sidecar/email/index.ts` |
@@ -155,7 +155,7 @@ The eight sidecars, from `ecosystem.config.cjs`:
entirely in the main process, with no sidecar owning any of it. entirely in the main process, with no sidecar owning any of it.
| Surface | Lines | | Surface | Lines |
|---|---| | ---------------------------------------------------------------------- | -------- |
| Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 | | Compliant proxy: `api/music/router.ts` + `api/music/sidecar-server.ts` | 88 |
| `hono.ts` (3) + `protocol.ts` (1) | 4 | | `hono.ts` (3) + `protocol.ts` (1) | 4 |
| `api/cliamp/websocket.ts` | 201 | | `api/cliamp/websocket.ts` | 201 |
@@ -171,20 +171,20 @@ entirely in the main process, with no sidecar owning any of it.
`PULSE_SINK: 'virtual_out'` and `ALSA_CONFIG_PATH` injected. Pumps stdout/stderr into JSON frames `PULSE_SINK: 'virtual_out'` and `ALSA_CONFIG_PATH` injected. Pumps stdout/stderr into JSON frames
(`:124-160`), forwards `{type:'input'}` to stdin (`:173-185`), kills the child on close (`:187-198`). (`:124-160`), forwards `{type:'input'}` to stdin (`:173-185`), kills the child on close (`:187-198`).
Child processes are held in a module-level `Map` (`:22`). Child processes are held in a module-level `Map` (`:22`).
*Belongs in* `sidecar/music/`, which already runs its own loopback HTTP server _Belongs in_ `sidecar/music/`, which already runs its own loopback HTTP server
(`sidecar/music/index.ts:140`). *Obstacle:* a browser-held WebSocket with bidirectional keystroke (`sidecar/music/index.ts:140`). _Obstacle:_ a browser-held WebSocket with bidirectional keystroke
traffic — but the relay pattern already exists twice (`server.tsx:164-228` for dev-server, traffic — but the relay pattern already exists twice (`server.tsx:164-228` for dev-server,
`server.tsx:323-326` for vault). `server.tsx:323-326` for vault).
2. **PulseAudio host-daemon bootstrap**`server.tsx:391-436`. A startup IIFE that locates 2. **PulseAudio host-daemon bootstrap**`server.tsx:391-436`. A startup IIFE that locates
`pulseaudio`/`pactl`, runs `pulseaudio --start -D` if the daemon is down (`:401-411`), then greps `pulseaudio`/`pactl`, runs `pulseaudio --start -D` if the daemon is down (`:401-411`), then greps
`pactl list short sinks` and loads `module-null-sink sink_name=virtual_out` if absent (`:414-435`). `pactl list short sinks` and loads `module-null-sink sink_name=virtual_out` if absent (`:414-435`).
Runs unconditionally at every boot even if nobody opens the player. Runs unconditionally at every boot even if nobody opens the player.
*Belongs in* the music sidecar's startup. *Obstacle:* none technical — same host, `pactl` works _Belongs in_ the music sidecar's startup. _Obstacle:_ none technical — same host, `pactl` works
identically. Must move together with (1) and (3), since the sink must exist before they start. identically. Must move together with (1) and (3), since the sink must exist before they start.
3. **Host audio capture → browser PCM**`api/cliamp/audio-ws.ts:1-91`. Spawns 3. **Host audio capture → browser PCM**`api/cliamp/audio-ws.ts:1-91`. Spawns
`parec --format=s16le --rate=44100 --channels=2 -d virtual_out.monitor` (`:28-33`) and pushes each `parec --format=s16le --rate=44100 --channels=2 -d virtual_out.monitor` (`:28-33`) and pushes each
chunk to the browser as a binary frame (`:44-73`). Hardcoded format, sample rate, channel count and chunk to the browser as a binary frame (`:44-73`). Hardcoded format, sample rate, channel count and
monitor device name — pipeline domain knowledge. *Obstacle:* continuous binary PCM, so a relay hop monitor device name — pipeline domain knowledge. _Obstacle:_ continuous binary PCM, so a relay hop
costs a copy per chunk. costs a copy per chunk.
4. **ALSA config shipped inside the API tree**`api/cliamp/asoundrc:1-9`, passed via 4. **ALSA config shipped inside the API tree**`api/cliamp/asoundrc:1-9`, passed via
`ALSA_CONFIG_PATH` (`websocket.ts:8`, `:112`). Upstream config in the thin-proxy process. Moves for `ALSA_CONFIG_PATH` (`websocket.ts:8`, `:112`). Upstream config in the thin-proxy process. Moves for
@@ -232,7 +232,7 @@ The transport proxy is right; the platform owns the entire Vaultwarden **auth/se
domain. This is the worst offender of the eight, and the one where placement has real consequences. domain. This is the worst offender of the eight, and the one where placement has real consequences.
| Surface | Lines | | Surface | Lines |
|---|---| | ------------------------------------------------------------------------------------------------ | ----------------------------------------------- |
| `api/vault/router.ts` | 169 | | `api/vault/router.ts` | 169 |
| `api/vault/websocket.ts` | 164 | | `api/vault/websocket.ts` | 164 |
| `api/vault/broker.ts` | 79 | | `api/vault/broker.ts` | 79 |
@@ -249,7 +249,8 @@ For scale: the sidecar itself is 295 lines and is a genuine dumb pass-through
`VAULTWARDEN_URL`). `VAULTWARDEN_URL`).
Two structural notes before the findings: Two structural notes before the findings:
- `api/vault/router.ts:123` *is* an `all('/*')` catch-all, but it is not thin — it **replaces** the
- `api/vault/router.ts:123` _is_ an `all('/*')` catch-all, but it is not thin — it **replaces** the
`Authorization` header with a platform-held upstream credential (`:137-141`) and implements `Authorization` header with a platform-held upstream credential (`:137-141`) and implements
401-refresh-retry (`:158-165`). 401-refresh-retry (`:158-165`).
- It does **not** inject `X-Officer-User` (contrast `api/slskd/router.ts:34`, - It does **not** inject `X-Officer-User` (contrast `api/slskd/router.ts:34`,
@@ -277,7 +278,7 @@ Two structural notes before the findings:
token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials token>`. `open` (`:55-118`) verifies the platform JWT, fetches the upstream token, dials
`ws://127.0.0.1:<sidecarPort>` and runs a full buffered bidirectional pipe — **which the sidecar `ws://127.0.0.1:<sidecarPort>` and runs a full buffered bidirectional pipe — **which the sidecar
already implements** (`sidecar/vault/index.ts:45-114`, `:143-151`). Frames are relayed twice. already implements** (`sidecar/vault/index.ts:45-114`, `:143-151`). Frames are relayed twice.
*Obstacle:* Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that _Obstacle:_ Bun requires a synchronous upgrade, hence the deferred validation at `:60-70`; that
pattern stays, the token lookup at `:73` should not. pattern stays, the token lookup at `:73` should not.
4. **The platform is the vault's key escrow**`api/vault/router.ts:107-120`. `PUT /unlock-key` 4. **The platform is the vault's key escrow**`api/vault/router.ts:107-120`. `PUT /unlock-key`
persists a `wrappedKey` (`:111`); `GET /unlock-key` hands it back to any owner session (`:117-119`). persists a `wrappedKey` (`:111`); `GET /unlock-key` hands it back to any owner session (`:117-119`).
@@ -290,7 +291,7 @@ Two structural notes before the findings:
`queries/vault.ts:22-23,34-35,67-68,83,88`. Derives an AES-256-GCM key as `queries/vault.ts:22-23,34-35,67-68,83,88`. Derives an AES-256-GCM key as
`SHA-256(VAULT_STORE_KEY)` (`:12-20`) and runs `createCipheriv`/`createDecipheriv` (`:23-39`). `SHA-256(VAULT_STORE_KEY)` (`:12-20`) and runs `createCipheriv`/`createDecipheriv` (`:23-39`).
Because the vault router imports `officerdb` (`router.ts:10`, `token-store.ts:1`), all of this runs Because the vault router imports `officerdb` (`router.ts:10`, `token-store.ts:1`), all of this runs
inside `officer`. *Obstacle:* `crypto.ts` lives in the shared package, so it is importable from inside `officer`. _Obstacle:_ `crypto.ts` lives in the shared package, so it is importable from
anywhere; moving it means moving the vault queries out of the shared package or enforcing a anywhere; moving it means moving the vault queries out of the shared package or enforcing a
sidecar-only import boundary. No config obstacle — both processes read the same `.env`. sidecar-only import boundary. No config obstacle — both processes read the same `.env`.
6. **Vault tables are read/written by the platform, not the sidecar**`queries/vault.ts:18-101`, 6. **Vault tables are read/written by the platform, not the sidecar**`queries/vault.ts:18-101`,
@@ -299,7 +300,7 @@ Two structural notes before the findings:
imports **no** DB module at all. Exact inverse of the intended ownership. imports **no** DB module at all. Exact inverse of the intended ownership.
7. **Auth flows reach into vault storage directly**`api/auth/signout.ts:10`, 7. **Auth flows reach into vault storage directly**`api/auth/signout.ts:10`,
`revoke-handler.ts:18-19`, `panic-handler.ts:15-16`. Signout deletes the token row; distress and `revoke-handler.ts:18-19`, `panic-handler.ts:15-16`. Signout deletes the token row; distress and
panic also burn the protector key. The *policy* is platform-level; the *mechanism* — direct DELETEs panic also burn the protector key. The _policy_ is platform-level; the _mechanism_ — direct DELETEs
against the sidecar's tables — is not. All three are already best-effort `.catch(() => {})`, so against the sidecar's tables — is not. All three are already best-effort `.catch(() => {})`, so
failure semantics wouldn't worsen behind a sidecar call. failure semantics wouldn't worsen behind a sidecar call.
8. **Dead weight**`router.ts:38-48` is a hand-written `GET /_health` passthrough the catch-all 8. **Dead weight**`router.ts:38-48` is a hand-written `GET /_health` passthrough the catch-all
@@ -309,13 +310,13 @@ Two structural notes before the findings:
strings — fix (2) and it's unnecessary. strings — fix (2) and it's unnecessary.
9. **Mounted outside the protected tree**`hono.ts:73-77`. `route('/api/vault', …)` sits outside 9. **Mounted outside the protected tree**`hono.ts:73-77`. `route('/api/vault', …)` sits outside
`protectedRouter`, so the router re-implements its own stack (`router.ts:31-33`: `originMiddleware`, `protectedRouter`, so the router re-implements its own stack (`router.ts:31-33`: `originMiddleware`,
`userMiddleware`, `ownerGate`). *Real constraint, probably why:* it deliberately avoids `userMiddleware`, `ownerGate`). _Real constraint, probably why:_ it deliberately avoids
`bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from `bodyParser()` so bodies stream (`router.ts:14`), and `protectedRouter` would inherit it from
`hono.ts:88` and buffer vault attachments. `hono.ts:88` and buffer vault attachments.
10. **Stale comments on a security boundary**`hono.ts:73-76` and 10. **Stale comments on a security boundary**`hono.ts:73-76` and
`origin-validation.ts:36-39,61-64` both claim vault requests "carry their own Bitwarden bearer `origin-validation.ts:36-39,61-64` both claim vault requests "carry their own Bitwarden bearer
token, not a platform session JWT" and that `userMiddleware` would 401 them. Untrue since token, not a platform session JWT" and that `userMiddleware` would 401 them. Untrue since
`router.ts:32-33` requires a valid platform JWT *and* owner status on every request. Also, `router.ts:32-33` requires a valid platform JWT _and_ owner status on every request. Also,
`VAULT_AUTH_SPEC.md` (cited at `router.ts:12`, `schema/vault.ts:4`) and `VAULT_AUTH_SPEC.md` (cited at `router.ts:12`, `schema/vault.ts:4`) and
`BITWARDEN_SIDECAR_PROMPT.md` (cited at `sidecar/vault/upstream.ts:3`) **do not exist** in the repo. `BITWARDEN_SIDECAR_PROMPT.md` (cited at `sidecar/vault/upstream.ts:3`) **do not exist** in the repo.
Not logic, but exactly the drift that makes someone loosen a gate by mistake. Not logic, but exactly the drift that makes someone loosen a gate by mistake.
@@ -327,7 +328,7 @@ every `officerdb` vault export across `src/servers` and `src/databases`; the onl
User-key derivation is genuinely client-side (the platform only relays `Kdf*` params at User-key derivation is genuinely client-side (the platform only relays `Kdf*` params at
`router.ts:96-101`) — the one credential decision that is correctly placed. `router.ts:96-101`) — the one credential decision that is correctly placed.
*Shortest path to compliance (inferred, not attempted):* move `broker.ts`, `token-store.ts`, _Shortest path to compliance (inferred, not attempted):_ move `broker.ts`, `token-store.ts`,
`/session/login`, `/unlock-key`, the vault queries and `crypto.ts` into `sidecar/vault/`; have the `/session/login`, `/unlock-key`, the vault queries and `crypto.ts` into `sidecar/vault/`; have the
router inject `X-Officer-User` instead of `Authorization`; reduce `websocket.ts` to router inject `X-Officer-User` instead of `Authorization`; reduce `websocket.ts` to
origin-check + verify + upgrade + dumb pipe; delete `/_health` and `proxy-util.ts`; replace the three origin-check + verify + upgrade + dumb pipe; delete `/_health` and `proxy-util.ts`; replace the three
@@ -342,7 +343,7 @@ The largest violation after email, and the one with the worst consequences, beca
for survivability. They overlap deliberately. for survivability. They overlap deliberately.
There is **no `/api/claude` mount, no proxy router, and no `X-Officer-User` anywhere on this path.** There is **no `/api/claude` mount, no proxy router, and no `X-Officer-User` anywhere on this path.**
Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it *drives* it, Nothing here is shaped like slskd. The platform does not forward to the claude sidecar; it _drives_ it,
over a typed RPC vocabulary, and interprets everything that comes back. over a typed RPC vocabulary, and interprets everything that comes back.
A structural fact worth stating before the list, because it inverts the usual reading: **the sidecar A structural fact worth stating before the list, because it inverts the usual reading: **the sidecar
@@ -363,7 +364,7 @@ than the owner of it. Every other item below is downstream of that.
the machinery that makes a restart lossy — it is the durable writer, and it sits on the far side the machinery that makes a restart lossy — it is the durable writer, and it sits on the far side
of the socket from the process producing the events. of the socket from the process producing the events.
3. **`api/chat/claude-sessions.ts:1-361` — a reimplementation of Claude's transcript format.** The 3. **`api/chat/claude-sessions.ts:1-361` — a reimplementation of Claude's transcript format.** The
platform reads and *writes* `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding platform reads and _writes_ `~/.claude/projects/<slug>/<uuid>.jsonl` directly: the slug encoding
(`:39`), the entry schema (`:69-78`), content-block decoding (`:143-227`), listing (`:350-361`), (`:39`), the entry schema (`:69-78`), content-block decoding (`:143-227`), listing (`:350-361`),
delete-by-unlink (`:253-258`), a 32KB `readSync` plus a `"cwd":"…"` regex to recover a session's delete-by-unlink (`:253-258`), a 32KB `readSync` plus a `"cwd":"…"` regex to recover a session's
directory (`:294-306`), and — the sharpest example — **rename implemented by appending a directory (`:294-306`), and — the sharpest example — **rename implemented by appending a
@@ -378,7 +379,7 @@ than the owner of it. Every other item below is downstream of that.
`spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with `spawnAndWaitForRegistration`: a per-email `Bun.spawn` of `user-instance.ts` with
`stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters` `stdout: 'inherit', stderr: 'inherit'` (`:240-241`), a `claudeProcs` Map, a `claudeSpawnWaiters`
Map, a 15s timeout and a 50ms registration poll (`:259-267`). Plus the claude verbs at `:306-358` Map, a 15s timeout and a 50ms registration poll (`:259-267`). Plus the claude verbs at `:306-358`
and a broadcast fallback at `:339-346`. `:88-90` uses `capabilities.includes('proxy')` as a and a broadcast fallback at `:339-346`. `:88-90` uses `permissions.includes('proxy')` as a
stand-in for "is this the claude sidecar", which is only true by accident of naming. stand-in for "is this the claude sidecar", which is only true by accident of naming.
6. **`generate-container-context.ts:135-182` (+ `:50-133`) — the platform writes the CLI's config.** 6. **`generate-container-context.ts:135-182` (+ `:50-133`) — the platform writes the CLI's config.**
It authors `~/.claude/settings.json`: a `Stop` hook curling It authors `~/.claude/settings.json`: a `Stop` hook curling
@@ -387,7 +388,7 @@ than the owner of it. Every other item below is downstream of that.
(`:163-179`). Called from `users/provision.ts:28-38`. Two notes: the hook points at the platform, (`:163-179`). Called from `users/provision.ts:28-38`. Two notes: the hook points at the platform,
so it fails during exactly the restart window that matters; and the permission posture is a so it fails during exactly the restart window that matters; and the permission posture is a
deliberate documented choice (`platform/CLAUDE.md`: agents run unsandboxed as the owner) that is deliberate documented choice (`platform/CLAUDE.md`: agents run unsandboxed as the owner) that is
being *implemented in the wrong process*, not a mistake. being _implemented in the wrong process_, not a mistake.
7. **`api/activity/router.ts:1-191` — the platform walks the agent's scratch tree.** Reads 7. **`api/activity/router.ts:1-191` — the platform walks the agent's scratch tree.** Reads
`/tmp/claude-<uid>/<encoded-cwd>/tasks/<id>.output` (`:24-61`, keyed on `/tmp/claude-<uid>/<encoded-cwd>/tasks/<id>.output` (`:24-61`, keyed on
`startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the `startsWith('claude-')` at `:34`) and tails it over SSE (`:116-191`). Another private layout the
@@ -419,7 +420,7 @@ than the owner of it. Every other item below is downstream of that.
`sk-ant-api03-<uuid>` keys (`:10`) — the live copy is `sidecar/claude/proxy.ts:135`. A stale second `sk-ant-api03-<uuid>` keys (`:10`) — the live copy is `sidecar/claude/proxy.ts:135`. A stale second
implementation of the credential path is worth deleting on security grounds alone, not just tidiness. implementation of the credential path is worth deleting on security grounds alone, not just tidiness.
*What the sidecar already has right:* the Anthropic proxy genuinely lives in the PM2-managed sidecar _What the sidecar already has right:_ the Anthropic proxy genuinely lives in the PM2-managed sidecar
(`sidecar/claude/index.ts:20`), so the platform never holds an API key at rest, and `ANTHROPIC_BASE_URL` (`sidecar/claude/index.ts:20`), so the platform never holds an API key at rest, and `ANTHROPIC_BASE_URL`
points at the sidecar (`sidecar-registry.ts:234`). The credential path is roughly correct. It is the points at the sidecar (`sidecar-registry.ts:234`). The credential path is roughly correct. It is the
process topology, the transport direction and the domain logic that are not. process topology, the transport direction and the domain logic that are not.
@@ -428,7 +429,7 @@ process topology, the transport direction and the domain logic that are not.
The worst of the eight by volume, and the only one where the arrow points backwards end to end: The worst of the eight by volume, and the only one where the arrow points backwards end to end:
**≈3,238 platform lines** (2,875 of them in seven files) against a **314-line sidecar** — and the **≈3,238 platform lines** (2,875 of them in seven files) against a **314-line sidecar** — and the
sidecar *imports platform code back out* (`sidecar/email/email-idle.ts:3` imports sidecar _imports platform code back out_ (`sidecar/email/email-idle.ts:3` imports
`../../api/email/resync`). There is no proxy router, no `email:server` port event, and no forwarding of `../../api/email/resync`). There is no proxy router, no `email:server` port event, and no forwarding of
any kind. `emailRouter` implements 18 concrete endpoints itself. any kind. `emailRouter` implements 18 concrete endpoints itself.
@@ -460,7 +461,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
work.** `gmailResync` (`:43-63`), `imapResync` (`:148-264`), `resolveImapAuth` (`:118-146`), work.** `gmailResync` (`:43-63`), `imapResync` (`:148-264`), `resolveImapAuth` (`:118-146`),
`refreshCredentials` (`:21-41`), and `performResync` (`:276-284`) which coalesces concurrent `refreshCredentials` (`:21-41`), and `performResync` (`:276-284`) which coalesces concurrent
resyncs through an **in-process Map**. It is imported by both `sidecar/email/email-cron.ts:2` and resyncs through an **in-process Map**. It is imported by both `sidecar/email/email-cron.ts:2` and
`email-idle.ts:3` *and* by `accounts.ts:159` — i.e. by two different processes. Each gets its own `email-idle.ts:3` _and_ by `accounts.ts:159` — i.e. by two different processes. Each gets its own
copy of the Map, so the coalescing silently does nothing across the boundary. This is what copy of the Map, so the coalescing silently does nothing across the boundary. This is what
"importing platform code back out" costs. "importing platform code back out" costs.
6. **`api/email/accounts.ts:1-259` — account setup does live IMAP.** Validation by real connection on 6. **`api/email/accounts.ts:1-259` — account setup does live IMAP.** Validation by real connection on
@@ -486,7 +487,7 @@ Read plainly: the sidecar is a cron/IDLE trigger, and the platform is the mail c
12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is 12. **`src/servers/sidecar/email-cron.ts` — 92 dead lines**, imported by nothing (the live one is
`sidecar/email/email-cron.ts`). `sidecar/email/email-cron.ts`).
*Shortest path (inferred):* this one is a rewrite, not a move. The realistic first step is not _Shortest path (inferred):_ this one is a rewrite, not a move. The realistic first step is not
relocating `email-db.ts` — it is deleting the duplicate clients (items 7 and 8) and moving the two relocating `email-db.ts` — it is deleting the duplicate clients (items 7 and 8) and moving the two
queue handlers (items 2 and 3) into the sidecar so sync stops dying with `officer`. The store itself queue handlers (items 2 and 3) into the sidecar so sync stops dying with `officer`. The store itself
can follow later, behind a proxy router. can follow later, behind a proxy router.
@@ -521,7 +522,7 @@ already does the same job. That makes this the cheapest of the non-compliant sur
6. **`channels/send-opencode.ts:29-66` — the terminal-event set (`:33-37`) and a resume policy keyed 6. **`channels/send-opencode.ts:29-66` — the terminal-event set (`:33-37`) and a resume policy keyed
on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the on the `ses_` id prefix (`:42-45`).** Protocol knowledge encoded as a string prefix, in the
platform. platform.
7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents capability 7. **`api/chat/list-models.ts:2, 11-59` — fetches `/config/providers` and then invents permission
metadata for the results (`:42-45`).** metadata for the results (`:42-45`).**
8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`** 8. **`api/chat/chat.ts:13-19, 44, 56-57, 68, 80-81, 91` — CRUD dispatch on `isOpenCodeSessionId`**
(`opencode-sessions.ts:112`, a `startsWith('ses_')` test). (`opencode-sessions.ts:112`, a `startsWith('ses_')` test).
@@ -573,8 +574,8 @@ already exists in the same codebase.
backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is backoff table (`:37` vs `connect.ts:22`). Its types are JSDoc (`:39`), so `protocol.ts:145-156` is
unenforced against it. The actual blocker to moving it is mundane: sibling `templates/` files on unenforced against it. The actual blocker to moving it is mundane: sibling `templates/` files on
disk (`:27-29, 61, 66, 71``.zshrc`, `.tmux.conf`, `starship-officer.toml`, and an unused disk (`:27-29, 61, 66, 71``.zshrc`, `.tmux.conf`, `starship-officer.toml`, and an unused
`.zshenv`). So a `git mv`, not a rewrite. *(Inferred: the `.mjs`/node choice is probably a `.zshenv`). So a `git mv`, not a rewrite. _(Inferred: the `.mjs`/node choice is probably a
node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)* node-pty native-addon workaround — corroborated by the comment at `api/cliamp/websocket.ts:100`.)_
3. **Every PTY byte transits the main process, double-JSON-encoded.** Plus terminal-specific query 3. **Every PTY byte transits the main process, double-JSON-encoded.** Plus terminal-specific query
parsing in the shared upgrade handler (`server.tsx:248-252`, `WSData:51-52`) and wiring at `:7, 38, parsing in the shared upgrade handler (`server.tsx:248-252`, `WSData:51-52`) and wiring at `:7, 38,
143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as 143, 234, 335`. Auth at `:236-246` is correct. Identity ships **inside the payload** as
@@ -588,7 +589,7 @@ already exists in the same codebase.
than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same than `getOwnerHomeDir` (`data-path.ts:34`), unlike the eight other host-executing surfaces. Same
result on this machine (`HOME_DIR` is set and equals `HOME`), divergent anywhere it isn't. result on this machine (`HOME_DIR` is set and equals `HOME`), divergent anywhere it isn't.
*Shortest path (inferred):* `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates, _Shortest path (inferred):_ `git mv` the sidecar into `src/servers/sidecar/pty/` with its templates,
switch it to `connect.ts`, move the `PtyInitConfig` construction and cwd resolution into it, and switch it to `connect.ts`, move the `PtyInitConfig` construction and cwd resolution into it, and
replace `websocket.ts` with the `devServerWebsocket` relay shape. The detach-on-disconnect policy moves replace `websocket.ts` with the `devServerWebsocket` relay shape. The detach-on-disconnect policy moves
with it. with it.
@@ -625,7 +626,7 @@ pile of leaked logic.
7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is already 7. Wiring at `server.tsx:13, 45, 149, 234, 339` is fine, and **`hono.ts:37, 122` is already
reference-shaped** (two lines). reference-shaped** (two lines).
*Adjacent, and its own domain rather than a vnc violation:* the browser relay — _Adjacent, and its own domain rather than a vnc violation:_ the browser relay —
`server.tsx:369, 371` plus `api/browser/relay.ts` (677 lines), `api/browser/router.ts` (198, including `server.tsx:369, 371` plus `api/browser/relay.ts` (677 lines), `api/browser/router.ts` (198, including
`Bun.spawn(['zip', …])` at `:26-30`), `cdp.ts` (99) and `relay-auth.ts` (42); and `Bun.spawn(['zip', …])` at `:26-30`), `cdp.ts` (99) and `relay-auth.ts` (42); and
`api/scrape/scrape.ts:9-19, 49+` launches chromium in-process. Noted for a future pass; not counted `api/scrape/scrape.ts:9-19, 49+` launches chromium in-process. Noted for a future pass; not counted
@@ -641,7 +642,7 @@ eight times.
Sorted by how far each is from the reference. This is the whole audit in one view: Sorted by how far each is from the reference. This is the whole audit in one view:
| sidecar | platform lines | verdict | | sidecar | platform lines | verdict |
|---|---:|---| | -------- | -------------: | ---------------------------------------------------- |
| slskd | 70 | ✅ reference | | slskd | 70 | ✅ reference |
| music | 88 | ✅ compliant (the cliamp subsystem beside it is not) | | music | 88 | ✅ compliant (the cliamp subsystem beside it is not) |
| pty | 169 | ✗ ~all of it is sidecar logic | | pty | 169 | ✗ ~all of it is sidecar logic |
@@ -652,7 +653,7 @@ Sorted by how far each is from the reference. This is the whole audit in one vie
| email | ~2,875 | ✗ no proxy exists at all | | email | ~2,875 | ✗ no proxy exists at all |
`hono.ts` mounts **36 routers. Three are thin sidecar proxies**`:106` (music), `:107` (slskd), and `hono.ts` mounts **36 routers. Three are thin sidecar proxies**`:106` (music), `:107` (slskd), and
`:77` (vault, mounted *outside* `protectedRouter`). `:77` (vault, mounted _outside_ `protectedRouter`).
For contrast, sidecar-side LOC: music 1,630 · claude 1,523 · slskd 653 · opencode 427 · vnc 326 · For contrast, sidecar-side LOC: music 1,630 · claude 1,523 · slskd 653 · opencode 427 · vnc 326 ·
email 314 · vault 295. Note the inversion on email: 314 sidecar lines to 2,875 platform lines. email 314 · vault 295. Note the inversion on email: 314 sidecar lines to 2,875 platform lines.
@@ -664,7 +665,7 @@ file-browser 1,465 · server-settings 1,452 · browser 1,105 · auth 607 · syst
### 2. The protocol is not a transport ### 2. The protocol is not a transport
`sidecar/protocol.ts` is a **closed union of ~34 message types: 7 transport, 25+ domain.** Every new `sidecar/protocol.ts` is a **closed union of ~34 message types: 7 transport, 25+ domain.** Every new
sidecar capability requires editing a shared platform file — which is why domain knowledge keeps sidecar permission requires editing a shared platform file — which is why domain knowledge keeps
landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params). landing there (CLI flags, `display`/`pid`, `proxySecret`, spawn params).
Two specific consequences: Two specific consequences:
@@ -675,7 +676,7 @@ Two specific consequences:
string test (`server.tsx:80`, `sidecar/email/index.ts:31, 38`). So the "closed" union is already string test (`server.tsx:80`, `sidecar/email/index.ts:31, 38`). So the "closed" union is already
being bypassed where it was inconvenient — evidence that the closed shape is the wrong shape. being bypassed where it was inconvenient — evidence that the closed shape is the wrong shape.
By contrast `registration-protocol.ts` (16 lines: `name` + `capabilities: string[]`) is genuinely By contrast `registration-protocol.ts` (16 lines: `name` + `permissions: string[]`) is genuinely
generic. The registration handshake got this right; the command channel did not. generic. The registration handshake got this right; the command channel did not.
### 3. Ten WebSocket providers, and only three are tunnels ### 3. Ten WebSocket providers, and only three are tunnels
@@ -708,10 +709,10 @@ is item 2 of the email section arriving from a different direction.
### 5. Registry bugs that will bite during any migration ### 5. Registry bugs that will bite during any migration
- **`unregisterSidecar` (`sidecar-registry.ts:80-85`) rejects the entire global pending-command map - **`unregisterSidecar` (`sidecar-registry.ts:80-85`) rejects the entire global pending-command map
when *any single* sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and when _any single_ sidecar disconnects.** So restarting `officer-music` fails in-flight claude, pty and
vault commands. This will look like random unrelated breakage the moment sidecars restart vault commands. This will look like random unrelated breakage the moment sidecars restart
independently — which is the entire goal. independently — which is the entire goal.
- **`:88-90` treats `capabilities.includes('proxy')` as "is this claude"** — true only by accident of - **`:88-90` treats `permissions.includes('proxy')` as "is this claude"** — true only by accident of
the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`. the naming confusion documented in `CLAUDE_SIDECAR_ISOLATION.md`.
### 6. What the database says (the clearest signal in the audit) ### 6. What the database says (the clearest signal in the audit)
@@ -726,8 +727,8 @@ Table ownership tracks compliance exactly:
- **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching across - **`queries/email-accounts.ts` — split**, with `api/chat/websocket.ts:11, 61-62` reaching across
domains into it. domains into it.
**A useful rule falls out of this:** *if a table is read by exactly one sidecar and nothing else, that **A useful rule falls out of this:** _if a table is read by exactly one sidecar and nothing else, that
sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't.* sidecar is probably compliant. If the platform reads it, the platform probably owns logic it shouldn't._
Cheaper to check than reading 3,000 lines. Cheaper to check than reading 3,000 lines.
--- ---
@@ -739,7 +740,7 @@ mirroring the slskd findings at the top of this document.
Same rule, applied one layer out. The question here is not "what logic runs in `officer`" but **"does Same rule, applied one layer out. The question here is not "what logic runs in `officer`" but **"does
the browser know things only the sidecar should know?"** — upstream URL shapes, wire formats, session-id the browser know things only the sidecar should know?"** — upstream URL shapes, wire formats, session-id
conventions, retry and reconnect policy, capability catalogues. conventions, retry and reconnect policy, permission catalogues.
The slskd case at the top of this document is the template: **37 raw `/slskd/api/v0/…` calls against 10 The slskd case at the top of this document is the template: **37 raw `/slskd/api/v0/…` calls against 10
`/slskd/_officer/…` calls**, meaning the browser is a second client of the upstream API rather than a `/slskd/_officer/…` calls**, meaning the browser is a second client of the upstream API rather than a
@@ -785,7 +786,7 @@ disconnected UI — a red "Disconnected" indicator (`ChatDetailPanel.tsx:38-52`)
(`InputArea.tsx:84`), model switching locked (`ModelSelector.tsx:67`). (`InputArea.tsx:84`), model switching locked (`ModelSelector.tsx:67`).
**So `seq` + `resume-cursor` already exist end to end.** Pass 1 found the matching backend half at **So `seq` + `resume-cursor` already exist end to end.** Pass 1 found the matching backend half at
`chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the *writer* is simply `chat/websocket.ts:612-629` (`getChatEventsSince`). The protocol is not missing; the _writer_ is simply
on the wrong side of the socket. That makes the durability stage of `CLAUDE_SIDECAR_ISOLATION.md` on the wrong side of the socket. That makes the durability stage of `CLAUDE_SIDECAR_ISOLATION.md`
substantially smaller than I estimated — a relocation, not a new mechanism. substantially smaller than I estimated — a relocation, not a new mechanism.
@@ -807,16 +808,16 @@ check when the writer moves.
string, in the task runner. This one silently goes stale. string, in the task runner. This one silently goes stale.
3. **The CLI invocation string is in the browser.** `apps/Terminal/index.tsx:32-33` 3. **The CLI invocation string is in the browser.** `apps/Terminal/index.tsx:32-33`
`command="claude --dangerously-skip-permissions"`, `statePrefix="claude-code"`. The browser decides `command="claude --dangerously-skip-permissions"`, `statePrefix="claude-code"`. The browser decides
how the agent binary is invoked, including its permission flag. *(The unsandboxed posture is how the agent binary is invoked, including its permission flag. _(The unsandboxed posture is
deliberate per `platform/CLAUDE.md`; the objection is only to where the decision lives — the deliberate per `platform/CLAUDE.md`; the objection is only to where the decision lives — the
browser is the furthest possible place from the sidecar that owns it.)* browser is the furthest possible place from the sidecar that owns it.)_
4. **Capability metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`, 4. **Permission metadata crosses to the client.** `Chat/types.ts:11-19` types `contextWindow`,
`maxTokens` and `reasoning?`, and `ModelSelector.tsx:116` branches the UI on `reasoning`. The `maxTokens` and `reasoning?`, and `ModelSelector.tsx:116` branches the UI on `reasoning`. The
browser doesn't compute these, so this is acceptable *if* they come from the sidecar — but Pass 1 browser doesn't compute these, so this is acceptable _if_ they come from the sidecar — but Pass 1
found them hardcoded in the platform at `api/chat/list-models.ts:5-9`, so today the numbers found them hardcoded in the platform at `api/chat/list-models.ts:5-9`, so today the numbers
originate two layers away from the thing they describe. originate two layers away from the thing they describe.
5. **Claude CLI session conventions are documented in the browser.** 5. **Claude CLI session conventions are documented in the browser.**
`state/src/useClaudeSessions.ts:8-9` comments that the id *is* the transcript filename; `state/src/useClaudeSessions.ts:8-9` comments that the id _is_ the transcript filename;
`SessionList.tsx:10-11` explains that clicking a session continues it "via --resume"; `:15` types `SessionList.tsx:10-11` explains that clicking a session continues it "via --resume"; `:15` types
`harness?: 'claude' | 'opencode'`. And the magic string **`'general_chat_sessions'`** — Claude's `harness?: 'claude' | 'opencode'`. And the magic string **`'general_chat_sessions'`** — Claude's
own default directory bucket — appears as a literal in `PwdSelector.tsx:7, 22, 62`, own default directory bucket — appears as a literal in `PwdSelector.tsx:7, 22, 62`,
@@ -851,7 +852,7 @@ check when the writer moves.
## email — routes are compliant, payloads and realtime are not ## email — routes are compliant, payloads and realtime are not
The mirror image of chat: **every one of the 20 API paths is Officer-shaped** — there is no The mirror image of chat: **every one of the 20 API paths is Officer-shaped** — there is no
`/imap/uid/…` anywhere — but the request *bodies* carry IMAP configuration, the compose path builds `/imap/uid/…` anywhere — but the request _bodies_ carry IMAP configuration, the compose path builds
MIME, and the realtime channel cannot recover from a restart at all. MIME, and the realtime channel cannot recover from a restart at all.
There is no windowed panel app; the email UI is screen-level under There is no windowed panel app; the email UI is screen-level under
@@ -884,9 +885,10 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
- `GoogleOAuthConfig.tsx:197-210``GET /integrations/google/config` returns `clientSecret` in - `GoogleOAuthConfig.tsx:197-210``GET /integrations/google/config` returns `clientSecret` in
plaintext; held in `useState` (`:184`), shown at `:255-262`. plaintext; held in `useState` (`:184`), shown at `:255-262`.
Neither is a mail credential *the sidecar owns*, and both are the owner's own secrets on the Neither is a mail credential _the sidecar owns_, and both are the owner's own secrets on the
owner's own machine — but "GET returns the secret so the form can prefill" is the pattern worth owner's own machine — but "GET returns the secret so the form can prefill" is the pattern worth
changing, since a write-only field would work identically. changing, since a write-only field would work identically.
5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds 5. **The session bearer token is passed in a URL.** `EmailList.tsx:110-112` builds
`new EventSource('/api/email/events?token=' + …)` from `localStorage`. Unavoidable for `EventSource` `new EventSource('/api/email/events?token=' + …)` from `localStorage`. Unavoidable for `EventSource`
(it can't set headers), but it puts the JWT into browser history and any proxy access log. Worth (it can't set headers), but it puts the JWT into browser history and any proxy access log. Worth
@@ -895,7 +897,7 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
`EmailList.tsx:109-125` opens the SSE stream, expects `{ type: 'new-mail' }`, invalidates three `EmailList.tsx:109-125` opens the SSE stream, expects `{ type: 'new-mail' }`, invalidates three
query keys, and closes on unmount. There is **no `es.onerror`, no backoff, no reconnect, and no query keys, and closes on unmount. There is **no `es.onerror`, no backoff, no reconnect, and no
`Last-Event-ID` handling.** And the server never sends an `id:` field — Pass 1's `Last-Event-ID` handling.** And the server never sends an `id:` field — Pass 1's
`api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's *native* `api/email/email.ts:101` emits only `data: {"type":"new-mail"}` — so even the browser's _native_
`EventSource` retry cannot request replay. Any `new-mail` event emitted during a restart is lost `EventSource` retry cannot request replay. Any `new-mail` event emitted during a restart is lost
silently until the next event arrives or the user hits Sync manually (`:134-154`). silently until the next event arrives or the user hits Sync manually (`:134-154`).
**Direct contrast with chat, in the same codebase: one channel has cursor-based replay, the other **Direct contrast with chat, in the same codebase: one channel has cursor-based replay, the other
@@ -925,18 +927,18 @@ out of `IntegrationsSettings/index.tsx:11, 71-79`), `EmailScreen.tsx` (60), `typ
- **No charset, quoted-printable, base64 or RFC-2047 decoding in the browser** — it receives decoded - **No charset, quoted-printable, base64 or RFC-2047 decoding in the browser** — it receives decoded
`text`/`html`/`snippet`. Reading is compliant; only composing leaks. `text`/`html`/`snippet`. Reading is compliant; only composing leaks.
- **No Gmail label ids and no Gmail query syntax constructed client-side.** The search box passes `q=` - **No Gmail label ids and no Gmail query syntax constructed client-side.** The search box passes `q=`
through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only *hints* at the syntax. through untouched (`EmailList.tsx:83-85`); `:309`'s placeholder only _hints_ at the syntax.
- **Mail credentials are write-only.** The password is POSTed at `EmailAccounts.tsx:132` and never read - **Mail credentials are write-only.** The password is POSTed at `EmailAccounts.tsx:132` and never read
back — `GET /email/accounts` returns no credential field. OAuth tokens never reach the browser at back — `GET /email/accounts` returns no credential field. OAuth tokens never reach the browser at
all: `:88-107` either redirects the page to `/api/integrations/google/authorize` or POSTs all: `:88-107` either redirects the page to `/api/integrations/google/authorize` or POSTs
`credentials: { userIntegrationId: true }`, a boolean. This is the right shape, and it is worth `credentials: { userIntegrationId: true }`, a boolean. This is the right shape, and it is worth
noting that the *account* credential path is stricter than the *settings* ones in item 4. noting that the _account_ credential path is stricter than the _settings_ ones in item 4.
- **No Message-Id handling** — and `Compose.tsx:382-384` documents the absence, noting `m.id` is a local - **No Message-Id handling** — and `Compose.tsx:382-384` documents the absence, noting `m.id` is a local
hash and that threading currently leans on `Re:` + participants. hash and that threading currently leans on `Re:` + participants.
## opencode — the most compliant frontend of the eight ## opencode — the most compliant frontend of the eight
Genuinely surprising given Pass 1 found ≈792 non-compliant *backend* lines. **The string `opencode` Genuinely surprising given Pass 1 found ≈792 non-compliant _backend_ lines. **The string `opencode`
appears in exactly four frontend files, and only one of those is logic.** Everything the backend leaks appears in exactly four frontend files, and only one of those is logic.** Everything the backend leaks
— the `ses_` prefix, the `opencode/<modelID>` id shape, `metadata.officer`, `auth.json`, — the `ses_` prefix, the `opencode/<modelID>` id shape, `metadata.officer`, `auth.json`,
`models.json`, the version pin — stops at the server. Verified by exhaustive grep: **zero frontend hits `models.json`, the version pin — stops at the server. Verified by exhaustive grep: **zero frontend hits
@@ -971,7 +973,7 @@ it just always sends `cwd`.
(`value.slice(0,3) + '...' + value.slice(-3)`), and the browser uses the result only as a (`value.slice(0,3) + '...' + value.slice(-3)`), and the browser uses the result only as a
placeholder (`AIHarnessesSection.tsx:467`). A freshly typed key lives transiently in placeholder (`AIHarnessesSection.tsx:467`). A freshly typed key lives transiently in
`keyInputs` state (`:74`) and is **deleted after the PUT** (`:121-125`). Never in `localStorage`, `keyInputs` state (`:74`) and is **deleted after the PUT** (`:121-125`). Never in `localStorage`,
`sessionStorage`, or the query cache. Local-provider config returns the auth *type* only, never key `sessionStorage`, or the query cache. Local-provider config returns the auth _type_ only, never key
material. **This is the pattern the email settings surface (Pass 2, email item 4) should copy.** material. **This is the pattern the email settings surface (Pass 2, email item 4) should copy.**
5. **No hardcoded model catalogue.** `state/src/useModels.ts:30-51` fetches everything from 5. **No hardcoded model catalogue.** `state/src/useModels.ts:30-51` fetches everything from
`/chat/models`. The only hardcoded data is display-name maps — `ModelSelector.tsx:7-23` (14 pairs) `/chat/models`. The only hardcoded data is display-name maps — `ModelSelector.tsx:7-23` (14 pairs)
@@ -987,16 +989,16 @@ it just always sends `cwd`.
(routed at `App.tsx:38-40`), `useClaudeSessions.ts`, `useEmbeddableChat.ts`. All reachable. (routed at `App.tsx:38-40`), `useClaudeSessions.ts`, `useEmbeddableChat.ts`. All reachable.
**One thing the frontend displays that isn't real, and the cause is in the backend.** **One thing the frontend displays that isn't real, and the cause is in the backend.**
`api/chat/list-models.ts:24` stubs *every* opencode-routed model with constant metadata — `api/chat/list-models.ts:24` stubs _every_ opencode-routed model with constant metadata —
`contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true`, with the comment "metadata is `contextWindow: 200000, maxTokens: 8192, reasoning: false, images: true`, with the comment "metadata is
left at neutral defaults for now". The browser faithfully renders these (`ModelSelector.tsx:116` left at neutral defaults for now". The browser faithfully renders these (`ModelSelector.tsx:116`
branches the thinking toggle on `reasoning`). So the capability numbers shown to the user for opencode branches the thinking toggle on `reasoning`). So the permission numbers shown to the user for opencode
models are placeholders, and `reasoning: false` will suppress the thinking toggle for models that do models are placeholders, and `reasoning: false` will suppress the thinking toggle for models that do
support it. A backend defect, surfaced by a compliant frontend. support it. A backend defect, surfaced by a compliant frontend.
## terminal / pty — the browser reconnects, and then loses the session anyway ## terminal / pty — the browser reconnects, and then loses the session anyway
The mirror of the backend result. Pass 1 called pty the least compliant *backend* surface; the frontend The mirror of the backend result. Pass 1 called pty the least compliant _backend_ surface; the frontend
is mostly well-behaved, has real reconnect logic, and yet contains **one bug that defeats the entire is mostly well-behaved, has real reconnect logic, and yet contains **one bug that defeats the entire
detach-not-kill design.** detach-not-kill design.**
@@ -1056,9 +1058,9 @@ between `pty-sidecar.mjs:37` and `connect.ts:22` (Pass 1). Four backoff policies
sidecar keeps a capped 50KB buffer (`pty-sidecar.mjs:36`, `BUFFER_MAX`) and re-emits it on re-init sidecar keeps a capped 50KB buffer (`pty-sidecar.mjs:36`, `BUFFER_MAX`) and re-emits it on re-init
(`:99-101`); the bridge forwards it as an ordinary `output` frame (`:99-101`); the bridge forwards it as an ordinary `output` frame
(`api/terminal/websocket.ts:71-79`), and `Terminal.tsx:181` `term.write()`s it indistinguishably from (`api/terminal/websocket.ts:71-79`), and `Terminal.tsx:181` `term.write()`s it indistinguishably from
live output. No dedup, no historical marker. It works, passively. *(INFERRED: survival across a hard live output. No dedup, no historical marker. It works, passively. _(INFERRED: survival across a hard
page reload depends on React cleanup not running during navigation teardown — standard behaviour, but page reload depends on React cleanup not running during navigation teardown — standard behaviour, but
not verified against `pagehide` here.)* not verified against `pagehide` here.)_
Session ids are **chosen by the browser** and persisted server-side through `useDashboardState` Session ids are **chosen by the browser** and persisted server-side through `useDashboardState`
`GET/PATCH /dashboards` (React Query key `['DASHBOARD_STATE']`, `staleTime: Infinity`), so they survive `GET/PATCH /dashboards` (React Query key `['DASHBOARD_STATE']`, `staleTime: Infinity`), so they survive
@@ -1072,9 +1074,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
1. **The browser composes shell commands by string concatenation, unescaped.** 1. **The browser composes shell commands by string concatenation, unescaped.**
```ts ```ts
// Terminal.tsx:187-190 // Terminal.tsx:187-190
const wrapped = onCommandDoneRef.current const wrapped = onCommandDoneRef.current ? `${commandRef.current}; echo "${EXIT_MARKER}$?__"` : commandRef.current;
? `${commandRef.current}; echo "${EXIT_MARKER}$?__"`
: commandRef.current;
ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' })); ws.send(JSON.stringify({ type: 'input', data: wrapped + '\r' }));
``` ```
That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at That assumes a POSIX shell (`;`, `$?`, `echo`) and does not escape `command`. Same pattern at
@@ -1104,7 +1104,7 @@ with an ephemeral `` `run-cmd-${Date.now()}` `` in local state — deliberate fo
- **The backend's `cwd` handler is unreachable.** Pass 1 flagged - **The backend's `cwd` handler is unreachable.** Pass 1 flagged
`api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``. `api/terminal/websocket.ts:129-138` for synthesizing `` `cd ${JSON.stringify(msg.path)}\r` ``.
Repo-wide grep finds **zero** frontend senders of `{type:'cwd'}` — the browser does its own `cd` Repo-wide grep finds **zero** frontend senders of `{type:'cwd'}` — the browser does its own `cd`
composition instead (item 1 above). So that branch is dead, and the capability it implements is composition instead (item 1 above). So that branch is dead, and the permission it implements is
duplicated in the client. duplicated in the client.
- **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message - **`detached` is dead in the other direction.** `Terminal.tsx:225-226` handles a `'detached'` message
and writes `[Session taken over]`, but **no backend code ever emits it** — the only `detached` in and writes `[Session taken over]`, but **no backend code ever emits it** — the only `detached` in
@@ -1267,7 +1267,7 @@ settled before any of that code is moved: who is actually meant to talk to the v
The single most useful thing in this pass. Ranked by frontend compliance: The single most useful thing in this pass. Ranked by frontend compliance:
| sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) | | sidecar | backend verdict (Pass 1) | frontend verdict (Pass 2) |
|---|---|---| | -------- | ------------------------------- | -------------------------------------------------------- |
| slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes | | slskd | ✅ compliant, 70 lines | ✗ **worst** — 37 raw upstream calls vs 10 Officer routes |
| music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned | | music | ✅ compliant, 88 lines | ✅ 12 routes, all Officer-owned |
| opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic | | opencode | ✗ ≈792 lines | ✅ **best** — 4 mentions, 1 of them logic |
@@ -1285,7 +1285,7 @@ because **the sidecar exposes Officer-shaped routes** — the `/_officer/*` name
proxying the upstream one. proxying the upstream one.
So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete So the rule as stated ("main server is a thin proxy") is necessary but not sufficient. The complete
version is: *the sidecar owns the contract the browser consumes.* Thinning a router without adding version is: _the sidecar owns the contract the browser consumes._ Thinning a router without adding
`/_officer/*` routes to the sidecar just moves domain logic from the platform into the browser, which is `/_officer/*` routes to the sidecar just moves domain logic from the platform into the browser, which is
strictly worse — it is further from the data and unversioned. strictly worse — it is further from the data and unversioned.
@@ -1295,7 +1295,7 @@ Pass 1 found an architecture problem. Pass 2 mostly finds a **resilience** probl
per-socket rather than systemic: per-socket rather than systemic:
| channel | reconnect | replay | | channel | reconnect | replay |
|---|---|---| | ---------------------------- | -------------------------------------- | --------------------------------------------------- |
| chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) | | chat WS | ✅ `min(5000, 300 × retry)` | ✅ `seq` + `resume-cursor` (best in repo) |
| terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware | | terminal / cliamp control WS | ✅ 5-entry table + visibility trigger | ◐ passive 50 KB sidecar buffer; browser unaware |
| cliamp audio WS | ✗ none | — n/a (live capture) | | cliamp audio WS | ✗ none | — n/a (live capture) |
@@ -1368,7 +1368,7 @@ Recorded because they surfaced during the audit, not because they're in scope:
2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()` 2. **Terminals don't re-fit after a resize.** `fitAddon.fit()` runs once per `connect()`
(`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves (`Terminal.tsx:158`); there is no `ResizeObserver` or window listener, so dragging a splitter leaves
the pty on stale dimensions until the next reconnect. the pty on stale dimensions until the next reconnect.
3. **opencode model capabilities shown to the user are placeholder constants.** 3. **opencode model permissions shown to the user are placeholder constants.**
`api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192, `api/chat/list-models.ts:24` stubs every opencode model at `contextWindow: 200000, maxTokens: 8192,
reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`. reasoning: false`, and `ModelSelector.tsx:116` hides the thinking toggle based on that `false`.
+18 -18
View File
@@ -17,7 +17,7 @@ A sidecar is a **PM2 peer of `officer`** — never a child. It dials _in_; offic
``` ```
PM2 starts it → it binds its own ephemeral port (if it serves HTTP) PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
→ it opens a WS to officer at /api/sidecar/register → it opens a WS to officer at /api/sidecar/register
→ it sends { type:'register', name, capabilities[] } → it sends { type:'register', name, permissions[] }
→ officer replies { type:'registered', id } → officer replies { type:'registered', id }
→ it sends { type:'<name>:server', port } (HTTP sidecars only) → it sends { type:'<name>:server', port } (HTTP sidecars only)
→ officer remembers the port and proxies <prefix>/* to it → officer remembers the port and proxies <prefix>/* to it
@@ -26,10 +26,10 @@ PM2 starts it → it binds its own ephemeral port (if it serves HTTP)
Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is Officer's side of that is `src/servers/sidecar-registry.ts`; the sidecar's side is
`src/servers/sidecar/connect.ts`. `src/servers/sidecar/connect.ts`.
**Nothing in this path is officer starting a process.** `waitForCapability` in the registry says so **Nothing in this path is officer starting a process.** `waitForPermission` in the registry says so
explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`, explicitly — it replaced ~77 lines of spawn-and-poll (`ensureClaudeSidecar`,
`spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_, `spawnAndWaitForRegistration`, and per-email process maps). The only startup problem left is _ordering_,
handled by waiting up to 15s for a capability to appear rather than failing the first request after boot. handled by waiting up to 15s for a permission to appear rather than failing the first request after boot.
--- ---
@@ -56,11 +56,11 @@ the reconnect loop. The ecosystem file says so in a comment, which is the right
Four things, and three of them fail loudly if missed. Four things, and three of them fail loudly if missed.
1. **A PM2 entry** in `ecosystem.config.cjs` (`script: 'bun'`, `args: 'run src/servers/sidecar/<n>/index.ts'`). 1. **A PM2 entry** in `ecosystem.config.cjs` (`script: 'bun'`, `args: 'run src/servers/sidecar/<n>/index.ts'`).
2. **A registration** with a `name` and `capabilities[]`. Officer indexes by capability, not by name — 2. **A registration** with a `name` and `permissions[]`. Officer indexes by permission, not by name —
`findSidecarByCapability` is how every caller reaches one. `findSidecarByPermission` is how every caller reaches one.
3. **A `'<name>:server'` event in `protocol.ts`**, if it serves HTTP. Without it the type does not exist 3. **A `'<name>:server'` event in `protocol.ts`**, if it serves HTTP. Without it the type does not exist
and `createSidecarProxy`'s listener never matches. and `createSidecarProxy`'s listener never matches.
4. **A capability-registry entry**, if it mounts a router. `assertCapabilityTotality` runs in 4. **A permission-registry entry**, if it mounts a router. `assertPermissionTotality` runs in
`server.tsx` _before_ `serve()` and **throws**, so a missing entry means the server refuses to boot, `server.tsx` _before_ `serve()` and **throws**, so a missing entry means the server refuses to boot,
naming what is missing. Alternatively an `EXEMPT_API_PREFIXES` entry _with a stated reason_. naming what is missing. Alternatively an `EXEMPT_API_PREFIXES` entry _with a stated reason_.
@@ -102,19 +102,19 @@ contains two entrypoints that register as _different sidecars_:
| File | PM2 entry | Registers as | What it is | | File | PM2 entry | Registers as | What it is |
| ------------------------- | ------------------------- | ------------------------------------ | ---------------------------------------------------- | | ------------------------- | ------------------------- | ------------------------------------ | ---------------------------------------------------- |
| `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, capability `['proxy']` | Holds the Anthropic credential, forwards API traffic | | `claude/index.ts` | `officer-anthropic-proxy` | name `proxy`, permission `['proxy']` | Holds the Anthropic credential, forwards API traffic |
| `claude/user-instance.ts` | `officer-agent` | capability `['claude']` | The process that actually spawns `claude` | | `claude/user-instance.ts` | `officer-agent` | permission `['claude']` | The process that actually spawns `claude` |
So **capability `proxy` is the Anthropic proxy, and capability `claude` is the agent.** Nothing named So **permission `proxy` is the Anthropic proxy, and permission `claude` is the agent.** Nothing named
"claude" registers the `claude` capability from `claude/index.ts`, which is exactly the sort of thing "claude" registers the `claude` permission from `claude/index.ts`, which is exactly the sort of thing
that reads as a bug in a grep and is not one. that reads as a bug in a grep and is not one.
That resolves the special-casing: `isConnected()` returns "a sidecar with capability `proxy` exists" — That resolves the special-casing: `isConnected()` returns "a sidecar with permission `proxy` exists" —
i.e. **the Anthropic proxy is up**, which is _not_ the same as "the agent is up", though the name reads i.e. **the Anthropic proxy is up**, which is _not_ the same as "the agent is up", though the name reads
that way. `[verified]` It currently has **no callers** outside the registry itself, so nothing is that way. `[verified]` It currently has **no callers** outside the registry itself, so nothing is
misreading it today. Worth either renaming or deleting before something starts trusting the name. misreading it today. Worth either renaming or deleting before something starts trusting the name.
`registerSidecar` also fires a notification when a registration includes capability `claude` `registerSidecar` also fires a notification when a registration includes permission `claude`
(`sidecar-registry.ts:75`) — "a new agent process has come up". That one is correctly aimed at the agent. (`sidecar-registry.ts:75`) — "a new agent process has come up". That one is correctly aimed at the agent.
--- ---
@@ -135,11 +135,11 @@ lines?
## Open questions, in the order I would answer them ## Open questions, in the order I would answer them
1. ~~What provides the `proxy` capability~~**answered above**: the Anthropic proxy, not the agent. 1. ~~What provides the `proxy` permission~~**answered above**: the Anthropic proxy, not the agent.
`isConnected()` has no callers; rename or delete it before its name misleads someone. `isConnected()` has no callers; rename or delete it before its name misleads someone.
2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the 2. **Is the sidecar-side boilerplate worth factoring**, given `create-proxy.ts` already proved the
officer side was? officer side was?
3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForCapability` 3. **What happens on a partial boot** — officer up, a sidecar permanently down. `waitForPermission`
throws after 15s; who catches it, and what does the user see? throws after 15s; who catches it, and what does the user see?
4. **Is the `PORT ?? '5000'` fallback reachable**, and should it fail loudly instead? 4. **Is the `PORT ?? '5000'` fallback reachable**, and should it fail loudly instead?
5. **`sweepStaleServes` is `/proc`-based and a no-op on macOS** (already noted in the OpenCode parity 5. **`sweepStaleServes` is `/proc`-based and a no-op on macOS** (already noted in the OpenCode parity
@@ -150,12 +150,12 @@ lines?
## Verified facts this document rests on ## Verified facts this document rests on
| Claim | How | | Claim | How |
| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` | | 20 PM2 entries, 18 sidecar dirs | `ecosystem.config.cjs`, `ls src/servers/sidecar/` |
| 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` | | 16 sidecars report a port, 2 do not | `grep` for `':server'` in each `index.ts`, cross-checked against 16 declarations in `protocol.ts` |
| `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` | | `pty` is node + `.mjs` + its own reconnect loop | `ecosystem.config.cjs` comment and `ls sidecar/pty/` |
| Officer spawns nothing | `waitForCapability` comment; no spawn call in the registry | | Officer spawns nothing | `waitForPermission` comment; no spawn call in the registry |
| Ports change across restarts and officer follows | observed live tonight across five photos restarts | | Ports change across restarts and officer follows | observed live tonight across five photos restarts |
| Boot fails on a missing capability entry | `assertCapabilityTotality` throws before `serve()` | | Boot fails on a missing permission entry | `assertPermissionTotality` throws before `serve()` |
| `sidecar/claude/` is two processes with different capabilities | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls | | `sidecar/claude/` is two processes with different permissions | `ecosystem.config.cjs` args + the two `createSidecarConnector` calls |
| `isConnected()` has no callers outside the registry | grep across `src/servers` | | `isConnected()` has no callers outside the registry | grep across `src/servers` |
+8 -8
View File
@@ -8,13 +8,13 @@ vocabulary left between them. Every HTTP sidecar shares one `createSidecarProxy`
happened is the part this document is actually about — fixed ports, the platform reading a table instead happened is the part this document is actually about — fixed ports, the platform reading a table instead
of being told at runtime, and `.env` feature toggles. Ports are still ephemeral and still announced. of being told at runtime, and `.env` feature toggles. Ports are still ephemeral and still announced.
Not to be confused with `sidecar-audit-2026-07.md`, which is the *audit* of the Not to be confused with `sidecar-audit-2026-07.md`, which is the _audit_ of the
current state (what's misplaced, and where). This is where it's going. current state (what's misplaced, and where). This is where it's going.
## The premise that makes it simple ## The premise that makes it simple
**The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand — **The tailnet is the perimeter.** Everything moves behind Tailscale and devices are admitted by hand —
friends and family included. Authentication *inside* that boundary is solving a problem we don't have, so friends and family included. Authentication _inside_ that boundary is solving a problem we don't have, so
this design has no token work in it at all. Sidecars trust their caller exactly as they do today; the trust this design has no token work in it at all. Sidecars trust their caller exactly as they do today; the trust
boundary just moves from loopback to the tailnet. boundary just moves from loopback to the tailnet.
@@ -22,7 +22,7 @@ Until that lands, things stay exposed as they are now. The security model is del
## The design ## The design
1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay *peers* of 1. **`ecosystem.config.cjs` is the source of truth.** PM2 starts every sidecar. They stay _peers_ of
`officer` — never children. This is not a style preference: officer used to spawn the agent itself, `officer` — never children. This is not a style preference: officer used to spawn the agent itself,
which made it a grandchild, and PM2's tree-kill took the owner's chat session down on every restart. which made it a grandchild, and PM2's tree-kill took the owner's chat session down on every restart.
That was the worst thing about working on the platform, and it is fixed. Don't reintroduce it. That was the worst thing about working on the platform, and it is fixed. Don't reintroduce it.
@@ -43,7 +43,7 @@ The point of the exercise, and the reason it's worth doing:
- `sidecar/connect.ts` — the dial-out-and-register loop, plus its per-sidecar reconnect backoff copies - `sidecar/connect.ts` — the dial-out-and-register loop, plus its per-sidecar reconnect backoff copies
- every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`, - every port announcement: `music:server`, `slskd:server`, `vault:server`, `opencode:server`,
`pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started` `pty:server`, `email:server`, `wallet:server`, `headscale:server`, … and `vnc:started`
- most of `sidecar-registry.ts` — discovery, the pending-command map, capability lookup - most of `sidecar-registry.ts` — discovery, the pending-command map, permission lookup
- officer's proxying for anything that isn't auth or layout state - officer's proxying for anything that isn't auth or layout state
## Migration order ## Migration order
@@ -60,14 +60,14 @@ least urgent anyway.
- **Ecosystem file, or a shared table?** The platform parsing `ecosystem.config.cjs` couples the app to - **Ecosystem file, or a shared table?** The platform parsing `ecosystem.config.cjs` couples the app to
PM2 being the thing that started it, which matters for containerising this later and for `bun dev`. PM2 being the thing that started it, which matters for containerising this later and for `bun dev`.
The alternative is one plain TypeScript table (name, script, port, capability, enabled) that The alternative is one plain TypeScript table (name, script, port, permission, enabled) that
`ecosystem.config.cjs` generates its `apps:` array from and the platform imports directly — same single `ecosystem.config.cjs` generates its `apps:` array from and the platform imports directly — same single
source of truth, no supervisor coupling. **Recommended, not yet decided.** source of truth, no supervisor coupling. **Recommended, not yet decided.**
- **Where do the things that are neither auth nor layout go?** The job/queue engine, the capabilities/items - **Where do the things that are neither auth nor layout go?** The job/queue engine, the permissions/items
store, the chat session list, the file browser. Each needs a named home or officer quietly stays fat. store, the chat session list, the file browser. Each needs a named home or officer quietly stays fat.
- **Does the registration socket survive?** Not needed for discovery once ports are static. Possibly worth - **Does the registration socket survive?** Not needed for discovery once ports are static. Possibly worth
keeping for liveness — or replace it with a health probe on the known port. keeping for liveness — or replace it with a health probe on the known port.
- **Capabilities.** Today a sidecar announces `capabilities: ['music']` and officer looks up by capability, - **Permissions.** Today a sidecar announces `permissions: ['music']` and officer looks up by permission,
not by name — which is what let the agent's PM2 name change from `officer-claude` to `officer-agent` not by name — which is what let the agent's PM2 name change from `officer-claude` to `officer-agent`
without touching a caller. In a static table it collapses to a column. Keep it; it's cheap. without touching a caller. In a static table it collapses to a column. Keep it; it's cheap.
- **The non-owner account class may become dead weight.** `NON_OWNER_PATHS`, the music-only account - **The non-owner account class may become dead weight.** `NON_OWNER_PATHS`, the music-only account
@@ -81,7 +81,7 @@ Recorded so they aren't re-litigated:
- **Platform spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the - **Platform spawns the sidecars.** Rejected — that's the tree-kill bug again. PM2 starts them; the
platform only reads the topology. platform only reads the topology.
- **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was - **Platform mints a token, tells every sidecar it's valid, apps then call sidecars directly.** This was
the original points 68. Dropped with the tailnet decision. Worth knowing *why* it was weak even on its the original points 68. Dropped with the tailnet decision. Worth knowing _why_ it was weak even on its
own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down own terms: it replicates session state across ten processes, and breaks whenever one restarts, is down
at login, or has to be told about a logout. at login, or has to be told about a logout.
- **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via - **A dedicated public auth sidecar** issuing short-lived asymmetric tokens, with sidecars verifying via
+18 -18
View File
@@ -1,7 +1,7 @@
# Working on Officer # Working on Officer
The guide for anyone — human or agent — changing this deployment. It assumes you are working from the The guide for anyone — human or agent — changing this deployment. It assumes you are working from the
root of the install (the directory holding `platform/`, `capabilities/` and `data/`), which is where root of the install (the directory holding `platform/`, `permissions/` and `data/`), which is where
agent sessions start. agent sessions start.
Three directories sit there, and knowing which one a change belongs in is most of the job: Three directories sit there, and knowing which one a change belongs in is most of the job:
@@ -9,7 +9,7 @@ Three directories sit there, and knowing which one a change belongs in is most o
``` ```
$OFFICER_ROOT/ $OFFICER_ROOT/
├── platform/ the application — a git repo ├── platform/ the application — a git repo
├── capabilities/ what the agent can do — a separate git repo ├── permissions/ what the agent can do — a separate git repo
├── data/ runtime state — NOT version controlled ├── data/ runtime state — NOT version controlled
├── dockers/ containers the app store provisioned ├── dockers/ containers the app store provisioned
└── secrets/ the key store — 0600, and NOT in your data backup └── secrets/ the key store — 0600, and NOT in your data backup
@@ -22,9 +22,9 @@ why `assertInstallLayout` refuses to boot from the wrong directory.
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner** bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also — user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
admits **additional accounts holding a strict subset of it**, governed by per-role capability grants. admits **additional accounts holding a strict subset of it**, governed by per-role permission grants.
So "which user" has three answers depending on the surface. For the **app** capabilities (gitea, So "which user" has three answers depending on the surface. For the **app** permissions (gitea,
music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones — music, photos, email, calendar…) it is a real question with a real answer. For **confined** ones —
terminal, chat, files — it is also real, because the account has its own Linux user and the kernel terminal, chat, files — it is also real, because the account has its own Linux user and the kernel
enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it. enforces the boundary; a grant there means nothing without that user, and `authorize.ts` drops it.
@@ -35,15 +35,15 @@ That is five kinds, not four: `core`, `app`, `confined`, `execution`, `admin`. T
files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts. files moved from `execution` to `confined` on 2026-08-11 with per-user Linux accounts.
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
and five non-owner accounts are live; treat the capability registry as the source of truth over any and five non-owner accounts are live; treat the permission registry as the source of truth over any
prose, here or elsewhere. prose, here or elsewhere.
## What is switched off (2026-08-13) ## What is switched off (2026-08-13)
A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`, A core install runs **six** pm2 processes: `officer`, `officer-anthropic-proxy`,
`officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a `officer-claude-code`, `officer-opencode`, `officer-pty`, `officer-headscale`. Everything else is a
plugin, and every plugin router is commented out in `hono.ts` with its capability's `api` claim plugin, and every plugin router is commented out in `hono.ts` with its permission's `api` claim
commented beside it — they must move together or `assertCapabilityTotality` refuses to boot. commented beside it — they must move together or `assertPermissionTotality` refuses to boot.
The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending The implementations are all still on disk. Nothing was deleted; the mounts were switched off pending
extraction into the plugin system. extraction into the plugin system.
@@ -54,7 +54,7 @@ Also gone: the four ecosystem files (generated now, at setup, and gitignored), o
`.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in `.env` holds three values — `PORT`, `PUBLIC_URL`, `POSTGRES_URL`. Every key lives in
`$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`. `$OFFICER_ROOT/secrets/officer-keys.db`, one per purpose. See `docs/secret-store.md`.
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer `platform/` and `permissions/` each have their own `CLAUDE.md` with detail. This file is the layer
above them: where things live, how to change them safely, and the things that are true of the running above them: where things live, how to change them safely, and the things that are true of the running
system but written down nowhere else. system but written down nowhere else.
@@ -62,19 +62,19 @@ system but written down nowhere else.
## Which directory does this change belong in? ## Which directory does this change belong in?
**`capabilities/` — almost always start here.** Tasks, tools, skills, processes. It is *data*: plain **`permissions/` — almost always start here.** Tasks, tools, skills, processes. It is _data_: plain
directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task directories of Markdown and scripts, read fresh on every request. Adding a task, changing what a task
does, renaming a category — none of that needs a code change or a restart. does, renaming a category — none of that needs a code change or a restart.
**`platform/` — only when the mechanism itself is missing.** If a task needs a form control that **`platform/` — only when the mechanism itself is missing.** If a task needs a form control that
doesn't exist, or an endpoint that isn't there, that's platform work. Adding a *capability* is not. doesn't exist, or an endpoint that isn't there, that's platform work. Adding a _permission_ is not.
**`data/` — never edit by hand.** `DATA_PATH`. Holds the owner's managed home, per-account email **`data/` — never edit by hand.** `DATA_PATH`. Holds the owner's managed home, per-account email
SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here SQLite stores, job logs, the queue, sidecar state. It is not backed up by git; deleting things here
destroys the only copy. destroys the only copy.
A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`. A useful test: **would this differ between two Officer installs?** Domain, paths, credentials → `.env`.
Which tasks exist and what they're called → `capabilities/`. Everything else → `platform/`. Which tasks exist and what they're called → `permissions/`. Everything else → `platform/`.
## Git ## Git
@@ -87,7 +87,7 @@ support it needs, and a half-pushed pair leaves the deployment inconsistent.
Keep history linear: `git pull --rebase`, not `git merge`. The remote moves — the owner develops on Keep history linear: `git pull --rebase`, not `git merge`. The remote moves — the owner develops on
this box too — so expect to rebase before pushing. Say so before force-pushing anything. this box too — so expect to rebase before pushing. Say so before force-pushing anything.
Commit messages: simple lowercase, no prefixes, explaining *why*. Commit messages: simple lowercase, no prefixes, explaining _why_.
--- ---
@@ -138,7 +138,7 @@ This trips people up repeatedly. It is also why script tasks are handed `OFFICER
### Services this box depends on ### Services this box depends on
| port | what | used by | | port | what | used by |
|------|------|---------| | ---- | ------------------------- | -------------- |
| 9010 | Officer itself | — | | 9010 | Officer itself | — |
| 9002 | Kokoro TTS | text-to-speech | | 9002 | Kokoro TTS | text-to-speech |
| 8178 | whisper.cpp | transcription | | 8178 | whisper.cpp | transcription |
@@ -155,16 +155,16 @@ transcription or OCR fails, check the service is up before reading any code.
This is what most requests will be about. Tasks appear in the file browser's right-click menu under This is what most requests will be about. Tasks appear in the file browser's right-click menu under
**Run Task**, grouped into submenus by category. **Run Task**, grouped into submenus by category.
A task is a directory under `capabilities/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body — A task is a directory under `permissions/tasks/<slug>/` with a `TASK.md` — frontmatter plus a body —
and, for script mode, a sibling `run.sh` / `run.py` / `index.ts`. **The directory name is the task's and, for script mode, a sibling `run.sh` / `run.py` / `index.ts`. **The directory name is the task's
identity**; renaming it breaks every reference to it. identity**; renaming it breaks every reference to it.
`capabilities/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are `permissions/CLAUDE.md` documents the format. It is accurate but **incomplete** — the following are
used heavily by real tasks and appear nowhere in it: used heavily by real tasks and appear nowhere in it:
| convention | what it does | | convention | what it does |
|---|---| | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `category: Video` | which submenu the task appears in. Order comes from `capabilities/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. | | `category: Video` | which submenu the task appears in. Order comes from `permissions/categories.yaml`; an unlisted category still works, sorting after the listed ones. A category with no tasks never renders. |
| `inline: true` | runs ephemerally in the modal instead of becoming a job | | `inline: true` | runs ephemerally in the modal instead of becoming a job |
| `inline: ask` | offers both — **Run here** and **Run as job** | | `inline: ask` | offers both — **Run here** and **Run as job** |
| `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. | | `INPUT_INCLUDE` | newline-separated paths, injected by the modal on a multi-selection. **The single most used input in the library** — a task that ignores it silently processes the whole folder instead of the selection. |
@@ -190,7 +190,7 @@ Two ways a task does work:
House style for file-processing tasks, worth keeping consistent: House style for file-processing tasks, worth keeping consistent:
- Never delete or modify the source; write output beside it. - Never delete or modify the source; write output beside it.
- Handle a single file *and* a directory, recursively. - Handle a single file _and_ a directory, recursively.
- Honour `INPUT_INCLUDE`. - Honour `INPUT_INCLUDE`.
- No caching. Re-running redoes the work and overwrites — and say so in the body, because it also - No caching. Re-running redoes the work and overwrites — and say so in the body, because it also
overwrites edits. overwrites edits.
+23 -23
View File
@@ -174,7 +174,7 @@ and the rename sequence leaves `workspaces` with no zombie.
`dashboards` is **empty (0 rows)** today, so none of this has fired yet. Members can now sign in `dashboards` is **empty (0 rows)** today, so none of this has fired yet. Members can now sign in
(`d8ee678`), so it is a matter of time. Note `TODO.md`'s preamble still says the platform is collapsing (`d8ee678`), so it is a matter of time. Note `TODO.md`'s preamble still says the platform is collapsing
to single-user — that predates the capability permission model and should not be used to deprioritise to single-user — that predates the permission permission model and should not be used to deprioritise
these. these.
> **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical. > **Re-measured 2026-08-07.** The premise above has moved and the section is no longer hypothetical.
@@ -184,7 +184,7 @@ these.
> >
> It also puts this section in **direct contradiction with `CLAUDE.md`**, which opens by calling > It also puts this section in **direct contradiction with `CLAUDE.md`**, which opens by calling
> single-user "a hard invariant, not a stage" — no roles, no per-user isolation, and "if a change seems > single-user "a hard invariant, not a stage" — no roles, no per-user isolation, and "if a change seems
> to need *which user is this*, the answer is always the owner." Five rows in `users` says otherwise. > to need _which user is this_, the answer is always the owner." Five rows in `users` says otherwise.
> One of the two documents is wrong about what this platform is, and that is a **product question, not a > One of the two documents is wrong about what this platform is, and that is a **product question, not a
> defect**: the answer decides whether the item below is urgent or should be deleted along with the rest > defect**: the answer decides whether the item below is urgent or should be deleted along with the rest
> of the section. > of the section.
@@ -371,7 +371,7 @@ playback, transcodes — not as a prerequisite for agent coordination._
**Measured `56ca411`.** `WorkspaceRenderer.test.tsx` mounts the real renderer against a mount-counting **Measured `56ca411`.** `WorkspaceRenderer.test.tsx` mounts the real renderer against a mount-counting
probe app and lets the real `layout-utils` mutators produce the "after" tree. The table below was written probe app and lets the real `layout-utils` mutators produce the "after" tree. The table below was written
from reading the code; the test disagrees with its *diagnosis* in every row, and found one row it had from reading the code; the test disagrees with its _diagnosis_ in every row, and found one row it had
missed entirely. Read this paragraph before acting on the bullets underneath it. missed entirely. Read this paragraph before acting on the bullets underneath it.
- **The key is not the cause.** A panel's React identity is its position plus `key={child.node.id}` on its - **The key is not the cause.** A panel's React identity is its position plus `key={child.node.id}` on its
@@ -391,7 +391,7 @@ missed entirely. Read this paragraph before acting on the bullets underneath it.
host that does not move when the tree reshapes, the way maximize is a CSS toggle on the same element. host that does not move when the tree reshapes, the way maximize is a CSS toggle on the same element.
That is a redesign, not a patch, and it is still Tier C. That is a redesign, not a patch, and it is still Tier C.
The original table, kept because its *observations* hold even where its explanation did not: The original table, kept because its _observations_ hold even where its explanation did not:
| operation | remounts? | why | | operation | remounts? | why |
| ------------------------------------- | ---------------------- | ------------------------------------------------------------------------- | | ------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
@@ -410,8 +410,7 @@ The original table, kept because its *observations* hold even where its explanat
Kills rows 2 and 3.~~ **Withdrawn `56ca411`** — measured, and it kills neither. The element type at Kills rows 2 and 3.~~ **Withdrawn `56ca411`** — measured, and it kills neither. The element type at
that position changes too, which React acts on first. It would also collide a panel id with a group that position changes too, which React acts on first. It would also collide a panel id with a group
id, and a panel id is an agent's address now. id, and a panel id is an agent's address now.
- [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row - [ ] ~~**Don't re-key the survivor when a group collapses** (`layout-utils.ts:68-70, 83-88`). Kills row 4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
4.~~ **Withdrawn `56ca411`**, same reason: the survivor changes type as well as key.
- [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace** - [ ] **Overlay the mobile ephemeral panel instead of replacing the workspace**
(`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`. (`WorkspaceView.tsx:165`). Affects `/files`, `/email`, `/chat`, `/browser`, `/dashboards`.
- [ ] **Reference for how it should feel:** maximize (`PanelSlot.tsx:430-457`) is a CSS state toggle on - [ ] **Reference for how it should feel:** maximize (`PanelSlot.tsx:430-457`) is a CSS state toggle on
@@ -463,6 +462,7 @@ The original table, kept because its *observations* hold even where its explanat
that passes and reaches `PanelSlot.tsx:311-317`, which on a `locked` screen renders an empty that passes and reaches `PanelSlot.tsx:311-317`, which on a `locked` screen renders an empty
teal-bordered box with no picker and no way for the user to recover. - `screens/QrTransferScreen.tsx:19-39` has the guard but no persist-back, so it re-normalises on teal-bordered box with no picker and no way for the user to recover. - `screens/QrTransferScreen.tsx:19-39` has the guard but no persist-back, so it re-normalises on
every mount forever and never heals the row. every mount forever and never heals the row.
- [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen - [x] **~~Then collapse the three default-layout mechanisms~~ — inventoried and dropped.** Per-screen
`defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()` `defaultLayout.ts` (21, not 20 — `Home/defaultLayout.tsx` is misnamed), `createDefaultLayout()`
in the core, and the 6-entry template array at `DashboardPreview.tsx:33-142`. in the core, and the 6-entry template array at `DashboardPreview.tsx:33-142`.
@@ -520,7 +520,7 @@ work disagree permanently about the roster, with neither told — a direct contr
the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client the repo. Meanwhile every PATCH computed and returned a full fresh state blob which the client
**discarded** — 3 SELECTs per splitter release, thrown away. **discarded** — 3 SELECTs per splitter release, thrown away.
**Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that **Resolved `81ad3ef`** — both halves. The PATCH returns `{ok: true}`; nothing had ever read that
body, and a caller that did would be reading state assembled *before* whatever concurrent write it body, and a caller that did would be reading state assembled _before_ whatever concurrent write it
raced. The client refetches **on focus**, with three non-default guards, because this cache is raced. The client refetches **on focus**, with three non-default guards, because this cache is
optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value optimistic: a refetch that started before an in-flight PATCH landed would overwrite the value
already on screen — the same lost-update shape as the two items above, and self-healing only until already on screen — the same lost-update shape as the two items above, and self-healing only until
@@ -529,7 +529,7 @@ work disagree permanently about the roster, with neither told — a direct contr
and `refetchOnWindowFocus` gated on a module-level in-flight count plus a 2 s quiet period. and `refetchOnWindowFocus` gated on a module-level in-flight count plus a 2 s quiet period.
- [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly - [x] **Preserve sibling sizes on split.** `splitInner`/`insertPanel` redistribute evenly
(`100 / newChildren.length`), so one split discards carefully tuned proportions. (`100 / newChildren.length`), so one split discards carefully tuned proportions.
**Resolved `abea7a3`** — the new sibling takes half of the *target's* size and nothing else moves. **Resolved `abea7a3`** — the new sibling takes half of the _target's_ size and nothing else moves.
One helper serves both call sites, because the drop path (`movePanel``insertPanel`) carried the One helper serves both call sites, because the drop path (`movePanel``insertPanel`) carried the
identical bug. Two of the three tests were already in `layout-utils.test.ts` asserting the even identical bug. Two of the three tests were already in `layout-utils.test.ts` asserting the even
split, written to the old behaviour deliberately; they now assert the new one. The third documents split, written to the old behaviour deliberately; they now assert the new one. The third documents
@@ -557,7 +557,7 @@ work disagree permanently about the roster, with neither told — a direct contr
(`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships (`AppRegistry.test.ts`) plus a `console.error` at runtime — the mistake is caught before it ships
and named if it somehow does. Confirmed: all 44 keys are unique today, and the test says so. and named if it somehow does. Confirmed: all 44 keys are unique today, and the test says so.
Getting the real list into a test needed one thing beyond exporting it: `test-setup.ts` was not Getting the real list into a test needed one thing beyond exporting it: `test-setup.ts` was not
providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at *import* time, so the providing `localStorage`, and `MusicPlayer/useLyricsOpen.ts` reads it at _import_ time, so the
whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything whole app graph was unimportable from a test. That is now fixed, which unblocks testing anything
else that pulls in a panel app. else that pulls in a panel app.
- [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with - [x] **Seeding depends on undocumented mount ordering.** Three call sites call `useAppRegistry()` with
@@ -581,7 +581,7 @@ work disagree permanently about the roster, with neither told — a direct contr
`availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say `availableOnPanel: false`, so it can't be picked. If it ever appeared in a layout it would say
"No file selected" forever. "No file selected" forever.
**Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file **Resolved `9fcc9c2`** — traced and confirmed dead, then removed rather than repaired. The file
viewer that users actually see is mounted by `useFileViewerPanels` as an *ephemeral* panel, which viewer that users actually see is mounted by `useFileViewerPanels` as an _ephemeral_ panel, which
supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from supplies `FileViewerBody`/`FileViewerHeader` itself with a provider reading the path from
`?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key `?view=`/`?ephemeral=` — it never touched the registry. No stored layout referenced the key
(checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero (checked across `dashboards`, `screens`, `dashboard_defaults`, `user_state`, `user_settings`: zero
@@ -624,7 +624,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
`TaskRunnerModal.tsx:1320` renders a Stop button while `phase === 'running'`. And a bare `ws.close()` `TaskRunnerModal.tsx:1320` renders a Stop button while `phase === 'running'`. And a bare `ws.close()`
is not abandonment: `task-executor.ts:303-309` kills the process tree on socket close, the same is not abandonment: `task-executor.ts:303-309` kills the process tree on socket close, the same
`killTree` the Stop button reaches. What is true is the last clause: there is no re-attach, so an `killTree` the Stop button reaches. What is true is the last clause: there is no re-attach, so an
inline run dies with its modal. That is defensible — inline is the *ephemeral* mode and the job path inline run dies with its modal. That is defensible — inline is the _ephemeral_ mode and the job path
exists for everything else — so this is left alone deliberately rather than left undone. exists for everything else — so this is left alone deliberately rather than left undone.
- [ ] **`VideoPlayer` kills the transcode on incidental unmount.** `apps/Jellyfin/VideoPlayer.tsx:217-223` - [ ] **`VideoPlayer` kills the transcode on incidental unmount.** `apps/Jellyfin/VideoPlayer.tsx:217-223`
POSTs `stopped`, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2 — POSTs `stopped`, killing server-side ffmpeg, then renegotiates. Fires on every "yes" row in 5.2 —
@@ -639,7 +639,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
- [x] **`PanelSlot` defines a component inside render.** — _resolved `c0fae47`_. `DefaultHeader` is gone: the - [x] **`PanelSlot` defines a component inside render.** — _resolved `c0fae47`_. `DefaultHeader` is gone: the
header is now an element, not a component type, so there is nothing for React to fail to match. header is now an element, not a component type, so there is nothing for React to fail to match.
- [x] **The context value is a fresh literal.**_resolved `c0fae47`_. `useMemo` over the eighteen members. - [x] **The context value is a fresh literal.**_resolved `c0fae47`_. `useMemo` over the eighteen members.
Note what it does *not* buy: the value still changes whenever `layout` does, because half the Note what it does _not_ buy: the value still changes whenever `layout` does, because half the
callbacks close over it. What it stops is the renders that change nothing a panel can see — the callbacks close over it. What it stops is the renders that change nothing a panel can see — the
ephemeral pane opening, a mobile panel switch, every frame of a maximize animation. ephemeral pane opening, a mobile panel switch, every frame of a maximize animation.
@@ -736,7 +736,7 @@ All the same bug: an app guessing "am I being closed?" from an unmount, or payin
touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound. touch the framework half, so the abstraction holds in one direction; the leak is entirely outbound.
**§5.9 is closed as of 2026-08-07.** The context is 15 fields, and the outbound half is `workspace`, **§5.9 is closed as of 2026-08-07.** The context is 15 fields, and the outbound half is `workspace`,
`cwd`, `root` — all three facts about *where the panel is*, which is the one thing a framework of this `cwd`, `root` — all three facts about _where the panel is_, which is the one thing a framework of this
shape genuinely owes an app. Nothing left on it is an app's vocabulary: the file-browser pair is shape genuinely owes an app. Nothing left on it is an app's vocabulary: the file-browser pair is
deleted, the chat's system prompt is a prop on the chat, and the key three apps used to parse is a deleted, the chat's system prompt is a prop on the chat, and the key three apps used to parse is a
parsed identity. The two hand-written copies of the inert half are one named constant. parsed identity. The two hand-written copies of the inert half are one named constant.
@@ -759,7 +759,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
Email can supply a pre-configured chat by panel id. Email can supply a pre-configured chat by panel id.
**Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in **Done in `d3922bd`**, exactly that way: both screens put their own `ChatPanelWrapper` in
`components` under the chat panel's id and pass the prefix as a prop. `PanelSlot` prefers a `components` under the chat panel's id and pass the prefix as a prop. `PanelSlot` prefers a
`components` entry over the registry for the *body* only, so the panel keeps its registry header — `components` entry over the registry for the _body_ only, so the panel keeps its registry header —
the screens did not have to reproduce any chrome. `ChatPanelWrapper` is exported from the barrel the screens did not have to reproduce any chrome. `ChatPanelWrapper` is exported from the barrel
for it. The same prop came off `WorkspaceLayout`, where it had no callers at all: every settings for it. The same prop came off `WorkspaceLayout`, where it had no callers at all: every settings
pane and job detail rendering through it had always been passing its chat panels `undefined`. pane and job detail rendering through it had always been passing its chat panels `undefined`.
@@ -793,7 +793,7 @@ parsed identity. The two hand-written copies of the inert half are one named con
workspace, plus the state those interactions run on — are one exported `inertInteraction`, spread workspace, plus the state those interactions run on — are one exported `inertInteraction`, spread
by `WorkspaceLayout` and by the `createContext` default. `root` stays omitted, and that is now a by `WorkspaceLayout` and by the `createContext` default. `root` stays omitted, and that is now a
stated decision rather than an oversight: it is only ever read when `cwd` is scoped, and no caller stated decision rather than an oversight: it is only ever read when `cwd` is scoped, and no caller
of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root *of*. of `WorkspaceLayout` passes a `cwd` at all, so there is nothing for it to be the root _of_.
### 5.10 Channel hygiene — _(found 2026-08-07)_ ### 5.10 Channel hygiene — _(found 2026-08-07)_
@@ -953,7 +953,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
- [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never - [x] `screens.terminals` / `screens.hostTerminals` columns — never read (confirmed), **but "never
written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it written" was stale**: `upsertScreen` accepted and inserted them, so all 15 rows hold the `{}` it
wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that wrote. The dead parameters and inserts are gone. **The columns themselves are not dropped** — that
needs `bun db:push`, which diffs the *whole* schema, and this tree currently holds another agent's needs `bun db:push`, which diffs the _whole_ schema, and this tree currently holds another agent's
uncommitted `schema/agent-panels.ts`. Drop them in a push of their own. uncommitted `schema/agent-panels.ts`. Drop them in a push of their own.
- [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical - [x] ~~`SELECTED_DASHBOARD`~~ **`SELECTED_DASHBOARD_KEY`** constant — zero consumers. The parenthetical
claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the claiming `SELECTED_DASHBOARD_KEY` was the live one was **backwards**: `'SELECTED_DASHBOARD'` is the
@@ -1009,17 +1009,17 @@ they are marked below, because a dead-code list that is itself wrong is the wors
tests in `56ca411`. The Workspace directory is 76 tests across three files and green. tests in `56ca411`. The Workspace directory is 76 tests across three files and green.
**Genuinely still untested: `WorkspaceView` and `PanelSlot`.** But note §5.5's lost updates are no **Genuinely still untested: `WorkspaceView` and `PanelSlot`.** But note §5.5's lost updates are no
longer what makes that urgent — every mutation in `WorkspaceView` now goes through `onLayoutChange` longer what makes that urgent — every mutation in `WorkspaceView` now goes through `onLayoutChange`
as an *updater*, never as a computed tree, which is the structural fix; a test there would be as an _updater_, never as a computed tree, which is the structural fix; a test there would be
guarding the fix rather than finding the bug. Checked, not assumed — `bun test src/workspaces/officerdev/src/components/Workspace/`. guarding the fix rather than finding the bug. Checked, not assumed — `bun test src/workspaces/officerdev/src/components/Workspace/`.
- [x] **`useDashboardState`, and the strongest argument this section has for itself.** _(`4f8046d`, - [x] **`useDashboardState`, and the strongest argument this section has for itself.** _(`4f8046d`,
branch `agent-coordination-mvp`)_ — 14 tests over the store every layout and every branch `agent-coordination-mvp`)_ — 14 tests over the store every layout and every
`config.agentName` is persisted through. They found a live Tier-A-class defect on the first run, `config.agentName` is persisted through. They found a live Tier-A-class defect on the first run,
in code written three days earlier to *stop* silent write loss: `revert` decided whether to roll in code written three days earlier to _stop_ silent write loss: `revert` decided whether to roll
back by asking "does the cache still hold exactly what I wrote?" **by reference**, and back by asking "does the cache still hold exactly what I wrote?" **by reference**, and
`setQueryData` runs React Query's structural sharing, which rebuilds the object it stores rather `setQueryData` runs React Query's structural sharing, which rebuilds the object it stores rather
than keeping the one it was handed. Measured against @tanstack/react-query 5.101.4 — an object than keeping the one it was handed. Measured against @tanstack/react-query 5.101.4 — an object
value comes back `!==`, a string comes back `===`. So the guard was false for every *container* value comes back `!==`, a string comes back `===`. So the guard was false for every _container_
the store exists to hold, and a refused write kept its optimistic value in the cache while the the store exists to hold, and a refused write kept its optimistic value in the cache while the
toast said it had been rolled back; the change then vanished at the next reload. Only primitives toast said it had been rolled back; the change then vanished at the next reload. Only primitives
ever reverted, which is exactly why nobody saw it. Replaced with a per-key write sequence, which ever reverted, which is exactly why nobody saw it. Replaced with a per-key write sequence, which
@@ -1029,7 +1029,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
been read carefully twice — which is the case for §9 stated better than any argument. And been read carefully twice — which is the case for §9 stated better than any argument. And
`mock.module` is **process-wide and permanent** in Bun: a stub that does not spread the real `mock.module` is **process-wide and permanent** in Bun: a stub that does not spread the real
module deletes exports out from under files that never heard of it. Likewise module deletes exports out from under files that never heard of it. Likewise
`@testing-library/react` auto-registers `afterEach(cleanup)` at *import* time, so it lands in `@testing-library/react` auto-registers `afterEach(cleanup)` at _import_ time, so it lands in
whichever test file imports the library first and every later file silently gets none — that is whichever test file imports the library first and every later file silently gets none — that is
now registered in `test-setup.ts`, where preload's lack of a file scope makes it global. Adding now registered in `test-setup.ts`, where preload's lack of a file scope makes it global. Adding
one test file broke fourteen assertions in `DataTable.test.tsx` before both were understood. one test file broke fourteen assertions in `DataTable.test.tsx` before both were understood.
@@ -1037,10 +1037,10 @@ they are marked below, because a dead-code list that is itself wrong is the wors
- [x] **`WorkspaceView`, and the second consecutive bug a test found that review had not.** _(`bfa9967`, - [x] **`WorkspaceView`, and the second consecutive bug a test found that review had not.** _(`bfa9967`,
branch `agent-coordination-mvp`)_ — 11 tests over the last untested mutator, driving the real branch `agent-coordination-mvp`)_ — 11 tests over the last untested mutator, driving the real
`WorkspaceView` through the real `WorkspaceRenderer` and `PanelSlot`, so the buttons under test `WorkspaceView` through the real `WorkspaceRenderer` and `PanelSlot`, so the buttons under test
are the buttons. Two properties: every layout write is an *updater* rather than a computed tree are the buttons. Two properties: every layout write is an _updater_ rather than a computed tree
(two of the paths are deferred — the 500 ms resize debounce, and a window resize firing `onLayout` (two of the paths are deferred — the 500 ms resize debounce, and a window resize firing `onLayout`
on every group at once — so a computed tree silently undoes the write before it and resurrects an on every group at once — so a computed tree silently undoes the write before it and resurrects an
older `config`); and `usePanelClose` fires on close *intent* only, never on the unmounts a drag, older `config`); and `usePanelClose` fires on close _intent_ only, never on the unmounts a drag,
a swap or a mobile switch cause. a swap or a mobile switch cause.
Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove` Four of the eleven failed on the first run, all on one defect. `TrafficLights` took `onRemove`
**and** `isLastPanel` and used `isLastPanel` only to pick the tooltip: the red button read "Close **and** `isLastPanel` and used `isLastPanel` only to pick the tooltip: the red button read "Close
@@ -1103,7 +1103,7 @@ they are marked below, because a dead-code list that is itself wrong is the wors
branch `agent-coordination-mvp`)_ — the first two items of §5.1, and the terminal orphan leak with branch `agent-coordination-mvp`)_ — the first two items of §5.1, and the terminal orphan leak with
them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from them. `usePanelClose(panelId, handler)`, fired by `WorkspaceView` from `handleRemove` and from
`handleSetApp` when the app actually changes, and from nowhere else. `handleSetApp` when the app actually changes, and from nowhere else.
The interesting part is what it is *not*. This item used to propose diffing the layout before and The interesting part is what it is _not_. This item used to propose diffing the layout before and
after; two tests now stand in `layout-utils.test.ts` to stop anyone trying it, because `movePanel` after; two tests now stand in `layout-utils.test.ts` to stop anyone trying it, because `movePanel`
mints a fresh panel id on the way and `swapPanels` exchanges contents between stationary ones — so mints a fresh panel id on the way and `swapPanels` exchanges contents between stationary ones — so
a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app a drag reads as a close and a swap reads as two. A panel id is a position in the tree, not an app
+1 -1
View File
@@ -19,7 +19,7 @@
"build:dashboard": "bun run ./scripts/build/dashboard.ts", "build:dashboard": "bun run ./scripts/build/dashboard.ts",
"build:landing": "bun run ./scripts/build/landing.ts", "build:landing": "bun run ./scripts/build/landing.ts",
"db:gen": "cd src/databases/officer_db && bun run generate", "db:gen": "cd src/databases/officer_db && bun run generate",
"db:push": "cd src/databases/officer_db && bun run push", "db:push": "bun run scripts/gen-plugin-schemas.ts && cd src/databases/officer_db && bun run push",
"db:migrate": "cd src/databases/officer_db && bun run migrate", "db:migrate": "cd src/databases/officer_db && bun run migrate",
"dev:emailer": "cd src/workspaces/emailer && bun run dev", "dev:emailer": "cd src/workspaces/emailer && bun run dev",
"format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write", "format": "{ git diff --name-only HEAD -- 'src/**/*.ts' 'src/**/*.tsx'; git ls-files --others --exclude-standard -- 'src/**/*.ts' 'src/**/*.tsx'; } | xargs -r prettier --write",
+194
View File
@@ -0,0 +1,194 @@
# Extracting a feature into a plugin
The runbook, written the day offscale became the first one. Follow it for music, then for the rest.
**Read first, in this order:**
1. `plugins/offscale/PLUGIN.md` — every decision and why, including the three that reversed
2. `plugins/example/` — the reference implementation, deliberately the smallest real plugin
3. `plugins/offscale/` — the worked example, all four parts
4. `plugins/music/PLUGIN.md` — the MESSY worked example: three pieces that stayed behind, and why each
is a seam rather than a loose end. Read it if your feature has anything the platform also uses.
5. `src/servers/plugins/` — the system itself: `manifest`, `discover`, `mount`, `install`, `ecosystem`, `schema`, `generate`
---
## The rules. These are not preferences
**Every plugin route renders a Workspace with at least one panel.** A plugin contributes `web/panels.ts`
(`appRegistryMetas`, at least one) and `web/layout.ts` (`defaultLayout`); the shell renders
`WorkspaceView` around them. There is no way to export a component — a `web/` directory missing either
file is **refused at discovery, by name**. Non-compliance is unrepresentable, not forbidden.
**Every plugin permission is grantable, per role, at read or write.** No `kind`, no `ownerOnly`, no field
of any sort. The platform's answer is uniform; what a grant _means_ — whose rows a member sees, whether a
resource is shared or per-user — is the plugin's own job, in its own queries.
**Say `permissions`, never the other word.** It already means three things in this codebase.
**The manifest holds only what a directory listing cannot say.** Identity facts and human choices:
`publisher`, `version`, `platform`, `label`, `summary`, `icon`, `color`, `permissions`. Everything
structural is convention — presence is the declaration:
```
manifest.ts required
api/router.ts a backend router, mounted at mountPrefix()
db/schema.ts tables, prefixed <app-name>_
sidecar/index.ts a process (.mjs instead means node)
web/panels.ts panels — REQUIRED with web/
web/layout.ts layout — REQUIRED with web/
```
**A host binary is the one exception, and it goes in the manifest** — the tree cannot say it. Declare
`osDependencies` when your plugin shells out to something: the binary to probe on PATH, why it is needed,
and a package name per package manager. Absent means self-sufficient, which offscale and example are.
Music added the field; see its PLUGIN.md for what it is guarding against.
`appName` is the **directory name**. The sidecar runtime is the **file extension**.
**Nothing may branch on provenance** except `mountPrefix()`. First-party and third-party differing
anywhere else means two systems, and only one gets tested.
**Uninstall never destroys data.** The generated schema barrel follows plugin **directories**, not the
install table — `db:push` drops what it cannot see, so following installs would delete a plugin's tables
on uninstall. Only deleting a plugin's source can lose its data.
---
## The order that worked
1. **Map it first.** Sidecar, api router, db, frontend, and every line of platform wiring that names it.
2. **Move the backend**: `sidecar/``plugins/<name>/sidecar/`, `api/<name>/router.ts`
`plugins/<name>/api/router.ts` (export `router`, not `<name>Router`), `officer_db/src/<name>/*`
`plugins/<name>/db/`.
3. **Rewrite imports.** Platform code becomes `@@/…` (resolves from `plugins/` — verified). Queries take
`officerdb/db` and `officerdb/crypto`. Schema takes `officerdb/auth/schema``users.id` is the one
reference a plugin may make.
4. **Write `manifest.ts`.**
5. **Move the frontend** to `web/`, as `panels.ts` + `layout.ts`. Imports of platform UI become
`officerdev` (the barrel exports `WorkspaceView`, `TerminalView`, `AppRegistryMeta`); `hooks/useClient`
and `helpers/clipboard` stay as they are.
6. **Remove every trace from the platform**, and delete rather than comment out: `hono.ts` mount and
import, the `permissions/registry.ts` entry, `App.tsx` routes, `Screens/Dashboard/index.tsx`,
`AppRegistry.tsx`, `officerdev/src/index.ts` re-exports, `Dock.tsx` tile, `usePageTitle.ts` rule, and
**both** database barrels (`index.ts` and `schema.ts`).
7. **`bunx tsgo`** until clean. It finds the wiring you missed.
8. **Verify on the live server** — see below.
9. **Commit and push.** Message says what moved, what it found, and what is still open.
---
## Verification — run all of it
```
bun test # 757 pass, 10 pre-existing failures. Any 11th is yours
pm2 restart officer
```
Then through `/plugins`, watching PM2 and the browser at each step:
| Step | Expect |
| ------------------------------- | ----------------------------------------------------------- |
| install | streamed log; schema applied; sidecar online; route mounted |
| the plugin's API | answers |
| the plugin's screen | renders as a Workspace |
| dock | tile appears |
| permissions page | its permission is listed, read/write/none |
| disable | route 404s, sidecar stops, **tables and rows survive** |
| enable | comes back |
| uninstall | route gone, `pm2 list` loses it, **data still there** |
| `bun db:push` while uninstalled | `No changes detected` — data survives |
| install again | identical to the first install |
A normal refresh is enough; the shell is `no-store`. When the log's last line appears, the bundle exists.
---
## Traps, all of which cost real time once
- **Mount before starting the sidecar.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the router is first imported — at mount. Start first and the
announcement fires into a void: online process, mounted routes, every request `503`. Already fixed in
`install.ts`; do not reorder it.
- **`src/servers/sidecar/protocol.ts` still declares `<name>:server` per sidecar.** Music will need its
line kept, or the union generalised to `` `${string}:server` `` — which is the better fix and is
pending for the whole protocol.
- **`bunfig.toml` plugins do not reach `Bun.build()`.** Tailwind is passed explicitly in `generate.ts`.
- **The shell output is named for the entrypoint** (`index.gen.html`), and `naming` does not change it.
- **A stale generated file** (`Plugins.gen.tsx`, `plugin-schemas.gen.ts`) will fail the typecheck after a
contract change. Regenerate rather than hand-edit.
- **Delete the feature's `app-store/catalogue.ts` entry, or its screen goes blank.** `permissionAvailability`
derives from `sidecar_installs`, and a plugin never gets a row there — its install state is
`plugin_installs`. A leftover catalogue entry therefore makes the permission permanently `unavailable`,
which puts its route into `deniedRoutes` and withholds the dock tile, on a server where the plugin is
installed and healthy. This has now bitten twice: headscale (2026-08-14) and nearly music. The note in
`catalogue.ts` is the one to read.
- **Moving a `*.test.ts` into `plugins/` used to stop it running, silently.** `[test] root` was `./src`
until music; it is now `.`. If that ever goes back, every extraction quietly shrinks the suite. Compare
the FILE COUNT across a run, not just pass/fail — that is the only thing that shows it.
- **A manifest is read once per server process.** Discovery does `await import(manifest.ts)`, and the
module cache holds it for the lifetime of the process — so editing a manifest while developing changes
nothing until `pm2 restart officer`. Costs ten minutes the first time, because the plugins page keeps
cheerfully showing the old values. `outdated` cannot notice a version bump without a restart either.
- **A plugin importing platform code is fine (`@@/…`); the reverse is not.** If something in `src/` imports
from your feature and cannot move — a widget, a relay — that piece stays, and the boundary goes around
it. Find those before you plan the split; they decide it for you.
---
## Music is done. What it changed about this runbook
Extracted 2026-08-15 and verified live through the whole table above. `plugins/music/PLUGIN.md` is the
record; the parts worth carrying forward are already folded into the rules and traps above.
The one thing that generalises: **map what the PLATFORM still needs from your feature before you plan the
split.** Music's boundary was not chosen — it was dictated by two imports pointing the wrong way (a
dashboard widget reaching for `useMusicPlayer`, a cliamp relay reaching for `getMusicServerWsUrl`), and
both were found by reading the import graph rather than by reasoning about what music "is". Offscale had
none, so it came out whole and made the job look cleaner than it is.
The three pieces music left behind are `officerdev/src/MusicPlayer/`, `src/servers/api/music/router.ts`
and everything cliamp. Each is documented where it sits. **None of them is work waiting for you** — do
not tidy them into a plugin as a warm-up.
### The global-overlay question is answered, and the answer is no
Music was the first feature wanting to render on every route. It does not get to, and neither will the
next one: a shell slot for a plugin-provided component reopens "there is no way to export a component",
which is the rule the whole frontend contract rests on. `MusicPlayerHost` stays in `DashboardLayout`,
gated on its plugin's permission so it switches itself off with the plugin.
Reopen this only for a feature where the overlay is the whole product, and expect to argue for it.
---
## Which one next
No decision has been made. What the tree says, for whoever picks it:
- **`schema.ts` still lists eight commented plugin schemas** — email, notify, dav, photos, jellyfin,
invoiceshelf, soulseek, vault, wallet. Each line names its tables and the file that defines them, which
is exactly what its extraction needs.
- **`hono.ts` still has fifteen commented mounts.** Same list, roughly.
- **Soulseek is the interesting one**, and not because it is easy: `docs/navigation-audit.md` records its
panels making 37 raw upstream calls, which is the mistake the offscale sidecar exists to avoid. Its
extraction is a rewrite wearing a move's clothes. Say so up front rather than discovering it at 2am.
- **Email and wallet both hold credentials**, so they meet `secret-store` and `service_connections` in a
way neither of the first two did. Read `docs/secret-store.md` first.
## Still open, platform-wide. Do not rediscover these
- **Websocket providers**`server.reload({ routes })` proven, never called. No plugin owns a socket yet;
music would have been the first and cliamp being out of scope is what let it pass.
- **`assertPermissionTotality` reads the wrong list** — `Object.keys(handlers)` while Bun serves the route
table, and plugin routes are not in `PROTECTED_API_PREFIXES` at all. It belongs in `buildHonoApp()`,
now the single place routes are mounted. Security-adjacent; close it before members reach plugin routes.
The live example is the two cliamp sockets: served in the route table, claimed by no permission, and
invisible to the check. Pinned by a test in `registry.test.ts` so it stays a known fact.
- **Two dock sources** — the app store keeps its own catalogue; one when it is rebuilt on this
- **Offscale's queries scope by caller**, so a granted member sees their own empty list rather than the
owner's. Its own job, not the platform's.
- **`protocol.ts` declares `<name>:server` per sidecar.** `music:server` and `headscale:server` are both
still there for plugins that have left. Generalising the union to `` `${string}:server` `` is the fix.
- **`hasPersonalWrites` reads `c.personal` only**, so a plugin declaring the same thing through
`readOnlyWrites` reports `false`. Nothing renders it, so it is dead on the wire.
+10
View File
@@ -0,0 +1,10 @@
import { createRouter } from '@@/create-router';
// Mounted at `/api/example` — the prefix comes from `mountPrefix()`, which reads the manifest's
// `publisher`. Nothing here knows or cares whether this plugin is first-party.
//
// `createRouter()` rather than a bare `new Hono()`: it carries the platform's context types, so
// `ctx.get('user')` is typed and the middleware above behaves the same as it does for core routes.
export const router = createRouter();
router.get('/ping', (ctx) => ctx.json({ plugin: 'example', ok: true }));
+35
View File
@@ -0,0 +1,35 @@
import type { PluginManifest } from '@@/plugins/manifest';
// The reference plugin. Not a fixture — this is what a plugin author reads first, and it is deliberately
// the smallest thing that is still a real one: a manifest and one route.
//
// Everything structural is convention, so this directory IS the documentation:
//
// manifest.ts you are here — only what a directory listing cannot say
// api/router.ts exports `router`; mounted at /api/example
// db/schema.ts tables, if it had any (every name prefixed `example_`)
// sidecar/index.ts a process, if it needed one (.mjs instead means node)
// web/Router.tsx a frontend, if it had one
//
// `appName` is not declared anywhere: it is the directory name, so the id cannot disagree with where the
// code sits.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Example',
summary: 'The reference plugin — one route, nothing else',
icon: 'Puzzle',
color: '#94a3b8',
// One permission gating the whole surface. `ownerOnly: false` means a role can be granted it — which is
// the interesting case, because it is the one the permission gate actually has to resolve.
permissions: [
{
key: 'example',
label: 'Example',
description: 'The reference plugin',
},
],
};
+24
View File
@@ -0,0 +1,24 @@
// The reference sidecar: a long-lived process PM2 supervises.
//
// A sidecar is a PEER of `officer`, never a child — that is why restarting the platform does not disturb
// it, and it is the property that makes install-without-restart possible on the platform side too.
//
// A real one binds a loopback port and registers over `/api/sidecar/register` so the platform can reach
// it by permission (see `servers/sidecar/connect.ts`). This one does neither, on purpose: it exists to
// prove that a plugin's process is written into the ecosystem file, started, stopped and deleted by the
// installer, and adding a socket here would test Bun rather than that.
const name = 'officer-example';
console.log(`[${name}] started (pid ${process.pid})`);
// Something to see in `pm2 logs officer-example`, and a reason for the process to still be alive.
const beat = setInterval(() => console.log(`[${name}] alive`), 60_000);
const shutdown = (signal: string) => {
console.log(`[${name}] ${signal} — exiting`);
clearInterval(beat);
process.exit(0);
};
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
+23
View File
@@ -0,0 +1,23 @@
import { useParams } from 'react-router';
// The second panel, reading the URL rather than being told by its sibling.
//
// The shell registers `<prefix>` and `<prefix>/:section`, so a plugin's sections are addressable,
// linkable and cmd-clickable — the same convention every core screen follows. Panels read `useParams`
// independently; nothing is passed between them, so they cannot disagree.
export const ExampleDetail = () => {
const { section } = useParams();
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Detail</h2>
<p className="mt-1 text-sm text-duck-dark/60">
Section from the URL: <code>{section ?? '(none)'}</code>
</p>
<p className="mt-3 text-xs text-duck-dark/40">
Try <code>/example/anything</code> this panel reads it from <code>useParams</code>, with no state passed from
the panel beside it.
</p>
</div>
);
};
+27
View File
@@ -0,0 +1,27 @@
import { useClient } from 'hooks/useClient';
import { useQuery } from '@tanstack/react-query';
// A panel, not a screen. It gets whatever space the layout gives it and knows nothing about routing.
//
// `useClient` comes from the platform's workspace packages, resolved because a plugin lives inside the
// repository — no publishing, no version negotiation. This is the whole plugin↔host API in one line.
export const ExampleOverview = () => {
const client = useClient();
const { data, isLoading } = useQuery({
queryKey: ['example', 'ping'],
queryFn: () => client.get<{ plugin: string; ok: boolean }>('/example/ping'),
});
return (
<div className="h-full overflow-auto p-6">
<h2 className="text-lg font-semibold text-duck-dark">Example</h2>
<p className="mt-1 text-sm text-duck-dark/60">
A panel from <code>plugins/example/web/</code>, rendered by the shell's <code>WorkspaceView</code>.
</p>
<div className="mt-4 rounded-md border border-duck-dark/10 bg-duck-dark/[0.02] p-3 font-mono text-xs">
<div className="mb-1 text-duck-dark/50">GET /api/example/ping</div>
{isLoading ? <span className="text-duck-dark/40"></span> : <span>{JSON.stringify(data)}</span>}
</div>
</div>
);
};
+16
View File
@@ -0,0 +1,16 @@
import type { LayoutNode } from 'officerdev';
// How this plugin's panels are arranged. The shell renders `WorkspaceView` with this as the default and
// persists the user's version per plugin, so this is the starting arrangement rather than a fixed one.
//
// Every `appType` here must be a key from `panels.ts` — `appTypes.allowed` is pinned to them, so a
// mismatch falls back rather than rendering another plugin's panel inside this screen.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'example-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'example-overview', appType: 'example-overview' }, size: 40 },
{ node: { type: 'panel', id: 'example-detail', appType: 'example-detail' }, size: 60 },
],
};
+16
View File
@@ -0,0 +1,16 @@
import { Puzzle, ListTree } from 'lucide-react';
import type { AppRegistryMeta } from 'officerdev';
import { ExampleOverview } from './ExampleOverview';
import { ExampleDetail } from './ExampleDetail';
// The panels this plugin contributes. AT LEAST ONE, or discovery refuses the plugin.
//
// A plugin never renders a screen — the shell renders `WorkspaceView` around these, arranged by
// `layout.ts`. That is what makes "every plugin route is a Workspace" a property of the shape rather than
// a rule someone has to remember.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'example-overview', name: 'Overview', icon: Puzzle, component: ExampleOverview, availableOnPanel: false },
{ key: 'example-detail', name: 'Detail', icon: ListTree, component: ExampleDetail, availableOnPanel: false },
];
+71 -28
View File
@@ -3,7 +3,7 @@
Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the Platform API the mobile app uses to **stream music** and **sync a server-built library index**, so the
app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device. app no longer pre-downloads whole tracks or walks/ID3-parses the library on-device.
- **Source of truth for the code:** `src/servers/sidecar/music/index.ts` (the `officer-music` sidecar owns - **Source of truth for the code:** `plugins/music/sidecar/index.ts` (the `officer-music` sidecar owns
all of this; the platform `/api/music/*` route is a transparent auth-ing proxy). all of this; the platform `/api/music/*` route is a transparent auth-ing proxy).
- **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g. - **Music root:** `~/Music` on the server. All `path` values are **home-relative** (e.g.
`Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`. `Music/Albums/AC-DC/[1980] Back in Black/01 Hells Bells.mp3`), identical to `/api/file-browser/raw`.
@@ -29,12 +29,12 @@ GET /api/music/stream?path=<home-relative>&token=<jwt>
Byte-range streaming so the player can **seek without downloading the whole file**. Byte-range streaming so the player can **seek without downloading the whole file**.
| Case | Status | Headers | | Case | Status | Headers |
|---|---|---| | --------------------- | ------ | --------------------------------------------------------------------------------------------- |
| No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` | | No `Range` | `200` | `Content-Type`, `Content-Length`, `Accept-Ranges: bytes`, `X-Audio-Duration` |
| With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` | | With `Range: bytes=…` | `206` | `Content-Range`, `Content-Length`, `Accept-Ranges: bytes`, `Content-Type`, `X-Audio-Duration` |
- **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's - **`X-Audio-Duration`**: track duration in **seconds** (ffprobe-derived). Read this to set the player's
duration up front — it's the fix for AVPlayer reporting an *indefinite* duration on progressively-streamed duration up front — it's the fix for AVPlayer reporting an _indefinite_ duration on progressively-streamed
VBR MP3s. No need to scan the file. VBR MP3s. No need to scan the file.
- Errors: `400` invalid/missing path · `404` not found · `416` bad range. - Errors: `400` invalid/missing path · `404` not found · `416` bad range.
@@ -51,13 +51,14 @@ The server maintains a cache tree that **mirrors the library**, one entry per al
this instead of walking + ID3-parsing the library itself. this instead of walking + ID3-parsing the library itself.
Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover). Each album has a **version stamp `v`** (hash of the album's source files' names/sizes/mtimes + its cover).
`v` changes **iff the album's content changed** → it's the whole basis of the diff: *unchanged `v` ⇒ skip*. `v` changes **iff the album's content changed** → it's the whole basis of the diff: _unchanged `v` ⇒ skip_.
### 2.1 Manifest — one call, whole library ### 2.1 Manifest — one call, whole library
``` ```
GET /api/music/manifest GET /api/music/manifest
``` ```
```jsonc ```jsonc
{ {
"version": 1, "version": 1,
@@ -66,11 +67,12 @@ GET /api/music/manifest
"Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 }, "Albums/AC-DC/[1980] Back in Black": { "v": "50856380f1ca8f9", "cover": true, "tracks": 10 },
"DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 }, "DJ Sets/Dave Clarke": { "v": "a1b2c3d4e5f6a7b", "cover": false, "tracks": 3 },
"Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 }, "Albums/Metallica/[1989] Live Shit": { "v": "beefbeefbeefbee", "cover": true, "tracks": 0, "videos": 2 },
"Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true } "Albums/AC-DC": { "v": "c0ffee1234567890", "cover": true, "tracks": 0, "disco": true },
// … // …
} },
} }
``` ```
`404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an `404` if the index has never been built (see §3). Entries with **`tracks: 0`** are container folders (e.g. an
**artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its **artist** folder). An entry with **`disco: true`** is an artist folder that has a discography — fetch its
grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live grouping via `/discography` (§2.4). **`videos: N`** (optional) counts video files (concerts, clips) that live
@@ -82,7 +84,9 @@ may have any mix of `tracks`, `videos`, and `disco`.
``` ```
GET /api/music/meta?path=<rel> GET /api/music/meta?path=<rel>
``` ```
Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`. Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Match: <v>` returns `304`.
```jsonc ```jsonc
{ {
"path": "Albums/AC-DC/[1980] Back in Black", "path": "Albums/AC-DC/[1980] Back in Black",
@@ -97,23 +101,25 @@ Returns the album's `meta.json`. Sends `ETag: <v>`; a request with `If-None-Matc
"track": "1", "track": "1",
"year": "1980", "year": "1980",
"durationSec": 312, "durationSec": 312,
"lyrics": "lrc" // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2) "lyrics": "lrc", // present if lyrics exist: "lrc" = synced, "txt" = plain (see §2.3.2)
} },
// … // …
], ],
"videos": [ // present only for folders that contain video files "videos": [
// present only for folders that contain video files
{ {
"file": "1989 - Seattle.mp4", // filename within the folder "file": "1989 - Seattle.mp4", // filename within the folder
"title": "Live Shit: Seattle", // from the container title tag, if any "title": "Live Shit: Seattle", // from the container title tag, if any
"durationSec": 8130, "durationSec": 8130,
"width": 1280, "width": 1280,
"height": 720, "height": 720,
"poster": "posters/1989 - Seattle.mp4.jpg" // present when a poster was generated (see §2.3.1) "poster": "posters/1989 - Seattle.mp4.jpg", // present when a poster was generated (see §2.3.1)
} },
// … // …
] ],
} }
``` ```
All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is All track/video fields except `file` are optional (absent when the tag/stream info is missing). `videos` is
omitted entirely when the folder has none. omitted entirely when the folder has none.
To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`). To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byte-range; works for `.mp4`).
@@ -123,6 +129,7 @@ To stream a track or video: `GET /api/music/stream?path=Music/<rel>/<file>` (byt
``` ```
GET /api/music/cover?path=<rel> GET /api/music/cover?path=<rel>
``` ```
Compressed JPEG (≤600px on the long edge, ~3080 KB). Sends `ETag: <v>`; `If-None-Match: <v>``304`. Compressed JPEG (≤600px on the long edge, ~3080 KB). Sends `ETag: <v>`; `If-None-Match: <v>``304`.
Only meaningful when the manifest entry has `"cover": true`. Only meaningful when the manifest entry has `"cover": true`.
@@ -131,6 +138,7 @@ Only meaningful when the manifest entry has `"cover": true`.
``` ```
GET /api/music/poster?path=<rel>&file=<video filename> GET /api/music/poster?path=<rel>&file=<video filename>
``` ```
A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is A compressed frame grab for a video (≤600px, same treatment as covers), taken ~10% into the clip. `file` is
the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404` the video's filename within `<rel>` (URL-encode it). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404`
when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field. when the video has no poster. Only request it when that video's `meta.videos[]` entry has a `poster` field.
@@ -140,6 +148,7 @@ when the video has no poster. Only request it when that video's `meta.videos[]`
``` ```
GET /api/music/lyrics?path=<rel>&file=<track filename> GET /api/music/lyrics?path=<rel>&file=<track filename>
``` ```
Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped) Plain-text body of the track's lyrics; the `X-Lyrics-Format` header is `lrc` (synced, `[mm:ss.xx]`-timestamped)
or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404` when the track has no lyrics. Only or `txt` (plain). Sends `ETag: <v>`; `If-None-Match: <v>``304`; `404` when the track has no lyrics. Only
request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`). request it when that track's `meta.tracks[]` entry has a `lyrics` field (`"lrc"`/`"txt"`).
@@ -156,7 +165,9 @@ type**, so the player can split an artist's album list into sections (Studio, Li
``` ```
GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC GET /api/music/discography?path=<artist rel> e.g. path=Albums/AC-DC
``` ```
Sends `ETag: <v>`; `If-None-Match: <v>``304`. Sends `ETag: <v>`; `If-None-Match: <v>``304`.
```jsonc ```jsonc
{ {
"artist": "Anthrax", "artist": "Anthrax",
@@ -164,11 +175,12 @@ Sends `ETag: <v>`; `If-None-Match: <v>` → `304`.
"[1984] Fistful Of Metal": "Studio", "[1984] Fistful Of Metal": "Studio",
"[1985] Armed And Dangerous": "EP", "[1985] Armed And Dangerous": "EP",
"[1994] The Island Years": "Live", "[1994] The Island Years": "Live",
"[1991] Attack Of The Killer B's": "Compilation" "[1991] Attack Of The Killer B's": "Compilation",
// … // …
} },
} }
``` ```
- Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the - Keys are **album folder names** (`[year] title`) — they map 1:1 to the artist's album folders, i.e. the
last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here. last path segment of that album's manifest `<rel>`. Group the artist's albums by looking each up here.
- **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`, - **Types** are a normalized set: `Studio`, `Live`, `Compilation`, `Single`, `EP`, `Soundtrack`, `Remix`,
@@ -193,14 +205,23 @@ GET /api/music/reindex/status → IndexStatus snapshot
``` ```
`IndexStatus`: `IndexStatus`:
```jsonc ```jsonc
{ {
"running": true, "running": true,
"startedAt": 1785034701973, "finishedAt": null, "startedAt": 1785034701973,
"foldersScanned": 45, "albumsBuilt": 12, "albumsSkipped": 3, "finishedAt": null,
"tracksIndexed": 320, "videosIndexed": 4, "coversSaved": 12, "postersSaved": 4, "lyricsIndexed": 45, "discographies": 3, "foldersScanned": 45,
"albumsBuilt": 12,
"albumsSkipped": 3,
"tracksIndexed": 320,
"videosIndexed": 4,
"coversSaved": 12,
"postersSaved": 4,
"lyricsIndexed": 45,
"discographies": 3,
"currentPath": "Albums/AC-DC/[1980] Back in Black", "currentPath": "Albums/AC-DC/[1980] Back in Black",
"error": null "error": null,
} }
``` ```
@@ -209,6 +230,7 @@ GET /api/music/reindex/status → IndexStatus snapshot
``` ```
GET /api/music/reindex/stream GET /api/music/reindex/stream
``` ```
- **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one). - **Triggers a build if none is running.** Pass `?trigger=0` to **watch only** (subscribe without starting one).
- Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an - Emits `event: progress` (an `IndexStatus`) throttled to ~200 ms, then a single `event: done` (an
`IndexReport`) and **closes** the stream. `IndexReport`) and **closes** the stream.
@@ -222,9 +244,19 @@ data: {"albums":15,"built":12,"skipped":3,"foldersScanned":45,"tracksIndexed":32
``` ```
`IndexReport` (the `done` payload): `IndexReport` (the `done` payload):
```jsonc ```jsonc
{ "albums": 15, "built": 12, "skipped": 3, "foldersScanned": 45, {
"tracksIndexed": 320, "coversSaved": 12, "discographies": 3, "elapsedSec": 37.2, "error": null } "albums": 15,
"built": 12,
"skipped": 3,
"foldersScanned": 45,
"tracksIndexed": 320,
"coversSaved": 12,
"discographies": 3,
"elapsedSec": 37.2,
"error": null,
}
``` ```
> First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`). > First build of a large library takes a few minutes; re-runs are near-instant (unchanged albums skip via `v`).
@@ -257,7 +289,7 @@ platform straight from Postgres — same `/api/music` prefix and same auth. Keys
supplies; the server never interprets them: supplies; the server never interprets them:
| kind | key | | kind | key |
|---|---| | -------- | --------------------------------------------------------------------- |
| `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) | | `track` | home-path — `Music/<rel>/<file>` (also the `/stream` path & queue id) |
| `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` | | `album` | music-rel — `Albums/AC-DC/[1980] Back in Black` |
| `artist` | music-rel — `Albums/AC-DC` | | `artist` | music-rel — `Albums/AC-DC` |
@@ -266,7 +298,11 @@ supplies; the server never interprets them:
- **`GET /api/music/favorites`** → grouped keys, newest first: - **`GET /api/music/favorites`** → grouped keys, newest first:
```json ```json
{ "tracks": ["Music/…/01 Hells Bells.mp3"], "albums": ["Albums/AC-DC/[1980] Back in Black"], "artists": ["Albums/AC-DC"] } {
"tracks": ["Music/…/01 Hells Bells.mp3"],
"albums": ["Albums/AC-DC/[1980] Back in Black"],
"artists": ["Albums/AC-DC"]
}
``` ```
- **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }``{ ok: true }`. Idempotent - **`POST /api/music/favorites`** `{ "kind": "track|album|artist", "key": "…" }``{ ok: true }`. Idempotent
(a repeat add is a no-op). (a repeat add is a no-op).
@@ -281,9 +317,16 @@ launch to offer "resume".
- **`GET /api/music/now-playing`** → the snapshot or `null`: - **`GET /api/music/now-playing`** → the snapshot or `null`:
```json ```json
{ "homePath": "Music/…/01 Hells Bells.mp3", "dir": "Music/Albums/AC-DC/[1980] Back in Black", {
"title": "Hells Bells", "artist": "AC/DC", "album": "Back in Black", "homePath": "Music/…/01 Hells Bells.mp3",
"durationSec": 312.5, "positionSec": 140, "updatedAt": "2026-07-27T11:27:54.441Z" } "dir": "Music/Albums/AC-DC/[1980] Back in Black",
"title": "Hells Bells",
"artist": "AC/DC",
"album": "Back in Black",
"durationSec": 312.5,
"positionSec": 140,
"updatedAt": "2026-07-27T11:27:54.441Z"
}
``` ```
`dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track). `dir` is the folder to rebuild the album queue from (empty for a cross-album queue → resume the single track).
- **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }` - **`PUT /api/music/now-playing`** `{ homePath (required), dir?, title?, artist?, album?, durationSec?, positionSec? }`
@@ -300,7 +343,7 @@ Server-side playlists, scoped to the calling user. Items are track **keys** —
put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable. put. `404` throughout means "not yours or not there"; the two are deliberately indistinguishable.
| method | path | body | returns | | method | path | body | returns |
|---|---|---|---| | -------- | -------------------------------- | -------------- | ---------------------------------------------------------------- |
| `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first | | `GET` | `/api/music/playlists` | — | `[{ id, name, count, createdAt, updatedAt }]`, most recent first |
| `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken | | `POST` | `/api/music/playlists` | `{ name }` | `201` with the row; `409` if the name is taken |
| `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` | | `GET` | `/api/music/playlists/:id` | — | `{ id, name, items: [key], … }` |
@@ -315,6 +358,6 @@ put. `404` throughout means "not yours or not there"; the two are deliberately i
- **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed. - **Covers are server-compressed** (≤600px / q5) — sync them as-is; no client-side resizing needed.
- **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds). - **Durations are exact** (ffprobe) in both `X-Audio-Duration` and `meta.json`'s `durationSec` (seconds).
- **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed *offline - **Playback still goes through `/stream`** — the index is metadata + covers only. (Server-managed _offline
audio files* is a separate, later feature.) audio files_ is a separate, later feature.)
- **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable. - **Errors** are plain HTTP: `503` if the music sidecar isn't connected, `502` if it's unreachable.
+284
View File
@@ -0,0 +1,284 @@
# Music — the second plugin
**Status: extracted 2026-08-15.** Written after the fact rather than during, because unlike offscale this
one had no design questions left open — the runbook (`plugins/EXTRACTING-A-PLUGIN.md`) had already decided
everything except one call. This records what moved, what did not, and the two bugs the extraction found.
Read `plugins/offscale/PLUGIN.md` first. It is the design document for the plugin system; this is a
worked second case, and it is interesting mainly for being the messy one.
---
## What music is
The `/music` screen, the library index, and the phone and tablet apps that stream from it. The contract
those apps speak is `MUSIC_API.md`, next to this file — it is the reason the sidecar's HTTP shape is not
free to change.
```
manifest.ts identity, one permission
api/router.ts re-exports the platform's proxy — see below
sidecar/index.ts the whole /api/music contract (503 lines)
sidecar/indexer.ts the library walker → cache tree + manifest (1079 lines)
sidecar/stream-audio.ts 206 / Content-Range / 416, and X-Audio-Duration
sidecar/nightly-reindex.ts 3am full rebuild, staged and swapped
db/ music_favorites, _playlists, _playlist_items, _now_playing
web/ two panels and a layout; the shell renders the Workspace
scripts/ the reindex CLI, which talks to the sidecar port directly
```
---
## The three things that stayed, and why
Offscale left nothing behind. Music leaves three, and calling them seams rather than loose ends only
means each one is written down with what would close it.
### 1. cliamp — parked in the plugin, not left in the platform
`cliamp` and `cliamp-audio` are a _second_ playback path: the `cliamp` TUI run on the server, with its
terminal and its PulseAudio null sink piped to the browser. Out of scope by the owner's decision.
**All of it now lives in `./cliamp/`** — moved 2026-08-15, in two passes on the same day:
```
sidecar/music/{cliamp-ws,pulse-audio}.ts, asoundrc, cliamp-ws.test.ts → cliamp/
api/cliamp/relay.ts → cliamp/relay.ts
apps/FileBrowser/{CliampPanel,AudioStreamPlayer}.tsx → cliamp/
```
`src/servers/sidecar/music/`, `src/servers/api/cliamp/` and `src/servers/api/music/` are **gone**, and
`server.tsx` has no cliamp import, provider name, handler entry or route left.
Two things went with it that were live rather than inert:
- **The file browser's `Play` action.** A context-menu item on any audio file or folder set `?play=`,
which rendered a cliamp terminal panel pointed at `/api/cliamp/ws` — a route that upgraded into a
`handlers` entry that was commented out, so `handlers[provider]!.open(ws)` asserted non-null on
`undefined`. **Using that menu item crashed the socket handler.** The action, its layout, its panel and
its two menu entries are removed; the components are parked here.
- **The two socket routes.** They now 404. Verified live.
That closed the totality drift as a side effect: `server.tsx`'s route table and its `handlers` map agree
again, which they had not since 2026-08-13. `registry.test.ts` keeps an assertion on it.
**What it takes to bring cliamp back:** a plugin owning a websocket. `server.reload({ routes })` is proven
and never called. A platform gap, not a music one.
### 2. ~~`src/servers/api/music/router.ts`~~ — deleted, and the reason it existed was nothing
The proxy was constructed in PLATFORM code that knew the string `'music'`, and this plugin's
`api/router.ts` merely re-exported it. The stated reason: `api/cliamp/relay.ts` imported
`getMusicServerWsUrl` from it, so it could not move.
That reason was three layers of nothing:
- `server.tsx:20` imported the relay's two exports — **used only on commented-out lines**
- so the relay's functions were never invoked, and its call to `getMusicServerWsUrl` never ran
- and the file's other export, `getMusicServerUrl`, had **no consumers at all**
A dead import held a music-named file in the platform. The proxy is now built in
`plugins/music/api/router.ts`; the relay takes its URL from there.
**And the prefix is derived rather than written.** It was the literal `'/api/music'`, which the proxy uses
to strip characters off the path. That is correct only because `mountPrefix` returns `/music` for a
first-party publisher — the same plugin published by anyone else mounts at `/api/p/<publisher>/music` and
would have forwarded `/alice/music/stream` to a sidecar expecting `/stream`. A latent bug only third
parties would ever hit, and a quiet violation of the rule that `mountPrefix` is the one function allowed
to know about provenance. It now calls `mountPrefix`.
`[open]` `appName` is still a literal there, because a plugin's router cannot see its own directory name —
the platform imports the module and reads `router`, so there is nowhere to inject it. The fix is
`api/router.ts` exporting a factory the installer calls with the plugin's own identity.
### 3. ~~The player~~ — moved, and the reasoning that kept it was removed rather than refuted
The first version of this document said the player stayed in the platform and called the decision
settled by a hard constraint:
> `useMusicPlayer` and `PlayerTrack` are imported from `officerdev` by `widgets/MusicPlayer/`, the
> dashboard widget — and the platform cannot import from a plugin. So the player state stays whatever is
> decided about the UI.
True at the time. The owner then moved the widget into the plugin, and the constraint evaporated: the
complete remaining platform dependency became one line, `DashboardLayout.tsx:66`.
So the whole of `officerdev/src/MusicPlayer/` now lives in `web/` — engine, state, bar, favourites,
lyrics toggle and the library vocabulary. **`src/` contains no music code at all.**
**Where the engine is mounted, and why it is not the shell.** `MusicPlayerHost` renders inside
`MusicDetail`, at the foot of the library view. That reads odd until you notice what it already did:
```tsx
if (pathname.startsWith('/music')) return null; // the panel draws its own MusicMiniBar
```
On `/music` the host has always rendered nothing and existed purely to own the `GaplessEngine`. Mounted
in the panel it does exactly that, and the bar code stays intact for whenever there is a slot to put it in.
**`[phase 2]` Leaving `/music` unmounts the host, which stops playback.** Deliberately deferred rather
than solved: making audio outlive the route needs either a shell slot a plugin can contribute to — which
reopens "there is no way to export a component" — or the engine hoisted to module scope so a panel
attaches and detaches from a singleton. The second keeps the rule and loses only the off-route transport
controls, and is the better idea, but it is a rewrite of the host's lifecycle rather than a move.
Nothing breaks in the meantime, and that was the bar: `player-time`'s `seekPlayer` is optional-chained
so a call with no host registered is a no-op, `registerPlayerSeek` clears only its own registration, the
host's cleanup destroys the engine and nulls its ref, and the queue lives in global state — so returning
to `/music` remounts the host and reloads it.
## Two bugs, neither visible from reading
**The app-store catalogue still listed music, and that would have blanked the screen.**
`permissionAvailability()` derives from `sidecar_installs`, and a _plugin_ never gets a row there — its
install state is `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
`/music` into `deniedRoutes`: dock tile withheld, screen blank, on a server where the plugin was
installed, enabled and healthy.
This is the **headscale bug, exactly** — and it is documented six lines above where the music entry sat,
in the same file. Found by reading that note rather than by hitting it again, which is the only reason
it cost minutes instead of an evening. Entry removed.
**`[test] root = "./src"`, so moving `lyrics.test.ts` into `plugins/` stopped running it silently.** The
count fell by nine and the suite still read green-ish. A test that quietly stops running is worse than
one that fails, and _every_ future extraction would have taken its tests out of the suite the same way.
Root is now the repo. Positional filters cannot fix this — `bun test plugins` matches paths under root,
so it finds `src/servers/plugins/` and not `plugins/`.
---
## Permissions
One permission, `music`, and the key is deliberately unchanged from the registry entry it replaces — so
every existing `role_permissions` grant keeps meaning what it meant, and `can('music')` keeps resolving
for the overlay. Renaming it would have been a silent data change.
The old entry carried `personal: ['/favorites', '/now-playing', '/playlists', '/queue']`. A manifest has
no `personal` field and should not grow one: that is the per-user visibility model, which is the plugin's
own job and explicitly not this extraction's work. They ride across on `readOnlyWrites` instead, because
`isRequestAllowedAtLevel` **concatenates the two lists** — one mechanism under two names. A read grant
therefore permits exactly the four paths it permitted yesterday, and no field was added.
`/queue` is in that list because it was. No such route exists, in the sidecar or anywhere else.
`[open]` What a member's grant _means_ is unfinished, and music is where the richer model was always
going to be designed (`plugins/offscale/PLUGIN.md` says so). It is genuinely non-uniform here in a way
offscale's is not: favourites, playlists and now-playing are already per-caller — the sidecar scopes
every one by the `X-Officer-User` header the proxy injects — while the library is one shared index for
the household. So "whose row is this" already has a real answer on one side and not the other. That is a
change inside `db/queries.ts`, not a flag on the manifest.
---
## Host dependencies — the field music created
`ffmpeg` and `ffprobe`. Offscale needed nothing, so until music there was no reason to build this and no
way to say it; the first draft of this document said "there is no field for a host binary" and left it at
that. That was the wrong answer, because of HOW music fails without them.
It does not fail. `ffprobe` missing means the indexer catches the spawn error and returns a track carrying
its filename and nothing else — no title, artist, album, duration or embedded lyrics — then walks the
whole library, writes a complete cache tree and reports success. Five swallowed catches in
`indexer.ts` and `stream-audio.ts`, no log, no counter. The only tell is `coversSaved: 0` in a report
nobody reads. A refusal wearing the costume of a normal result.
So `osDependencies` is a manifest field now (`servers/plugins/manifest.ts`, `servers/plugins/os-deps.ts`):
```ts
osDependencies: [
{ binary: 'ffprobe', reason: '…', packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' } },
{ binary: 'ffmpeg', reason: '…', packages: { … } },
]
```
Both are declared even though one package provides both, because the platform probes BINARIES and these
two fail differently — and the owner should be told which one they are missing. The installer dedupes to
a single `ffmpeg` before anything reaches a command line.
The shape is `scripts/setup-old/setup.sh`'s, not invented: probe the binary, map to a package name per
manager. Probing the binary is what makes "built-in on this OS" free — if it is on PATH the package map
is never consulted. Per-manager names rather than canonical-with-overrides because `packages.sh` already
recorded why that indirection was rejected.
**Verified end to end on 2026-08-15.** Both binaries were absent on this machine all evening. The plugins
page showed `ffprobe missing — ffmpeg` and `ffmpeg missing — ffmpeg` with the exact root command it would
run; installing streamed `dependencies: installing ffmpeg with apt` → `dependencies: ffprobe, ffmpeg now
on PATH`, and `X-Audio-Duration: 7.026939` appeared on a stream response for the first time. The refusal
path was exercised separately against a temporary probe dependency: HTTP 400, `steps: []`, and the reason
named — nothing had happened, so there was nothing to undo.
`~/Music` still does not exist, so there is no library to index.
`cliamp`, `parec`, `pulseaudio` and `pactl` stayed behind with cliamp. The sidecar logs
`pulseaudio not installed, skipping audio setup` and carries on, which is the right shape.
---
## Verified on the live server, 2026-08-15
The runbook's table, run against `platform.officer.dev` rather than reasoned about.
| Step | Result |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| install | streamed 5 steps; schema applied in 2082ms; `officer-music` online; `/example, /music, /offscale` mounted |
| the API | `/api/music/manifest` 200, `/api/music/favorites` returns per-user JSON |
| range requests | full 200 + `Accept-Ranges`; `bytes=100-199`**206**, correct `Content-Range`, exactly 100 bytes; unsatisfiable → **416**; `../../etc/passwd`**400** |
| the screen | route generated in `Plugins.gen.tsx`, panels in the built bundle, `PluginScreen` wraps `WorkspaceView`. Structural — not eyeballed in a browser |
| dock | tile present in `/api/user/permissions`; `/music` in `routes`, not in `deniedRoutes` |
| permissions page | `music` listed among the grantable |
| disable | route 404s, sidecar `stopped`, **rows survive** |
| enable | 200 again, sidecar online, favourites still there |
| uninstall | route 404s, **absent from pm2**, ecosystem entry removed, **rows survive** |
| `bun db:push` while uninstalled | **`No changes detected`**, rows survive |
| install again | byte-identical steps, and a **restore** — the seeded favourite and playlist came back |
| `pm2 restart officer` | boots clean, all three plugins mount, music answers 200 |
Seeded rows and the audio fixture were removed afterwards; `~/Music` was deleted again, since it did not
exist before.
**Music is left INSTALLED and enabled.** It had been switched off since 2026-08-13, so this restores it.
---
## Still open
### The library browser reads the filesystem, not this plugin — and that is a PERMISSION dependency
Found 2026-08-15, after the extraction landed, by reading the code rather than by anything failing.
`MusicBrowser.tsx` lists folders with `GET /file-browser/ls`, not through the music sidecar
(`MusicBrowser.tsx:63,81`). `/file-browser` belongs to the **`files`** permission, and `files` is
**`confined`** — so:
- a member granted `music` but not `files` gets a working player, working favourites, and an **empty
library**, because every listing 403s;
- and `files` is not a grant that can simply be handed over. `authorize.ts` drops a confined grant for an
account with no `osUser`, so it means nothing without a per-user Linux account.
This is the first **cross-plugin permission dependency** in the system, and it is a different animal from
the one offscale has. Offscale's `ConsoleView``TerminalView` is a CODE dependency: it resolves at build
time, and the worst case is a plugin that will not compile. This one resolves at request time, per
account, and its failure mode is a screen that renders perfectly and shows nothing.
Three possible shapes, none chosen:
1. **The sidecar lists.** Music already walks the library for its index — `GET /music/ls` would put the
listing behind the `music` permission where it belongs, and the plugin stops needing `files` at all.
Most self-contained, and the most work.
2. **The manifest declares a permission dependency**, and the platform refuses the grant or warns. Honest,
but it makes one plugin's grant conditional on another permission, which is new machinery.
3. **Leave it and document it** — a member needs `files` too. Cheapest, and it quietly ties a music grant
to a Linux account, which is a much bigger commitment than the owner is agreeing to on that page.
(1) is probably right, and it is the same shape as offscale's rule that the sidecar absorbs everything.
Not tonight's call.
- **`hasPersonalWrites` reads `c.personal` only**, so the permissions API reports `false` for a plugin
that declares the same thing through `readOnlyWrites`. Nothing renders the field, so it is dead on the
wire — noted rather than fixed.
- **Two dock sources.** The app store keeps its own catalogue while the plugin system builds tiles from
manifests, and the self endpoint concatenates both. One when the store is rebuilt on the plugin system.
- **`src/servers/sidecar/protocol.ts` still declares `music:server`** per sidecar. Generalising the union
to `` `${string}:server` `` is the better fix and is pending for the whole protocol.
- **The cliamp sockets are claimed by no permission**, and are served. Now pinned by a test in
`registry.test.ts` rather than left to be rediscovered — closing it is the totality work.
+46
View File
@@ -0,0 +1,46 @@
import { createSidecarProxy } from '@@/sidecar/create-proxy';
import { mountPrefix } from '@@/plugins/manifest';
import { manifest } from '../manifest';
// /api/music/* — auth, then forward to officer-music. No routes of its own and no music knowledge here:
// the whole contract lives in ../sidecar/index.ts, which is where the routes actually are.
//
// ── This used to live in the platform, and that was the bug ──
//
// Until 2026-08-15 the proxy was constructed in `src/servers/api/music/router.ts` — PLATFORM code that
// knew the string 'music' — and this file merely re-exported it. The justification was that
// `api/cliamp/relay.ts` imported `getMusicServerWsUrl` from it, so it could not move.
//
// That justification was three layers of nothing. The relay's functions were only reachable through
// `handlers` entries in server.tsx that were commented out, and its own import there was unused. A dead
// import held a music-named file in the platform, and the second export on it (`getMusicServerUrl`) had
// no callers at all. The relay now lives in ../cliamp/ and takes its URL from here.
//
// ── The prefix is DERIVED, not written ──
//
// It was the literal '/api/music', and that is wrong in a way that only shows up for someone else's
// plugin. The proxy strips `prefix.length` characters to build the sidecar path, so a hardcoded
// '/api/music' (10 chars) is correct only because `mountPrefix` happens to return `/music` for a
// first-party publisher. The same plugin published by anyone else mounts at `/api/p/<publisher>/music`
// and would forward `/alice/music/stream` to a sidecar expecting `/stream`.
//
// `mountPrefix` is the ONE function allowed to know about provenance, so the prefix comes from it. A
// literal here is that rule being broken quietly, which is exactly how first-party and third-party
// become two systems with only one of them tested.
//
// `appName` is passed as a literal because this file cannot see its own directory name. That is a real
// gap — the platform imports `router.ts` and reads `router`, so there is nowhere to inject it — and the
// day a plugin's router needs its own identity for anything else, `api/router.ts` should export a
// factory the installer calls instead. Recorded rather than worked around.
const proxy = createSidecarProxy({
name: 'music',
prefix: `/api${mountPrefix({ appName: 'music', manifest })}`,
// A from-scratch reindex holds the connection open for minutes with no bytes flowing; the default 60s
// idle drop would kill it. Applied to the whole prefix — the proxy must not know which routes are slow.
timeoutSeconds: 1800,
});
export const router = proxy.router;
/** The sidecar as a `ws://` base. Used by ../cliamp/relay.ts, and by nothing else. */
export const getMusicServerWsUrl = proxy.getWsUrl;
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

@@ -83,7 +83,10 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
ctxRef.current = audioCtx; ctxRef.current = audioCtx;
await audioCtx.audioWorklet.addModule(workletBlobUrl); await audioCtx.audioWorklet.addModule(workletBlobUrl);
if (disposed) { audioCtx.close(); return; } if (disposed) {
audioCtx.close();
return;
}
const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', { const workletNode = new AudioWorkletNode(audioCtx, 'pcm-processor', {
outputChannelCount: [CHANNELS], outputChannelCount: [CHANNELS],
@@ -138,11 +141,23 @@ export const AudioStreamPlayer = ({ wsUrl, onError }: AudioStreamPlayerProps) =>
return () => { return () => {
disposed = true; disposed = true;
try { wsRef.current?.close(); } catch { /* ignore */ } try {
wsRef.current?.close();
} catch {
/* ignore */
}
wsRef.current = null; wsRef.current = null;
try { nodeRef.current?.disconnect(); } catch { /* ignore */ } try {
nodeRef.current?.disconnect();
} catch {
/* ignore */
}
nodeRef.current = null; nodeRef.current = null;
try { audioCtx?.close(); } catch { /* ignore */ } try {
audioCtx?.close();
} catch {
/* ignore */
}
ctxRef.current = null; ctxRef.current = null;
gainRef.current = null; gainRef.current = null;
}; };
@@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useSearchParams } from 'react-router'; import { useSearchParams } from 'react-router';
import { Music } from 'lucide-react'; import { Music } from 'lucide-react';
import { TerminalView } from '../Terminal/Terminal'; import { TerminalView } from 'officerdev';
import { AudioStreamPlayer } from './AudioStreamPlayer'; import { AudioStreamPlayer } from './AudioStreamPlayer';
export const CliampPanelHeader = () => { export const CliampPanelHeader = () => {
@@ -1,9 +1,9 @@
import type { ServerWebSocket } from 'bun'; import type { ServerWebSocket } from 'bun';
import { getMusicServerWsUrl } from '../music/router'; import { getMusicServerWsUrl } from '../api/router';
// Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a // Platform side of the two cliamp sockets. Both used to spawn processes here — the `cliamp` player and a
// `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the // `parec` capture — which put the whole local-audio pipeline inside the thin proxy. They now live in the
// music sidecar (`sidecar/music/cliamp-ws.ts`), and this is what is left of them: authenticate the browser // music PLUGIN (`plugins/music/cliamp/cliamp-ws.ts`), and this is what is left of them: authenticate the browser
// (done before the upgrade, in server.tsx), then pass frames through in both directions without reading // (done before the upgrade, in server.tsx), then pass frames through in both directions without reading
// them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay. // them. Text or binary, no inspection — same dumb-pipe shape as the vault notifications relay.
@@ -1,5 +1,5 @@
import { eq, and, desc, asc, sql } from 'drizzle-orm'; import { eq, and, desc, asc, sql } from 'drizzle-orm';
import { db } from '../db'; import { db } from 'officerdb/db';
import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema'; import { musicFavorites, musicNowPlaying, musicPlaylists, musicPlaylistItems } from './schema';
export type FavoriteKind = 'track' | 'album' | 'artist'; export type FavoriteKind = 'track' | 'album' | 'artist';
@@ -1,5 +1,5 @@
import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core'; import { pgTable, serial, integer, text, real, timestamp, index, primaryKey, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from '../auth/schema'; import { users } from 'officerdb/auth/schema';
// Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets: // Per-user music favorites. `key` is an opaque path the app supplies and the server never interprets:
// track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id) // track → homePath "Music/<rel>/<file>" (also the /stream path + RNTP queue id)
+103
View File
@@ -0,0 +1,103 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Music — the library, the player, and the phone and tablet apps that stream from it.
//
// The second plugin extracted from the platform, on 2026-08-15. Bigger than offscale and, unlike it, not
// a clean cut. It took two passes: the first left three pieces in the platform, and the second moved two
// of them here after the owner read the code and asked why the platform still had files named for music.
// He was right — one of the three "seams" turned out to be dead code holding the door open.
//
// api/router.ts the sidecar proxy, built here — thin, and it must never grow music knowledge
// cliamp/ the second playback path, parked
// widgets/ the dashboard widget, parked
// assets/icon.png the dock tile, published to /plugins/music/ on install
// sidecar/ the whole /api/music contract: indexing, streaming, per-user state
// db/ music_favorites, _playlists, _playlist_items, _now_playing
// web/ the library panels; the shell renders the Workspace
//
// ── What stayed in the platform, and why ── (nothing. All three moved here.)
//
// 1. ~~cliamp~~ — MOVED HERE, all of it, into `./cliamp/`. The sidecar halves, the relay, the file
// browser's panel and its `Play` action. `src/servers/sidecar/music/`, `src/servers/api/cliamp/` and
// `src/servers/api/music/` no longer exist, and `server.tsx` has no cliamp anything. It is PARKED, not
// working: bringing it back needs a plugin to own a websocket, which is a platform gap.
//
// 2. ~~The dashboard widget~~ — MOVED HERE, to `./widgets/`, and unregistered from WidgetRegistry.
// Parked: plugins cannot contribute widgets and that mechanism is not built.
//
// 3. ~~The global player overlay~~ — MOVED HERE, to `./web/`. It stayed while the widget pinned
// `useMusicPlayer` in `officerdev`; once the widget left, the only platform dependency was one line
// in DashboardLayout. `MusicPlayerHost` now mounts inside the MusicDetail panel, where it owns the
// audio engine and renders nothing — which is what it already did on /music.
//
// `[phase 2]` Leaving /music stops playback. Giving audio a life outside the route needs a shell slot
// a plugin can contribute to, or the engine hoisted to module scope. Deferred deliberately; nothing
// breaks meanwhile.
//
// ── Host dependencies ──
//
// Music is the plugin that made `osDependencies` exist. Offscale was self-sufficient, so until this one
// there was nothing to declare and no reason to build the field — see ./PLUGIN.md.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Music',
summary: 'The music library — browse, play, favourites and playlists',
// No `icon` field: this plugin ships `assets/icon.png` and the file wins. A lucide name could only
// ever pick from the 106 glyphs the platform happens to bundle, which is a ceiling a plugin from a
// marketplace cannot see coming — and this one's artwork is a voxel duck in headphones, not a glyph.
color: '#22c55e',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// The key is `music` and that is not incidental: it is the key the platform's own registry used until
// this extraction, so every existing `role_permissions` grant keeps meaning what it meant, and the
// overlay's `can('music')` keeps resolving. Renaming it would have been a silent data change.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. Favourites,
// playlists and now-playing are already per-caller — the sidecar scopes every one of them by the
// `X-Officer-User` header the proxy injects — while the library itself is one shared index for the
// household. So "whose row is this" already has a real, non-uniform answer, which is why the platform's
// side of it is a uniform read/write and nothing more. Designing the rest belongs in these queries.
permissions: [
{
key: 'music',
label: 'Music',
description: 'The music library, playback, and your own favourites and playlists',
// These are the `personal` paths from the registry entry this replaces, carried across verbatim.
//
// They are not read-only — they are genuine writes to the CALLER'S own data, which is what made
// them safe at read level. The manifest deliberately has no `personal` field, and adding one would
// be designing the per-user visibility model that is explicitly not this extraction's work. It
// costs nothing to go without: `isRequestAllowedAtLevel` concatenates `personal` and
// `readOnlyWrites` into a single allow-list, so the two are the same mechanism under two names and
// a read grant permits exactly the same four paths it permitted yesterday.
//
// `/queue` is here because it was there. No such route exists, in the sidecar or anywhere else.
readOnlyWrites: ['/favorites', '/now-playing', '/playlists', '/queue'],
},
],
// Both come from one package everywhere, which is luck rather than a rule — hence a name per manager
// rather than one canonical name. `packages.sh` records why that indirection was rejected.
//
// They are declared SEPARATELY even so, because the platform probes binaries and these two fail
// differently. Losing `ffprobe` is the quiet one: the indexer catches the spawn error and returns a
// track carrying its filename and nothing else — no title, artist, album, duration or embedded
// lyrics — then reports success. Losing `ffmpeg` costs cover art and video poster frames, which is at
// least visible. Naming both means the owner is told which of the two they are missing.
osDependencies: [
{
binary: 'ffprobe',
reason: 'Reads tags, duration and embedded lyrics. Without it every track indexes as a bare filename.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
{
binary: 'ffmpeg',
reason: 'Compresses cover art for phones and grabs poster frames from videos.',
packages: { apt: 'ffmpeg', pacman: 'ffmpeg', dnf: 'ffmpeg', brew: 'ffmpeg' },
},
],
};
@@ -1,10 +1,10 @@
import { mkdirSync, writeFileSync } from 'node:fs'; import { mkdirSync, writeFileSync } from 'node:fs';
import { join, basename } from 'node:path'; import { join, basename } from 'node:path';
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { streamAudioFile } from './stream-audio'; import { streamAudioFile } from './stream-audio';
import { cliampUpgradeData, musicWebsocket } from './cliamp-ws'; import { cliampUpgradeData, musicWebsocket } from '../cliamp/cliamp-ws';
import { ensurePulseAudio } from './pulse-audio'; import { ensurePulseAudio } from '../cliamp/pulse-audio';
import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex'; import { startNightlyReindex, stopNightlyReindex } from './nightly-reindex';
import { import {
reindexNow, reindexNow,
@@ -37,10 +37,9 @@ import {
addPlaylistItems, addPlaylistItems,
setPlaylistItems, setPlaylistItems,
type FavoriteKind, type FavoriteKind,
} from 'officerdb'; } from '../db/queries';
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '@@/data-path';
import { API_URL } from '../../officer-url.mjs'; import { API_URL } from '@@/officer-url.mjs';
// ── Per-user state validation ── // ── Per-user state validation ──
// The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're // The authenticated user id arrives in X-Officer-User (the platform proxy injects it after auth; we're
@@ -115,7 +114,6 @@ const asKeys = (v: unknown): string[] | null =>
// `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading. // `v` = per-album version stamp; unchanged `v` ⇒ nothing changed ⇒ the phone can skip re-downloading.
// ───────────────────────────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────────────────────────
// ── Audio-streaming HTTP server ── // ── Audio-streaming HTTP server ──
/** Grab an ephemeral free port by briefly binding one and releasing it. */ /** Grab an ephemeral free port by briefly binding one and releasing it. */
@@ -476,7 +474,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
const connection = createSidecarConnector({ const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`, apiUrl: `${API_URL}/api/sidecar/register`,
name: 'music', name: 'music',
capabilities: ['music'], handles: ['music'],
onCommand(cmd, reply) { onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn); handleCommand(cmd as SidecarCommand, reply as ReplyFn);
}, },
@@ -20,7 +20,7 @@ import { homedir } from 'node:os';
// name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the // name+size+mtime, cover size+mtime). It drives BOTH incremental build (skip unchanged albums) and the
// phone's resync diff (fetch only changed `v`s). // phone's resync diff (fetch only changed `v`s).
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '@@/data-path';
const HOME = homedir(); const HOME = homedir();
export const MUSIC_ROOT = join(HOME, 'Music'); export const MUSIC_ROOT = join(HOME, 'Music');
@@ -678,7 +678,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
// A cache-format upgrade rebuilds every album by definition, so the delta is expected and says // A cache-format upgrade rebuilds every album by definition, so the delta is expected and says
// nothing about drift. Label it rather than let it read as 6k albums of rot. // nothing about drift. Label it rather than let it read as 6k albums of rot.
if (prev.version !== next.version) { if (prev.version !== next.version) {
console.log(`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`); console.log(
`[music] full reindex: cache format v${prev.version} → v${next.version}, delta below is the upgrade itself`,
);
} }
const { added, removed, changed } = diffManifest(prev, next); const { added, removed, changed } = diffManifest(prev, next);
@@ -688,7 +690,9 @@ function logManifestDelta(prev: Manifest, next: Manifest): void {
return; return;
} }
console.log(`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`); console.log(
`[music] full reindex delta: +${added.length} added, -${removed.length} removed, ~${changed.length} changed`,
);
const sample = (label: string, rels: string[]) => { const sample = (label: string, rels: string[]) => {
for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`); for (const rel of rels.slice(0, 5)) console.log(`[music] ${label} ${rel || '.'}`);
if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`); if (rels.length > 5) console.log(`[music] ${label} …and ${rels.length - 5} more`);
@@ -21,7 +21,9 @@ export function startNightlyReindex(): void {
const schedule = () => { const schedule = () => {
const ms = msUntilNextHour(REINDEX_HOUR); const ms = msUntilNextHour(REINDEX_HOUR);
const at = new Date(Date.now() + ms); const at = new Date(Date.now() + ms);
console.log(`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`); console.log(
`[music] nightly full reindex scheduled for ${at.toLocaleString()} (in ${(ms / 3_600_000).toFixed(1)}h)`,
);
timer = setTimeout(async () => { timer = setTimeout(async () => {
console.log('[music] nightly full reindex starting'); console.log('[music] nightly full reindex starting');
try { try {
@@ -29,7 +29,16 @@ async function probeDuration(absPath: string, mtimeMs: number): Promise<number |
if (cached !== undefined) return cached; if (cached !== undefined) return cached;
try { try {
const proc = Bun.spawn( const proc = Bun.spawn(
['ffprobe', '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', absPath], [
'ffprobe',
'-v',
'error',
'-show_entries',
'format=duration',
'-of',
'default=noprint_wrappers=1:nokey=1',
absPath,
],
{ stdout: 'pipe', stderr: 'ignore' }, { stdout: 'pipe', stderr: 'ignore' },
); );
const out = (await new Response(proc.stdout).text()).trim(); const out = (await new Response(proc.stdout).text()).trim();
@@ -88,7 +97,11 @@ export async function streamAudioFile(relPath: string, rangeHeader: string | nul
} }
return new Response(file.slice(start, end + 1), { return new Response(file.slice(start, end + 1), {
status: 206, status: 206,
headers: { ...baseHeaders, 'Content-Range': `bytes ${start}-${end}/${total}`, 'Content-Length': String(end - start + 1) }, headers: {
...baseHeaders,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Content-Length': String(end - start + 1),
},
}); });
} }
} }
@@ -3,7 +3,7 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react'; import { Heart, User, Disc3, Music, ChevronRight, X } from 'lucide-react';
import { useMusicPlayer, type PlayerTrack } from '../../MusicPlayer'; import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { MusicHeart } from './MusicHeart'; import { MusicHeart } from './MusicHeart';
import { useMusicFavorites } from './useMusicFavorites'; import { useMusicFavorites } from './useMusicFavorites';
import { import {
@@ -35,8 +35,19 @@ export const FavoritesView = () => {
const albumRel = toRel(albumHome); const albumRel = toRel(albumHome);
try { try {
const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`); const meta = await get<AlbumMeta>(`/music/meta?path=${encodeURIComponent(albumRel)}`);
const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({ albumRel, file: t.file, title: t.title, artist: t.artist })); const q: PlayerTrack[] = sortTracks(meta.tracks).map((t) => ({
player.playQueue(q, Math.max(0, q.findIndex((t) => t.file === file))); albumRel,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(
q,
Math.max(
0,
q.findIndex((t) => t.file === file),
),
);
} catch { } catch {
player.playQueue([{ albumRel, file }], 0); player.playQueue([{ albumRel, file }], 0);
} }
@@ -63,7 +74,9 @@ export const FavoritesView = () => {
{empty ? ( {empty ? (
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
<Heart size={44} className="text-muted-foreground/30" /> <Heart size={44} className="text-muted-foreground/30" />
<p className="text-sm text-muted-foreground">No favorites yet. Click the heart on any artist, album or track.</p> <p className="text-sm text-muted-foreground">
No favorites yet. Click the heart on any artist, album or track.
</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
@@ -4,15 +4,16 @@ import { Link, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { usePanelChannel } from 'hooks/usePanelChannel'; import { usePanelChannel } from 'hooks/usePanelChannel';
import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react'; import { Play, Pause, ChevronLeft, MicVocal, Volume2 } from 'lucide-react';
import type { LayoutNode, PanelComponents } from '../../components/Workspace'; import type { LayoutNode, PanelComponents } from 'officerdev';
import { WorkspaceLayout } from '../../components/Workspace'; import { WorkspaceLayout } from 'officerdev';
import { MusicHeart } from './MusicHeart'; import { MusicHeart } from './MusicHeart';
import { FavoritesView } from './FavoritesView'; import { FavoritesView } from './FavoritesView';
import { useMusicPlayer } from '../../MusicPlayer'; import { useMusicPlayer } from './useMusicPlayer';
import type { PlayerTrack } from '../../MusicPlayer'; import type { PlayerTrack } from './useMusicPlayer';
import { LyricsPanel } from '../../MusicPlayer/LyricsPanel'; import { LyricsPanel } from './LyricsPanel';
import { MusicMiniBar } from '../../MusicPlayer/MusicMiniBar'; import { MusicMiniBar } from './MusicMiniBar';
import { useLyricsOpen } from '../../MusicPlayer/useLyricsOpen'; import { MusicPlayerHost } from './MusicPlayerHost';
import { useLyricsOpen } from './useLyricsOpen';
import { import {
MUSIC_ROOT, MUSIC_ROOT,
MUSIC_FAV_CHANNEL, MUSIC_FAV_CHANNEL,
@@ -414,6 +415,16 @@ export const MusicDetail = () => {
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{content} {content}
<MusicMiniBar /> <MusicMiniBar />
{/* The audio engine, mounted HERE rather than by the shell.
It moved out of DashboardLayout on 2026-08-15 when the player became the plugin's. It renders
nothing while the route is /music the mini bar above is the transport so this is purely
"something owns the GaplessEngine while the screen is open".
[phase 2] Leaving /music unmounts it, which stops playback. Making audio outlive the route
needs either a shell slot a plugin can contribute to, or the engine hoisted to module scope;
that decision is deliberately deferred. Nothing breaks in the meantime: player-time's
registrations are optional-chained and the queue lives in global state, so returning to /music
remounts the host and reloads it. */}
<MusicPlayerHost />
</div> </div>
); );
@@ -1,8 +1,8 @@
import { useRef } from 'react'; import { useRef } from 'react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { MicVocal, Pause, Play } from 'lucide-react'; import { MicVocal, Pause, Play } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar'; import { SeekBar } from 'officerdev';
import { coverUrl, fmtClock } from '../apps/Music/shared'; import { coverUrl, fmtClock } from './shared';
import { seekPlayer } from './player-time'; import { seekPlayer } from './player-time';
import { useLyricsOpen } from './useLyricsOpen'; import { useLyricsOpen } from './useLyricsOpen';
import { useMusicPlayer } from './useMusicPlayer'; import { useMusicPlayer } from './useMusicPlayer';
@@ -1,11 +1,11 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react'; import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from '../apps/FileViewer/renderers/SeekBar'; import { SeekBar } from 'officerdev';
import { MusicHeart } from '../apps/Music/MusicHeart'; import { MusicHeart } from './MusicHeart';
import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from '../apps/Music/shared'; import { fmtClock, musicPath, sortTracks, trackHomePath, type AlbumMeta, type NowPlaying } from './shared';
import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer'; import { useMusicPlayer, type PlayerTrack } from './useMusicPlayer';
import { GaplessEngine, type EngineTrack } from './gapless-engine'; import { GaplessEngine, type EngineTrack } from './gapless-engine';
import { publishPlayerTime, registerPlayerSeek } from './player-time'; import { publishPlayerTime, registerPlayerSeek } from './player-time';
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
export const MusicPlayerHost = () => { export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient(); const { token, get, put, delete: del } = useClient();
const { can } = useCapabilities(); const { can } = usePermissions();
const canUseMusic = can('music'); const canUseMusic = can('music');
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -123,7 +123,7 @@ export const MusicPlayerHost = () => {
// Restore the saved "currently playing" on first load — paused, at its position — so a reload/return // Restore the saved "currently playing" on first load — paused, at its position — so a reload/return
// lands back on the track. Skipped when a queue already exists (an in-app nav kept player state). // lands back on the track. Skipped when a queue already exists (an in-app nav kept player state).
// //
// Also skipped without the `music` capability. This host is mounted by the shell for every account, so it // Also skipped without the `music` permission. This host is mounted by the shell for every account, so it
// used to reach for `/music/now-playing` on a member's very first paint and 403. // used to reach for `/music/now-playing` on a member's very first paint and 403.
useEffect(() => { useEffect(() => {
if (restoredRef.current) return; if (restoredRef.current) return;
+18
View File
@@ -0,0 +1,18 @@
import type { AppRegistryMeta } from 'officerdev';
import { Music, ListMusic } from 'lucide-react';
import { MusicBrowser } from './MusicBrowser';
import { MusicDetail } from './MusicDetail';
// The panels this plugin contributes. The shell renders `WorkspaceView` around them, arranged by
// `layout.ts` — a plugin never renders the screen.
//
// The two do not coordinate with each other: both read `?path=` off the URL, which is why there is no
// channel between them and why a library location is linkable and cmd-clickable. `MusicDetail` opens a
// NESTED workspace of its own for the lyrics split, which is a layout inside a panel rather than a second
// screen.
//
// `availableOnPanel: false` keeps them off the generic panel picker: they belong to this plugin's screen.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'music-browser', name: 'Library', icon: ListMusic, component: MusicBrowser, availableOnPanel: false },
{ key: 'music-detail', name: 'Music', icon: Music, component: MusicDetail, availableOnPanel: false },
];
@@ -1,9 +1,12 @@
import type { WidgetRegistryMeta, PlayerTrack } from 'officerdev'; import type { WidgetRegistryMeta } from 'officerdev';
import { useMusicPlayer } from 'officerdev'; // The player is this plugin's own now, so these are siblings rather than host API. Parked: nothing
// registers this widget — plugins cannot contribute widgets, and that mechanism is not built.
import type { PlayerTrack } from '../web/useMusicPlayer';
import { useMusicPlayer } from '../web/useMusicPlayer';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Music, ChevronLeft, Play, Folder } from 'lucide-react'; import { Music, ChevronLeft, Play, Folder } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { Widget } from '../Widget'; import { Widget } from 'widgets/Widget';
// Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs); // Music Player widget — a BROWSER over the library. Top-level dirs of ~/Music are "libraries" (tabs);
// within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs). // within a library you drill through folders (Artist → Albums → …) until a folder has tracks (songs).
@@ -36,7 +39,14 @@ export const MusicPlayer = () => {
// Top-level libraries (once). // Top-level libraries (once).
useEffect(() => { useEffect(() => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`) get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(MUSIC_ROOT)}`)
.then((r) => setLibraries(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort())) .then((r) =>
setLibraries(
r.entries
.filter((e) => e.type === 'directory')
.map((e) => e.name)
.sort(),
),
)
.catch(() => setLibraries([])); .catch(() => setLibraries([]));
}, []); }, []);
@@ -50,7 +60,12 @@ export const MusicPlayer = () => {
get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`) get<LsResult>(`/file-browser/ls?path=${encodeURIComponent(cwd)}`)
.then(async (r) => { .then(async (r) => {
if (cancelled) return; if (cancelled) return;
setDirs(r.entries.filter((e) => e.type === 'directory').map((e) => e.name).sort()); setDirs(
r.entries
.filter((e) => e.type === 'directory')
.map((e) => e.name)
.sort(),
);
const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name); const audio = r.entries.filter((e) => e.type === 'file' && isAudio(e.name)).map((e) => e.name);
if (audio.length) { if (audio.length) {
const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…> const rel = cwd.slice(MUSIC_ROOT.length + 1); // <library>/<…>
@@ -92,7 +107,12 @@ export const MusicPlayer = () => {
}; };
const play = (i: number) => { const play = (i: number) => {
const queue: PlayerTrack[] = songs.map((t) => ({ albumRel: relToMusic, file: t.file, title: t.title, artist: t.artist })); const queue: PlayerTrack[] = songs.map((t) => ({
albumRel: relToMusic,
file: t.file,
title: t.title,
artist: t.artist,
}));
player.playQueue(queue, i); player.playQueue(queue, i);
}; };
const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file; const isCurrent = (file: string) => player.current?.albumRel === relToMusic && player.current?.file === file;
@@ -107,7 +127,9 @@ export const MusicPlayer = () => {
type="button" type="button"
onClick={() => selectLibrary(lib)} onClick={() => selectLibrary(lib)}
className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${ className={`shrink-0 rounded-full px-2.5 py-1 text-xs ${
library === lib ? 'bg-primary text-primary-foreground' : 'bg-muted text-muted-foreground hover:text-foreground' library === lib
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground hover:text-foreground'
}`} }`}
> >
{lib} {lib}
@@ -144,7 +166,9 @@ export const MusicPlayer = () => {
/> />
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-foreground">{breadcrumb[breadcrumb.length - 1] ?? library}</p> <p className="truncate text-sm font-semibold text-foreground">
{breadcrumb[breadcrumb.length - 1] ?? library}
</p>
<p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p> <p className="truncate text-xs text-muted-foreground">{breadcrumb[breadcrumb.length - 2] ?? ''}</p>
</div> </div>
<button <button
+733
View File
@@ -0,0 +1,733 @@
# Offscale — the first real plugin
**Status: LIVE DOCUMENT, opened 2026-08-14, offscale extracted 2026-08-15.** Decisions and findings from
the session that built the plugin system. Correct it in place; it is meant to be edited, not archived.
It lives HERE, in the plugin, rather than in the platform's `docs/`. Most of it is about the plugin
system generally rather than about offscale, and that is deliberate: this is the worked example, and the
reasoning is most useful next to the code it produced. The platform's own docs should not carry the
history of something it no longer knows exists.
Offscale is Headscale extracted into a plugin. It is the pilot: chosen because it is a genuine vertical
slice (schema + backend router + sidecar + frontend screen + permissions) without being pathological.
**The name is not a rename.** Offscale is Headscale _plus the Companion_ — an API and UI that ship beside
the Headscale server and add what Headscale itself does not do, the invite flow being the first of them.
Calling it Headscale would undersell it and calling it a fork would be wrong: the server underneath is
stock. The distinct name marks a distinct product, not a badge on someone else's.
Related, and older: `sidecar-app-store.md` is the origin design and is largely implemented despite its
"Nothing implemented" header. `sidecar-topology.md` is where the runtime shape was going.
---
## The reframe
**Core is `officer` and nothing else. Everything else is a plugin** — `officer-pty`, `officer-opencode`,
`officer-claude-code`, offscale. `officer-anthropic-proxy` is a known exception to think about later; the
intuition is that it is one plugin requiring two sidecars.
The old baseline was six PM2 processes. Headscale was removed from it on 2026-08-14 (`services.sh`,
the local ecosystem file, `catalogue.test.ts`'s `CORE[]` mirror, and PM2 itself), so the machine this was
written on runs five.
### Two words, because "core" was doing two jobs
- **baseline** — what a fresh install actually runs
- **first-party** — what Officer Dev publishes
They come apart immediately: offscale is first-party and no longer baseline. Saying "core" for both makes
"is X core?" a question with two answers.
---
## What a plugin is made of
Combined per plugin as needed. **Only `meta` and the ID are always required.**
- a **meta** object — id, name, dock item, backend/frontend mount, etc.
- an **ID** (see below)
- a **sidecar**
- a **backend router** and its routes
- a **db schema**
- **default permissions per user group**
- what it stores in the **secret store**, and whether that is per-user or plugin-global
- a **frontend router**, its routes, and the frontend code
- how it **mounts into the file browser context menu**
- a set of **permissions added to officer-items**
- **plugin settings page** definitions
- an accompanying **mobile app**
A plugin is completely self-contained. The platform's installed/enabled state decides whether its routers
mount, whether its sidecar is in the ecosystem file, and so on.
### What offscale needs
db schema · backend router + routes · frontend router + routes · sidecar.
**Not** a context menu, **not** officer-items permissions, and (probably) **not** a settings page.
---
## Identity and routing
**The app-name is the ID.** One identifier, not two — it names the plugin, prefixes its tables, and is its
route. A random ID plus a separate app-name was considered and dropped: splitting the uniqueness guarantee
across two namespaces means whichever is weaker becomes the real attack surface.
**Uniqueness comes from two mechanisms**, because one is not enough:
- **globally** — the marketplace owns the namespace for published names, with human review. A name as
generic as `notes` gets refused: it is a name Officer Dev may want later.
- **locally** — the platform refuses to install a plugin whose app-name is already taken on this machine.
Needed because a private plugin never asks the marketplace anything.
The marketplace works like the Chrome extension store. Anyone may write plugins for their own use with no
restrictions; publishing is what invites review.
### Mount prefixes
```
first-party /api/<app-name> e.g. /api/offscale
third-party /api/p/<creator>/<app-name> e.g. /api/p/alice/notes
```
`p` is a literal segment meaning "plugin". First-party plugins sit at the root because Officer Dev owns
that namespace anyway, and because provenance is then legible at a glance in a log or a route table.
**The prefix must be derived by exactly one function from the manifest.** Nothing about a first-party
plugin's code may know it is first-party. If that difference ever leaks past the one derivation — a
special case in the router, a bypassed check, a different install branch — first-party and third-party
become two systems, and only one of them gets tested.
`/p/` does **not** solve plugin-vs-plugin collisions; the marketplace and the local check do. What it
guarantees is that a plugin can never shadow a **core** route, which also means the platform can keep
adding core routes forever without breaking installs.
---
## The database
**Tables live in `public`, prefixed with the app-name** — `offscale_servers`, exactly as the codebase
already does (`headscale_servers`, `music_favorites`, `vault_tokens`). No new machinery.
### A Postgres schema per plugin was tested and rejected
Not rejected on suspicion — it was built and proven to work, then dropped as more complexity than it
earns. Recorded so nobody re-runs the experiment:
| Property | Result |
| ----------------------------------------------------------------- | ------------------------- |
| `pgSchema('offscale')` + `drizzle-kit push` creates the namespace | works |
| Cross-schema FK to `public.users` | works |
| Partial unique index preserved | works |
| Push is idempotent, no spurious re-creation | works |
| Cascade delete across the schema boundary | works |
| `DROP SCHEMA offscale CASCADE` as uninstall | works, `public` untouched |
**The finding worth keeping: `schemaFilter` is mandatory, and the docs are wrong.** Drizzle's config
documentation states that push "will by default manage all schemas". On drizzle-kit **0.31.8** that is
false. A push with the table verifiably exported reported `No changes detected` and created nothing;
naming the schema in `schemaFilter` made the identical push work.
If per-plugin schemas are ever revisited, that is the trap: **a plugin install would report success and
silently create no tables.** Same failure shape as several bugs found the same day — a refusal wearing the
costume of a normal result.
---
## Mounting — rebuild and swap, at runtime
**Runtime mounting, no restart.** This went round twice — C, then B on the belief that Hono could not
mount at runtime, then back — so the reasoning is recorded rather than the conclusion alone.
### What was actually tested
| Router | `app.route()` after serving has begun |
| -------------------------------- | --------------------------------------------------------------------- |
| `SmartRouter` _(Hono's default)_ | **throws**`Can not add a route since the matcher is already built` |
| `RegExpRouter` | **throws**, same reason |
| `TrieRouter` | works |
| `PatternRouter` | works |
So adding at runtime is possible, but only by giving up the fast matcher — and Hono has **no API to
remove a route**, which uninstall needs.
### The approach that solves both
Rebuild the whole app from the current plugin set and **reassign the variable**:
```ts
let app = buildApp(installedPlugins()); // core routes + one .route() per plugin
serve({ fetch: (req, server) => app.fetch(req, server) }); // closure, NOT app.fetch
// install: app = buildApp([...installed, 'offscale'])
// uninstall: app = buildApp(installed.filter(p => p !== 'offscale'))
```
The `fetch` closure reads `app` on every request, so reassigning it **is** the swap. Verified end to end:
```
no plugins /offscale/x -> 404 | /core -> 200
installed /offscale/x -> 200 | /core -> 200
uninstalled /offscale/x -> 404 | /core -> 200
```
Better than the TrieRouter route on both counts: the default `SmartRouter` is kept, so the fast
`RegExpRouter` path survives — and **uninstall works**, which an add-only API cannot express.
### The one line that has to change
`server.tsx:322` is `'/api/*': honoServer.fetch` — a **bound method**, evaluated once at `serve()`. It has
to become `(req, server) => honoServer.fetch(req, server)`, or reassigning the app has no effect at all.
This is the whole mechanical cost.
### Websockets are a separate table, and they reload
Six providers are declared in **Bun's route table**, not Hono's: `/api/tasks/run/ws`,
`/api/tasks/pipeline/ws`, `/api/terminal/ws`, `/api/chat/ws`, `/api/cliamp/ws`, `/api/cliamp/audio/ws`.
The Hono swap does not reach them — but `server.reload({ routes })` does, in both directions:
```
before reload /api/offscale/ws -> refused | /core -> 200
after reload /api/offscale/ws -> CONNECTED | /core -> 200
after remove /api/offscale/ws -> refused | /core -> 200
```
So **nothing needs a restart, for either table.** A plugin owning a socket is possible from the start.
`reload` wants the whole option set, so `fetch` is passed alongside `routes`.
`[open]` Whether connections already open across a `reload` survive it was not tested. Worth knowing
before a plugin install can interrupt somebody's terminal.
The two tables remain two lists, which is the same seam as the totality bug below.
### What this means for `assertPermissionTotality`
It can no longer be only a boot check, because the mount set changes after boot. The question moves to
**per rebuild**: `buildApp()` is the one place routes are mounted, so it is the one place to assert that
every mounted route has a permission — and to refuse the swap if one does not. Same invariant, asserted
where mounting actually happens instead of once at start-up.
Two things it must survive, both live today:
- The premise in `sidecar-app-store.md` that "every API route stays mounted regardless" is **retired**. An
uninstalled plugin's routes are not mounted, so nothing can reach them.
- The check is currently **fed the wrong list**`Object.keys(handlers)` from `server.tsx`, while Bun
serves the _route table_, and the two diverged when plugins were switched off. Moving the assertion into
`buildApp()` fixes this by construction for Hono routes, and leaves the websocket table as the part that
still needs pointing at reality.
---
## Permissions
A plugin declares permissions. **A plugin may declare `app`, and nothing else.**
`PermissionKind` is `core | app | confined | execution | admin`. `core` means _every account, not
deniable_, so a third-party manifest naming its own kind is a privilege-escalation surface: "malicious
plugin declares itself core" is an ungated grant to every user. `core`, `execution` and `admin` stay the
platform's to assign.
### The platform grants read or write. Everything richer is the plugin's own job
The platform's contract is exactly what it already has and no more: **a role holds `read` or `write` on a
permission**, stored in `role_permissions`, enforced by the gate. `read` permits safe methods anywhere in
the surface; `write` permits everything.
Anything beyond that — who may see whose rows, per-user isolation, ownership of individual records,
visibility rules of any kind — is **implemented inside the plugin**, by the plugin's author. It is not the
platform's responsibility and the platform should not grow machinery for it. A plugin knows what its data
means; the platform only knows whether this account got through the door.
### Offscale v1 uses that model exactly, with nothing added
One shared resource, role-gated:
- **read** — sees what the owner sees: the owner's registered servers, nodes, users, keys, policy
- **write** — can change them, including deleting a server the owner registered
The second is genuinely dangerous, and deliberately allowed. The stored credential is a Headscale **admin**
key that can delete every node on a tailnet, and there is no read-only version of it. So `write` on
offscale is close to full control of the tailnet — which is the owner's decision to make, and the expected
use is read for most roles. Say Developers get `read` and nobody gets `write`.
Two implementation consequences, both inside the plugin:
1. **The queries stop scoping by the caller.** Every one takes the caller's `userId` today —
`listHeadscaleServers(userId)`, `getActiveHeadscaleCredentials(userId)` — and the schema is per-user
because of it. Under this model a member sees the **owner's** rows, so those resolve to the owner's id
always. The per-user shape stays in the table, unused, and becomes the seam if isolation is ever wanted.
2. **Two POSTs are really reads, and must be declared `readOnlyWrites`:**
- `POST /ssh-test` — a reachability probe that mutates nothing
- `POST /policy/assist` — proposes a document and, emphatically, never saves one
Without them a read-level account cannot test a connection or draft a policy, which reads as a broken
feature rather than a withheld permission. Everything else — activate, rename, tags, routes, expire,
delete, policy `PUT` — is a genuine write.
### Music is where the richer model gets designed
Offscale is deliberately the simple case. **The next plugin extracted is most likely music, and that is
the right place to develop the in-plugin visibility system** — it has genuinely per-user data (favourites,
playlists, now-playing) sitting on top of a genuinely shared one (a single global library index, noted in
`TODO.md` as one household, one library). So "whose is this row" has a real and non-uniform answer there,
where offscale's is just "the owner's".
Not designed yet, and deliberately not designed here. Recorded so the intent survives.
### Several different things were called "capability" here
A manifest needs three names, not one:
1. `permissions/registry.ts`**permissions** (`headscale`, `vpn`)
2. `$OFFICER_ROOT/capabilities/` — the **file-based item store** (skills, tools, tasks)
3. `sidecar-registry` `handles: ['music']`**routing keys** for `sendCommand`, renamed from
`capabilities` on 2026-08-15
Offscale needs (1) and (3), and not (2).
---
## Secrets
Two stores, and a plugin author will reach for the wrong one unless told:
- **plugin-global keys** → the secret store (`officer_db/src/secret-store.ts`, real: `getKey(purpose)`,
`hasKey`, `retiredKeys`). Purpose-keyed encryption and signing keys, not arbitrary values.
- **per-user credentials**`service_connections`, which already does the hard part: the row is keyed
`(userId, service)` and **a NULL `url` means "inherit the instance"**, so a member structurally cannot
see or supply the URL. `service` is free text with no namespacing yet — that needs solving before third
parties touch it.
Offscale's own coupling is small and instructive. `headscale/queries.ts` imports exactly two things from
the host:
```ts
import { db } from '../db'; // the connection
import { encryptSecret, decryptSecret } from '../crypto'; // at-rest encryption, 10 uses
```
A plugin cannot carry its own `db` (it must share the connection to reference `users.id`) and should not
carry its own crypto (the key lives in the platform's store). **So those two are provided to a plugin
rather than imported by it.** That is the first concrete piece of the plugin↔host API, and it fell out of
the pilot rather than being invented.
---
## `/api/vpn` is being deleted
Officer had two headscale surfaces:
| | `/api/vpn` | `/api/headscale` |
| ---------- | ---------------------------------------- | -------------------------------------- |
| permission | `vpn`, kind `app` — grantable to members | `headscale`, kind `admin` — owner only |
| purpose | enrol your own device | the tailnet: machines, routes, ACLs |
| surface | one route, `POST /enroll` | the whole admin API |
`POST /api/vpn/enroll` was one-tap enrollment for a phone already signed into Officer. **It has no caller
anywhere.** Verified against the mobile monorepo:
1. `enrollVpn()` has one call site, `useVpnScreen.ts:617`, inside `enroll()`
2. `enroll()` is reached only via `if (embedded) await enroll()`
3. `embedded` is optional and defaults to `false`
4. `VpnScreen` is rendered in exactly one place — `apps/offscale/src/App.tsx` — which never passes it
`apps/mobile` and `apps/headscale` have zero references to `enrollVpn`, `VpnScreen` or `api/vpn`. Neither
does the Officer web app. The live database holds no `vpn` grants.
**And it will never come back.** Offscale is permanently standalone: no login, no backend calls, no
dependency on Officer or the platform. The reasoning is the app's own and it is sound — _the thing that
gets you to the platform cannot itself need the platform_, or a broken tailnet locks you out of both.
### Everything collapses to one namespace
`/api/offscale/*`. The comment in `vpn/router.ts` claiming "the path is a contract" no longer binds: the
contract has no counterparty.
**The invite flow stays and does not need the mobile app changed.** `claimInvite` calls
`${invite.base}/api/v1/enroll/claim` — the **Companion** on the server, at a base URL carried in the
invite link. `/api/v1/` is Headscale's own namespace. The phone never talks to Officer for invites.
- **phone → Companion** — untouched by anything here
- **web admin → Officer → sidecar** — ours to rename freely
### There are THREE components, not two
Easy to miss, and worth stating because two of them contain the word "enroll":
| Component | Repo | Enrolment surface |
| ---------------- | ---------------------------- | ------------------------------------------------ |
| Officer platform | `officerdev/platform` | `/api/offscale/*` — web admin only |
| Mobile suite | `officerdev/monorepo-mobile` | calls the Companion, never Officer |
| **Companion** | `officerdev/offscale-server` | `/api/v1/enroll/*` under basePath `/officer-api` |
The Companion ships beside each Headscale server. Confirmed against its source on 2026-08-14: zero
references to `/api/vpn/*`, and its only outbound calls are the docker socket and its sibling headscale's
`/health`. It never calls Officer and does not use `/api/offscale/*` either.
**`/api/v1/enroll/*` is the Companion's and is not ours to collapse.** The phone claims at
`${invite.base}/api/v1/enroll/claim`, where `invite.base` is the `sidecarOrigin` the Companion itself put
in the invite (`https://<domain>/officer-api`).
**Trap when deleting:** do not delete the sidecar's `enroll.ts`. Line 71 dispatches
`/enroll/invites` to `handleInvitesRoute`, so it is the invite flow's entry point. Only the bare
`POST /_officer/enroll` handler below it is dead.
**A public route is possible if ever needed.** `/api/vault` is already exempt from platform auth
(`EXEMPT_API_PREFIXES`) because Bitwarden clients carry a Vaultwarden bearer rather than a platform JWT.
The exemption must be declared with a reason or the boot check refuses. Not needed today.
**Not an open question — decided.** Removing `vpn` leaves no member-grantable headscale surface, and that
is correct. The invite flow supersedes it completely:
1. the Officer headscale app holds an admin API key for the Headscale server
2. from it the owner mints an **invite** — a URL pointing at the Companion
3. the Companion turns that into the redirect the phone app claims
4. the device joins
That path needs no per-member permission on Officer at all, and it is the one that exists and works.
`/api/vpn/enroll` was the design it replaced, not a permission still waiting for a UI — there never was
one. Do not reintroduce a member-facing enrolment route on the assumption something is missing.
---
## What headscale actually is — the inventory
Read end to end on 2026-08-14. This is what has to move.
### Backend — 2,406 lines
`/api/headscale` is **18 lines**: a pure `createSidecarProxy`, no Headscale knowledge, "must never grow app
logic". Everything is in the sidecar under `/_officer/*`, dispatched by `routes.ts` to eight handlers —
`servers · nodes · users · keys · policy · enroll · ssh-test · companion`.
Three things worth knowing before touching it:
- **Every domain route acts on the _active_ server**, stored in Postgres behind a partial unique index and
never passed as a parameter — so no client can act on a server the owner is not currently looking at.
- **`client.ts` is a quirk-absorption layer, and that is the good part.** The quirks are Headscale's:
uint64 ids arrive as JSON _strings_ (never round-trip through `Number` — it breaks above 2^53), 401/403
bodies are plain text while every other error is JSON, and the gateway uses `DiscardUnknown` so a
misspelled request field makes the call **succeed and do nothing** — which is why mutations read the
object back. One file containing all of it is the model for a plugin's client layer, not something to
undo.
- **The Companion is optional per server** and answers `{available:false, reason}` at HTTP 200. The trick
is distinguishing nginx's HTML 502 (no companion) from the companion's JSON 502 (docker op failed): it
branches on whether the body parses.
Host dependencies: `officerdb` (db + crypto), `DATA_PATH`, `officer-url.mjs`, `createSidecarConnector`,
`createSidecarProxy`, the anthropic proxy's state file, and the `ssh` binary.
### Frontend — 29 files, 27 endpoints
Three registered panels (`headscale-servers`, `headscale-nav`, `headscale-view`, all
`availableOnPanel: false`) inside a locked `WorkspaceView`, with `headscale-view` dispatching on
`useHeadscaleSection()` to eight section views: Servers · Nodes · Users · Keys · Invites · Policy ·
Diagnostics · Console.
It **follows the navigation conventions** — no `usePanelChannel` anywhere, no opaque clicks, the section
lives in `:section` and nowhere else. The one exception is documented and correct: choosing the active
server is a DB write that re-scopes every query, so it stays a button rather than a URL.
The whole frontend↔host coupling, which becomes the plugin API:
| Import | Why it matters |
| --------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `hooks/useClient``useClient`, `getHeaders` | both, not just the client — `useCompanionLogStream` needs raw headers because `EventSource` cannot send `Authorization` |
| `helpers/clipboard``copyToClipboard` | carries the non-secure-context fallback; re-implementing it would silently regress |
| `AppRegistryMeta` | the panel-contribution contract |
| `officerdev``WorkspaceView`, `LayoutNode` | needs `appTypes: {allowed, fallback}` and `locked` |
| `state/useDashboardState` | per-user layout, backed by `/api/dashboards`, a `core` permission — stays host-provided |
| `../Terminal/Terminal``TerminalView` | **the awkward one** — a code dependency on another panel app |
### `assist.ts` travels, but stays unwired
The ACL-drafting assistant was written and never tested. **Carry it into the plugin, do not delete it, and
do not wire it up** — it is there as a marker that the idea exists, to be finished or removed deliberately
later. Do not tidy it away as unused code.
---
## The manifest — proposal
Written against offscale rather than invented in the abstract, on the principle that a field list designed
from nothing includes what nothing needs and misses what is awkward. The field set grows per plugin; this
is the floor, not the ceiling.
```ts
// plugins/offscale/manifest.ts
export const manifest = {
/** Constant today. The one input to `mountPrefix()`, and the seam third parties hang off later. */
publisher: 'officerdev',
/** The plugin's own semver. Updates compare against this. */
version: '1.0.0',
/** Which platforms this build is good for. Refused at install when it does not match. */
platform: '>=1.0.0 <2.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys and access policy',
icon: 'Network',
color: '#818cf8',
// Named `permissions`, NOT `capabilities`. That word meant several different things here — the
// permission registry, the officer-items store, and the sidecar's routing keys — and a fourth would be
// one too many. `permissions` is accurate and free: the old table of that name went in 044aacf4.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes and ACLs',
/** Owner-only, or grantable to members. The whole distinction a plugin needs. */
ownerOnly: true,
},
],
} as const;
```
### THE RULE: every plugin route renders a Workspace with at least one panel
Exclusionary, and enforced by shape rather than by review. A plugin **does not render a screen.** It
contributes panels and says how they are arranged; the shell renders `WorkspaceView` around them.
```
web/panels.ts exports appRegistryMetas — at least one panel
web/layout.ts exports defaultLayout — how they are arranged
```
Both are required the moment `web/` exists. Missing either and the plugin is **refused at discovery**, by
name and with the reason:
```
probeplug: has a web/ directory but is missing web/layout.ts.
Every plugin route renders a Workspace: contribute panels and a layout, not a screen.
```
There is deliberately no way to export a component. A plugin that could would be free to render a bare
div, a full-page form, its own navigation — and the platform would become a shell hosting strangers'
layouts rather than one application. Non-compliance is not refused so much as **unrepresentable**: there
is nowhere to put a screen.
The shell registers the pair `<prefix>` and `<prefix>/:section`, exactly as the core screens do
(`/headscale/:section`), so a plugin's sections stay addressable, linkable and cmd-clickable. Panels read
`useParams` independently — nothing is passed between them, so they cannot disagree. `appTypes.allowed`
is pinned to that plugin's own panel keys, so a persisted layout naming something else falls back rather
than rendering another plugin's panel inside this one.
### Everything the tree can say, the tree says
The manifest holds only what a directory listing genuinely cannot tell you: an identity fact, or something
a human chose. Everything structural is convention, and **presence is the declaration**:
| Path | Means |
| -------------------- | --------------------------------------------------------------------------------------------- |
| _the directory name_ | `appName``plugins/offscale/` **is** the id, so it cannot disagree with where the code sits |
| `sidecar/index.ts` | there is a sidecar; PM2 gets an entry. `.mjs` instead means node — see below |
| `api/router.ts` | there is a backend router, mounted at `mountPrefix(manifest)` |
| `db/schema.ts` | there are tables; pushed on install, every name prefixed `offscale_` |
| `web/Router.tsx` | there is a frontend; its default export mounts at `<prefix>/*` |
| `web/panels.ts` | it contributes panels; exports `appRegistryMetas` |
The dock tile and the page title need no fields either — the tile is `{ label, icon, color, to:
mountPrefix(manifest) }` and the title is `label`, all of which are already above. Writing them again was
duplication that could only ever drift.
**The runtime is the file extension.** `sidecar/index.mjs` runs under node, `sidecar/index.ts` under bun.
Implicit, but it is the rule this repo already follows — `officer-pty` is `pty/index.mjs` under node
because node-pty is a native module built against Node's ABI, and everything else is bun. Better than a
field that can contradict the file it describes.
### Install asks nothing, and that is the default
Offscale needs **none** of the install fields the current app-store catalogue carries — no `modes`, no
`existingFields`, no `configFields`, no `composeTemplate`, no `members`. There is no Docker to provision
and no external service to point at.
Its install is the whole of it: put the code there, push the schema, start the sidecar, swap the routes.
Available immediately. Everything else is configuration the user does **afterwards, inside the app** — a
Headscale server is registered at `/offscale/servers` and lands in `offscale_servers`, which is already
how it works today.
So the rule is **a plugin installs with no questions unless it says otherwise**, and the prompting
machinery (the three install shapes in `sidecar-app-store.md`) gets designed against the first extracted
plugin that actually needs Docker or a remote instance. That was part of why offscale is the right pilot:
it exercises the mounting, the schema and the sidecar without the install flow being a variable too.
### Dropped from the first draft
- **`dependsOn`** — nothing read it and nothing enforced it. Both of offscale's dependencies already
explain themselves where it matters (`assistant_unavailable`; "no SSH host configured"). A field whose
only job is to be displayed, that nothing displays, is stale the first time anyone looks at it. Add it
when something consumes it.
- **`kind`** — see below.
- **`sidecar` / `schema` / `frontend` objects** — all convention now.
`[open]` A plugin with a frontend that should NOT get a dock tile has no way to say so: `web/` present
means a tile. Fine for offscale; add a flag the first time something needs it.
### `admin` has to be allowed, and the pilot proved it immediately
The earlier rule here was "a plugin may declare `app`, and nothing else". **That is wrong, and offscale is
the counterexample**: its permission is `kind: 'admin'` — owner-only — and it should stay that way.
The distinction is direction. `core` means _every account, undeniable_, so a plugin claiming it grants
itself to everyone: escalation. `admin` means _owner only_, which is a plugin **restricting** itself, and
nothing is gained by forbidding it.
Corrected rule:
| Kind | May a plugin declare it? | Why |
| ----------- | ------------------------ | ---------------------------------------------------------- |
| `app` | yes | the ordinary grantable surface |
| `admin` | yes | self-restriction, never an escalation |
| `core` | **no** | every account, not deniable — an ungated grant to everyone |
| `execution` | **no** | runs as the owner's OS user; the platform's to assign |
| `confined` | **no** | implies a Linux identity the platform provisions |
### One function decides the prefix
`publisher` is the only input, so first-party and third-party cannot become two code paths:
```ts
const mountPrefix = (m: Manifest) =>
m.publisher === 'officerdev' ? `/${m.appName}` : `/p/${m.publisher}/${m.appName}`;
```
Used for both `/api/...` and the frontend route. Nothing else in the codebase may branch on provenance.
### Notes on the fields
- **`sidecar.runtime`** exists because `officer-pty` runs under node for node-pty's native ABI while
everything else is bun. One plugin already needs it, so it is not speculative generality.
- **`platform`** is the compat range, and it presumes the platform gains a version. It has none today;
1.0 is expected before anyone outside Officer Dev writes a plugin.
- **`dependsOn`** is deliberately not enforced. Code dependencies need no declaration — a plugin builds
inside the workspace, so `import { TerminalView }` simply resolves — and service dependencies already
degrade. This is for the human reading the store.
- **No `health`.** Deferred; process-online is what the store knows and that is enough for now.
- **No `migrations`.** Deferred; a field can be added without redesign.
- **No permission list.** A plugin calls the API with the user's token and the user's permissions.
---
## What is built — complete, as of 2026-08-15
**Offscale is a plugin, and nothing in the system is a stub.** Validated by the owner against the live
server across repeated install / enable / disable / uninstall cycles, checking PM2 and the frontend each
time.
| Piece | Where |
| --------------------------------------- | -------------------------------------------------- |
| Manifest, `mountPrefix`, validation | `servers/plugins/manifest.ts` |
| Discovery by convention | `servers/plugins/discover.ts` |
| Disk ⋈ database, mounts, dock manifests | `servers/plugins/mount.ts` |
| Install runner, four verbs, streamed | `servers/plugins/install.ts` |
| PM2 ecosystem entry | `servers/plugins/ecosystem.ts` |
| Schema barrel + `db:push` | `servers/plugins/schema.ts` |
| `Plugins.gen.tsx` + `Bun.build` | `servers/plugins/generate.ts` |
| `buildHonoApp` / `rebuildHonoApp` | `servers/hono.ts` |
| Permission registration | `permissions/registry.ts``setPluginPermissions` |
| Install state | `plugin_installs` |
| The screen | `/plugins`, two panels, SSE log |
| The reference plugin | `plugins/example/` |
| **The first real plugin** | `plugins/offscale/` — 45 files |
Nothing needs a restart. Routes swap by rebuilding the Hono app, the sidecar gets a PM2 entry, the
frontend is regenerated and rebuilt in ~3s, permissions are registered before routes mount, and the
whole thing survives a restart because boot regenerates and mounts before `serve()`.
### Three bugs the extraction found
Worth recording because none were visible from reading:
1. **Install started the sidecar before mounting.** `createSidecarProxy` learns its port from a one-shot
`<name>:server` event and subscribes when the plugin's router is first imported — at mount. So the
announcement fired into a void: process online, routes mounted, every request `503 sidecar not
available`. It would have hit every plugin with an HTTP sidecar; `example` never caught it because it
has no listener to announce. Install and enable now mount first.
2. **The built SPA had no Tailwind.** `bunfig.toml` declares the plugin under `[serve.static]`, which
applies to Bun's static serving and not to a programmatic `Bun.build()`.
3. **The build could destroy itself.** Clearing `build/` before building meant a failed build left
nothing, and two overlapping builds could delete each other's shell. It now stages and swaps.
### Still open
- **Websocket providers.** `server.reload({ routes })` is proven but not called; Bun's route table is
still the hardcoded providers. No plugin owns a socket yet.
- **Totality across plugin routes.** `PROTECTED_API_PREFIXES` is still the core list, and the check reads
`Object.keys(handlers)` while Bun serves the route table. The assertion wants moving into
`buildHonoApp`, which is now the single place routes are mounted.
- **Two dock sources.** The app store keeps its own catalogue, so tiles come from there and from the
plugin system. One when the app store is rebuilt on this.
- **Members.** Offscale is `ownerOnly` — read/write for members needs its queries resolving to the
OWNER's rows rather than the caller's, which is a change inside the plugin.
---
## The state of the app store, as found
It **is** the plugin system, roughly 90% built, with one structural hole.
`ecosystem.config.cjs` is generated once at setup and **nothing appends to it on install**, so the
installer's final step runs `pm2 start ecosystem.config.cjs --only officer-jellyfin`, matches no app, and
silently does nothing. Acknowledged in `app-store/pm2.ts:23-29`:
> _"Installing a plugin has to append its entry here before starting it — that is the plugin system's job
> and it is not built."_
Net: **nothing in the catalogue installs end-to-end today.** Containers come up, `service_connections` is
written, assets publish, the dock tile appears — and the sidecar never starts.
Also found:
- The `schema` install step is a **logged no-op** (`effects.ts:117-124`). Every table still ships via
`bun db:push`.
- Of 8 entries declaring a compose template, **only 2 exist on disk** (`transmission`, `vaultwarden`).
`slskd` has an icon and nothing else. `catalogue.test.ts` asserts a template _name_ is declared but never
that the directory exists.
- `hono.ts` has **28 routers mounted and 15 commented out**; `officer_db/src/schema.ts` has **11 commented
schema exports** under "uncomment when the plugin is installed". Today, installing a plugin literally
means editing two files and rebuilding.
- `catalogue.test.ts` asserts every entry's process has a matching `src/servers/sidecar/<dir>`. A plugin in
its own repository has no such directory, so that test inverts — as `sidecar-app-store.md` predicted.
- A **dead, unrelated** plugin system still exists: `GET /server-settings/plugins` scans
`src/workspaces/plugins/`, which does not exist, so it always returns `[]`. `PluginsSection.tsx` still
renders against it. Not to be confused with any of the above.
---
## Where the code lives
`plugins/offscale` on `gitea.officer.dev` — private, default branch `main`, topic `officer-plugin`.
The `plugins` org exists because Gitea has **no nested organizations** (verified: no `parent` field on the
org object), so `<owner>/<repo>` is the only real namespace it has. Topics work and are searchable, and are
used in addition rather than instead — they span orgs, which matters because browser extensions under
`extensions/` may become plugins later.
---
## Open questions
1. ~~**Frontend code is the hard one.**~~ **Answered** — see "How the frontend ships". Build to `build/`,
rebuild on install, one generated `Plugins.tsx`, same origin. No federation, no import maps, no iframe:
everything compiles together and a plugin changes what "everything" is. The developer builds inside a
platform checkout, so dev-time and build-time are the same mechanism.
2. **Migrations and versioning.** A plugin needs a version and a platform-compatibility range, and
something has to apply schema changes over time. Cheap now, miserable to retrofit.
3. ~~**Health, distinct from enabled.**~~ **Deferred, deliberately.** A sidecar can be online while the
thing it exists to talk to is unreachable — offscale's own `/servers/:id/health` is exactly that
question. But process-online covers the common failure, every plugin that needs more surfaces it in its
own UI, and this is a manifest field that can be added later without redesign. Revisit in a distant
future, not before.
4. ~~**No inter-plugin dependencies.**~~ **Overtaken by evidence.** That measurement was of _schemas_ and is
still true there; at runtime the pilot has two — `assist` → anthropic-proxy (service) and `ConsoleView`
`TerminalView` (code). The rule became "may depend, must degrade" — see "Dependencies between
plugins". What is still open is the **code** kind: either `TerminalView` becomes host API, or the
Console section does not travel with the plugin.
5. **`service_connections.service` namespacing** before third parties touch it.
6. **`officer-anthropic-proxy`** — one plugin, two sidecars.
7. **Gitea is installed but invisible.** Containers `gitea` and `gitea-postgres` run, `officer-gitea` is
not in PM2, and there is no `sidecar_installs` row — it predates the store. "Already there, but not by
us" needs an answer, and the store deliberately refuses to adopt directories it did not create.
@@ -1,4 +1,4 @@
import { createSidecarProxy } from '../../sidecar/create-proxy'; import { createSidecarProxy } from '@@/sidecar/create-proxy';
// /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge: // /api/headscale/* — auth, then forward to officer-headscale. No routes of its own and no headscale knowledge:
// this file must never grow app logic. // this file must never grow app logic.
@@ -9,10 +9,10 @@ import { createSidecarProxy } from '../../sidecar/create-proxy';
const proxy = createSidecarProxy({ const proxy = createSidecarProxy({
name: 'headscale', name: 'headscale',
prefix: '/api/headscale', prefix: '/api/offscale',
}); });
export const headscaleRouter = proxy.router; export const router = proxy.router;
/** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */ /** Base URL of the sidecar's HTTP server, or null if it hasn't reported in yet. */
export const getHeadscaleServerUrl = proxy.getHttpUrl; export const getHeadscaleServerUrl = proxy.getHttpUrl;
@@ -1,7 +1,7 @@
import { eq, and, desc } from 'drizzle-orm'; import { eq, and, desc } from 'drizzle-orm';
import { db } from '../db'; import { db } from 'officerdb/db';
import { headscaleServers } from './schema'; import { headscaleServers } from './schema';
import { encryptSecret, decryptSecret } from '../crypto'; import { encryptSecret, decryptSecret } from 'officerdb/crypto';
// Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT — // Headscale server registry access for the officer-headscale sidecar. Callers deal in PLAINTEXT —
// encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto. // encryption to/from at-rest ciphertext happens here, so the sidecar's route handlers never touch crypto.
@@ -1,6 +1,6 @@
import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core'; import { pgTable, serial, integer, text, boolean, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { users } from '../auth/schema'; import { users } from 'officerdb/auth/schema';
// The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single // The Headscale servers the owner manages, for the officer-headscale sidecar. Officer targets no single
// Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and // Headscale: the owner registers one or more servers (URL + an admin API key generated on that server) and
+47
View File
@@ -0,0 +1,47 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Offscale — Headscale, plus the Companion that ships beside it.
//
// Not a rename of Headscale and not a fork: the server underneath is stock, and the Companion adds what
// Headscale itself does not do — the invite flow being the first of them. The distinct name marks a
// distinct product rather than a badge on someone else's.
//
// The first real plugin, extracted from the platform on 2026-08-15. Everything it needs is here:
//
// api/router.ts a thin auth-gated proxy — no Headscale knowledge, and it must never grow any
// sidecar/ the whole Headscale contract, holding the admin API keys
// db/ offscale_servers, and the only table this plugin owns
// web/ panels and a layout; the shell renders the Workspace
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '1.0.0',
platform: '>=1.0.0',
label: 'Offscale',
summary: 'Your tailnet — machines, users, pre-auth keys, access policy and device invites',
icon: 'Network',
color: '#818cf8',
// One permission gating the whole surface, grantable per role at read or write like every other.
//
// `[open]` What a member's grant MEANS here is this plugin's own job and is not finished. The queries
// still scope by the caller (`listHeadscaleServers(userId)`), so a granted member would see their own
// empty server list rather than the owner's, and could register a Headscale of their own. The model in
// ./PLUGIN.md is one shared resource: read sees what the owner sees, write can change it.
// That is a change inside these queries, not a flag on the manifest.
//
// Worth knowing while it is unfinished: the stored credential is a Headscale ADMIN api key that can
// delete every node on a tailnet, and there is no read-only version of it — so `write` here is close to
// full control of the tailnet, which is the owner's decision to make deliberately.
permissions: [
{
key: 'offscale',
label: 'Offscale',
description: 'The tailnet: machines, routes, keys and ACLs',
// Two POSTs that are really reads — a reachability probe and a policy DRAFT that never saves.
// Without declaring them a read-level account meets a broken feature where a withheld permission
// should be. Inert while ownerOnly, and correct the moment that changes.
readOnlyWrites: ['/ssh-test', '/policy/assist'],
},
],
};
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials } from 'officerdb'; import { getActiveHeadscaleCredentials } from '../db/queries';
import { createClient, type HeadscaleClient } from './client'; import { createClient, type HeadscaleClient } from './client';
// Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That // Every domain route acts on the ACTIVE server — the one the owner selected in the servers section. That
@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { DATA_PATH } from '../../data-path'; import { DATA_PATH } from '@@/data-path';
import { ANTHROPIC_PROXY_URL } from '../../officer-url.mjs'; import { ANTHROPIC_PROXY_URL } from '@@/officer-url.mjs';
// One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent. // One-shot model calls, for sidecar features that need a sentence of reasoning rather than an agent.
// //
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb'; import type { HeadscaleServerCredentials } from '../db/queries';
// The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the // The Headscale admin API call layer. Every upstream request in this sidecar goes through here, so the
// wire-level quirks are handled once: // wire-level quirks are handled once:
@@ -1,4 +1,4 @@
import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from 'officerdb'; import { getActiveHeadscaleCredentials, type HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes'; import { badRequest, methodNotAllowed, notFound, type OfficerContext } from './routes';
// The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the // The Officer Companion API — a small service deployed NEXT TO each Headscale server that answers what the
@@ -1,6 +1,6 @@
import type { OfficerContext } from './routes'; import type { OfficerContext } from './routes';
import type { OfficerUser } from './normalize'; import type { OfficerUser } from './normalize';
import { getActiveHeadscaleCredentials } from 'officerdb'; import { getActiveHeadscaleCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, readJson } from './routes'; import { badRequest, methodNotAllowed, readJson } from './routes';
import { createClient, type HeadscaleClient } from './client'; import { createClient, type HeadscaleClient } from './client';
import { arrayField, toUser } from './normalize'; import { arrayField, toUser } from './normalize';
@@ -9,7 +9,8 @@ import { handleInvitesRoute } from './invites';
// Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated // Device enrolment — POST /_officer/enroll. The mobile app's one-tap join: it turns an authenticated
// Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand. // Officer session into a short-lived, single-use pre-auth key, so nobody pastes a key by hand.
// //
// THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` read HEADSCALE_URL, HEADSCALE_API_KEY // THIS USED TO LIVE IN THE PLATFORM. `src/servers/api/vpn/router.ts` (deleted 2026-08-14) read
// HEADSCALE_URL, HEADSCALE_API_KEY
// and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server, // and HEADSCALE_USER straight from the host env — three globals that could only ever describe ONE server,
// while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some // while this sidecar already kept a registry of many. Worse, the two credential vars were removed at some
// point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks // point and nobody noticed: the route had been answering 503 to every enrolment attempt, because it checks
@@ -1,8 +1,8 @@
import type { SidecarCommand, SidecarEvent } from '../protocol'; import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol';
import { createSidecarConnector } from '../connect'; import { createSidecarConnector } from '@@/sidecar/connect';
import { handleOfficerRoute } from './routes'; import { handleOfficerRoute } from './routes';
import { MIN_VERSION_LABEL } from './version'; import { MIN_VERSION_LABEL } from './version';
import { API_URL } from '../../officer-url.mjs'; import { API_URL } from '@@/officer-url.mjs';
// The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and // The officer-headscale sidecar. Owns the whole Headscale contract for Officer: the registered servers and
// their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform // their admin API keys, the >=0.29 version floor, and every multi-call composition the UI needs. The platform
@@ -47,7 +47,11 @@ import { API_URL } from '../../officer-url.mjs';
// DELETE /_officer/keys/:id delete outright // DELETE /_officer/keys/:id delete outright
// POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key // POST /_officer/enroll {userId?} → {controlUrl, authKey} — a single-use 10-minute key
// for a joining device. userId is only required when the server // for a joining device. userId is only required when the server
// has more than one user; reached via /api/vpn/enroll. // has more than one user.
// NO CALLER since 2026-08-14: its only door was /api/vpn/enroll,
// which is deleted. Kept because it is the handler a route under
// /api/offscale would reuse, and because `/enroll/invites` — which
// IS live — dispatches through the same function.
// anything else 404 // anything else 404
// //
// There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly // There is deliberately NO transparent /api/v1/* passthrough. Headscale's REST shape changed repeatedly
@@ -55,7 +59,6 @@ import { API_URL } from '../../officer-url.mjs';
// — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here. // — the mistake the Soulseek panels made with 37 raw upstream calls. Every quirk is absorbed here.
// ───────────────────────────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────────────────────────
/** Grab an ephemeral free port by briefly binding one and releasing it. */ /** Grab an ephemeral free port by briefly binding one and releasing it. */
function getFreePort(): number { function getFreePort(): number {
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
@@ -118,7 +121,7 @@ function handleCommand(cmd: SidecarCommand, reply: ReplyFn) {
const connection = createSidecarConnector({ const connection = createSidecarConnector({
apiUrl: `${API_URL}/api/sidecar/register`, apiUrl: `${API_URL}/api/sidecar/register`,
name: 'headscale', name: 'headscale',
capabilities: ['headscale'], handles: ['headscale'],
onCommand(cmd, reply) { onCommand(cmd, reply) {
handleCommand(cmd as SidecarCommand, reply as ReplyFn); handleCommand(cmd as SidecarCommand, reply as ReplyFn);
}, },
@@ -1,4 +1,4 @@
import type { HeadscaleServerCredentials } from 'officerdb'; import type { HeadscaleServerCredentials } from '../db/queries';
import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes'; import { badRequest, methodNotAllowed, notFound, readJson, type OfficerContext } from './routes';
import { activeCreds, callCompanion, readBody, unavailable } from './companion'; import { activeCreds, callCompanion, readBody, unavailable } from './companion';
@@ -7,7 +7,7 @@ import {
deleteHeadscaleServer, deleteHeadscaleServer,
getHeadscaleCredentials, getHeadscaleCredentials,
recordHeadscaleProbe, recordHeadscaleProbe,
} from 'officerdb'; } from '../db/queries';
import { createClient, HeadscaleError } from './client'; import { createClient, HeadscaleError } from './client';
import { probeVersion, MIN_VERSION_LABEL } from './version'; import { probeVersion, MIN_VERSION_LABEL } from './version';
import { badRequest, notFound, methodNotAllowed } from './routes'; import { badRequest, notFound, methodNotAllowed } from './routes';

Some files were not shown because too many files have changed in this diff Show More