# 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 capability permission model
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
below that proposed deleting it have been removed rather than left to mislead the next reader.

What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
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.

- [ ] **`capabilities/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.

- [ ] **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` capabilities. If per-user home confinement is ever attempted, this is the
      function to start from.

- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
      `X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
      `/_officer/sessions` endpoints list and kill _every_ session on the box; vault and opencode take
      no user argument. All three are covered today only because `terminal`, `vault` and the agent are
      owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
      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` capability 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

- [x] **Gmail-style search operators** (done 2026-07-24, `0e5b91e`). `parseEmailQuery` +
      `searchEmails` in `sidecar/email/store.ts` parse: `from:`/`to:`/`subject:`/`body:` → FTS5 column
      filters; `has:attachment`, `is:unread`/`is:read`, `label:X`, `before:`/`after:YYYY-MM-DD` → SQL
      `WHERE` on `emails`; quoted values → exact phrases; free text → prefix-AND full-text; unknown
      `op:val` falls back to free text. Structured-filters-only queries skip the FTS join. Validated
      on the real 18k-mail DB. Search box placeholder hints at operators.
  - [x] **`OR`** (done 2026-07-24, `85d38fb`). Query splits on top-level uppercase `OR` into branches;
        each branch is a self-contained condition (`id IN (FTS subquery)` + its SQL filters) and
        branches are OR'd — so OR works across full-text _and_ structured filters. Verified on the 18k
        DB (`from:github OR from:deepgram` = 90+7 = 97 exactly).
  - [ ] **Remaining:** parenthesised grouping (nesting `(a OR b) c`), `in:sent/inbox/spam/trash`
        (folder scope), and relative dates (`newer_than:7d`). Grouping needs a recursive parser.
  - [ ] **Group search results into threads too.** Folder views group by conversation (`ee11c94`), but
        `/email/search` still returns one row per message. Apply the same `thread_id` collapse to the
        FTS result set so search matches Gmail's grouped results.

- [x] **Conversation threading** (done 2026-07-24, `ee11c94`). `thread_id` column on `emails`:
      header-based for new mail (`id` = `sha1(Message-Id)`, so `References[0]` hashes to the root's
      id — `computeThreadId` in `sidecar/email/store.ts`), subject+counterpart backfill for already-synced mail.
      `/messages` collapses to one row per thread (window fn) with count/unread; `/thread/:id` +
      `/thread/:id/read`; reader renders a collapsible stack. Verified on synthetic + 18k real DB.
  - [ ] **Upgrade old mail to exact threading.** One-time full re-fetch to capture `References` for
        already-synced mail — replaces the subject+counterpart fallback (which can over-merge recurring
        same-subject mail from one sender). Heavy/network-bound; opt-in.
  - [ ] **Store the RFC `Message-Id` header** on ingest so replies can set a real `In-Reply-To`
        (currently reply threading leans on subject/participants; `id` is a local hash, not the header).

- [x] **Compose/reply/send** (done 2026-07-24). Gmail SMTP send with contacts autocomplete, rich
      contenteditable body (inline images at the caret via paste/drag-drop, Bcc), single close button.
- [x] **`busy_timeout` on the email db** (done 2026-07-24, `9527e0b`). API + email sidecar share
      `emails.db`; wait out a concurrent writer instead of 500-ing with "database is locked".

- [ ] **Multiple views in the Email screen (tabs).** The screen grows beyond the current mailbox into
      several switchable views:
  - [ ] **Sender/domain management view.** Left panel: controls to group mail **by sender** or **by
        domain** (room for more grouping axes later). From a group, run bulk actions on that
        sender/domain: **unsubscribe + mark irrelevant**, **delete all mail** from it, **block/ignore
        future incoming**, etc. Think "inbox cleanup / triage" — operate on a whole sender at once. - Needs: aggregate query (count/size per sender + per domain), a block/ignore list the
        sync/IDLE path honors on incoming, an unsubscribe action (List-Unsubscribe header / link),
        and bulk delete over a sender/domain.
  - [ ] **Chat view.** The existing AI email-assistant flow — likely stays roughly as-is, just lives
        as one of the tabs (main view).
  - Open question: tabs vs. some other view switcher; which view is default.

## 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.

## Known bugs

- [ ] **`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.
