diff --git a/TODO.md b/TODO.md index 0e91e80d..3e557def 100644 --- a/TODO.md +++ b/TODO.md @@ -1,1050 +1,67 @@ -# TODO +# TODO — the index -Deferred work. +Deferred work. **The items live in `todo/`, one file each**, grouped by theme; this file is the map +over them and is GENERATED — run `bun run todo:index` after any change rather than editing it here. +Format and the two-way-sync contract: `todo/README.md`. -**Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from +**Context, corrected 2026-08-07.** This list 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. +indirection as accidental complexity. **That direction was reversed.** The 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 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.** - -## Speaking into a session from the backend - -**Correction, 2026-08-21: the door exists, and three entries in this file said it did not.** The claim -that `pushTurn` has exactly one caller (the chat websocket) is **stale**. `api/chat/nudger.ts:102` calls -`startClaudeTurn({ ws: null, … })` — a turn started server-side, with no browser attached, into a live -session. Whoever is watching the chat sees it arrive the same way they would see a turn typed in another -tab. `startNudger()` is wired at `server.tsx:182` and ticks once a minute against a `nudges` table. - -So **both** "missing" pieces are built: a recurring trigger, and a way to put a prompt into a running -session. What remains is narrower, and worth doing. - -- [ ] **Generalise it: deliver a turn to a named session, from anywhere in the backend.** Wanted - 2026-08-21 — "ideal" is the owner's word for it. Today the only server-side caller is the nudger, - and delivery is welded to its schedule: a row in `nudges`, one per (user, session), a fixed - message, a tick. What the dispatcher, the file watchers and Command HQ all want instead is a plain - verb — *here is some text, deliver it to session X* — with the schedule, if any, belonging to the - caller. - - Two things already established and worth not re-deriving. The nudger's `checkSafe` holds three - refusals — not live, live but generating, another live session in the same directory — and the - comments above it carry why each one is there. And "not live" is the case that differs by caller: - the nudger refuses (nudging a dead session would CREATE an agent, which is not what nudge means), - while a dispatcher handing over five todos does want it created. - -- [ ] **The cost of resuming grows with the transcript.** Raised 2026-08-21 by the owner as a challenge - worth taking rather than a limitation to live with. Every resume re-reads the whole conversation as - input tokens, so a long-lived commander costs more per note than it did in week one, for identical - work. Measurable today rather than theoretical: `command_hq_turns` already records input tokens and - USD per turn, so the curve can be plotted before anyone argues about a fix. - -## 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. **The third is - no longer missing** — see "Speaking into a session from the backend"; the nudger already starts a - server-side turn with no browser attached. What is missing here is arrival-triggered firing, since - the existing scheduler is a clock tick. - - 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: **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. Command HQ is already most of that registry — `command_hq_sessions` names - commanders and keys them on Claude's own transcript uuid. Delivery into a session exists too; see - "Speaking into a session from the backend". - - 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 -`