diff --git a/connectors/claude-code-plugin/skills/switch/SKILL.md b/connectors/claude-code-plugin/skills/switch/SKILL.md index 8c0d877b5..5b3b17488 100644 --- a/connectors/claude-code-plugin/skills/switch/SKILL.md +++ b/connectors/claude-code-plugin/skills/switch/SKILL.md @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/connectors/codex-plugin/skills/switch/SKILL.md b/connectors/codex-plugin/skills/switch/SKILL.md index fbaf9a7f9..c95b4dc3b 100644 --- a/connectors/codex-plugin/skills/switch/SKILL.md +++ b/connectors/codex-plugin/skills/switch/SKILL.md @@ -788,6 +788,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/connectors/opencode-plugin/skills/switch/SKILL.md b/connectors/opencode-plugin/skills/switch/SKILL.md index cb274a1ec..6e8a6070c 100644 --- a/connectors/opencode-plugin/skills/switch/SKILL.md +++ b/connectors/opencode-plugin/skills/switch/SKILL.md @@ -791,6 +791,7 @@ failure-mode tools are covered in the sections just above. - `list_room_groups` — the group tree rooms are organised into. - `get_room_group_detail` — one group's rooms and child groups. - `create_room_group` — provision a new room group. +- `create_room_from_yaml` — provision a room or group from a YAML template. - `list_agents` — every agent on the instance, with optional filters. - `get_agent_detail` — one agent's config, capabilities and sessions. - `update_agent_detail` — change an agent you own. diff --git a/console/apps/switch-console-desktop/electron.vite.config.ts b/console/apps/switch-console-desktop/electron.vite.config.ts index b471b950f..7ce88c869 100644 --- a/console/apps/switch-console-desktop/electron.vite.config.ts +++ b/console/apps/switch-console-desktop/electron.vite.config.ts @@ -61,6 +61,10 @@ export default defineConfig({ // Per-instance so two dev builds from different worktrees can run at // once; the main process follows through ELECTRON_RENDERER_URL. port: Number(process.env.SWITCH_CONSOLE_RENDERER_PORT) || 3000, + // The bundled Switch expert template is read from `switch-expert/` at + // the repository root, outside this app's directory, so the dev server + // has to be allowed to serve it; the production build inlines it. + fs: { allow: [resolve('../../..')] }, }, }, }); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/agent-template-format.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/agent-template-format.ts new file mode 100644 index 000000000..faf81d0a7 --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/agent-template-format.ts @@ -0,0 +1,167 @@ +import { basename, join } from 'node:path'; +import { load } from 'js-yaml'; +import { composeTemplateDocument, serverDocument } from './template-document'; + +/** + * An agent template is a YAML document with an `agent:` block and, optionally, + * a `room:` and `kickoff:` in the room-template shape. The format is described + * field by field in `switch-expert/template.yaml` at the repository root. + */ +export type AgentTemplateSource = { url: string; label: string | null }; + +/** Who may address the agent: only its owner, its owner and their agents, or anyone in its rooms. */ +export type AgentTemplateAddressing = 'owner' | 'owner-agents' | 'anyone'; + +export type ParsedAgentTemplate = { + /** The agent name from the template's `name` field. The deployer can change it before creating. */ + name: string | null; + /** Null when the template has no `addressing` field; the Console's default applies (only its owner). */ + addressing: AgentTemplateAddressing | null; + description: string; + instructions: string; + /** Repository the agent works from. The Console offers to clone it into the + * agent's directory; when that is off or fails, the agent clones it itself. */ + repoUrl: string | null; + /** Pages the agent should read. Shown to the deployer; the agent fetches them itself. */ + sources: AgentTemplateSource[]; + /** The room the agent is put in once it exists, when the template declares one. */ + room: { name: string | null; kickoff: string | null } | null; + /** A provider id (`claude`, `codex`, `opencode`) or a `{param}` whose value is one. Null when the template has no `provider` field. */ + provider: string | null; + warnings: string[]; +}; + +function parseYaml(yamlText: string): Record { + let doc: unknown; + try { + doc = load(yamlText); + } catch (e) { + throw new Error(`Invalid YAML: ${e instanceof Error ? e.message : String(e)}`); + } + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error('Template must be a YAML mapping'); + } + return doc as Record; +} + +export function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +const ADDRESSING_VALUES: ReadonlySet = new Set(['owner', 'owner-agents', 'anyone']); + +export function optionalString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +export function extractSources(raw: unknown): AgentTemplateSource[] { + if (!Array.isArray(raw)) return []; + return raw.flatMap((entry) => { + if (typeof entry === 'string') + return optionalString(entry) ? [{ url: entry, label: null }] : []; + const record = asRecord(entry); + const url = record ? optionalString(record.url) : null; + if (!url) return []; + return [{ url, label: record ? optionalString(record.label) : null }]; + }); +} + +/** + * Remove a leading YAML front matter block from instructions. Claude Code + * agent files start with one, and the Console writes its own when it + * renders the agent's definition file, so one inside the instructions + * would be written twice. + */ +export function stripFrontMatter(instructions: string): string { + const match = /^---\r?\n[\s\S]*?\r?\n---\r?\n?/.exec(instructions); + return match ? instructions.slice(match[0].length).replace(/^\s*\n/, '') : instructions; +} + +/** + * Parse a single-agent template. + * + * `fallbackInstructions` is used when the document has no `instructions:`. + * The bundled Switch expert keeps its instructions in `AGENT.md` next to + * the template, and the Console passes that file's content here. + */ +export function parseAgentTemplate( + yamlText: string, + fallbackInstructions: string | null = null +): ParsedAgentTemplate { + const doc = parseYaml(yamlText); + const agent = asRecord(doc.agent); + if (!agent) { + throw new Error('Template must have an "agent:" block.'); + } + const instructions = stripFrontMatter( + typeof agent.instructions === 'string' && agent.instructions.trim().length > 0 + ? agent.instructions + : (fallbackInstructions ?? '') + ); + if (instructions.trim().length === 0) { + throw new Error('The "agent:" block needs "instructions:" — the agent has nothing to go on.'); + } + + const warnings: string[] = []; + const room = asRecord(doc.room); + const kickoff = optionalString(doc.kickoff); + if (kickoff && !room) { + warnings.push('`kickoff:` needs a `room:` to be posted into; without one it is ignored.'); + } + if (typeof agent.kickoff === 'string' || typeof agent.room === 'object') { + warnings.push('`room:` and `kickoff:` belong at the top level, beside `agent:`.'); + } + + const addressing = optionalString(agent.addressing); + if (addressing !== null && !ADDRESSING_VALUES.has(addressing)) { + warnings.push( + `\`addressing: ${addressing}\` is not one of owner, owner-agents, anyone; the agent will answer only its owner.` + ); + } + + return { + name: optionalString(agent.name), + addressing: ADDRESSING_VALUES.has(addressing ?? '') + ? (addressing as AgentTemplateAddressing) + : null, + description: typeof agent.description === 'string' ? agent.description.trim() : '', + instructions, + repoUrl: optionalString(agent.repo), + sources: extractSources(agent.sources), + room: room ? { name: optionalString(room.name), kickoff } : null, + provider: optionalString(agent.provider), + warnings, + }; +} + +export function agentTemplateRoomDocument(yamlText: string): string | null { + return serverDocument(yamlText); +} + +export function composeAgentTemplateDocument(yamlText: string, instructions: string): string { + return composeTemplateDocument(yamlText, instructions); +} + +/** A folder named after the repository, inside `dir`. */ +export function cloneDirectory(dir: string, repoUrl: string): string { + const name = basename(repoUrl.replace(/\/+$/, '')).replace(/\.git$/, ''); + return join(dir, name || 'repo'); +} + +/** + * `base`, or the first of `base-2`, `base-3`, … that does not already hold + * an agent (a `.switch/` directory). A directory left behind by a removed + * agent still holds that agent's credentials, and creating an agent refuses + * to overwrite them, so the suggestion moves to a free directory instead of + * failing at creation time. + */ +export async function firstFreeDirectory( + base: string, + isTaken: (dir: string) => Promise +): Promise { + let candidate = base; + for (let i = 2; await isTaken(candidate); i++) candidate = `${base}-${i}`; + return candidate; +} diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.test.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.test.ts new file mode 100644 index 000000000..5cb48f3f2 --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.test.ts @@ -0,0 +1,173 @@ +import { load } from 'js-yaml'; +import { describe, expect, it } from 'vitest'; +import { + agentTemplateRoomDocument, + cloneDirectory, + composeAgentTemplateDocument, + firstFreeDirectory, + parseAgentTemplate, + stripFrontMatter, +} from './agent-template-format'; + +const SWITCH_EXPERT = ` +version: 1 +agent: + name: switch-expert + description: Answers questions about Switch. + instructions: | + You are switch-expert. + repo: https://github.com/sandbox-quantum/switch + sources: + - label: Stand up a Switch expert + url: https://docs.flintai.dev/flintai/switch/getting-started/switch-expert + - https://docs.flintai.dev +room: + name: "Ask {agent}" + agents: ["{agent}"] + users: ["{$creator}"] +kickoff: | + @{agent} hi. +`; + +describe('parseAgentTemplate', () => { + it('reads the agent block, the repo and the sources', () => { + const t = parseAgentTemplate(SWITCH_EXPERT); + expect(t.name).toBe('switch-expert'); + expect(t.description).toBe('Answers questions about Switch.'); + expect(t.instructions).toContain('You are switch-expert.'); + expect(t.repoUrl).toBe('https://github.com/sandbox-quantum/switch'); + expect(t.sources).toEqual([ + { + label: 'Stand up a Switch expert', + url: 'https://docs.flintai.dev/flintai/switch/getting-started/switch-expert', + }, + { label: null, url: 'https://docs.flintai.dev' }, + ]); + expect(t.room).toEqual({ name: 'Ask {agent}', kickoff: '@{agent} hi.' }); + expect(t.warnings).toEqual([]); + }); + + it('is fine without a room, a repo or sources', () => { + const t = parseAgentTemplate('agent:\n description: d\n instructions: i\n'); + expect(t.name).toBeNull(); + expect(t.repoUrl).toBeNull(); + expect(t.sources).toEqual([]); + expect(t.room).toBeNull(); + }); + + it('reads who may address the agent, and warns about a value it does not know', () => { + expect(parseAgentTemplate('agent:\n instructions: i\n addressing: anyone\n').addressing).toBe( + 'anyone' + ); + expect(parseAgentTemplate('agent:\n instructions: i\n').addressing).toBeNull(); + const odd = parseAgentTemplate('agent:\n instructions: i\n addressing: everyone\n'); + expect(odd.addressing).toBeNull(); + expect(odd.warnings[0]).toMatch(/addressing/); + }); + + it('refuses a document with no agent block', () => { + expect(() => parseAgentTemplate('room:\n name: r\n')).toThrow(/"agent:" block/); + }); + + it('refuses an agent with no instructions', () => { + expect(() => parseAgentTemplate('agent:\n name: a\n description: d\n')).toThrow( + /instructions/ + ); + }); + + it('refuses text that is not YAML', () => { + expect(() => parseAgentTemplate('agent: [')).toThrow(/Invalid YAML/); + }); + + it('warns about a kickoff with no room to land in', () => { + const t = parseAgentTemplate('agent:\n instructions: i\nkickoff: hi\n'); + expect(t.warnings).toHaveLength(1); + expect(t.warnings[0]).toMatch(/kickoff/); + }); +}); + +describe('stripFrontMatter', () => { + it('drops a leading front matter block and keeps the body', () => { + expect(stripFrontMatter('---\nname: x\ndescription: y\n---\n\nYou are x.\n')).toBe( + 'You are x.\n' + ); + }); + + it('leaves instructions without front matter alone', () => { + expect(stripFrontMatter('You are x.\n---\nnot front matter\n')).toBe( + 'You are x.\n---\nnot front matter\n' + ); + }); + + it('applies to the fallback instructions too', () => { + const t = parseAgentTemplate('agent:\n description: d\n', '---\nname: a\n---\nBody.\n'); + expect(t.instructions).toBe('Body.\n'); + }); +}); + +describe('agentTemplateRoomDocument', () => { + it('turns the room half into a room template with the agent as a declared param', () => { + const yaml = agentTemplateRoomDocument(SWITCH_EXPERT); + expect(yaml).not.toBeNull(); + const doc = load(yaml!) as Record; + expect(Object.keys(doc).sort()).toEqual(['kickoff', 'params', 'room', 'version']); + expect((doc.params as Record).agent).toMatchObject({ type: 'string' }); + expect(doc.room).toEqual({ + name: 'Ask {agent}', + agents: ['{agent}'], + users: ['{$creator}'], + }); + expect(doc.kickoff).toBe('@{agent} hi.\n'); + expect(doc).not.toHaveProperty('agent'); + }); + + it('keeps params the template declares itself', () => { + const yaml = agentTemplateRoomDocument( + 'agent:\n instructions: i\nparams:\n topic:\n type: string\nroom:\n name: "{topic}"\n' + ); + const doc = load(yaml!) as { params: Record }; + expect(Object.keys(doc.params).sort()).toEqual(['agent', 'topic']); + }); + + it('is null when the template has no room', () => { + expect(agentTemplateRoomDocument('agent:\n instructions: i\n')).toBeNull(); + }); +}); + +describe('composeAgentTemplateDocument', () => { + it('inlines the persona, minus its front matter, and keeps the rest of the document', () => { + const out = composeAgentTemplateDocument( + 'agent:\n name: a\n repo: https://x/y\nroom:\n name: r\n', + '---\nname: a\n---\nBody.\n' + ); + const doc = load(out) as { agent: Record; room: Record }; + expect(doc.agent.instructions).toBe('Body.\n'); + expect(doc.agent.repo).toBe('https://x/y'); + expect(doc.room).toEqual({ name: 'r' }); + expect(parseAgentTemplate(out).instructions).toBe('Body.\n'); + }); + + it('leaves inline instructions alone', () => { + const out = composeAgentTemplateDocument('agent:\n instructions: mine\n', 'other'); + expect((load(out) as { agent: { instructions: string } }).agent.instructions).toBe('mine'); + }); +}); + +describe('cloneDirectory', () => { + it('names the clone after the repository', () => { + expect(cloneDirectory('/w/switch-expert', 'https://github.com/sandbox-quantum/switch')).toBe( + '/w/switch-expert/switch' + ); + expect(cloneDirectory('/w', 'https://github.com/jqlang/jq.git')).toBe('/w/jq'); + expect(cloneDirectory('/w', 'git@github.com:jqlang/jq.git')).toBe('/w/jq'); + expect(cloneDirectory('/w', 'https://example.com/repo/')).toBe('/w/repo'); + }); +}); + +describe('firstFreeDirectory', () => { + it('keeps the base when nothing lives there, and steps past folders that hold an agent', async () => { + expect(await firstFreeDirectory('/w/a', async () => false)).toBe('/w/a'); + const taken = new Set(['/w/a', '/w/a-2']); + expect(await firstFreeDirectory('/w/a', async (d) => taken.has(d))).toBe('/w/a-3'); + }); +}); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.ts new file mode 100644 index 000000000..78df75f2a --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/controller.ts @@ -0,0 +1,236 @@ +import { execFile } from 'node:child_process'; +import { mkdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { SshExecutionContext } from '@main/core/execution-context/ssh-execution-context'; +import type { IExecutionContext } from '@main/core/execution-context/types'; +import { sshConnectionIdForHost } from '@main/core/locations/location-transport'; +import { appSettingsService } from '@main/core/settings/settings-service'; +import { ensureSshConnected } from '@main/core/ssh/connect/connect-agent-ssh'; +import { resolveRemoteHome } from '@main/core/ssh/lifecycle/remote-shell-profile'; +import { getGitExecutable } from '@main/core/utils/exec'; +import { buildExternalToolEnv } from '@main/utils/childProcessEnv'; +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { + agentTemplateRoomDocument, + cloneDirectory, + composeAgentTemplateDocument, + firstFreeDirectory, + parseAgentTemplate, + type ParsedAgentTemplate, +} from './agent-template-format'; +import { + serverDocument, + dropUnsetParams, + parseTemplateAgents, + substituteAgentSlots, + type TemplateAgents, + templateKind, + type TemplateKind, +} from './template-document'; +import { summarizeTemplate, type TemplateSummary } from './template-summary'; + +export type { AgentTemplateSource, ParsedAgentTemplate } from './agent-template-format'; +export type { ParsedAgentEntry, TemplateAgents, TemplateKind } from './template-document'; +export type { TemplateEntity, TemplateSummary } from './template-summary'; + +const execFileAsync = promisify(execFile); + +// Only URL forms git can clone. A value starting with `-` would be read by +// git as a command-line option, not a URL. +const CLONEABLE_URL = /^(https?:\/\/|git@|ssh:\/\/)[^\s-]/; + +export type PrepareWorkspaceResult = { + dir: string; + /** The outcome of the clone, when the template has a `repo` field. */ + repo: { + target: string; + outcome: 'cloned' | 'present' | 'failed'; + error: string | null; + } | null; +}; + +async function isDirectory(path: string): Promise { + try { + return (await stat(path)).isDirectory(); + } catch { + return false; + } +} + +async function remoteContext(sshHost: string): Promise { + const proxy = await ensureSshConnected(sshConnectionIdForHost(sshHost), sshHost); + return new SshExecutionContext(proxy); +} + +// On a host, agents get the same one-folder-per-agent layout as on this +// machine, under the host's home. The Console's default on this machine is +// `~/.switch/agents`. +const REMOTE_LOCATIONS_DIR = '.switch/agents'; + +function failedRepo(dir: string, target: string, e: unknown): PrepareWorkspaceResult { + const stderr = (e as { stderr?: string }).stderr; + const message = + typeof stderr === 'string' && stderr.trim().length > 0 + ? stderr.trim() + : e instanceof Error + ? e.message + : String(e); + return { dir, repo: { target, outcome: 'failed', error: message } }; +} + +/** Make the directory and put a shallow clone inside, on a host reached over SSH. */ +async function prepareRemoteWorkspace( + sshHost: string, + dir: string, + repoUrl: string | null +): Promise { + const ctx = await remoteContext(sshHost); + try { + await ctx.exec('mkdir', ['-p', dir]); + if (!repoUrl) return { dir, repo: null }; + const target = cloneDirectory(dir, repoUrl); + const probe = await ctx.exec('sh', [ + '-c', + `test -d "$1" && echo present || echo absent`, + 'sh', + target, + ]); + if (probe.stdout.trim() === 'present') { + return { dir, repo: { target, outcome: 'present', error: null } }; + } + if (!CLONEABLE_URL.test(repoUrl)) { + return { + dir, + repo: { + target, + outcome: 'failed', + error: `Not a URL git can clone: ${repoUrl}`, + }, + }; + } + try { + await ctx.exec('git', ['clone', '--quiet', '--depth', '1', '--', repoUrl, target]); + return { dir, repo: { target, outcome: 'cloned', error: null } }; + } catch (e) { + return failedRepo(dir, target, e); + } + } finally { + ctx.dispose(); + } +} + +export const agentTemplatesController = createRPCController({ + parse: (params: { yamlText: string; instructions?: string | null }): ParsedAgentTemplate => + parseAgentTemplate(params.yamlText, params.instructions ?? null), + + roomDocument: (params: { yamlText: string }): string | null => + agentTemplateRoomDocument(params.yamlText), + + /** What a document of any known shape creates, for a listing card. */ + summarize: (params: { yamlText: string }): TemplateSummary => summarizeTemplate(params.yamlText), + + /** Classify a document as an agent, room or group template. */ + kind: (params: { yamlText: string }): TemplateKind => templateKind(params.yamlText), + + /** The agents a document creates. Empty for a room template. */ + parseAgents: (params: { yamlText: string; instructions?: string | null }): TemplateAgents => + parseTemplateAgents(params.yamlText, params.instructions ?? null), + + /** The room part of a document as the server receives it, or null when the document has no rooms. */ + serverDocument: (params: { yamlText: string; keepConsoleParams?: boolean }): string | null => + serverDocument(params.yamlText, { keepConsoleParams: params.keepConsoleParams }), + + /** The server document with the named params removed (see `dropUnsetParams`). */ + dropParams: (params: { coreYaml: string; names: string[] }): string => + dropUnsetParams(params.coreYaml, params.names), + + /** The server document with agent names replaced (see `substituteAgentSlots`). */ + substituteSlots: (params: { coreYaml: string; replacements: Record }): string => + substituteAgentSlots(params.coreYaml, params.replacements), + + /** The document with every agent's instructions inlined, ready to store on a server. */ + compose: (params: { yamlText: string; instructions: string }): string => + composeAgentTemplateDocument(params.yamlText, params.instructions), + + /** The default working directory for an agent of this name: a folder named + * after it under the Console's locations directory, or under the host's + * home when `sshHost` is given. */ + suggestDirectory: async (params: { + agentName: string; + sshHost?: string | null; + }): Promise => { + if (params.sshHost) { + const ctx = await remoteContext(params.sshHost); + try { + const home = await resolveRemoteHome(ctx); + const base = `${home.replace(/\/+$/, '')}/${REMOTE_LOCATIONS_DIR}/${params.agentName}`; + // `return await`, not `return`: with a bare `return` the `finally` + // below would dispose the SSH context before the probes finished. + return await firstFreeDirectory(base, async (dir) => { + // Both outcomes exit 0. The runner treats a non-zero exit as a + // failed command, and "free" is an answer, not a failure. + const probe = await ctx.exec('sh', [ + '-c', + 'test -e "$1/.switch" && echo taken || echo free', + 'sh', + dir, + ]); + return probe.stdout.trim() === 'taken'; + }); + } finally { + ctx.dispose(); + } + } + const { defaultLocationsDirectory } = await appSettingsService.get('localLocation'); + return firstFreeDirectory(join(defaultLocationsDirectory, params.agentName), (dir) => + isDirectory(join(dir, '.switch')) + ); + }, + + /** + * Create the working directory and, when the template has a `repo` field, + * put a shallow clone of it inside. A failed clone is reported in the + * result rather than thrown, so the agent is still created and clones the + * repository itself. + */ + prepareWorkspace: async (params: { + dir: string; + repoUrl: string | null; + sshHost?: string | null; + }): Promise => { + if (params.sshHost) return prepareRemoteWorkspace(params.sshHost, params.dir, params.repoUrl); + await mkdir(params.dir, { recursive: true }); + if (!params.repoUrl) return { dir: params.dir, repo: null }; + const target = cloneDirectory(params.dir, params.repoUrl); + if (await isDirectory(target)) { + return { + dir: params.dir, + repo: { target, outcome: 'present', error: null }, + }; + } + if (!CLONEABLE_URL.test(params.repoUrl)) { + return { + dir: params.dir, + repo: { + target, + outcome: 'failed', + error: `Not a URL git can clone: ${params.repoUrl}`, + }, + }; + } + try { + await execFileAsync( + getGitExecutable(), + ['clone', '--quiet', '--depth', '1', '--', params.repoUrl, target], + { env: buildExternalToolEnv(), timeout: 5 * 60 * 1000 } + ); + return { + dir: params.dir, + repo: { target, outcome: 'cloned', error: null }, + }; + } catch (e) { + return failedRepo(params.dir, target, e); + } + }, +}); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.test.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.test.ts new file mode 100644 index 000000000..974c5158d --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.test.ts @@ -0,0 +1,194 @@ +import { load } from 'js-yaml'; +import { describe, expect, it } from 'vitest'; +import { + composeTemplateDocument, + serverDocument, + dropUnsetParams, + parseTemplateAgents, + substituteAgentSlots, + templateKind, +} from './template-document'; + +const TRIAGE_PAIR = ` +version: 1 +params: + team: + type: string + description: Prefix for every name this creates + provider: + type: provider + default: claude + bridge: + type: bridge +agents: + - name: "{team}-triager" + description: Reads reports, files and routes them + instructions: You triage. + provider: "{provider}" + - name: "{team}-repro" + description: Reproduces what the triager routes to it + instructions: You reproduce. + repo: https://github.com/sandbox-quantum/switch +room: + name: "{team}-triage" + bridge: "{bridge}" + agents: ["{team}-triager", "{team}-repro"] + aliases: + "{team}-triager": triager +kickoff: Start. +`; + +const SOLO = ` +agent: + name: jq-expert + instructions: You know jq. +room: + name: "Ask {agent}" + agents: ["{agent}"] +`; + +describe('templateKind', () => { + it('tells the three shapes apart', () => { + expect(templateKind(TRIAGE_PAIR)).toBe('group'); + expect(templateKind(SOLO)).toBe('agent'); + expect(templateKind('room:\n name: r\n')).toBe('room'); + expect(templateKind('group:\n name: g\nrooms:\n - name: a\n')).toBe('group'); + }); +}); + +describe('parseTemplateAgents', () => { + it('reads every agent of a list, provider included', () => { + const { agents, warnings } = parseTemplateAgents(TRIAGE_PAIR); + expect(warnings).toEqual([]); + expect(agents.map((a) => a.name)).toEqual(['{team}-triager', '{team}-repro']); + expect(agents[0].provider).toBe('{provider}'); + expect(agents[1].provider).toBeNull(); + expect(agents[1].repoUrl).toBe('https://github.com/sandbox-quantum/switch'); + }); + + it('reads a lone agent and fills its instructions from the fallback', () => { + const { agents } = parseTemplateAgents('agent:\n name: x\n', 'Persona.'); + expect(agents).toHaveLength(1); + expect(agents[0].instructions).toBe('Persona.'); + }); + + it('tells a lone agent: from a one-entry list', () => { + expect(parseTemplateAgents('agent:\n name: a\n instructions: i\n').singular).toBe(true); + expect(parseTemplateAgents('agents:\n - name: a\n instructions: i\n').singular).toBe(false); + }); + + it('gives a room template no agents', () => { + expect(parseTemplateAgents('room:\n name: r\n').agents).toEqual([]); + }); + + it('refuses a listed agent with nothing to go on', () => { + expect(() => + parseTemplateAgents('agents:\n - name: a\n instructions: ok\n - name: b\n') + ).toThrow(/agent 2 needs "instructions:"/); + }); +}); + +describe('serverDocument', () => { + it('keeps the server half and drops agents and provider params', () => { + const doc = load(serverDocument(TRIAGE_PAIR) ?? '') as Record; + expect(Object.keys(doc).sort()).toEqual(['kickoff', 'params', 'room', 'version']); + expect(Object.keys(doc.params as object)).toEqual(['team', 'bridge']); + expect((doc.room as { agents: string[] }).agents).toEqual(['{team}-triager', '{team}-repro']); + }); + + it('keeps provider params when asked, for the form', () => { + const doc = load(serverDocument(TRIAGE_PAIR, { keepConsoleParams: true }) ?? '') as { + params: Record; + }; + expect(Object.keys(doc.params)).toEqual(['team', 'provider', 'bridge']); + }); + + it('drops prefill, which a server that predates the key refuses', () => { + const doc = load( + serverDocument( + [ + 'params:', + ' bridge:', + ' type: bridge', + ' description: Where the room lives', + ' prefill: first', + 'room:', + ' name: n', + ' description: d', + ' bridge: "{bridge}"', + ].join('\n'), + { keepConsoleParams: true } + ) ?? '' + ) as { params: Record }; + expect(doc.params.bridge).toEqual({ type: 'bridge', description: 'Where the room lives' }); + }); + + it('declares {agent} for a lone agent', () => { + const doc = load(serverDocument(SOLO) ?? '') as { params: Record }; + expect(Object.keys(doc.params)).toEqual(['agent']); + }); + + it('keeps a group document whole', () => { + const text = + 'group:\n name: g\nrooms:\n - name: a\n kickoff: hi\nlinks: []\nagents:\n - name: x\n instructions: i\n'; + const doc = load(serverDocument(text) ?? '') as Record; + expect(Object.keys(doc).sort()).toEqual(['group', 'links', 'rooms']); + }); + + it('keeps a misplaced group kickoff for the server to refuse', () => { + const doc = load( + serverDocument('group:\n name: g\nrooms:\n - name: a\nkickoff: hi\n') ?? '' + ) as Record; + expect(doc.kickoff).toBe('hi'); + }); + + it('is null without a room half', () => { + expect(serverDocument('agent:\n name: a\n instructions: i\n')).toBeNull(); + }); +}); + +describe('substituteAgentSlots', () => { + it('renames agents in every room, aliases included', () => { + const core = serverDocument(TRIAGE_PAIR) ?? ''; + const out = load(substituteAgentSlots(core, { '{team}-triager': 'claude-code.alice' })) as { + room: { agents: string[]; aliases: Record }; + }; + expect(out.room.agents).toEqual(['claude-code.alice', '{team}-repro']); + expect(out.room.aliases).toEqual({ 'claude-code.alice': 'triager' }); + }); +}); + +describe('substituteAgentSlots kickoffs', () => { + it('renames the mentions in a kickoff too', () => { + const core = + 'room:\n name: r\n agents: ["{team}-a"]\n kickoff: "@{team}-a go"\nkickoff: "@{team}-a hi"\n'; + const out = load(substituteAgentSlots(core, { '{team}-a': 'alpha-a-2' })) as { + room: { kickoff: string }; + kickoff: string; + }; + expect(out.kickoff).toBe('@alpha-a-2 hi'); + expect(out.room.kickoff).toBe('@alpha-a-2 go'); + }); +}); + +describe('dropUnsetParams', () => { + it('removes the declaration and the room fields that read it', () => { + const out = load( + dropUnsetParams( + 'params:\n bridge:\n type: bridge\n team:\n type: string\nroom:\n name: "{team}"\n bridge: "{bridge}"\n', + ['bridge'] + ) + ) as { params: Record; room: Record }; + expect(Object.keys(out.params)).toEqual(['team']); + expect(out.room).toEqual({ name: '{team}' }); + }); +}); + +describe('composeTemplateDocument', () => { + it('fills every agent missing instructions', () => { + const out = load( + composeTemplateDocument('agents:\n - name: a\n - name: b\n instructions: own\n', 'P') + ) as { agents: { instructions: string }[] }; + expect(out.agents.map((a) => a.instructions)).toEqual(['P', 'own']); + }); +}); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.ts new file mode 100644 index 000000000..49c0a289b --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-document.ts @@ -0,0 +1,307 @@ +import { dump, load } from 'js-yaml'; +import { + type AgentTemplateAddressing, + type AgentTemplateSource, + extractSources, + optionalString, + stripFrontMatter, +} from './agent-template-format'; + +/** + * Helpers for a template document. + * + * The Console creates the agents listed under `agent:` or `agents:`, because + * an agent runs on a machine the Console can reach and the server cannot. + * The server creates the rooms under `room:`, or `group:` with `rooms:`, + * through `POST /rooms/from-yaml`. + * + * A room's `agents:` list refers to those agents by the text written in the + * template, `{team}-triager` for example, before any `{param}` is filled in. + * The helpers here compare that unfilled text to match a room entry with + * the agent it refers to. + * + * `switch-expert/template.yaml` at the repository root documents every field. + */ +export type TemplateKind = 'agent' | 'room' | 'group'; + +export type ParsedAgentEntry = { + /** The name as written in the template, with any `{param}` still unfilled. */ + name: string | null; + description: string; + instructions: string; + repoUrl: string | null; + sources: AgentTemplateSource[]; + addressing: AgentTemplateAddressing | null; + /** + * Which coding agent runs it. Either a provider id (`claude`, `codex`, + * `opencode`) or a `{param}` whose value is one. Null when the template + * has no `provider` field; the Use page asks for one. + */ + provider: string | null; +}; + +export type TemplateAgents = { + agents: ParsedAgentEntry[]; + /** + * True when the document uses the singular `agent:` form. That form has + * one extra convention: its room refers to the agent as `{agent}`, and the + * Console fills that in with the agent's final name. + */ + singular: boolean; + warnings: string[]; +}; + +const ADDRESSING_VALUES: ReadonlySet = new Set(['owner', 'owner-agents', 'anyone']); + +export function parseYaml(yamlText: string): Record { + let doc: unknown; + try { + doc = load(yamlText); + } catch (e) { + throw new Error(`Invalid YAML: ${e instanceof Error ? e.message : String(e)}`); + } + if (doc === null || typeof doc !== 'object' || Array.isArray(doc)) { + throw new Error('Template must be a YAML mapping'); + } + return doc as Record; +} + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +/** The agent entries as parsed YAML objects, from `agents:` (a list) or `agent:` (a single entry). */ +function rawAgents(doc: Record): Record[] { + if (Array.isArray(doc.agents)) { + return doc.agents.map((a) => asRecord(a)).filter((a): a is Record => !!a); + } + const one = asRecord(doc.agent); + return one ? [one] : []; +} + +/** + * Classify a document. `group`: it has `group:` or `rooms:`, or more than + * one agent. `agent`: one agent, with or without a room. `room`: a room and + * no agents. + */ +export function templateKind(yamlText: string): TemplateKind { + return kindOf(parseYaml(yamlText)); +} + +export function kindOf(doc: Record): TemplateKind { + const agents = rawAgents(doc); + if (doc.group !== undefined || Array.isArray(doc.rooms)) return 'group'; + if (agents.length > 1) return 'group'; + if (agents.length === 1) return 'agent'; + return 'room'; +} + +/** + * Parse every agent entry of a document. + * + * `fallbackInstructions` is used when a single `agent:` has no + * `instructions:` of its own. The bundled Switch expert is the case: its + * instructions live in `AGENT.md` next to the template rather than inside + * it, and the Console passes that file's content here. + */ +export function parseTemplateAgents( + yamlText: string, + fallbackInstructions: string | null = null +): TemplateAgents { + const doc = parseYaml(yamlText); + const entries = rawAgents(doc); + const warnings: string[] = []; + if (Array.isArray(doc.agents) && entries.length !== doc.agents.length) { + warnings.push('Every entry of `agents:` must be a mapping with a `name:`.'); + } + const hasRoom = asRecord(doc.room) !== null || Array.isArray(doc.rooms); + if (typeof doc.kickoff === 'string' && !hasRoom) { + warnings.push('`kickoff:` needs a `room:` to be posted into; without one it is ignored.'); + } + const agents = entries.map((agent, i) => { + const label = entries.length === 1 ? '`agent:`' : `agent ${i + 1}`; + const inline = + typeof agent.instructions === 'string' && agent.instructions.trim().length > 0 + ? agent.instructions + : entries.length === 1 + ? (fallbackInstructions ?? '') + : ''; + const instructions = stripFrontMatter(inline); + if (instructions.trim().length === 0) { + throw new Error(`${label} needs "instructions:" — the agent has nothing to go on.`); + } + if (typeof agent.kickoff === 'string' || typeof agent.room === 'object') { + warnings.push('`room:` and `kickoff:` belong at the top level, beside `agent:`.'); + } + const addressing = optionalString(agent.addressing); + if (addressing !== null && !ADDRESSING_VALUES.has(addressing)) { + warnings.push( + `\`addressing: ${addressing}\` is not one of owner, owner-agents, anyone; the agent will answer only its owner.` + ); + } + return { + name: optionalString(agent.name), + description: typeof agent.description === 'string' ? agent.description.trim() : '', + instructions, + repoUrl: optionalString(agent.repo), + sources: extractSources(agent.sources), + addressing: ADDRESSING_VALUES.has(addressing ?? '') + ? (addressing as AgentTemplateAddressing) + : null, + provider: optionalString(agent.provider), + }; + }); + return { agents, singular: !Array.isArray(doc.agents) && agents.length === 1, warnings }; +} + +function isProviderParam(spec: unknown): boolean { + const record = asRecord(spec); + return record !== null && record.type === 'provider'; +} + +/** + * Build the document the server receives: the room part only, in the shape + * `POST /rooms/from-yaml` validates. + * + * Kept: `room:` (or `group:`, `rooms:`, `links:`), `params:`, `kickoff:`, + * `version:`. Dropped: the agent entries, which the server does not + * understand, and any `type: provider` param, which only the Console can + * answer. A param's `prefill` key is dropped too: the form has already + * applied it, and a server that predates the key refuses the document. + * For the singular `agent:` form, an `agent` param is added so the room's + * `{agent}` reference resolves on the server. + * + * Returns null when the document has no room part. + */ +export function serverDocument( + yamlText: string, + options: { keepConsoleParams?: boolean } = {} +): string | null { + const doc = parseYaml(yamlText); + const room = asRecord(doc.room); + const isGroup = doc.group !== undefined || Array.isArray(doc.rooms); + if (!room && !isGroup) return null; + + // The Use page also parses this document to build its form, and the form + // must show provider params. Only the copy sent to the server drops them. + const declared = Object.fromEntries( + Object.entries(asRecord(doc.params) ?? {}) + .filter(([, spec]) => options.keepConsoleParams || !isProviderParam(spec)) + .map(([name, spec]) => { + const record = asRecord(spec); + if (record === null || record.prefill === undefined) return [name, spec]; + const { prefill: _prefill, ...rest } = record; + return [name, rest]; + }) + ); + const params: Record = + asRecord(doc.agent) !== null && !Array.isArray(doc.agents) + ? { agent: { type: 'string', description: 'The agent this room is for' }, ...declared } + : declared; + + const out: Record = {}; + if (typeof doc.version === 'number') out.version = doc.version; + if (Object.keys(params).length > 0) out.params = params; + if (isGroup) { + if (doc.group !== undefined) out.group = doc.group; + out.rooms = doc.rooms ?? []; + if (doc.links !== undefined) out.links = doc.links; + } else { + out.room = room; + } + // A top-level kickoff on a group document is a mistake the server reports + // with a clear message. Passing it through lets the deployer see that message. + if (doc.kickoff !== undefined) out.kickoff = doc.kickoff; + return dump(out, { lineWidth: -1 }); +} + +/** + * Replace agent names in the server document. + * + * `replacements` maps a name as written in the template (`{team}-triager`) + * to the name the agent has. Two situations need this: the deployer + * chose an existing agent for that slot instead of creating one, or the + * intended name was taken and the agent was created as `name-2`. + * + * Every place a room refers to an agent is updated: the `agents:` list, the + * keys of `aliases:`, and mentions inside `kickoff:` text. In kickoff text + * the name is replaced wherever it occurs, with or without a leading `@`, + * so a mention still names an agent that exists. + */ +export function substituteAgentSlots( + coreYaml: string, + replacements: Record +): string { + const doc = parseYaml(coreYaml); + const rename = (name: unknown) => + typeof name === 'string' && Object.hasOwn(replacements, name) ? replacements[name] : name; + const inText = (text: unknown) => { + if (typeof text !== 'string') return text; + let out = text; + for (const [from, to] of Object.entries(replacements)) out = out.split(from).join(to); + return out; + }; + const rooms = [asRecord(doc.room), ...(Array.isArray(doc.rooms) ? doc.rooms.map(asRecord) : [])]; + for (const room of rooms) { + if (!room) continue; + if (Array.isArray(room.agents)) room.agents = room.agents.map(rename); + const aliases = asRecord(room.aliases); + if (aliases) { + room.aliases = Object.fromEntries( + Object.entries(aliases).map(([name, alias]) => [String(rename(name)), alias]) + ); + } + if (room.kickoff !== undefined) room.kickoff = inText(room.kickoff); + } + if (doc.kickoff !== undefined) doc.kickoff = inText(doc.kickoff); + return dump(doc, { lineWidth: -1 }); +} + +/** + * Remove params the deployer left empty from the server document, both the + * declaration under `params:` and every room field set to `{name}`. + * + * This exists for `bridge` params. The server treats a missing `bridge:` as + * "use the default messaging app", so leaving the input empty should produce + * a room with no `bridge:` field rather than a validation error. + */ +export function dropUnsetParams(coreYaml: string, names: string[]): string { + if (names.length === 0) return coreYaml; + const doc = parseYaml(coreYaml); + const params = asRecord(doc.params); + if (params) { + for (const name of names) delete params[name]; + if (Object.keys(params).length === 0) delete doc.params; + } + const placeholders = new Set(names.map((n) => `{${n}}`)); + const rooms = [asRecord(doc.room), ...(Array.isArray(doc.rooms) ? doc.rooms.map(asRecord) : [])]; + for (const room of rooms) { + if (!room) continue; + for (const [key, value] of Object.entries(room)) { + if (typeof value === 'string' && placeholders.has(value)) delete room[key]; + } + } + return dump(doc, { lineWidth: -1 }); +} + +/** + * Inline `instructions` into every agent entry that lacks them. + * + * Used when saving a bundled template to a server. The bundled Switch expert + * keeps its instructions in a separate file; a copy stored on the server + * must be self-contained. YAML comments are lost in the process, field + * values are kept. + */ +export function composeTemplateDocument(yamlText: string, instructions: string): string { + const doc = parseYaml(yamlText); + const entries = rawAgents(doc); + if (entries.length === 0) throw new Error('Template must have an "agent:" block.'); + for (const agent of entries) { + if (typeof agent.instructions !== 'string' || agent.instructions.trim().length === 0) { + agent.instructions = stripFrontMatter(instructions); + } + } + return dump(doc, { lineWidth: -1 }); +} diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.test.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.test.ts new file mode 100644 index 000000000..9af288f32 --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest'; +import { describeSummary, summarizeTemplate, type TemplateSummary } from './template-summary'; + +const counts = ({ creates: _creates, ...rest }: TemplateSummary) => rest; + +describe('summarizeTemplate', () => { + it('counts an agent template with a companion room as one of each', () => { + const s = summarizeTemplate(` +agent: + name: switch-expert +room: + name: "Ask {agent}" + agents: ["{agent}", helper] +`); + expect(counts(s)).toEqual({ kind: 'agent', rooms: 1, agents: 1, inputs: 0 }); + expect(describeSummary(s)).toEqual({ + creates: 'Creates 1 room and 1 agent', + inputs: 'no inputs', + }); + }); + + it('counts an agent template without a room as one agent', () => { + expect(counts(summarizeTemplate('agent:\n name: jq-expert\n'))).toEqual({ + kind: 'agent', + rooms: 0, + agents: 1, + inputs: 0, + }); + }); + + it('counts a room template by its rooms and params, not the agents it names', () => { + const s = summarizeTemplate(` +params: + red: { type: agent } + blue: { type: agent } + topic: { type: string } +room: + name: "{topic}" + agents: ["{red}", "{blue}", judge] +`); + expect(counts(s)).toEqual({ kind: 'room', rooms: 1, agents: 0, inputs: 3 }); + expect(describeSummary(s).creates).toBe('Creates 1 room'); + expect(describeSummary(s).inputs).toBe('3 inputs'); + }); + + it('counts a rooms-only group by its rooms', () => { + const s = summarizeTemplate(` +params: + lead: { type: agent } +rooms: + - name: plan + agents: ["{lead}", scribe] + - name: build + agents: ["{lead}", coder] +`); + expect(counts(s)).toEqual({ kind: 'group', rooms: 2, agents: 0, inputs: 1 }); + }); + + it('does not throw on a document that is not YAML', () => { + expect(counts(summarizeTemplate('{{{'))).toEqual({ + kind: 'room', + rooms: 1, + agents: 0, + inputs: 0, + }); + }); +}); + +describe('summarizeTemplate creates', () => { + it('lists rooms then agents, with the template spelling', () => { + const s = summarizeTemplate(` +agents: + - name: "{team}-triager" + description: Reads reports + - name: "{team}-repro" +room: + name: "{team}-triage" + agents: ["{team}-triager", "{team}-repro"] +`); + expect(s.kind).toBe('group'); + expect(s.creates.map((c) => [c.kind, c.name])).toEqual([ + ['room', '{team}-triage'], + ['agent', '{team}-triager'], + ['agent', '{team}-repro'], + ]); + expect(s.creates[0].description).toBe('With {team}-triager, {team}-repro'); + expect(s.creates[1].description).toBe('Reads reports'); + }); +}); diff --git a/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.ts b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.ts new file mode 100644 index 000000000..57568ebbd --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/core/agent-templates/template-summary.ts @@ -0,0 +1,123 @@ +import { load } from 'js-yaml'; + +/** + * What a template document creates: the counts a listing card shows + * ("Creates 1 room and 1 agent · 4 inputs") and the rooms and agents a + * template page lists under "What it creates". + */ +export type TemplateSummary = { + kind: 'agent' | 'room' | 'group'; + rooms: number; + agents: number; + inputs: number; + /** Each room and agent it creates, rooms first. */ + creates: TemplateEntity[]; +}; + +export type TemplateEntity = { + kind: 'room' | 'agent'; + /** As written in the template, with any `{param}` still unfilled. */ + name: string; + /** From the template, or a generated line when it has none. */ + description: string; +}; + +function asRecord(value: unknown): Record | null { + return value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function str(value: unknown): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function agentsOf(room: Record | null): string[] { + if (!room) return []; + const raw = room.agents; + if (!Array.isArray(raw)) return []; + return raw.map((a) => { + const r = asRecord(a); + return String(r?.name ?? a); + }); +} + +function roomThing(room: Record): TemplateEntity { + const members = agentsOf(room); + const description = + str(room.description) || + (members.length > 0 ? `With ${members.join(', ')}` : 'A room with nobody in it yet'); + return { kind: 'room', name: str(room.name) || 'Unnamed room', description }; +} + +export function summarizeTemplate(yamlText: string): TemplateSummary { + // Text that is not YAML is summarized as one empty room, so the listing + // card still renders; the Use page reports the parse error. + let doc: unknown; + try { + doc = load(yamlText); + } catch { + doc = null; + } + const root = asRecord(doc) ?? {}; + const inputs = Object.keys(asRecord(root.params) ?? {}).length; + + const agentEntries: Record[] = Array.isArray(root.agents) + ? root.agents.map(asRecord).filter((a): a is Record => !!a) + : asRecord(root.agent) + ? [asRecord(root.agent) as Record] + : []; + const roomEntries: Record[] = Array.isArray(root.rooms) + ? root.rooms.map(asRecord).filter((r): r is Record => !!r) + : asRecord(root.room) + ? [asRecord(root.room) as Record] + : []; + const isGroup = root.group !== undefined || Array.isArray(root.rooms) || agentEntries.length > 1; + + const creates: TemplateEntity[] = [ + ...roomEntries.map(roomThing), + ...agentEntries.map((a) => ({ + kind: 'agent' as const, + name: str(a.name) || 'Named when created', + description: str(a.description) || 'An agent with its own instructions', + })), + ]; + + if (agentEntries.length > 0) { + // Only the entries under `agent:` or `agents:` are created. Other names in + // a room's list refer to agents the server must already have. + return { + kind: isGroup ? 'group' : 'agent', + rooms: roomEntries.length, + agents: agentEntries.length, + inputs, + creates, + }; + } + // A room or group document without `agents:` creates no agents. The names + // in its rooms refer to agents the server already has. + if (isGroup) { + return { kind: 'group', rooms: roomEntries.length, agents: 0, inputs, creates }; + } + return { + kind: 'room', + rooms: 1, + agents: 0, + inputs, + creates: roomEntries.length > 0 ? creates : [], + }; +} + +function plural(n: number, noun: string): string { + return `${n} ${noun}${n === 1 ? '' : 's'}`; +} + +/** The card line: "Creates 1 room and 2 agents · 4 inputs". */ +export function describeSummary(s: TemplateSummary): { creates: string; inputs: string } { + const parts: string[] = []; + if (s.rooms > 0) parts.push(plural(s.rooms, 'room')); + if (s.agents > 0) parts.push(plural(s.agents, 'agent')); + const creates = parts.length > 0 ? `Creates ${parts.join(' and ')}` : 'Creates nothing yet'; + const inputs = s.inputs === 0 ? 'no inputs' : plural(s.inputs, 'input'); + return { creates, inputs }; +} diff --git a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts index f86c988c9..97232a6e7 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/add-agent.ts @@ -20,6 +20,7 @@ import type { AgentProviderId } from '@shared/core/providers/agent-provider-regi import type { UiEntryPoint } from '@shared/core/telemetry/reporting'; import { basenameFromAnyPath } from '@shared/path-name'; import { writeAgentConfigFile } from './agent-config-file'; +import type { AgentTemplateOrigin } from './agent-config-file'; import { syncAgentConfig } from './agent-config-sync'; import { foreignCredentialsOwner, sameEndpointAgentId } from './agent-credentials-slot'; import { agentEvents } from './agent-events'; @@ -68,6 +69,9 @@ export type AddAgentParams = { providerConfig?: AgentProviderConfig | null; /** Which control the user opened the add-agent form from, for reporting. */ entryPoint: UiEntryPoint; + /** The template the agent is created from, if any. Recorded so the agent's + * settings page can offer the template's current instructions later. */ + templateOrigin?: AgentTemplateOrigin | null; }; export type AddAgentResult = @@ -240,6 +244,7 @@ async function runAddAgent(params: AddAgentParams): Promise { await writeAgentConfigFile(workspace.fs, params.name, { instructions: params.instructions, settings: params.definitionAttributes, + ...(params.templateOrigin ? { template: params.templateOrigin } : {}), }); await syncAgentConfig({ workspaceFs: workspace.fs, diff --git a/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.test.ts b/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.test.ts index f5d3bf15d..28254ff84 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.test.ts @@ -208,3 +208,24 @@ describe('decideArtifactSync', () => { ).toBe('adopt'); }); }); + +describe('template origin', () => { + it('round-trips the template an agent was created from', () => { + const text = serialiseAgentConfigFile({ + instructions: 'i', + template: { id: 'bundled:switch-expert', name: 'Switch expert', source: 'bundled' }, + }); + expect(parseAgentConfigFile(text).template).toEqual({ + id: 'bundled:switch-expert', + name: 'Switch expert', + source: 'bundled', + }); + }); + + it('drops an origin it cannot make sense of', () => { + expect(parseAgentConfigFile('{"template": {"id": 1}}').template).toBeUndefined(); + expect( + parseAgentConfigFile('{"template": {"id": "x", "name": "y", "source": "elsewhere"}}').template + ).toBeUndefined(); + }); +}); diff --git a/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.ts b/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.ts index e47817de3..4c3b06e1d 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/agent-config-file.ts @@ -47,8 +47,31 @@ export type AgentConfigFile = { * read back in. */ rendered?: Record; + /** + * The template this agent was created from. Absent for an agent created + * without one. Holds what is needed to load the template again later, so + * the agent's settings page can offer the template's current instructions: + * a bundled template is found by id in this Console, a server one on its + * server. + */ + template?: AgentTemplateOrigin; +}; + +export type AgentTemplateOrigin = { + id: string; + name: string; + source: 'bundled' | 'server'; + serverId?: string; }; +function parseTemplateOrigin(value: unknown): AgentTemplateOrigin | undefined { + if (!isPlainObject(value)) return undefined; + const { id, name, source, serverId } = value; + if (typeof id !== 'string' || typeof name !== 'string') return undefined; + if (source !== 'bundled' && source !== 'server') return undefined; + return { id, name, source, ...(typeof serverId === 'string' ? { serverId } : {}) }; +} + /** * Parse an agent config file's text. * @@ -72,6 +95,8 @@ export function parseAgentConfigFile(raw: string): AgentConfigFile { const config: AgentConfigFile = {}; if (typeof record.instructions === 'string') config.instructions = record.instructions; if (isPlainObject(record.settings)) config.settings = record.settings as RepoAgentAttributes; + const template = parseTemplateOrigin(record.template); + if (template) config.template = template; if (isPlainObject(record.rendered)) { const rendered: Record = {}; for (const [path, digest] of Object.entries(record.rendered)) { @@ -101,6 +126,7 @@ export function serialiseAgentConfigFile( delete out.instructions; delete out.settings; delete out.rendered; + delete out.template; // Blank is how the owner says "none", and none is the absent state — the // value itself is written exactly as given, never trimmed, because trimming @@ -113,6 +139,7 @@ export function serialiseAgentConfigFile( if (Object.keys(settings).length > 0) out.settings = settings; if (config.rendered && Object.keys(config.rendered).length > 0) out.rendered = config.rendered; + if (config.template) out.template = config.template; return `${JSON.stringify(out, null, 2)}\n`; } diff --git a/console/apps/switch-console-desktop/src/main/core/agents/agent-config.ts b/console/apps/switch-console-desktop/src/main/core/agents/agent-config.ts index 96905fb50..c1c37ccb3 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/agent-config.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/agent-config.ts @@ -3,7 +3,7 @@ import { getPlugin } from '@main/core/providers/plugin-registry'; import { providerConfigFromAttributes } from '@shared/core/agents/agent-provider-config'; import type { Agent } from '@shared/core/agents/agents'; import type { AgentConfigFile } from './agent-config-file'; -import { writeAgentConfigFile } from './agent-config-file'; +import { writeAgentConfigFile, type AgentTemplateOrigin } from './agent-config-file'; import { syncAgentConfig } from './agent-config-sync'; import { withAgentWorkspace } from './agent-launch-config'; import { getAgentById } from './getAgentById'; @@ -52,6 +52,13 @@ export async function writeAgentConfig(params: { }); } +/** The template the agent was created from, or null for an agent created without one. */ +export async function readAgentTemplateOrigin( + agentId: string +): Promise { + return (await readAgentConfig(agentId)).template ?? null; +} + /** The agent's instructions, or empty when it has none. */ export async function readAgentInstructions(agentId: string): Promise { return (await readAgentConfig(agentId)).instructions ?? ''; diff --git a/console/apps/switch-console-desktop/src/main/core/agents/controller.ts b/console/apps/switch-console-desktop/src/main/core/agents/controller.ts index f50e7725a..5935951f5 100644 --- a/console/apps/switch-console-desktop/src/main/core/agents/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/agents/controller.ts @@ -11,7 +11,11 @@ import { readAgentAdvancedConfig, updateAgentAdvancedConfig, } from './agent-advanced-config'; -import { readAgentInstructions, setAgentInstructions } from './agent-config'; +import { + readAgentInstructions, + readAgentTemplateOrigin, + setAgentInstructions, +} from './agent-config'; import { readAgentDefinition, updateAgentDefinition } from './agent-definition'; import { getAgentModelCatalogue } from './agent-model-catalogue'; import { assignAgentServer } from './assignAgentServer'; @@ -77,6 +81,7 @@ export const agentsController = createRPCController({ * the agent rather than one of its provider's settings. */ readInstructions: (params: { agentId: string }) => readAgentInstructions(params.agentId), + readTemplateOrigin: (params: { agentId: string }) => readAgentTemplateOrigin(params.agentId), updateInstructions: (params: { agentId: string; instructions: string }): Promise => setAgentInstructions(params).then(() => undefined), readAdvancedConfig: (params: { agentId: string }) => readAgentAdvancedConfig(params.agentId), diff --git a/console/apps/switch-console-desktop/src/main/core/app/controller.ts b/console/apps/switch-console-desktop/src/main/core/app/controller.ts index 14e119606..d34c171a7 100644 --- a/console/apps/switch-console-desktop/src/main/core/app/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/app/controller.ts @@ -67,6 +67,8 @@ export const appController = createRPCController({ }, openSelectDirectoryDialog: (args: { title: string; message: string; defaultPath?: string }) => appService.openSelectDirectoryDialog(args), + saveTextFile: (args: { title: string; defaultPath?: string; content: string }) => + appService.saveTextFile(args), openSelectAudioFileDialog: (args: { title: string; message: string }) => appService.openSelectAudioFileDialog(args), readAudioFileDataUrl: async (filePath: string) => { diff --git a/console/apps/switch-console-desktop/src/main/core/app/service.ts b/console/apps/switch-console-desktop/src/main/core/app/service.ts index 066b1f870..bc83bafd3 100644 --- a/console/apps/switch-console-desktop/src/main/core/app/service.ts +++ b/console/apps/switch-console-desktop/src/main/core/app/service.ts @@ -1,5 +1,5 @@ import { exec } from 'node:child_process'; -import { readFile, realpath, stat } from 'node:fs/promises'; +import { readFile, realpath, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { extname, isAbsolute, join, resolve, sep } from 'node:path'; import type { IDisposable, IInitializable } from '@switch-console/shared'; @@ -309,6 +309,21 @@ class AppService implements IInitializable, IDisposable { return result.filePaths[0]; } + /** Write `content` to the path the user picks in the OS save dialog. Null when the user cancels. */ + async saveTextFile(args: { + title: string; + defaultPath?: string; + content: string; + }): Promise { + const result = await dialog.showSaveDialog(getMainWindow()!, { + title: args.title, + defaultPath: args.defaultPath, + }); + if (result.canceled || !result.filePath) return null; + await writeFile(result.filePath, args.content, 'utf8'); + return result.filePath; + } + async openSelectAudioFileDialog(args: { title: string; message: string; diff --git a/console/apps/switch-console-desktop/src/main/core/room-templates/controller.test.ts b/console/apps/switch-console-desktop/src/main/core/room-templates/controller.test.ts index 46bf212e7..cfa3e7842 100644 --- a/console/apps/switch-console-desktop/src/main/core/room-templates/controller.test.ts +++ b/console/apps/switch-console-desktop/src/main/core/room-templates/controller.test.ts @@ -255,3 +255,23 @@ describe('roomTemplatesController.parse: multiline params', () => { expect(result.params[0].multiline).toBe(false); }); }); + +describe('roomTemplatesController.params: prefill', () => { + it('reads prefill on a param whose type has a list, and nowhere else', () => { + const params = roomTemplatesController.params({ + yamlText: [ + 'params:', + ' bridge:', + ' type: bridge', + ' prefill: first', + ' topic:', + ' type: string', + ' prefill: first', + ' reviewer:', + ' type: agent', + ].join('\n'), + }); + const byName = Object.fromEntries(params.map((p) => [p.name, p.prefill])); + expect(byName).toEqual({ bridge: 'first', topic: null, reviewer: null }); + }); +}); diff --git a/console/apps/switch-console-desktop/src/main/core/room-templates/controller.ts b/console/apps/switch-console-desktop/src/main/core/room-templates/controller.ts index 51c68d6bf..615a70d01 100644 --- a/console/apps/switch-console-desktop/src/main/core/room-templates/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/room-templates/controller.ts @@ -1,6 +1,15 @@ +import { writeFile } from 'node:fs/promises'; import Ajv from 'ajv'; +import { clipboard, dialog } from 'electron'; import { dump, load } from 'js-yaml'; -import { PARAM_TYPES, type ParamType } from '@shared/core/switch-servers/room-template-params'; +import { getMainWindow } from '@main/app/window'; +import type { KV } from '@main/db/kv'; +import exampleTemplateYaml from '@root/../../../examples/room-templates/red-blue-workroom.template.yaml?raw'; +import { + PARAM_TYPES, + PREFILL_PARAM_TYPES, + type ParamType, +} from '@shared/core/switch-servers/room-template-params'; import { createRPCController } from '@shared/lib/ipc/rpc'; export type ParamSpec = { @@ -12,11 +21,30 @@ export type ParamSpec = { /** String params carrying long text render as a textarea (a one-line input * would strip pasted newlines). Declared in the template: `multiline: true`. */ multiline: boolean; + /** With `first`, the form selects the first agent, room or messaging app + * and the deployer can change it. Declared in the template: `prefill: first`. */ + prefill: 'first' | null; +}; + +/** One room of a template, with the fields the Use page shows and edits. */ +export type TemplateRoom = { + name: string | null; + description: string | null; + agents: string[]; + users: string[]; + bridge: string | null; + kickoff: string | null; }; export type ParsedTemplate = { params: ParamSpec[]; + /** The rooms the document creates: one for `room:`, one per entry of `rooms:`. */ + rooms: TemplateRoom[]; + /** The group's name when the document is a group, else null. */ + groupName: string | null; roomName: string | null; + /** The room's description as written in the template, for a listing card. */ + roomDescription: string | null; /** All agents from the template (both interpolated and hardcoded). */ agents: string[]; /** Hardcoded agents (no `{param}` interpolation), editable in the form. */ @@ -34,7 +62,7 @@ export type ParsedTemplate = { warnings: string[]; }; -function extractParams(raw: unknown): ParamSpec[] { +export function extractParams(raw: unknown): ParamSpec[] { if (raw === null || raw === undefined || typeof raw !== 'object') return []; const params = raw as Record; return Object.entries(params).map(([name, spec]) => { @@ -46,6 +74,7 @@ function extractParams(raw: unknown): ParamSpec[] { default: null, enum: null, multiline: false, + prefill: null, }; } const s = spec as Record; @@ -60,6 +89,10 @@ function extractParams(raw: unknown): ParamSpec[] { default: s.default !== undefined ? (s.default as ParamSpec['default']) : null, enum: Array.isArray(s.enum) ? (s.enum as string[]) : null, multiline: validType === 'string' && s.multiline === true, + prefill: + s.prefill === 'first' && PREFILL_PARAM_TYPES.includes(validType) + ? ('first' as const) + : null, }; }); } @@ -89,14 +122,115 @@ function extractStringList(raw: unknown): string[] { const ajv = new Ajv({ allErrors: true, strict: false }); +/** + * Pick the part of the server's JSON schema that applies to this document. + * + * The schema can be a plain object schema or a `oneOf` with one branch per + * document shape (room, group). Validating a room document against the + * whole `oneOf` reports the group branch's errors too, which reads as + * noise. Validating against the matching branch reports only the mistake. + * + * The group branch is found by `Group` in its `$ref`, the server's model + * name for it; the other branch is the room one. + */ +function documentSchema( + schema: Record, + doc: Record +): Record { + const branches = (schema.oneOf ?? schema.anyOf) as Array<{ $ref?: string }> | undefined; + if (!Array.isArray(branches)) return schema; + const isGroup = doc.group !== undefined || Array.isArray(doc.rooms); + const branch = branches.find((b) => + isGroup ? /Group/.test(b.$ref ?? '') : !/Group/.test(b.$ref ?? '') + ); + if (!branch?.$ref) return schema; + const { oneOf: _one, anyOf: _any, ...rest } = schema; + return { ...rest, $ref: branch.$ref }; +} + +// ── Recents ──────────────────────────────────────────────────────────────── + +/** A template document used from this Console, kept locally so it can be used again or saved to a workspace. */ +export type RecentTemplate = { + name: string; + yamlText: string; + usedAt: number; +}; + +type RecentsKV = Record; + +const MAX_RECENTS = 10; + +// Imported inside the function rather than at the top of the module. The KV +// module imports Electron's `app`, which does not exist in the unit tests +// that call `parse`. +let _recentsKV: KV | null = null; +async function recentsKV(): Promise> { + if (!_recentsKV) { + const { KV: Store } = await import('@main/db/kv'); + _recentsKV = new Store('template-recents'); + } + return _recentsKV; +} + export const roomTemplatesController = createRPCController({ + /** Templates used from this Console on `serverId`, newest first. */ + getRecents: async (serverId: string): Promise => { + const kv = await recentsKV(); + return (await kv.get(serverId)) ?? []; + }, + + saveRecent: async (params: { + serverId: string; + name: string; + yamlText: string; + }): Promise => { + const kv = await recentsKV(); + const existing = (await kv.get(params.serverId)) ?? []; + // Using the same document again moves its entry to the top instead of adding a duplicate. + const rest = existing.filter((r) => r.yamlText !== params.yamlText); + const entry: RecentTemplate = { + name: params.name, + yamlText: params.yamlText, + usedAt: Date.now(), + }; + await kv.set(params.serverId, [entry, ...rest].slice(0, MAX_RECENTS)); + }, + + /** The example room template from `examples/`, shown when there is nothing else to start from. */ + getExampleTemplate: (): string => exampleTemplateYaml, + + /** Only the `params:` of a document. For agent-only documents, which have no room to parse. */ + params: (params: { yamlText: string }): ParamSpec[] => + extractParams(parseYaml(params.yamlText).params), + + /** Save YAML text to a file via the native save dialog. Returns the path, or + * null if the user cancelled. */ + saveToFile: async (params: { yamlText: string; defaultName: string }): Promise => { + const win = getMainWindow(); + if (!win) return null; + const result = await dialog.showSaveDialog(win, { + title: 'Save room template', + defaultPath: params.defaultName, + filters: [{ name: 'YAML', extensions: ['yaml', 'yml'] }], + }); + if (result.canceled || !result.filePath) return null; + await writeFile(result.filePath, params.yamlText, 'utf8'); + return result.filePath; + }, + + /** Copy YAML text to the system clipboard. */ + copyToClipboard: (params: { text: string }): void => { + clipboard.writeText(params.text); + }, + parse: (params: { yamlText: string; schema?: Record }): ParsedTemplate => { const warnings: string[] = []; const doc = parseYaml(params.yamlText); // Validate against server schema if provided if (params.schema) { - const validate = ajv.compile(params.schema); + const validate = ajv.compile(documentSchema(params.schema, doc)); if (!validate(doc)) { const errors = (validate.errors ?? []) .map((err) => { @@ -106,38 +240,58 @@ export const roomTemplatesController = createRPCController({ .slice(0, 5); throw new Error(errors.join('\n')); } - } else if (!doc.room) { - throw new Error('Template must have a "room:" block.'); + } else if (!doc.room && !Array.isArray(doc.rooms)) { + throw new Error('Template must have a "room:" block, or "group:" with "rooms:".'); } - const room = doc.room as Record | undefined; - const roomName = room && typeof room.name === 'string' ? room.name : null; - const allAgents = extractStringList(room?.agents); - const allUsers = extractStringList(room?.users); + const isGroup = doc.group !== undefined || Array.isArray(doc.rooms); + const rawRooms: Record[] = isGroup + ? (Array.isArray(doc.rooms) ? doc.rooms : []).filter( + (r): r is Record => r !== null && typeof r === 'object' + ) + : doc.room && typeof doc.room === 'object' + ? [doc.room as Record] + : []; + const rooms: TemplateRoom[] = rawRooms.map((room) => ({ + name: typeof room.name === 'string' ? room.name : null, + description: typeof room.description === 'string' ? room.description.trim() : null, + agents: extractStringList(room.agents), + users: extractStringList(room.users), + bridge: + typeof room.bridge === 'string' && !hasInterpolation(room.bridge) ? room.bridge : null, + kickoff: typeof room.kickoff === 'string' ? room.kickoff : null, + })); + const first = rooms[0] ?? null; + const allAgents = [...new Set(rooms.flatMap((r) => r.agents))]; + const allUsers = [...new Set(rooms.flatMap((r) => r.users))]; const paramSpecs = extractParams(doc.params); const kickoff = typeof doc.kickoff === 'string' ? doc.kickoff : null; - if (room && typeof room.kickoff === 'string') { + if (!isGroup && first?.kickoff) { warnings.push( '`kickoff:` belongs at the top level, beside `room:`. Inside `room:` the server ignores it.' ); } - const bridge = - room && typeof room.bridge === 'string' && !hasInterpolation(room.bridge) - ? room.bridge - : null; - - if (!room) { + if (isGroup && kickoff) { + warnings.push( + "A group's `kickoff:` goes inside the room it is for; at the top level the server refuses it." + ); + } + if (rooms.length === 0) { warnings.push('Template has no "room:" block, so the server may reject it.'); } + const group = doc.group as Record | undefined; return { params: paramSpecs, - roomName, + rooms, + groupName: group && typeof group.name === 'string' ? group.name : null, + roomName: first?.name ?? null, + roomDescription: first?.description ?? null, agents: allAgents, hardcodedAgents: allAgents.filter((a) => !hasInterpolation(a)), hardcodedUsers: allUsers.filter((u) => !hasInterpolation(u)), users: allUsers, - bridge, + bridge: first?.bridge ?? null, kickoff, usesCreator: usesCreator(params.yamlText), warnings, @@ -148,7 +302,9 @@ export const roomTemplatesController = createRPCController({ rewriteYaml: (params: { yamlText: string; agents: string[]; users: string[] }): string => { const doc = parseYaml(params.yamlText); const room = doc.room as Record | undefined; - if (!room) return params.yamlText; + // The form does not edit the member lists of a group's rooms, so a group + // document is returned unchanged. + if (!room || Array.isArray(doc.rooms)) return params.yamlText; room.agents = params.agents; if (params.users.length > 0) { room.users = params.users; diff --git a/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts b/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts index 4dac2b8cf..d31d389b3 100644 --- a/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts +++ b/console/apps/switch-console-desktop/src/main/core/settings/settings-registry.ts @@ -16,7 +16,7 @@ export const SETTINGS_DEFAULTS = { tmuxByDefault: false, }, localLocation: () => ({ - defaultLocationsDirectory: join(homedir(), 'switchdash', 'repositories'), + defaultLocationsDirectory: join(homedir(), '.switch', 'agents'), defaultWorktreeDirectory: getDefaultLocalWorktreeDirectory(), writeAgentConfigToGitIgnore: true, }), diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/controller.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/controller.ts index d9e6232b7..d67365678 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/controller.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/controller.ts @@ -103,13 +103,20 @@ import { fetchRoomGroups, fetchRoomRoles, fetchRooms, + createTemplate, + deleteTemplate, + fetchTemplateDetail, + fetchTemplates, GatewayError, ownsOwnerAddressedAgent, releaseBridgeIdentity, createRoomFromTemplate, + exportRoomYaml, fetchTemplateSchema, removeRoomAgent, - type TemplateProvisionResult, + type StoredTemplateDetail, + type StoredTemplateSummary, + type ProvisionFromTemplateResult, updateAddressingPolicy, updateAgentIcon, updateRoom, @@ -555,12 +562,41 @@ export const switchServersController = createRPCController({ serverId: string, yamlText: string, inputs: Record - ): Promise => + ): Promise => createRoomFromTemplate(await requireServer(serverId), yamlText, inputs), + listTemplates: async (params: { + serverId: string; + kind?: string; + }): Promise => + fetchTemplates(await requireServer(params.serverId), params.kind), + + getTemplateDetail: async (params: { + serverId: string; + templateId: string; + }): Promise => + fetchTemplateDetail(await requireServer(params.serverId), params.templateId), + + deleteTemplate: async (params: { serverId: string; templateId: string }): Promise => + deleteTemplate(await requireServer(params.serverId), params.templateId), + + saveTemplate: async (params: { + serverId: string; + name: string; + description: string; + kind: string; + content: string; + }): Promise => { + const { serverId, ...template } = params; + return createTemplate(await requireServer(serverId), template); + }, + fetchTemplateSchema: async (serverId: string): Promise | null> => fetchTemplateSchema(await requireServer(serverId)), + exportRoomYaml: async (params: { serverId: string; roomId: string }): Promise => + exportRoomYaml(await requireServer(params.serverId), params.roomId), + listAgentRooms: async (params: { serverId: string; agentId: string; diff --git a/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts b/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts index 9b43bd397..2bdcf24dc 100644 --- a/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts +++ b/console/apps/switch-console-desktop/src/main/core/switch-servers/gateway-client.ts @@ -1043,6 +1043,36 @@ export async function deleteAgent(server: SwitchServer, agentId: string): Promis }); } +/** + * Export a room's configuration as YAML (`GET /rooms/{roomId}/yaml`). Returns + * the raw YAML text — the same surface `POST /rooms/from-yaml` accepts, so + * the exported file round-trips through import unchanged. + * + * Each section can be dropped via its boolean toggles (default: all included). + */ +export async function exportRoomYaml( + server: SwitchServer, + roomId: string, + sections?: { + agents?: boolean; + users?: boolean; + references?: boolean; + docs?: boolean; + roles?: boolean; + } +): Promise { + const params = new URLSearchParams(); + if (sections?.agents === false) params.set('agents', 'false'); + if (sections?.users === false) params.set('users', 'false'); + if (sections?.references === false) params.set('references', 'false'); + if (sections?.docs === false) params.set('docs', 'false'); + if (sections?.roles === false) params.set('roles', 'false'); + const query = params.toString(); + const path = `/rooms/${encodeURIComponent(roomId)}/yaml${query ? `?${query}` : ''}`; + const res = await gatewayFetch(server, path, { authenticated: true }); + return res.text(); +} + /** The result of provisioning a room from a YAML template. */ export type TemplateProvisionResult = { roomId: string; @@ -1050,11 +1080,39 @@ export type TemplateProvisionResult = { failedAttachments: Array<{ kind: string; id: string; error: string }>; }; +/** The result of provisioning a room group from a YAML template. */ +export type GroupProvisionResult = { + groupId: string; + groupName: string; + rooms: TemplateProvisionResult[]; + /** Rooms or links that could not be created. The others were. */ + errors: Array & { error: string }>; +}; + +export type ProvisionFromTemplateResult = + | ({ kind: 'room' } & TemplateProvisionResult) + | ({ kind: 'group' } & GroupProvisionResult); + +type RoomJson = { + room_id: string; + room_name: string; + failed_attachments?: Array<{ kind: string; id: string; error: string }>; +}; + +function toRoomResult(json: RoomJson): TemplateProvisionResult { + return { + roomId: json.room_id, + roomName: json.room_name, + failedAttachments: json.failed_attachments ?? [], + }; +} + /** - * Create a room from a YAML template (`POST /rooms/from-yaml`). Sends the - * template as a JSON body with the YAML text and any user-supplied inputs. - * The server parses the template, interpolates inputs, and provisions - * everything in one call. + * Create a room, or a group of rooms, from a YAML template + * (`POST /rooms/from-yaml`). Sends the template as a JSON body with the YAML + * text and any user-supplied inputs. The server parses the template, + * interpolates inputs, and provisions everything in one call; the document's + * shape decides which result comes back. * * A 400 carries a `detail` naming the bad input; the caller maps it back to * the form field. @@ -1063,24 +1121,113 @@ export async function createRoomFromTemplate( server: SwitchServer, yamlText: string, inputs: Record -): Promise { +): Promise { const res = await gatewayFetch(server, '/rooms/from-yaml', { authenticated: true, method: 'POST', body: { yaml: yamlText, inputs }, }); - const json = (await res.json()) as { - room_id: string; - room_name: string; - failed_attachments?: Array<{ kind: string; id: string; error: string }>; - }; + const json = (await res.json()) as + | RoomJson + | { + group_id: string; + group_name: string; + rooms?: RoomJson[]; + errors?: Array & { error: string }>; + }; + if ('group_id' in json) { + return { + kind: 'group', + groupId: json.group_id, + groupName: json.group_name, + rooms: (json.rooms ?? []).map(toRoomResult), + errors: json.errors ?? [], + }; + } + return { kind: 'room', ...toRoomResult(json) }; +} + +// ── Stored templates (template registry) ──────────────────────────────────── + +export type StoredTemplateSummary = { + id: string; + name: string; + description: string; + kind: string; + creator: string; + /** The owner's user id, so the Console can mark the signed-in user's own templates. */ + ownerId: string | null; +}; + +export type StoredTemplateDetail = StoredTemplateSummary & { + definition: string; +}; + +type RegistryTemplateSummary = { + id: string; + owner_id: string; + owner_name: string | null; + name: string; + description: string; + kind: string; +}; + +function toSummary(t: RegistryTemplateSummary): StoredTemplateSummary { return { - roomId: json.room_id, - roomName: json.room_name, - failedAttachments: json.failed_attachments ?? [], + id: t.id, + name: t.name, + description: t.description, + kind: t.kind, + creator: t.owner_name ?? t.owner_id, + ownerId: t.owner_id, }; } +export async function fetchTemplates( + server: SwitchServer, + kind?: string +): Promise { + const qs = kind ? `?kind=${encodeURIComponent(kind)}` : ''; + const res = await gatewayFetch(server, `/templates${qs}`, { + authenticated: true, + }); + const json = (await res.json()) as RegistryTemplateSummary[]; + return json.map(toSummary); +} + +/** Store a template document on the server's registry (`POST /templates`). */ +export async function createTemplate( + server: SwitchServer, + params: { name: string; description: string; kind: string; content: string } +): Promise { + const res = await gatewayFetch(server, '/templates', { + authenticated: true, + method: 'POST', + body: params, + }); + const t = (await res.json()) as RegistryTemplateSummary & { content: string }; + return { ...toSummary(t), definition: t.content }; +} + +/** Remove a template from the server's registry (`DELETE /templates/{id}`). */ +export async function deleteTemplate(server: SwitchServer, templateId: string): Promise { + await gatewayFetch(server, `/templates/${encodeURIComponent(templateId)}`, { + authenticated: true, + method: 'DELETE', + }); +} + +export async function fetchTemplateDetail( + server: SwitchServer, + templateId: string +): Promise { + const res = await gatewayFetch(server, `/templates/${encodeURIComponent(templateId)}`, { + authenticated: true, + }); + const t = (await res.json()) as RegistryTemplateSummary & { content: string }; + return { ...toSummary(t), definition: t.content }; +} + /** * Fetch the JSON Schema describing a valid room template. Returns null when * the server does not support the endpoint (404): older servers without diff --git a/console/apps/switch-console-desktop/src/main/rpc.ts b/console/apps/switch-console-desktop/src/main/rpc.ts index 230e58447..6bafd3595 100644 --- a/console/apps/switch-console-desktop/src/main/rpc.ts +++ b/console/apps/switch-console-desktop/src/main/rpc.ts @@ -1,4 +1,5 @@ import { createRPCNamespace, createRPCRouter } from '../shared/lib/ipc/rpc'; +import { agentTemplatesController } from './core/agent-templates/controller'; import { agentsController } from './core/agents/controller'; import { appController } from './core/app/controller'; import { filesController } from './core/fs/controller'; @@ -27,6 +28,7 @@ import { viewStateController } from './core/view-state/controller'; export const rpcRouter = createRPCRouter({ providers: providersController, agents: agentsController, + agentTemplates: agentTemplatesController, app: appController, appSettings: appSettingsController, providerSettings: providerSettingsController, diff --git a/console/apps/switch-console-desktop/src/main/vite-env.d.ts b/console/apps/switch-console-desktop/src/main/vite-env.d.ts new file mode 100644 index 000000000..22dd406e2 --- /dev/null +++ b/console/apps/switch-console-desktop/src/main/vite-env.d.ts @@ -0,0 +1,4 @@ +declare module '*.yaml?raw' { + const value: string; + export default value; +} diff --git a/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts b/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts index 22808fac9..b94ed44bb 100644 --- a/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts +++ b/console/apps/switch-console-desktop/src/renderer/app/modal-registry.ts @@ -19,6 +19,7 @@ import { CreateRoomModal } from '@renderer/features/switch-servers/CreateRoomMod import { DeleteServerModal } from '@renderer/features/switch-servers/DeleteServerModal'; import { DisconnectMessagingAppModal } from '@renderer/features/switch-servers/DisconnectMessagingAppModal'; import { RenameServerModal } from '@renderer/features/switch-servers/RenameServerModal'; +import { SaveTemplateModal } from '@renderer/features/templates/save-template-modal'; import { ConfirmActionDialog } from '@renderer/lib/components/confirm-action-dialog'; import { ExternalLinkChoiceDialog } from '@renderer/lib/components/external-link-choice-dialog'; import { UnsavedChangesDialog } from '@renderer/lib/components/unsaved-changes-dialog'; @@ -51,6 +52,7 @@ export const modalRegistry = { commandPaletteModal: createModal(CommandPaletteModal, { size: 'md' }), sessionModal: createModal(CreateSessionModal, { dismissOnOutsideClick: false }), addAgentModal: createModal(AddAgentModal, { size: 'lg', dismissOnOutsideClick: false }), + saveTemplateModal: createModal(SaveTemplateModal, { size: 'sm', dismissOnOutsideClick: false }), confirmActionModal: createModal(ConfirmActionDialog, { size: 'xs' }), deleteAgentModal: createModal(DeleteAgentModal, { size: 'sm' }), removeAgentConfigModal: createModal(RemoveAgentConfigModal, { size: 'sm' }), diff --git a/console/apps/switch-console-desktop/src/renderer/app/view-registry.ts b/console/apps/switch-console-desktop/src/renderer/app/view-registry.ts index c2b25fe60..fbd991036 100644 --- a/console/apps/switch-console-desktop/src/renderer/app/view-registry.ts +++ b/console/apps/switch-console-desktop/src/renderer/app/view-registry.ts @@ -3,13 +3,17 @@ import { homeView } from '@renderer/app/home-view'; import { locationView } from '@renderer/features/locations/view'; import { remoteHostView } from '@renderer/features/remote-hosts/views/remote-host-view'; import { remoteHostsView } from '@renderer/features/remote-hosts/views/remote-hosts-view'; -import { roomTemplateImportView } from '@renderer/features/room-templates/room-template-import-view'; +import { templateCaptureView } from '@renderer/features/room-templates/room-template-capture-view'; +import { templateImportView } from '@renderer/features/room-templates/room-template-import-view'; import { sessionView } from '@renderer/features/sessions/view'; import { settingsView } from '@renderer/features/settings/settings-view'; import { roomView } from '@renderer/features/switch-rooms/view'; import { serverAgentsView } from '@renderer/features/switch-servers/server-agents-view'; import { serverRoomsView } from '@renderer/features/switch-servers/server-rooms-view'; import { serverView } from '@renderer/features/switch-servers/view'; +import { templateDetailView } from '@renderer/features/templates/template-detail-view'; +import { templateUseView } from '@renderer/features/templates/template-use-view'; +import { templatesView } from '@renderer/features/templates/templates-view'; import type { CommandProvider } from '@renderer/lib/commands/types'; import { appState } from '@renderer/lib/stores/app-state'; import type { ViewIdName } from '@shared/core/views/view-ids'; @@ -28,7 +32,11 @@ export const views = { serverRooms: serverRoomsView, remoteHosts: remoteHostsView, remoteHost: remoteHostView, - roomTemplateImport: roomTemplateImportView, + templateImport: templateImportView, + templates: templatesView, + templateDetail: templateDetailView, + templateUse: templateUseView, + templateCapture: templateCaptureView, // oxlint-disable-next-line typescript/no-explicit-any } satisfies Record>; diff --git a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx index 91c607534..3bba1aa5b 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/add-agent-modal.tsx @@ -22,6 +22,7 @@ import { useShowModal, type BaseModalProps, } from '@renderer/lib/modal/modal-provider'; +import { useRemoteAgents } from '@renderer/lib/stores/use-remote-agents'; import { Button } from '@renderer/lib/ui/button'; import { ConfirmButton } from '@renderer/lib/ui/confirm-button'; import { @@ -95,6 +96,7 @@ export const AddAgentModal = observer(function AddAgentModal({ // Typed directly, with no commit step: it used to need one because committing // fired the directory scans, and there are none left to fire. const [remoteRepoDir, setRemoteRepoDir] = useState(''); + const { data: remoteHosts } = useQuery({ queryKey: ['remote-hosts'], queryFn: () => rpc.remoteHosts.listHosts(), @@ -130,6 +132,15 @@ export const AddAgentModal = observer(function AddAgentModal({ } }, [targetServerId, pickedServerId, setServerId]); + // Names already taken on the server, so a clash is refused before anything + // is created rather than reported by the server afterwards. + const remoteAgents = useRemoteAgents(pickState.serverId); + const takenNames = useMemo( + () => new Set((remoteAgents.data ?? []).map((a) => a.name)), + [remoteAgents.data] + ); + const nameTaken = form.nameIsValid && takenNames.has(form.agentName); + // A managed server is only reachable from certain run locations, so constrain // the picker to them: a remote-managed server from this computer or its own // host (the desktop reaches it through the SSH forward; the host reaches it on @@ -220,6 +231,7 @@ export const AddAgentModal = observer(function AddAgentModal({ const canSubmit = form.isValid && + !nameTaken && !policyHasDeadRule(form.addressingPolicy) && !!pickState.serverId && !!pickState.providerId && @@ -238,23 +250,25 @@ export const AddAgentModal = observer(function AddAgentModal({ ? 'Enter a name for the agent.' : !form.nameIsValid ? 'Fix the agent name: lowercase letters, digits, . - _, starting with a letter or digit.' - : form.description.trim().length === 0 - ? 'Add a description so people and agents know what this agent is for.' - : !runHostReachable - ? `${runLocationLabel} can’t be reached right now — pick a run location that can.` - : hostReadiness.checking - ? `Checking what ${runLocationLabel} has installed…` - : hostReadiness.blocked - ? `${runLocationLabel} is missing setup this agent needs — the notice below has the details.` - : !pickState.providerId - ? 'Choose an agent type.' - : dir.trim().length === 0 - ? isRemoteRun - ? 'Enter the agent’s working directory on the host.' - : 'Choose the agent’s working directory.' - : policyHasDeadRule(form.addressingPolicy) - ? 'One addressing rule can never match — fix it under Settings.' - : null; + : nameTaken + ? `An agent called ${form.agentName} already exists on this server. Pick another name.` + : form.description.trim().length === 0 + ? 'Add a description so people and agents know what this agent is for.' + : !runHostReachable + ? `${runLocationLabel} can’t be reached right now — pick a run location that can.` + : hostReadiness.checking + ? `Checking what ${runLocationLabel} has installed…` + : hostReadiness.blocked + ? `${runLocationLabel} is missing setup this agent needs — the notice below has the details.` + : !pickState.providerId + ? 'Choose an agent type.' + : dir.trim().length === 0 + ? isRemoteRun + ? 'Enter the agent’s working directory on the host.' + : 'Choose the agent’s working directory.' + : policyHasDeadRule(form.addressingPolicy) + ? 'One addressing rule can never match — fix it under Settings.' + : null; /** `agentName` is what picks the agent out of the location — a location can * hold several, so navigating on `locationId` alone opens the directory @@ -324,9 +338,9 @@ export const AddAgentModal = observer(function AddAgentModal({ * per-agent credentials, and create the row — all via `addAgent`. */ const createNewAgent = async () => { if (!pickState.serverId || !pickState.providerId) return; - setSubmitState('creating'); setCloseGuard(true); try { + setSubmitState('creating'); const result = await getLocationManagerStore().addAgentAndOpen({ sshHost: isRemoteRun ? runHost : null, dir: isRemoteRun ? trimmedRemoteDir : pickState.path, @@ -377,6 +391,19 @@ export const AddAgentModal = observer(function AddAgentModal({ header={ New agent + {targetServerId && ( + + )} } footer={ diff --git a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/configure-agent-panel.tsx b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/configure-agent-panel.tsx index 428feb0e5..f0ca86415 100644 --- a/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/configure-agent-panel.tsx +++ b/console/apps/switch-console-desktop/src/renderer/features/locations/components/add-agent-modal/configure-agent-panel.tsx @@ -232,12 +232,25 @@ export const AgentSettingsSection = observer(function AgentSettingsSection({ * agents — so the two halves can sit in different places in the dialog without * the identity fields waiting on four queries they do not use. */ -export function AgentIdentityFields({ form }: { form: ConfigureAgentFormState }) { +export function AgentIdentityFields({ + form, + instructionsTemplateName = null, +}: { + form: ConfigureAgentFormState; + /** The name of the template the instructions were prefilled from. When + * set, the instructions start folded to a one-line summary with an Edit + * button: the user did not write them and does not need to read a page + * of text to confirm the agent. */ + instructionsTemplateName?: string | null; +}) { const nameId = useId(); const displayNameId = useId(); const descriptionId = useId(); const instructionsId = useId(); const nameRef = useRef(null); + const [instructionsOpen, setInstructionsOpen] = useState(instructionsTemplateName === null); + const instructionLines = + form.instructions.length === 0 ? 0 : form.instructions.split('\n').length; return ( @@ -343,13 +356,30 @@ export function AgentIdentityFields({ form }: { form: ConfigureAgentFormState }) Agent instructions (optional) -