Files
platform/TODO.md
T

38 KiB

TODO

Deferred work.

Context, corrected 2026-08-07. This file used to open by saying Officer was "collapsing from multi-tenant / open-source-ready to a single-user platform", and told you to treat multi-tenant indirection as accidental complexity. That direction was reversed. The permission permission model shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items below that proposed deleting it have been removed rather than left to mislead the next reader.

What did NOT reverse: execution permissions (terminal, chat, tasks, files, desktop, browser) run as the owner's OS user and can never be granted. Indirection there really is accidental complexity.

Multi-user

  • deprovisionOsAccount is written but has never run against a real account. Landed 2026-08-12 in os-user-deprovision.ts and wired into deleteUserHandler, which now refuses to delete the row when the Linux teardown fails — so a failure is retryable instead of forgotten. Only the pure guards (guardDeletable, guardMemberTree, parseSubUidEntry) have tests; the reap loop, the chown -R sever and userdel have been exercised by nobody. docs/deprovision-os-account.md → "What is still unproven" has the five-step validation, and it has to happen on the production host with a throwaway account that has a shell left open and a container writing as a non-root user — those are the two cases the quiet path passes vacuously.

  • The terminal replays terminal QUERIES, which get typed into the shell. sidecar/pty/sessions.mjs replays the whole scrollback on attach; query sequences in the buffer get re-asked, xterm.js answers, and the answers arrive as keystrokes. Visible to a member daily. Fix is to strip query sequences in appendBuffer, so a replay reproduces output and never re-issues requests.

  • The web terminal renders a long URL as unreadable fragments. Claude Code's first-run login prints a ~400-character OAuth URL; the web terminal shows scattered characters with large gaps, nothing selectable. Half worked around by 2a8f004 (OSC 52, so "press c to copy" reaches the clipboard) — the rendering itself is undiagnosed. This is every new member's first five minutes. docs/open-threads-after-per-user-claude.md §1 has what is known and where to start.

  • Agent sessions are not durable, and it is one property behind three symptoms. A sidecar restart loses session identity, which is why endTurnIfAgentIsGone must skip sessions with no recorded userId, why a stuck "generating" spinner survives until a reconnect, and why any crash in that process is destructive rather than merely inconvenient. Fixing the three separately would miss that they are one missing property. docs/open-threads-after-per-user-claude.md §2.

  • ProcessTransport is not ready for writing — survivable since 8c4f150, still unexplained. A floating rejection inside the SDK's own input pump, with no frames from our code, so no await of ours can catch it. It crashed officer-agent four times on 2026-08-11, once truncating a turn mid-sentence; the unhandledRejection backstop has caught it once since. Best hypothesis is the claude CLI exiting while streamInput is still pumping. It needs looking at after the next occurrence, not catching in the act — markers to grep in docs/open-threads-after-per-user-claude.md §3.

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

  • A second Super Admin was storable, and made the owner nondeterministic. Fixed 2026-08-11. ck_users_owner_is_super_admin pins user 1's role but a row-level CHECK cannot see other rows, and updateUserRoleHandler happily promoted anyone — while getOwnerUser() was WHERE role='Super Admin' LIMIT 1 with no ORDER BY. Two holders would have made "who owns this server" a question the query plan answered, and that answer feeds the agent sidecar's identity, vault access and origin scoping. Both write paths now refuse the role, the list endpoint offers assignableRoles without it, and getOwnerUser() orders by id.

  • dashboards.id is a global primary key, and ids are slugify(name). Two accounts cannot both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on the table is a composite PK (user_id, id) — it matches the uq_dashboards_user_id index already there and keeps every stored ws-layout-<id> address valid, which uuid ids would not. Note the drizzle composite-PK re-diff quirk in databases/CLAUDE.md. Full analysis in docs/workspace-panel-todo.md §3.

  • permissions/authorize.ts has no automated tests. registry.test.ts covers the pure registry functions and the totality check; the resolver that does the owner bypass, the grant lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file standing between a Member and a shell.

  • assertPermissionTotality checks the wrong list, and registry.test.ts has been red since 2026-08-13. It is fed Object.keys(handlers) from server.tsx, but Bun serves the route table. Those diverged when the cliamp/desktop/vault plugins were switched off: /api/cliamp/ws and /api/cliamp/audio/ws are still live routes with their handlers and registry claims commented out. Not exploitable — isWsProviderAllowed finds no permission and 403s a member; the owner upgrades onto a dead socket. But the boot check that exists to stop exactly this cannot see it. Two fixes: point totality at the route table, and either delete the dead routes or restore their claims. The 8 failing tests in registry.test.ts are the same drift — REAL_WS still lists all nine providers as served, which is why nobody noticed. Found 2026-08-14.

  • No empty state for a denied screen. A member who reaches a route their role lacks gets a broken panel or an endless spinner rather than a clean refusal.

  • getOwnerHomeDir(email) ignores its argument whenever HOME_DIR is set, which it is here — every caller resolves to the owner's real login home. Safe only because all seven callers sit behind execution permissions. If per-user home confinement is ever attempted, this is the function to start from.

  • pty, vault and opencode receive no identity at all. Every other sidecar validates X-Officer-User. The pty sidecar keys purely on a sessionId from the query string and its /_officer/sessions endpoints list and kill every session on the box; vault and opencode take no user argument. All three are covered today only because terminal, vault and the agent are owner-only permissions — that is a correct outcome resting on the wrong layer, and it is the thing to fix first if any of them is ever granted.

  • Radicale is configured type = owner_only (sidecar/caldav/radicale.ts:54) while the caldav sidecar itself is fully per-user and confines every JSON read to /dav/<userId>/. The platform side is ready for members; the CalDAV server underneath is not.

  • The music library is one global index. sidecar/music/indexer.ts reads HOME_DIR and serves every account from it. Favourites, playlists and now-playing are per-user. Deliberate for now (one household, one library) but worth stating rather than discovering.

  • markInterruptedJobs() and getOldestPendingJob() are platform-wide. The pipeline queue is a single global lane; ownership is enforced one layer up, in pipeline-jobs-routes.ts, by an explicit job.userId !== user.id → 404 on every by-id route. Correct today, but the queue itself has no notion of whose work it is running.

  • 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

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

    • 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.
  • 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).
  • 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.

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

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

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

  • User rename — was already shipped end to end (/users/:id/rename, UsersView); this entry was stale when it was written.

  • Polling — useHeadscaleNodes has had refetchInterval: 20_000 all along; also stale.

  • Device invites, platform side (COMMS/OFFSCALE_INVITE_ENROLLMENT.md §5, built 2026-08-05). Authorize-new-device form, the one-time link with copy/share/QR, and the invite list with revoke — /_officer/enroll/invites in invites.ts, InvitesView. The records are not Officer's. They proxy to the server's Officer Companion, because the joining phone has to claim without an Officer account and this sidecar is loopback-only behind our auth; the spec's own "an invite must work when the platform is down" argument says the same. Officer stores no invite and no claim token. Live on all four servers since 2026-08-05. The 401 that blocked it was a companion-side prefix parse (a Headscale key prefix is a fixed 12 chars and may contain -; they split on the first one); fixed upstream. Verified end to end against pastilhas-eu: create returns the officer-offscale://join#… link, list shows the record, revoke flips it to revoked. The list envelope is not in the spec, so pickInvites takes the body's array whatever it is keyed under. Four gaps were sent back to the spec author: keys.ts has no sub-day key TTL for the 5-minute claim key, "the sidecar must refuse plaintext" is unenforceable behind nginx, tailnet is not a headscale concept and has to be recorded on the invite, and /api/v1/enroll/* collides with headscale's own namespace.

Workspaces & Panels

Its own list — docs/workspace-panel-todo.md — because it is long and actively worked. Full analysis behind it: COMMS/workspace-panel-framework-analysis-2026-08-07.md.

Headlines: the framework has no panel lifecycle (a panel can be destroyed but never told, which is the real cause of the terminal orphan leak); three state-key families are silently dropped on every write, orphaning a pty per reload; Running Shells has 404'd since 2026-07-31; there are zero error boundaries anywhere in the repo; and dashboards.id is a global primary key fed by slugify(name), so two members naming a dashboard the same thing collide.

  • Renaming a panel key is a silent data migration, and the repair is destructive. A saved layout holds appType strings. normalizeLayout replaces any appType not in the allow-list with the caller's fallback, and WorkspaceView.tsx:97 writes that back — so a renamed key turns every saved layout into N copies of whichever panel is allowed[0], permanently, on one page load. Renaming headscale-* → offscale-* did exactly this: /offscale rendered three server pickers, ServersView never mounted, and it survived every restart because the code was correct and the data was not (2026-08-17, ed3806f9).

    Plugin screens are now cleared at install, which covers plugins and nothing else. The general fix
    is a rule about wholesale mismatch: **a layout in which NO panel has a recognised appType is
    residue from older code — discard it and use the screen's default**, rather than collapsing it.
    `fallback: allowed[0]` is arbitrary (it is whatever `panels.ts` happens to list first) and should
    probably not be a per-panel substitution at all.
    

Code editor

  • The owner wants to go deeper here — ideas pending (noted 2026-08-15). Raised right after the file browser gained its first links into /code-editor. Do this after the file-browser test pass.

    What is worth knowing before starting, all established on 2026-08-15:
    
    **It has been effectively invisible for six months.** The app landed 2026-02-21 (`9e9acd96`), the
    screen 2026-02-17, and `1fa3a659` on 2026-07-25 is titled "**restore** the code editor screen" — so
    it was dropped and brought back at least once. It IS in `CORE_DOCK_ITEMS` as "Editor", but
    `DEFAULT_DOCK_PATHS` is `['/', '/files', '/terminal', '/dashboards', '/chat']` and the owner's own
    `dock_configs` row does not list it either. Until today the only way in was typing the URL.
    
    **So it has no users, and that is the risk.** A surface nobody reached for six months is where things
    rot quietly. Nothing about the editor itself has been verified — whether saving works, how the file
    tree behaves, what closing the last tab does, what an unsaved-changes navigation does. Only the
    plumbing was checked: `?file=` is read (`EDITOR_FILE_PARAM`), `/code-editor` renders with `urlState`,
    and `CodeEditor.tsx:60-76` fetches a path that arrives in the URL rather than only matching already
    open tabs.
    
    **One bug of exactly this kind was already found and fixed** in the FileViewer's editor on the same
    day: the Edit toggle was gated on `content` rather than `content !== null`, so an empty file — the
    one "New file" produces — could not be edited at all. Assume siblings.
    
    **Open question, deliberately not decided:** `Open in editor` in the file browser's row menu still
    navigates to `/code-editor`, which is the last action that leaves the browser. Editing now happens
    in place via double-click → the viewer's pane. Either `/code-editor` earns its keep as a genuinely
    different tool (tree, tabs, multi-file) or that menu item should go.
    

Files app — routing

The URL model is done (2026-08-15). The folder is the pathname — /files/archive/Tests/platform/docs — and ?view= is a NAME within it, not a second copy of the path. Deep links and refresh work, which they never had. Landed across 559560de, 98ba604a, 7e31564c, 0dc88b51, 7359af2f, with files-route.ts + 23 tests as the one owner of encoding and of which params belong to the overlay.

Four bugs came out of that work and are fixed: closing a pane sent you home; ?view= was stripped on every page load by a mount effect that had made deep links impossible since 2026-02-23; navigating kept a pane belonging to the folder you left; and the breadcrumb opted out of that last rule because it is a <Link> and never called the function enforcing it.

  • Rows are still not links. This is the half that did not get done, and it is the original finding. Both list and grid render <div onDoubleClick> (FileItem.tsx:701,748), and search results render <div onClick> (FileViewContainer.tsx:187) — the "opaque click" that docs/navigation-audit.md names. No cmd-click into a new tab, no middle-click, not link-focusable, and the target lives in a closure rather than the DOM.

    It is now much easier than it was: `folderHref(path)` gives a folder's URL and `hrefForPath` gives
    a full `To` with the overlay already stripped, so a row becomes
    `<Link to={hrefForPath(childPath)}>` and a file row becomes a link that sets `?view=<name>`. The
    breadcrumb has done exactly this since before today and is the model.
    
    Watch for: a row is also a click target for SELECTION (single click selects, double opens,
    shift/cmd extend). An anchor changes what those gestures mean by default, so the selection
    handlers have to keep working and `preventDefault` where they win.
    
  • The acts that clear the overlay have no owner, only the list does. VIEWER_PARAMS and withoutViewerParams live in files-route.ts and are shared. But every site still decides FOR ITSELF whether to call them, which is precisely how the breadcrumb shipped wrong an hour after the rule was written. Three bugs in a row came from this. A single navigateToFolder() that every caller must go through — rather than a helper they may remember — is the fix if a fourth appears.

  • A panel's folder is still not addressable. urlPath is true only on the /files screen; panels keep the path in local state, so they always open at their base and cannot be linked or restored. Deliberate as written — a dashboard can hold two browsers and one URL cannot serve both — but worth re-deciding rather than inheriting.

Plugin repos — what writing their READMEs turned up

Three READMEs were written on 2026-08-16 (music, offscale, example) and pushed to their own repos. Writing them meant reading each plugin against its own documentation, which is the cheapest audit there is. Everything below was found that way and none of it is fixed.

Offscale is still branded headscale in shipped, user-visible places.

  • web/panels.ts registers three panels named "Headscale servers", "Headscale", "Headscale" — these are what a user reads in the panel picker.
  • web/OffscaleServerPicker.tsx:26 renders the literal heading Headscale.
  • web/ConsoleView.tsx:22 mints terminal session ids as headscale-console-<id>. That is persisted state rather than a comment, so renaming it orphans live console sessions — cheap now, less so later.
  • ~12 stale /headscale route paths in comments across web/, plus App.tsx:78 in the platform.
  • PLUGIN.md has ~11 stale self-references (headscale_servers, listHeadscaleServers, permission key headscale, /api/headscale, panel keys, useHeadscaleSection()).

Documentation that actively lies.

  • plugins/offscale/PLUGIN.md:445-449 says the ACL assistant is deliberately unwired and must not be wired up. It IS wired — sidecar/assist.ts, claude-proxy.ts, web/PolicyAssistant.tsx and the manifest's readOnlyWrites all ship.
  • plugins/offscale/OFFSCALE_API.md claims to publish the contract "in full" and omits six live surfaces, including all of /_officer/companion/* and /_officer/enroll/invites.
  • plugins/music/MUSIC_API.md says favourites and now-playing are served by the platform from Postgres; the sidecar serves them itself, scoped by X-Officer-User. It also documents the playlist item key as <albumRel>/<file> while the schema and favourites use the home path — the same file contradicts itself.
  • plugins/music/MUSIC_API.md documents now-playing as one snapshot per user with no mention of ?device=, which the schema makes part of the primary key. A mobile client written to that doc is correct only by accident.
  • plugins/example/manifest.ts:12 lists web/Router.tsx as the frontend convention; discovery requires web/panels.ts AND web/layout.ts. The same file documents an ownerOnly field that does not exist and never did.
  • The Companion contract docs the offscale code cites (COMMS/OFFSCALE_COMPANION_API.md, COMMS/OFFSCALE_INVITE_ENROLLMENT.md) exist nowhere — COMMS/ was deleted. Two of the plugin's eight sections depend on a service with no published spec.

Conventions a plugin author has to guess. All verified in discover.ts/mount.ts, none documented outside the example's new README:

  • Panel keys and layout appTypes must agree, and a mismatch fails SILENTLY — PluginScreen.tsx pins appTypes.allowed with fallback: allowed[0], so a typo renders the first panel.
  • The <appName>_ table prefix is unenforced. The barrel is export * across every plugin into one public schema, so two plugins choosing the same unprefixed name collide with no error until push.
  • A manifest is module-cached for the process lifetime: editing it does nothing until pm2 restart officer, and outdated cannot see a version bump either.
  • Permission-key prefixing is convention, not validation. Only a collision with a CORE key is refused, silently, at mount, with a console error.

Genuinely missing.

  • plugins/offscale spawns the ssh binary (sidecar/ssh.ts) but declares no osDependencies, so install never checks for it and the Console fails at runtime instead of refusing at install. PLUGIN.md:421 already lists it as a host dependency — the manifest is the file that is wrong.
  • scripts/reindex-music.ts resolves its port file from process.env.DATA_PATH ?? cwd/data, and DATA_PATH stopped being an env var on 2026-08-13. Run from the repo it looks in platform/data/music/.server and never finds it. One line.
  • plugins/music/web/MusicPlayerHost.tsx header says it is mounted once in the persistent DashboardLayout; it moved into MusicDetail. That is exactly the comment that would mislead someone about why playback stops off-route.
  • A plugin's assets/icon.png is only published while installed, so its real icon is invisible in the store before install. The catalogue's iconUrl works around it, and an author has to know to add one.

Waiting on the owner to run it

  • Plan mode is built and has never executed. Landed 1b811dfa on 2026-08-17, typechecks, unit tested at the translation layer — and not one line of it has run on a machine. The owner was away when it was finished and asked to be reminded, which is what this entry is.

    **Restart BOTH:** `pm2 restart officer` and `pm2 restart officer-claude-code`. The sidecar holds
    every interesting part (the gate, the pending-plan map, the mode control request); restarting only
    the server leaves the old sidecar running with no `canUseTool` at all, and the toggle would then
    appear to work while changing nothing. Note the server is ALSO carrying two earlier unrestarted
    changes — the tier-less-model fix (`773f0e1c`) and the sessions-list work.
    
    **Then, in `/chat`, in this order:**
    
    1. The clipboard icon by the model picker lights up, and `pm2 logs officer-claude-code` says
       `[claude:plan:<key>] plan mode on`.
    2. Ask for something that needs a plan. The agent researches and does NOT edit — a `Write` in plan
       mode should reach neither the disk nor the gate.
    3. The plan card renders with Approve / Reject, and **the turn is genuinely parked** while it sits
       there (the spinner stays; that is correct, an agent is blocked on a promise in the sidecar).
    4. Approve → the agent continues *in the same turn*, and `plan mode off` follows immediately rather
       than at turn end. Reject with a reason → it revises and asks again.
    5. Refresh mid-plan → the card replays with its decision, rather than a conversation that stops dead.
    6. Pick `claude-code/opusplan` with plan mode OFF → the toggle's tooltip says it is resting on Sonnet.
    
    **The one failure worth watching for** is a turn that never ends after a plan is answered. That would
    mean a `canUseTool` promise was left unresolved — the agent waits forever and the stall watchdog
    eventually tears the session down. `settleAllPlans` runs on every teardown path plus a 20-minute
    timeout to make it impossible, and it is the part with no test behind it.
    
    Design decisions and the verification they came from: `docs/chat-plan-mode.md`.
    

Known bugs

  • Background job logs appeared INSIDE an open file in the code editor. Reported 2026-08-16 by the owner, on the production host, running as a member with a bun dev background job streaming. The editor had a file open; the runner's log output was rendered into that file's buffer. It was never saved — the file on disk was intact, and the editor was not marked dirty as far as the report goes. So this is a DISPLAY-layer mixing, not a write path: the bytes reached a buffer that only exists in the browser.

    Not reproduced, not diagnosed, and deliberately not guessed at further than this. What is worth
    recording is where the two streams could meet, because both were added in the same week and they are
    the only two things writing text into a panel:
    
    - `useTaskDetail` polls `GET /chat/tasks/:id` every 2s while a job runs and returns `{kind:'log', text}`.
      `CodeSurface` renders it — and `CodeSurface` is shared with the editor's own surfaces.
    - The editor keys open files by PATH (`useEditorState`), while the task panel keys by TASK ID, and
      several panel-scoped stores in between are keyed by `panelId` through `useGlobal` /
      `usePanelMemory`. A key collision between those namespaces would produce exactly this: one panel's
      text arriving in another's buffer, with no server involvement and nothing persisted.
    
    **Screenshot 2026-08-16 answers the first question and kills two theories.** The tab carries a DIRTY
    dot, and the log lines have Monaco line numbers continuing the file's own sequence (…180, then eight
    lines of dotenv/POSTGRES_URL output, then 189 `</Link>`). So the MODEL was mutated — this is the
    editor's content state, not its render, and the earlier "the file was okay" means it was simply never
    saved. Note the splice is mid-file, not a replacement: JSX text and log text share line 180
    (`Forgot?--env-file=.env --watch src/server.tsx`).
    
    Ruled out by that: `CodeSurface` renders with shiki, not Monaco, so the two cannot share an editor
    instance. And the app's only other Monaco mount (`FileViewer/renderers/EditorRenderer.tsx`) passes no
    `path`, so its model URI is `inmemory://model/N` and cannot collide with the editor's `file:///…`.
    
    So the pollution arrives as TEXT through the editor's own content path — `openFile`, `setContent`, or
    the end-of-turn refresh that re-reads every clean file. That last one is the prime suspect: it is the
    only thing that writes a buffer without a keystroke, it runs exactly when a background job is
    producing output, and it lands its result by path.
    
    **Narrowed 2026-08-16, and a trap is now set.** `reloadFile` sets `originalContent` and clears
    `isDirty`, so the end-of-turn refresh CANNOT produce the dirty dot in the screenshot — the refresh
    path is out. Nothing in the app injects text into the page either: no `execCommand('insertText')`,
    no synthetic keyboard events, no clipboard writes near the editor. That leaves Monaco's own change
    event, which means it looks like a paste or a programmatic `executeEdits`.
    
    **`docs/editor-buffer-corruption.md` is the procedure — read that when someone reports this.** A
    trap is set: `reportSuspiciousEdit` in CodeEditor.tsx records any insertion over 200 characters
    arriving while the editor is NOT focused, with the stack at that moment, into
    `localStorage['officer:editor-buffer-trap']` (last five). It persists rather than only logging
    because this arrives as a report hours later, when the console is long gone — the question becomes
    "paste this key" instead of "what did you see?". Delete all of it once the cause is known.
    
    One thing to rule out first, because it is mine and it is recent: `2b70a570` gave `<Editor>` a real
    `path`, so models are now keyed by file URI globally rather than being per-mount `inmemory://`. Two
    panels open on the same file now share one model. That should be an improvement, but it is a change
    in exactly the layer that turned out to be involved.
    
    Worth noting because it is the reason to look at all: an editor that can show content the file does
    not contain is a correctness problem even when it never writes — the next person's instinct will be to
    save the file to "fix" it.
    
  • bootstrap.ts runs npm install -g for Pi on every boot. findPiPackageDir checks stale paths and an outdated package name, so installPi always fires and floods the logs with EEXIST noise. Should detect @earendil-works/pi-coding-agent at the real npm prefix.

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