The platform hosts the agent and never introduces itself. An agent gets a shell, a filesystem and a prompt; everything about the CLIENT on the other end has to be guessed. It writes , nothing renders, and nothing says why — because an <img> carries no bearer token and it is the user's DEVICE that fetches the URL. servers/affordances/load.ts reads capabilities/affordances/ and compiles one block; claude-manager passes it as systemPrompt.append with the claude_code preset, so the harness keeps its own prompt. Passed there rather than written to a file because it is the one place every session goes through, owner and member alike. A generated CLAUDE.md cannot do that — user-instance.ts deliberately refuses to write one for the owner, since it would pollute the personal ~/.claude/CLAUDE.md they maintain themselves. Read per session, not cached: capabilities/ is already read fresh each session, so a plugin installed an hour ago is live on the next message. Synchronous because createSession is not async, and making it so to read a few kilobytes would ripple through every caller. No filtering. Everything is loaded into every conversation, because the axis to filter on is not one dimension — for image rendering it is what the CLIENT renders, for gitea what TOOLING exists on the machine. Fine at two files, not at twenty; TODO.md carries the argument. Opencode gets nothing, and that is recorded as a gap rather than hidden: an affordance that holds on one harness is the drift this removes. Deliberate — opencode is paused pending v2 and the work would be thrown away. Nine tests, aimed at the failure that matters: a file that silently does not reach the agent looks exactly like one nobody wrote. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
613 lines
43 KiB
Markdown
613 lines
43 KiB
Markdown
# TODO
|
|
|
|
Deferred work.
|
|
|
|
**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
|
|
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
|
|
indirection as accidental complexity. **That direction was reversed.** The permission permission model
|
|
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
|
|
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
|
|
below that proposed deleting it have been removed rather than left to mislead the next reader.
|
|
|
|
What did NOT reverse: `execution` permissions (terminal, chat, tasks, files, desktop, browser) run as
|
|
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
|
|
|
## Multi-user
|
|
|
|
- [ ] **`deprovisionOsAccount` is written but has never run against a real account.** Landed 2026-08-12 in
|
|
`os-user-deprovision.ts` and wired into `deleteUserHandler`, which now refuses to delete the row when
|
|
the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards
|
|
(`guardDeletable`, `guardMemberTree`, `parseSubUidEntry`) have tests; the reap loop, the `chown -R`
|
|
sever and `userdel` have been exercised by nobody. `docs/deprovision-os-account.md` → "What is still
|
|
unproven" has the five-step validation, and it has to happen on the production host with a throwaway
|
|
account that has **a shell left open** and **a container writing as a non-root user** — those are the
|
|
two cases the quiet path passes vacuously.
|
|
|
|
- [ ] **The terminal replays terminal QUERIES, which get typed into the shell.** `sidecar/pty/sessions.mjs`
|
|
replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers,
|
|
and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in
|
|
`appendBuffer`, so a replay reproduces output and never re-issues requests.
|
|
|
|
- [ ] **The web terminal renders a long URL as unreadable fragments.** Claude Code's first-run login prints
|
|
a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing
|
|
selectable. Half worked around by `2a8f004` (OSC 52, so "press c to copy" reaches the clipboard) — the
|
|
rendering itself is undiagnosed. This is every new member's first five minutes.
|
|
`docs/open-threads-after-per-user-claude.md` §1 has what is known and where to start.
|
|
|
|
- [ ] **Agent sessions are not durable, and it is one property behind three symptoms.** A sidecar restart
|
|
loses session identity, which is why `endTurnIfAgentIsGone` must skip sessions with no recorded
|
|
`userId`, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process
|
|
is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one
|
|
missing property. `docs/open-threads-after-per-user-claude.md` §2.
|
|
|
|
- [ ] **`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained.** A
|
|
floating rejection inside the SDK's own input pump, with no frames from our code, so no `await` of ours
|
|
can catch it. It crashed `officer-agent` four times on 2026-08-11, once truncating a turn mid-sentence;
|
|
the `unhandledRejection` backstop has caught it once since. Best hypothesis is the `claude` CLI exiting
|
|
while `streamInput` is still pumping. It needs looking at after the next occurrence, not catching in the
|
|
act — markers to grep in `docs/open-threads-after-per-user-claude.md` §3.
|
|
|
|
- [x] **No way to create a second account.** Fixed 2026-08-11 on `sidecar-app-store`: `POST /api/users`
|
|
(`api/users/create-user.ts`, owner-gated) plus an Add-account form in
|
|
Settings → User management. Created accounts are `status: 'Active'` — the column defaults to
|
|
`'Unverified'` and `signin.ts` refuses anything else with a bare UNAUTHORIZED, which is the trap
|
|
the hand-INSERT route fell into. The owner sets the password and reads it out; `passwordChangedAt`
|
|
stays null. Directories come from the shared `provisionUserDirs`/`USER_DIRS` in `data-path.ts`,
|
|
which `scripts/provision-user-dirs.ts` now imports rather than restating.
|
|
|
|
- [ ] **Still no invite flow, and no password reset for a member.** The owner types the password and
|
|
tells the person, which means the owner knows it and the member cannot change it back if they
|
|
forget theirs — recovery today is delete-and-recreate. An invite (token, expiry, member sets
|
|
their own) needs a mail path. This is the next piece, not a nice-to-have.
|
|
|
|
- [x] **A second Super Admin was storable, and made the owner nondeterministic.** Fixed 2026-08-11.
|
|
`ck_users_owner_is_super_admin` pins user 1's role but a row-level CHECK cannot see other rows, and
|
|
`updateUserRoleHandler` happily promoted anyone — while `getOwnerUser()` was
|
|
`WHERE role='Super Admin' LIMIT 1` with no ORDER BY. Two holders would have made "who owns this
|
|
server" a question the query plan answered, and that answer feeds the agent sidecar's identity,
|
|
vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers
|
|
`assignableRoles` without it, and `getOwnerUser()` orders by id.
|
|
|
|
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
|
|
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
|
|
the table is a composite PK `(user_id, id)` — it matches the `uq_dashboards_user_id` index
|
|
already there and keeps every stored `ws-layout-<id>` address valid, which uuid ids would not.
|
|
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
|
|
`docs/workspace-panel-todo.md` §3.
|
|
|
|
- [ ] **`permissions/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
|
|
registry functions and the totality check; the resolver that does the owner bypass, the grant
|
|
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
|
|
standing between a Member and a shell.
|
|
|
|
- [ ] **`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
|
|
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 —
|
|
every caller resolves to the owner's real login home. Safe only because all seven callers sit
|
|
behind `execution` permissions. If per-user home confinement is ever attempted, this is the
|
|
function to start from.
|
|
|
|
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
|
|
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
|
|
`/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
|
|
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
|
|
owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the
|
|
thing to fix first if any of them is ever granted.
|
|
|
|
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
|
|
sidecar itself is fully per-user and confines every JSON read to `/dav/<userId>/`. The platform
|
|
side is ready for members; the CalDAV server underneath is not.
|
|
|
|
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
|
|
every account from it. Favourites, playlists and now-playing _are_ per-user. Deliberate for now
|
|
(one household, one library) but worth stating rather than discovering.
|
|
|
|
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
|
|
single global lane; ownership is enforced one layer up, in `pipeline-jobs-routes.ts`, by an
|
|
explicit `job.userId !== user.id → 404` on every by-id route. Correct today, but the queue itself
|
|
has no notion of whose work it is running.
|
|
|
|
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
|
|
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
|
|
`notify` permission could deregister another's device; and `POST /_officer/notify` let a request
|
|
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
|
|
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
|
|
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
|
|
|
|
- [ ] **Remove dead `username` plumbing.** `send-claude-code.ts` declares `username` in two types
|
|
without using it. (`toShellUsername` is NOT dead — `server.tsx:190` and
|
|
`pipeline-job-manager.ts:267` both call it. The `provision.ts` caller this item used to name no
|
|
longer exists.)
|
|
|
|
## Email
|
|
|
|
Email became a plugin on 2026-08-18 (`plugins/email/`, `gitea.officer.dev/plugins/email`). Its work queue
|
|
went with it — the open items pointed at `sidecar/email/store.ts`, a path in another repository, which is
|
|
exactly the way a leftover list sends someone to edit files that are not there.
|
|
|
|
Anything still owed to the mail client belongs in that repository's own TODO.
|
|
|
|
## Transmission
|
|
|
|
Phase 1 (parity with `_references/transmission-web`) shipped 2026-07-30: the `officer-transmission`
|
|
sidecar plus `/transmission` (torrents / stats / settings). Everything below is **phase 2** — none of
|
|
it exists in the reference app, so none of it was in scope for parity.
|
|
|
|
### Probably needed regardless
|
|
|
|
- [ ] **Resizable detail pane.** `TorrentsView` pins it at `DETAIL_HEIGHT = '45%'`, which is a guess.
|
|
Either a drag handle or a persisted height in `useLocalStorageState`, alongside the column set.
|
|
- [ ] **Revisit `DEFAULT_VISIBLE_COLUMNS`.** Eleven of thirty, chosen before ever seeing the table
|
|
rendered against the real 43-torrent library. Likely wrong in both directions.
|
|
- [ ] **Files tab on huge torrents.** `detail/FilesTab.tsx` builds the whole tree and renders every
|
|
node — no virtualisation. Fine at 14 files; unknown at a few thousand. If it stalls, the fix is
|
|
`useVirtualizer` over a flattened visible-node list, the same shape as `TorrentTable`.
|
|
|
|
### Ideas, unprioritised
|
|
|
|
- [ ] **Container→host path mapping.** The daemon reports its own namespace (`/downloads/complete`),
|
|
which is not a path on this host. A mapping table (`/downloads` → `~/hdds/08_TB_01/Torrents`,
|
|
`/downloadsTV` → `14_TB_02/Torrents`) would make locations clickable through to `/files` and
|
|
make "Set location" offer real destinations. Deliberately dropped from phase 1: the reference
|
|
app has no such feature, so it would have been config nothing read.
|
|
- [ ] **Push instead of poll.** Transmission RPC has no push channel, so `useTransmissionData` polls
|
|
every 5s. The sidecar could poll once and fan out over a WebSocket, which is both cheaper and
|
|
what every other live surface in the platform already does.
|
|
- [ ] **Cross-surface links.** Soulseek and `download-media` both land files in the same library;
|
|
Transmission is the third door to it. Worth a think about whether they should know about each
|
|
other at all.
|
|
|
|
## Headscale
|
|
|
|
Gaps against headscale's own API, found while reading the app on 2026-08-05 — all closed the same
|
|
day. Kept here for what the policy work turned up, which is not obvious from the code.
|
|
|
|
- [x] **ACL policy.** `/api/v1/policy` GET/PUT behind `/_officer/policy`, with a plain HuJSON textarea
|
|
that sends the text byte for byte and shows headscale's verdict verbatim (line and column
|
|
included). Officer does not pre-validate: headscale owns the only parser that resolves groups,
|
|
tags and hosts, and a second weaker one would disagree with the thing that actually enforces.
|
|
**The mode cannot be read.** A file-backed policy is still served over GET; only a PUT refuses,
|
|
with "update is disabled for modes other than 'database'". So the first save is what discovers
|
|
writability, and a refusal becomes a persistent read-only banner. Verified live, not inferred.
|
|
|
|
- [x] **A node can be moved between users.** `/api/v1/node/{id}/user`, in the expanded node card next
|
|
to the tag editor — both being the things that decide which policy rules apply to a node.
|
|
|
|
- [x] **User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry
|
|
was stale when it was written.
|
|
|
|
- [x] **Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
|
|
|
|
- [x] **Device invites, platform side** (`COMMS/OFFSCALE_INVITE_ENROLLMENT.md` §5, built 2026-08-05).
|
|
Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke —
|
|
`/_officer/enroll/invites` in `invites.ts`, `InvitesView`. **The records are not Officer's.** They
|
|
proxy to the server's Officer Companion, because the joining phone has to claim without an Officer
|
|
account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when
|
|
the platform is down" argument says the same. Officer stores no invite and no claim token.
|
|
**Live on all four servers since 2026-08-05.** The 401 that blocked it was a companion-side prefix
|
|
parse (a Headscale key prefix is a fixed 12 chars and may contain `-`; they split on the first one);
|
|
fixed upstream. Verified end to end against pastilhas-eu: create returns the
|
|
`officer-offscale://join#…` link, list shows the record, revoke flips it to `revoked`. The list
|
|
envelope is not in the spec, so `pickInvites` takes the body's array whatever it is keyed under.
|
|
Four gaps were sent back to the spec author: `keys.ts` has no
|
|
sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable
|
|
behind nginx, `tailnet` is not a headscale concept and has to be recorded on the invite, and
|
|
`/api/v1/enroll/*` collides with headscale's own namespace.
|
|
|
|
## Workspaces & Panels
|
|
|
|
Its own list — **`docs/workspace-panel-todo.md`** — because it is long and actively worked. Full
|
|
analysis behind it: `COMMS/workspace-panel-framework-analysis-2026-08-07.md`.
|
|
|
|
Headlines: the framework has **no panel lifecycle** (a panel can be destroyed but never told, which is
|
|
the real cause of the terminal orphan leak); three state-key families are **silently dropped on every
|
|
write**, orphaning a pty per reload; Running Shells has **404'd since 2026-07-31**; there are **zero
|
|
error boundaries** anywhere in the repo; and `dashboards.id` is a **global** primary key fed by
|
|
`slugify(name)`, so two members naming a dashboard the same thing collide.
|
|
|
|
- [ ] **Renaming a panel key is a silent data migration, and the repair is destructive.** A saved layout
|
|
holds `appType` strings. `normalizeLayout` replaces any appType not in the allow-list with the
|
|
caller's `fallback`, and `WorkspaceView.tsx:97` writes that back — so a renamed key turns every
|
|
saved layout into N copies of whichever panel is `allowed[0]`, permanently, on one page load.
|
|
Renaming `headscale-*` → `offscale-*` did exactly this: `/offscale` rendered three server pickers,
|
|
`ServersView` never mounted, and it survived every restart because the code was correct and the
|
|
data was not (2026-08-17, `ed3806f9`).
|
|
|
|
Plugin screens are now cleared at install, which covers plugins and nothing else. The general fix
|
|
is a rule about wholesale mismatch: **a layout in which NO panel has a recognised appType is
|
|
residue from older code — discard it and use the screen's default**, rather than collapsing it.
|
|
`fallback: allowed[0]` is arbitrary (it is whatever `panels.ts` happens to list first) and should
|
|
probably not be a per-panel substitution at all.
|
|
|
|
## Code editor
|
|
|
|
- [ ] **The owner wants to go deeper here — ideas pending (noted 2026-08-15).** Raised right after the file
|
|
browser gained its first links into `/code-editor`. Do this after the file-browser test pass.
|
|
|
|
What is worth knowing before starting, all established on 2026-08-15:
|
|
|
|
**It has been effectively invisible for six months.** The app landed 2026-02-21 (`9e9acd96`), the
|
|
screen 2026-02-17, and `1fa3a659` on 2026-07-25 is titled "**restore** the code editor screen" — so
|
|
it was dropped and brought back at least once. It IS in `CORE_DOCK_ITEMS` as "Editor", but
|
|
`DEFAULT_DOCK_PATHS` is `['/', '/files', '/terminal', '/dashboards', '/chat']` and the owner's own
|
|
`dock_configs` row does not list it either. Until today the only way in was typing the URL.
|
|
|
|
**So it has no users, and that is the risk.** A surface nobody reached for six months is where things
|
|
rot quietly. Nothing about the editor itself has been verified — whether saving works, how the file
|
|
tree behaves, what closing the last tab does, what an unsaved-changes navigation does. Only the
|
|
plumbing was checked: `?file=` is read (`EDITOR_FILE_PARAM`), `/code-editor` renders with `urlState`,
|
|
and `CodeEditor.tsx:60-76` fetches a path that arrives in the URL rather than only matching already
|
|
open tabs.
|
|
|
|
**One bug of exactly this kind was already found and fixed** in the FileViewer's editor on the same
|
|
day: the Edit toggle was gated on `content` rather than `content !== null`, so an empty file — the
|
|
one "New file" produces — could not be edited at all. Assume siblings.
|
|
|
|
**Open question, deliberately not decided:** `Open in editor` in the file browser's row menu still
|
|
navigates to `/code-editor`, which is the last action that leaves the browser. Editing now happens
|
|
in place via double-click → the viewer's pane. Either `/code-editor` earns its keep as a genuinely
|
|
different tool (tree, tabs, multi-file) or that menu item should go.
|
|
|
|
## Files app — routing
|
|
|
|
**The URL model is done (2026-08-15).** The folder is the pathname —
|
|
`/files/archive/Tests/platform/docs` — and `?view=` is a NAME within it, not a second copy of the path.
|
|
Deep links and refresh work, which they never had. Landed across `559560de`, `98ba604a`, `7e31564c`,
|
|
`0dc88b51`, `7359af2f`, with `files-route.ts` + 23 tests as the one owner of encoding and of which params
|
|
belong to the overlay.
|
|
|
|
Four bugs came out of that work and are fixed: closing a pane sent you home; `?view=` was stripped on
|
|
every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a
|
|
pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a
|
|
`<Link>` and never called the function enforcing it.
|
|
|
|
- [ ] **Rows are still not links.** This is the half that did not get done, and it is the original
|
|
finding. Both list and grid render `<div onDoubleClick>` (`FileItem.tsx:701,748`), and search
|
|
results render `<div onClick>` (`FileViewContainer.tsx:187`) — the "opaque click" that
|
|
`docs/navigation-audit.md` names. No cmd-click into a new tab, no middle-click, not
|
|
link-focusable, and the target lives in a closure rather than the DOM.
|
|
|
|
It is now much easier than it was: `folderHref(path)` gives a folder's URL and `hrefForPath` gives
|
|
a full `To` with the overlay already stripped, so a row becomes
|
|
`<Link to={hrefForPath(childPath)}>` and a file row becomes a link that sets `?view=<name>`. The
|
|
breadcrumb has done exactly this since before today and is the model.
|
|
|
|
Watch for: a row is also a click target for SELECTION (single click selects, double opens,
|
|
shift/cmd extend). An anchor changes what those gestures mean by default, so the selection
|
|
handlers have to keep working and `preventDefault` where they win.
|
|
|
|
- [ ] **The acts that clear the overlay have no owner, only the list does.** `VIEWER_PARAMS` and
|
|
`withoutViewerParams` live in `files-route.ts` and are shared. But every site still decides FOR
|
|
ITSELF whether to call them, which is precisely how the breadcrumb shipped wrong an hour after the
|
|
rule was written. Three bugs in a row came from this. A single `navigateToFolder()` that every
|
|
caller must go through — rather than a helper they may remember — is the fix if a fourth appears.
|
|
|
|
- [ ] **A panel's folder is still not addressable.** `urlPath` is true only on the `/files` screen;
|
|
panels keep the path in local state, so they always open at their base and cannot be linked or
|
|
restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both —
|
|
but worth re-deciding rather than inheriting.
|
|
|
|
## Affordances (2026-08-19)
|
|
|
|
Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An affordance is what the surface
|
|
on the other end offers the agent — declared by the code that implements it, read at runtime. The first
|
|
two are image rendering (core) and gitea (`git` for git, `tea` for everything else).
|
|
|
|
Built 2026-08-19: `servers/affordances/load.ts` reads the tree and compiles one block, and
|
|
`claude-manager.ts` appends it to every Claude session via
|
|
`systemPrompt: { type: 'preset', preset: 'claude_code', append }`. Read per session, so a plugin
|
|
installed an hour ago is live on the next message.
|
|
|
|
Still to build:
|
|
|
|
- `capabilities/affordances/` created by the setup script, with `officer/core/` populated from an
|
|
officer-capabilities repository. The directory exists on this machine by hand; a fresh install gets
|
|
nothing, so the first affordance is currently a local artefact rather than a shipped one.
|
|
- **Opencode delivery.** It has its own driver and gets nothing, so image rendering is currently a claim
|
|
that holds on one harness — exactly the drift affordances exist to remove. Deliberate: opencode work is
|
|
paused pending its v2 migration and this would be thrown away. Revisit when v2 lands.
|
|
- A bare `claude` run in a terminal on the host gets nothing either, since it never passes through
|
|
`claude-manager`. Would need a generated file, which is the thing `user-instance.ts` refuses to write
|
|
for the owner.
|
|
- Plugin contribution: an `affordances/` directory in the plugin repository, copied to
|
|
`capabilities/affordances/<publisher>/<plugin>/` on install and removed on uninstall. Same shape as
|
|
`assets/` and `publishAssets`. **Nothing does this yet** — gitea will be the first.
|
|
|
|
Open, and deliberately deferred:
|
|
|
|
- **Scoping.** Everything is loaded into every conversation. Fine at two files, not at twenty. A
|
|
`surface` field was drafted and dropped because it was doing two jobs at once: for image rendering it
|
|
means _what the client renders_ (a phone, a web bubble, a cron run with nobody watching), for gitea
|
|
_what tooling exists on this machine_. Those are different axes and one field cannot be both. Decide
|
|
the axis before adding the field — and note this codebase has already been bitten by a field that
|
|
validated itself and was never consumed (`unauthenticatedPaths`, see `plugins/public-paths.ts`).
|
|
- **Drift.** The rule is "declared where implemented", but a hand-authored markdown file in a repository
|
|
is not that — `ChatImage` could stop rendering images and no file would notice. Accepted for now
|
|
because prose that reaches every harness beats a registry that reaches one; revisit if it bites.
|
|
|
|
## Plugin ecosystem — before it opens to third parties (2026-08-18)
|
|
|
|
Not now. Months away. Recorded so it is not re-derived.
|
|
|
|
- [ ] **A plugin install is not pinned, so a review would bind a repository rather than code.**
|
|
`fetch.ts:70` is `git clone --depth 1 <source>` — no ref — so it takes whatever the default branch
|
|
points at when the install runs. A plugin reviewed at commit X installs at whatever was pushed
|
|
since, and `--depth 1` leaves no history to compare against.
|
|
|
|
This is the shape of the npm attack already documented in `CLAUDE.md` (2026-08-04), and the same
|
|
shape as the Chrome Web Store's real failures — not bad extensions passing review, but good ones
|
|
sold or compromised and then pushing an update to users who had already accepted them.
|
|
|
|
Fix: a `reviewedCommit` on the catalogue entry, checkout detached at that SHA, and **verify
|
|
`rev-parse HEAD` matches or refuse**. The verification is the part that matters. An update then
|
|
becomes "a new reviewed SHA exists" — a catalogue change rather than something the author triggers.
|
|
|
|
- [ ] **Third-party plugin sidecars are unconfined.** A plugin sidecar runs `bun run <script>` with
|
|
`cwd: PLATFORM_DIR`, so Bun auto-loads `.env` and it gets `POSTGRES_URL`; it runs as the service
|
|
user, which owns `secrets/officer-keys.db` and can therefore mint an owner JWT. Fine for
|
|
first-party plugins, total compromise for anyone else's.
|
|
|
|
The mechanism to fix it already exists and is pointed elsewhere: `spawn-as-member.ts` drops
|
|
privileges with `setpriv --reset-env` and a six-name allowlist for exactly this reason.
|
|
|
|
- [ ] **No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
|
|
"build your own, give it to a friend" path has no affordance. Small, and needed before the
|
|
unofficial channel is real.
|
|
|
|
## Plugin system — gaps the email extraction found (2026-08-18)
|
|
|
|
- [ ] **A plugin cannot declare its own npm dependencies, so the PLATFORM is carrying them.** `imapflow`,
|
|
`mailparser` and `@types/mailparser` are in the platform's `package.json` and are imported by nothing
|
|
in `src/` — they exist solely for `plugins/email/sidecar/`. Plugins have no `package.json`, run with
|
|
`cwd: PLATFORM_DIR`, and resolve from the platform's `node_modules`, so deleting those three lines
|
|
breaks the installed plugin on its first sync.
|
|
|
|
This does not scale and it is the wrong party holding the risk: the platform cannot be answerable for
|
|
what an arbitrary plugin decides to install, and `bunfig.toml` sets `frozenLockfile = true` precisely
|
|
so a dependency change is a deliberate, reviewed act. Publishing will be reviewed rather than a
|
|
free-for-all, which narrows the threat but does not move the responsibility.
|
|
|
|
What it probably needs: a `dependencies` field in the manifest (the manifest already holds
|
|
`osDependencies` for host binaries, which is the same shape of problem solved once), installed into
|
|
the plugin's own directory at install time rather than merged into the platform's tree. Note the
|
|
install path already learned not to trust `$PATH` — `pushPluginSchema` resolves a binary rather than
|
|
spawning `bunx` — so whatever runs the install has to do the same.
|
|
|
|
Until then: `imapflow` and `mailparser` STAY. Removing them is not tidying, it is breaking email.
|
|
|
|
- [ ] **`email_accounts/` is created by the PLATFORM for every account, and should be the plugin's job.**
|
|
`USER_DIRS` in `data-path.ts` pre-creates it at 0700 for every user, whether or not the email plugin
|
|
is installed — so a core install provisions a directory named for a plugin it does not have.
|
|
|
|
Ownership should invert: the plugin provisions its own storage, in all three moments —
|
|
|
|
1. **on plugin install**, for accounts that already exist;
|
|
2. **on user provisioning**, for accounts created afterwards;
|
|
3. **lazily**, the first time a provisioned user opens the plugin, so a machine that missed either of
|
|
the above still heals itself.
|
|
|
|
The third is the one that makes the other two non-critical, and it belongs in the email sidecar
|
|
rather than in the platform's account handler.
|
|
|
|
This is entangled with the `email_db` leak below — core reads that directory today, so the plugin
|
|
cannot become its sole owner until that is resolved too. Do them together.
|
|
|
|
- [ ] **The platform still knows email's on-disk vocabulary, deliberately, and it is untested by anything.**
|
|
`findEmailAccountName` in `data-path.ts` reads `DATA_PATH/<user>/email_accounts/*/emails.db` and picks
|
|
the newest by mtime. Two core callers use it: `user-instance.ts` (`OFFICER_EMAIL_DB`, for the agent's
|
|
`email_db` MCP tool) and `chat/websocket.ts` (`resolveEmailCwd`, for a chat opened against a mailbox).
|
|
`ChatPanelWrapper.tsx` hardcodes `workspace.id === 'email'` to trigger the second.
|
|
|
|
This traded a CODE dependency for a CONVENTION, which was the right trade at the time — the code
|
|
dependency is what put `officer-agent` into a restart loop on 2026-08-13 (`fbb6d6c7`). But the
|
|
convention has no enforcement on either side: if the plugin ever renames `emails.db`, moves to one
|
|
database per folder, or nests differently, core silently points the agent at a path that does not
|
|
exist. No import breaks, no type fails, no test covers it. The tool just goes quiet.
|
|
|
|
The clean version needs two things the platform does not have: a way for a plugin to ship an MCP tool,
|
|
and a way for a plugin to declare a chat context. Until both exist this stays — but it should be a
|
|
decision someone re-takes, not one that is inherited.
|
|
|
|
## Plugin repos — what writing their READMEs turned up
|
|
|
|
Three READMEs were written on 2026-08-16 (`music`, `offscale`, `example`) and pushed to their own repos.
|
|
Writing them meant reading each plugin against its own documentation, which is the cheapest audit there
|
|
is. Everything below was found that way and none of it is fixed.
|
|
|
|
**Offscale is still branded headscale in shipped, user-visible places.**
|
|
|
|
- [ ] `web/panels.ts` registers three panels named "Headscale servers", "Headscale", "Headscale" — these
|
|
are what a user reads in the panel picker.
|
|
- [ ] `web/OffscaleServerPicker.tsx:26` renders the literal heading `Headscale`.
|
|
- [ ] `web/ConsoleView.tsx:22` mints terminal session ids as `headscale-console-<id>`. That is persisted
|
|
state rather than a comment, so renaming it orphans live console sessions — cheap now, less so later.
|
|
- [ ] ~12 stale `/headscale` route paths in comments across `web/`, plus `App.tsx:78` in the platform.
|
|
- [ ] `PLUGIN.md` has ~11 stale self-references (`headscale_servers`, `listHeadscaleServers`, permission
|
|
key `headscale`, `/api/headscale`, panel keys, `useHeadscaleSection()`).
|
|
|
|
**Documentation that actively lies.**
|
|
|
|
- [ ] `plugins/offscale/PLUGIN.md:445-449` says the ACL assistant is deliberately unwired and must not be
|
|
wired up. It IS wired — `sidecar/assist.ts`, `claude-proxy.ts`, `web/PolicyAssistant.tsx` and the
|
|
manifest's `readOnlyWrites` all ship.
|
|
- [ ] `plugins/offscale/OFFSCALE_API.md` claims to publish the contract "in full" and omits six live
|
|
surfaces, including all of `/_officer/companion/*` and `/_officer/enroll/invites`.
|
|
- [ ] `plugins/music/MUSIC_API.md` says favourites and now-playing are served by the platform from
|
|
Postgres; the sidecar serves them itself, scoped by `X-Officer-User`. It also documents the playlist
|
|
item key as `<albumRel>/<file>` while the schema and favourites use the home path — the same file
|
|
contradicts itself.
|
|
- [ ] `plugins/music/MUSIC_API.md` documents now-playing as one snapshot per user with no mention of
|
|
`?device=`, which the schema makes part of the primary key. A mobile client written to that doc is
|
|
correct only by accident.
|
|
- [ ] `plugins/example/manifest.ts:12` lists `web/Router.tsx` as the frontend convention; discovery
|
|
requires `web/panels.ts` AND `web/layout.ts`. The same file documents an `ownerOnly` field that does
|
|
not exist and never did.
|
|
- [ ] The Companion contract docs the offscale code cites (`COMMS/OFFSCALE_COMPANION_API.md`,
|
|
`COMMS/OFFSCALE_INVITE_ENROLLMENT.md`) exist nowhere — `COMMS/` was deleted. Two of the plugin's
|
|
eight sections depend on a service with no published spec.
|
|
|
|
**Conventions a plugin author has to guess.** All verified in `discover.ts`/`mount.ts`, none documented
|
|
outside the example's new README:
|
|
|
|
- [ ] Panel `key`s and layout `appType`s must agree, and a mismatch fails SILENTLY — `PluginScreen.tsx`
|
|
pins `appTypes.allowed` with `fallback: allowed[0]`, so a typo renders the first panel.
|
|
- [ ] The `<appName>_` table prefix is unenforced. The barrel is `export *` across every plugin into one
|
|
`public` schema, so two plugins choosing the same unprefixed name collide with no error until push.
|
|
- [ ] A manifest is module-cached for the process lifetime: editing it does nothing until
|
|
`pm2 restart officer`, and `outdated` cannot see a version bump either.
|
|
- [ ] Permission-key prefixing is convention, not validation. Only a collision with a CORE key is refused,
|
|
silently, at mount, with a console error.
|
|
|
|
**Genuinely missing.**
|
|
|
|
- [ ] `plugins/offscale` spawns the `ssh` binary (`sidecar/ssh.ts`) but declares no `osDependencies`, so
|
|
install never checks for it and the Console fails at runtime instead of refusing at install.
|
|
`PLUGIN.md:421` already lists it as a host dependency — the manifest is the file that is wrong.
|
|
- [ ] `scripts/reindex-music.ts` resolves its port file from `process.env.DATA_PATH ?? cwd/data`, and
|
|
`DATA_PATH` stopped being an env var on 2026-08-13. Run from the repo it looks in
|
|
`platform/data/music/.server` and never finds it. One line.
|
|
- [ ] `plugins/music/web/MusicPlayerHost.tsx` header says it is mounted once in the persistent
|
|
DashboardLayout; it moved into `MusicDetail`. That is exactly the comment that would mislead
|
|
someone about why playback stops off-route.
|
|
- [ ] A plugin's `assets/icon.png` is only published while installed, so its real icon is invisible in the
|
|
store before install. The catalogue's `iconUrl` works around it, and an author has to know to add one.
|
|
|
|
## Waiting on the owner to run it
|
|
|
|
- [ ] **The AI usage panel works for one account; the multi-account half is unproven.** Landed and verified
|
|
live 2026-08-18 — click the harness pill in the chat composer and it shows Claude Code's own usage
|
|
display, limits with countdowns, tokens by day and by model.
|
|
|
|
What is NOT proven is the reason it was built: a second Anthropic login, the account tabs, and a
|
|
stored row surviving a switch. **Group C of `docs/ai-usage-panel.md`** is that, and test 10 is the one
|
|
most likely to be wrong — a refresh must not overwrite an account it could not measure, and that
|
|
failure is silent.
|
|
|
|
Deliberately deferred rather than forgotten: the owner switches accounts when the work needs it, and
|
|
the worst case is one wrong tab. Run Group C the first time a second login lands on this machine.
|
|
|
|
Also unexercised: the model dropdown no longer renders the literal string `claude-code` for a chat
|
|
that named no tier. Needs `officer-claude-code` restarted, not just `officer` — the parser in the
|
|
sidecar is what emits it.
|
|
|
|
- [ ] **Plan mode is live and has never been exercised.** Landed `1b811dfa` on 2026-08-17; all three
|
|
processes were restarted the same evening, so the code IS running. Not one part of it has been used:
|
|
not the toggle, not the plan card, not the round trip from the sidecar's pending promise to a browser
|
|
and back, not the stored column, not `settleAllPlans`.
|
|
|
|
**`docs/chat-plan-mode-test-plan.md` is the checklist** — eleven ordered tests, what each should show,
|
|
and what its failure looks like. Test 4 (approve) is the one most likely to be wrong: it is the case
|
|
that changed the design, because approving `ExitPlanMode` drops the harness into `default` rather than
|
|
back into `bypassPermissions`.
|
|
|
|
Safe to leave untested. Plan mode is opt-in and off by default, so a conversation that never touches
|
|
the toggle behaves exactly as it did before. The one failure worth knowing in advance is a turn that
|
|
never ends after a plan is answered — that would be a leaked `canUseTool` promise, and
|
|
`pm2 restart officer-claude-code` clears it.
|
|
|
|
Design and the probe results behind it: `docs/chat-plan-mode.md`.
|
|
|
|
## Known bugs
|
|
|
|
- [ ] **Background job logs appeared INSIDE an open file in the code editor.** Reported 2026-08-16 by the
|
|
owner, on the production host, running as a member with a `bun dev` background job streaming. The
|
|
editor had a file open; the runner's log output was rendered into that file's buffer. **It was never
|
|
saved** — the file on disk was intact, and the editor was not marked dirty as far as the report goes.
|
|
So this is a DISPLAY-layer mixing, not a write path: the bytes reached a buffer that only exists in
|
|
the browser.
|
|
|
|
Not reproduced, not diagnosed, and deliberately not guessed at further than this. What is worth
|
|
recording is where the two streams could meet, because both were added in the same week and they are
|
|
the only two things writing text into a panel:
|
|
|
|
- `useTaskDetail` polls `GET /chat/tasks/:id` every 2s while a job runs and returns `{kind:'log', text}`.
|
|
`CodeSurface` renders it — and `CodeSurface` is shared with the editor's own surfaces.
|
|
- The editor keys open files by PATH (`useEditorState`), while the task panel keys by TASK ID, and
|
|
several panel-scoped stores in between are keyed by `panelId` through `useGlobal` /
|
|
`usePanelMemory`. A key collision between those namespaces would produce exactly this: one panel's
|
|
text arriving in another's buffer, with no server involvement and nothing persisted.
|
|
|
|
**Screenshot 2026-08-16 answers the first question and kills two theories.** The tab carries a DIRTY
|
|
dot, and the log lines have Monaco line numbers continuing the file's own sequence (…180, then eight
|
|
lines of dotenv/POSTGRES_URL output, then 189 `</Link>`). So the MODEL was mutated — this is the
|
|
editor's content state, not its render, and the earlier "the file was okay" means it was simply never
|
|
saved. Note the splice is mid-file, not a replacement: JSX text and log text share line 180
|
|
(`Forgot?--env-file=.env --watch src/server.tsx`).
|
|
|
|
Ruled out by that: `CodeSurface` renders with shiki, not Monaco, so the two cannot share an editor
|
|
instance. And the app's only other Monaco mount (`FileViewer/renderers/EditorRenderer.tsx`) passes no
|
|
`path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`.
|
|
|
|
So the pollution arrives as TEXT through the editor's own content path — `openFile`, `setContent`, or
|
|
the end-of-turn refresh that re-reads every clean file. That last one is the prime suspect: it is the
|
|
only thing that writes a buffer without a keystroke, it runs exactly when a background job is
|
|
producing output, and it lands its result by path.
|
|
|
|
**Narrowed 2026-08-16, and a trap is now set.** `reloadFile` sets `originalContent` and clears
|
|
`isDirty`, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
|
|
path is out. Nothing in the app injects text into the page either: no `execCommand('insertText')`,
|
|
no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
|
|
event, which means it looks like a paste or a programmatic `executeEdits`.
|
|
|
|
**`docs/editor-buffer-corruption.md` is the procedure — read that when someone reports this.** A
|
|
trap is set: `reportSuspiciousEdit` in CodeEditor.tsx records any insertion over 200 characters
|
|
arriving while the editor is NOT focused, with the stack at that moment, into
|
|
`localStorage['officer:editor-buffer-trap']` (last five). It persists rather than only logging
|
|
because this arrives as a report hours later, when the console is long gone — the question becomes
|
|
"paste this key" instead of "what did you see?". Delete all of it once the cause is known.
|
|
|
|
One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `<Editor>` a real
|
|
`path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two
|
|
panels open on the same file now share one model. That should be an improvement, but it is a change
|
|
in exactly the layer that turned out to be involved.
|
|
|
|
Worth noting because it is the reason to look at all: an editor that can show content the file does
|
|
not contain is a correctness problem even when it never writes — the next person's instinct will be to
|
|
save the file to "fix" it.
|
|
|
|
- [ ] **`bootstrap.ts` runs `npm install -g` for Pi on every boot.** `findPiPackageDir` checks stale
|
|
paths and an outdated package name, so `installPi` always fires and floods the logs with
|
|
`EEXIST` noise. Should detect `@earendil-works/pi-coding-agent` at the real npm prefix.
|
|
|
|
- [x] **VNC mirror can orphan/duplicate x11vnc across sidecar restarts.** FIXED 2026-08-01 in the
|
|
Xvnc rewrite. The diagnosis was right and survived the move off mirroring: the running desktop is
|
|
tracked in module-level state, so a sidecar restart forgot it while the server kept running
|
|
orphaned, and `waitForPort` treated ANY listener on 5900 as success — so the next start reported
|
|
success while the browser talked to the stale process. Now `reclaimPort` frees 5900 (TERM, then
|
|
KILL after 2s) before spawning, and `waitForPort` also fails if the process we spawned has
|
|
exited, so a listener that is not ours can no longer be mistaken for a healthy start.
|
|
|
|
## Infra (alpha)
|
|
|
|
- [ ] **Delete `/etc/systemd/system/officer-vnc.service`.** Hand-installed unit (not in this repo)
|
|
that ran `vncserver :1 -geometry 1920x1080 -localhost yes -fg` with `Restart=on-failure`,
|
|
enabled at boot — it kept a whole parallel XFCE session alive on `:1` (283 MB, 166 tasks)
|
|
independently of the platform, and silently respawned it whenever the display was killed.
|
|
Obsolete now that the Desktop panel mirrors `:0` via x11vnc. Disabled 2026-07-15
|
|
(`systemctl disable --now`), but the unit file is still on disk. Nothing in the codebase
|
|
recreates it and nothing documents it, so delete the file rather than leave a mystery service
|
|
one `systemctl enable` away from returning.
|
|
|
|
- [ ] **`ufw` blocks port 9010 on the LAN.** Default deny incoming; only `22/tcp`, `80/tcp`,
|
|
`443/tcp`, and everything on `tailscale0` are allowed — so `http://192.168.47.196:9010` is
|
|
unreachable from the LAN while localhost and Tailscale work.
|
|
Fix: `sudo ufw allow from 192.168.47.0/24 to any port 9010 proto tcp`
|
|
|
|
## Backburner
|
|
|
|
- [ ] **Music tagging feature** (`/music`, Mp3tag-inspired). v1 was scoped as a two-panel workspace —
|
|
file browser left, table right, single "Load" context-menu item flattening audio files into the
|
|
table. Discarded before completion; would need `GET /file-browser/flatten-audio`, a
|
|
`useFilesAPI.flattenAudio` method, and the Music app panel rebuilt from scratch.
|