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/src/builder.js ADDED
@@ -0,0 +1,436 @@
1
+ // The agent builder: a task or workflow in words → an agentkit agent (docs/agentkit.md), developed
2
+ // in an agent session that works in the agent's folder: it writes the analysis, spec, instructions
3
+ // and tests, and makes the changes the person asks for. The person tests, runs, packages and
4
+ // downloads it here. Built agents live in ~/.agentmanager/built/<name>/ as ordinary agentkit folders.
5
+ import { spawn } from 'node:child_process';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { fileURLToPath } from 'node:url';
9
+ import YAML from 'yaml';
10
+ import { Agent } from './agentkit/agent.js';
11
+ import { turn as engineTurn } from './agentkit/engines.js';
12
+ import { HOST, PLATFORMS, packageAgent } from './agentkit/package.js';
13
+ import { checkAgent, loadAgent } from './agentkit/spec.js';
14
+ import { parseJsonReply } from './brief.js';
15
+ import { paths } from './config.js';
16
+
17
+ const AGENTKIT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentkit.js');
18
+ const root = () => path.join(paths.home, 'built');
19
+ const err = (status, message) => Object.assign(new Error(message), { status });
20
+ const now = () => new Date().toISOString();
21
+ const slug = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'agent';
22
+
23
+ const DOCS = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'docs', 'agentkit.md');
24
+ const ENGINES = ['claude', 'codex', 'muse'];
25
+
26
+ /** The agent's name from the task: its first few words. */
27
+ export function nameFrom(task) {
28
+ const words = String(task).toLowerCase().replace(/https?:\/\/\S+/g, ' ').split(/[^a-z0-9]+/).filter((w) => w && !STOP.has(w));
29
+ const n = slug(words.slice(0, 4).join('-')).slice(0, 32).replace(/-+$/, '');
30
+ return /^[a-z][a-z0-9-]{1,}$/.test(n) ? n : `agent-${n}`.replace(/-+$/, '').slice(0, 32);
31
+ }
32
+ const STOP = new Set(['a', 'an', 'the', 'to', 'of', 'for', 'and', 'or', 'in', 'on', 'by', 'with', 'from', 'that', 'this', 'it', 'is', 'are', 'be', 'i', 'we', 'my', 'our', 'want', 'need', 'agent', 'build', 'make', 'create', 'please', 'which', 'who']);
33
+
34
+ /** The commands its session uses to check and try the agent. */
35
+ export function agentkitCommands(dir) {
36
+ return { check: `${process.execPath} ${AGENTKIT} check`, run: `${process.execPath} ${AGENTKIT} run ${dir} --once` };
37
+ }
38
+
39
+ /** What its session may do without a permission prompt: check the agent, read the reference. */
40
+ export function builderAllowedTools(dir) {
41
+ return [`Bash(${agentkitCommands(dir).check}:*)`, `Read(/${DOCS})`];
42
+ }
43
+
44
+ /**
45
+ * What a session that builds an agent is told (appended to its system prompt on every launch):
46
+ * the folder is the agent, the files it keeps, the format, and how to check and try it.
47
+ */
48
+ export function builderPrompt({ dir, name, engine = 'claude' }) {
49
+ const cmd = agentkitCommands(dir);
50
+ return [
51
+ `You are building an autonomous agent together with the person: an agentkit agent that runs on its own, continuously, and does one job well. This folder (${dir}) is the agent. Its name is "${name}"; keep it (it is the folder's name).`,
52
+ '',
53
+ 'Keep these files current:',
54
+ '- agent.yaml: the spec (below).',
55
+ '- instructions.md: how to do the job, step by step; what it must never do; when it asks a person.',
56
+ '- tests.yaml: 3 to 8 test cases, a YAML list of {input, expect}: the normal case, an edge case, and a case it should refuse or escalate. The person runs them from the Built tab; each result is judged against `expect`.',
57
+ '- analysis.json: {"analysis": {"problem": "<the job and why>", "inputs": [...], "outputs": [...], "risks": ["<what could go wrong, what needs a person>"]}, "tools": [{"name": "<MCP server id>", "why": "<what for>"}]}. The person reads it in the Built tab; update it when the design changes.',
58
+ '',
59
+ `agent.yaml has: name, title, description (one line), goal (what is true when it has done its job, for each input), instructions: instructions.md, engine: ${engine},`,
60
+ ' tools (MCP servers by id: {command, args, env}; only servers that really exist; secrets only as "${VAR}"),',
61
+ ' allow (engine tools it may use without asking, e.g. Read, Grep, "mcp__<id>__*"; to let it write files only under a folder: "Edit(./out/**)", which covers Write), approve (tools that change things in the outside world and need a person\'s yes),',
62
+ ' triggers (only when the task implies running on its own: {every: 30m}, {cron: "0 9 * * 1-5"}, {webhook: /hooks/<name>}, {watch: ./inbox}; a chat-only agent has none),',
63
+ ' loop: {plan: true, check: true, maxSteps: 4}, memory: true, interfaces: {http: {port: 0}, a2a: true, acp: true, mcp: true}.',
64
+ `The full reference: ${DOCS}.`,
65
+ '',
66
+ 'Write and edit these files with your file tools (Write, Edit), not with shell commands.',
67
+ `After every change, check it loads: ${cmd.check} ${dir}`,
68
+ `To try one input for real (it runs with its allowed tools; tools that need approval are refused): ${cmd.run} "<input>". Only with inputs that are safe to run for real.`,
69
+ 'Be concrete; do not invent systems the task does not mention. When something only the person knows is missing (an account, a team name, a label set), ask.',
70
+ 'The person tests, starts, packages and downloads it from the Built tab; you do not start it continuously or package it. When they ask for a change, make it in these files, check, and say in a sentence what changed.',
71
+ ].join('\n');
72
+ }
73
+
74
+ /** The first message of a build session. */
75
+ export function buildIntent({ task, name, engine }) {
76
+ return `Build the agent "${name}" (engine: ${engine}) for this task:\n\n${task}\n\nWrite analysis.json, agent.yaml, instructions.md and tests.yaml in this folder, check it, then tell me what it does and what I should decide or provide.`;
77
+ }
78
+
79
+ export function judgePrompt({ goal, input, expect, result, files = [] }) {
80
+ return [
81
+ "You check one test of an autonomous agent. Decide if the agent's result meets the expectation for the input.",
82
+ 'The files it created or changed during the run are shown too: judge what it did from them, not only from what it says.',
83
+ 'Be strict about substance, lenient about wording. Reply with JSON only: {"pass": true|false, "why": "<one sentence>"}.',
84
+ `<goal>\n${goal}\n</goal>`,
85
+ `<input>\n${input}\n</input>`,
86
+ `<expectation>\n${expect}\n</expectation>`,
87
+ `<result>\n${result}\n</result>`,
88
+ ...(files.length ? ['<files_written>', ...files.map((f) => `--- ${f.path}${f.truncated ? ' (first part)' : ''}\n${f.text}`), '</files_written>'] : ['<files_written>\n(none)\n</files_written>']),
89
+ ].join('\n');
90
+ }
91
+
92
+ const SKIP = new Set(['.agentkit', '.git', 'node_modules', 'dist']);
93
+ /** Files under `dir` (not its own state, git or build output) with when each last changed. */
94
+ export function snapshot(dir, { max = 5000 } = {}) {
95
+ const seen = new Map();
96
+ const walk = (d) => {
97
+ let entries = [];
98
+ try {
99
+ entries = fs.readdirSync(d, { withFileTypes: true });
100
+ } catch {
101
+ return;
102
+ }
103
+ for (const e of entries) {
104
+ if (seen.size >= max) return;
105
+ const p = path.join(d, e.name);
106
+ if (e.isDirectory()) {
107
+ if (!SKIP.has(e.name)) walk(p);
108
+ } else if (e.isFile()) {
109
+ const st = fs.statSync(p, { throwIfNoEntry: false });
110
+ if (st) seen.set(p, `${st.mtimeMs}:${st.size}`);
111
+ }
112
+ }
113
+ };
114
+ walk(dir);
115
+ return seen;
116
+ }
117
+
118
+ /** The text files created or changed since `before` (a snapshot), for the judge; a few, each capped. */
119
+ export function filesWritten(dir, before, { root = dir, limit = 6, bytes = 4000 } = {}) {
120
+ const out = [];
121
+ for (const [p, sig] of snapshot(dir)) {
122
+ if (before.get(p) === sig || out.length >= limit) continue;
123
+ const buf = fs.readFileSync(p);
124
+ if (buf.subarray(0, 2000).includes(0)) continue; // binary
125
+ out.push({ path: path.relative(root, p), text: buf.subarray(0, bytes).toString('utf8'), truncated: buf.length > bytes });
126
+ }
127
+ return out;
128
+ }
129
+
130
+ export class Builder {
131
+ /** `oneShot(prompt)` answers a judge prompt (the daemon's writer engine). */
132
+ /** `sessions`: the daemon's agent sessions ({ create, get, tell, stop }); each built agent is developed in one. */
133
+ /** `reportEnv()`: settings that make an agent started here report to this daemon. */
134
+ constructor({ oneShot, sessions, env = process.env, runTurn = engineTurn, pack = packageAgent, reportEnv = () => ({}) }) {
135
+ this.reportEnv = reportEnv;
136
+ this.oneShot = oneShot;
137
+ this.sessions = sessions;
138
+ this.env = env;
139
+ this.runTurn = runTurn;
140
+ this.pack = pack;
141
+ this.procs = new Map(); // name -> child process of `agentkit run`
142
+ this.pending = new Map(); // name -> what's in progress (testing, packaging)
143
+ }
144
+
145
+ dir(name) {
146
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(String(name))) throw err(400, 'Unknown agent');
147
+ return path.join(root(), name);
148
+ }
149
+
150
+ meta(name) {
151
+ try {
152
+ return JSON.parse(fs.readFileSync(path.join(this.dir(name), 'build.json'), 'utf8'));
153
+ } catch {
154
+ return null;
155
+ }
156
+ }
157
+
158
+ saveMeta(name, patch) {
159
+ const next = { ...this.meta(name), ...patch, updatedAt: now() };
160
+ fs.writeFileSync(path.join(this.dir(name), 'build.json'), JSON.stringify(next, null, 2));
161
+ return next;
162
+ }
163
+
164
+ list() {
165
+ let names = [];
166
+ try {
167
+ names = fs.readdirSync(root()).filter((n) => fs.existsSync(path.join(root(), n, 'build.json')));
168
+ } catch {}
169
+ return names.map((n) => this.view(n)).sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)));
170
+ }
171
+
172
+ view(name) {
173
+ const m = this.meta(name);
174
+ if (!m) throw err(404, `No built agent "${name}"`);
175
+ const dir = this.dir(name);
176
+ const read = (f) => (fs.existsSync(path.join(dir, f)) ? fs.readFileSync(path.join(dir, f), 'utf8') : '');
177
+ let spec = null;
178
+ let specError = null;
179
+ const yaml = read('agent.yaml');
180
+ if (yaml) {
181
+ try {
182
+ spec = loadAgent(dir, { env: this.env });
183
+ } catch (e) {
184
+ specError = e.message;
185
+ }
186
+ }
187
+ const p = this.procs.get(name);
188
+ let runs = [];
189
+ if (spec) runs = new Agent(spec, { env: this.env }).runs(20);
190
+ // Its session keeps analysis.json and tests.yaml current; read them as they are now.
191
+ let design = {};
192
+ try {
193
+ design = JSON.parse(read('analysis.json') || '{}');
194
+ } catch {}
195
+ let tests = m.tests ?? [];
196
+ try {
197
+ tests = (YAML.parse(read('tests.yaml') || '[]') ?? []).filter((t) => t?.input).map((t) => ({ input: String(t.input), expect: String(t.expect ?? '') }));
198
+ } catch {}
199
+ // Until its spec loads, the status follows the files: designing, then designed or needs fixing.
200
+ let status = m.status;
201
+ if (!yaml) status = 'designing';
202
+ else if (specError) status = 'needs-fixing';
203
+ else if (['designing', 'needs-fixing'].includes(status)) status = 'designed';
204
+ const s = m.session ? this.sessions?.get(m.session) : null;
205
+ // When its files last changed (its session edits them): results and executables from before are out of date.
206
+ const changedAt = new Date(Math.max(0, ...['agent.yaml', 'instructions.md', 'tests.yaml'].map((f) => fs.statSync(path.join(dir, f), { throwIfNoEntry: false })?.mtimeMs ?? 0))).toISOString();
207
+ return {
208
+ ...m,
209
+ name,
210
+ status,
211
+ title: spec?.title ?? name,
212
+ description: spec?.description ?? '',
213
+ dir,
214
+ yaml,
215
+ instructionsText: read('instructions.md'),
216
+ analysis: design.analysis ?? m.analysis ?? null,
217
+ tools: design.tools ?? m.tools ?? [],
218
+ tests,
219
+ specError,
220
+ engine: spec?.engine ?? m.engine ?? null,
221
+ triggers: spec?.triggers.map((t) => t.label) ?? [],
222
+ missingEnv: spec?.missingEnv ?? [],
223
+ pending: this.pending.get(name) ?? null,
224
+ running: p ? { pid: p.child.pid, url: p.url, since: p.since } : null,
225
+ session: s ? { id: s.id, name: s.name, status: s.status, running: s.runtime.running, busy: s.runtime.busy, attention: s.runtime.attention ?? s.runtime.waiting?.text ?? null } : null,
226
+ runs,
227
+ platforms: PLATFORMS.filter((x) => process.platform === 'darwin' || !x.startsWith('macos-')),
228
+ host: HOST,
229
+ changedAt,
230
+ // Executables built since the last edit that are still there.
231
+ executables: Object.fromEntries(Object.entries(m.executables ?? {}).filter(([, x]) => fs.existsSync(path.join(dir, 'dist', x.file)))),
232
+ };
233
+ }
234
+
235
+ /**
236
+ * Start building an agent for a task: its folder, and an agent session working in it that
237
+ * analyses the task and writes the agent (analysis, spec, instructions, tests). The person
238
+ * keeps developing it in that session.
239
+ */
240
+ design({ task, engine = 'claude', name, attachments, permissionMode, model }) {
241
+ task = String(task ?? '').trim();
242
+ if (!task) throw err(400, 'Describe the task or workflow');
243
+ if (!ENGINES.includes(engine) && !/^acp:[a-z0-9-]+$/.test(engine)) throw err(400, 'engine: claude, codex, muse or acp:<name>');
244
+ if (!this.sessions) throw err(503, 'Agent sessions are not available');
245
+ const want = name ? slug(name) : nameFrom(task);
246
+ if (!/^[a-z][a-z0-9-]{1,40}$/.test(want)) throw err(400, 'name: lowercase letters, digits and dashes, starting with a letter');
247
+ let n = want;
248
+ for (let i = 2; fs.existsSync(path.join(root(), n)); i++) n = `${want.slice(0, 36)}-${i}`;
249
+ const dir = path.join(root(), n);
250
+ fs.mkdirSync(dir, { recursive: true });
251
+ this.saveMeta(n, { task, engine, results: null, status: 'designing', createdAt: now() });
252
+ try {
253
+ const s = this.sessions.create({ name: n, dir, intent: buildIntent({ task, name: n, engine }), builds: { name: n, engine }, attachments, permissionMode, model });
254
+ this.saveMeta(n, { session: s.id });
255
+ } catch (e) {
256
+ fs.rmSync(dir, { recursive: true, force: true });
257
+ throw e;
258
+ }
259
+ return this.view(n);
260
+ }
261
+
262
+ /**
263
+ * Ask its session for a change (typed in when it's idle; started if it's closed). An agent with
264
+ * no session yet (built before sessions, or its session deleted) gets a new one.
265
+ */
266
+ change(name, text) {
267
+ const dir = this.dir(name);
268
+ const m = this.meta(name);
269
+ if (!m) throw err(404, `No built agent "${name}"`);
270
+ text = String(text ?? '').trim();
271
+ const s = m.session ? this.sessions.get(m.session) : null;
272
+ if (s) {
273
+ if (!text) throw err(400, 'Say what to change');
274
+ this.sessions.tell(s.id, text);
275
+ } else {
276
+ const engine = this.view(name).engine ?? 'claude';
277
+ const intent = `This folder is the agent "${name}"${m.task ? `, built for this task:\n\n${m.task}\n\n` : '. '}Read agent.yaml, instructions.md and tests.yaml${fs.existsSync(path.join(dir, 'analysis.json')) ? ' and analysis.json' : ' and write analysis.json'}. ${text || 'Then tell me briefly what it does and wait for what to change.'}`;
278
+ const created = this.sessions.create({ name, dir, intent, builds: { name, engine } });
279
+ this.saveMeta(name, { session: created.id });
280
+ }
281
+ return this.view(name);
282
+ }
283
+
284
+ /** Save edits to agent.yaml, the instructions or the tests (each checked before it's written). */
285
+ update(name, { yaml, instructions, tests } = {}) {
286
+ const dir = this.dir(name);
287
+ if (!this.meta(name)) throw err(404, `No built agent "${name}"`);
288
+ if (yaml != null) {
289
+ let raw;
290
+ try {
291
+ raw = YAML.parse(String(yaml));
292
+ } catch (e) {
293
+ throw err(400, `agent.yaml: ${e.message.split('\n')[0]}`);
294
+ }
295
+ if (raw?.name !== name) throw err(400, `agent.yaml: keep the name "${name}" (it is the agent's folder)`);
296
+ fs.writeFileSync(path.join(dir, 'instructions.md'), instructions != null ? String(instructions) : fs.readFileSync(path.join(dir, 'instructions.md'), 'utf8'));
297
+ try {
298
+ checkAgent(raw, { dir, env: this.env });
299
+ } catch (e) {
300
+ throw err(400, e.message);
301
+ }
302
+ fs.writeFileSync(path.join(dir, 'agent.yaml'), String(yaml));
303
+ } else if (instructions != null) fs.writeFileSync(path.join(dir, 'instructions.md'), String(instructions));
304
+ if (tests != null) {
305
+ const list = (Array.isArray(tests) ? tests : []).filter((t) => t?.input).slice(0, 8).map((t) => ({ input: String(t.input), expect: String(t.expect ?? '') }));
306
+ fs.writeFileSync(path.join(dir, 'tests.yaml'), YAML.stringify(list));
307
+ this.saveMeta(name, { tests: list });
308
+ }
309
+ // Executables built before carry the old files; build them again after an edit.
310
+ if (yaml != null || instructions != null || tests != null) {
311
+ fs.rmSync(path.join(dir, 'dist'), { recursive: true, force: true });
312
+ this.saveMeta(name, { executables: {} });
313
+ }
314
+ return this.view(name);
315
+ }
316
+
317
+ /** Run every test through the real runtime, and judge each result against its expectation. */
318
+ async test(name) {
319
+ const dir = this.dir(name);
320
+ if (this.pending.get(name)) throw err(409, `Already ${this.pending.get(name)}`);
321
+ const spec = loadAgent(dir, { env: this.env });
322
+ const tests = YAML.parse(fs.readFileSync(path.join(dir, 'tests.yaml'), 'utf8') || '[]') ?? [];
323
+ if (!tests.length) throw err(400, 'No test cases; add some first');
324
+ this.pending.set(name, 'testing');
325
+ this.saveMeta(name, { status: 'testing' });
326
+ try {
327
+ // Tests don't wait for people: approval tools are simply not available in them.
328
+ const agent = new Agent({ ...spec, triggers: [], report: null }, { env: this.env, engineTurn: this.runTurn, approvals: 'deny' });
329
+ const results = [];
330
+ // The judge sees what each run wrote in the agent's folders, not only what it says it did.
331
+ const roots = [...new Set([dir, spec.workdir])].filter((d, i, all) => !all.some((o, j) => j !== i && d.startsWith(o + path.sep)));
332
+ for (const t of tests) {
333
+ const before = roots.map((d) => snapshot(d));
334
+ const r = await agent.run(t.input, { source: 'test' });
335
+ const files = roots.flatMap((d, i) => filesWritten(d, before[i], { root: dir })).slice(0, 6);
336
+ let verdict = { pass: false, why: 'The run failed' };
337
+ if (r.status !== 'failed') {
338
+ try {
339
+ const v = parseJsonReply(await this.oneShot(judgePrompt({ goal: spec.goal, input: t.input, expect: t.expect, result: r.result, files })));
340
+ verdict = { pass: v.pass === true, why: String(v.why ?? '') };
341
+ } catch (e) {
342
+ verdict = { pass: false, why: `Could not judge: ${e.message}` };
343
+ }
344
+ }
345
+ results.push({ input: t.input, expect: t.expect, result: r.result, status: r.status, costUsd: r.costUsd, files: files.map((f) => f.path), ...verdict });
346
+ }
347
+ const passed = results.filter((r) => r.pass).length;
348
+ this.saveMeta(name, { results: { at: now(), passed, total: results.length, list: results }, status: passed === results.length ? 'tested' : 'failing' });
349
+ } catch (e) {
350
+ this.saveMeta(name, { status: 'failing', results: { at: now(), passed: 0, total: 0, list: [], error: e.message } });
351
+ } finally {
352
+ this.pending.delete(name);
353
+ }
354
+ return this.view(name);
355
+ }
356
+
357
+ /** Build its single executable for a platform into dist/ (in the background; the view says when). */
358
+ package(name, platform = HOST) {
359
+ const dir = this.dir(name);
360
+ if (!this.meta(name)) throw err(404, `No built agent "${name}"`);
361
+ if (this.pending.get(name)) throw err(409, `Already ${this.pending.get(name)}`);
362
+ if (!PLATFORMS.includes(platform)) throw err(400, `platform: one of ${PLATFORMS.join(', ')}`);
363
+ if (platform.startsWith('macos-') && process.platform !== 'darwin') throw err(400, 'macOS executables are built on a Mac');
364
+ loadAgent(dir, { env: this.env });
365
+ this.pending.set(name, `packaging for ${platform}`);
366
+ this.saveMeta(name, { packageError: null });
367
+ return this.#pack(name, dir, platform);
368
+ }
369
+
370
+ async #pack(name, dir, platform) {
371
+ try {
372
+ const r = await this.pack(dir, { outDir: path.join(dir, 'dist'), platform });
373
+ const executables = { ...this.meta(name).executables, [platform]: { file: path.basename(r.file), size: r.size, at: now() } };
374
+ this.saveMeta(name, { executables });
375
+ } catch (e) {
376
+ this.saveMeta(name, { packageError: `${platform}: ${e.message}` });
377
+ } finally {
378
+ this.pending.delete(name);
379
+ }
380
+ return this.view(name);
381
+ }
382
+
383
+ /** The path of a built executable, for download. */
384
+ executable(name, platform) {
385
+ const x = this.view(name).executables[platform];
386
+ if (!x) throw err(404, `No ${platform} executable built yet`);
387
+ return path.join(this.dir(name), 'dist', x.file);
388
+ }
389
+
390
+ /** Run it continuously on this computer (`agentkit run`); its output goes to agent.log. */
391
+ start(name) {
392
+ const dir = this.dir(name);
393
+ if (this.procs.has(name)) return this.view(name);
394
+ loadAgent(dir, { env: this.env });
395
+ const log = fs.openSync(path.join(dir, 'agent.log'), 'a');
396
+ const child = spawn(process.execPath, [AGENTKIT, 'run', dir], { env: { ...this.env, ...this.reportEnv() }, stdio: ['ignore', log, log], detached: false });
397
+ const entry = { child, url: null, since: now() };
398
+ this.procs.set(name, entry);
399
+ // Its chat address, from the log line it prints when listening.
400
+ const watch = setInterval(() => {
401
+ const m = /chat and API: (http:\/\/\S+)/.exec(fs.readFileSync(path.join(dir, 'agent.log'), 'utf8').split('\n').slice(-40).join('\n'));
402
+ if (m) {
403
+ entry.url = m[1];
404
+ clearInterval(watch);
405
+ }
406
+ }, 300);
407
+ setTimeout(() => clearInterval(watch), 20_000);
408
+ child.on('exit', () => {
409
+ clearInterval(watch);
410
+ if (this.procs.get(name) === entry) this.procs.delete(name);
411
+ this.saveMeta(name, { lastStoppedAt: now() });
412
+ });
413
+ this.saveMeta(name, { status: this.meta(name).status === 'tested' ? 'running' : this.meta(name).status, lastStartedAt: now() });
414
+ return this.view(name);
415
+ }
416
+
417
+ stop(name) {
418
+ const p = this.procs.get(name);
419
+ if (p) p.child.kill('SIGTERM');
420
+ this.procs.delete(name);
421
+ return this.view(name);
422
+ }
423
+
424
+ stopAll() {
425
+ for (const name of [...this.procs.keys()]) this.stop(name);
426
+ }
427
+
428
+ remove(name) {
429
+ this.stop(name);
430
+ // Its session works in the folder about to go; close it (the conversation is kept).
431
+ const sid = this.meta(name)?.session;
432
+ if (sid && this.sessions?.get(sid)) this.sessions.stop(sid);
433
+ fs.rmSync(this.dir(name), { recursive: true, force: true });
434
+ return { ok: true };
435
+ }
436
+ }
package/src/manager.js CHANGED
@@ -113,6 +113,7 @@ import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
113
113
  import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
