# 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. ## Shell — spotlight and the dock - [ ] **A Spotlight-style launcher, on `ctrl-space`.** The dock is full and cannot grow; navigation already borrows from macOS (see `docs/navigation-audit.md`), so the launcher is the same borrowing taken one step further. Type a few letters, get routes, chats, files, plugins, settings sections — and hit enter. Dock settings already exist, so the other half is pruning the dock down to what is actually used and letting the launcher carry the rest. **Trigger to confirm:** `alt-space` is the owner's preferred chord but is already load-bearing for him elsewhere, so `ctrl-space` unless that clashes too. - [ ] **Dock effects, and auto-hide.** Wanted alongside the launcher rather than after it: once the launcher carries navigation, the dock stops needing to be permanently visible. ## Background jobs - [ ] **A repeating prompt on the background-job starter.** An option beside the existing controls: send this agent a message every N minutes or hours. This is the Nudger from 2026-08-20 in the place it actually belongs — attached to a job that is already running, rather than a separate schedule pointing at a session that may not exist. The hard-won part is already written down: `api/chat/nudger.ts` refuses when the session is not live, when it is generating, and when any other session is live in the same tree — because "not generating" was once treated as permission and started a second agent in a repository another was building in. Whatever shape this takes, it inherits those three refusals. ## Fleet — one command across every server **The list exists: `docs/servers.md`**, written 2026-08-21 and verified from this host. Five machines hold a platform checkout, three run it, and it records how to reach each one. - [ ] **An orchestrator that acts on every server at once** — pull and update every platform, restart a process everywhere, report which host is behind. The list is the input; this is the thing that reads it. Three facts from writing the list decide the design, and all three are verified: the checkout **path differs per host** (take it from PM2's `pm_cwd` for the `officer` process, never a hard-coded path); `alpha` carries a **second, stale checkout** so pulling the wrong one succeeds while changing nothing; and the **git remotes differ** — one host pulls over HTTPS and one points at a different Gitea altogether. An orchestrator built on "ssh, cd to the same path, git pull" is wrong on three counts before it starts. Open: whether this is a script over SSH, an Officer screen, or a chat capability; and whether `headscale` and `omega` — checkout present, nothing running — are targets at all. Note the platform already has an **Update button** for a single server (`03abdeb2`); this is that, fanned out, and it should probably share the code rather than reimplement the pull. - [ ] **A way to mark a commit "this one ships", so a release is not just `master`'s tip.** Wanted 2026-08-21. **No version numbers** — not now — and **not a tag**: the owner does not want a second ref to mint and push for something the commit itself could carry. So a **codeword in the commit message**, and the fleet updater pulls to the newest commit carrying it rather than to whatever `master` points at. That distinction is the whole value: today a server updates to the tip, so an in-progress commit ships the moment it is pushed. A marker makes shipping a deliberate act while leaving the push unchanged. Shape to settle when it is built: - **A trailer, not a word in prose** — `Release: yes` on its own line, in the same family as `Co-Authored-By`. `git log --grep='^Release:' --format=%H -1` then gives the target SHA in one command, and no commit message that merely *talks* about a release matches by accident. - **The trade against a tag, stated honestly:** a tag can be moved or deleted when a release turns out to be bad; a commit message cannot be edited once pushed. Un-marking means marking a newer commit — which is a forward-only, revert-shaped workflow. That is a fine answer, but it should be a chosen one. - The updater then does a checkout at that SHA, not a `pull` — same discipline the plugin-pinning item asks for: **verify `rev-parse HEAD` matches, or refuse.** ## File watchers - [ ] **Watch a directory, and attach actions to what lands in it.** Wanted 2026-08-21. The owner sets a watcher on a path and configures what happens when a file appears there — the general mechanism, not one hard-coded flow. **The driving use case, end to end:** instead of opening a chat, record an audio note; it lands in a watched directory; the existing transcription path turns it into a text file; and on the watch, an agent picks the new files up and files them as todos. So the owner can capture an idea with no session open and no phone unlocked into the right screen. What it needs, in rough order: a watcher registry (path + what to do), a trigger that fires on file arrival rather than on a clock, and a way to hand the file to an agent turn. Note the second and third are the **same two gaps the Nudger item names** — the platform has no periodic/event trigger, and `pushTurn` has one caller, so nothing server-side can speak into a session. Build them once. Open, not decided: whether the watcher is a plugin or core; whether "action" is a task, a prompt from the prompt library, or a chat message; and how a file is marked consumed so it is not picked up twice. **The split, settled by the owner 2026-08-21: it is two stages, and only one of them is an agent.** 1. **Audio lands → transcribe.** Purely mechanical: watch the directory, call the transcription service, write the markdown out. A script and a service call, no model anywhere. This is the part that genuinely needs building, and building it as a script is the point — an agent here would be cost and nondeterminism for a job with one correct answer. 2. **Markdown appears → read, understand, act.** Reading a note and deciding it is a todo (and which one, and where it belongs) is real agent work. This half needs no new infrastructure: a recurring agent job already does it — the same hourly loop that logs a heartbeat can instead be told "look in this folder for new markdown and file what it contains". The prompt is the configuration. So the watcher to build is the dumb one. What a built-in watcher buys over a standing agent job for stage 2 — arrival-time firing instead of a poll, a UI to manage watchers, working with no session open — is worth re-asking then, not now. ## Prompt library - [ ] **A store of ready-made prompts, versioned, categorized and tagged.** Wanted 2026-08-21. The owner keeps prompts he reuses; today they live nowhere. What it needs: - **Database rows** with the usual fields (`createdAt`, `modifiedAt`, …), one **category** each and **multiple tags** per prompt. - **Versioning by row, not update-in-place.** Editing a prompt inserts a new row with a bumped version. The UI shows the latest version unless asked otherwise, with copy-to-use as the primary action. - **Categories do double duty:** they catalogue, and they decide where a prompt appears (or does not) in the workspace/page-layout routing system. - **Open question, deliberately deferred:** a separate `kind` alongside `category` — e.g. background-job prompt, image-generation prompt. Unclear whether both fields earn their keep; keep both in the design and decide when building. ## Notifications - [ ] **A push payload does not say which server sent it**, which is fine for one and breaks three things for several — the deep link resolves a session id against the wrong server, threading can collapse two servers' chats into one banner, and `mv` (a per-server catalogue hash) makes the app resync forever, always against whichever server pushed last. Blocks the multi-server sign-on work. **Settle the server identifier with that design rather than inventing one here.** See `docs/multi-server-push.md`. ## Affordances - [ ] **No way to see which affordances are reaching your agents, or to turn one off.** They are global: `affordanceBlock()` reads every file under `capabilities/affordances/` and is called with no user context (`claude-manager.ts:582`), so every account gets every one, in every conversation. That is fine while they are uncontroversial and stops being fine immediately after. "Wait for the question" was written to one owner's taste on 2026-08-21 — probably what most people want, and nobody has asked anyone. An affordance nobody can see and nobody can decline is a preference imposed as a fact. Wants: a settings screen listing every affordance with its name, description, where it came from (`officer/core` versus a plugin) and its body, plus a per-user toggle. Storage is one table keyed on user and the affordance's namespaced id. The work is threading a userId into `affordanceBlock`, which today takes only a root path. Two things to decide when it is built rather than now: whether a plugin can ship an affordance that is off by default, and whether disabling one should be visible to the agent at all — an agent told "this client renders images" behaves differently from one told nothing, and a THIRD state, "you were told not to", is a different thing again. ## Mobile apps - [ ] **iPhone Action Button → run an Officer task from a locked phone.** Wanted 2026-08-21. The driving use case: an idea arrives, press the Action Button, an audio recorder opens immediately — even on the lock screen — and the recording goes straight into an Officer chat session, without unlocking, opening the app and navigating there. Shape (assumed, not verified): the Action Button runs a Shortcut, and a Shortcut runs an App Intent the Officer app exports — so the work is shipping App Intents ("record to chat", and task-running more generally). **The load-bearing unknown is the lock-screen constraint**: what an App Intent may do while the phone is locked (microphone access, network, whether iOS forces an unlock prompt) decides whether this is the feature as imagined or a compromise. Settle that first, on a real device. - [ ] **The mobile recorder app, and the dispatcher agent behind it.** Wanted 2026-08-21. The owner is building a recorder for the phone. **Every audio lands in one central place** — one inbox, not a folder per purpose — and a **dispatcher agent** decides where each one goes. Routing has two modes and both are wanted: - **Told.** The recording opens with the destination — "send this to my todo guy", or just "todo" — and the dispatcher obeys. An explicit address always wins. - **Inferred.** Nothing is named, and the dispatcher works it out from the content. This is the one that makes the app worth using: the ideal is that most notes need no prefix at all. What this implies, and it is the same missing piece named twice already: **destinations must be addressable and speakable.** "My todo guy" is a name for a standing agent session, so the dispatcher needs a registry of destinations with names a person would actually say, and a door to deliver into a live session — which is the `pushTurn`-has-one-caller gap in the Nudger and File watchers items. Three features now want it; it is the thing to build. Fits with the rest rather than duplicating it: the **File watchers** split applies unchanged — a script transcribes on arrival, the dispatcher is the agent stage — and the **iPhone Action Button** item is how a recording starts without unlocking the phone. Open: whether the dispatcher is one long-lived session or spawned per note; what it does when inference is uncertain (ask back, or file into an inbox for review); and whether a routing rule is a prompt from the prompt library. ## Sudo terminal modal - [ ] **A modal carrying a terminal emulator, for anything that needs `sudo`.** Wanted 2026-08-21. Anywhere a command needs `sudo`, the modal opens with a live terminal session, the command runs in it, and the user types his password into the terminal himself — the password goes to the pty, never through the platform or an agent. Plumbing exists: the pty sidecar already serves ad-hoc sessions, and xterm.js is already in the terminal panel; the new part is the modal surface and an entry point ("run this with sudo") that other surfaces can invoke. **Needs an affordance** (`capabilities/affordances/`) so chat agents know the mechanism exists and reach for it instead of attempting `sudo` non-interactively or asking the owner to paste a command. ## Plugin infrastructure - [ ] **A plugin cannot claim a WebSocket provider.** `pluginPermissions` in mount.ts sets `api` and `routes` and never `ws`, and `server.tsx`'s handler map is a literal — so a plugin's socket has no door. The desktop plugin ships one that cannot be mounted (`plugins/desktop/BLOCKERS.md` §2), and it will not be the last: agreed 2026-08-21 that this is real infrastructure to build, not a per-plugin workaround. - [ ] **A plugin cannot run host setup.** `osDependencies` installs packages; it cannot reconfigure a display manager, and `plugins/desktop/setup/setup-desktop.sh` forces Xorg, enables auto-login and needs a reboot. Left manual. The owner recalls a mechanism for this existing months ago and being removed — **look in git history before designing a new one**, it reportedly worked and made the install page considerably better than it is now. ## Multi-user - [ ] **A plugin's settings routes are reachable by anyone the plugin is granted to.** Jellyfin's `/_config` stores a server's admin token and drives provisioning, and a Member granted the plugin can reach it. Agreed 2026-08-21 that the plugin should enforce this from facts the platform passes (`isOwner` plus the effective level, never the role name), with a proposal to declare owner-only path prefixes in the manifest so forgetting the check is not a silent hole. **Blocked on credential resolution:** connections are per-user, so locking `/_config` today leaves a Member with a plugin that only shows a connect form they may not submit. See `docs/plugin-route-authorization.md`. - [ ] **`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-` 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//`. 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 - [ ] **A deep read of the whole framework, to understand it and then improve it.** Low priority and explicitly not pain-driven — it works, and it works well. But it was built fast and then added to, repeatedly, and nobody has since sat down and read the whole thing at once. Three questions to answer rather than assume: can the query strings come out of the flow; is there a better shape for panel-to-panel communication than the current channel/URL split; and what does the layout tree cost that a simpler one would not. **Fold the existing panel items into this** — the silent `key`/`appType` mismatch, the destructive layout migration on a renamed key, and `docs/workspace-panel-todo.md`. They are symptoms of the same unexamined design, and fixing them one at a time is how it stays unexamined. Also a teaching exercise: the owner wants to understand it end to end, which is a reason to do it properly rather than quickly. 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 `` 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 `
` (`FileItem.tsx:701,748`), and search results render `
` (`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 `` and a file row becomes a link that sets `?view=`. 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. ## Release-binary dependencies (2026-08-19) Built: a plugin can declare `release: { url, identify }` for a dependency no package manager carries. Gitea's `tea` is the motivating case and the first user. Two things it does NOT do, both deliberate and both worth knowing: - **No checksum.** The download is verified by RUNNING it through the same `identify` rule the probe uses, which catches an HTML error page, a truncated file and a wrong-architecture build — none of them print what the plugin said to look for. It does not catch a compromised upstream. A checksum per os/arch is a table that goes stale every release and would stop installs the day upstream re-cuts a tag; HTTPS and a URL the owner reads in the install dialog is what stands behind it today. - **The version is pinned in the manifest.** `tea` is fixed at 0.15.1, so a new release needs a plugin update. That is the right default — an install that silently fetches whatever is latest is not reproducible — but there is no "check for a newer one" anywhere. ## Inputs outside forms (2026-08-19) — swept, fixed, closed The "19 files" count in the previous version of this entry came from one grep (`components/ui/input`) and was a starting point, not the answer. A wider sweep — bare `` and `