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 ## The shape of it
Three writers, one read-only checker. That's the whole mechanism — smaller than the number of files 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 | | # | Site | Verb | Cardinality | Trigger |
|---|------|------|-------------|---------| | --- | ------------------------------------------------------ | --------------------------- | -------------------------------------------------------------- | --------------------------------------------------------- |
| 1 | `command-hq/directory.ts` → `createCommanderDirectory` | generate (write once) | one file per commander | commander creation | | 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 | | 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 | | 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` | | 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 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: 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 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 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 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. 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 **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 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 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. months of self-written notes in its `CLAUDE.md` cannot be treated as regeneratable state.
### 2. Plugin chat briefs — the copier ### 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 Called from `resolveChatCwd()` from **two** branches, and the second one is the interesting case:
commander, never for an ordinary `/chat` session, never for email. That branch also does the `mkdirSync`
- `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 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 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/`. 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 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 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 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 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 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. what fixed it.
- **The owner's own sessions** — `src/servers/sidecar/claude/user-instance.ts:235-237`. The owner runs - **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 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 reads. Writing one there would silently mix generated content into a document that is supposed to be
entirely the owner's. entirely the owner's.
- **Per-user container provisioning** — `scripts/provision-user-dirs.ts:10-13`. The comment records that - **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. `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 What survives from it is only the directory skeleton — a user gets a root and a home inside it, nothing
generated inside either. 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 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. checks whether the file exists at that fixed path within the plugin's directory on every chat start.
| Plugin | `chat/CLAUDE.md` | Notes | | 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. | | `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 | | | `atelier` | no | |
| `bitcoin-wallet` | no | | | `bitcoin-wallet` | no | |
| `browser` | 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. | | `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 | | | `email` | no | |
| `example` | 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. | | `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 | | | `invoices` | no | |
| `iris` | no | | | `iris` | no | |
| `jellyfin` | no | `db/schema.ts` cites `src/databases/CLAUDE.md` for the composite-key rule; not a touch. | | `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. | | `kiosk` | no | `db/schema.ts` cites `databases/CLAUDE.md` the same way. |
| `minesweeper` | no | | | `minesweeper` | no | |
| `music` | no | | | `music` | no | |
| `offscale` | no | | | `offscale` | no | |
| `photos` | no | | | `photos` | no | |
| `soulseek` | 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. | | `transcribe` | no | `tasks/transcribe-audio-file/index.ts` cites `platform/CLAUDE.md` for the script-task self-containment rule; not a touch. |
| `transmission` | no | | | `transmission` | no | |
| `vault` | no | `db/schema.ts` cites `src/databases/CLAUDE.md`'s singleton rule; not a touch. | | `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 **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 mentioned the string was a comment pointing back at a platform doc for a rule the plugin's own code
+20 -1
View File
@@ -200,9 +200,28 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi
// Found on disk, never queried. Email is a plugin and the platform may not import its queries; the // Found on disk, never queried. Email is a plugin and the platform may not import its queries; the
// directory layout is already the platform's, so reading it answers the question with no coupling and // directory layout is already the platform's, so reading it answers the question with no coupling and
// nothing to throw when the plugin is not installed. // nothing to throw when the plugin is not installed.
const account = accountEmail?.trim() || findEmailAccountName(ownerEmail); //
// The named mailbox has to EXIST before it is joined. `accountEmail` reaches here from the client — it is
// the mailbox picker's `?account=` — and this function ends in `mkdirSync`, so an unrecognised value does
// not fail, it CREATES a directory named after whatever was sent and runs the turn in it. The list's
// unified view uses the sentinel `all`, which is the value most likely to arrive by accident; the panel
// drops it, and this is the half that does not depend on the panel remembering to.
//
// Falling back to the most recently synced account is the same answer the unified list is mostly showing.
const requested = accountEmail?.trim();
const known = requested && existsSync(join(getEmailAccountsDir(ownerEmail), requested, 'emails.db'));
if (requested && !known) {
console.warn(`[chat] email: no mailbox named ${requested} — using the most recently synced one instead`);
}
const account = (known ? requested : null) ?? findEmailAccountName(ownerEmail);
const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail); const dir = account ? join(getEmailAccountsDir(ownerEmail), account) : getEmailAccountsDir(ownerEmail);
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
// The same brief a plugin's own chats get, written where an email chat actually runs.
//
// `installPluginChatBrief` is not reached by the `plugin` branch below, because an email chat does not
// run in the plugin's chats directory — it runs in the ACCOUNT's, beside `emails.db`, which is the whole
// reason this branch exists. The brief follows the chat rather than the chat moving to the brief.
await installPluginChatBrief('email', dir);
return dir; return dir;
} }
@@ -203,24 +203,21 @@ export const ChatPanelWrapper = ({
// `contextId` stays the raw workspace key: it is the same string `agent_panels.dashboard_id` holds, and // `contextId` stays the raw workspace key: it is the same string `agent_panels.dashboard_id` holds, and
// a stored address should not be two different strings depending on which feature wrote it. The bare id // a stored address should not be two different strings depending on which feature wrote it. The bare id
// is for deciding, which is what `kind` and `id` are doing above it. // is for deciding, which is what `kind` and `id` are doing above it.
// `'email'` is a PLUGIN's screen id, hardcoded in a shared platform package, and it is the one piece of
// plugin vocabulary the extraction on 2026-08-18 could not remove.
// //
// It is the frontend half of a pair: this sends `context: 'email'`, and the chat socket answers it by // There WAS a third branch here — `workspace.id === 'email'` → `{ context: 'email' }` — one plugin's
// running the turn in that mailbox's directory (`resolveEmailCwd`). The backend half carries its own // screen id hardcoded in a shared platform package. Its own comment said the general fix was a way for a
// justification; this line is recorded here so the coupling is visible from both ends rather than only // plugin to DECLARE its chat context, and that a second hardcoded id would be the signal it was needed.
// one. If the email plugin is not installed, no workspace has this id and the branch is simply never // `declared` is that fix, and the email plugin now passes it, so the branch is gone.
// taken — it fails to nothing rather than to an error.
// //
// The general fix is a way for a plugin to declare a chat context, which does not exist. Until it does, // It also could not fire any more, which is the part worth keeping: a plugin's workspace key is
// adding a SECOND id here is the signal that it needs to. // `screens/plugin/<name>`, so email's id became `plugin/email` the day it was extracted and the test
// silently stopped matching. Every email chat since ran with no mailbox anywhere near it. A hardcoded id
// does not fail loudly when the thing it names moves — it just stops being true.
const chatContext = declared const chatContext = declared
? declared ? declared
: workspace?.kind === 'screen' && workspace.id === 'email' : workspace?.kind === 'dashboard'
? { context: 'email' as const } ? { context: 'dashboard' as const, contextId: workspace.key }
: workspace?.kind === 'dashboard' : {};
? { context: 'dashboard' as const, contextId: workspace.key }
: {};
const agentPanel = useAgentPanel(panelId); const agentPanel = useAgentPanel(panelId);