mirror of
https://github.com/qwibitai/nanoclaw.git
synced 2026-06-04 10:14:47 +08:00
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:
@@ -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<void> {
|
||||
const providerName = (process.env.AGENT_PROVIDER || 'claude') as ProviderName;
|
||||
@@ -44,14 +43,11 @@ async function main(): Promise<void> {
|
||||
|
||||
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[] = [];
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}`);
|
||||
+15
-42
@@ -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
|
||||
|
||||
+6
-8
@@ -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".
|
||||
|
||||
@@ -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/<folder>/ — 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/<folder>/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/<id>/.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/<id>/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,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user