ai-agentmanager 1.13.0 → 1.15.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 +13 -0
- package/README.md +26 -1
- package/docs/agentkit.md +1 -1
- package/package.json +1 -1
- package/public/app.js +124 -7
- package/public/index.html +5 -0
- package/public/menu.js +18 -0
- package/public/style.css +10 -0
- package/src/autopilot.js +184 -0
- package/src/backlog.js +19 -7
- package/src/builder.js +36 -15
- package/src/cli.js +57 -2
- package/src/manager.js +63 -2
- package/src/repeats.js +155 -0
- package/src/review.js +14 -2
- package/src/rules.js +161 -0
- package/src/server.js +44 -6
- package/src/stuck.js +9 -2
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
|
@@ -9,7 +9,7 @@ import { spawn } from 'node:child_process';
|
|
|
9
9
|
import express from 'express';
|
|
10
10
|
import { WebSocketServer } from 'ws';
|
|
11
11
|
import { ensureHome, HOST, loadConfig, paths, PORT, saveConfig } from './config.js';
|
|
12
|
-
import { AgentManager, MACHINE, shellNumber } from './manager.js';
|
|
12
|
+
import { AgentManager, MACHINE, projectOf, shellNumber } from './manager.js';
|
|
13
13
|
import { Backlog } from './backlog.js';
|
|
14
14
|
import { Races } from './races.js';
|
|
15
15
|
import { Review } from './review.js';
|
|
@@ -17,9 +17,11 @@ import { Teams } from './teams.js';
|
|
|
17
17
|
import { Assistant } from './assistant.js';
|
|
18
18
|
import { Cleanup } from './cleanup.js';
|
|
19
19
|
import { Landing } from './landing.js';
|
|
20
|
+
import { Autopilot } from './autopilot.js';
|
|
20
21
|
import { Budgets } from './budgets.js';
|
|
21
22
|
import { stats } from './stats.js';
|
|
22
23
|
import { Playbooks } from './playbooks.js';
|
|
24
|
+
import { Repeats } from './repeats.js';
|
|
23
25
|
import { Updates } from './update.js';
|
|
24
26
|
import { identity } from './identity.js';
|
|
25
27
|
import { Studio } from './studio.js';
|
|
@@ -90,6 +92,9 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
90
92
|
const teams = new Teams({ manager, store, config });
|
|
91
93
|
const cleanup = new Cleanup({ manager, store, config, teams });
|
|
92
94
|
const landing = new Landing({ manager, store, config });
|
|
95
|
+
// Backlog work the scorecard calls ready goes on to a pull request (and merged) by itself.
|
|
96
|
+
const autopilot = new Autopilot({ manager, store, backlog, review, landing, config });
|
|
97
|
+
manager.inboxSources.push(() => autopilot.inbox());
|
|
93
98
|
const assistant = new Assistant({ manager, store, config });
|
|
94
99
|
// Alerts link to the phone address when remote access is on, else the local UI.
|
|
95
100
|
const notifier = new Notifier({
|
|
@@ -104,6 +109,14 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
104
109
|
const triggers = new Triggers({ manager, store, config, notifier });
|
|
105
110
|
const budgets = new Budgets({ manager, store, config, notifier });
|
|
106
111
|
const playbooks = new Playbooks({ manager, store });
|
|
112
|
+
// Requests you keep typing, offered as a playbook.
|
|
113
|
+
const repeats = new Repeats({
|
|
114
|
+
suggestAfter: config.repeats?.suggestAfter,
|
|
115
|
+
enabled: config.repeats?.enabled !== false,
|
|
116
|
+
playbooks: () => playbooks.list(),
|
|
117
|
+
projects: () => Object.fromEntries(Object.values(store.data.projects ?? {}).map((p) => [p.key, p.shortcut || p.name])),
|
|
118
|
+
});
|
|
119
|
+
manager.inboxSources.push(() => repeats.inbox());
|
|
107
120
|
const memory = new Memory({ manager, config });
|
|
108
121
|
const updates = new Updates({ config });
|
|
109
122
|
let remoteServer = null;
|
|
@@ -230,6 +243,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
230
243
|
// Agents the assistant's own session starts: its limits apply, and they are marked as its.
|
|
231
244
|
const extra = assistant.admit(req.headers['x-agentmgr-agent'], list, opts);
|
|
232
245
|
const created = manager.createMany(list, { ...opts, ...extra });
|
|
246
|
+
if (!extra.startedBy) repeats.record(created.map((a) => ({ text: a.intent, projectKey: projectOf(store.get(a.id) ?? a).key })));
|
|
233
247
|
if (extra.startedBy) {
|
|
234
248
|
for (const a of created) notifier.send('assistant', a, `was started by the assistant: ${a.intent.replace(/\s+/g, ' ').slice(0, 160)}`);
|
|
235
249
|
}
|
|
@@ -443,11 +457,23 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
443
457
|
'/backlog',
|
|
444
458
|
wrap((req) => {
|
|
445
459
|
const { items, item, ...opts } = req.body ?? {};
|
|
446
|
-
|
|
460
|
+
const added = backlog.add(Array.isArray(items) ? items : [item], opts);
|
|
461
|
+
repeats.record(added.map((it) => ({ text: it.title, projectKey: it.projectKey })));
|
|
462
|
+
return added;
|
|
447
463
|
}),
|
|
448
464
|
);
|
|
449
465
|
// Everything waiting on the user: live prompts, agents' questions, stuck agents, overlaps.
|
|
450
466
|
api.get('/inbox', wrap(() => manager.inbox()));
|
|
467
|
+
// Approval rules learned from your answers (src/rules.js): allowed per repo, and suggestions.
|
|
468
|
+
const ruleArgs = (req) => {
|
|
469
|
+
const { repo, rule } = req.body ?? {};
|
|
470
|
+
if (typeof repo !== 'string' || typeof rule !== 'string') throw Object.assign(new Error('repo and rule are required'), { status: 400 });
|
|
471
|
+
return [repo, rule];
|
|
472
|
+
};
|
|
473
|
+
api.get('/rules', wrap(() => manager.rules.view()));
|
|
474
|
+
api.post('/rules/accept', wrap((req) => (manager.rules.accept(...ruleArgs(req)), manager.applyRules(), manager.rules.view())));
|
|
475
|
+
api.post('/rules/dismiss', wrap((req) => (manager.rules.dismiss(...ruleArgs(req)), manager.rules.view())));
|
|
476
|
+
api.post('/rules/remove', wrap((req) => (manager.rules.remove(...ruleArgs(req)), manager.rules.view())));
|
|
451
477
|
api.post('/agents/:id/tell', wrap((req) => manager.tell(req.params.id, req.body?.text)));
|
|
452
478
|
api.post('/agents/:id/answer', wrap((req) => manager.answerQuestion(req.params.id, req.body?.question, req.body?.text)));
|
|
453
479
|
api.post('/agents/:id/prompt-key', wrap((req) => manager.answerPrompt(req.params.id, String(req.body?.key ?? ''))));
|
|
@@ -648,9 +674,17 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
648
674
|
};
|
|
649
675
|
}));
|
|
650
676
|
api.get('/playbooks', wrap(() => playbooks.list()));
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
677
|
+
// Saving a playbook can answer a repeated-request suggestion.
|
|
678
|
+
const withRepeats = (fn) => (req) => {
|
|
679
|
+
const out = fn(req);
|
|
680
|
+
repeats.changed();
|
|
681
|
+
return out;
|
|
682
|
+
};
|
|
683
|
+
api.post('/playbooks', wrap(withRepeats((req) => playbooks.create(req.body ?? {}))));
|
|
684
|
+
api.put('/playbooks/:id', wrap(withRepeats((req) => playbooks.update(req.params.id, req.body ?? {}))));
|
|
685
|
+
api.delete('/playbooks/:id', wrap(withRepeats((req) => playbooks.remove(req.params.id))));
|
|
686
|
+
api.get('/repeats', wrap(() => repeats.view()));
|
|
687
|
+
api.post('/repeats/dismiss', wrap((req) => repeats.dismiss(String(req.body?.key ?? ''))));
|
|
654
688
|
api.post('/playbooks/:id/run', wrap((req) => playbooks.run(req.params.id, { vars: req.body?.vars ?? {} })));
|
|
655
689
|
api.get('/stats', wrap((req) => {
|
|
656
690
|
const days = req.query.days === undefined ? 30 : Math.max(0, Math.min(3650, Math.floor(Number(req.query.days)) || 0));
|
|
@@ -703,6 +737,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
703
737
|
api.post('/backlog/:id/move', wrap((req) => backlog.move(req.params.id, req.body?.where)));
|
|
704
738
|
api.post('/backlog/:id/split', wrap((req) => backlog.split(req.params.id, req.body?.parts)));
|
|
705
739
|
api.post('/backlog/:id/keep-whole', wrap((req) => backlog.keepWhole(req.params.id)));
|
|
740
|
+
api.post('/backlog/:id/ship', wrap((req) => autopilot.shipNow(req.params.id)));
|
|
706
741
|
api.post(
|
|
707
742
|
'/shutdown',
|
|
708
743
|
wrap(() => {
|
|
@@ -912,6 +947,8 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
912
947
|
};
|
|
913
948
|
manager.on('change', (agent) => broadcast({ t: 'agent', agent }));
|
|
914
949
|
reports.on('change', () => broadcast({ t: 'built', reports: reports.list() }));
|
|
950
|
+
manager.rules.on('change', (rules) => broadcast({ t: 'rules', rules }));
|
|
951
|
+
repeats.on('change', (view) => broadcast({ t: 'repeats', repeats: view }));
|
|
915
952
|
manager.on('removed', (id) => {
|
|
916
953
|
broadcast({ t: 'removed', id });
|
|
917
954
|
live.closeAgent(id).catch(() => {});
|
|
@@ -948,7 +985,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
948
985
|
const url = new URL(req.url, 'http://x');
|
|
949
986
|
if (url.pathname === '/ws/events') {
|
|
950
987
|
eventClients.add(ws);
|
|
951
|
-
ws.send(JSON.stringify({ t: 'snapshot', agents: manager.list(), backlog: backlog.view(), projects: manager.listProjects(), races: races.list(), teams: teams.list(), built: reports.list() }));
|
|
988
|
+
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(), repeats: repeats.view() }));
|
|
952
989
|
ws.on('close', () => eventClients.delete(ws));
|
|
953
990
|
return;
|
|
954
991
|
}
|
|
@@ -1029,6 +1066,7 @@ export async function startServer({ port = PORT, host = HOST } = {}) {
|
|
|
1029
1066
|
closing = true;
|
|
1030
1067
|
cleanup.close();
|
|
1031
1068
|
landing.close();
|
|
1069
|
+
autopilot.close();
|
|
1032
1070
|
triggers.close();
|
|
1033
1071
|
budgets.close();
|
|
1034
1072
|
playbooks.close();
|
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);
|