refactor(v2): per-group filesystem init, persistent across spawns

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/<folder>/, .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) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-13 14:17:07 +03:00
parent 8676c07448
commit 2e6dc21748
6 changed files with 188 additions and 59 deletions

View File

@@ -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