diff --git a/docs/claude-md-touchpoints.md b/docs/claude-md-touchpoints.md index b307b1fb..16619884 100644 --- a/docs/claude-md-touchpoints.md +++ b/docs/claude-md-touchpoints.md @@ -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/`. 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//commanders//`, a plugin chat's is `$DATA_PATH//plugins//chats/`. @@ -105,7 +115,7 @@ The source is `/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 diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index f9a41973..744ceb83 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -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; } diff --git a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx index 93379ef6..4aad31e5 100644 --- a/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/ChatPanelWrapper.tsx @@ -203,24 +203,21 @@ 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/`, 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 } - : {}; + : workspace?.kind === 'dashboard' + ? { context: 'dashboard' as const, contextId: workspace.key } + : {}; const agentPanel = useAgentPanel(panelId);