ai-agentmanager 1.11.1 → 1.13.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.
- package/CHANGELOG.md +20 -0
- package/bin/agentkit.js +10 -0
- package/docs/agentkit.md +175 -0
- package/package.json +7 -3
- package/public/app.js +44 -6
- package/public/built.js +332 -0
- package/public/index.html +22 -0
- package/public/style.css +30 -0
- package/src/agentkit/agent.js +171 -0
- package/src/agentkit/approvals.js +97 -0
- package/src/agentkit/cli.js +153 -0
- package/src/agentkit/engines.js +128 -0
- package/src/agentkit/http.js +131 -0
- package/src/agentkit/package.js +114 -0
- package/src/agentkit/report.js +106 -0
- package/src/agentkit/sea-main.js +25 -0
- package/src/agentkit/service.js +118 -0
- package/src/agentkit/spec.js +109 -0
- package/src/agentkit/stdio.js +95 -0
- package/src/agentkit/triggers.js +60 -0
- package/src/agentkit-reports.js +117 -0
- package/src/builder.js +436 -0
- package/src/manager.js +25 -2
- package/src/server.js +74 -5
|
@@ -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,128 @@
|
|
|
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
|
+
* Claude Code scopes file writes with Edit(<path>) rules (they cover Write too); a Write(<path>)
|
|
63
|
+
* rule is ignored. Each Write(<path>) also gets its Edit(<path>), so writing there is allowed.
|
|
64
|
+
*/
|
|
65
|
+
export function claudeRules(allow) {
|
|
66
|
+
const out = [...allow];
|
|
67
|
+
for (const p of allow) {
|
|
68
|
+
const m = /^Write\((.+)\)$/.exec(p);
|
|
69
|
+
if (m && !out.includes(`Edit(${m[1]})`)) out.push(`Edit(${m[1]})`);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* One turn. `session`: the engine session to continue (null starts one); returns
|
|
76
|
+
* { text, session, costUsd }. `system` goes in as an extra system prompt where the engine has one,
|
|
77
|
+
* else ahead of the prompt.
|
|
78
|
+
*/
|
|
79
|
+
export async function turn(agent, { prompt, system = '', session = null, mcpFile = null, approver = false, env = process.env, timeoutMs = 15 * 60_000 }) {
|
|
80
|
+
fs.mkdirSync(agent.workdir, { recursive: true });
|
|
81
|
+
const cwd = agent.workdir;
|
|
82
|
+
if (agent.engine === 'claude') {
|
|
83
|
+
const [cmd, ...pre] = binOf('claude', env);
|
|
84
|
+
const sid = session ?? crypto.randomUUID();
|
|
85
|
+
const args = [...pre, '-p', '--output-format', 'json', session ? '--resume' : '--session-id', sid];
|
|
86
|
+
if (agent.model) args.push('--model', agent.model);
|
|
87
|
+
if (system) args.push('--append-system-prompt', system);
|
|
88
|
+
// Self-contained: the agent's own MCP servers only (none when it has none), and none of the
|
|
89
|
+
// person's own Claude settings (their MCP servers, hooks, permissions) leak in.
|
|
90
|
+
args.push('--setting-sources', 'project', '--strict-mcp-config');
|
|
91
|
+
if (mcpFile) args.push('--mcp-config', mcpFile);
|
|
92
|
+
// The approve list wins: an allow pattern that also covers an approval tool goes to the approver.
|
|
93
|
+
const allow = claudeRules(approver ? allowedOutright(agent) : agent.allow);
|
|
94
|
+
if (allow.length) args.push('--allowedTools', ...allow);
|
|
95
|
+
// With an approver, Claude asks it before anything not allowed outright (it says no to tools
|
|
96
|
+
// off the approve list); without one, approval tools are simply not available.
|
|
97
|
+
if (approver) args.push('--permission-prompt-tool', `mcp__${APPROVER}__decide`);
|
|
98
|
+
else if (agent.approve.length) args.push('--disallowedTools', ...agent.approve);
|
|
99
|
+
const out = await run(cmd, args, { input: prompt, cwd, env, timeoutMs });
|
|
100
|
+
try {
|
|
101
|
+
const j = JSON.parse(out);
|
|
102
|
+
if (j.is_error) throw new Error(String(j.result ?? 'the engine reported an error'));
|
|
103
|
+
return { text: String(j.result ?? ''), session: j.session_id ?? sid, costUsd: j.total_cost_usd ?? null };
|
|
104
|
+
} catch (e) {
|
|
105
|
+
if (e instanceof SyntaxError) return { text: out.trim(), session: sid, costUsd: null };
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (agent.engine === 'codex') {
|
|
110
|
+
const [cmd, ...pre] = binOf('codex', env);
|
|
111
|
+
const base = ['exec', '--skip-git-repo-check', '--sandbox', 'workspace-write', ...(agent.model ? ['--model', agent.model] : [])];
|
|
112
|
+
const args = [...pre, ...(session ? [...base, 'resume', session] : base), '-'];
|
|
113
|
+
const out = await run(cmd, args, { input: system ? `${system}\n\n---\n\n${prompt}` : prompt, cwd, env, timeoutMs });
|
|
114
|
+
// Codex prints its session id in a header line ("session id: …").
|
|
115
|
+
const sid = session ?? /session id:\s*([0-9a-f-]{36})/i.exec(out)?.[1] ?? null;
|
|
116
|
+
return { text: out.trim().split(/\n-{3,}\n|\ntokens used/i)[0].trim(), session: sid, costUsd: null };
|
|
117
|
+
}
|
|
118
|
+
if (agent.engine === 'muse') {
|
|
119
|
+
const [cmd, ...pre] = binOf('muse', env);
|
|
120
|
+
const args = [...pre, 'exec', ...(session ? ['--session-id', session] : []), ...(agent.model ? ['--model', agent.model] : []), '--', system ? `${system}\n\n---\n\n${prompt}` : prompt];
|
|
121
|
+
return { text: (await run(cmd, args, { cwd, env, timeoutMs })).trim(), session, costUsd: null };
|
|
122
|
+
}
|
|
123
|
+
// acp:<name>: our ACP client in one-shot mode, with the agent's command from AGENTKIT_ACP_<NAME>.
|
|
124
|
+
const name = agent.engine.slice(4);
|
|
125
|
+
const command = (env[`AGENTKIT_ACP_${name.toUpperCase().replace(/-/g, '_')}`] ?? name).split(' ');
|
|
126
|
+
const out = await run(process.execPath, [ACP_CLIENT, '--once', '--', ...command], { input: system ? `${system}\n\n---\n\n${prompt}` : prompt, cwd, env, timeoutMs });
|
|
127
|
+
return { text: out.trim(), session: null, costUsd: null };
|
|
128
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
// The agent over HTTP: a chat page for people, a small API for apps, webhooks for triggers, and
|
|
2
|
+
// A2A (the agent-to-agent protocol: an agent card, and tasks over JSON-RPC) for other agents.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import http from 'node:http';
|
|
5
|
+
import express from 'express';
|
|
6
|
+
|
|
7
|
+
const esc = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
|
|
8
|
+
|
|
9
|
+
/** The A2A agent card: who the agent is and how to reach it. */
|
|
10
|
+
export function agentCard(agent, base) {
|
|
11
|
+
const s = agent.spec;
|
|
12
|
+
return {
|
|
13
|
+
protocolVersion: '0.3.0',
|
|
14
|
+
name: s.title,
|
|
15
|
+
description: s.description || s.goal,
|
|
16
|
+
url: `${base}/a2a`,
|
|
17
|
+
preferredTransport: 'JSONRPC',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
capabilities: { streaming: false, pushNotifications: false },
|
|
20
|
+
defaultInputModes: ['text/plain'],
|
|
21
|
+
defaultOutputModes: ['text/plain'],
|
|
22
|
+
skills: [{ id: s.name, name: s.title, description: s.goal, tags: [s.engine] }],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const taskOf = (t) => ({
|
|
27
|
+
kind: 'task',
|
|
28
|
+
id: t.id,
|
|
29
|
+
contextId: t.contextId,
|
|
30
|
+
status: { state: t.state, timestamp: t.at, ...(t.result != null ? { message: { kind: 'message', role: 'agent', messageId: `${t.id}-r`, parts: [{ kind: 'text', text: t.result }] } } : {}) },
|
|
31
|
+
...(t.result != null && t.state === 'completed' ? { artifacts: [{ artifactId: `${t.id}-a`, parts: [{ kind: 'text', text: t.result }] }] } : {}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const PAGE = (agent) => `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
35
|
+
<title>${esc(agent.spec.title)}</title><style>
|
|
36
|
+
:root{--bg:#f6f7f9;--panel:#fff;--text:#1c2330;--muted:#667085;--border:#dde1e7;--accent:#3b5bdb}
|
|
37
|
+
@media (prefers-color-scheme:dark){:root{--bg:#0d0f14;--panel:#151821;--text:#e5e8ef;--muted:#8b93a7;--border:#2a2f3c;--accent:#6d8bff}}
|
|
38
|
+
body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:var(--bg);color:var(--text);display:flex;flex-direction:column;height:100vh}
|
|
39
|
+
header{padding:12px 16px;border-bottom:1px solid var(--border);background:var(--panel)}header b{font-size:16px}header p{margin:2px 0 0;color:var(--muted);font-size:13px}
|
|
40
|
+
#log{flex:1;overflow:auto;padding:16px;display:flex;flex-direction:column;gap:10px}
|
|
41
|
+
.m{max-width:760px;padding:10px 12px;border-radius:10px;white-space:pre-wrap;word-break:break-word}.you{align-self:flex-end;background:var(--accent);color:#fff}.agent{background:var(--panel);border:1px solid var(--border)}
|
|
42
|
+
#ap{display:flex;flex-direction:column;gap:8px;padding:0 16px}#ap:not(:empty){padding:12px 16px;border-bottom:1px solid var(--border)}.ap{padding:10px 12px;border-radius:10px;border:1px solid #e8a33d;background:var(--panel)}.ap pre{margin:6px 0;font-size:12px;white-space:pre-wrap;max-height:160px;overflow:auto}.ap .no{background:transparent;color:var(--text);border:1px solid var(--border)}
|
|
43
|
+
.step{font-size:12px;color:var(--muted)}form{display:flex;gap:8px;padding:12px 16px;border-top:1px solid var(--border);background:var(--panel)}
|
|
44
|
+
textarea{flex:1;resize:none;font:inherit;padding:8px;border-radius:8px;border:1px solid var(--border);background:var(--bg);color:inherit}button{font:inherit;padding:8px 14px;border-radius:8px;border:0;background:var(--accent);color:#fff}
|
|
45
|
+
</style></head><body><header><b>${esc(agent.spec.title)}</b><p>${esc(agent.spec.description || agent.spec.goal)}</p></header>
|
|
46
|
+
<div id="ap"></div><div id="log"></div><form id="f"><textarea id="t" rows="2" placeholder="Ask or tell the agent…"></textarea><button>Send</button></form>
|
|
47
|
+
<script>
|
|
48
|
+
const log=document.getElementById('log');const conv=localStorage.getItem('conv')||crypto.randomUUID();localStorage.setItem('conv',conv);
|
|
49
|
+
const add=(cls,text)=>{const d=document.createElement('div');d.className='m '+cls;d.textContent=text;log.append(d);log.scrollTop=log.scrollHeight;return d};
|
|
50
|
+
document.getElementById('f').onsubmit=async(e)=>{e.preventDefault();const t=document.getElementById('t');const text=t.value.trim();if(!text)return;t.value='';add('you',text);
|
|
51
|
+
const wait=add('agent step','Working on it…');const r=await fetch('api/chat',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({message:text,conversation:conv})}).then(r=>r.json()).catch(e=>({error:e.message}));
|
|
52
|
+
wait.remove();add('agent',r.result??('Error: '+r.error))};
|
|
53
|
+
const ap=document.getElementById('ap');const esc=(s)=>String(s).replace(/[&<>"]/g,(c)=>({'&':'&','<':'<','>':'>','"':'"'})[c]);
|
|
54
|
+
async function approvals(){const l=await fetch('api/approvals').then(r=>r.json()).catch(()=>[]);ap.innerHTML=l.map(a=>'<div class="ap"><b>Approve '+esc(a.tool)+'?</b> <span class="step">asked '+new Date(a.at).toLocaleTimeString()+'</span><pre>'+esc(JSON.stringify(a.input,null,2))+'</pre><button data-id="'+esc(a.id)+'" data-allow="1">Approve</button> <button class="no" data-id="'+esc(a.id)+'">Deny</button></div>').join('')}
|
|
55
|
+
ap.onclick=async(e)=>{const b=e.target.closest('button[data-id]');if(!b)return;b.disabled=true;await fetch('api/approvals/'+b.dataset.id,{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({allow:!!b.dataset.allow})});approvals()};
|
|
56
|
+
approvals();setInterval(approvals,2000);
|
|
57
|
+
document.getElementById('t').onkeydown=(e)=>{if(e.key==='Enter'&&!e.shiftKey){e.preventDefault();document.getElementById('f').requestSubmit()}};
|
|
58
|
+
</script></body></html>`;
|
|
59
|
+
|
|
60
|
+
/** Start the HTTP side; resolves with { server, url, close }. */
|
|
61
|
+
export function startHttp(agent, { port, host = '127.0.0.1', log = () => {} } = {}) {
|
|
62
|
+
const app = express();
|
|
63
|
+
app.use(express.json({ limit: '2mb' }));
|
|
64
|
+
const tasks = new Map(); // A2A task id -> { id, contextId, state, result, at }
|
|
65
|
+
const wrap = (fn) => (req, res) => Promise.resolve(fn(req, res)).then((v) => v !== undefined && res.json(v)).catch((err) => res.status(err.status ?? 500).json({ error: err.message }));
|
|
66
|
+
|
|
67
|
+
app.get('/', (req, res) => res.type('html').send(PAGE(agent)));
|
|
68
|
+
app.get('/api/health', (req, res) => res.json({ ok: true, agent: agent.spec.name, engine: agent.spec.engine, running: agent.current, triggers: agent.spec.triggers.map((t) => t.label) }));
|
|
69
|
+
app.post('/api/chat', wrap(async (req) => {
|
|
70
|
+
const message = String(req.body?.message ?? '').trim();
|
|
71
|
+
if (!message) throw Object.assign(new Error('message is required'), { status: 400 });
|
|
72
|
+
return agent.run(message, { source: 'chat', conversation: String(req.body?.conversation ?? '').slice(0, 80) || crypto.randomUUID() });
|
|
73
|
+
}));
|
|
74
|
+
// Tools waiting for a person's approval, and their answers (JSON only: a page elsewhere can't post here).
|
|
75
|
+
app.get('/api/approvals', wrap(() => agent.approvals.pending()));
|
|
76
|
+
app.post('/api/approvals/:id', wrap((req) => {
|
|
77
|
+
if (!req.is('application/json') || typeof req.body?.allow !== 'boolean') throw Object.assign(new Error('Send {"allow": true|false}'), { status: 400 });
|
|
78
|
+
if (!agent.approvals.decide(req.params.id, req.body.allow, { by: 'the chat page', note: String(req.body.note ?? '') })) throw Object.assign(new Error('Already decided'), { status: 409 });
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}));
|
|
81
|
+
agent.approvals.channels.add('chat');
|
|
82
|
+
app.post('/api/runs', wrap((req) => agent.run(String(req.body?.input ?? ''), { source: 'api' })));
|
|
83
|
+
app.get('/api/runs', wrap(() => agent.runs()));
|
|
84
|
+
app.get('/api/runs/:id', wrap((req) => agent.runLog(req.params.id) ?? Promise.reject(Object.assign(new Error('No such run'), { status: 404 }))));
|
|
85
|
+
|
|
86
|
+
// Webhook triggers: the body (JSON or text) is the run's input.
|
|
87
|
+
for (const t of agent.spec.triggers.filter((x) => x.kind === 'webhook')) {
|
|
88
|
+
app.post(t.path, express.text({ type: () => true, limit: '2mb' }), wrap(async (req) => {
|
|
89
|
+
const run = agent.run(typeof req.body === 'string' ? req.body : JSON.stringify(req.body ?? {}), { source: t.label });
|
|
90
|
+
run.catch(() => {});
|
|
91
|
+
return { accepted: true };
|
|
92
|
+
}));
|
|
93
|
+
log(`trigger: ${t.label}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A2A.
|
|
97
|
+
if (agent.spec.interfaces.a2a) {
|
|
98
|
+
app.get('/.well-known/agent.json', (req, res) => res.json(agentCard(agent, `http://${req.headers.host}`)));
|
|
99
|
+
app.get('/.well-known/agent-card.json', (req, res) => res.json(agentCard(agent, `http://${req.headers.host}`)));
|
|
100
|
+
app.post('/a2a', async (req, res) => {
|
|
101
|
+
const { id, method, params = {} } = req.body ?? {};
|
|
102
|
+
const reply = (result) => res.json({ jsonrpc: '2.0', id, result });
|
|
103
|
+
const fail = (code, message) => res.json({ jsonrpc: '2.0', id, error: { code, message } });
|
|
104
|
+
if (method === 'message/send') {
|
|
105
|
+
const msg = params.message ?? {};
|
|
106
|
+
const text = (msg.parts ?? []).filter((p) => p.kind === 'text' || p.type === 'text').map((p) => p.text).join('\n').trim();
|
|
107
|
+
if (!text) return fail(-32602, 'message.parts needs a text part');
|
|
108
|
+
const t = { id: crypto.randomUUID(), contextId: msg.contextId ?? crypto.randomUUID(), state: 'working', result: null, at: new Date().toISOString() };
|
|
109
|
+
tasks.set(t.id, t);
|
|
110
|
+
const r = await agent.run(text, { source: 'a2a', conversation: `a2a:${t.contextId}` });
|
|
111
|
+
Object.assign(t, { state: r.status === 'failed' ? 'failed' : 'completed', result: r.result, at: new Date().toISOString() });
|
|
112
|
+
return reply(taskOf(t));
|
|
113
|
+
}
|
|
114
|
+
if (method === 'tasks/get') {
|
|
115
|
+
const t = tasks.get(params.id);
|
|
116
|
+
return t ? reply(taskOf(t)) : fail(-32001, 'Task not found');
|
|
117
|
+
}
|
|
118
|
+
return fail(-32601, `Method not found: ${method}`);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const server = http.createServer(app);
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
server.once('error', reject);
|
|
125
|
+
server.listen(port, host, () => {
|
|
126
|
+
const url = `http://${host}:${server.address().port}`;
|
|
127
|
+
log(`chat and API: ${url}${agent.spec.interfaces.a2a ? ` · A2A card: ${url}/.well-known/agent.json` : ''}`);
|
|
128
|
+
resolve({ server, url, close: () => (agent.approvals.channels.delete('chat'), new Promise((r) => server.close(r))) });
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Package an agent as one executable (Node's single executable applications): the runtime bundled
|
|
2
|
+
// with esbuild, the agent's files embedded as assets, injected with postject into the official Node
|
|
3
|
+
// binary (from nodejs.org, same version as this one, checksum-verified, cached) for each platform.
|
|
4
|
+
// The prepared blob depends only on the Node version, so every platform builds from here; macOS
|
|
5
|
+
// ones must be signed, so they build on a Mac. The engine CLIs it drives are installed separately.
|
|
6
|
+
import { execFile } from 'node:child_process';
|
|
7
|
+
import crypto from 'node:crypto';
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import os from 'node:os';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { createRequire } from 'node:module';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { loadAgent } from './spec.js';
|
|
14
|
+
|
|
15
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
16
|
+
// Assembled at run time: the literal must not appear in the bundle we inject (this file is part of
|
|
17
|
+
// it), or postject finds the sentinel twice.
|
|
18
|
+
const FUSE = ['NODE', 'SEA', 'FUSE', 'fce680ab2cc467b6e072b8b5df1996b2'].join('_');
|
|
19
|
+
export const HOST = `${process.platform === 'darwin' ? 'macos' : process.platform}-${process.arch}`;
|
|
20
|
+
export const PLATFORMS = ['macos-arm64', 'macos-x64', 'linux-x64', 'linux-arm64'];
|
|
21
|
+
const sh = (cmd, args, opts = {}) =>
|
|
22
|
+
new Promise((resolve, reject) =>
|
|
23
|
+
execFile(cmd, args, { timeout: 20 * 60_000, maxBuffer: 64 * 1024 * 1024, ...opts }, (e, out, err) => (e ? reject(new Error(`${path.basename(cmd)} failed: ${`${err}\n${out}`.replace(/\x1b\[[0-9;]*m/g, '').trim().split('\n').slice(-4).join(' ') || e.message}`)) : resolve(String(out)))),
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
/** The agent's own files to embed: agent.yaml, its instructions file, tests, and anything it names. */
|
|
27
|
+
function agentFiles(dir) {
|
|
28
|
+
const keep = ['agent.yaml', 'agent.yml', 'agent.json', 'instructions.md', 'tests.yaml', 'README.md'];
|
|
29
|
+
return keep.filter((f) => fs.existsSync(path.join(dir, f)));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One file: the runtime and its libraries (express, yaml, commander) as CommonJS. */
|
|
33
|
+
async function bundle(outFile) {
|
|
34
|
+
const esbuild = await import('esbuild');
|
|
35
|
+
await esbuild.build({
|
|
36
|
+
entryPoints: [path.join(ROOT, 'src', 'agentkit', 'sea-main.js')],
|
|
37
|
+
outfile: outFile,
|
|
38
|
+
bundle: true,
|
|
39
|
+
platform: 'node',
|
|
40
|
+
format: 'cjs',
|
|
41
|
+
target: 'node22',
|
|
42
|
+
// import.meta.url doesn't exist in CommonJS; give the modules that use it this file's URL.
|
|
43
|
+
define: { 'import.meta.url': '__agentkitUrl' },
|
|
44
|
+
banner: { js: "const __agentkitUrl = require('node:url').pathToFileURL(__filename).href;" },
|
|
45
|
+
logLevel: 'error',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The official Node binary for a platform (this Node's version), downloaded once and checked. */
|
|
50
|
+
export async function nodeBinary(platform, { cacheDir, log = () => {} } = {}) {
|
|
51
|
+
const v = process.version;
|
|
52
|
+
const [osName, arch] = platform.split('-');
|
|
53
|
+
const dist = `node-${v}-${osName === 'macos' ? 'darwin' : 'linux'}-${arch}`;
|
|
54
|
+
const dir = path.join(cacheDir, dist);
|
|
55
|
+
const bin = path.join(dir, 'node');
|
|
56
|
+
if (fs.existsSync(bin)) return bin;
|
|
57
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
58
|
+
const base = `https://nodejs.org/dist/${v}`;
|
|
59
|
+
log(`downloading Node ${v} for ${platform}`);
|
|
60
|
+
const sums = await (await fetch(`${base}/SHASUMS256.txt`)).text();
|
|
61
|
+
const want = new RegExp(`^([0-9a-f]{64})\\s+${dist}\\.tar\\.gz$`, 'm').exec(sums)?.[1];
|
|
62
|
+
if (!want) throw new Error(`nodejs.org has no ${dist}.tar.gz`);
|
|
63
|
+
const res = await fetch(`${base}/${dist}.tar.gz`);
|
|
64
|
+
if (!res.ok) throw new Error(`Download of ${dist} failed (HTTP ${res.status})`);
|
|
65
|
+
const tgz = Buffer.from(await res.arrayBuffer());
|
|
66
|
+
const got = crypto.createHash('sha256').update(tgz).digest('hex');
|
|
67
|
+
if (got !== want) throw new Error(`${dist}.tar.gz failed its checksum; not using it`);
|
|
68
|
+
const tmp = path.join(dir, 'node.tar.gz');
|
|
69
|
+
fs.writeFileSync(tmp, tgz);
|
|
70
|
+
await sh('tar', ['-xzf', tmp, '-C', dir, '--strip-components', '2', `${dist}/bin/node`]);
|
|
71
|
+
fs.rmSync(tmp);
|
|
72
|
+
return bin;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Inject the blob into a copy of `nodeBin` (signature removed and re-applied for macOS). */
|
|
76
|
+
async function inject(nodeBin, blob, out, { mac }) {
|
|
77
|
+
fs.copyFileSync(nodeBin, out);
|
|
78
|
+
fs.chmodSync(out, 0o755);
|
|
79
|
+
if (mac) await sh('codesign', ['--remove-signature', out]);
|
|
80
|
+
await sh(process.execPath, [createRequire(import.meta.url).resolve('postject/dist/cli.js'), out, 'NODE_SEA_BLOB', blob, '--sentinel-fuse', FUSE, ...(mac ? ['--macho-segment-name', 'NODE_SEA'] : [])]);
|
|
81
|
+
if (mac) await sh('codesign', ['--sign', '-', out]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build `<outDir>/<name>-<platform>` for an agent folder (any platform; macOS ones on a Mac).
|
|
86
|
+
* Returns { file, platform, size }.
|
|
87
|
+
*/
|
|
88
|
+
export async function packageAgent(agentDir, { outDir, platform = HOST, cacheDir = path.join(os.homedir(), '.agentmanager', 'node-dist'), log = () => {} } = {}) {
|
|
89
|
+
const spec = loadAgent(agentDir);
|
|
90
|
+
if (spec.engine.startsWith('acp:')) throw Object.assign(new Error('ACP engines are not supported in a packaged agent yet; run its folder with agentkit'), { status: 400 });
|
|
91
|
+
if (!PLATFORMS.includes(platform)) throw Object.assign(new Error(`platform: one of ${PLATFORMS.join(', ')}`), { status: 400 });
|
|
92
|
+
if (platform.startsWith('macos-') && process.platform !== 'darwin') throw Object.assign(new Error('macOS executables are built on a Mac (they must be signed there)'), { status: 400 });
|
|
93
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
94
|
+
const work = fs.mkdtempSync(path.join(os.tmpdir(), `agentkit-pack-${spec.name}-`));
|
|
95
|
+
try {
|
|
96
|
+
log('bundling the runtime');
|
|
97
|
+
await bundle(path.join(work, 'main.cjs'));
|
|
98
|
+
const files = agentFiles(spec.dir);
|
|
99
|
+
const assets = { 'manifest.json': path.join(work, 'manifest.json') };
|
|
100
|
+
for (const f of files) assets[`files/${f}`] = path.join(spec.dir, f);
|
|
101
|
+
const build = crypto.createHash('sha256').update(files.map((f) => fs.readFileSync(path.join(spec.dir, f))).join('\0')).digest('hex').slice(0, 16);
|
|
102
|
+
fs.writeFileSync(assets['manifest.json'], JSON.stringify({ name: spec.name, title: spec.title, build, files }));
|
|
103
|
+
const out = path.join(outDir, `${spec.name}-${platform}`);
|
|
104
|
+
fs.writeFileSync(path.join(work, 'sea.json'), JSON.stringify({ main: path.join(work, 'main.cjs'), output: path.join(work, 'blob'), disableExperimentalSEAWarning: true, useCodeCache: false, assets }));
|
|
105
|
+
log('preparing the blob');
|
|
106
|
+
await sh(process.execPath, ['--experimental-sea-config', path.join(work, 'sea.json')]);
|
|
107
|
+
const nodeBin = await nodeBinary(platform, { cacheDir, log });
|
|
108
|
+
log(`making the executable for ${platform}`);
|
|
109
|
+
await inject(nodeBin, path.join(work, 'blob'), out, { mac: platform.startsWith('macos-') });
|
|
110
|
+
return { file: out, platform, size: fs.statSync(out).size };
|
|
111
|
+
} finally {
|
|
112
|
+
fs.rmSync(work, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|