From 8a12d5094be7b26ecadfd3c6f5521d76836a8c25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Tue, 25 Aug 2026 18:50:01 +0000 Subject: [PATCH] a plugin briefs its chats with a file, not a prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit a plugin said what its chats should know through promptPrefix, which rides every message. three costs, and the owner hit all of them: paid per turn forever, invisible as a design artefact because it lives in a typescript constant, and visible in the one place it should not be — he sent three words and watched a paragraph of preamble go out attached to them. so the platform now copies plugins//chat/CLAUDE.md into the chats directory whenever a chat starts there, and claude code reads it from its own cwd at session start. same words, no per-turn cost, and a file the person it is about can open and edit. COPIED, which looks like it contradicts docs/plugin-capabilities.md. it does not: 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 indirection changes where that program looks. the staleness is answered by rewriting on every resolve rather than once at install, so the file is always this version of the plugin's and plugins:update needs no republish step. skipped when the content already matches, so mtimes do not churn. best effort: no brief is the ordinary case, and a chat that cannot be given one still runs. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- src/servers/api/chat/websocket.ts | 51 +++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) 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; }