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:
@@ -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",
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
@@ -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
Reference in New Issue
Block a user