Compare commits

..

1 Commits

Author SHA1 Message Date
Omri Maya 2cfa86e570 feat(memory): opt-in persistent memory scaffold for providers
Adds a provider capability (usesMemoryScaffold) and a container-side boot
scaffold that materializes a persistent memory/ tree for providers that opt
in. Dormant for the default provider — the scaffold is only built when a
provider declares the capability, so existing installs are byte-identical
(asserted by a boot-gate wiring test).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 11:30:09 +03:00
11 changed files with 199 additions and 278 deletions
+7
View File
@@ -27,6 +27,7 @@ import { fileURLToPath } from 'url';
import { loadConfig } from './config.js';
import { buildSystemPromptAddendum } from './destinations.js';
import { ensureMemoryScaffold } from './memory-scaffold.js';
// Providers barrel — each enabled provider self-registers on import.
// Provider skills append imports to providers/index.ts.
import './providers/index.js';
@@ -95,6 +96,12 @@ async function main(): Promise<void> {
effort: config.effort,
});
// Providers that lack native memory opt in via `usesMemoryScaffold`; for them
// the runner creates a persistent memory/ tree in its host-backed workspace at
// boot (idempotent). Default off — the trunk default (Claude) omits the flag
// and keeps its native memory untouched.
if (provider.usesMemoryScaffold) ensureMemoryScaffold();
await runPollLoop({
provider,
providerName,
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import os from 'os';
import path from 'path';
import { ensureMemoryScaffold } from './memory-scaffold.js';
describe('ensureMemoryScaffold', () => {
it('deterministically creates the memory tree', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
expect(fs.existsSync(path.join(base, 'memory', 'index.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'system', 'definition.md'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'memories'))).toBe(true);
expect(fs.existsSync(path.join(base, 'memory', 'data'))).toBe(true);
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
it('is idempotent and never clobbers the agent edits', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'nanoclaw-mem-'));
try {
ensureMemoryScaffold(base);
const indexFile = path.join(base, 'memory', 'index.md');
fs.writeFileSync(indexFile, '# my own index\n');
ensureMemoryScaffold(base);
expect(fs.readFileSync(indexFile, 'utf-8')).toBe('# my own index\n');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,39 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
/**
* Create the agent's persistent memory scaffold, container-side, at boot.
*
* The runner owns its own workspace: it writes the memory tree straight into
* `/workspace/agent` (the host-backed, RW group dir, so it persists across the
* ephemeral container). No host-side step, nothing mounted in.
*
* The default `definition.md` / `index.md` live as real markdown templates next
* to this module (under `memory-templates/`) — not as strings in code — so the
* doctrine is editable as markdown and the agent receives an unescaped copy.
* They ship in the mounted `/app/src` tree, so no image change is needed.
*
* Idempotent — only writes what's missing, so the agent's own edits and
* accumulated memory are never clobbered on a later wake. Provider-agnostic:
* the runner makes no assumption about which harness is running — a provider
* opts in via `usesMemoryScaffold`.
*/
const TEMPLATES_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), 'memory-templates');
export function ensureMemoryScaffold(baseDir = '/workspace/agent'): void {
const memoryDir = path.join(baseDir, 'memory');
const systemDir = path.join(memoryDir, 'system');
for (const dir of [systemDir, path.join(memoryDir, 'memories'), path.join(memoryDir, 'data')]) {
fs.mkdirSync(dir, { recursive: true });
}
copyTemplateIfMissing('definition.md', path.join(systemDir, 'definition.md'));
copyTemplateIfMissing('index.md', path.join(memoryDir, 'index.md'));
}
function copyTemplateIfMissing(template: string, dest: string): void {
if (fs.existsSync(dest)) return;
fs.copyFileSync(path.join(TEMPLATES_DIR, template), dest);
}
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'bun:test';
import fs from 'fs';
import path from 'path';
// Wiring guard for the memory-scaffold seam: the boot gate in index.ts
// (`if (provider.usesMemoryScaffold) ensureMemoryScaffold()`) is the seam's
// single functional reach-in. The unit tests in memory-scaffold.test.ts drive
// ensureMemoryScaffold directly and stay green if the gate is deleted — this
// test goes red. main() can't be driven in-process (it reads
// /workspace/agent/container.json and enters the poll loop), so the guard is
// structural: gate + import must both be present in the real entry point.
describe('memory scaffold boot wiring', () => {
const indexSrc = fs.readFileSync(path.join(import.meta.dir, 'index.ts'), 'utf-8');
it('gates the scaffold on the provider capability in main()', () => {
expect(indexSrc).toContain('if (provider.usesMemoryScaffold) ensureMemoryScaffold()');
});
it('imports ensureMemoryScaffold from the seam module', () => {
expect(indexSrc).toContain("import { ensureMemoryScaffold } from './memory-scaffold.js'");
});
});
@@ -0,0 +1,23 @@
# Agent Memory System
This editable file defines how your persistent memory works. It is a starting
point, not a contract — reorganize it as the work demands. If the user or another
memory system replaces this definition, follow the replacement.
Start every memory task at `memory/index.md`, then follow the narrowest relevant index.
Treat indexes as core data: keep them accurate and concise.
Every folder of durable memory has its own `index.md` describing its contents.
When an index grows past roughly 20 entries, group related items into subfolders,
and give each new subfolder its own `index.md` linked from the parent.
Use `memory/memories/` for durable facts, project context, people, decisions, and entity notes.
Use `memory/data/` for structured reference data, datasets, tables, and reusable records.
Use entity folders for things that matter: projects, people, places, organizations, decisions.
When the user shares something that should survive future turns, store it in the
smallest useful file; prefer updating an existing file over creating duplicates.
Write concise, source-aware notes; include dates when timing matters.
If a fact is corrected, update the memory and keep only useful history.
When you add, move, or remove memory, update the nearest index.
Before answering from memory, read the relevant index or file instead of guessing;
if memory is missing or uncertain, say so and verify when it matters.
@@ -0,0 +1,5 @@
# Memory Index
- [Memory system definition](system/definition.md)
- [Memories](memories/) - durable facts, people, projects, decisions
- [Data](data/) - structured reference data
@@ -6,6 +6,14 @@ export interface AgentProvider {
*/
readonly supportsNativeSlashCommands: boolean;
/**
* Optional. When true, the runner scaffolds a persistent `memory/` tree in the
* agent's workspace at boot. Providers with their own native memory (e.g.
* Claude's `CLAUDE.local.md`) omit this and get nothing — memory is opt-in per
* provider, never gated on a provider name.
*/
readonly usesMemoryScaffold?: boolean;
/** Start a new query. Returns a handle for streaming input and output. */
query(input: QueryInput): AgentQuery;
+34 -49
View File
@@ -36,7 +36,6 @@ import { validateAdditionalMounts } from './modules/mount-security/index.js';
import './providers/index.js';
import {
getProviderContainerConfig,
providerProvidesAgentSurfaces,
type ProviderContainerContribution,
type VolumeMount,
} from './providers/provider-container-registry.js';
@@ -128,19 +127,12 @@ async function spawnContainer(session: Session): Promise<void> {
// and buildContainerArgs so we don't re-read.
const containerConfig = materializeContainerJson(agentGroup.id);
// 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. Runs before the provider
// contribution so a surfaces-providing provider finds the group dir ready.
const providerName = resolveProviderName(session.agent_provider, containerConfig.provider);
initGroupFilesystem(agentGroup, { provider: providerName });
// Resolve the effective provider + any host-side contribution it declares
// (extra mounts, env passthrough). Computed once and threaded through both
// buildMounts and buildContainerArgs so side effects (mkdir, etc.) fire once.
const { provider, contribution } = resolveProviderContribution(session, agentGroup, containerConfig);
const mounts = buildMounts(agentGroup, session, containerConfig, provider, contribution);
const mounts = buildMounts(agentGroup, session, containerConfig, contribution);
const containerName = `nanoclaw-v2-${agentGroup.folder}-${Date.now()}`;
// OneCLI agent identifier is always the agent group id — stable across
// sessions and reversible via getAgentGroup() for approval routing.
@@ -242,37 +234,32 @@ function resolveProviderContribution(
? fn({
sessionDir: sessionDir(agentGroup.id, session.id),
agentGroupId: agentGroup.id,
groupDir: path.resolve(GROUPS_DIR, agentGroup.folder),
selectedSkills: selectedSkillNames(containerConfig),
hostEnv: process.env,
})
: {};
return { provider, contribution };
}
export function buildMounts(
function buildMounts(
agentGroup: AgentGroup,
session: Session,
containerConfig: import('./container-config.js').ContainerConfig,
provider: string,
providerContribution: ProviderContainerContribution,
): VolumeMount[] {
const projectRoot = process.cwd();
// Default agent surfaces (composed project doc, skill links, provider state
// dir) apply unless the provider's registration declares it provides its
// own — a capability, never a provider name. See provider-container-registry.
const defaultSurfaces = !providerProvidesAgentSurfaces(provider);
// 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.
initGroupFilesystem(agentGroup);
// Sync skill symlinks based on container.json selection before mounting.
const claudeDir = path.join(DATA_DIR, 'v2-sessions', agentGroup.id, '.claude-shared');
if (defaultSurfaces) {
// Sync skill symlinks based on container.json selection before mounting.
syncSkillSymlinks(claudeDir, containerConfig);
syncSkillSymlinks(claudeDir, containerConfig);
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
}
// Compose CLAUDE.md fresh every spawn from the shared base, enabled skill
// fragments, and MCP server instructions. See `claude-md-compose.ts`.
composeGroupClaudeMd(agentGroup);
const mounts: VolumeMount[] = [];
const sessDir = sessionDir(agentGroup.id, session.id);
@@ -299,11 +286,11 @@ export function buildMounts(
// already RO-mounted, so writes through it fail regardless — no need for
// a nested mount there.
const composedClaudeMd = path.join(groupDir, 'CLAUDE.md');
if (defaultSurfaces && fs.existsSync(composedClaudeMd)) {
if (fs.existsSync(composedClaudeMd)) {
mounts.push({ hostPath: composedClaudeMd, containerPath: '/workspace/agent/CLAUDE.md', readonly: true });
}
const fragmentsDir = path.join(groupDir, '.claude-fragments');
if (defaultSurfaces && fs.existsSync(fragmentsDir)) {
if (fs.existsSync(fragmentsDir)) {
mounts.push({ hostPath: fragmentsDir, containerPath: '/workspace/agent/.claude-fragments', readonly: true });
}
@@ -316,15 +303,13 @@ export function buildMounts(
// Shared CLAUDE.md — read-only, imported by the composed entry point via
// the `.claude-shared.md` symlink inside the group dir.
const sharedClaudeMd = path.join(process.cwd(), 'container', 'CLAUDE.md');
if (defaultSurfaces && fs.existsSync(sharedClaudeMd)) {
if (fs.existsSync(sharedClaudeMd)) {
mounts.push({ hostPath: sharedClaudeMd, containerPath: '/app/CLAUDE.md', readonly: true });
}
// Per-group .claude-shared at /home/node/.claude (Claude state, settings,
// skill symlinks)
if (defaultSurfaces) {
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
}
mounts.push({ hostPath: claudeDir, containerPath: '/home/node/.claude', readonly: false });
// Shared agent-runner source — read-only, same code for all groups.
const agentRunnerSrc = path.join(projectRoot, 'container', 'agent-runner', 'src');
@@ -361,7 +346,25 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
fs.mkdirSync(skillsDir, { recursive: true });
}
const desired = selectedSkillNames(containerConfig);
// Determine desired skill set
const projectRoot = process.cwd();
const sharedSkillsDir = path.join(projectRoot, 'container', 'skills');
let desired: string[];
if (containerConfig.skills === 'all') {
// Recompute from shared dir — newly-added upstream skills appear automatically
desired = fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
} else {
desired = containerConfig.skills;
}
const desiredSet = new Set(desired);
// Remove symlinks not in the desired set
@@ -394,24 +397,6 @@ function syncSkillSymlinks(claudeDir: string, containerConfig: import('./contain
}
}
/**
* Resolve the group's skill selection to concrete names — `'all'` recomputes
* from `container/skills/` so newly-added upstream skills appear automatically.
*/
function selectedSkillNames(containerConfig: import('./container-config.js').ContainerConfig): string[] {
if (containerConfig.skills !== 'all') return containerConfig.skills;
const sharedSkillsDir = path.join(process.cwd(), 'container', 'skills');
return fs.existsSync(sharedSkillsDir)
? fs.readdirSync(sharedSkillsDir).filter((e) => {
try {
return fs.statSync(path.join(sharedSkillsDir, e)).isDirectory();
} catch {
return false;
}
})
: [];
}
async function buildContainerArgs(
mounts: VolumeMount[],
containerName: string,
+20 -32
View File
@@ -4,7 +4,6 @@ import path from 'path';
import { DATA_DIR, GROUPS_DIR } from './config.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { log } from './log.js';
import { providerProvidesAgentSurfaces } from './providers/provider-container-registry.js';
import type { AgentGroup } from './types.js';
const DEFAULT_SETTINGS_JSON =
@@ -47,18 +46,9 @@ const DEFAULT_SETTINGS_JSON =
* spawn by `composeGroupClaudeMd()` (see `claude-md-compose.ts`). Initial
* per-group instructions (if provided) seed `CLAUDE.local.md`.
*/
export function initGroupFilesystem(
group: AgentGroup,
opts?: { instructions?: string; provider?: string | null },
): void {
export function initGroupFilesystem(group: AgentGroup, opts?: { instructions?: string }): void {
const initialized: string[] = [];
// Default agent surfaces apply unless the group's provider declares (at
// registration) that it provides its own. Callers that don't know the
// provider omit it — unregistered/unknown names report no capabilities,
// so the default surfaces are written, exactly as before this seam.
const defaultSurfaces = !providerProvidesAgentSurfaces(opts?.provider);
// 1. groups/<folder>/ — group memory + working dir
const groupDir = path.resolve(GROUPS_DIR, group.folder);
if (!fs.existsSync(groupDir)) {
@@ -69,7 +59,7 @@ export function initGroupFilesystem(
// groups/<folder>/CLAUDE.local.md — per-group agent memory, auto-loaded by
// Claude Code. Seeded with caller-provided instructions on first creation.
const claudeLocalFile = path.join(groupDir, 'CLAUDE.local.md');
if (defaultSurfaces && !fs.existsSync(claudeLocalFile)) {
if (!fs.existsSync(claudeLocalFile)) {
const body = opts?.instructions ? opts.instructions + '\n' : '';
fs.writeFileSync(claudeLocalFile, body);
initialized.push('CLAUDE.local.md');
@@ -81,28 +71,26 @@ export function initGroupFilesystem(
initialized.push('container_configs');
// 2. data/v2-sessions/<id>/.claude-shared/ — Claude state + per-group skills
if (defaultSurfaces) {
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 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');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
const settingsFile = path.join(claudeDir, 'settings.json');
if (!fs.existsSync(settingsFile)) {
fs.writeFileSync(settingsFile, DEFAULT_SETTINGS_JSON);
initialized.push('settings.json');
} else {
ensurePreCompactHook(settingsFile, initialized);
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
// Skills directory — created empty here; symlinks are synced at spawn
// time by container-runner.ts based on container.json skills selection.
const skillsDst = path.join(claudeDir, 'skills');
if (!fs.existsSync(skillsDst)) {
fs.mkdirSync(skillsDst, { recursive: true });
initialized.push('skills/');
}
if (initialized.length > 0) {
-143
View File
@@ -1,143 +0,0 @@
import fs from 'fs';
import path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const TEST_ROOT = '/tmp/nanoclaw-provider-surfaces-test';
const GROUPS_DIR = path.join(TEST_ROOT, 'groups');
const DATA_DIR = path.join(TEST_ROOT, 'data');
vi.mock('./config.js', async (importOriginal) => ({
...(await importOriginal<typeof import('./config.js')>()),
DATA_DIR: '/tmp/nanoclaw-provider-surfaces-test/data',
GROUPS_DIR: '/tmp/nanoclaw-provider-surfaces-test/groups',
}));
vi.mock('./log.js', () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
},
}));
import { buildMounts } from './container-runner.js';
import { closeDb, createAgentGroup, initTestDb, runMigrations } from './db/index.js';
import { ensureContainerConfig } from './db/container-configs.js';
import { initGroupFilesystem } from './group-init.js';
import { registerProviderContainerConfig } from './providers/provider-container-registry.js';
import type { ContainerConfig } from './container-config.js';
import type { AgentGroup, Session } from './types.js';
// A provider that declares (at registration) that it owns its agent surfaces.
// Registered once — the registry is module-global and rejects duplicates.
registerProviderContainerConfig('surfaces-test-provider', () => ({}), { providesAgentSurfaces: true });
function group(id: string, folder: string): AgentGroup {
return { id, name: folder, folder, agent_provider: null, created_at: new Date().toISOString() } as AgentGroup;
}
function session(id: string, agentGroupId: string): Session {
return { id, agent_group_id: agentGroupId } as Session;
}
function containerConfig(): ContainerConfig {
return { mcpServers: {}, packages: { apt: [], npm: [] }, additionalMounts: [], skills: [] };
}
beforeEach(() => {
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
fs.mkdirSync(TEST_ROOT, { recursive: true });
runMigrations(initTestDb());
});
afterEach(() => {
closeDb();
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
});
describe('initGroupFilesystem agent surfaces', () => {
it('writes the default surfaces when no provider is given (todays behavior)', () => {
const ag = group('ag-default', 'default-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const claudeDir = path.join(DATA_DIR, 'v2-sessions', ag.id, '.claude-shared');
expect(fs.readFileSync(path.join(groupDir, 'CLAUDE.local.md'), 'utf-8')).toBe('hello\n');
expect(fs.existsSync(path.join(claudeDir, 'settings.json'))).toBe(true);
expect(fs.existsSync(path.join(claudeDir, 'skills'))).toBe(true);
});
it('skips the default surfaces for a provider that provides its own', () => {
const ag = group('ag-surfy', 'surfy-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { instructions: 'hello', provider: 'surfaces-test-provider' });
const groupDir = path.join(GROUPS_DIR, ag.folder);
const sessionRoot = path.join(DATA_DIR, 'v2-sessions', ag.id);
expect(fs.existsSync(groupDir)).toBe(true);
expect(fs.existsSync(path.join(groupDir, 'CLAUDE.local.md'))).toBe(false);
expect(fs.existsSync(path.join(sessionRoot, '.claude-shared'))).toBe(false);
});
it('treats an unregistered provider name as default surfaces', () => {
const ag = group('ag-unknown', 'unknown-group');
createAgentGroup(ag);
initGroupFilesystem(ag, { provider: 'not-registered' });
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.local.md'))).toBe(true);
});
});
describe('buildMounts agent surfaces', () => {
it('mounts the default surfaces for an unregistered provider (todays behavior)', () => {
const ag = group('ag-mounts-default', 'mounts-default');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, {});
const mounts = buildMounts(ag, session('s1', ag.id), containerConfig(), 'claude', {});
const byContainerPath = new Map(mounts.map((m) => [m.containerPath, m]));
expect(byContainerPath.has('/home/node/.claude')).toBe(true);
expect(byContainerPath.has('/app/CLAUDE.md')).toBe(true);
expect(byContainerPath.has('/workspace/agent/CLAUDE.md')).toBe(true);
// Composer ran: the generated project doc exists on disk.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(true);
});
it('suppresses the default surfaces and keeps contributed mounts for a surfaces-providing provider', () => {
const ag = group('ag-mounts-surfy', 'mounts-surfy');
createAgentGroup(ag);
ensureContainerConfig(ag.id);
initGroupFilesystem(ag, { provider: 'surfaces-test-provider' });
const contributed = {
mounts: [
{
hostPath: path.join(GROUPS_DIR, ag.folder),
containerPath: '/workspace/agent/OWN-DOC.md',
readonly: true,
},
],
};
const mounts = buildMounts(ag, session('s2', ag.id), containerConfig(), 'surfaces-test-provider', contributed);
const containerPaths = mounts.map((m) => m.containerPath);
expect(containerPaths).not.toContain('/home/node/.claude');
expect(containerPaths).not.toContain('/app/CLAUDE.md');
expect(containerPaths).not.toContain('/workspace/agent/CLAUDE.md');
// Composer did NOT run for this group.
expect(fs.existsSync(path.join(GROUPS_DIR, ag.folder, 'CLAUDE.md'))).toBe(false);
// Core mounts and the provider's own contribution are intact.
expect(containerPaths).toContain('/workspace');
expect(containerPaths).toContain('/workspace/agent');
expect(containerPaths).toContain('/app/src');
expect(containerPaths).toContain('/workspace/agent/OWN-DOC.md');
});
});
+4 -54
View File
@@ -27,19 +27,6 @@ export interface ProviderContainerContext {
sessionDir: string;
/** Agent group ID, for any per-group logic. */
agentGroupId: string;
/**
* Per-group host directory: `<GROUPS_DIR>/<folder>` (mounted RW at
* `/workspace/agent`). Exists by the time the config fn runs — group
* filesystem init happens first. Surfaces-providing providers compose
* their project doc and skill links here.
*/
groupDir: string;
/**
* Skill names selected by the group's container config, with `'all'`
* already resolved against `container/skills/`. Surfaces-providing
* providers use this to sync their own skill-discovery links.
*/
selectedSkills: string[];
/** `process.env` at spawn time — pull passthrough values from here. */
hostEnv: NodeJS.ProcessEnv;
}
@@ -51,56 +38,19 @@ export interface ProviderContainerContribution {
env?: Record<string, string>;
}
/**
* Static capabilities a provider declares at registration time — knowable
* without a spawn context, so any host path (group init, spawn, creation
* flows) can consult them by name.
*/
export interface ProviderHostCapabilities {
/**
* Optional. When true, this provider owns its agent-facing surfaces — the
* composed project doc, skill-discovery links, and provider state dir —
* and the host must NOT compose or mount the default ones (composed
* CLAUDE.md, `.claude-fragments`, `/app/CLAUDE.md`, `/home/node/.claude`,
* `CLAUDE.local.md` seeding). The provider's config fn does its own
* composing and returns its own mounts. Default off — providers that omit
* this get the default surfaces, which is today's behavior.
*/
readonly providesAgentSurfaces?: boolean;
}
export type ProviderContainerConfigFn = (ctx: ProviderContainerContext) => ProviderContainerContribution;
interface RegistryEntry {
fn: ProviderContainerConfigFn;
capabilities: ProviderHostCapabilities;
}
const registry = new Map<string, ProviderContainerConfigFn>();
const registry = new Map<string, RegistryEntry>();
export function registerProviderContainerConfig(
name: string,
fn: ProviderContainerConfigFn,
capabilities: ProviderHostCapabilities = {},
): void {
export function registerProviderContainerConfig(name: string, fn: ProviderContainerConfigFn): void {
if (registry.has(name)) {
throw new Error(`Provider container config already registered: ${name}`);
}
registry.set(name, { fn, capabilities });
registry.set(name, fn);
}
export function getProviderContainerConfig(name: string): ProviderContainerConfigFn | undefined {
return registry.get(name)?.fn;
}
/**
* Capability lookup by provider name. Unregistered providers (including the
* baked-in default) report no capabilities — the host applies its default
* surfaces, exactly as before this seam existed.
*/
export function providerProvidesAgentSurfaces(name: string | null | undefined): boolean {
if (!name) return false;
return registry.get(name)?.capabilities.providesAgentSurfaces === true;
return registry.get(name);
}
export function listProviderContainerConfigNames(): string[] {