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,171 @@
1
+ // The agent at work: each input becomes a run of plan → act → check (→ act again on what's left)
2
+ // → reflect, through the engine (engines.js), one run at a time. Runs are logged as JSONL; memory
3
+ // carries lessons between runs; a conversation (a chat, an A2A context) keeps its engine session.
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 { Approvals } from './approvals.js';
9
+ import { agentPaths } from './spec.js';
10
+ import { APPROVER, approverServer, turn, writeMcpConfig } from './engines.js';
11
+
12
+ const now = () => new Date().toISOString();
13
+ const readJson = (f, d) => {
14
+ try {
15
+ return JSON.parse(fs.readFileSync(f, 'utf8'));
16
+ } catch {
17
+ return d;
18
+ }
19
+ };
20
+
21
+ /** The check step's verdict, from a reply that should hold {"done": …, "remaining": …}. */
22
+ export function verdictOf(text) {
23
+ const m = /\{[\s\S]*\}/.exec(String(text ?? ''));
24
+ try {
25
+ const j = JSON.parse(m?.[0] ?? '');
26
+ return { done: j.done === true, remaining: String(j.remaining ?? '').trim() };
27
+ } catch {
28
+ // No JSON: read "done" / "not done" in plain words, else assume done (don't loop on noise).
29
+ return { done: !/\bnot (yet )?(done|met|complete)\b/i.test(String(text)), remaining: '' };
30
+ }
31
+ }
32
+
33
+ export class Agent extends EventEmitter {
34
+ /** `approvals`: 'ask' (a person decides approval tools, where the engine can ask) or 'deny'. */
35
+ constructor(spec, { env = process.env, engineTurn = turn, approvals = 'ask' } = {}) {
36
+ super();
37
+ this.spec = spec;
38
+ this.env = env;
39
+ this.engineTurn = engineTurn;
40
+ this.paths = agentPaths(spec);
41
+ fs.mkdirSync(this.paths.runs, { recursive: true });
42
+ this.mcpFile = writeMcpConfig(spec, this.paths.mcp);
43
+ this.approvals = new Approvals(spec, { timeoutMs: spec.approveTimeoutMs ?? 30 * 60_000 });
44
+ this.approvals.on('change', () => this.emit('approvals', this.approvals.pending()));
45
+ // Only Claude can ask mid-run (the others get approval tools taken away instead).
46
+ this.asks = approvals === 'ask' && spec.engine === 'claude' && spec.approve.length > 0;
47
+ this.approverReady = false;
48
+ this.sessions = readJson(this.paths.sessions, {});
49
+ this.queue = Promise.resolve();
50
+ this.current = null;
51
+ }
52
+
53
+ memory() {
54
+ try {
55
+ return fs.readFileSync(this.paths.memory, 'utf8').trim();
56
+ } catch {
57
+ return '';
58
+ }
59
+ }
60
+
61
+ system() {
62
+ const s = this.spec;
63
+ return [
64
+ `You are "${s.title}", an autonomous agent that works on its own, without a person watching.`,
65
+ `Your goal:\n${s.goal}`,
66
+ s.instructions ? `How to do the job:\n${s.instructions}` : '',
67
+ s.memory && this.memory() ? `What you learned in earlier runs (keep using it):\n${this.memory().slice(-6000)}` : '',
68
+ 'Work in your folder with your tools. Never invent results: if you could not do something, say so plainly.',
69
+ ]
70
+ .filter(Boolean)
71
+ .join('\n\n');
72
+ }
73
+
74
+ /** Queue a run; resolves with its result. `conversation` continues an engine session. */
75
+ run(input, { source = 'api', conversation = null } = {}) {
76
+ const job = this.queue.then(() => this.doRun(String(input ?? ''), { source, conversation }));
77
+ this.queue = job.catch(() => {});
78
+ return job;
79
+ }
80
+
81
+ async doRun(input, { source, conversation }) {
82
+ const id = `${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
83
+ const file = path.join(this.paths.runs, `${id}.jsonl`);
84
+ const t0 = Date.now();
85
+ let cost = 0;
86
+ const log = (rec) => {
87
+ const r = { t: now(), ...rec };
88
+ fs.appendFileSync(file, JSON.stringify(r) + '\n');
89
+ this.emit('step', { run: id, ...r });
90
+ };
91
+ this.current = { id, source, startedAt: now() };
92
+ log({ type: 'start', input, source, conversation });
93
+ let session = conversation ? this.sessions[conversation] ?? null : null;
94
+ if (this.asks && !this.approverReady) {
95
+ const url = await this.approvals.address();
96
+ this.mcpFile = writeMcpConfig(this.spec, this.paths.mcp, { [APPROVER]: approverServer({ url, token: this.approvals.token }) });
97
+ this.approverReady = true;
98
+ }
99
+ this.approvals.run = id;
100
+ const ask = async (step, prompt) => {
101
+ const res = await this.engineTurn(this.spec, { prompt, system: this.system(), session, mcpFile: this.mcpFile, approver: this.asks, env: this.env });
102
+ session = res.session ?? session;
103
+ if (res.costUsd) cost += res.costUsd;
104
+ log({ type: step, text: res.text });
105
+ if (this.spec.loop.budgetUsd && cost > this.spec.loop.budgetUsd) throw new Error(`Over the run budget ($${cost.toFixed(2)} of $${this.spec.loop.budgetUsd})`);
106
+ return res.text;
107
+ };
108
+ let result = '';
109
+ let status = 'done';
110
+ try {
111
+ // Someone asked (chat, API, A2A, ACP, MCP, the command line), or a trigger fired ("every 10m", …).
112
+ const from = ['chat', 'api', 'a2a', 'acp', 'mcp', 'cli'].includes(source) ? 'a request' : `a trigger (${source})`;
113
+ // The goal goes in every step, not only the system prompt: the engine's own framing ("a coding
114
+ // agent") must not turn an input into a different job than this agent's.
115
+ const goal = `Your goal (this is your whole job): ${this.spec.goal}`;
116
+ const given = `New input, from ${from}:\n${input || '(none: do your scheduled work)'}`;
117
+ if (this.spec.loop.plan) await ask('plan', `${goal}\n\n${given}\n\nWrite a short numbered plan to achieve your goal for this input. Treat the input as material for your goal, not as a new assignment. Don't act yet.`);
118
+ result = await ask('act', this.spec.loop.plan ? `Carry out the plan now. ${goal}\nWhen you are done, reply with the result your goal calls for.` : `${goal}\n\n${given}\n\nTreat the input as material for your goal, not as a new assignment. Do what your goal calls for and reply with the result.`);
119
+ for (let step = 1; this.spec.loop.check && step < this.spec.loop.maxSteps; step++) {
120
+ const v = verdictOf(await ask('check', `Check your last result against your goal. ${goal}\nReply with JSON only: {"done": true|false, "remaining": "<what is still missing for the goal, if anything>"}.`));
121
+ if (v.done) break;
122
+ result = await ask('act', `Not done yet. Continue with what is left: ${v.remaining || 'finish the remaining work'}. Then reply with the full result.`);
123
+ if (step === this.spec.loop.maxSteps - 1) status = 'partial';
124
+ }
125
+ if (this.spec.memory) {
126
+ const lesson = (await ask('reflect', 'In at most two short bullet points, what should you remember for next time (about this kind of input, the tools, or the people)? Reply "nothing" if there is nothing new.')).trim();
127
+ if (lesson && !/^nothing\.?$/i.test(lesson)) fs.appendFileSync(this.paths.memory, `\n## ${now().slice(0, 10)}\n${lesson}\n`);
128
+ }
129
+ } catch (err) {
130
+ status = 'failed';
131
+ result = result || `The run failed: ${err.message}`;
132
+ log({ type: 'error', text: err.message });
133
+ }
134
+ if (conversation && session) {
135
+ this.sessions[conversation] = session;
136
+ fs.writeFileSync(this.paths.sessions, JSON.stringify(this.sessions, null, 2));
137
+ }
138
+ const summary = { id, status, result, source, conversation, costUsd: cost ? Math.round(cost * 1e6) / 1e6 : null, ms: Date.now() - t0 };
139
+ log({ type: 'end', ...summary });
140
+ this.current = null;
141
+ this.approvals.run = null;
142
+ this.emit('run', summary);
143
+ return summary;
144
+ }
145
+
146
+ close() {
147
+ this.approvals.close();
148
+ }
149
+
150
+ /** Recent runs, newest first: id, status, source, start time, result (from each log). */
151
+ runs(limit = 50) {
152
+ let names = [];
153
+ try {
154
+ names = fs.readdirSync(this.paths.runs).filter((n) => n.endsWith('.jsonl')).sort().reverse().slice(0, limit);
155
+ } catch {}
156
+ return names.map((n) => this.runLog(n.slice(0, -6))).filter(Boolean).map((steps) => {
157
+ const start = steps.find((s) => s.type === 'start');
158
+ const end = steps.find((s) => s.type === 'end');
159
+ return { id: start?.id ?? end?.id, startedAt: start?.t, source: start?.source, input: start?.input, status: end?.status ?? 'running', result: end?.result ?? null, costUsd: end?.costUsd ?? null };
160
+ }).map((r, i) => ({ ...r, id: r.id ?? names[i].slice(0, -6) }));
161
+ }
162
+
163
+ runLog(id) {
164
+ if (!/^[\w-]+$/.test(id)) return null;
165
+ try {
166
+ return fs.readFileSync(path.join(this.paths.runs, `${id}.jsonl`), 'utf8').trim().split('\n').map((l) => JSON.parse(l));
167
+ } catch {
168
+ return null;
169
+ }
170
+ }
171
+ }
@@ -0,0 +1,97 @@
1
+ // Approvals: a tool on the agent's `approve` list runs only after a person says yes. Claude asks
2
+ // the approver (an MCP tool, --permission-prompt-tool) before any tool it isn't allowed outright;
3
+ // the approver asks this object over a private local address; this asks the people reachable
4
+ // (the chat page, AI Agent Manager) and answers with the first decision, or no after a while.
5
+ import crypto from 'node:crypto';
6
+ import { EventEmitter } from 'node:events';
7
+ import http from 'node:http';
8
+
9
+ /** Does a tool name match a pattern ("mcp__jira__*", "Bash", "Bash(git push:*)" → Bash)? */
10
+ export function toolMatches(pattern, tool) {
11
+ const base = String(pattern).replace(/\(.*\)$/, '');
12
+ const re = new RegExp(`^${base.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')}$`);
13
+ return re.test(String(tool));
14
+ }
15
+
16
+ /** Allow patterns Claude may apply on its own: not ones that also cover a tool that needs approval. */
17
+ export function allowedOutright(spec) {
18
+ return spec.allow.filter((p) => !spec.approve.some((q) => toolMatches(p, q.replace(/\(.*\)$/, ''))));
19
+ }
20
+
21
+ export class Approvals extends EventEmitter {
22
+ constructor(spec, { timeoutMs = 30 * 60_000 } = {}) {
23
+ super();
24
+ this.spec = spec;
25
+ this.timeoutMs = timeoutMs;
26
+ this.open = new Map(); // id -> { id, tool, input, run, at, finish }
27
+ this.decided = []; // the latest decisions, for the views
28
+ this.channels = new Set(); // who can answer: 'chat', 'manager'
29
+ this.server = null;
30
+ this.token = crypto.randomBytes(24).toString('hex');
31
+ }
32
+
33
+ /** The private address the approver calls (started on first use). */
34
+ async address() {
35
+ if (this.server) return this.url;
36
+ this.server = http.createServer((req, res) => {
37
+ let body = '';
38
+ req.on('data', (d) => (body += d));
39
+ req.on('end', async () => {
40
+ const ok = req.method === 'POST' && req.headers.authorization === `Bearer ${this.token}`;
41
+ if (!ok) return res.writeHead(401).end();
42
+ let ask = {};
43
+ try {
44
+ ask = JSON.parse(body);
45
+ } catch {}
46
+ const d = await this.request({ tool: String(ask.tool ?? ''), input: ask.input ?? {} });
47
+ res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify(d));
48
+ });
49
+ });
50
+ await new Promise((r) => this.server.listen(0, '127.0.0.1', r));
51
+ this.server.unref();
52
+ this.url = `http://127.0.0.1:${this.server.address().port}/`;
53
+ return this.url;
54
+ }
55
+
56
+ /** A tool wants to run: yes now if allowed, no if not on the approve list, else ask and wait. */
57
+ request({ tool, input = {} }) {
58
+ const ask = this.spec.approve.some((p) => toolMatches(p, tool));
59
+ if (!ask && this.spec.allow.some((p) => toolMatches(p, tool))) return Promise.resolve({ allow: true });
60
+ if (!ask) return Promise.resolve({ allow: false, message: `${tool} is not in this agent's allow list` });
61
+ if (!this.channels.size) return Promise.resolve({ allow: false, message: `${tool} needs a person's approval and no one can be asked (no chat page, not connected to AI Agent Manager)` });
62
+ const id = `ap-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
63
+ return new Promise((resolve) => {
64
+ const timer = setTimeout(() => this.decide(id, false, { by: 'timeout', note: `No answer in ${Math.round(this.timeoutMs / 60_000)} min` }), this.timeoutMs);
65
+ const a = { id, tool, input, run: this.run ?? null, at: new Date().toISOString(), finish: (d) => (clearTimeout(timer), resolve(d)) };
66
+ this.open.set(id, a);
67
+ this.emit('request', this.view(a));
68
+ this.emit('change');
69
+ });
70
+ }
71
+
72
+ /** A person's (or the timeout's) answer. Returns false if it was already decided. */
73
+ decide(id, allow, { by = 'someone', note = '' } = {}) {
74
+ const a = this.open.get(id);
75
+ if (!a) return false;
76
+ this.open.delete(id);
77
+ const d = { ...this.view(a), allow: Boolean(allow), by, note, decidedAt: new Date().toISOString() };
78
+ this.decided = [d, ...this.decided].slice(0, 20);
79
+ a.finish({ allow: Boolean(allow), message: allow ? '' : `Declined by ${by}${note ? `: ${note}` : ''}` });
80
+ this.emit('decided', d);
81
+ this.emit('change');
82
+ return true;
83
+ }
84
+
85
+ view(a) {
86
+ return { id: a.id, tool: a.tool, input: a.input, run: a.run, at: a.at };
87
+ }
88
+
89
+ pending() {
90
+ return [...this.open.values()].map((a) => this.view(a));
91
+ }
92
+
93
+ close() {
94
+ for (const id of [...this.open.keys()]) this.decide(id, false, { by: 'shutdown' });
95
+ this.server?.close();
96
+ }
97
+ }
@@ -0,0 +1,153 @@
1
+ // The agentkit command line (bin/agentkit.js), also inside a packaged agent (sea-main.js), where
2
+ // the agent's own folder is the default and `run` is the default command.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { Command } from 'commander';
6
+ import { Agent } from './agent.js';
7
+ import { startHttp } from './http.js';
8
+ import { loadAgent } from './spec.js';
9
+ import { install as serviceInstall, serviceSpec, status as serviceStatus, uninstall as serviceUninstall } from './service.js';
10
+ import { startReporting } from './report.js';
11
+ import { serveAcp, serveApprover, serveMcp } from './stdio.js';
12
+ import { startTriggers } from './triggers.js';
13
+
14
+ // Status goes to stderr: stdout belongs to the protocol (acp, mcp) or the result (--once).
15
+ const say = (m) => process.stderr.write(`[agentkit] ${m}\n`);
16
+ const load = (ref) => {
17
+ const spec = loadAgent(ref);
18
+ if (spec.missingEnv.length) say(`warning: not set in the environment: ${spec.missingEnv.join(', ')}`);
19
+ return spec;
20
+ };
21
+
22
+ /** Run the command line; `defaultRef` is the agent folder when none is given. */
23
+ export async function main(argv, { name = 'agentkit', defaultRef = '.', packaged = false } = {}) {
24
+ const program = new Command().name(name).description(packaged ? `${name}: an agent built with agentkit` : 'Run agents described by agent.yaml');
25
+
26
+ program
27
+ .command('run')
28
+ .argument('[agent]', 'the agent folder (or its agent.yaml)', defaultRef)
29
+ .option('--once <input>', 'do one run with this input, print the result and exit')
30
+ .option('-p, --port <port>', 'HTTP port (default: agent.yaml interfaces.http.port, or 7700)')
31
+ .action(async (ref, opts) => {
32
+ const spec = load(ref);
33
+ const agent = new Agent(spec);
34
+ agent.on('step', (s) => s.type !== 'start' && s.type !== 'end' && say(`${s.run} ${s.type}${s.text ? `: ${String(s.text).split('\n')[0].slice(0, 120)}` : ''}`));
35
+ agent.approvals.on('request', (a) => say(`approval needed: ${a.tool} (${a.id}); approve it ${[agent.approvals.channels.has('chat') && 'on the chat page', agent.approvals.channels.has('manager') && 'in AI Agent Manager'].filter(Boolean).join(' or ')}`));
36
+ agent.approvals.on('decided', (d) => say(`${d.tool}: ${d.allow ? 'approved' : 'declined'} by ${d.by}`));
37
+ let web = null;
38
+ const reporting = spec.report ? startReporting(agent, { url: spec.report.url, token: spec.report.token, cert: spec.report.cert, chat: () => web?.url ?? null, log: say }) : null;
39
+ if (opts.once !== undefined) {
40
+ const r = await agent.run(opts.once, { source: 'cli' });
41
+ await reporting?.flush();
42
+ process.stdout.write(`${r.result}\n`);
43
+ process.exit(r.status === 'failed' ? 1 : 0);
44
+ }
45
+ say(`${spec.title} (${spec.engine}) · working in ${spec.workdir}`);
46
+ const stop = startTriggers(agent, { log: say });
47
+ web = spec.interfaces.http ? await startHttp(agent, { port: opts.port ? Number(opts.port) : spec.interfaces.http.port, host: spec.interfaces.http.host, log: say }) : null;
48
+ reporting?.push(); // with its chat address now
49
+ agent.on('run', (r) => say(`run ${r.id} ${r.status} (${r.source}, ${Math.round(r.ms / 1000)}s${r.costUsd ? `, $${r.costUsd.toFixed(3)}` : ''})`));
50
+ const quit = async () => {
51
+ stop();
52
+ reporting?.stop();
53
+ agent.close();
54
+ await web?.close();
55
+ process.exit(0);
56
+ };
57
+ process.on('SIGINT', quit);
58
+ process.on('SIGTERM', quit);
59
+ });
60
+
61
+ program
62
+ .command('acp')
63
+ .argument('[agent]', 'the agent folder', defaultRef)
64
+ .action((ref) => serveAcp(new Agent(load(ref))));
65
+
66
+ program
67
+ .command('mcp')
68
+ .argument('[agent]', 'the agent folder', defaultRef)
69
+ .action((ref) => serveMcp(new Agent(load(ref))));
70
+
71
+ // Started by Claude as an MCP server (see approvals.js), not by people.
72
+ program
73
+ .command('approver', { hidden: true })
74
+ .action(() => serveApprover({ url: process.env.AGENTKIT_APPROVAL_URL, token: process.env.AGENTKIT_APPROVAL_TOKEN }));
75
+
76
+ program
77
+ .command('check')
78
+ .argument('[agent]', 'the agent folder', defaultRef)
79
+ .action((ref) => {
80
+ const s = load(ref);
81
+ console.log(`${s.title} (${s.name}): engine ${s.engine}${s.model ? ` (${s.model})` : ''}, ${Object.keys(s.tools).length} MCP server(s), triggers: ${s.triggers.map((t) => t.label).join(', ') || 'none'}; interfaces: ${['http', 'a2a', 'acp', 'mcp'].filter((k) => s.interfaces[k]).join(', ')}`);
82
+ });
83
+
84
+ if (!packaged)
85
+ program
86
+ .command('package')
87
+ .description('Make the agent one executable to run anywhere (macOS or Linux; macOS ones are built on a Mac)')
88
+ .argument('[agent]', 'the agent folder', defaultRef)
89
+ .option('--platform <platform>', 'macos-arm64, macos-x64, linux-x64 or linux-arm64 (default: this computer)')
90
+ .option('-o, --out <folder>', 'where to write it (default: <agent>/dist)')
91
+ .action(async (ref, opts) => {
92
+ const { packageAgent, HOST } = await import('./package.js');
93
+ const spec = load(ref);
94
+ const r = await packageAgent(spec.dir, { outDir: path.resolve(opts.out ?? path.join(spec.dir, 'dist')), platform: opts.platform ?? HOST, log: say });
95
+ console.log(`${r.file} (${(r.size / 1048576).toFixed(0)} MB, ${r.platform}). Run it: ${r.file} (needs ${spec.engine === 'claude' ? 'Claude Code' : spec.engine} installed and signed in)`);
96
+ });
97
+ if (!packaged)
98
+ program
99
+ .command('init')
100
+ .argument('<folder>', 'where to create the agent')
101
+ .option('-n, --name <name>', 'its name (default: the folder name)')
102
+ .action((folder, opts) => {
103
+ const dir = path.resolve(folder);
104
+ if (fs.existsSync(path.join(dir, 'agent.yaml'))) throw new Error(`${dir} already has an agent.yaml`);
105
+ const name = (opts.name ?? path.basename(dir)).toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/^-+|-+$/g, '') || 'my-agent';
106
+ fs.mkdirSync(dir, { recursive: true });
107
+ fs.writeFileSync(
108
+ path.join(dir, 'agent.yaml'),
109
+ `name: ${name}\ntitle: ${name}\ndescription: What this agent does, in one line.\ngoal: |\n What is true when the agent has done its job.\ninstructions: instructions.md\nengine: claude\n\ntools: {} # MCP servers, e.g. jira: { command: npx, args: [-y, "@acme/jira-mcp"] }\nallow: [Read, Grep, Glob]\n\ntriggers: [] # e.g. - every: 30m - cron: "0 9 * * 1-5" - webhook: /hooks/new - watch: ./inbox\n\nloop: { plan: true, check: true, maxSteps: 4 }\nmemory: true\ninterfaces: { http: { port: 7700 }, a2a: true, acp: true, mcp: true }\n`,
110
+ );
111
+ fs.writeFileSync(path.join(dir, 'instructions.md'), '# How to do the job\n\n1. …\n2. …\n\nWhen something is unclear, say what you need rather than guessing.\n');
112
+ console.log(`Created ${path.join(dir, 'agent.yaml')}. Edit it, then: agentkit run ${folder}`);
113
+ });
114
+
115
+ // In the background: started at login, restarted after a crash, output in <agent>/agent.log.
116
+ const service = program.command('service').description('Keep it running in the background (launchd on macOS, systemd on Linux)');
117
+ const serviceOf = (ref) => {
118
+ const spec = load(ref);
119
+ // A packaged agent runs itself; otherwise this agentkit runs the folder.
120
+ const argv = packaged ? [process.execPath, 'run'] : [process.execPath, path.resolve(process.argv[1]), 'run', spec.dir];
121
+ return { spec, s: serviceSpec({ name: spec.name, dir: spec.dir, argv }) };
122
+ };
123
+ service
124
+ .command('install')
125
+ .argument('[agent]', 'the agent folder', defaultRef)
126
+ .action(async (ref) => {
127
+ const { spec, s } = serviceOf(ref);
128
+ const r = await serviceInstall(s);
129
+ console.log(`${spec.title} now runs in the background (${r.manager}: ${r.file}); output in ${s.log}.${packaged ? ` Keep ${process.execPath} where it is.` : ''}${r.note ? `\n${r.note}` : ''}`);
130
+ });
131
+ service
132
+ .command('uninstall')
133
+ .argument('[agent]', 'the agent folder', defaultRef)
134
+ .action(async (ref) => {
135
+ const r = await serviceUninstall(load(ref).name);
136
+ console.log(`Stopped and removed ${r.file}`);
137
+ });
138
+ service
139
+ .command('status')
140
+ .argument('[agent]', 'the agent folder', defaultRef)
141
+ .action(async (ref) => {
142
+ const r = await serviceStatus(load(ref).name);
143
+ console.log(r.installed ? `installed (${r.file}); ${r.running ? 'running' : 'not running'}` : 'not installed');
144
+ });
145
+
146
+ // A packaged agent: `<agent>` runs it, `<agent> --once …` does one run, `<agent> acp|mcp|check` the rest.
147
+ if (packaged && !['run', 'acp', 'mcp', 'check', 'service', 'approver', 'help', '--help', '-h', '--version'].includes(argv[0])) argv = ['run', ...argv];
148
+ return program.parseAsync(argv, { from: 'user' }).catch((err) => {
149
+ console.error(`${name}: ${err.message}`);
150
+ process.exit(1);
151
+ });
152
+ }
153
+
@@ -0,0 +1,115 @@
1
+ // One headless turn of an engine's CLI: a prompt in, the answer (and session, cost) out. The agent
2
+ // keeps one engine session per conversation, so a follow-up continues it. Tools come from the
3
+ // agent's MCP servers; the allow list says what runs without asking. "approve" tools ask a person
4
+ // through the approver (Claude), or are kept out of reach (engines with no way to ask).
5
+ import { spawn } from 'node:child_process';
6
+ import crypto from 'node:crypto';
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { createRequire } from 'node:module';
10
+ import { fileURLToPath } from 'node:url';
11
+
12
+ import { allowedOutright } from './approvals.js';
13
+
14
+ // Inside a packaged agent? (node:sea only exists from Node 20.12; the daemon supports older 20s.)
15
+ const isSea = () => {
16
+ try {
17
+ return createRequire(import.meta.url)('node:sea').isSea();
18
+ } catch {
19
+ return false;
20
+ }
21
+ };
22
+ const AGENTKIT_BIN = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'agentkit.js');
23
+ const ACP_CLIENT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'bin', 'acp-client.js');
24
+ // Overrides for tests and odd installs: AGENTKIT_CLAUDE_BIN, AGENTKIT_CODEX_BIN, AGENTKIT_MUSE_BIN
25
+ // (a command, optionally with arguments: "node /path/fake.js").
26
+ const binOf = (name, env) => (env[`AGENTKIT_${name.toUpperCase()}_BIN`] ?? name).split(' ');
27
+
28
+ function run(cmd, args, { input, cwd, env, timeoutMs }) {
29
+ return new Promise((resolve, reject) => {
30
+ const child = spawn(cmd, args, { cwd, env, stdio: ['pipe', 'pipe', 'pipe'] });
31
+ let out = '';
32
+ let err = '';
33
+ const timer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
34
+ child.stdout.on('data', (d) => (out += d));
35
+ child.stderr.on('data', (d) => (err += d));
36
+ child.on('error', (e) => (clearTimeout(timer), reject(Object.assign(e, { message: `Could not start ${cmd}: ${e.message}` }))));
37
+ child.on('close', (code) => {
38
+ clearTimeout(timer);
39
+ if (code === 0) resolve(out);
40
+ else reject(new Error(`${path.basename(cmd)} exited with ${code}: ${(err || out).trim().slice(-400)}`));
41
+ });
42
+ child.stdin.end(input ?? '');
43
+ });
44
+ }
45
+
46
+ /** The MCP config file the engine loads (Claude's format: { mcpServers: {...} }), plus `extra` servers. */
47
+ export function writeMcpConfig(agent, file, extra = {}) {
48
+ const servers = { ...agent.tools, ...extra };
49
+ if (!Object.keys(servers).length) return null;
50
+ fs.mkdirSync(path.dirname(file), { recursive: true });
51
+ fs.writeFileSync(file, JSON.stringify({ mcpServers: servers }, null, 2), { mode: 0o600 });
52
+ return file;
53
+ }
54
+
55
+ /** The approver as an MCP server: this program (`<agent> approver` when packaged) asking `url`. */
56
+ export const APPROVER = 'agentkit_approval';
57
+ export function approverServer({ url, token }) {
58
+ return { command: process.execPath, args: isSea() ? ['approver'] : [AGENTKIT_BIN, 'approver'], env: { AGENTKIT_APPROVAL_URL: url, AGENTKIT_APPROVAL_TOKEN: token } };
59
+ }
60
+
61
+ /**
62
+ * One turn. `session`: the engine session to continue (null starts one); returns
63
+ * { text, session, costUsd }. `system` goes in as an extra system prompt where the engine has one,
64
+ * else ahead of the prompt.
65
+ */
66
+ export async function turn(agent, { prompt, system = '', session = null, mcpFile = null, approver = false, env = process.env, timeoutMs = 15 * 60_000 }) {
67
+ fs.mkdirSync(agent.workdir, { recursive: true });
68
+ const cwd = agent.workdir;
69
+ if (agent.engine === 'claude') {
70
+ const [cmd, ...pre] = binOf('claude', env);
71
+ const sid = session ?? crypto.randomUUID();
72
+ const args = [...pre, '-p', '--output-format', 'json', session ? '--resume' : '--session-id', sid];
73
+ if (agent.model) args.push('--model', agent.model);
74
+ if (system) args.push('--append-system-prompt', system);
75
+ // Self-contained: the agent's own MCP servers only (none when it has none), and none of the
76
+ // person's own Claude settings (their MCP servers, hooks, permissions) leak in.
77
+ args.push('--setting-sources', 'project', '--strict-mcp-config');
78
+ if (mcpFile) args.push('--mcp-config', mcpFile);
79
+ // The approve list wins: an allow pattern that also covers an approval tool goes to the approver.
80
+ const allow = approver ? allowedOutright(agent) : agent.allow;
81
+ if (allow.length) args.push('--allowedTools', ...allow);
82
+ // With an approver, Claude asks it before anything not allowed outright (it says no to tools
83
+ // off the approve list); without one, approval tools are simply not available.
84
+ if (approver) args.push('--permission-prompt-tool', `mcp__${APPROVER}__decide`);
85
+ else if (agent.approve.length) args.push('--disallowedTools', ...agent.approve);
86
+ const out = await run(cmd, args, { input: prompt, cwd, env, timeoutMs });
87
+ try {
88
+ const j = JSON.parse(out);
89
+ if (j.is_error) throw new Error(String(j.result ?? 'the engine reported an error'));
90
+ return { text: String(j.result ?? ''), session: j.session_id ?? sid, costUsd: j.total_cost_usd ?? null };
91
+ } catch (e) {
92
+ if (e instanceof SyntaxError) return { text: out.trim(), session: sid, costUsd: null };
93
+ throw e;
94
+ }
95
+ }
96
+ if (agent.engine === 'codex') {
97
+ const [cmd, ...pre] = binOf('codex', env);
98
+ const base = ['exec', '--skip-git-repo-check', '--sandbox', 'workspace-write', ...(agent.model ? ['--model', agent.model] : [])];
99
+ const args = [...pre, ...(session ? [...base, 'resume', session] : base), '-'];
100
+ const out = await run(cmd, args, { input: system ? `${system}\n\n---\n\n${prompt}` : prompt, cwd, env, timeoutMs });
101
+ // Codex prints its session id in a header line ("session id: …").
102
+ const sid = session ?? /session id:\s*([0-9a-f-]{36})/i.exec(out)?.[1] ?? null;
103
+ return { text: out.trim().split(/\n-{3,}\n|\ntokens used/i)[0].trim(), session: sid, costUsd: null };
104
+ }
105
+ if (agent.engine === 'muse') {
106
+ const [cmd, ...pre] = binOf('muse', env);
107
+ const args = [...pre, 'exec', ...(session ? ['--session-id', session] : []), ...(agent.model ? ['--model', agent.model] : []), '--', system ? `${system}\n\n---\n\n${prompt}` : prompt];
108
+ return { text: (await run(cmd, args, { cwd, env, timeoutMs })).trim(), session, costUsd: null };
109
+ }
110
+ // acp:<name>: our ACP client in one-shot mode, with the agent's command from AGENTKIT_ACP_<NAME>.
111
+ const name = agent.engine.slice(4);
112
+ const command = (env[`AGENTKIT_ACP_${name.toUpperCase().replace(/-/g, '_')}`] ?? name).split(' ');
113
+ const out = await run(process.execPath, [ACP_CLIENT, '--once', '--', ...command], { input: system ? `${system}\n\n---\n\n${prompt}` : prompt, cwd, env, timeoutMs });
114
+ return { text: out.trim(), session: null, costUsd: null };
115
+ }