ai-agentmanager 1.11.1 → 1.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,95 @@
1
+ // The agent on stdio, for clients that start it as a program:
2
+ // ACP (`<agent> acp`): editors and AI Agent Manager's ACP engine drive it like a coding agent.
3
+ // MCP (`<agent> mcp`): other agents get one tool, ask_<name>, that hands it a task.
4
+ // Both are newline-delimited JSON-RPC 2.0. Nothing but protocol goes to stdout.
5
+ import crypto from 'node:crypto';
6
+ import readline from 'node:readline';
7
+
8
+ function serve(handle, { input = process.stdin, output = process.stdout } = {}) {
9
+ const send = (m) => output.write(JSON.stringify({ jsonrpc: '2.0', ...m }) + '\n');
10
+ readline.createInterface({ input }).on('line', async (line) => {
11
+ let msg;
12
+ try {
13
+ msg = JSON.parse(line);
14
+ } catch {
15
+ return;
16
+ }
17
+ if (!msg.method) return;
18
+ try {
19
+ const result = await handle(msg.method, msg.params ?? {}, send);
20
+ if (msg.id != null) send({ id: msg.id, result: result ?? {} });
21
+ } catch (err) {
22
+ if (msg.id != null) send({ id: msg.id, error: { code: err.code ?? -32603, message: err.message } });
23
+ }
24
+ });
25
+ }
26
+
27
+ /** ACP: sessions are conversations; a prompt is a run whose steps stream as updates. */
28
+ export function serveAcp(agent, io) {
29
+ serve(async (method, p, send) => {
30
+ if (method === 'initialize') return { protocolVersion: 1, agentCapabilities: { loadSession: true }, agentInfo: { name: agent.spec.name, title: agent.spec.title, version: '1.0.0' }, authMethods: [] };
31
+ if (method === 'session/new') return { sessionId: `ak-${crypto.randomUUID()}` };
32
+ if (method === 'session/load') return {};
33
+ if (method === 'session/cancel') return {};
34
+ if (method === 'session/prompt') {
35
+ const text = (p.prompt ?? []).map((b) => b.text ?? '').join('').trim();
36
+ const update = (u) => send({ method: 'session/update', params: { sessionId: p.sessionId, update: u } });
37
+ const onStep = (s) => {
38
+ if (s.type === 'plan' || s.type === 'check' || s.type === 'reflect') update({ sessionUpdate: 'agent_thought_chunk', content: { type: 'text', text: `[${s.type}] ${s.text}\n` } });
39
+ };
40
+ agent.on('step', onStep);
41
+ try {
42
+ const r = await agent.run(text, { source: 'acp', conversation: `acp:${p.sessionId}` });
43
+ update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: r.result } });
44
+ return { stopReason: r.status === 'failed' ? 'refusal' : 'end_turn' };
45
+ } finally {
46
+ agent.off('step', onStep);
47
+ }
48
+ }
49
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
50
+ }, io);
51
+ }
52
+
53
+ /** MCP: one tool, ask_<name>, whose answer is the run's result. */
54
+ export function serveMcp(agent, io) {
55
+ const tool = `ask_${agent.spec.name.replace(/-/g, '_')}`;
56
+ serve(async (method, p) => {
57
+ if (method === 'initialize') return { protocolVersion: p.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: agent.spec.name, version: '1.0.0' } };
58
+ if (method === 'notifications/initialized' || method === 'ping') return {};
59
+ if (method === 'tools/list') {
60
+ return { tools: [{ name: tool, description: `${agent.spec.title}: ${agent.spec.description || agent.spec.goal}`, inputSchema: { type: 'object', properties: { task: { type: 'string', description: 'What you want done' } }, required: ['task'] } }] };
61
+ }
62
+ if (method === 'tools/call') {
63
+ if (p.name !== tool) throw Object.assign(new Error(`Unknown tool ${p.name}`), { code: -32602 });
64
+ const r = await agent.run(String(p.arguments?.task ?? ''), { source: 'mcp' });
65
+ return { content: [{ type: 'text', text: r.result }], isError: r.status === 'failed' };
66
+ }
67
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
68
+ }, io);
69
+ }
70
+
71
+ /**
72
+ * The approver (`<agent> approver`): the MCP tool Claude asks (--permission-prompt-tool) before a
73
+ * tool that isn't allowed outright. It hands the request to the running agent (AGENTKIT_APPROVAL_URL,
74
+ * with AGENTKIT_APPROVAL_TOKEN), which asks a person, and answers with their decision.
75
+ */
76
+ export function serveApprover({ url, token, fetchImpl = fetch } = {}, io) {
77
+ serve(async (method, p) => {
78
+ if (method === 'initialize') return { protocolVersion: p.protocolVersion ?? '2025-06-18', capabilities: { tools: {} }, serverInfo: { name: 'agentkit-approver', version: '1.0.0' } };
79
+ if (method === 'notifications/initialized' || method === 'ping') return {};
80
+ if (method === 'tools/list') {
81
+ return { tools: [{ name: 'decide', description: 'Asks a person whether a tool may run', inputSchema: { type: 'object', properties: { tool_name: { type: 'string' }, input: { type: 'object' }, tool_use_id: { type: 'string' } }, required: ['tool_name'] } }] };
82
+ }
83
+ if (method === 'tools/call') {
84
+ const a = p.arguments ?? {};
85
+ let d = { allow: false, message: 'No one could be asked (the agent is not reachable)' };
86
+ try {
87
+ const res = await fetchImpl(url, { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, body: JSON.stringify({ tool: a.tool_name, input: a.input ?? {} }) });
88
+ if (res.ok) d = await res.json();
89
+ } catch {}
90
+ const text = d.allow ? JSON.stringify({ behavior: 'allow', updatedInput: a.input ?? {} }) : JSON.stringify({ behavior: 'deny', message: d.message || 'A person declined this' });
91
+ return { content: [{ type: 'text', text }] };
92
+ }
93
+ throw Object.assign(new Error(`Method not found: ${method}`), { code: -32601 });
94
+ }, io);
95
+ }
@@ -0,0 +1,60 @@
1
+ // What starts a run by itself: an interval, a cron schedule, a folder watch. (Webhooks arrive over
2
+ // HTTP, see http.js.) Each fires agent.run with a short description of why.
3
+ import fs from 'node:fs';
4
+
5
+ /** One cron field ("*", "5", "1-5", "*\/15", "1,15") against a value. */
6
+ function fieldMatches(field, value, min, max) {
7
+ return field.split(',').some((part) => {
8
+ const [range, stepRaw] = part.split('/');
9
+ const step = Number(stepRaw ?? 1) || 1;
10
+ let [lo, hi] = range === '*' ? [min, max] : range.split('-').map(Number);
11
+ if (hi == null) hi = stepRaw ? max : lo;
12
+ return value >= lo && value <= hi && (value - lo) % step === 0;
13
+ });
14
+ }
15
+
16
+ /** Whether a five-field cron expression (minute hour day month weekday) matches a time. */
17
+ export function cronMatches(expr, d = new Date()) {
18
+ const [mi, h, dom, mon, dow] = String(expr).trim().split(/\s+/);
19
+ return fieldMatches(mi, d.getMinutes(), 0, 59) && fieldMatches(h, d.getHours(), 0, 23) && fieldMatches(dom, d.getDate(), 1, 31) && fieldMatches(mon, d.getMonth() + 1, 1, 12) && fieldMatches(dow, d.getDay(), 0, 6);
20
+ }
21
+
22
+ /** Start the agent's triggers; returns a function that stops them. */
23
+ export function startTriggers(agent, { log = () => {} } = {}) {
24
+ const stops = [];
25
+ for (const t of agent.spec.triggers) {
26
+ if (t.kind === 'every') {
27
+ const timer = setInterval(() => agent.run('', { source: t.label }).catch(() => {}), t.ms);
28
+ stops.push(() => clearInterval(timer));
29
+ } else if (t.kind === 'cron') {
30
+ let last = '';
31
+ const timer = setInterval(() => {
32
+ const d = new Date();
33
+ const minute = d.toISOString().slice(0, 16);
34
+ if (minute !== last && cronMatches(t.cron, d)) {
35
+ last = minute;
36
+ agent.run('', { source: t.label }).catch(() => {});
37
+ }
38
+ }, 15_000);
39
+ stops.push(() => clearInterval(timer));
40
+ } else if (t.kind === 'watch') {
41
+ fs.mkdirSync(t.dir, { recursive: true });
42
+ // A burst of changes (an editor saving, a copy) is one run.
43
+ let pending = new Set();
44
+ let timer = null;
45
+ const w = fs.watch(t.dir, (event, file) => {
46
+ if (!file || file.startsWith('.')) return;
47
+ pending.add(file);
48
+ clearTimeout(timer);
49
+ timer = setTimeout(() => {
50
+ const files = [...pending];
51
+ pending = new Set();
52
+ agent.run(`Files changed in ${t.dir}: ${files.join(', ')}`, { source: t.label }).catch(() => {});
53
+ }, 1500);
54
+ });
55
+ stops.push(() => (clearTimeout(timer), w.close()));
56
+ }
57
+ if (t.kind !== 'webhook') log(`trigger: ${t.label}`);
58
+ }
59
+ return () => stops.forEach((s) => s());
60
+ }
@@ -0,0 +1,117 @@
1
+ // Agents built with agentkit report here (src/agentkit/report.js): who they are, each run, and the
2
+ // tool approvals they're waiting on. People see them in the Built tab and the Inbox, and their
3
+ // decisions go back with the agent's next check-in. Kept in ~/.agentmanager/agentkit-reports.json.
4
+ import crypto from 'node:crypto';
5
+ import { EventEmitter } from 'node:events';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { paths } from './config.js';
9
+
10
+ const ONLINE_MS = 90_000;
11
+ const file = () => path.join(paths.home, 'agentkit-reports.json');
12
+ const err = (status, message) => Object.assign(new Error(message), { status });
13
+ const str = (v, n = 200) => String(v ?? '').slice(0, n);
14
+
15
+ export class AgentkitReports extends EventEmitter {
16
+ constructor() {
17
+ super();
18
+ let saved = {};
19
+ try {
20
+ saved = JSON.parse(fs.readFileSync(file(), 'utf8'));
21
+ } catch {}
22
+ this.token = saved.token ?? null;
23
+ this.agents = new Map(Object.entries(saved.agents ?? {}));
24
+ this.decisions = new Map(); // key -> [{ id, allow, by, note }] to hand back on its next check-in
25
+ this.saveTimer = null;
26
+ }
27
+
28
+ /** The token agents report with (made on first use). */
29
+ reportToken() {
30
+ if (!this.token) {
31
+ this.token = crypto.randomBytes(24).toString('base64url');
32
+ this.save(true);
33
+ }
34
+ return this.token;
35
+ }
36
+
37
+ save(now = false) {
38
+ clearTimeout(this.saveTimer);
39
+ const write = () => {
40
+ fs.mkdirSync(paths.home, { recursive: true });
41
+ fs.writeFileSync(file(), JSON.stringify({ token: this.token, agents: Object.fromEntries(this.agents) }, null, 2), { mode: 0o600 });
42
+ };
43
+ if (now) write();
44
+ else this.saveTimer = setTimeout(write, 1000);
45
+ }
46
+
47
+ /** A check-in from an agent. Returns the decisions waiting for it. */
48
+ report(body = {}) {
49
+ const a = body.agent ?? {};
50
+ if (!/^[a-z][a-z0-9-]{0,63}$/.test(String(a.name ?? ''))) throw err(400, 'agent.name: required');
51
+ const key = `${a.name}@${str(a.host, 120) || 'unknown'}`;
52
+ const prev = this.agents.get(key) ?? { runs: [], totals: { runs: 0, failed: 0, costUsd: 0 } };
53
+ const known = new Set(prev.runs.map((r) => r.id));
54
+ const fresh = (Array.isArray(body.runs) ? body.runs : []).filter((r) => r?.id && !known.has(r.id)).map((r) => ({ id: str(r.id, 80), status: str(r.status, 20), source: str(r.source, 80), costUsd: Number(r.costUsd) || null, ms: Number(r.ms) || null, result: str(r.result, 2000), at: str(r.at, 40) }));
55
+ const totals = { ...prev.totals };
56
+ for (const r of fresh) {
57
+ totals.runs++;
58
+ if (r.status === 'failed') totals.failed++;
59
+ totals.costUsd = Math.round((totals.costUsd + (r.costUsd ?? 0)) * 1e6) / 1e6;
60
+ }
61
+ const decided = new Set((this.decisions.get(key) ?? []).map((d) => d.id));
62
+ const next = {
63
+ key,
64
+ name: a.name,
65
+ title: str(a.title) || a.name,
66
+ description: str(a.description, 400),
67
+ engine: str(a.engine, 40),
68
+ triggers: (Array.isArray(a.triggers) ? a.triggers : []).slice(0, 10).map((t) => str(t, 80)),
69
+ host: str(a.host, 120),
70
+ pid: Number(a.pid) || null,
71
+ chat: /^https?:\/\//.test(String(a.chat ?? '')) ? str(a.chat, 300) : null,
72
+ version: str(a.version, 40) || null,
73
+ startedAt: str(a.startedAt, 40) || null,
74
+ lastSeen: new Date().toISOString(),
75
+ current: body.current?.id ? { id: str(body.current.id, 80), source: str(body.current.source, 80), startedAt: str(body.current.startedAt, 40) } : null,
76
+ // Approvals already answered here stay hidden until the agent confirms them.
77
+ pending: (Array.isArray(body.pending) ? body.pending : []).filter((p) => p?.id && !decided.has(p.id)).slice(0, 20).map((p) => ({ id: str(p.id, 80), tool: str(p.tool, 200), input: p.input && typeof p.input === 'object' ? p.input : {}, run: p.run ? str(p.run, 80) : null, at: str(p.at, 40) })),
78
+ runs: [...fresh.reverse(), ...prev.runs].slice(0, 50),
79
+ totals,
80
+ };
81
+ this.agents.set(key, next);
82
+ const decisions = this.decisions.get(key) ?? [];
83
+ this.decisions.delete(key);
84
+ this.save();
85
+ this.emit('change');
86
+ return { ok: true, decisions };
87
+ }
88
+
89
+ /** A person's answer to an approval; it goes back on the agent's next check-in. */
90
+ decide(key, id, allow, by = 'you') {
91
+ const a = this.agents.get(key);
92
+ if (!a) throw err(404, 'No such agent');
93
+ const p = a.pending.find((x) => x.id === id);
94
+ if (!p) throw err(409, 'Already decided, or no longer waiting');
95
+ this.decisions.set(key, [...(this.decisions.get(key) ?? []), { id, allow: Boolean(allow), by }]);
96
+ a.pending = a.pending.filter((x) => x.id !== id);
97
+ this.emit('change');
98
+ return this.view(a);
99
+ }
100
+
101
+ forget(key) {
102
+ this.agents.delete(key);
103
+ this.decisions.delete(key);
104
+ this.save();
105
+ this.emit('change');
106
+ return { ok: true };
107
+ }
108
+
109
+ view(a) {
110
+ const online = Date.now() - Date.parse(a.lastSeen) < ONLINE_MS;
111
+ return { ...a, online, pending: online ? a.pending : [], runs: a.runs.slice(0, 10) };
112
+ }
113
+
114
+ list() {
115
+ return [...this.agents.values()].map((a) => this.view(a)).sort((x, y) => String(y.lastSeen).localeCompare(String(x.lastSeen)));
116
+ }
117
+ }
package/src/builder.js ADDED
@@ -0,0 +1,332 @@
1
+ // The agent builder: a task or workflow in words → an analysis and an agentkit agent
2
+ // (docs/agentkit.md) with test cases, which the person reviews, tests, runs and downloads. Built
3
+ // agents live in ~/.agentmanager/built/<name>/ as ordinary agentkit folders.
4
+ import { spawn } from 'node:child_process';
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import YAML from 'yaml';
9
+ import { Agent } from './agentkit/agent.js';
10
+ import { turn as engineTurn } from './agentkit/engines.js';
11
+ import { HOST, PLATFORMS, packageAgent } from './agentkit/package.js';
12
+ import { checkAgent, loadAgent } from './agentkit/spec.js';
13
+ import { parseJsonReply } from './brief.js';
14
+ import { paths } from './config.js';
15
+
16
+ const AGENTKIT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentkit.js');
17
+ const root = () => path.join(paths.home, 'built');
18
+ const err = (status, message) => Object.assign(new Error(message), { status });
19
+ const now = () => new Date().toISOString();
20
+ const slug = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'agent';
21
+
22
+ export function designPrompt({ task, engine, attachments = [] }) {
23
+ return [
24
+ 'You design an autonomous agent that will run continuously on its own and do one job well. It runs through',
25
+ `${engine === 'claude' ? 'Claude Code' : engine} as its engine, uses tools through MCP servers, and is reached by people (chat) and other agents (A2A, MCP).`,
26
+ 'From the task below, write the analysis and the agent. Be concrete; do not invent systems the task does not mention.',
27
+ '',
28
+ 'Reply with one JSON object only:',
29
+ '{',
30
+ ' "analysis": {"problem": "<what the job is and why>", "inputs": ["<what it receives>"], "outputs": ["<what it produces>"], "risks": ["<what could go wrong, and what needs a person>"]},',
31
+ ' "name": "<lowercase-with-dashes, 2-40 chars>", "title": "<short>", "description": "<one line>",',
32
+ ' "goal": "<what is true when it has done its job, for each input; one or two sentences>",',
33
+ ' "instructions": "<Markdown: how to do the job, step by step; what to never do; when to ask>",',
34
+ ' "tools": [{"name": "<short id>", "why": "<what it needs it for>", "command": "<MCP server command, e.g. npx>", "args": ["…"], "env": {"VAR": "${VAR}"}}],',
35
+ ' "allow": ["<engine tool names it may use without asking, e.g. Read, Grep, mcp__<tool>__*>"],',
36
+ ' "approve": ["<tools that change things in the outside world and need a person\'s yes>"],',
37
+ ' "triggers": [{"every": "30m"} | {"cron": "0 9 * * 1-5"} | {"webhook": "/hooks/<name>"} | {"watch": "./inbox"}],',
38
+ ' "tests": [{"input": "<a realistic input>", "expect": "<what a good result does or contains>"}]',
39
+ '}',
40
+ '',
41
+ 'Rules: 3 to 5 tests that cover the normal case, an edge case and a case it should refuse or escalate.',
42
+ 'Only MCP servers that really exist and fit (leave "tools" empty when none is needed). Secrets only as "${VAR}".',
43
+ 'Triggers only when the task implies running on its own (a schedule, events); chat-only agents have none.',
44
+ '',
45
+ '<task>',
46
+ task,
47
+ '</task>',
48
+ ...(attachments.length ? ['<attached_files>', ...attachments.map((a) => `--- ${a.name}\n${a.text}`), '</attached_files>'] : []),
49
+ ].join('\n');
50
+ }
51
+
52
+ export function judgePrompt({ goal, input, expect, result }) {
53
+ return [
54
+ "You check one test of an autonomous agent. Decide if the agent's result meets the expectation for the input.",
55
+ 'Be strict about substance, lenient about wording. Reply with JSON only: {"pass": true|false, "why": "<one sentence>"}.',
56
+ `<goal>\n${goal}\n</goal>`,
57
+ `<input>\n${input}\n</input>`,
58
+ `<expectation>\n${expect}\n</expectation>`,
59
+ `<result>\n${result}\n</result>`,
60
+ ].join('\n');
61
+ }
62
+
63
+ /** agent.yaml text from a design. */
64
+ export function yamlOf(d, engine) {
65
+ const tools = Object.fromEntries((d.tools ?? []).filter((t) => t?.name && t.command).map((t) => [slug(t.name), { command: t.command, ...(t.args?.length ? { args: t.args } : {}), ...(t.env && Object.keys(t.env).length ? { env: t.env } : {}) }]));
66
+ const doc = {
67
+ name: slug(d.name),
68
+ title: d.title || d.name,
69
+ description: d.description ?? '',
70
+ goal: d.goal,
71
+ instructions: 'instructions.md',
72
+ engine,
73
+ tools,
74
+ allow: d.allow ?? [],
75
+ approve: d.approve ?? [],
76
+ triggers: d.triggers ?? [],
77
+ loop: { plan: true, check: true, maxSteps: 4 },
78
+ memory: true,
79
+ interfaces: { http: { port: 0 }, a2a: true, acp: true, mcp: true },
80
+ };
81
+ return YAML.stringify(doc, { lineWidth: 0 });
82
+ }
83
+
84
+ export class Builder {
85
+ /** `oneShot(prompt)` answers a design or judge prompt (the daemon's writer engine). */
86
+ /** `reportEnv()`: settings that make an agent started here report to this daemon. */
87
+ constructor({ oneShot, env = process.env, runTurn = engineTurn, pack = packageAgent, reportEnv = () => ({}) }) {
88
+ this.reportEnv = reportEnv;
89
+ this.oneShot = oneShot;
90
+ this.env = env;
91
+ this.runTurn = runTurn;
92
+ this.pack = pack;
93
+ this.procs = new Map(); // name -> child process of `agentkit run`
94
+ this.pending = new Map(); // name -> what's in progress (designing, testing)
95
+ }
96
+
97
+ dir(name) {
98
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(String(name))) throw err(400, 'Unknown agent');
99
+ return path.join(root(), name);
100
+ }
101
+
102
+ meta(name) {
103
+ try {
104
+ return JSON.parse(fs.readFileSync(path.join(this.dir(name), 'build.json'), 'utf8'));
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ saveMeta(name, patch) {
111
+ const next = { ...this.meta(name), ...patch, updatedAt: now() };
112
+ fs.writeFileSync(path.join(this.dir(name), 'build.json'), JSON.stringify(next, null, 2));
113
+ return next;
114
+ }
115
+
116
+ list() {
117
+ let names = [];
118
+ try {
119
+ names = fs.readdirSync(root()).filter((n) => fs.existsSync(path.join(root(), n, 'build.json')));
120
+ } catch {}
121
+ return names.map((n) => this.view(n)).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
122
+ }
123
+
124
+ view(name) {
125
+ const m = this.meta(name);
126
+ if (!m) throw err(404, `No built agent "${name}"`);
127
+ const dir = this.dir(name);
128
+ const read = (f) => (fs.existsSync(path.join(dir, f)) ? fs.readFileSync(path.join(dir, f), 'utf8') : '');
129
+ let spec = null;
130
+ let specError = null;
131
+ try {
132
+ spec = loadAgent(dir, { env: this.env });
133
+ } catch (e) {
134
+ specError = e.message;
135
+ }
136
+ const p = this.procs.get(name);
137
+ let runs = [];
138
+ if (spec) runs = new Agent(spec, { env: this.env }).runs(20);
139
+ return {
140
+ ...m,
141
+ name,
142
+ title: spec?.title ?? name,
143
+ description: spec?.description ?? '',
144
+ dir,
145
+ yaml: read('agent.yaml'),
146
+ instructionsText: read('instructions.md'),
147
+ specError,
148
+ engine: spec?.engine ?? null,
149
+ triggers: spec?.triggers.map((t) => t.label) ?? [],
150
+ missingEnv: spec?.missingEnv ?? [],
151
+ pending: this.pending.get(name) ?? null,
152
+ running: p ? { pid: p.child.pid, url: p.url, since: p.since } : null,
153
+ runs,
154
+ platforms: PLATFORMS.filter((x) => process.platform === 'darwin' || !x.startsWith('macos-')),
155
+ host: HOST,
156
+ // Executables built since the last edit that are still there.
157
+ executables: Object.fromEntries(Object.entries(m.executables ?? {}).filter(([, x]) => fs.existsSync(path.join(dir, 'dist', x.file)))),
158
+ };
159
+ }
160
+
161
+ /** Design an agent from a task (one model call); writes the folder and returns it. */
162
+ async design({ task, engine = 'claude', attachments = [] }) {
163
+ task = String(task ?? '').trim();
164
+ if (!task) throw err(400, 'Describe the task or workflow');
165
+ if (!['claude', 'codex', 'muse'].includes(engine) && !/^acp:[a-z0-9-]+$/.test(engine)) throw err(400, 'engine: claude, codex, muse or acp:<name>');
166
+ const d = parseJsonReply(await this.oneShot(designPrompt({ task, engine, attachments })));
167
+ if (!d.goal || !d.name) throw err(502, 'The design was missing a name or goal; try again');
168
+ let name = slug(d.name);
169
+ for (let i = 2; fs.existsSync(path.join(root(), name)); i++) name = `${slug(d.name).slice(0, 36)}-${i}`;
170
+ const dir = path.join(root(), name);
171
+ fs.mkdirSync(dir, { recursive: true });
172
+ fs.writeFileSync(path.join(dir, 'agent.yaml'), yamlOf({ ...d, name }, engine));
173
+ fs.writeFileSync(path.join(dir, 'instructions.md'), String(d.instructions ?? '').trim() + '\n');
174
+ const tests = (d.tests ?? []).filter((t) => t?.input).slice(0, 8).map((t) => ({ input: String(t.input), expect: String(t.expect ?? '') }));
175
+ fs.writeFileSync(path.join(dir, 'tests.yaml'), YAML.stringify(tests));
176
+ // Check it loads; a broken design is kept so the person can fix it.
177
+ let specError = null;
178
+ try {
179
+ checkAgent(YAML.parse(fs.readFileSync(path.join(dir, 'agent.yaml'), 'utf8')), { dir, env: this.env });
180
+ } catch (e) {
181
+ specError = e.message;
182
+ }
183
+ this.saveMeta(name, { task, analysis: d.analysis ?? null, tools: (d.tools ?? []).map((t) => ({ name: t.name, why: t.why ?? '' })), tests, results: null, status: specError ? 'needs-fixing' : 'designed', createdAt: now() });
184
+ return this.view(name);
185
+ }
186
+
187
+ /** Save edits to agent.yaml, the instructions or the tests (each checked before it's written). */
188
+ update(name, { yaml, instructions, tests } = {}) {
189
+ const dir = this.dir(name);
190
+ if (!this.meta(name)) throw err(404, `No built agent "${name}"`);
191
+ if (yaml != null) {
192
+ let raw;
193
+ try {
194
+ raw = YAML.parse(String(yaml));
195
+ } catch (e) {
196
+ throw err(400, `agent.yaml: ${e.message.split('\n')[0]}`);
197
+ }
198
+ if (raw?.name !== name) throw err(400, `agent.yaml: keep the name "${name}" (it is the agent's folder)`);
199
+ fs.writeFileSync(path.join(dir, 'instructions.md'), instructions != null ? String(instructions) : fs.readFileSync(path.join(dir, 'instructions.md'), 'utf8'));
200
+ try {
201
+ checkAgent(raw, { dir, env: this.env });
202
+ } catch (e) {
203
+ throw err(400, e.message);
204
+ }
205
+ fs.writeFileSync(path.join(dir, 'agent.yaml'), String(yaml));
206
+ } else if (instructions != null) fs.writeFileSync(path.join(dir, 'instructions.md'), String(instructions));
207
+ if (tests != null) {
208
+ const list = (Array.isArray(tests) ? tests : []).filter((t) => t?.input).slice(0, 8).map((t) => ({ input: String(t.input), expect: String(t.expect ?? '') }));
209
+ fs.writeFileSync(path.join(dir, 'tests.yaml'), YAML.stringify(list));
210
+ this.saveMeta(name, { tests: list });
211
+ }
212
+ // Executables built before carry the old files; build them again after an edit.
213
+ if (yaml != null || instructions != null || tests != null) {
214
+ fs.rmSync(path.join(dir, 'dist'), { recursive: true, force: true });
215
+ this.saveMeta(name, { executables: {} });
216
+ }
217
+ return this.view(name);
218
+ }
219
+
220
+ /** Run every test through the real runtime, and judge each result against its expectation. */
221
+ async test(name) {
222
+ const dir = this.dir(name);
223
+ if (this.pending.get(name)) throw err(409, `Already ${this.pending.get(name)}`);
224
+ const spec = loadAgent(dir, { env: this.env });
225
+ const tests = YAML.parse(fs.readFileSync(path.join(dir, 'tests.yaml'), 'utf8') || '[]') ?? [];
226
+ if (!tests.length) throw err(400, 'No test cases; add some first');
227
+ this.pending.set(name, 'testing');
228
+ this.saveMeta(name, { status: 'testing' });
229
+ try {
230
+ // Tests don't wait for people: approval tools are simply not available in them.
231
+ const agent = new Agent({ ...spec, triggers: [], report: null }, { env: this.env, engineTurn: this.runTurn, approvals: 'deny' });
232
+ const results = [];
233
+ for (const t of tests) {
234
+ const r = await agent.run(t.input, { source: 'test' });
235
+ let verdict = { pass: false, why: 'The run failed' };
236
+ if (r.status !== 'failed') {
237
+ try {
238
+ const v = parseJsonReply(await this.oneShot(judgePrompt({ goal: spec.goal, input: t.input, expect: t.expect, result: r.result })));
239
+ verdict = { pass: v.pass === true, why: String(v.why ?? '') };
240
+ } catch (e) {
241
+ verdict = { pass: false, why: `Could not judge: ${e.message}` };
242
+ }
243
+ }
244
+ results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, ...verdict });
245
+ }
246
+ const passed = results.filter((r) => r.pass).length;
247
+ this.saveMeta(name, { results: { at: now(), passed, total: results.length, list: results }, status: passed === results.length ? 'tested' : 'failing' });
248
+ } catch (e) {
249
+ this.saveMeta(name, { status: 'failing', results: { at: now(), passed: 0, total: 0, list: [], error: e.message } });
250
+ } finally {
251
+ this.pending.delete(name);
252
+ }
253
+ return this.view(name);
254
+ }
255
+
256
+ /** Build its single executable for a platform into dist/ (in the background; the view says when). */
257
+ package(name, platform = HOST) {
258
+ const dir = this.dir(name);
259
+ if (!this.meta(name)) throw err(404, `No built agent "${name}"`);
260
+ if (this.pending.get(name)) throw err(409, `Already ${this.pending.get(name)}`);
261
+ if (!PLATFORMS.includes(platform)) throw err(400, `platform: one of ${PLATFORMS.join(', ')}`);
262
+ if (platform.startsWith('macos-') && process.platform !== 'darwin') throw err(400, 'macOS executables are built on a Mac');
263
+ loadAgent(dir, { env: this.env });
264
+ this.pending.set(name, `packaging for ${platform}`);
265
+ this.saveMeta(name, { packageError: null });
266
+ return this.#pack(name, dir, platform);
267
+ }
268
+
269
+ async #pack(name, dir, platform) {
270
+ try {
271
+ const r = await this.pack(dir, { outDir: path.join(dir, 'dist'), platform });
272
+ const executables = { ...this.meta(name).executables, [platform]: { file: path.basename(r.file), size: r.size, at: now() } };
273
+ this.saveMeta(name, { executables });
274
+ } catch (e) {
275
+ this.saveMeta(name, { packageError: `${platform}: ${e.message}` });
276
+ } finally {
277
+ this.pending.delete(name);
278
+ }
279
+ return this.view(name);
280
+ }
281
+
282
+ /** The path of a built executable, for download. */
283
+ executable(name, platform) {
284
+ const x = this.view(name).executables[platform];
285
+ if (!x) throw err(404, `No ${platform} executable built yet`);
286
+ return path.join(this.dir(name), 'dist', x.file);
287
+ }
288
+
289
+ /** Run it continuously on this computer (`agentkit run`); its output goes to agent.log. */
290
+ start(name) {
291
+ const dir = this.dir(name);
292
+ if (this.procs.has(name)) return this.view(name);
293
+ loadAgent(dir, { env: this.env });
294
+ const log = fs.openSync(path.join(dir, 'agent.log'), 'a');
295
+ const child = spawn(process.execPath, [AGENTKIT, 'run', dir], { env: { ...this.env, ...this.reportEnv() }, stdio: ['ignore', log, log], detached: false });
296
+ const entry = { child, url: null, since: now() };
297
+ this.procs.set(name, entry);
298
+ // Its chat address, from the log line it prints when listening.
299
+ const watch = setInterval(() => {
300
+ const m = /chat and API: (http:\/\/\S+)/.exec(fs.readFileSync(path.join(dir, 'agent.log'), 'utf8').split('\n').slice(-40).join('\n'));
301
+ if (m) {
302
+ entry.url = m[1];
303
+ clearInterval(watch);
304
+ }
305
+ }, 300);
306
+ setTimeout(() => clearInterval(watch), 20_000);
307
+ child.on('exit', () => {
308
+ clearInterval(watch);
309
+ if (this.procs.get(name) === entry) this.procs.delete(name);
310
+ this.saveMeta(name, { lastStoppedAt: now() });
311
+ });
312
+ this.saveMeta(name, { status: this.meta(name).status === 'tested' ? 'running' : this.meta(name).status, lastStartedAt: now() });
313
+ return this.view(name);
314
+ }
315
+
316
+ stop(name) {
317
+ const p = this.procs.get(name);
318
+ if (p) p.child.kill('SIGTERM');
319
+ this.procs.delete(name);
320
+ return this.view(name);
321
+ }
322
+
323
+ stopAll() {
324
+ for (const name of [...this.procs.keys()]) this.stop(name);
325
+ }
326
+
327
+ remove(name) {
328
+ this.stop(name);
329
+ fs.rmSync(this.dir(name), { recursive: true, force: true });
330
+ return { ok: true };
331
+ }
332
+ }
package/src/manager.js CHANGED
@@ -1797,6 +1797,11 @@ export class AgentManager extends EventEmitter {
1797
1797
  return [String(intent ?? '').trim(), attachmentNote(files)].filter(Boolean).join('\n\n');
1798
1798
  }
1799
1799
 
1800
+ /** Staged uploads as text, for a model to read (the builder's attachments): name and up to 100 KB each. */
1801
+ stagedText(attachments) {
1802
+ return stagedFiles(attachments).map((f) => ({ name: path.basename(f).replace(/^\d{4}-\d\d-\d\dT[\d-]+Z-[0-9a-f]{6}-/, ''), text: fs.readFileSync(f, 'utf8').slice(0, 100_000) }));
1803
+ }
1804
+
1800
1805
  /** A file attached before its agent exists (New agents, Ask); create() copies it to the agent. */
1801
1806
  stageUpload(name, data) {
1802
1807
  fs.mkdirSync(stagedDir(), { recursive: true });