ai-agentmanager 1.12.0 → 1.14.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/manager.js CHANGED
@@ -112,7 +112,9 @@ import { Plugins } from './plugins.js';
112
112
  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
+ import { Rules, describeTool } from './rules.js';
115
116
  import { runningClaude, scanClaudeSessions } from './sessions.js';
117
+ import { builderAllowedTools, builderPrompt } from './builder.js';
116
118
  import {
117
119
  bugFromRef,
118
120
  extractRefs,
@@ -148,6 +150,8 @@ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-
148
150
  // When a link is found from several sources, the higher-ranked source is kept.
149
151
  const SOURCE_RANK = { manual: 5, intent: 4, session: 3, branch: 2, mentioned: 1 };
150
152
  const BUSY_IDLE_MS = 3000;
153
+ // The attention label engines give a tool permission menu (what rules can answer).
154
+ const PERMISSION = 'Permission prompt';
151
155
  /** Looser stuck thresholds: candidates the judge looks at before anyone is bothered. */
152
156
  const loosen = (o) => ({
153
157
  repeatFailures: Math.max(2, Math.ceil(o.repeatFailures / 2)),
@@ -281,6 +285,7 @@ export class AgentManager extends EventEmitter {
281
285
  this.stuck = new Map(); // agent id -> [finding]
282
286
  this.outbox = new Map(); // agent id -> [message] waiting to be typed into its session
283
287
  // The AI judge: rules decide when to look, a model decides what is going on (see judge.js).
288
+ this.rules = new Rules({ suggestAfter: config.rules?.suggestAfter });
284
289
  this.judge = new Judge({ config, call: (agent, prompt, { timeoutMs }) => this.oneShot(agent, prompt, { ...this.judgeModel(agent), timeoutMs }) });
285
290
  this.stuckVerdicts = new Map(); // agent id -> Map(finding key -> { at, since, data })
286
291
  this.signIns = new Map(); // agent id -> { url, port, callback, terminal, at, finished } from a CLI's sign-in link
@@ -372,7 +377,7 @@ export class AgentManager extends EventEmitter {
372
377
  shell: this.shellNumbers(agent.id).length > 0,
373
378
  shells: this.shellNumbers(agent.id),
374
379
  lastOutputAt: rt?.lastOutputAt ? new Date(rt.lastOutputAt).toISOString() : null,
375
- prompt: rt?.pty && !rt.busy && rt.attention ? engineOf(agent).promptChoices(rt.tail) : null,
380
+ prompt: rt?.pty && !rt.busy && rt.attention ? this.promptView(agent, rt) : null,
376
381
  },
377
382
  };
378
383
  }
@@ -596,6 +601,7 @@ export class AgentManager extends EventEmitter {
596
601
  trigger,
597
602
  attachments,
598
603
  isolation,
604
+ builds,
599
605
  }) {
600
606
  intent = (intent ?? '').trim();
601
607
  const staged = stagedFiles(attachments);
@@ -648,6 +654,8 @@ export class AgentManager extends EventEmitter {
648
654
  ...(startedBy ? { startedBy } : {}),
649
655
  ...(trigger ? { trigger } : {}),
650
656
  ...(isolation ? { isolation } : {}),
657
+ // A session that develops an agent built here (builder.js): { name, engine }.
658
+ ...(builds ? { builds: { name: String(builds.name), engine: String(builds.engine ?? 'claude') } } : {}),
651
659
  name: name?.trim() || jiraKey || slug,
652
660
  slug,
653
661
  intent,
@@ -664,7 +672,7 @@ export class AgentManager extends EventEmitter {
664
672
  model: model || null,
665
673
  // Review the implementation plan before the agent implements it: null = no review.
666
674
  // Team members get their review from the team instead.
667
- planReview: !general && !team && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
675
+ planReview: !general && !team && !builds && (planReview ?? this.config.planReview ?? false) ? { status: 'writing' } : null,
668
676
  status: 'preparing',
669
677
  desired: start ? 'running' : 'stopped',
670
678
  error: null,
@@ -1042,7 +1050,8 @@ export class AgentManager extends EventEmitter {
1042
1050
  if (this.config.inbox !== false) prompts.push(questionsPrompt(path.join(dir, path.basename(questionsPath(agent.id)))));
1043
1051
  }
1044
1052
  // 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) {
1053
+ // A session building an agent keeps its analysis in the agent's folder instead.
1054
+ if (task && this.config.specs !== false && !agent.builds) {
1046
1055
  fs.mkdirSync(briefDir(agent.id), { recursive: true });
1047
1056
  const dir = fs.realpathSync(briefDir(agent.id));
1048
1057
  writableDir ??= dir;
@@ -1061,6 +1070,14 @@ export class AgentManager extends EventEmitter {
1061
1070
  prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
1062
1071
  allowedTools = [`Bash(${learnCommand()}:*)`];
1063
1072
  }
1073
+ // Commands you chose to always allow in this repo.
1074
+ const ruled = agent.workdir ? this.rules.allowed(memoryKey(agent)) : [];
1075
+ if (ruled.length) allowedTools = [...(allowedTools ?? []), ...ruled];
1076
+ if (agent.builds && agent.workdir) {
1077
+ prompts.push(builderPrompt({ dir: agent.workdir, name: agent.builds.name, engine: agent.builds.engine }));
1078
+ // Checking the agent loads and reading the reference need no permission prompt.
1079
+ allowedTools = [...(allowedTools ?? []), ...builderAllowedTools(agent.workdir)];
1080
+ }
1064
1081
  if (agent.kind === 'assistant') {
1065
1082
  fs.mkdirSync(assistantDir(), { recursive: true });
1066
1083
  const dir = fs.realpathSync(assistantDir());
@@ -1153,6 +1170,14 @@ export class AgentManager extends EventEmitter {
1153
1170
  this.runtimes.set(agent.id, rt);
1154
1171
 
1155
1172
  proc.onData((data) => {
1173
+ // Newer Claude Code blinks a ⏺ beside a pending tool call (while its permission prompt is
1174
+ // up): drawn, not work. It goes to the terminal but doesn't keep the agent busy or fill the
1175
+ // screen text the prompt is read from.
1176
+ if (!data.replace(ANSI, '').replace(/[\s⏺]/g, '')) {
1177
+ this.appendOutput(rt, data);
1178
+ this.broadcast(rt, { t: 'o', d: data });
1179
+ return;
1180
+ }
1156
1181
  rt.linkScan = true;
1157
1182
  rt.lastOutputAt = Date.now();
1158
1183
  rt.tail = (rt.tail + data).slice(-4000);
@@ -2848,16 +2873,66 @@ export class AgentManager extends EventEmitter {
2848
2873
  items.push({ ...base, kind: 'conflict', key: `conflict:${agent.id}:${g.agent.id}`, title: `Overlaps with ${g.agent.name}`, conflict: g });
2849
2874
  }
2850
2875
  }
2851
- const order = { prompt: 0, waiting: 1, question: 1, review: 2, stuck: 3, conflict: 4 };
2876
+ // Commands you keep allowing in a repo: offer to always allow them there.
2877
+ for (const r of this.rules.view().suggestions) items.push({ kind: 'rule', key: `rule:${r.repo}:${r.rule}`, title: `Always allow ${r.label}?`, priority: 2, ...r });
2878
+ const order = { prompt: 0, waiting: 1, question: 1, rule: 2, review: 2, stuck: 3, conflict: 4 };
2852
2879
  return items.sort((a, b) => order[a.kind] - order[b.kind] || a.priority - b.priority);
2853
2880
  }
2854
2881
 
2882
+ /** A live prompt's question and choices, and the tool call it asks about when the transcript says. */
2883
+ promptView(agent, rt) {
2884
+ const p = engineOf(agent).promptChoices(rt.tail);
2885
+ const call = rt.attention === PERMISSION ? this.pendingCall(agent) : null;
2886
+ return call ? { ...p, tool: describeTool(call) } : p;
2887
+ }
2888
+
2889
+ /** The one tool call a live prompt is waiting on, from the transcript (Claude Code); null when unsure. */
2890
+ pendingCall(agent) {
2891
+ const engine = engineOf(agent);
2892
+ if (engine.id !== 'claude' || !agent.workdir || !agent.sessionId) return null;
2893
+ const key = `${agent.sessionId}:${agent.workdir}`;
2894
+ const state = this.activity.get(key) ?? {};
2895
+ this.activity.set(key, engine.activity(engine.sessionFile(agent.workdir, agent.sessionId), state));
2896
+ return state.calls?.size === 1 ? [...state.calls.values()][0] : null;
2897
+ }
2898
+
2899
+ /** A permission prompt for a call one of the repo's allowed rules covers is answered yes by itself. */
2900
+ autoAllow(agent, rt) {
2901
+ if (this.config.rules?.auto === false) return false;
2902
+ const call = this.pendingCall(agent);
2903
+ const repo = call ? memoryKey(agent) : null;
2904
+ const rule = call && this.rules.allows(repo, call);
2905
+ if (!rule) return false;
2906
+ const first = engineOf(agent).promptChoices(rt.tail)?.choices?.[0];
2907
+ if (first?.key !== '1' || !/^yes\b/i.test(first.label)) return false;
2908
+ this.write(agent.id, '1');
2909
+ rt.attention = null;
2910
+ this.rules.used(repo, rule);
2911
+ this.plugins.emit('agent.prompt.allowed', { agent: this.auditView(agent), rule, tool: describeTool(call) });
2912
+ return true;
2913
+ }
2914
+
2915
+ /** After a rule is allowed: prompts already waiting on a call it covers are answered too. */
2916
+ applyRules() {
2917
+ for (const [id, rt] of this.runtimes) {
2918
+ const agent = this.store.get(id);
2919
+ if (agent && rt.pty && !rt.busy && rt.attention === PERMISSION && this.autoAllow(agent, rt)) this.changed(agent);
2920
+ }
2921
+ }
2922
+
2855
2923
  /** Send keys to a live prompt (e.g. "1" to pick the first option). */
2856
2924
  answerPrompt(ref, key) {
2857
2925
  const agent = this.mustGet(ref);
2858
2926
  const keys = { esc: '\x1b', enter: '\r', up: '\x1b[A', down: '\x1b[B' };
2859
2927
  const data = keys[key] ?? (/^\d$/.test(key) ? key : null);
2860
2928
  if (!data) throw Object.assign(new Error('Unsupported key'), { status: 400 });
2929
+ // A yes to a permission prompt counts toward offering a rule for it in this repo.
2930
+ const rt = this.runtimes.get(agent.id);
2931
+ if (rt?.pty && !rt.busy && rt.attention === PERMISSION) {
2932
+ const choice = engineOf(agent).promptChoices(rt.tail)?.choices?.find((c) => c.key === key);
2933
+ const call = /^yes\b/i.test(choice?.label ?? '') ? this.pendingCall(agent) : null;
2934
+ if (call) this.rules.approved(memoryKey(agent), call);
2935
+ }
2861
2936
  // Menus that read a line (the ACP client's) take Enter after the choice.
2862
2937
  this.write(agent.id, engineOf(agent).promptKeyEnter && key !== 'enter' ? `${data}\r` : data);
2863
2938
  return { ok: true };
@@ -2967,6 +3042,7 @@ export class AgentManager extends EventEmitter {
2967
3042
  const screen = rt.tail.slice(-1500).replace(ANSI, '').replace(/\s+/g, '');
2968
3043
  const a0 = this.store.get(id);
2969
3044
  rt.attention = (a0 ? engineOf(a0).attention : []).find(([re]) => re.test(screen))?.[1] ?? null;
3045
+ if (a0 && rt.attention === PERMISSION) this.autoAllow(a0, rt);
2970
3046
  const a = this.store.get(id);
2971
3047
  if (a) this.changed(a);
2972
3048
  // A turn just ended: remember the code and conversation at this point.
package/src/rules.js ADDED
@@ -0,0 +1,161 @@
1
+ // Approval rules learned from your answers: when you keep saying yes to the same command in a repo
2
+ // ("npm test", three times), the inbox offers to always allow it there. Allowed rules go to every
3
+ // agent in that repo at launch (Claude Code's --allowedTools) and answer its live prompts, so the
4
+ // same question isn't asked again. Kept in ~/.agentmanager/rules.json, per repo (the repo-memory key).
5
+ import { EventEmitter } from 'node:events';
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { paths } from './config.js';
9
+
10
+ export const SUGGEST_AFTER = 3;
11
+ // Commands whose first words name a subcommand: "npm test" is a rule, "npm" alone would allow anything npm does.
12
+ const SUBCOMMANDS = new Set(['npm', 'pnpm', 'yarn', 'bun', 'npx', 'node', 'deno', 'git', 'gh', 'cargo', 'go', 'make', 'python', 'python3', 'uv', 'pip', 'poetry', 'pytest', 'mvn', 'gradle', './gradlew', 'dotnet', 'swift', 'xcodebuild', 'bundle', 'rake', 'rails', 'mix', 'composer', 'php', 'docker', 'kubectl', 'terraform', 'just', 'task']);
13
+ // Never offered as a standing rule, however often approved: they delete, escalate, publish or reach out.
14
+ const NEVER = /^(rm|sudo|su|chmod|chown|dd|mkfs|kill|killall|pkill|shutdown|reboot|curl|wget|ssh|scp|rsync|eval|exec|sh|bash|zsh|env|xargs|find|git (push|reset|clean|rebase|checkout|branch|tag)|gh (pr merge|release|repo|api|secret)|npm (publish|unpublish|login|adduser|token)|docker (rm|rmi|system|push|login)|kubectl (delete|apply)|terraform (apply|destroy))$/;
15
+ // Anything that chains, redirects or substitutes isn't one command, so no rule covers it.
16
+ const COMPOUND = /[;&|<>`$(){}\n\\]/;
17
+
18
+ /** The words of a plain command, or null when it chains, redirects, substitutes or sets variables. */
19
+ function words(command) {
20
+ const cmd = String(command ?? '').trim();
21
+ if (!cmd || COMPOUND.test(cmd)) return null;
22
+ const w = cmd.split(/\s+/);
23
+ return /^\w+=/.test(w[0]) ? null : w;
24
+ }
25
+
26
+ /**
27
+ * The rule that would allow a tool call, as Claude Code writes it, with a label for people:
28
+ * Bash → "Bash(npm test:*)", WebFetch → "WebFetch(domain:docs.x.com)", an MCP tool → its name.
29
+ * Null for calls that shouldn't become a standing rule (file edits: use Accept edits instead).
30
+ */
31
+ export function ruleFor(tool) {
32
+ const input = tool?.input ?? {};
33
+ if (tool?.name === 'Bash') {
34
+ const w = words(input.command);
35
+ if (!w) return null;
36
+ const prefix = (SUBCOMMANDS.has(w[0]) && w[1] && !w[1].startsWith('-') ? w.slice(0, 2) : w.slice(0, 1)).join(' ');
37
+ if (NEVER.test(prefix) || NEVER.test(w[0])) return null;
38
+ return { rule: `Bash(${prefix}:*)`, label: prefix };
39
+ }
40
+ if (tool?.name === 'WebFetch') {
41
+ try {
42
+ const host = new URL(input.url).hostname;
43
+ return host ? { rule: `WebFetch(domain:${host})`, label: `fetch from ${host}` } : null;
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+ if (/^mcp__[\w-]+__[\w-]+$/.test(tool?.name ?? '')) return { rule: tool.name, label: tool.name.replace(/^mcp__/, '').replace('__', ': ') };
49
+ return null;
50
+ }
51
+
52
+ /** Whether an allowed rule covers this tool call. */
53
+ export function covers(rule, tool) {
54
+ const m = /^Bash\((.+):\*\)$/.exec(rule);
55
+ if (m) {
56
+ const w = words(tool?.input?.command);
57
+ return tool?.name === 'Bash' && Boolean(w) && (w.join(' ') === m[1] || w.join(' ').startsWith(`${m[1]} `));
58
+ }
59
+ return ruleFor(tool)?.rule === rule;
60
+ }
61
+
62
+ /** What a pending tool call is, in a few words for an inbox card. */
63
+ export function describeTool(tool) {
64
+ const input = tool?.input ?? {};
65
+ if (tool?.name === 'Bash') return { name: 'Bash', text: String(input.command ?? '').slice(0, 300) };
66
+ if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Read'].includes(tool?.name)) return { name: tool.name, text: String(input.file_path ?? input.notebook_path ?? '') };
67
+ if (tool?.name === 'WebFetch') return { name: 'WebFetch', text: String(input.url ?? '') };
68
+ return { name: String(tool?.name ?? ''), text: JSON.stringify(input).slice(0, 300) };
69
+ }
70
+
71
+ export class Rules extends EventEmitter {
72
+ constructor({ file = path.join(paths.home, 'rules.json'), suggestAfter = SUGGEST_AFTER } = {}) {
73
+ super();
74
+ this.file = file;
75
+ this.suggestAfter = suggestAfter;
76
+ try {
77
+ this.data = JSON.parse(fs.readFileSync(file, 'utf8'));
78
+ } catch {
79
+ this.data = {};
80
+ }
81
+ this.data.repos ??= {};
82
+ }
83
+
84
+ save() {
85
+ fs.mkdirSync(path.dirname(this.file), { recursive: true });
86
+ fs.writeFileSync(this.file, JSON.stringify(this.data, null, 2));
87
+ this.emit('change', this.view());
88
+ }
89
+
90
+ repo(key) {
91
+ return (this.data.repos[key] ??= { allow: [], dismissed: [], seen: {} });
92
+ }
93
+
94
+ /** The rules allowed in a repo, as Claude Code allow rules. */
95
+ allowed(key) {
96
+ return (this.data.repos[key]?.allow ?? []).map((r) => r.rule);
97
+ }
98
+
99
+ /** The allowed rule that covers this call in this repo, if any. */
100
+ allows(key, tool) {
101
+ return this.allowed(key).find((r) => covers(r, tool)) ?? null;
102
+ }
103
+
104
+ /** An allowed rule answered a prompt by itself: count it, so the list shows what it saves. */
105
+ used(key, rule) {
106
+ const a = this.data.repos[key]?.allow.find((x) => x.rule === rule);
107
+ if (!a) return;
108
+ a.uses = (a.uses ?? 0) + 1;
109
+ a.lastUsedAt = new Date().toISOString();
110
+ this.save();
111
+ }
112
+
113
+ /** You said yes to this call in this repo; count it toward a suggestion. */
114
+ approved(key, tool) {
115
+ const r = ruleFor(tool);
116
+ if (!r) return null;
117
+ const repo = this.repo(key);
118
+ if (repo.allow.some((x) => x.rule === r.rule)) return null;
119
+ const seen = (repo.seen[r.rule] ??= { label: r.label, n: 0 });
120
+ seen.n++;
121
+ seen.at = new Date().toISOString();
122
+ this.save();
123
+ return { ...r, n: seen.n };
124
+ }
125
+
126
+ accept(key, rule) {
127
+ const repo = this.data.repos[key];
128
+ const seen = repo?.seen[rule];
129
+ if (!seen && !repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such suggestion'), { status: 404 });
130
+ if (!repo.allow.some((x) => x.rule === rule)) repo.allow.push({ rule, label: seen.label, at: new Date().toISOString() });
131
+ delete repo.seen[rule];
132
+ this.save();
133
+ }
134
+
135
+ /** "Not now": stop suggesting it (it can still be allowed later from the list). */
136
+ dismiss(key, rule) {
137
+ const repo = this.data.repos[key];
138
+ if (!repo?.seen[rule]) throw Object.assign(new Error('No such suggestion'), { status: 404 });
139
+ if (!repo.dismissed.includes(rule)) repo.dismissed.push(rule);
140
+ this.save();
141
+ }
142
+
143
+ remove(key, rule) {
144
+ const repo = this.data.repos[key];
145
+ if (!repo?.allow.some((x) => x.rule === rule)) throw Object.assign(new Error('No such rule'), { status: 404 });
146
+ repo.allow = repo.allow.filter((x) => x.rule !== rule);
147
+ repo.dismissed = repo.dismissed.filter((x) => x !== rule);
148
+ this.save();
149
+ }
150
+
151
+ /** Every repo's allowed rules and the suggestions waiting for an answer. */
152
+ view() {
153
+ const allowed = [];
154
+ const suggestions = [];
155
+ for (const [repo, r] of Object.entries(this.data.repos)) {
156
+ for (const a of r.allow) allowed.push({ repo, ...a });
157
+ for (const [rule, s] of Object.entries(r.seen)) if (s.n >= this.suggestAfter && !r.dismissed.includes(rule)) suggestions.push({ repo, rule, label: s.label, n: s.n, at: s.at });
158
+ }
159
+ return { allowed, suggestions, suggestAfter: this.suggestAfter };
160
+ }
161
+ }
package/src/server.js CHANGED
@@ -448,6 +448,16 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
448
448
  );
449
449
  // Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
450
450
  api.get('/inbox', wrap(() => manager.inbox()));
451
+ // Approval rules learned from your answers (src/rules.js): allowed per repo, and suggestions.
452
+ const ruleArgs = (req) => {
453
+ const { repo, rule } = req.body ?? {};
454
+ if (typeof repo !== 'string' || typeof rule !== 'string') throw Object.assign(new Error('repo and rule are required'), { status: 400 });
455
+ return [repo, rule];
456
+ };
457
+ api.get('/rules', wrap(() => manager.rules.view()));
458
+ api.post('/rules/accept', wrap((req) => (manager.rules.accept(...ruleArgs(req)), manager.applyRules(), manager.rules.view())));
459
+ api.post('/rules/dismiss', wrap((req) => (manager.rules.dismiss(...ruleArgs(req)), manager.rules.view())));
460
+ api.post('/rules/remove', wrap((req) => (manager.rules.remove(...ruleArgs(req)), manager.rules.view())));
451
461
  api.post('/agents/:id/tell', wrap((req) => manager.tell(req.params.id, req.body?.text)));
452
462
  api.post('/agents/:id/answer', wrap((req) => manager.answerQuestion(req.params.id, req.body?.question, req.body?.text)));
453
463
  api.post('/agents/:id/prompt-key', wrap((req) => manager.answerPrompt(req.params.id, String(req.body?.key ?? ''))));
@@ -525,9 +535,19 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
525
535
  api.get('/agents/:id/memory', wrap((req) => memory.read(req.params.id)));
526
536
  api.put('/agents/:id/memory', wrap((req) => memory.write(req.params.id, req.body?.text)));
527
537
  api.post('/agents/:id/learn', wrap((req) => memory.learn(req.params.id, req.body?.text, { by: req.headers['x-agentmgr-agent'] ? undefined : 'you' })));
528
- // The agent builder (agentkit agents designed from a task): design, edit, test, run, download.
529
- // Agents started here report to this daemon (runs, cost, approvals).
530
- const builder = new Builder({ oneShot: (prompt) => manager.oneShot(null, prompt, { ...manager.writerFor({}), timeoutMs: 300_000 }), reportEnv: () => ({ AGENTKIT_REPORT_URL: `http://127.0.0.1:${port}`, AGENTKIT_REPORT_TOKEN: reports.reportToken() }) });
538
+ // The agent builder (agentkit agents built from a task, each developed in an agent session
539
+ // working in its folder): build, change, test, run, package, download. Agents started here
540
+ // report to this daemon (runs, cost, approvals).
541
+ const sessions = {
542
+ create: (opts) => manager.create({ ...opts, worktree: false }),
543
+ get: (id) => {
544
+ const a = manager.get(id);
545
+ return a ? manager.view(a) : null;
546
+ },
547
+ tell: (id, text) => manager.tell(id, text),
548
+ stop: (id) => manager.stop(id).catch(() => {}),
549
+ };
550
+ const builder = new Builder({ sessions, oneShot: (prompt) => manager.oneShot(null, prompt, { ...manager.writerFor({}), timeoutMs: 300_000 }), reportEnv: () => ({ AGENTKIT_REPORT_URL: `http://127.0.0.1:${port}`, AGENTKIT_REPORT_TOKEN: reports.reportToken() }) });
531
551
  api.post('/agentkit/report', wrap((req) => reports.report(req.body ?? {})));
532
552
  api.get('/agentkit/agents', wrap(() => reports.list()));
533
553
  api.post('/agentkit/agents/:key/approvals/:id', wrap((req) => reports.decide(req.params.key, req.params.id, req.body?.allow === true, req.device ? `${req.device.name ?? 'a signed-in device'}` : 'you')));
@@ -535,7 +555,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
535
555
  // What an agent elsewhere needs to report here (this computer only: it shows the token).
536
556
  api.get('/agentkit/connect', wrap(() => ({ token: reports.reportToken(), local: `http://127.0.0.1:${port}`, remote: remoteServer ? remote.remoteUrls(remotePort).map((u) => u.url) : [], cert: remoteServer ? remote.certificateFingerprint() : null })));
537
557
  api.get('/builder', wrap(() => builder.list()));
538
- api.post('/builder', wrap((req) => builder.design({ task: req.body?.task, engine: req.body?.engine || 'claude', attachments: req.body?.attachments?.length ? manager.stagedText(req.body.attachments) : [] })));
558
+ api.post('/builder', wrap((req) => builder.design({ task: req.body?.task, engine: req.body?.engine || 'claude', name: req.body?.name || undefined, attachments: req.body?.attachments, permissionMode: req.body?.permissionMode || undefined, model: req.body?.model || undefined })));
559
+ api.post('/builder/:name/change', wrap((req) => builder.change(req.params.name, req.body?.text)));
539
560
  api.get('/builder/:name', wrap((req) => builder.view(req.params.name)));
540
561
  api.put('/builder/:name', wrap((req) => builder.update(req.params.name, req.body ?? {})));
541
562
  api.post('/builder/:name/test', wrap((req) => {
@@ -901,6 +922,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
901
922
  };
902
923
  manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
903
924
  reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
925
+ manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
904
926
  manager.on('removed', (id) => {
905
927
  broadcast({ t: 'removed', id });
906
928
  live.closeAgent(id).catch(() => {});
@@ -937,7 +959,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
937
959
  const url = new URL(req.url, 'http://x');
938
960
  if (url.pathname === '/ws/events') {
939
961
  eventClients.add(ws);
940
- ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list() }));
962
+ ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list(), rules: manager.rules.view() }));
941
963
  ws.on('close', () => eventClients.delete(ws));
942
964
  return;
943
965
  }
package/src/stuck.js CHANGED
@@ -20,12 +20,14 @@ const textOf = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((b)
20
20
 
21
21
  /**
22
22
  * Incrementally read new transcript lines into `state.events` (tool calls with their results).
23
- * state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event) }.
23
+ * state: { offset, events: [{ t, kind: 'cmd'|'edit'|'other', ... }], pending: Map(id -> event),
24
+ * calls: Map(id -> { name, input }) for the tool calls still waiting for a result (what a prompt asks about) }.
24
25
  */
25
26
  export function scanActivity(file, state) {
26
27
  state.offset ??= 0;
27
28
  state.events ??= [];
28
29
  state.pending ??= new Map();
30
+ state.calls ??= new Map();
29
31
  let fd;
30
32
  try {
31
33
  fd = fs.openSync(file, 'r');
@@ -34,7 +36,7 @@ export function scanActivity(file, state) {
34
36
  }
35
37
  try {
36
38
  const { size } = fs.fstatSync(fd);
37
- if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map() });
39
+ if (size < state.offset) Object.assign(state, { offset: 0, events: [], pending: new Map(), calls: new Map() });
38
40
  if (size === state.offset) return state;
39
41
  const buf = Buffer.alloc(size - state.offset);
40
42
  fs.readSync(fd, buf, 0, buf.length, state.offset);
@@ -56,7 +58,12 @@ export function scanActivity(file, state) {
56
58
  const ev = eventFor(b, t);
57
59
  state.events.push(ev);
58
60
  state.pending.set(b.id, ev);
61
+ // Calls left without a result by an earlier response (interrupted) aren't what's asked now.
62
+ if (rec.message?.id && rec.message.id !== state.callsMsg) state.calls.clear();
63
+ state.callsMsg = rec.message?.id;
64
+ state.calls.set(b.id, { name: b.name, input: b.input ?? {} });
59
65
  } else if (rec.type === 'user' && b.type === 'tool_result') {
66
+ state.calls.delete(b.tool_use_id);
60
67
  const ev = state.pending.get(b.tool_use_id);
61
68
  if (!ev) continue;
62
69
  state.pending.delete(b.tool_use_id);