From 2e6dc21748f96b132586e0c4c616230abc0cb2e6 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Mon, 13 Apr 2026 14:17:07 +0300 Subject: [PATCH] refactor(v2): per-group filesystem init, persistent across spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each group's on-disk state (CLAUDE.md, .claude-shared/, agent-runner-src/) is now initialized exactly once at group creation and owned by the group forever after. Spawn does only mounts — no copies, no settings.json overwrites, no skill clobbers, no source resyncs. Global memory composition switches from "host reads /workspace/global/CLAUDE.md at bootstrap and stuffs it into systemPrompt.append" to "group CLAUDE.md imports it via @/workspace/global/CLAUDE.md at the top." Edits to global propagate instantly through the existing read-only mount; no copy, no restart. - src/group-init.ts: new initGroupFilesystem(group, opts?) — idempotent, populates groups//, .claude-shared/, agent-runner-src/ only when paths don't already exist. - src/container-runner.ts: buildMounts() calls init defensively at the top (catches existing groups on first spawn after this change), drops the inline settings.json write, skills cpSync loop, and agent-runner-src rm-then-copy. Just mounts now. - src/delivery.ts: create_agent flow uses initGroupFilesystem with optional instructions, replacing the inline mkdirSync + writeFileSync. - container/agent-runner/src/index.ts: drops GLOBAL_CLAUDE_MD reading. systemContext.instructions is now only the runtime-generated destinations addendum. - scripts/migrate-group-claude-md.ts: one-shot migration that prepends the @-import to existing groups' CLAUDE.md. Skips if global doesn't exist or if the @-import is already present (regex match on the @ form to avoid false positives from prose mentions of the path). - groups/main/CLAUDE.md: prepended by the migration. Existing groups need a one-time wipe of their agent-runner-src/ dir so init re-populates from current host source — done locally before this commit. Future host-side updates to container/skills/ or container/agent-runner/src/ won't auto-propagate; that's the trade-off for unconditional persistence and will be covered by host-mediated refresh tools in a follow-up. Co-Authored-By: Claude Opus 4.6 (1M context) --- container/agent-runner/src/index.ts | 14 ++--- groups/main/CLAUDE.md | 2 + scripts/migrate-group-claude-md.ts | 65 ++++++++++++++++++++ src/container-runner.ts | 57 +++++------------ src/delivery.ts | 14 ++--- src/group-init.ts | 95 +++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 59 deletions(-) create mode 100644 scripts/migrate-group-claude-md.ts create mode 100644 src/group-init.ts diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts index c0e431c..ad689da 100644 --- a/container/agent-runner/src/index.ts +++ b/container/agent-runner/src/index.ts @@ -35,7 +35,6 @@ function log(msg: string): void { } const CWD = '/workspace/agent'; -const GLOBAL_CLAUDE_MD = '/workspace/global/CLAUDE.md'; async function main(): Promise { const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName; @@ -44,14 +43,11 @@ async function main(): Promise { log(`Starting v2 agent-runner (provider: ${providerName})`); - // Load global CLAUDE.md as additional system context, then append destinations addendum - let instructions: string | undefined; - if (fs.existsSync(GLOBAL_CLAUDE_MD)) { - instructions = fs.readFileSync(GLOBAL_CLAUDE_MD, 'utf-8'); - log('Loaded global CLAUDE.md'); - } - const addendum = buildSystemPromptAddendum(); - instructions = instructions ? `${instructions}\n\n${addendum}` : addendum; + // Destinations addendum is the only runtime-generated context we inject. + // Global CLAUDE.md is loaded by Claude Code from /workspace/agent/CLAUDE.md + // (which imports /workspace/global/CLAUDE.md via @-syntax) — no need to + // read it manually anymore. + const instructions = buildSystemPromptAddendum(); // Discover additional directories mounted at /workspace/extra/* const additionalDirectories: string[] = []; diff --git a/groups/main/CLAUDE.md b/groups/main/CLAUDE.md index c8c0e9f..d07793f 100644 --- a/groups/main/CLAUDE.md +++ b/groups/main/CLAUDE.md @@ -1,3 +1,5 @@ +@/workspace/global/CLAUDE.md + # Main You are Main, a personal assistant. You help with tasks, answer questions, and can schedule reminders. diff --git a/scripts/migrate-group-claude-md.ts b/scripts/migrate-group-claude-md.ts new file mode 100644 index 0000000..568e381 --- /dev/null +++ b/scripts/migrate-group-claude-md.ts @@ -0,0 +1,65 @@ +/** + * One-shot migration: prepend `@/workspace/global/CLAUDE.md` to each + * existing group's CLAUDE.md so it imports the global memory under the + * new model where the host no longer reads global CLAUDE.md at bootstrap. + * + * - Skips entirely if `groups/global/CLAUDE.md` doesn't exist (nothing + * to import; running the script would just add a broken @-import). + * - Skips any group whose CLAUDE.md already references + * `/workspace/global/CLAUDE.md` (idempotent). + * - Skips groups with no CLAUDE.md (nothing to prepend to). + * + * Usage: npx tsx scripts/migrate-group-claude-md.ts + */ +import fs from 'fs'; +import path from 'path'; + +import { GROUPS_DIR } from '../src/config.js'; + +const GLOBAL_CLAUDE_MD = path.join(GROUPS_DIR, 'global', 'CLAUDE.md'); +const IMPORT_LINE = '@/workspace/global/CLAUDE.md'; +// Must match the @-import syntax exactly — a bare path reference inside +// instructional prose ("you can write to /workspace/global/CLAUDE.md") +// shouldn't count as "already wired." +const IMPORT_REGEX = /@\/workspace\/global\/CLAUDE\.md/; + +if (!fs.existsSync(GLOBAL_CLAUDE_MD)) { + console.error(`No global CLAUDE.md at ${GLOBAL_CLAUDE_MD} — nothing to migrate.`); + process.exit(1); +} + +if (!fs.existsSync(GROUPS_DIR)) { + console.error(`No groups dir at ${GROUPS_DIR} — nothing to migrate.`); + process.exit(1); +} + +const entries = fs.readdirSync(GROUPS_DIR, { withFileTypes: true }); +let updated = 0; +let alreadyWired = 0; +let missingClaudeMd = 0; + +for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === 'global') continue; // not a group + + const claudeMd = path.join(GROUPS_DIR, entry.name, 'CLAUDE.md'); + if (!fs.existsSync(claudeMd)) { + console.log(`[skip] ${entry.name}: no CLAUDE.md`); + missingClaudeMd++; + continue; + } + + const body = fs.readFileSync(claudeMd, 'utf-8'); + if (IMPORT_REGEX.test(body)) { + console.log(`[wired] ${entry.name}: already imports ${IMPORT_LINE}`); + alreadyWired++; + continue; + } + + const newBody = `${IMPORT_LINE}\n\n${body}`; + fs.writeFileSync(claudeMd, newBody); + console.log(`[ok] ${entry.name}: prepended import`); + updated++; +} + +console.log(`\nDone. updated=${updated} alreadyWired=${alreadyWired} missingClaudeMd=${missingClaudeMd}`); diff --git a/src/container-runner.ts b/src/container-runner.ts index 34e9096..25b1b34 100644 --- a/src/container-runner.ts +++ b/src/container-runner.ts @@ -13,6 +13,7 @@ import { CONTAINER_IMAGE, DATA_DIR, GROUPS_DIR, IDLE_TIMEOUT, ONECLI_URL, TIMEZO import { CONTAINER_RUNTIME_BIN, hostGatewayArgs, readonlyMountArgs, stopContainer } from './container-runtime.js'; import { getAgentGroup } from './db/agent-groups.js'; import { getMessagingGroup } from './db/messaging-groups.js'; +import { initGroupFilesystem } from './group-init.js'; import { log } from './log.js'; import { validateAdditionalMounts } from './mount-security.js'; import { @@ -164,6 +165,13 @@ export function killContainer(sessionId: string, reason: string): void { } function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { + // Per-group filesystem state lives forever after first creation. Init is + // idempotent: it only writes paths that don't already exist, so this call + // is a no-op for groups that have spawned before. Pulling in upstream + // built-in skill or agent-runner source updates is an explicit operation + // (host-mediated tools), not something the spawn path does silently. + initGroupFilesystem(agentGroup); + const mounts: VolumeMount[] = []; const projectRoot = process.cwd(); const sessDir = sessionDir(agentGroup.id, session.id); @@ -173,59 +181,24 @@ function buildMounts(agentGroup: AgentGroup, session: Session): VolumeMount[] { mounts.push({ hostPath: sessDir, containerPath: '/workspace', readonly: false }); // Agent group folder at /workspace/agent - fs.mkdirSync(groupDir, { recursive: true }); mounts.push({ hostPath: groupDir, containerPath: '/workspace/agent', readonly: false }); - // Global memory directory + // Global memory directory — read-only for non-admin so the @import + // in each group's CLAUDE.md can resolve it without risk of being + // overwritten by an agent in some other group. const globalDir = path.join(GROUPS_DIR, 'global'); if (fs.existsSync(globalDir)) { mounts.push({ hostPath: globalDir, containerPath: '/workspace/global', readonly: !agentGroup.is_admin }); } - // Claude sessions directory (per agent group, shared across sessions) + // Per-group .claude-shared at /home/node/.claude (Claude state, settings, + // skills — initialized once at group creation, persistent thereafter) const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared'); - fs.mkdirSync(claudeDir, { recursive: true }); - const settingsFile = path.join(claudeDir, 'settings.json'); - if (!fs.existsSync(settingsFile)) { - fs.writeFileSync( - settingsFile, - JSON.stringify( - { - env: { - CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', - CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', - CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', - }, - }, - null, - 2, - ) + '\n', - ); - } - - // Sync container skills - const skillsSrc = path.join(projectRoot, 'container', 'skills'); - const skillsDst = path.join(claudeDir, 'skills'); - if (fs.existsSync(skillsSrc)) { - for (const skillDir of fs.readdirSync(skillsSrc)) { - const srcDir = path.join(skillsSrc, skillDir); - if (fs.statSync(srcDir).isDirectory()) { - fs.cpSync(srcDir, path.join(skillsDst, skillDir), { recursive: true }); - } - } - } mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false }); - // Agent-runner source (per agent group, recompiled on container startup). - // Clear the destination before copying so files deleted or renamed - // upstream don't linger — tsc picks them up via `include: ["src/**/*"]` - // and a single stale file will fail the compile. - const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + // Per-group agent-runner source at /app/src (initialized once at group + // creation, persistent thereafter — agents can modify their runner) const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, 'agent-runner-src'); - if (fs.existsSync(agentRunnerSrc)) { - fs.rmSync(groupRunnerDir, { recursive: true, force: true }); - fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); - } mounts.push({ hostPath: groupRunnerDir, containerPath: '/app/src', readonly: false }); // Admin: mount project root read-only diff --git a/src/delivery.ts b/src/delivery.ts index e884efb..a8466b9 100644 --- a/src/delivery.ts +++ b/src/delivery.ts @@ -51,8 +51,9 @@ import { writeSystemResponse, } from './session-manager.js'; import { resetContainerIdleTimer, wakeContainer } from './container-runner.js'; +import { initGroupFilesystem } from './group-init.js'; import type { OutboundFile } from './channels/adapter.js'; -import type { Session } from './types.js'; +import type { AgentGroup, Session } from './types.js'; const ACTIVE_POLL_MS = 1000; const SWEEP_POLL_MS = 60_000; @@ -509,7 +510,7 @@ async function handleSystemAction( const agentGroupId = `ag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; const now = new Date().toISOString(); - createAgentGroup({ + const newGroup: AgentGroup = { id: agentGroupId, name, folder, @@ -517,12 +518,9 @@ async function handleSystemAction( agent_provider: null, container_config: null, created_at: now, - }); - - fs.mkdirSync(groupPath, { recursive: true }); - if (instructions) { - fs.writeFileSync(path.join(groupPath, 'CLAUDE.md'), instructions); - } + }; + createAgentGroup(newGroup); + initGroupFilesystem(newGroup, { instructions: instructions ?? undefined }); // Insert bidirectional destination rows (= ACL grants). // Creator refers to child by the name it chose; child refers to creator as "parent". diff --git a/src/group-init.ts b/src/group-init.ts new file mode 100644 index 0000000..d2f6332 --- /dev/null +++ b/src/group-init.ts @@ -0,0 +1,95 @@ +import fs from 'fs'; +import path from 'path'; + +import { DATA_DIR, GROUPS_DIR } from './config.js'; +import { log } from './log.js'; +import type { AgentGroup } from './types.js'; + +const GLOBAL_CLAUDE_IMPORT = '@/workspace/global/CLAUDE.md'; + +const DEFAULT_SETTINGS_JSON = + JSON.stringify( + { + env: { + CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1', + CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD: '1', + CLAUDE_CODE_DISABLE_AUTO_MEMORY: '0', + }, + }, + null, + 2, + ) + '\n'; + +/** + * Initialize the on-disk filesystem state for an agent group. Idempotent — + * every step is gated on the target not already existing, so re-running on + * an already-initialized group is a no-op. + * + * Called once per group lifetime: at creation, or defensively from + * `buildMounts()` for groups that pre-date this code path. After init, the + * host never overwrites any of these paths automatically — agents own them. + * To pull in upstream changes, use the host-mediated reset/refresh tools. + */ +export function initGroupFilesystem( + group: AgentGroup, + opts?: { instructions?: string }, +): void { + const projectRoot = process.cwd(); + const initialized: string[] = []; + + // 1. groups// — group memory + working dir + const groupDir = path.resolve(GROUPS_DIR, group.folder); + if (!fs.existsSync(groupDir)) { + fs.mkdirSync(groupDir, { recursive: true }); + initialized.push('groupDir'); + } + + // groups//CLAUDE.md — written once, then owned by the group + const claudeMdFile = path.join(groupDir, 'CLAUDE.md'); + if (!fs.existsSync(claudeMdFile)) { + const body = [GLOBAL_CLAUDE_IMPORT, '', opts?.instructions ?? `# ${group.name}`].join('\n') + '\n'; + fs.writeFileSync(claudeMdFile, body); + initialized.push('CLAUDE.md'); + } + + // 2. data/v2-sessions//.claude-shared/ — Claude state + per-group skills + const claudeDir = path.join(DATA_DIR, 'v2-sessions', group.id, '.claude-shared'); + if (!fs.existsSync(claudeDir)) { + fs.mkdirSync(claudeDir, { recursive: true }); + initialized.push('.claude-shared'); + } + + const settingsFile = path.join(claudeDir, 'settings.json'); + if (!fs.existsSync(settingsFile)) { + fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON); + initialized.push('settings.json'); + } + + const skillsDst = path.join(claudeDir, 'skills'); + if (!fs.existsSync(skillsDst)) { + const skillsSrc = path.join(projectRoot, 'container', 'skills'); + if (fs.existsSync(skillsSrc)) { + fs.cpSync(skillsSrc, skillsDst, { recursive: true }); + initialized.push('skills/'); + } + } + + // 3. data/v2-sessions//agent-runner-src/ — per-group source copy + const groupRunnerDir = path.join(DATA_DIR, 'v2-sessions', group.id, 'agent-runner-src'); + if (!fs.existsSync(groupRunnerDir)) { + const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src'); + if (fs.existsSync(agentRunnerSrc)) { + fs.cpSync(agentRunnerSrc, groupRunnerDir, { recursive: true }); + initialized.push('agent-runner-src/'); + } + } + + if (initialized.length > 0) { + log.info('Initialized group filesystem', { + group: group.name, + folder: group.folder, + id: group.id, + steps: initialized, + }); + } +}