import { randomUUID } from 'crypto'; import type { McpServerConfig } from '../../container-config.js'; import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { restartAgentGroupContainers } from '../../container-restart.js'; import { createAgentGroup } from '../../db/agent-groups.js'; import { getDb, hasTable } from '../../db/connection.js'; import { getSession } from '../../db/sessions.js'; import { writeSessionMessage } from '../../session-manager.js'; import { getContainerConfig, updateContainerConfigScalars, updateContainerConfigJson, } from '../../db/container-configs.js'; import { createAgentFromTemplate } from '../../templates/create-agent.js'; import { parseHarnessCapabilitiesArg, resolveHarnessCapabilities } from '../../harness-capabilities.js'; import type { AgentGroup, ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; /** Raw override map from the stored JSON; malformed degrades to {} (resolve warns). */ function harnessOverrides(row: ContainerConfigRow): Record { try { const parsed: unknown = JSON.parse(row.harness_capabilities); return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : {}; } catch (err) { if (!(err instanceof SyntaxError)) throw err; return {}; } } /** Deserialize JSON columns for display. */ function presentConfig(row: ContainerConfigRow): Record { const overrides = harnessOverrides(row); const resolved = resolveHarnessCapabilities(row.harness_capabilities); return { agent_group_id: row.agent_group_id, provider: row.provider, model: row.model, effort: row.effort, image_tag: row.image_tag, assistant_name: row.assistant_name, max_messages_per_prompt: row.max_messages_per_prompt, skills: JSON.parse(row.skills), mcp_servers: JSON.parse(row.mcp_servers), packages_apt: JSON.parse(row.packages_apt), packages_npm: JSON.parse(row.packages_npm), additional_mounts: JSON.parse(row.additional_mounts), cli_scope: row.cli_scope, harness_capabilities: overrides, harness_capabilities_resolved: Object.fromEntries( Object.entries(resolved).map(([k, v]) => [k, `${v} (${k in overrides ? 'override' : 'default'})`]), ), updated_at: row.updated_at, }; } registerResource({ name: 'group', plural: 'groups', table: 'agent_groups', description: 'Agent group — a logical agent identity. Each group has its own workspace folder (CLAUDE.md, skills, container config), conversation history, and container image. Multiple messaging groups can be wired to one agent group.', idColumn: 'id', scopeField: 'id', columns: [ { name: 'id', type: 'string', description: 'UUID.', generated: true }, { name: 'name', type: 'string', description: 'Display name shown in logs, help output, and channel adapters. Does not need to be unique.', required: true, updatable: true, }, { name: 'folder', type: 'string', description: 'Directory name under groups/ on the host. Must be unique. Contains CLAUDE.md, skills/, and container.json. Cannot be changed after creation.', required: true, }, { name: 'created_at', type: 'string', description: 'Auto-set.', generated: true }, ], // `create` and `delete` are intentionally not in `operations` — create needs // a `--template` branch (below); the generic single-table DELETE violates FK // constraints (see #2525). Both are provided as `customOperations`. operations: { list: 'open', get: 'open', update: 'approval' }, customOperations: { create: { access: 'approval', description: 'Create an agent group. With --template , stamp from a local template under templates/ ' + '(MCP servers + instructions + skills); else insert a bare row (--name, --folder).', handler: async (args) => { if (args.template) { return createAgentFromTemplate(String(args.template), { name: args.name ? String(args.name) : undefined, }); } const name = args.name ? String(args.name) : ''; const folder = args.folder ? String(args.folder) : ''; if (!name) throw new Error('--name is required'); if (!folder) throw new Error('--folder is required'); const group: AgentGroup = { id: randomUUID(), name, folder, agent_provider: null, created_at: new Date().toISOString(), }; createAgentGroup(group); return group; }, }, delete: { access: 'approval', description: 'Delete an agent group and its dependent rows (sessions, destinations, approvals, role grants, ' + 'memberships, channel wirings). FK-ordered cascade in a single transaction. ' + 'Use --id . Out of scope: killing running containers, on-disk cleanup of groups// and data/v2-sessions//.', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const db = getDb(); // Verify the group exists before doing anything — preserves the // genericDelete behaviour of throwing "not found" for unknown IDs. const exists = db.prepare('SELECT 1 FROM agent_groups WHERE id = ? LIMIT 1').get(id); if (!exists) throw new Error(`group not found: ${id}`); const hasAgentDestinations = hasTable(db, 'agent_destinations'); const hasPendingApprovals = hasTable(db, 'pending_approvals'); // FK-ordered cascade. Single sync transaction — better-sqlite3 rolls // back the whole thing if any statement throws (e.g. an FK constraint // we missed), so the central DB stays consistent. The `removed` counts // are sourced from each DELETE's `changes` so they describe exactly // what the transaction did, not a separate pre-flight snapshot. const cascade = db.transaction((groupId: string) => { const counts = { sessions: 0, pending_questions: 0, pending_approvals: 0, agent_destinations_owned: 0, agent_destinations_pointing: 0, pending_sender_approvals: 0, pending_channel_approvals: 0, messaging_group_agents: 0, agent_group_members: 0, user_roles: 0, container_configs: 0, }; if (hasAgentDestinations) { counts.agent_destinations_owned = db .prepare('DELETE FROM agent_destinations WHERE agent_group_id = ?') .run(groupId).changes; counts.agent_destinations_pointing = db .prepare('DELETE FROM agent_destinations WHERE target_type = ? AND target_id = ?') .run('agent', groupId).changes; } counts.pending_questions = db .prepare( 'DELETE FROM pending_questions WHERE session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)', ) .run(groupId).changes; if (hasPendingApprovals) { counts.pending_approvals = db .prepare( 'DELETE FROM pending_approvals WHERE agent_group_id = ? OR session_id IN (SELECT id FROM sessions WHERE agent_group_id = ?)', ) .run(groupId, groupId).changes; } counts.sessions = db.prepare('DELETE FROM sessions WHERE agent_group_id = ?').run(groupId).changes; counts.pending_sender_approvals = db .prepare('DELETE FROM pending_sender_approvals WHERE agent_group_id = ?') .run(groupId).changes; counts.pending_channel_approvals = db .prepare('DELETE FROM pending_channel_approvals WHERE agent_group_id = ?') .run(groupId).changes; counts.messaging_group_agents = db .prepare('DELETE FROM messaging_group_agents WHERE agent_group_id = ?') .run(groupId).changes; counts.agent_group_members = db .prepare('DELETE FROM agent_group_members WHERE agent_group_id = ?') .run(groupId).changes; counts.user_roles = db.prepare('DELETE FROM user_roles WHERE agent_group_id = ?').run(groupId).changes; // migration-014 has ON DELETE CASCADE on container_configs.agent_group_id; // the explicit delete here mirrors the other tables and surfaces the count. counts.container_configs = db .prepare('DELETE FROM container_configs WHERE agent_group_id = ?') .run(groupId).changes; db.prepare('DELETE FROM agent_groups WHERE id = ?').run(groupId); return counts; }); const removed = cascade(id); return { deleted: id, removed }; }, }, restart: { access: 'approval', description: 'Restart containers for a group. Use --id [--rebuild] [--message ]. ' + 'From inside a container, --id is auto-filled and only the calling session is restarted. ' + '--rebuild rebuilds the container image first (required for package changes). ' + '--message sets an on-wake instruction for the fresh container to act on when it starts — ' + 'use this when you need to continue after the restart (e.g. verify a new tool works, notify the user). ' + 'Without --message, the container stops and only starts again on the next user message.', handler: async (args, ctx) => { const id = (args.id as string) || (ctx.caller === 'agent' ? ctx.agentGroupId : undefined); if (!id) throw new Error('--id is required'); if (args.rebuild) { await buildAgentGroupImage(id); } const message = args.message as string | undefined; // From an agent: scope to the calling session only if (ctx.caller === 'agent') { if (message) { writeSessionMessage(id, ctx.sessionId, { id: `restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, kind: 'chat', timestamp: new Date().toISOString(), platformId: id, channelType: 'agent', threadId: null, content: JSON.stringify({ text: message, sender: 'system', senderId: 'system' }), onWake: 1, }); } killContainer( ctx.sessionId, 'restarted via ncl', message ? () => { const s = getSession(ctx.sessionId); if (s) wakeContainer(s); } : undefined, ); return { restarted: 1, rebuilt: !!args.rebuild }; } // From the host: restart all running containers in the group const count = restartAgentGroupContainers(id, 'restarted via ncl', message); return { restarted: count, rebuilt: !!args.rebuild }; }, }, 'config get': { access: 'open', description: 'Show the container config for a group. Use --id .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); return presentConfig(row); }, }, 'config update': { access: 'approval', description: 'Update container config fields. Changes are saved but do NOT take effect until you run `ncl groups restart`. ' + 'Use --id and any of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, ' + "--harness-capabilities 'key=on|off|default[,key=...]' (keys: agent-teams, workflow; `default` clears the per-group override).", handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); const updates: Partial< Pick< ContainerConfigRow, 'provider' | 'model' | 'effort' | 'image_tag' | 'assistant_name' | 'max_messages_per_prompt' | 'cli_scope' > > = {}; if (args.provider !== undefined) updates.provider = args.provider as string; if (args.model !== undefined) updates.model = args.model as string; if (args.effort !== undefined) updates.effort = args.effort as string; if (args.image_tag !== undefined) updates.image_tag = args.image_tag as string; if (args.assistant_name !== undefined) updates.assistant_name = args.assistant_name as string; if (args.max_messages_per_prompt !== undefined) updates.max_messages_per_prompt = Number(args.max_messages_per_prompt); if (args['cli-scope'] !== undefined || args.cli_scope !== undefined) { const scope = (args['cli-scope'] ?? args.cli_scope) as string; if (!['disabled', 'group', 'global'].includes(scope)) { throw new Error('--cli-scope must be one of: disabled, group, global'); } updates.cli_scope = scope; } // Harness capabilities are a JSON column of sparse overrides — apply // set/clear ops on the stored map (`default` clears; never stored). let harnessChanged = false; const capsArg = (args['harness-capabilities'] ?? args.harness_capabilities) as string | undefined; if (capsArg !== undefined) { const ops = parseHarnessCapabilitiesArg(String(capsArg)); const overrides = harnessOverrides(row); for (const key of ops.clear) delete overrides[key]; Object.assign(overrides, ops.set); updateContainerConfigJson(id, 'harness_capabilities', overrides); harnessChanged = true; } if (Object.keys(updates).length === 0 && !harnessChanged) { throw new Error( 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope, --harness-capabilities', ); } if (Object.keys(updates).length > 0) updateContainerConfigScalars(id, updates); const updated = getContainerConfig(id)!; return presentConfig(updated); }, }, 'config add-mcp-server': { access: 'approval', description: 'Add an MCP server to a group. Requires `ncl groups restart` to take effect. ' + 'Use --id --name --command [--args ] [--env ].', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const name = args.name as string; if (!name) throw new Error('--name is required'); const command = args.command as string; if (!command) throw new Error('--command is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); const servers = JSON.parse(row.mcp_servers) as Record; servers[name] = { command, args: args.args ? (JSON.parse(args.args as string) as string[]) : [], env: args.env ? (JSON.parse(args.env as string) as Record) : {}, }; updateContainerConfigJson(id, 'mcp_servers', servers); return { added: name, servers }; }, }, 'config remove-mcp-server': { access: 'approval', description: 'Remove an MCP server from a group. Requires `ncl groups restart` to take effect. Use --id --name .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const name = args.name as string; if (!name) throw new Error('--name is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); const servers = JSON.parse(row.mcp_servers) as Record; if (!servers[name]) throw new Error(`MCP server "${name}" not found`); delete servers[name]; updateContainerConfigJson(id, 'mcp_servers', servers); return { removed: name }; }, }, 'config add-package': { access: 'approval', description: 'Add a package to a group. Requires `ncl groups restart --rebuild` to take effect. Use --id and --apt or --npm .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); const apt = args.apt as string | undefined; const npm = args.npm as string | undefined; if (!apt && !npm) throw new Error('Provide --apt or --npm '); if (apt) { const existing = JSON.parse(row.packages_apt) as string[]; if (!existing.includes(apt)) { existing.push(apt); updateContainerConfigJson(id, 'packages_apt', existing); } } if (npm) { const existing = JSON.parse(row.packages_npm) as string[]; if (!existing.includes(npm)) { existing.push(npm); updateContainerConfigJson(id, 'packages_npm', existing); } } return { added: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for packages to take effect. Use install_packages from the agent or rebuild manually.', }; }, }, 'config remove-package': { access: 'approval', description: 'Remove a package from a group. Requires `ncl groups restart --rebuild` to take effect. Use --id and --apt or --npm .', handler: async (args) => { const id = args.id as string; if (!id) throw new Error('--id is required'); const row = getContainerConfig(id); if (!row) throw new Error(`No container config for group: ${id}`); const apt = args.apt as string | undefined; const npm = args.npm as string | undefined; if (!apt && !npm) throw new Error('Provide --apt or --npm '); if (apt) { const existing = JSON.parse(row.packages_apt) as string[]; const filtered = existing.filter((p) => p !== apt); updateContainerConfigJson(id, 'packages_apt', filtered); } if (npm) { const existing = JSON.parse(row.packages_npm) as string[]; const filtered = existing.filter((p) => p !== npm); updateContainerConfigJson(id, 'packages_npm', filtered); } return { removed: { apt: apt || null, npm: npm || null }, note: 'Image rebuild required for package changes to take effect.', }; }, }, }, });