Replace the per-group "written once at init, owned by the group" CLAUDE.md
with a host-regenerated entry point that imports:
- a shared base (`container/CLAUDE.md` mounted RO at `/app/CLAUDE.md`)
- optional per-skill fragments (skills that ship `instructions.md`)
- optional per-MCP-server fragments (inline `instructions` field in
`container.json`)
- per-group agent memory (`CLAUDE.local.md`, auto-loaded by Claude Code)
Principle: RW = per-group memory, RO = shared content. Source/skills/base
are shared; personality, config, working files, and Claude state stay
per-group.
Key changes:
- New `src/claude-md-compose.ts` — per-spawn composition +
`migrateGroupsToClaudeLocal()` one-time cutover.
- New `container/CLAUDE.md` — shared base, seeded verbatim from the
former `groups/global/CLAUDE.md`.
- `src/container-runner.ts` — swap `/workspace/global` mount for RO
`/app/CLAUDE.md`; call `composeGroupClaudeMd()` after
`initGroupFilesystem()`.
- `src/group-init.ts` — drop `.claude-global.md` symlink + initial
`CLAUDE.md` write; seed `CLAUDE.local.md` from `opts.instructions`.
- `src/index.ts` — call `migrateGroupsToClaudeLocal()` at startup.
- `src/container-config.ts` — add optional `instructions` field to
`McpServerConfig` (inline per-MCP guidance fragment).
- `container/Dockerfile` — drop dead `/workspace/global` mkdir.
- Remove obsolete `scripts/migrate-group-claude-md.ts`.
Migration (runs once at host startup, idempotent):
- Delete `.claude-global.md` symlinks in each group.
- Rename each `groups/<folder>/CLAUDE.md` → `CLAUDE.local.md`
(preserves existing per-group content as memory).
- Delete `groups/global/` directory.
Design docs: `docs/claude-md-composition.md` and `docs/shared-source.md`
(the latter is the sibling design discussion this refactor builds on).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
/**
|
|
* Per-group container config, stored as a plain JSON file at
|
|
* `groups/<folder>/container.json`. Mounted read-only inside the container
|
|
* at `/workspace/agent/container.json` — the runner reads it at startup but
|
|
* cannot modify it. Config changes go through the self-mod approval flow.
|
|
*
|
|
* All fields are optional — a missing file or a partial file both resolve
|
|
* to sensible defaults. Writes are atomic-enough (write-then-rename is not
|
|
* worth the ceremony here since there's only one writer in practice: the
|
|
* host, from the delivery thread that processes approved system actions).
|
|
*/
|
|
import fs from 'fs';
|
|
import path from 'path';
|
|
|
|
import { GROUPS_DIR } from './config.js';
|
|
|
|
export interface McpServerConfig {
|
|
command: string;
|
|
args?: string[];
|
|
env?: Record<string, string>;
|
|
// Optional always-in-context guidance. When set, the host writes the
|
|
// content to `.claude-fragments/mcp-<name>.md` at spawn and imports it
|
|
// into the composed CLAUDE.md.
|
|
instructions?: string;
|
|
}
|
|
|
|
export interface AdditionalMountConfig {
|
|
hostPath: string;
|
|
containerPath: string;
|
|
readonly?: boolean;
|
|
}
|
|
|
|
export interface ContainerConfig {
|
|
mcpServers: Record<string, McpServerConfig>;
|
|
packages: { apt: string[]; npm: string[] };
|
|
imageTag?: string;
|
|
additionalMounts: AdditionalMountConfig[];
|
|
/** Which skills to enable — array of skill names or "all" (default). */
|
|
skills: string[] | 'all';
|
|
/** Agent provider name (e.g. "claude", "opencode"). Default: "claude". */
|
|
provider?: string;
|
|
/** Agent group display name (used in transcript archiving). */
|
|
groupName?: string;
|
|
/** Assistant display name (used in system prompt / responses). */
|
|
assistantName?: string;
|
|
/** Agent group ID — set by the host, read by the runner. */
|
|
agentGroupId?: string;
|
|
/** Max messages per prompt. Falls back to code default if unset. */
|
|
maxMessagesPerPrompt?: number;
|
|
}
|
|
|
|
function emptyConfig(): ContainerConfig {
|
|
return {
|
|
mcpServers: {},
|
|
packages: { apt: [], npm: [] },
|
|
additionalMounts: [],
|
|
skills: 'all',
|
|
};
|
|
}
|
|
|
|
function configPath(folder: string): string {
|
|
return path.join(GROUPS_DIR, folder, 'container.json');
|
|
}
|
|
|
|
/**
|
|
* Read the container config for a group, returning sensible defaults for
|
|
* any missing fields (or an entirely empty config if the file is absent).
|
|
* Never throws for missing / malformed files — corruption logs a warning
|
|
* via console.error and falls back to empty.
|
|
*/
|
|
export function readContainerConfig(folder: string): ContainerConfig {
|
|
const p = configPath(folder);
|
|
if (!fs.existsSync(p)) return emptyConfig();
|
|
try {
|
|
const raw = JSON.parse(fs.readFileSync(p, 'utf8')) as Partial<ContainerConfig>;
|
|
return {
|
|
mcpServers: raw.mcpServers ?? {},
|
|
packages: {
|
|
apt: raw.packages?.apt ?? [],
|
|
npm: raw.packages?.npm ?? [],
|
|
},
|
|
imageTag: raw.imageTag,
|
|
additionalMounts: raw.additionalMounts ?? [],
|
|
skills: raw.skills ?? 'all',
|
|
provider: raw.provider,
|
|
groupName: raw.groupName,
|
|
assistantName: raw.assistantName,
|
|
agentGroupId: raw.agentGroupId,
|
|
maxMessagesPerPrompt: raw.maxMessagesPerPrompt,
|
|
};
|
|
} catch (err) {
|
|
console.error(`[container-config] failed to parse ${p}: ${String(err)}`);
|
|
return emptyConfig();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Write the container config for a group, creating the groups/<folder>/
|
|
* directory if necessary. Pretty-printed JSON so diffs in the activation
|
|
* flow are reviewable.
|
|
*/
|
|
export function writeContainerConfig(folder: string, config: ContainerConfig): void {
|
|
const p = configPath(folder);
|
|
const dir = path.dirname(p);
|
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(p, JSON.stringify(config, null, 2) + '\n');
|
|
}
|
|
|
|
/**
|
|
* Apply a mutator function to a group's container config and persist the
|
|
* result. Convenient for append-style changes like `install_packages` and
|
|
* `add_mcp_server` handlers.
|
|
*/
|
|
export function updateContainerConfig(folder: string, mutate: (config: ContainerConfig) => void): ContainerConfig {
|
|
const config = readContainerConfig(folder);
|
|
mutate(config);
|
|
writeContainerConfig(folder, config);
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Initialize an empty container.json for a group if one doesn't already
|
|
* exist. Idempotent — used from `group-init.ts`.
|
|
*/
|
|
export function initContainerConfig(folder: string): boolean {
|
|
const p = configPath(folder);
|
|
if (fs.existsSync(p)) return false;
|
|
writeContainerConfig(folder, emptyConfig());
|
|
return true;
|
|
}
|