114
114
  import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
115
115
  import { runningClaude, scanClaudeSessions } from './sessions.js';
116
+ import { builderAllowedTools, builderPrompt } from './builder.js';
116
117
  import {
117
118
  bugFromRef,
118
119
  extractRefs,
@@ -596,6 +597,7 @@ export class AgentManager extends EventEmitter {
596
597
  trigger,
597
598
  attachments,
598
599
  isolation,
600
+ builds,
599
601
  }) {
600
602
  intent = (intent ?? '').trim();
601
603
  const staged = stagedFiles(attachments);
@@ -648,6 +650,8 @@ export class AgentManager extends EventEmitter {
648
650
  ...(startedBy ? { startedBy } : {}),
649
651
  ...(trigger ? { trigger } : {}),
650
652
  ...(isolation ? { isolation } : {}),
653
+ // A session that develops an agent built here (builder.js): { name, engine }.
654
+ ...(builds ? { builds: { name: String(builds.name), engine: String(builds.engine ?? 'claude') } } : {}),
651
655
  name: name?.trim() || jiraKey || slug,
652
656
  slug,
653
657
  intent,
@@ -664,7 +668,7 @@ export class AgentManager extends EventEmitter {
664
668
  model: model || null,
665
669
  // Review the implementation plan before the agent implements it: null = no review.
666
670
  // Team members get their review from the team instead.
667
- planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
671
+ planReview: !general && !team && !builds && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
668
672
  status: 'preparing',
669
673
  desired: start ? 'running' : 'stopped',
670
674
  error: null,
@@ -1042,7 +1046,8 @@ export class AgentManager extends EventEmitter {
1042
1046
  if (this.config.inbox !== false) prompts.push(questionsPrompt(path.join(dir, path.basename(questionsPath(agent.id)))));
1043
1047
  }
1044
1048
  // SPEC.md before changing code, and PLAN.md kept as it goes (unless the plan review asks for the plan).
1045
- if (task && this.config.specs !== false) {
1049
+ // A session building an agent keeps its analysis in the agent's folder instead.
1050
+ if (task && this.config.specs !== false && !agent.builds) {
1046
1051
  fs.mkdirSync(briefDir(agent.id), { recursive: true });
1047
1052
  const dir = fs.realpathSync(briefDir(agent.id));
1048
1053
  writableDir ??= dir;
@@ -1061,6 +1066,11 @@ export class AgentManager extends EventEmitter {
1061
1066
  prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
1062
1067
  allowedTools = [`Bash(${learnCommand()}:*)`];
1063
1068
  }
1069
+ if (agent.builds && agent.workdir) {
1070
+ prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
1071
+ // Checking the agent loads and reading the reference need no permission prompt.
1072
+ allowedTools = [...(allowedTools ?? []), ...builderAllowedTools(agent.workdir)];
1073
+ }
1064
1074
  if (agent.kind === 'assistant') {
1065
1075
  fs.mkdirSync(assistantDir(), { recursive: true });
1066
1076
  const dir = fs.realpathSync(assistantDir());
@@ -1153,6 +1163,14 @@ export class AgentManager extends EventEmitter {
1153
1163
  this.runtimes.set(agent.id, rt);
1154
1164
 
1155
1165
  proc.onData((data) => {
1166
+ // Newer Claude Code blinks a ⏺ beside a pending tool call (while its permission prompt is
1167
+ // up): drawn, not work. It goes to the terminal but doesn't keep the agent busy or fill the
1168
+ // screen text the prompt is read from.
1169
+ if (!data.replace(ANSI, '').replace(/[\s⏺]/g, '')) {
1170
+ this.appendOutput(rt, data);
1171
+ this.broadcast(rt, { t: 'o', d: data });
1172
+ return;
1173
+ }
1156
1174
  rt.linkScan = true;
1157
1175
  rt.lastOutputAt = Date.now();
1158
1176
  rt.tail = (rt.tail + data).slice(-4000);
@@ -1797,6 +1815,11 @@ export class AgentManager extends EventEmitter {
1797
1815
  return [String(intent ?? '').trim(), attachmentNote(files)].filter(Boolean).join('\n\n');
1798
1816
  }
1799
1817
 
1818
+ /** Staged uploads as text, for a model to read (the builder's attachments): name and up to 100 KB each. */
1819
+ stagedText(attachments) {
1820
+ 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) }));
1821
+ }
1822
+
1800
1823
  /** A file attached before its agent exists (New agents, Ask); create() copies it to the agent. */
1801
1824
  stageUpload(name, data) {
1802
1825
  fs.mkdirSync(stagedDir(), { recursive: true });