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:
@@ -9,12 +9,12 @@ 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 |
|
||||
| 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` |
|
||||
|
||||
@@ -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.
|
||||
@@ -200,7 +210,7 @@ starts a conversation. There is no manifest field for this and nothing to regist
|
||||
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 | |
|
||||
@@ -208,7 +218,7 @@ checks whether the file exists at that fixed path within the plugin's directory
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -203,21 +203,18 @@ export const ChatPanelWrapper = ({
|
||||
// `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
|
||||
// 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
|
||||
// running the turn in that mailbox's directory (`resolveEmailCwd`). The backend half carries its own
|
||||
// justification; this line is recorded here so the coupling is visible from both ends rather than only
|
||||
// one. If the email plugin is not installed, no workspace has this id and the branch is simply never
|
||||
// taken — it fails to nothing rather than to an error.
|
||||
// There WAS a third branch here — `workspace.id === 'email'` → `{ context: 'email' }` — one plugin's
|
||||
// screen id hardcoded in a shared platform package. 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.
|
||||
// `declared` is that fix, and the email plugin now passes it, so the branch is gone.
|
||||
//
|
||||
// The general fix is a way for a plugin to declare a chat context, which does not exist. Until it does,
|
||||
// adding a SECOND id here is the signal that it needs to.
|
||||
// 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. 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
|
||||
? declared
|
||||
: workspace?.kind === 'screen' && workspace.id === 'email'
|
||||
? { context: 'email' as const }
|
||||
: workspace?.kind === 'dashboard'
|
||||
? { context: 'dashboard' as const, contextId: workspace.key }
|
||||
: {};
|
||||
|
||||
Reference in New Issue
Block a user