a plugin declares its chat directory; email stops being hardcoded here

ChatPanelWrapper had one plugin's screen id compiled into a shared platform
package: `workspace.id === 'email'` → `{ context: 'email' }`. its own comment
said the general fix was a way for a plugin to DECLARE its chat context, and
that a second hardcoded id would be the signal it was needed. that fix exists
(`declared`), the email plugin now passes it, so the branch is gone.

it also could not fire any more, which is the part worth keeping. a plugin's
workspace key is `screens/plugin/<name>`, so email's id became `plugin/email`
the day it was extracted and the test silently stopped matching. a hardcoded id
does not fail loudly when the thing it names moves — it just stops being true,
and nothing said so for eight days.

two things on the backend half:

resolveEmailCwd now installs the email plugin's chat/CLAUDE.md. an email chat
runs in the account's directory, not the plugin's chats directory, so the
`plugin` branch never reaches it — the brief follows the chat rather than the
chat moving to the brief. a second plugin wanting this is the signal that
"which directory does this chat run in" belongs in the manifest.

and it verifies the mailbox exists before joining it. `accountEmail` comes from
the client (the mailbox picker's `?account=`) and this function ends in mkdirSync
— so an unrecognised value did not fail, it CREATED a directory named after
whatever arrived and ran the turn in it. the list's unified view uses the
sentinel `all`, which is the value most likely to show up by accident. the panel
drops it; this is the half that does not depend on the panel remembering to.

docs/claude-md-touchpoints.md updated: the copier now has two callers.

unrelated and NOT from this change: ModelSelector's "an unstarted composer gets
a default" fails on master too — verified by stashing these files and rerunning.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-26 18:43:13 +00:00
co-authored by Claude Opus 5
parent d58652bf14
commit be210c3ed4
3 changed files with 77 additions and 51 deletions
+46 -36
View File
@@ -9,14 +9,14 @@ read-in-place rule this mechanism is the one deliberate exception to).
## The shape of it
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files
that merely *mention* `CLAUDE.md` in a comment suggests.
that merely _mention_ `CLAUDE.md` in a comment suggests.
| # | Site | Verb | Cardinality | Trigger |
|---|------|------|-------------|---------|
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory | every plugin-panel chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
| # | Site | Verb | Cardinality | Trigger |
| --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation |
| 2 | `api/chat/websocket.ts` → `installPluginChatBrief` | copy (overwrite if changed) | one file per plugin-chat directory, plus one per email account | every plugin-panel chat start, and every email chat start |
| 3 | `scripts/backfill-commander-credentials.ts` | append (idempotent) | existing commander files only | manual, one-off per deploy |
| 4 | `scripts/docs-check.ts` | read only | — | `bun docs:check` |
Two things fell out of tracing this that are worth stating up front, because they explain why the
mechanism looks the way it does rather than something simpler:
@@ -39,7 +39,7 @@ mechanism looks the way it does rather than something simpler:
Called once, from `command-hq/router.ts`, when `POST /command-hq/commanders` creates a commander — the
row, the directory, and this file, in that order, all before any turn is allowed to spawn. The ordering
is load-bearing: a turn that spawned before the directory existed used to recreate an *empty* one on a
is load-bearing: a turn that spawned before the directory existed used to recreate an _empty_ one on a
race with deletion (`websocket.ts:300-308` describes the incident), so the row now has to exist first
and the directory has to exist before a turn can reach it.
@@ -68,7 +68,7 @@ to be reading the file yet.
**What happens after creation is the interesting part: nothing does, from the platform's side.** The
file is described everywhere as "a living document its commander has been extending" — the commander
itself edits it during its own life, the same file a human would open in the file browser. The platform
never rewrites it. That is *why* item 3 below has to append rather than regenerate: a commander with
never rewrites it. That is _why_ item 3 below has to append rather than regenerate: a commander with
months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
### 2. Plugin chat briefs — the copier
@@ -95,8 +95,18 @@ async function installPluginChatBrief(appName: string, dir: string): Promise<voi
}
```
Called from `resolveChatCwd()` (line 332-336) exactly when `msg.context === 'plugin'` — never for a
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
- `msg.context === 'plugin'` — the plugin's own chats directory. The ordinary path.
- `msg.context === 'email'` — the **account's** directory, the one holding `emails.db`.
Never for a commander and never for an ordinary `/chat` session.
Email is the exception because an email chat does not run in the plugin's chats directory: it runs beside
the database it exists to answer from, which is the entire point of that context. So the brief follows the
chat rather than the chat moving to the brief, and `plugins/email/chat/CLAUDE.md` lands in
`email_accounts/<address>/`. A second plugin wanting this would be the signal that "which directory does
this plugin's chat run in" belongs in the manifest rather than in a branch here. That branch also does the `mkdirSync`
for the chat directory itself, so the two writers (this one and the commander generator) can never
target the same directory: a commander's cwd is `$DATA_PATH/<email>/commanders/<dirName>/`, a plugin
chat's is `$DATA_PATH/<email>/plugins/<appName>/chats/`.
@@ -105,7 +115,7 @@ The source is `<plugin-repo>/chat/CLAUDE.md` — a file the plugin author ships
read fresh from `snapshotPlugins()` on every chat start rather than cached, so a `plugins:update` needs
no republish step for the brief to change. Content-compared before writing (`writeAtomic` only fires on
an actual diff) specifically so a chat directory's mtimes don't churn on every message — this is called
once per chat *start*, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
once per chat _start_, but a resumed conversation calls `resolveChatCwd` again each time it reconnects.
Best-effort throughout, on purpose: a plugin with no `chat/CLAUDE.md` (the ordinary case — see the
per-plugin table below) is not an error, it is a chat that knows a bit less than one with a brief. A
@@ -178,11 +188,11 @@ specifically to stop a future change from "fixing" this back in.
what fixed it.
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs
un-isolated, `HOME` pointed at their real home, so a generated container `CLAUDE.md` would land
*inside* `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
_inside_ `~/.claude/CLAUDE.md` — the file the owner maintains by hand and the terminal `claude` also
reads. Writing one there would silently mix generated content into a document that is supposed to be
entirely the owner's.
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that
the *old* `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
the _old_ `provision.ts` (deleted) used to seed shell rc files and write a generated `CLAUDE.md` +
`settings.json` describing a user's Docker container, as part of an architecture that no longer exists.
What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
generated inside either.
@@ -199,28 +209,28 @@ at the top of the plugin's own repository, picked up automatically the moment th
starts a conversation. There is no manifest field for this and nothing to register — the platform simply
checks whether the file exists at that fixed path within the plugin's directory on every chat start.
| Plugin | `chat/CLAUDE.md` | Notes |
|---|---|---|
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an *affordance* (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
| Plugin | `chat/CLAUDE.md` | Notes |
| ---------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `proxy-manager` | **yes** | The only one. `ChatPanel.tsx:22` documents the mechanism from the plugin's side — its own comment explains why the brief lives in a file rather than a `promptPrefix` constant. |
| `atelier` | no | |
| `bitcoin-wallet` | no | |
| `browser` | no | |
| `desktop` | no | Its `sidecar/protocol.ts` mentions `CLAUDE.md` once, but only as a comment citing the platform's frozen-lockfile rule — not a touch. |
| `email` | no | |
| `example` | no | |
| `gitea` | no | Ships an _affordance_ (a different, unrelated mechanism — see `plugins/CLAUDE.md`'s worked example, which is this plugin's) but no chat brief. |
| `invoices` | no | |
| `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. |
| `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | |
| `music` | no | |
| `offscale` | no | |
| `photos` | no | |
| `soulseek` | no | |
| `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. |
**No plugin writes, generates, or appends to a `CLAUDE.md` programmatically.** Every plugin-side hit that
mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code