import type { McpServerConfig } from '../../container-config.js'; import { buildAgentGroupImage, killContainer, wakeContainer } from '../../container-runner.js'; import { restartAgentGroupContainers } from '../../container-restart.js'; import { getSession } from '../../db/sessions.js'; import { writeSessionMessage } from '../../session-manager.js'; import { getContainerConfig, updateContainerConfigScalars, updateContainerConfigJson, } from '../../db/container-configs.js'; import type { ContainerConfigRow } from '../../types.js'; import { registerResource } from '../crud.js'; /** Deserialize JSON columns for display. */ function presentConfig(row: ContainerConfigRow): Record { 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, 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 }, ], operations: { list: 'open', get: 'open', create: 'approval', update: 'approval', delete: 'approval' }, customOperations: { 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 scalar 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.', 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; } if (Object.keys(updates).length === 0) { throw new Error( 'Nothing to update — provide at least one of: --provider, --model, --effort, --image-tag, --assistant-name, --max-messages-per-prompt, --cli-scope', ); } 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.', }; }, }, }, });