todo: one file per item, grouped by theme, with a generated index

TODO.md had reached 1050 lines and 72 open items across 28 sections, read mostly
on a phone. It is now a directory: todo/<theme>/<item>.md, one card per file, with
frontmatter carrying id, status, created date and order, and the prose body copied
across verbatim. TODO.md becomes a generated index (bun run todo:index).

Dates: where an item's prose stated one it is used; otherwise its old section's;
otherwise 2026-08-20, floored at the day before the split rather than invented.
date_source records which.

Shaped for a two-way Kanban board, filed as todo/tooling — a card is a file, id is
the identity, frontmatter is the machine's half and the body is the owner's.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 05:42:39 +00:00
co-authored by Claude Fable 5
parent 8fa560df1b
commit 6071ded175
124 changed files with 2240 additions and 1043 deletions
+60 -1043
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -11,6 +11,7 @@
"preinstall": "node -e \"var v = +process.versions.node.split('.')[0]; if (v < 22) { console.error('Node 22 or newer required (got ' + process.versions.node + '). Run: nvm use 22'); process.exit(1); }\"",
"gen:index": "bun run ./scripts/gen-index.ts",
"fix:env": "bun run ./scripts/fix-env.ts",
"todo:index": "bun run ./scripts/todo-index.ts",
"nudge": "bun run --env-file=.env ./scripts/nudge-agent.ts",
"doctor": "bun run --env-file=.env ./scripts/doctor.ts",
"secret:set": "bun run --env-file=.env ./scripts/secret-set.ts",
+126
View File
@@ -0,0 +1,126 @@
/**
* Regenerate `TODO.md` — the index over `todo/`.
*
* The index is the one file everybody reads, so it is the one most able to lie. It is generated rather
* than maintained: `bun run todo:index` after any change to `todo/`, and it cannot drift from the tree
* it describes.
*/
import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs';
import { join } from 'node:path';
const ROOT = new URL('..', import.meta.url).pathname;
const OUT = join(ROOT, 'todo');
export type Item = {
id: string;
title: string;
theme: string;
status: string;
created: string;
order: number;
file: string;
};
/** Minimal frontmatter reader — `key: value`, values optionally JSON-quoted. No YAML dependency. */
export function frontmatter(text: string): Record<string, string> {
const end = text.indexOf('\n---', 4);
if (!text.startsWith('---') || end === -1) return {};
const out: Record<string, string> = {};
for (const line of text.slice(4, end).split('\n')) {
const m = line.match(/^([a-z_]+):\s*(.*)$/);
if (!m || !m[1]) continue;
const raw = (m[2] ?? '').trim();
out[m[1]] = raw.startsWith('"') ? (JSON.parse(raw) as string) : raw;
}
return out;
}
export function readTree(): { themes: { theme: string; heading: string; items: Item[] }[] } {
const themes = readdirSync(OUT)
.filter((d) => statSync(join(OUT, d)).isDirectory())
.sort()
.map((theme) => {
const files = readdirSync(join(OUT, theme)).filter((f) => f.endsWith('.md'));
const meta = frontmatter(readFileSync(join(OUT, theme, '_index.md'), 'utf8'));
const items = files
.filter((f) => f !== '_index.md')
.map((f) => {
const fm = frontmatter(readFileSync(join(OUT, theme, f), 'utf8'));
return {
id: fm.id ?? `${theme}/${f.replace(/\.md$/, '')}`,
title: fm.title ?? f,
theme,
status: fm.status ?? 'open',
created: fm.created ?? '',
order: Number(fm.order ?? 0),
file: `todo/${theme}/${f}`,
};
})
.sort((a, b) => a.order - b.order);
return { theme, heading: meta.heading ?? theme, items };
});
return { themes };
}
const HEADER = `# TODO — the index
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 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 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.
`;
function render(): string {
const { themes } = readTree();
const open = themes.reduce((n, t) => n + t.items.filter((i) => i.status !== 'done').length, 0);
const done = themes.reduce((n, t) => n + t.items.filter((i) => i.status === 'done').length, 0);
const rows = themes
.filter((t) => t.items.length)
.map((t) => {
const o = t.items.filter((i) => i.status !== 'done').length;
const d = t.items.length - o;
return `| [${t.heading}](todo/${t.theme}/) | ${o} | ${d} |`;
});
const recent = themes
.flatMap((t) => t.items)
.filter((i) => i.status !== 'done')
.sort((a, b) => b.created.localeCompare(a.created))
.slice(0, 10)
.map((i) => `- \`${i.created}\` **${i.title}** — [${i.id}](${i.file})`);
// A theme holding only prose has no row above, and an entry nobody can see is the failure mode this
// whole split risks. List them explicitly rather than letting them age out of view.
const notes = themes.filter((t) => !t.items.length).map((t) => `- [${t.heading}](todo/${t.theme}/_index.md)`);
return [
HEADER,
`## Themes\n`,
`**${open} open, ${done} closed**, across ${rows.length} themes.\n`,
`| Theme | Open | Closed |`,
`| --- | --- | --- |`,
...rows,
``,
`## Records — no items, kept for the reasoning\n`,
...notes,
``,
`## Newest ten\n`,
...recent,
``,
].join('\n');
}
if (import.meta.main) {
writeFileSync(join(ROOT, 'TODO.md'), render());
console.log('TODO.md regenerated');
}
+66
View File
@@ -0,0 +1,66 @@
# `todo/` — the format
One item per file. A file is a card. That is the whole idea, and everything below follows from it.
It replaced a single 1,050-line `TODO.md` on 2026-08-21, which had reached 72 open items across 28
sections and was read mostly on a phone. The split was mechanical — no prose was rewritten.
## Layout
```
todo/
├── README.md # this file
├── <theme>/
│ ├── _index.md # the theme itself: display name, and any prose that belongs to the whole group
│ └── <item>.md # one item
```
The theme is the directory. `_index.md` carries the theme's display heading, so a theme is
self-describing and nothing has to hold a slug→name map. `TODO.md` at the repository root is the
generated index over all of it — `bun run todo:index`.
## An item
```markdown
---
id: fleet/an-orchestrator-that-acts-on-every-server-at
title: 'An orchestrator that acts on every server at once'
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**An orchestrator that acts on every server at once** — pull and update every platform, restart a
process everywhere, report which host is behind…
```
- **`id`** — `<theme>/<slug>`, and it is the filename. Stable: rename the title freely, leave the id
alone. Anything pointing at an item points at this.
- **`status`** — `open` or `done` today. The board will want more (`doing`, `blocked`); adding one is a
new value here, not a new file or a new directory.
- **`created`** — the date the item was raised. **`date_source`** says how much to trust it:
`stated` (the prose named a date — true), `section` (inherited from its old section heading — close),
`assumed` (unknown, floored at 2026-08-20, the day before the split — a bounded "before today"
rather than an invented day).
- **`order`** — position within the theme, preserved from the original list. What a board reorders.
- **The body is prose, and it is the point.** These entries carry the reasoning, what was verified
versus assumed, and what was already tried. An item stripped to a title loses the thing that made
this list worth keeping.
## Two-way sync — the contract for the Kanban board
A board that reads this tree and writes back to it is a wanted feature
(`todo/prompt-library/`… see `todo/tooling/`). What it may rely on:
- **A card is a file.** Creating a card creates a file; moving a card between columns rewrites one
frontmatter field; deleting a card deletes a file. Every board action is a small, reviewable git diff.
- **`id` is the identity, not the title and not the line number.** Renaming or rewording never orphans a
card.
- **Frontmatter is the machine's half, the body is the human's half.** A board may rewrite frontmatter
freely. It must not reformat a body it did not change — the prose is hand-written and a normalising
pass would produce a diff nobody can read.
- **`TODO.md` is generated output.** A board must never write to it; it regenerates from the tree.
- **Git is the sync log.** No lock file and no sidecar state: the file on disk is the truth, and two
writers are resolved the way every other conflict in this repository is.
+49
View File
@@ -0,0 +1,49 @@
---
theme: affordances-runtime
heading: "Affordances (2026-08-19)"
kind: theme
---
Agreed and written down in `plugins/CLAUDE.md`; **nothing is built**. An affordance is what the surface
on the other end offers the agent — declared by the code that implements it, read at runtime. The first
two are image rendering (core) and gitea (`git` for git, `tea` for everything else).
Built 2026-08-19: `servers/affordances/load.ts` reads the tree and compiles one block, and
`claude-manager.ts` appends it to every Claude session via
`systemPrompt: { type: 'preset', preset: 'claude_code', append }`. Read per session, so a plugin
installed an hour ago is live on the next message.
Still to build:
- `capabilities/affordances/` created by the setup script, with `officer/core/` populated from an
officer-capabilities repository. The directory exists on this machine by hand; a fresh install gets
nothing, so the first affordance is currently a local artefact rather than a shipped one.
- **Opencode delivery.** It has its own driver and gets nothing, so image rendering is currently a claim
that holds on one harness — exactly the drift affordances exist to remove. Deliberate: opencode work is
paused pending its v2 migration and this would be thrown away. Revisit when v2 lands.
- A bare `claude` run in a terminal on the host gets nothing either, since it never passes through
`claude-manager`. Would need a generated file, which is the thing `user-instance.ts` refuses to write
for the owner.
- ~~Plugin contribution~~ — built 2026-08-19 (`affordances/publish.ts`). An `affordances/` directory in
the plugin repository is copied to `capabilities/affordances/<publisher>/<plugin>/` on install and
removed on uninstall. Gitea is the first to ship one.
- **The other categories.** A plugin can contribute affordances and nothing else. Skills, tools, tasks
and processes are the obvious next ones and were deliberately left out — one mechanism, proven, before
five. When they land, `publishAffordances` generalises rather than gaining siblings.
- **`officer/core` and `officerdev/<plugin>` are both correct — do not normalise them.** The path uses
`publisher` verbatim, so a plugin published by officerdev lands under `officerdev/`. `officer/core` is
not a plugin at all: it is the PLATFORM's own, shipped by the setup script from the capabilities
repository. Two spellings marking two different things, arrived at by accident and kept on purpose.
Anyone tidying this into one name is removing the distinction, not the inconsistency.
Open, and deliberately deferred:
- **Scoping.** Everything is loaded into every conversation. Fine at two files, not at twenty. A
`surface` field was drafted and dropped because it was doing two jobs at once: for image rendering it
means _what the client renders_ (a phone, a web bubble, a cron run with nobody watching), for gitea
_what tooling exists on this machine_. Those are different axes and one field cannot be both. Decide
the axis before adding the field — and note this codebase has already been bitten by a field that
validated itself and was never consumed (`unauthenticatedPaths`, see `plugins/public-paths.ts`).
- **Drift.** The rule is "declared where implemented", but a hand-authored markdown file in a repository
is not that — `ChatImage` could stop rendering images and no file would notice. Accepted for now
because prose that reaches every harness beats a registry that reaches one; revisit if it bites.
+5
View File
@@ -0,0 +1,5 @@
---
theme: affordances
heading: "Affordances"
kind: theme
---
@@ -0,0 +1,28 @@
---
id: affordances/no-way-to-see-which-affordances-are-reaching
title: "No way to see which affordances are reaching your agents, or to turn one off"
theme: affordances
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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.
+14
View File
@@ -0,0 +1,14 @@
---
theme: agent-delivery
heading: "Speaking into a session from the backend"
kind: theme
---
**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.
@@ -0,0 +1,22 @@
---
id: agent-delivery/generalise-it-deliver-a-turn-to-a-named
title: "Generalise it: deliver a turn to a named session, from anywhere in the backend"
theme: agent-delivery
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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.
@@ -0,0 +1,15 @@
---
id: agent-delivery/the-cost-of-resuming-grows-with-the-transcript
title: "The cost of resuming grows with the transcript"
theme: agent-delivery
status: open
created: 2026-08-21
date_source: stated
order: 2
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: backburner
heading: "Backburner"
kind: theme
---
@@ -0,0 +1,28 @@
---
id: backburner/four-foreign-keys-are-over-postgres-s-63
title: "Four foreign keys are over Postgres's 63-character limit and re-create on every `db:push`"
theme: backburner
status: open
created: 2026-08-19
date_source: stated
order: 1
---
**Four foreign keys are over Postgres's 63-character limit and re-create on every `db:push`.**
Found 2026-08-19 while pushing `chat_notification_prefs`. Not a blocker and not causing trouble:
eight extra statements per push, no prompt, no data touched — the same harmless churn already
documented for `pk_music_now_playing`, and recorded here only so the next person reading a push
plan knows it is not their change.
command_hq_session_tags_session_row_id_command_hq_sessions_id_fk 64
command_hq_watcher_fires_session_row_id_command_hq_sessions_id_fk 65
bitcoin_wallet_frozen_utxos_wallet_id_bitcoin_wallet_wallets_id_fk 66
bitcoin_wallet_chain_cache_wallet_id_bitcoin_wallet_wallets_id_fk 65
The fix is the one `src/databases/CLAUDE.md` already prescribes and these four missed — declare
them with `foreignKey({ name: 'fk_…', … })` under 63 characters, exactly as
`fk_user_integrations_server_integration` was. **Renaming a constraint is not a data change**, but
it does mean one push where the old name drops and the new one is added, so do it deliberately
rather than folded into an unrelated change. Note the two bitcoin-wallet ones live in that
plugin's own repository (`plugins/bitcoin-wallet/db/schema.ts`), so this spans two repos and the
plugin half only churns on a host where that plugin is installed.
+14
View File
@@ -0,0 +1,14 @@
---
id: backburner/music-tagging-feature
title: "Music tagging feature"
theme: backburner
status: open
created: 2026-08-20
date_source: assumed
order: 2
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: background-jobs
heading: "Background jobs"
kind: theme
---
@@ -0,0 +1,18 @@
---
id: background-jobs/a-repeating-prompt-on-the-background-job
title: "A repeating prompt on the background-job starter"
theme: background-jobs
status: open
created: 2026-08-20
date_source: stated
order: 1
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: code-editor
heading: "Code editor"
kind: theme
---
@@ -0,0 +1,36 @@
---
id: code-editor/the-owner-wants-to-go-deeper-here-ideas
title: "The owner wants to go deeper here — ideas pending (noted 2026-08-15)"
theme: code-editor
status: open
created: 2026-08-15
date_source: stated
order: 1
---
**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.
+11
View File
@@ -0,0 +1,11 @@
---
theme: email
heading: "Email"
kind: theme
---
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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: file-watchers
heading: "File watchers"
kind: theme
---
@@ -0,0 +1,45 @@
---
id: file-watchers/watch-a-directory-and-attach-actions-to-what
title: "Watch a directory, and attach actions to what lands in it"
theme: file-watchers
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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.
+16
View File
@@ -0,0 +1,16 @@
---
theme: files-app
heading: "Files app — routing"
kind: theme
---
**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.
@@ -0,0 +1,14 @@
---
id: files-app/a-panel-s-folder-is-still-not-addressable
title: "A panel's folder is still not addressable"
theme: files-app
status: open
created: 2026-08-20
date_source: assumed
order: 3
---
**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.
@@ -0,0 +1,24 @@
---
id: files-app/rows-are-still-not-links
title: "Rows are still not links"
theme: files-app
status: open
created: 2026-08-20
date_source: assumed
order: 1
---
**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.
@@ -0,0 +1,15 @@
---
id: files-app/the-acts-that-clear-the-overlay-have-no-owner
title: "The acts that clear the overlay have no owner, only the list does"
theme: files-app
status: open
created: 2026-08-20
date_source: assumed
order: 2
---
**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.
+8
View File
@@ -0,0 +1,8 @@
---
theme: fleet
heading: "Fleet — one command across every server"
kind: theme
---
**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.
@@ -0,0 +1,31 @@
---
id: fleet/a-way-to-mark-a-commit-this-one-ships-so-a
title: "A way to mark a commit \"this one ships\", so a release is not just `master`'s tip"
theme: fleet
status: open
created: 2026-08-21
date_source: stated
order: 2
---
**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.**
@@ -0,0 +1,25 @@
---
id: fleet/an-orchestrator-that-acts-on-every-server-at
title: "An orchestrator that acts on every server at once"
theme: fleet
status: open
created: 2026-08-20
date_source: assumed
order: 1
---
**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.
+8
View File
@@ -0,0 +1,8 @@
---
theme: headscale
heading: "Headscale"
kind: theme
---
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.
@@ -0,0 +1,12 @@
---
id: headscale/a-node-can-be-moved-between-users
title: "A node can be moved between users"
theme: headscale
status: done
created: 2026-08-20
date_source: assumed
order: 2
---
**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.
+17
View File
@@ -0,0 +1,17 @@
---
id: headscale/acl-policy
title: "ACL policy"
theme: headscale
status: done
created: 2026-08-20
date_source: assumed
order: 1
---
**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.
@@ -0,0 +1,25 @@
---
id: headscale/device-invites-platform-side
title: "Device invites, platform side"
theme: headscale
status: done
created: 2026-08-05
date_source: stated
order: 5
---
**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.
+11
View File
@@ -0,0 +1,11 @@
---
id: headscale/polling
title: "Polling"
theme: headscale
status: done
created: 2026-08-20
date_source: assumed
order: 4
---
**Polling** — `useHeadscaleNodes` has had `refetchInterval: 20_000` all along; also stale.
+12
View File
@@ -0,0 +1,12 @@
---
id: headscale/user-rename
title: "User rename"
theme: headscale
status: done
created: 2026-08-20
date_source: assumed
order: 3
---
**User rename** — was already shipped end to end (`/users/:id/rename`, `UsersView`); this entry
was stale when it was written.
+5
View File
@@ -0,0 +1,5 @@
---
theme: infra
heading: "Infra (alpha)"
kind: theme
---
@@ -0,0 +1,18 @@
---
id: infra/delete-etc-systemd-system-officer-vnc-service
title: "Delete `/etc/systemd/system/officer-vnc.service`"
theme: infra
status: open
created: 2026-07-15
date_source: stated
order: 1
---
**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.
@@ -0,0 +1,16 @@
---
id: infra/move-the-remaining-env-credentials-into-the
title: "Move the remaining env credentials into the secret store"
theme: infra
status: open
created: 2026-08-19
date_source: stated
order: 3
---
**Move the remaining env credentials into the secret store.** `putSecret`/`getSecret` and
`bun run secret:set` landed 2026-08-19, and notify now reads `apns-p8`, `apns-key-id`,
`apns-team-id`, `fcm-service-account` and `discord-webhook` from the store with env as a
fallback. Every other plugin credential is still env-only — same argument applies: bun
auto-loads `.env` into every PM2 process, so each one is readable from `/proc/<pid>/environ`
of twenty processes that have no business with it.
@@ -0,0 +1,33 @@
---
id: infra/nudger-a-control-in-the-chat-composer-beside
title: "Nudger — a control in the chat composer, beside the background-job button"
theme: infra
status: open
created: 2026-08-20
date_source: stated
order: 4
---
**Nudger — a control in the chat composer, beside the background-job button.** Wanted
2026-08-20 while leaving an agent to work overnight: "continue every hour if you have stalled".
`scripts/nudge-agent.ts` does it from outside tonight — it polls `GET /api/chat/sessions?all=1`
and speaks over the chat websocket, exactly as a browser does, so the platform needed no changes.
That is the workaround, not the feature.
**Both things this said the platform lacked have since been built** (verified 2026-08-21): the
recurring trigger is `startNudger()` at `server.tsx:182`, ticking once a minute over a `nudges`
table; the door into a session is `startClaudeTurn({ ws: null })` at `nudger.ts:102`. What is left
is the UI — a control in the chat composer, beside the background-job button — plus generalising
delivery beyond the nudger's own schedule. See "Speaking into a session from the backend".
Design notes worth keeping:
- **Never interrupt work.** Check `isGenerating` immediately before speaking. The point is to
restart a stopped agent, not to hurry a busy one — the script already gets this right.
- **It must outlive what it nudges.** A chat background job is owned by its session, so a nudge
job launched from the session being nudged dies with it — the 30-minute idle GC, a clear, or a
restart takes the watchdog with the agent. Whatever owns the schedule cannot be the session.
- **Bound it.** The script stops after `--max` (12). A loop nobody is watching should end.
- The same trigger would serve `COMMS/command-hq/watcher-loop-proposal.md`, which wants
infrastructure to own a loop rather than a model holding a protocol across turns.
@@ -0,0 +1,26 @@
---
id: infra/revisit-the-frozen-lockfile-rule-in-claude-md
title: "Revisit the frozen-lockfile rule in `CLAUDE.md`, now that plugins resolve at install time"
theme: infra
status: open
created: 2026-08-20
date_source: stated
order: 5
---
**Revisit the frozen-lockfile rule in `CLAUDE.md`, now that plugins resolve at install time.**
Raised by the owner 2026-08-20, to be discussed rather than decided alone.
The rule as written is absolute: `bunfig.toml` sets `frozenLockfile = true`, `bun install` resolves
from `bun.lock` and nothing else, and an unexplained lockfile change in a diff is the signal the
whole arrangement exists to produce. The reasoning is the 2026-08-04 compromise, and it is good.
`npmDependencies` does not follow it. Plugin packages are declared as RANGES and installed with a
plain `bun install` into `plugins/node_modules/`, so they resolve per machine and per install. That
was chosen deliberately — ranges are what let two plugins share one copy of `sharp` — but it means
the codebase now holds two different postures on the same risk, and only one of them is written down.
Worth settling: whether the rule should say "the platform's own dependencies" rather than reading as
universal; whether the merged `plugins/bun.lock` should be committed after all (it would pin the
tree, at the cost of a per-machine file in git); and whether `bun install --frozen-lockfile` in
`plugins/` is achievable once that lockfile exists.
@@ -0,0 +1,14 @@
---
id: infra/ufw-blocks-port-9010-on-the-lan
title: "`ufw` blocks port 9010 on the LAN"
theme: infra
status: open
created: 2026-08-20
date_source: assumed
order: 2
---
**`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`
+73
View File
@@ -0,0 +1,73 @@
---
theme: inputs-outside-forms
heading: "Inputs outside forms (2026-08-19) — swept, fixed, closed"
kind: theme
---
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 `<input>` and
`<textarea>` too, across the platform and every installed plugin — found **87 files with a matching
element and no enclosing `<form>`**. Three were false positives (the `Input`/`Textarea` primitives
themselves, and a `DataTable` test file). Of the remaining 84, **41 were fixed** (real forms, single
fields with an obvious action, or multi-field editors) and **43 were left alone**, almost all because
they're a search/filter box with nothing to submit.
**Fixed — platform (24 files, one commit):** `CapabilityPage`, `ApifyConfig`, `ApiKeys`,
`DavAppPasswords`, `GoogleOAuthConfig`, `AIHarnessesSection` (local-provider connect, its auth step,
and both API-key edit rows), `OCRSection`, `SMTPSection` (split into two independent forms — save vs.
send-test-email, since one form would have made Enter in the test-email field trigger Save),
`STTSection`, `TTSSection`, `AppStoreDetail`, `ChatHeader`, `BackgroundJobDialog`, `QuestionActivity`,
`WebpageDialog`, `DirPickerModal`, `SessionRow`, `CommandHqCategories`, `FileItem`, `NewEntryDialog`,
`TaskRunnerModal` (the Agentic and Pipeline ready phases), `PreviewHeader`, `widgets/DailyGoals`,
`widgets/Pomodoro`.
**Fixed — plugins (17 files, one commit per plugin, each pushed to its own remote):**
`bitcoin-wallet` (`ChainSourceCard`, `RescanCard`, `WalletSettingsView`), `email` (`Compose`,
`EmailAccounts`), `invoices` (`DocumentEditor`, `RecordEditors`, `SendDocumentDialog`), `offscale`
(`NodesView` — rename and tags fields), `soulseek` (`SoulseekConnection`), `transmission`
(`ConnectionView`, `SettingsView`, `AddTorrentDialog`, `MoveTorrentDialog`, `RenameDialog`,
`TorrentOptionsDialog`), `vault` (`VaultPanel`). `jellyfin` and `music` needed no changes.
**Left alone — search/filter boxes with no submit action** (the large majority of the 43): `JobsPage`,
`SettingsPanel`, `SearchInput`, `sounds/Catalog`, `widgets/QuickNotes`, `Dashboards/DashboardListApp`,
`SystemMonitor/DockerView`, `FileBrowser*` toolbar/header/view-container files, `ChatHistory` list
filters, `invoices`' six list views (`CustomersListView`, `EstimatesListView`, `ExpensesListView`,
`InvoicesListView`, `ItemsListView`, `ReportsView`, `components.tsx`), `jellyfin` (`PlayerControls` —
range sliders, not text fields — and `SearchSection`), `music` (`MusicBrowser`, `MusicPlayerHost`),
`offscale` (`Cards`, `PolicyAssistant` — already handles Enter/Shift+Enter correctly, `PolicyView` — a
textarea, never auto-submits), `soulseek` (`SearchResults`, `SharesBrowser`), `transmission`
(`TorrentToolbar`), `FileViewer` renderers (`AudioRenderer`/`VideoRenderer` — volume/seek sliders), and
the main chat composers (`InputArea`, `ChatLauncher`, `SoulseekChat`) — all textareas with their own
Enter-sends/Shift+Enter-newlines handling, where a `<form>` would add nothing and risks regressing that
handling.
**Left alone — genuinely ambiguous, flagged rather than guessed:**
- `TaskRunnerModal`'s `ScriptRunner` ready phase offers three simultaneously-visible, non-equivalent
primary actions ("Run here" / "Queue" / "Run as job"); there's no single canonical action for Enter to
trigger.
- `Dashboards/DashboardPreview`'s `NamePanel` (name + description) has its submit button in a
_different_, independently-mounted panel (`CreatePanel`) elsewhere in the workspace layout tree — a
`<form>` can't span two panels without restructuring the wizard.
- `ColorPicker` and `MarkdownEditor` have zero call sites anywhere in the repo right now; both are
shaped to be embedded in a future caller's own form (uncontrolled inputs with `name` attributes, or a
plain controlled textarea with no submit of its own), so there's nothing to wrap yet.
- `widgets/Weather`'s `LocationSetup` is a live autocomplete with several result rows and no single
obvious Enter target (which of N matches?).
**Noticed, out of scope, not fixed:**
- `ModelSelector`'s provider-picker button has no `type` attribute — harmless today only because it
currently sits outside every `<form>`; a landmine if that layout changes.
- `MusicBrowser`'s filter input uses `(e) =>` instead of the house `ev` convention — pre-existing, not
touched since the file needed no fix.
- `EmailList.tsx`, and a few plugin files touched by this sweep (`VaultPanel`, `ConnectionView`,
`SoulseekConnection`, `ChainSourceCard`), have pre-existing `prettier --check` drift outside the lines
this pass touched — left as found, not reformatted, per "don't reformat what you're not fixing."
Relatedly: **`prettier --write` on a `plugins/<name>/…` path silently no-ops when run from the
platform root**, because Prettier v3 respects the root `.gitignore` and `/plugins/*/` is in it — it
exits 0 with no diff and no error. Formatting a plugin file requires running prettier from inside
that plugin's own directory (still resolves the platform's `.prettierrc` by walking up).
- A few "Save"/"I've saved it"-style buttons (`ApiKeys`, `DavAppPasswords`, `DirPickerModal`) have no
`type` attribute but currently sit outside any `<form>`, so it's harmless today — worth remembering if
a future edit wraps that region.
+5
View File
@@ -0,0 +1,5 @@
---
theme: known-bugs
heading: "Known bugs"
kind: theme
---
@@ -0,0 +1,65 @@
---
id: known-bugs/background-job-logs-appeared-inside-an-open
title: "Background job logs appeared INSIDE an open file in the code editor"
theme: known-bugs
status: open
created: 2026-08-16
date_source: stated
order: 1
---
**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.
@@ -0,0 +1,13 @@
---
id: known-bugs/bootstrap-ts-runs-npm-install-g-for-pi-on
title: "`bootstrap.ts` runs `npm install -g` for Pi on every boot"
theme: known-bugs
status: open
created: 2026-08-20
date_source: assumed
order: 2
---
**`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.
@@ -0,0 +1,17 @@
---
id: known-bugs/vnc-mirror-can-orphan-duplicate-x11vnc-across
title: "VNC mirror can orphan/duplicate x11vnc across sidecar restarts"
theme: known-bugs
status: done
created: 2026-08-01
date_source: stated
order: 3
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: mobile-apps
heading: "Mobile apps"
kind: theme
---
@@ -0,0 +1,20 @@
---
id: mobile-apps/iphone-action-button-run-an-officer-task-from
title: "iPhone Action Button → run an Officer task from a locked phone"
theme: mobile-apps
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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.
@@ -0,0 +1,34 @@
---
id: mobile-apps/the-mobile-recorder-app-and-the-dispatcher
title: "The mobile recorder app, and the dispatcher agent behind it"
theme: mobile-apps
status: open
created: 2026-08-21
date_source: stated
order: 2
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: multi-user
heading: "Multi-user"
kind: theme
---
@@ -0,0 +1,17 @@
---
id: multi-user/a-plugin-s-settings-routes-are-reachable-by
title: "A plugin's settings routes are reachable by anyone the plugin is granted to"
theme: multi-user
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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`.
@@ -0,0 +1,17 @@
---
id: multi-user/a-second-super-admin-was-storable-and-made
title: "A second Super Admin was storable, and made the owner nondeterministic"
theme: multi-user
status: done
created: 2026-08-11
date_source: stated
order: 9
---
**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.
@@ -0,0 +1,15 @@
---
id: multi-user/agent-sessions-are-not-durable-and-it-is-one
title: "Agent sessions are not durable, and it is one property behind three symptoms"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 5
---
**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.
@@ -0,0 +1,19 @@
---
id: multi-user/assertpermissiontotality-checks-the-wrong
title: "`assertPermissionTotality` checks the wrong list, and `registry.test.ts` has been red since 2026-08-13"
theme: multi-user
status: open
created: 2026-08-13
date_source: stated
order: 12
---
**`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.
@@ -0,0 +1,16 @@
---
id: multi-user/cross-user-writes-in-the-notify-sidecar
title: "Cross-user writes in the notify sidecar"
theme: multi-user
status: done
created: 2026-08-07
date_source: stated
order: 19
---
**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.
@@ -0,0 +1,16 @@
---
id: multi-user/dashboards-id-is-a-global-primary-key-and-ids
title: "`dashboards.id` is a global primary key, and ids are `slugify(name)`"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 10
---
**`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.
@@ -0,0 +1,18 @@
---
id: multi-user/deprovisionosaccount-is-written-but-has-never
title: "`deprovisionOsAccount` is written but has never run against a real account"
theme: multi-user
status: open
created: 2026-08-12
date_source: stated
order: 2
---
**`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.
@@ -0,0 +1,14 @@
---
id: multi-user/getownerhomediremail-ignores-its-argument
title: "`getOwnerHomeDir(email)` ignores its argument"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 14
---
**`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.
@@ -0,0 +1,14 @@
---
id: multi-user/markinterruptedjobs-and-getoldestpendingjob
title: "`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 18
---
**`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.
@@ -0,0 +1,12 @@
---
id: multi-user/no-empty-state-for-a-denied-screen
title: "No empty state for a denied screen"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 13
---
**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.
@@ -0,0 +1,17 @@
---
id: multi-user/no-way-to-create-a-second-account
title: "No way to create a second account"
theme: multi-user
status: done
created: 2026-08-11
date_source: stated
order: 7
---
**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.
@@ -0,0 +1,14 @@
---
id: multi-user/permissions-authorize-ts-has-no-automated
title: "`permissions/authorize.ts` has no automated tests"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 11
---
**`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.
@@ -0,0 +1,16 @@
---
id: multi-user/processtransport-is-not-ready-for-writing
title: "`ProcessTransport is not ready for writing` — survivable since `8c4f150`, still unexplained"
theme: multi-user
status: open
created: 2026-08-11
date_source: stated
order: 6
---
**`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.
@@ -0,0 +1,16 @@
---
id: multi-user/pty-vault-and-opencode-receive-no-identity-at
title: "`pty`, `vault` and `opencode` receive no identity at all"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 15
---
**`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.
@@ -0,0 +1,13 @@
---
id: multi-user/radicale-is-configured-type-owneronly
title: "Radicale is configured `type = owner_only`"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 16
---
**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.
@@ -0,0 +1,14 @@
---
id: multi-user/remove-dead-username-plumbing
title: "Remove dead `username` plumbing"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 20
---
**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.)
@@ -0,0 +1,14 @@
---
id: multi-user/still-no-invite-flow-and-no-password-reset
title: "Still no invite flow, and no password reset for a member"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 8
---
**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.
@@ -0,0 +1,13 @@
---
id: multi-user/the-music-library-is-one-global-index
title: "The music library is one global index"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 17
---
**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.
@@ -0,0 +1,14 @@
---
id: multi-user/the-terminal-replays-terminal-queries-which
title: "The terminal replays terminal QUERIES, which get typed into the shell"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 3
---
**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.
@@ -0,0 +1,15 @@
---
id: multi-user/the-web-terminal-renders-a-long-url-as
title: "The web terminal renders a long URL as unreadable fragments"
theme: multi-user
status: open
created: 2026-08-20
date_source: assumed
order: 4
---
**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.
+5
View File
@@ -0,0 +1,5 @@
---
theme: notifications
heading: "Notifications"
kind: theme
---
@@ -0,0 +1,16 @@
---
id: notifications/a-push-payload-does-not-say-which-server-sent
title: "A push payload does not say which server sent it"
theme: notifications
status: open
created: 2026-08-20
date_source: assumed
order: 1
---
**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`.
+7
View File
@@ -0,0 +1,7 @@
---
theme: plugin-ecosystem
heading: "Plugin ecosystem — before it opens to third parties (2026-08-18)"
kind: theme
---
Not now. Months away. Recorded so it is not re-derived.
@@ -0,0 +1,22 @@
---
id: plugin-ecosystem/a-plugin-install-is-not-pinned-so-a-review
title: "A plugin install is not pinned, so a review would bind a repository rather than code"
theme: plugin-ecosystem
status: open
created: 2026-08-04
date_source: stated
order: 1
---
**A plugin install is not pinned, so a review would bind a repository rather than code.**
`fetch.ts:70` is `git clone --depth 1 <source>` — no ref — so it takes whatever the default branch
points at when the install runs. A plugin reviewed at commit X installs at whatever was pushed
since, and `--depth 1` leaves no history to compare against.
This is the shape of the npm attack already documented in `CLAUDE.md` (2026-08-04), and the same
shape as the Chrome Web Store's real failures — not bad extensions passing review, but good ones
sold or compromised and then pushing an update to users who had already accepted them.
Fix: a `reviewedCommit` on the catalogue entry, checkout detached at that SHA, and **verify
`rev-parse HEAD` matches or refuse**. The verification is the part that matters. An update then
becomes "a new reviewed SHA exists" — a catalogue change rather than something the author triggers.
@@ -0,0 +1,13 @@
---
id: plugin-ecosystem/no-install-from-git-url
title: "No install-from-git-URL"
theme: plugin-ecosystem
status: open
created: 2026-08-18
date_source: section
order: 3
---
**No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the
"build your own, give it to a friend" path has no affordance. Small, and needed before the
unofficial channel is real.
@@ -0,0 +1,17 @@
---
id: plugin-ecosystem/third-party-plugin-sidecars-are-unconfined
title: "Third-party plugin sidecars are unconfined"
theme: plugin-ecosystem
status: open
created: 2026-08-18
date_source: section
order: 2
---
**Third-party plugin sidecars are unconfined.** A plugin sidecar runs `bun run <script>` with
`cwd: PLATFORM_DIR`, so Bun auto-loads `.env` and it gets `POSTGRES_URL`; it runs as the service
user, which owns `secrets/officer-keys.db` and can therefore mint an owner JWT. Fine for
first-party plugins, total compromise for anyone else's.
The mechanism to fix it already exists and is pointed elsewhere: `spawn-as-member.ts` drops
privileges with `setpriv --reset-env` and a six-name allowlist for exactly this reason.
+5
View File
@@ -0,0 +1,5 @@
---
theme: plugin-infrastructure
heading: "Plugin infrastructure"
kind: theme
---
@@ -0,0 +1,15 @@
---
id: plugin-infrastructure/a-plugin-cannot-claim-a-websocket-provider
title: "A plugin cannot claim a WebSocket provider"
theme: plugin-infrastructure
status: open
created: 2026-08-21
date_source: stated
order: 1
---
**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.
@@ -0,0 +1,15 @@
---
id: plugin-infrastructure/a-plugin-cannot-run-host-setup
title: "A plugin cannot run host setup"
theme: plugin-infrastructure
status: open
created: 2026-08-20
date_source: assumed
order: 2
---
**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.
@@ -0,0 +1,13 @@
---
id: plugin-repos/12-stale-headscale-route-paths-in-comments
title: "~12 stale `/headscale` route paths in comments across `web/`, plus `App.tsx:78` in the platform"
theme: plugin-repos
status: done
created: 2026-08-21
date_source: stated
order: 4
---
~12 stale `/headscale` route paths in comments across `web/`, plus `App.tsx:78` in the platform.
**Done.** Verified 2026-08-21: none left in either. The one remaining mention is a form
placeholder, `https://headscale.example.com`, which is correct — that IS a Headscale URL.
+20
View File
@@ -0,0 +1,20 @@
---
theme: plugin-repos
heading: "Plugin repos — what writing their READMEs turned up"
kind: theme
---
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.**
**Documentation that actively lies.**
**Done.** Verified 2026-08-21: zero occurrences of either name.
**Conventions a plugin author has to guess.** All verified in `discover.ts`/`mount.ts`, none documented
outside the example's new README:
**Genuinely missing.**
@@ -0,0 +1,17 @@
---
id: plugin-repos/a-manifest-is-module-cached-for-the-process
title: "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"
theme: plugin-repos
status: done
created: 2026-08-21
date_source: stated
order: 14
---
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.
**Fixed 2026-08-21 (`da30d1a6`).** `importFresh` keys the import on the file's mtime — a changed
manifest is re-read, an unchanged one is still a cache hit. Found the expensive way: a reinstalled
jellyfin was judged against the DELETED plugin's manifest, so it was ruled ineligible for the user
list and provisioning silently did nothing. Note the limit: only the named file is re-read, so a
plugin's own imports still need a restart.
@@ -0,0 +1,12 @@
---
id: plugin-repos/a-plugin-s-assets-icon-png-is-only-published
title: "A plugin's `assets/icon.png` is only published while installed, so its real icon is invisible in the store before install"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 19
---
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.
@@ -0,0 +1,12 @@
---
id: plugin-repos/panel-keys-and-layout-apptypes-must-agree-and
title: "Panel `key`s and layout `appType`s must agree, and a mismatch fails SILENTLY — `PluginScreen.tsx` pins `appTypes.allowed` with `fallback: allowed[0]`, so a typo renders the first panel"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 12
---
Panel `key`s and layout `appType`s must agree, and a mismatch fails SILENTLY — `PluginScreen.tsx`
pins `appTypes.allowed` with `fallback: allowed[0]`, so a typo renders the first panel.
@@ -0,0 +1,12 @@
---
id: plugin-repos/permission-key-prefixing-is-convention-not
title: "Permission-key prefixing is convention, not validation"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 15
---
Permission-key prefixing is convention, not validation. Only a collision with a CORE key is refused,
silently, at mount, with a console error.
@@ -0,0 +1,12 @@
---
id: plugin-repos/plugin-md-has-11-stale-self-references
title: "`PLUGIN.md` has ~11 stale self-references (`headscale_servers`, `listHeadscaleServers`, permission key `headscale`, `/api/headscale`, panel keys, `useHeadscaleSection()`)"
theme: plugin-repos
status: done
created: 2026-08-20
date_source: assumed
order: 5
---
`PLUGIN.md` has ~11 stale self-references (`headscale_servers`, `listHeadscaleServers`, permission
key `headscale`, `/api/headscale`, panel keys, `useHeadscaleSection()`).
@@ -0,0 +1,13 @@
---
id: plugin-repos/plugins-example-manifest-ts-12-lists-web
title: "`plugins/example/manifest.ts:12` lists `web/Router.tsx` as the frontend convention; discovery requires `web/panels.ts` AND `web/layout.ts`"
theme: plugin-repos
status: done
created: 2026-08-20
date_source: assumed
order: 10
---
`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.
@@ -0,0 +1,13 @@
---
id: plugin-repos/plugins-music-musicapi-md-documents-now
title: "`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"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 9
---
`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.
@@ -0,0 +1,14 @@
---
id: plugin-repos/plugins-music-musicapi-md-says-favourites-and
title: "`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`"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 8
---
`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.
@@ -0,0 +1,13 @@
---
id: plugin-repos/plugins-music-web-musicplayerhost-tsx-header
title: "`plugins/music/web/MusicPlayerHost.tsx` header says it is mounted once in the persistent DashboardLayout; it moved into `MusicDetail`"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 18
---
`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.
@@ -0,0 +1,12 @@
---
id: plugin-repos/plugins-offscale-offscaleapi-md-claims-to
title: "`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`"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 7
---
`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`.
@@ -0,0 +1,13 @@
---
id: plugin-repos/plugins-offscale-plugin-md-445-449-says-the
title: "`plugins/offscale/PLUGIN.md:445-449` says the ACL assistant is deliberately unwired and must not be wired up"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 6
---
`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.
@@ -0,0 +1,15 @@
---
id: plugin-repos/plugins-offscale-spawns-the-ssh-binary
title: "`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"
theme: plugin-repos
status: done
created: 2026-08-16
date_source: stated
order: 16
---
`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.
**Already fixed** — the manifest declares `ssh` with per-manager packages, and its own comment
records the 2026-08-16 change. Verified 2026-08-21: the probe reports `offscale ssh=ok`. The entry
outlived the fix.
@@ -0,0 +1,13 @@
---
id: plugin-repos/scripts-reindex-music-ts-resolves-its-port
title: "`scripts/reindex-music.ts` resolves its port file from `process.env.DATA_PATH ??"
theme: plugin-repos
status: done
created: 2026-08-13
date_source: stated
order: 17
---
`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.
@@ -0,0 +1,12 @@
---
id: plugin-repos/the-appname-table-prefix-is-unenforced
title: "The `<appName>_` table prefix is unenforced"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 13
---
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.
@@ -0,0 +1,13 @@
---
id: plugin-repos/the-companion-contract-docs-the-offscale-code
title: "The Companion contract docs the offscale code cites (`COMMS/OFFSCALE_COMPANION_API.md`, `COMMS/OFFSCALE_INVITE_ENROLLMENT.md`) exist nowhere — `COMMS/` was deleted"
theme: plugin-repos
status: open
created: 2026-08-20
date_source: assumed
order: 11
---
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.
@@ -0,0 +1,13 @@
---
id: plugin-repos/web-consoleview-tsx-22-mints-terminal-session
title: "`web/ConsoleView.tsx:22` mints terminal session ids as `headscale-console-<id>`"
theme: plugin-repos
status: done
created: 2026-08-16
date_source: stated
order: 3
---
`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.
**Done 2026-08-16**, and the file carries a comment recording it — a pty session KEY, not a label.
@@ -0,0 +1,12 @@
---
id: plugin-repos/web-offscaleserverpicker-tsx-26-renders-the
title: "`web/OffscaleServerPicker.tsx:26` renders the literal heading `Headscale`"
theme: plugin-repos
status: done
created: 2026-08-21
date_source: stated
order: 2
---
`web/OffscaleServerPicker.tsx:26` renders the literal heading `Headscale`.
**Done.** Verified 2026-08-21: it renders `Offscale`.
@@ -0,0 +1,13 @@
---
id: plugin-repos/web-panels-ts-registers-three-panels-named
title: "`web/panels.ts` registers three panels named \"Headscale servers\", \"Headscale\", \"Headscale\" — these are what a user reads in the panel picker"
theme: plugin-repos
status: done
created: 2026-08-21
date_source: stated
order: 1
---
`web/panels.ts` registers three panels named "Headscale servers", "Headscale", "Headscale" — these
are what a user reads in the panel picker.
**Done.** Verified 2026-08-21: the three read "Offscale servers", "Offscale", "Offscale".
+5
View File
@@ -0,0 +1,5 @@
---
theme: plugin-system
heading: "Plugin system — gaps the email extraction found (2026-08-18)"
kind: theme
---
@@ -0,0 +1,28 @@
---
id: plugin-system/a-plugin-cannot-declare-its-own-npm
title: "A plugin cannot declare its own npm dependencies, so the PLATFORM is carrying them"
theme: plugin-system
status: open
created: 2026-08-18
date_source: section
order: 1
---
**A plugin cannot declare its own npm dependencies, so the PLATFORM is carrying them.** `imapflow`,
`mailparser` and `@types/mailparser` are in the platform's `package.json` and are imported by nothing
in `src/` — they exist solely for `plugins/email/sidecar/`. Plugins have no `package.json`, run with
`cwd: PLATFORM_DIR`, and resolve from the platform's `node_modules`, so deleting those three lines
breaks the installed plugin on its first sync.
This does not scale and it is the wrong party holding the risk: the platform cannot be answerable for
what an arbitrary plugin decides to install, and `bunfig.toml` sets `frozenLockfile = true` precisely
so a dependency change is a deliberate, reviewed act. Publishing will be reviewed rather than a
free-for-all, which narrows the threat but does not move the responsibility.
What it probably needs: a `dependencies` field in the manifest (the manifest already holds
`osDependencies` for host binaries, which is the same shape of problem solved once), installed into
the plugin's own directory at install time rather than merged into the platform's tree. Note the
install path already learned not to trust `$PATH` — `pushPluginSchema` resolves a binary rather than
spawning `bunx` — so whatever runs the install has to do the same.
Until then: `imapflow` and `mailparser` STAY. Removing them is not tidying, it is breaking email.
@@ -0,0 +1,26 @@
---
id: plugin-system/emailaccounts-is-created-by-the-platform-for
title: "`email_accounts/` is created by the PLATFORM for every account, and should be the plugin's job"
theme: plugin-system
status: open
created: 2026-08-18
date_source: section
order: 2
---
**`email_accounts/` is created by the PLATFORM for every account, and should be the plugin's job.**
`USER_DIRS` in `data-path.ts` pre-creates it at 0700 for every user, whether or not the email plugin
is installed — so a core install provisions a directory named for a plugin it does not have.
Ownership should invert: the plugin provisions its own storage, in all three moments —
1. **on plugin install**, for accounts that already exist;
2. **on user provisioning**, for accounts created afterwards;
3. **lazily**, the first time a provisioned user opens the plugin, so a machine that missed either of
the above still heals itself.
The third is the one that makes the other two non-critical, and it belongs in the email sidecar
rather than in the platform's account handler.
This is entangled with the `email_db` leak below — core reads that directory today, so the plugin
cannot become its sole owner until that is resolved too. Do them together.

Some files were not shown because too many files have changed in this diff Show More