diff --git a/src/servers/api/chat/websocket.ts b/src/servers/api/chat/websocket.ts index d3942fa6..81522b19 100644 --- a/src/servers/api/chat/websocket.ts +++ b/src/servers/api/chat/websocket.ts @@ -41,6 +41,8 @@ import { resolveHomeDir } from '@@/user-home'; import { renameClaudeSession, type ChatIdentity } from './claude-sessions'; import { claudeLoginState } from '@@/os-user-claude'; import { mkdirSync, existsSync } from 'node:fs'; +import { snapshotPlugins } from '@@/plugins/mount'; +import { writeAtomic } from '@@/write-atomic'; import { logger } from './logger'; import { isSuperAdmin } from '@@/super-admin'; import { snapshotClaudeUsage } from './usage-store'; @@ -203,6 +205,54 @@ async function resolveEmailCwd(ownerEmail: string, accountEmail?: string): Promi return dir; } +/** + * Put the plugin's own `CLAUDE.md` where its chats will run. + * + * ── Why a file and not a prompt prefix ── + * + * A plugin used to say what its chats should know through `promptPrefix`, which rides every message. That + * has three costs and the owner hit all of them: it is paid per turn forever, it is INVISIBLE as a design + * artefact because it lives in a TypeScript constant, and it is visible in the worst place — the owner + * sent three words and watched a paragraph of preamble go out attached to them. + * + * Claude Code reads `CLAUDE.md` from its working directory at session start. So the same words, written + * once into the directory these chats already run in, cost nothing per turn, can be opened and edited by + * the person they are about, and are the mechanism `docs/commander-directory.md` already settled on for + * exactly this problem. + * + * ── Copied, not read in place, and why that is not a contradiction ── + * + * `docs/plugin-capabilities.md` argues at length that a plugin's capabilities are READ where they live and + * never copied. That rule is about files the PLATFORM reads, where a copy goes stale and survives an + * uninstall. This one is read by Claude Code, from a cwd, so it has to exist there — no amount of + * indirection changes where that program looks. + * + * The staleness the copy rule warns about is answered by rewriting it on every resolve rather than once at + * install: the file on disk is always this version of the plugin's, and a `plugins:update` needs no + * republish step to take effect. It is a few kilobytes written when a chat starts. + * + * Best-effort throughout. A plugin with no brief is the ordinary case, and a chat that cannot be given one + * still runs — it simply knows less, which is where every chat was before this existed. + */ +async function installPluginChatBrief(appName: string, dir: string): Promise { + try { + const { states } = await snapshotPlugins(); + const state = states.find((s) => s.plugin.appName === appName && s.install?.enabled); + if (!state) return; + + const source = join(state.plugin.dir, 'chat', 'CLAUDE.md'); + if (!existsSync(source)) return; + + const target = join(dir, 'CLAUDE.md'); + const next = await Bun.file(source).text(); + // Skip the write when nothing changed, so a directory's mtimes do not churn on every chat start. + if (existsSync(target) && (await Bun.file(target).text()) === next) return; + await writeAtomic(target, next); + } catch (err) { + console.error(`[chat] could not install ${appName}'s chat brief:`, err instanceof Error ? err.message : err); + } +} + // The working directory a chat turn runs in, by context: email → the account dir; /chat → a chosen // pwd or the caller's own home; everything else (browser/project/dashboard) → the given cwd. async function resolveChatCwd( @@ -282,6 +332,7 @@ async function resolveChatCwd( if (msg.context === 'plugin' && msg.contextId) { const dir = getPluginChatsDir(email, msg.contextId); mkdirSync(dir, { recursive: true }); + await installPluginChatBrief(msg.contextId, dir); return dir; }