ai-agentmanager 1.2.1 → 1.3.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
@@ -47,10 +47,12 @@ import { findLocalUrls, findSignInLinks, parseCallback, parseUrl } from './previ
47
47
  import { Plugins } from './plugins.js';
48
48
  import { leadPrompt, subagents, teamDir, teamPrompt } from './teams.js';
49
49
  import { assistantDir, assistantPrompt, assistantSettings } from './assistant.js';
50
+ import { learnCommand, memoryKey, memoryPrompt, memoryText } from './memory.js';
50
51
  import { runningClaude, scanClaudeSessions } from './sessions.js';
51
52
  import {
52
53
  bugFromRef,
53
54
  extractRefs,
55
+ landState,
54
56
  leadingJiraKey,
55
57
  prDetails,
56
58
  prFromUrl,
@@ -204,7 +206,7 @@ export class AgentManager extends EventEmitter {
204
206
  this.shells = new Map(); // agent id -> ancillary shell runtime in the agent's folder
205
207
  this.cursors = new Map(); // session id -> transcript scan cursor
206
208
  this.locks = new Map();
207
- this.refreshing = new Set();
209
+ this.refreshing = new Map(); // agent id -> the refresh in progress
208
210
  this.lastPrCheck = new Map();
209
211
  this.changeSets = new Map(); // agent id -> { files, baseSha, at }: what its branch changed (conflict radar)
210
212
  this.conflicts = new Map(); // agent id -> [conflict]
@@ -283,7 +285,7 @@ export class AgentManager extends EventEmitter {
283
285
  usage,
284
286
  // Claude Code's own cost figure from the transcripts (absent until it records one).
285
287
  costUsd: costs.length ? costs.reduce((a, b) => a + b, 0) : null,
286
- prs: Object.values(agent.prs ?? {}),
288
+ prs: Object.values(agent.prs ?? {}).map((p) => ({ ...p, land: landState(p) })),
287
289
  bugs: Object.values(agent.bugs ?? {}),
288
290
  conflicts: this.conflictGroups(agent),
289
291
  stuck: this.activeStuck(agent),
@@ -522,6 +524,7 @@ export class AgentManager extends EventEmitter {
522
524
  sessionId: adopt,
523
525
  imported,
524
526
  startedBy,
527
+ trigger,
525
528
  }) {
526
529
  intent = (intent ?? '').trim();
527
530
  const engineDef = engineOf(engine || this.config.engine || DEFAULT_ENGINE);
@@ -563,6 +566,7 @@ export class AgentManager extends EventEmitter {
563
566
  project: project?.trim() || null,
564
567
  priority: parsePriority(priority),
565
568
  ...(startedBy ? { startedBy } : {}),
569
+ ...(trigger ? { trigger } : {}),
566
570
  name: name?.trim() || jiraKey || slug,
567
571
  slug,
568
572
  intent,
@@ -891,6 +895,12 @@ export class AgentManager extends EventEmitter {
891
895
  prompts.push(agent.planReview.status === 'approved' ? planApprovedPrompt(file) : planReviewPrompt(file));
892
896
  }
893
897
  if (planning) prompts.push(planningPrompt(agent));
898
+ // What earlier agents learned in this repo, and the one command to add to it.
899
+ let allowedTools = null;
900
+ if (task && this.config.memory !== false) {
901
+ prompts.push(memoryPrompt(memoryText(memoryKey(agent))));
902
+ allowedTools = [`Bash(${learnCommand()}:*)`];
903
+ }
894
904
  if (agent.kind === 'assistant') {
895
905
  fs.mkdirSync(assistantDir(), { recursive: true });
896
906
  const dir = fs.realpathSync(assistantDir());
@@ -911,7 +921,7 @@ export class AgentManager extends EventEmitter {
911
921
  }
912
922
  }
913
923
  // Appended system prompts last only for the process they're passed to, so they go on every launch.
914
- return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents };
924
+ return { extra: engine.extraArgs(this.config), permissionMode: agent.permissionMode, model: agent.model, writableDir, instructions: prompts.join('\n\n') || null, agents, allowedTools };
915
925
  }
916
926
 
917
927
  /** Spawn the agent's engine in a PTY, reusing its terminal clients and scrollback. */
@@ -1235,6 +1245,14 @@ export class AgentManager extends EventEmitter {
1235
1245
  if ('model' in fields) allowed.model = String(fields.model ?? '').trim() || null;
1236
1246
  // Keep: clean up never closes or deletes it.
1237
1247
  if ('keep' in fields) allowed.keep = Boolean(fields.keep);
1248
+ if ('budgetUsd' in fields) {
1249
+ // Its own spending limit in US dollars; empty for the default (budgets.perAgentUsd).
1250
+ const raw = fields.budgetUsd;
1251
+ const b = raw === null || raw === '' ? null : Number(raw);
1252
+ if (b !== null && !(Number.isFinite(b) && b > 0)) throw Object.assign(new Error('budgetUsd must be a positive number of dollars, or empty'), { status: 400 });
1253
+ allowed.budgetUsd = b === null ? null : Math.round(b * 100) / 100;
1254
+ allowed.budgetHit = null;
1255
+ }
1238
1256
  if ('priority' in fields) {
1239
1257
  const p = parsePriority(fields.priority, null);
1240
1258
  if (p === null) throw Object.assign(new Error('priority must be P0, P1, P2 or P3'), { status: 400 });
@@ -1971,80 +1989,93 @@ export class AgentManager extends EventEmitter {
1971
1989
 
1972
1990
  async refresh(ref, { withPrs = false } = {}) {
1973
1991
  const id = this.mustGet(ref).id;
1974
- if (this.refreshing.has(id)) return this.view(this.store.get(id));
1975
- this.refreshing.add(id);
1992
+ const running = this.refreshing.get(id);
1993
+ if (running) {
1994
+ // A pull request check asked for during a plain refresh runs right after it.
1995
+ if (!withPrs || running.withPrs) return running.promise.then(() => this.store.get(id) && this.view(this.store.get(id)));
1996
+ await running.promise.catch(() => {});
1997
+ return this.refresh(id, { withPrs });
1998
+ }
1999
+ const promise = this.doRefresh(id, { withPrs });
2000
+ this.refreshing.set(id, { promise, withPrs });
1976
2001
  try {
1977
- this.reloadBrief(id);
1978
- const agent = this.store.get(id);
1979
- // Before handoff, a planning session's transcript lives in the planning folder.
1980
- const planningOnly = agent?.planning && !agent.planning.handedOffAt && !agent.workdir;
1981
- const sessionDir = planningOnly ? agent.planning.dir : agent?.workdir;
1982
- if (!sessionDir) return agent && this.view(agent);
1983
- let changed = agent.workdir ? this.adoptSessions(agent) : false;
1984
-
1985
- const info = agent.workdir ? await git.gitInfo(agent.workdir) : agent.git;
1986
- if (!this.store.get(id)) return null;
1987
- if (info !== undefined && JSON.stringify({ ...info, updatedAt: 0 }) !== JSON.stringify({ ...agent.git, updatedAt: 0 })) {
1988
- agent.git = info;
2002
+ return await promise;
2003
+ } finally {
2004
+ this.refreshing.delete(id);
2005
+ }
2006
+ }
2007
+
2008
+ async doRefresh(id, { withPrs }) {
2009
+ this.reloadBrief(id);
2010
+ const agent = this.store.get(id);
2011
+ // Before handoff, a planning session's transcript lives in the planning folder.
2012
+ const planningOnly = agent?.planning && !agent.planning.handedOffAt && !agent.workdir;
2013
+ const sessionDir = planningOnly ? agent.planning.dir : agent?.workdir;
2014
+ if (!sessionDir) return agent && this.view(agent);
2015
+ let changed = agent.workdir ? this.adoptSessions(agent) : false;
2016
+
2017
+ const info = agent.workdir ? await git.gitInfo(agent.workdir) : agent.git;
2018
+ if (!this.store.get(id)) return null;
2019
+ if (info !== undefined && JSON.stringify({ ...info, updatedAt: 0 }) !== JSON.stringify({ ...agent.git, updatedAt: 0 })) {
2020
+ agent.git = info;
2021
+ changed = true;
2022
+ }
2023
+
2024
+ const engine = engineOf(agent);
2025
+ for (const session of agent.sessions) {
2026
+ const file = engine.sessionFile(sessionDir, session.id);
2027
+ const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
2028
+ const cursor = this.cursors.get(key) ?? { offset: 0 };
2029
+ this.cursors.set(key, cursor);
2030
+ const res = engine.scan(file, cursor, this.config.bugPatterns);
2031
+ const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
2032
+ const c = this.cursors.get(sub) ?? { offset: 0 };
2033
+ this.cursors.set(sub, c);
2034
+ engine.scan(sub, c);
2035
+ return c.usage;
2036
+ });
2037
+ const usage = sumUsage(cursor.usage, ...subMaps);
2038
+ if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
2039
+ session.usage = usage;
1989
2040
  changed = true;
1990
2041
  }
1991
-
1992
- const engine = engineOf(agent);
1993
- for (const session of agent.sessions) {
1994
- const file = engine.sessionFile(sessionDir, session.id);
1995
- const key = `${session.id}:${file}`; // the transcript moves at handoff; rescan the new copy
1996
- const cursor = this.cursors.get(key) ?? { offset: 0 };
1997
- this.cursors.set(key, cursor);
1998
- const res = engine.scan(file, cursor, this.config.bugPatterns);
1999
- const subMaps = engine.subagentFiles(sessionDir, session.id).map((sub) => {
2000
- const c = this.cursors.get(sub) ?? { offset: 0 };
2001
- this.cursors.set(sub, c);
2002
- engine.scan(sub, c);
2003
- return c.usage;
2004
- });
2005
- const usage = sumUsage(cursor.usage, ...subMaps);
2006
- if (usage.requests && JSON.stringify(session.usage) !== JSON.stringify(usage)) {
2007
- session.usage = usage;
2042
+ for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
2043
+ if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
2044
+ session[key] = res[key];
2008
2045
  changed = true;
2009
2046
  }
2010
- for (const key of ['title', 'lastPrompt', 'cost', 'lastActivity', 'lastMessageAt']) {
2011
- if (res[key] != null && JSON.stringify(session[key]) !== JSON.stringify(res[key])) {
2012
- session[key] = res[key];
2013
- changed = true;
2014
- }
2015
- }
2016
- const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
2017
- changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
2018
- changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
2019
2047
  }
2020
-
2021
- if (withPrs) {
2022
- this.lastPrCheck.set(id, Date.now());
2023
- // A shared worktree's pull requests belong to the agent that owns it.
2024
- const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
2025
- changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
2026
- // Fill in title/state for PRs we only know by URL, and keep open ones current.
2027
- const stale = Object.values(agent.prs).filter(
2028
- (p) => p.source !== 'branch' && (!p.state || p.state === 'OPEN'),
2029
- );
2030
- for (const pr of stale.slice(0, 10)) {
2031
- const d = await prDetails(pr.url);
2032
- if (d) changed = this.mergeLinks(agent, agent.prs, [{ ...d, source: pr.source }], (p) => p.url) || changed;
2033
- }
2048
+ const prs = res.prs.map((p) => ({ ...p, sessionId: session.id }));
2049
+ changed = this.mergeLinks(agent, agent.prs, prs, (p) => p.url) || changed;
2050
+ changed = this.mergeLinks(agent, agent.bugs, res.bugs, (b) => b.key) || changed;
2051
+ }
2052
+
2053
+ if (withPrs) {
2054
+ this.lastPrCheck.set(id, Date.now());
2055
+ // A shared worktree's pull requests belong to the agent that owns it.
2056
+ const branchPrs = agent.sharedWith ? [] : await prsForBranch(agent.workdir, info?.branch);
2057
+ changed = this.mergeLinks(agent, agent.prs, branchPrs, (p) => p.url) || changed;
2058
+ // Fill in title/state for PRs we only know by URL, and keep open ones current.
2059
+ const stale = Object.values(agent.prs).filter(
2060
+ (p) => p.source !== 'branch' && (!p.state || p.state === 'OPEN'),
2061
+ );
2062
+ for (const pr of stale.slice(0, 10)) {
2063
+ const d = await prDetails(pr.url);
2064
+ if (d) changed = this.mergeLinks(agent, agent.prs, [{ ...d, source: pr.source }], (p) => p.url) || changed;
2034
2065
  }
2066
+ // Landing reacts to failing checks and requested changes.
2067
+ this.emit('prs', agent);
2068
+ }
2035
2069
 
2036
- await this.updateChanges(agent);
2037
- this.updateStuck(agent, sessionDir);
2070
+ await this.updateChanges(agent);
2071
+ this.updateStuck(agent, sessionDir);
2038
2072
 
2039
- if (changed && this.store.get(id)) {
2040
- agent.updatedAt = now();
2041
- this.store.save();
2042
- this.changed(agent);
2043
- }
2044
- return this.view(agent);
2045
- } finally {
2046
- this.refreshing.delete(id);
2073
+ if (changed && this.store.get(id)) {
2074
+ agent.updatedAt = now();
2075
+ this.store.save();
2076
+ this.changed(agent);
2047
2077
  }
2078
+ return this.view(agent);
2048
2079
  }
2049
2080
 
2050
2081
  // ------------------------------------------------------ telling an agent
package/src/memory.js ADDED
@@ -0,0 +1,106 @@
1
+ // Shared memory per repository: what agents learned working in it (how to build and test,
2
+ // gotchas, where things are), so the next agent there starts with it instead of finding it out
3
+ // again. One Markdown file per repo under ~/.agentmanager/memory, read by every agent in that
4
+ // repo, added to with `agentmgr learn "<one line>"`, and editable by people in the UI.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { paths } from './config.js';
9
+ import { NO_REPO, projectOf } from './manager.js';
10
+
11
+ export const memoryText = (key) => {
12
+ try {
13
+ return fs.readFileSync(memoryFile(key), 'utf8');
14
+ } catch {
15
+ return '';
16
+ }
17
+ };
18
+
19
+ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'bin', 'agentmgr.js');
20
+ export const MAX_BYTES = 16_000;
21
+ const err = (status, message) => Object.assign(new Error(message), { status });
22
+
23
+ /** The repo an agent works in, ignoring where it is listed (moved or custom project). */
24
+ export const memoryKey = (agent) => {
25
+ const key = projectOf({ ...agent, projectMove: null, project: null }).key;
26
+ if (!key.startsWith('dir:')) return key;
27
+ // The same folder by any path (macOS /var is /private/var) is one repo.
28
+ try {
29
+ return `dir:${fs.realpathSync(key.slice(4))}`;
30
+ } catch {
31
+ return key;
32
+ }
33
+ };
34
+ export const memoryFile = (key) => path.join(paths.home, 'memory', `${String(key).replace(/[^\w.-]+/g, '_').slice(0, 120)}.md`);
35
+
36
+ export const learnCommand = (bin = `node ${BIN}`) => `${bin} learn`;
37
+
38
+ /** What earlier agents learned here (inlined, so no file access is needed), and how to add to it. */
39
+ export function memoryPrompt(text, bin = `node ${BIN}`) {
40
+ const known = String(text ?? '').trim();
41
+ return [
42
+ known
43
+ ? `What earlier agents learned working in this repository (shared memory; trust it, but check anything that looks out of date):\n${known}`
44
+ : 'There is a shared memory for this repository, empty so far.',
45
+ `When you learn something non-obvious that would save the next agent in this repo real time (how to build or run the tests, a gotcha, where something lives, a convention that isn't written down), and it isn't already above or in CLAUDE.md or the README, record it as one short line: ${learnCommand(bin)} "<the learning>". ` +
46
+ 'Record facts about the repo, not about your task; never secrets, tokens or personal data.',
47
+ ].join('\n\n');
48
+ }
49
+
50
+ /** One line, trimmed, without Markdown that could break the list. */
51
+ export function cleanLearning(text) {
52
+ const t = String(text ?? '').replace(/\s+/g, ' ').replace(/^[-*#>\s]+/, '').trim();
53
+ if (!t) throw err(400, 'Nothing to learn: give one line of text');
54
+ if (t.length > 400) throw err(400, 'Keep a learning to one short line (400 characters at most)');
55
+ return t;
56
+ }
57
+
58
+ export class Memory {
59
+ constructor({ manager, config = {} }) {
60
+ this.manager = manager;
61
+ this.config = config;
62
+ }
63
+
64
+ get enabled() {
65
+ return this.config.memory !== false;
66
+ }
67
+
68
+ agentKey(ref) {
69
+ const agent = this.manager.mustGet(ref);
70
+ if (NO_REPO.has(agent.kind)) throw err(400, 'This agent has no repository');
71
+ return { agent, key: memoryKey(agent) };
72
+ }
73
+
74
+ read(ref) {
75
+ const { key } = this.agentKey(ref);
76
+ const file = memoryFile(key);
77
+ const text = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
78
+ return { key, file, text, entries: text.split('\n').filter((l) => l.startsWith('- ')).length, bytes: Buffer.byteLength(text), max: MAX_BYTES };
79
+ }
80
+
81
+ write(ref, text) {
82
+ const { key } = this.agentKey(ref);
83
+ text = String(text ?? '');
84
+ if (Buffer.byteLength(text) > MAX_BYTES) throw err(413, `Keep the memory under ${MAX_BYTES / 1000} KB; it is read by every agent in the repo`);
85
+ const file = memoryFile(key);
86
+ fs.mkdirSync(path.dirname(file), { recursive: true });
87
+ if (!text.trim()) fs.rmSync(file, { force: true });
88
+ else fs.writeFileSync(file, text.endsWith('\n') ? text : `${text}\n`);
89
+ return this.read(ref);
90
+ }
91
+
92
+ /** Append one learning from an agent (or a person), skipping one already there. */
93
+ learn(ref, text, { by } = {}) {
94
+ const { agent, key } = this.agentKey(ref);
95
+ const line = cleanLearning(text);
96
+ const file = memoryFile(key);
97
+ const cur = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : `# What agents learned in ${projectOf(agent).full ?? projectOf(agent).name}\n\n`;
98
+ const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
99
+ if (cur.split('\n').some((l) => l.startsWith('- ') && norm(l.replace(/ \([^)]*\)$/, '')).includes(norm(line)))) return { ...this.read(ref), added: false };
100
+ const entry = `- ${line} (${by ?? agent.name}, ${new Date().toISOString().slice(0, 10)})\n`;
101
+ if (Buffer.byteLength(cur + entry) > MAX_BYTES) throw err(413, 'The repo memory is full; ask the person to tidy it (Memory on the agent)');
102
+ fs.mkdirSync(path.dirname(file), { recursive: true });
103
+ fs.writeFileSync(file, cur.endsWith('\n') ? cur + entry : `${cur}\n${entry}`);
104
+ return { ...this.read(ref), added: true };
105
+ }
106
+ }
package/src/notify.js CHANGED
@@ -16,7 +16,8 @@ export const EVENT_DEFAULTS = {
16
16
  stuck: true, // going in circles: same failure, undoing its edits, or long stretches without changes
17
17
  conflict: true, // changing the same files as another agent in the same repo
18
18
  question: true, // asked a question in the decision inbox
19
- assistant: true, // the assistant started an agent for new work
19
+ assistant: true, // an agent was started for new work (assistant, GitHub label, webhook)
20
+ budget: true, // an agent neared or reached its budget, or today's spend reached the daily limit
20
21
  };
21
22
 
22
23
  // Alerts the AI judge may hold for the digest or drop; the rest are always sent at once.
@@ -265,7 +266,7 @@ export class Notifier {
265
266
 
266
267
  deliver(kind, agent, what, n, urgency = null) {
267
268
  if (!n.enabled || !n.events[kind]) return;
268
- const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:', assistant: ':robot_face:' }[kind];
269
+ const icon = { needsInput: ':raised_hand:', review: ':white_check_mark:', error: ':x:', waiting: ':hourglass_flowing_sand:', blocked: ':no_entry:', stuck: ':repeat:', conflict: ':crossed_swords:', question: ':question:', assistant: ':robot_face:', budget: ':moneybag:' }[kind];
269
270
  const project = agent.projectInfo?.name ? ` (${agent.projectInfo.name})` : '';
270
271
  const link = this.linkFor(agent.id);
271
272
  const intent = n.includeIntent && agent.intent ? `\n> ${agent.intent.replace(/\s+/g, ' ').slice(0, 200)}` : '';
@@ -0,0 +1,178 @@
1
+ // Playbooks: saved requests you run again, by hand or on a schedule. One playbook is a request
2
+ // (with {{placeholders}} filled in at run time), the projects to run it in (one agent each), and
3
+ // optionally a model, permission mode and schedule: "upgrade dependency X in every repo",
4
+ // "every weekday at 9:00, triage the flaky tests".
5
+ import crypto from 'node:crypto';
6
+
7
+ const err = (status, message) => Object.assign(new Error(message), { status });
8
+ export const EVERY = ['hour', 'day', 'weekday', 'week'];
9
+ const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
10
+
11
+ /** "{{name}}" placeholders in a request, in order, once each. */
12
+ export function placeholders(text) {
13
+ return [...new Set([...String(text ?? '').matchAll(/\{\{\s*([\w-]+)\s*\}\}/g)].map((m) => m[1]))];
14
+ }
15
+
16
+ export function render(text, vars = {}) {
17
+ return String(text).replace(/\{\{\s*([\w-]+)\s*\}\}/g, (m, k) => (vars[k] != null && String(vars[k]).trim() ? String(vars[k]).trim() : m));
18
+ }
19
+
20
+ /** A schedule, checked: { every: hour|day|weekday|week, at: "HH:MM", day: "mon".."sun" } or null. */
21
+ export function cleanSchedule(s) {
22
+ if (!s || !s.every) return null;
23
+ if (!EVERY.includes(s.every)) throw err(400, `schedule.every must be one of ${EVERY.join(', ')}`);
24
+ const at = String(s.at ?? '09:00').trim();
25
+ if (!/^([01]?\d|2[0-3]):[0-5]\d$/.test(at)) throw err(400, 'schedule.at must be HH:MM');
26
+ const day = s.every === 'week' ? String(s.day ?? 'mon').toLowerCase().slice(0, 3) : undefined;
27
+ if (day !== undefined && !DAYS.includes(day)) throw err(400, 'schedule.day must be mon..sun');
28
+ const [h, m] = at.split(':').map(Number);
29
+ return { every: s.every, at: `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`, ...(day ? { day } : {}) };
30
+ }
31
+
32
+ /** The first time the schedule fires strictly after `after` (local time). */
33
+ export function nextRun(schedule, after = new Date()) {
34
+ if (!schedule) return null;
35
+ const [h, m] = schedule.at.split(':').map(Number);
36
+ const t = new Date(after);
37
+ if (schedule.every === 'hour') {
38
+ t.setSeconds(0, 0);
39
+ t.setMinutes(m);
40
+ if (t <= after) t.setHours(t.getHours() + 1);
41
+ return t;
42
+ }
43
+ t.setHours(h, m, 0, 0);
44
+ for (let i = 0; i < 9; i += 1) {
45
+ const ok =
46
+ t > after &&
47
+ (schedule.every === 'day' ||
48
+ (schedule.every === 'weekday' && t.getDay() >= 1 && t.getDay() <= 5) ||
49
+ (schedule.every === 'week' && DAYS[t.getDay()] === schedule.day));
50
+ if (ok) return t;
51
+ t.setDate(t.getDate() + 1);
52
+ }
53
+ return null;
54
+ }
55
+
56
+ export class Playbooks {
57
+ constructor({ manager, store, log = (m) => console.log(`[agentmgr] ${m}`) }) {
58
+ this.manager = manager;
59
+ this.store = store;
60
+ this.log = log;
61
+ store.data.playbooks ??= {};
62
+ this.timer = setInterval(() => this.tick().catch((e) => this.log(`playbooks: ${e.message}`)), 60_000);
63
+ this.timer.unref?.();
64
+ }
65
+
66
+ close() {
67
+ clearInterval(this.timer);
68
+ }
69
+
70
+ get all() {
71
+ return this.store.data.playbooks;
72
+ }
73
+
74
+ view(p) {
75
+ return { ...p, placeholders: placeholders(p.intent), nextRunAt: p.enabled && p.schedule ? nextRun(p.schedule, new Date(Math.max(Date.now(), Date.parse(p.lastRunAt ?? 0) || 0)))?.toISOString() ?? null : null };
76
+ }
77
+
78
+ list() {
79
+ return Object.values(this.all)
80
+ .map((p) => this.view(p))
81
+ .sort((a, b) => a.name.localeCompare(b.name));
82
+ }
83
+
84
+ get(id) {
85
+ const p = this.all[id] ?? Object.values(this.all).find((x) => x.name.toLowerCase() === String(id).toLowerCase());
86
+ if (!p) throw err(404, `No playbook "${id}"`);
87
+ return p;
88
+ }
89
+
90
+ clean(input, prev = {}) {
91
+ const name = String(input.name ?? prev.name ?? '').trim().slice(0, 80);
92
+ if (!name) throw err(400, 'A name is required');
93
+ const intent = String(input.intent ?? prev.intent ?? '').trim();
94
+ if (!intent) throw err(400, 'The request is required');
95
+ const projects = (Array.isArray(input.projects) ? input.projects : String(input.projects ?? '').split(/[\s,]+/)).map((x) => String(x).trim().replace(/^@/, '')).filter(Boolean);
96
+ const list = input.projects === undefined ? prev.projects ?? [] : projects;
97
+ for (const p of list) if (!this.manager.resolveProject(p)) throw err(400, `No project "${p}" (agentmgr project ls)`);
98
+ if (!list.length) throw err(400, 'Pick at least one project to run it in');
99
+ const schedule = input.schedule === undefined ? prev.schedule ?? null : cleanSchedule(input.schedule);
100
+ if (schedule && placeholders(intent).some((k) => !(input.defaults ?? prev.defaults ?? {})[k])) {
101
+ throw err(400, 'A scheduled playbook needs a default value for each {{placeholder}}');
102
+ }
103
+ return {
104
+ name,
105
+ intent,
106
+ projects: list,
107
+ model: (input.model ?? prev.model ?? '') || null,
108
+ permissionMode: (input.permissionMode ?? prev.permissionMode ?? '') || null,
109
+ priority: input.priority ?? prev.priority ?? 2,
110
+ defaults: input.defaults ?? prev.defaults ?? {},
111
+ schedule,
112
+ enabled: input.enabled ?? prev.enabled ?? true,
113
+ };
114
+ }
115
+
116
+ create(input = {}) {
117
+ const p = { id: crypto.randomBytes(3).toString('hex'), ...this.clean(input), createdAt: new Date().toISOString(), runs: [] };
118
+ if (Object.values(this.all).some((x) => x.name.toLowerCase() === p.name.toLowerCase())) throw err(409, `A playbook named "${p.name}" exists`);
119
+ this.all[p.id] = p;
120
+ this.store.save();
121
+ return this.view(p);
122
+ }
123
+
124
+ update(id, input = {}) {
125
+ const p = this.get(id);
126
+ Object.assign(p, this.clean(input, p));
127
+ this.store.save();
128
+ return this.view(p);
129
+ }
130
+
131
+ remove(id) {
132
+ const p = this.get(id);
133
+ delete this.all[p.id];
134
+ this.store.save();
135
+ return { ok: true };
136
+ }
137
+
138
+ /** One agent per project, with the placeholders filled in. */
139
+ run(id, { vars = {}, by = 'person' } = {}) {
140
+ const p = this.get(id);
141
+ const values = { ...p.defaults, ...vars };
142
+ const intent = render(p.intent, values);
143
+ const missing = placeholders(intent);
144
+ if (missing.length) throw err(400, `Fill in ${missing.map((k) => `{{${k}}}`).join(', ')}`);
145
+ const run = { at: new Date().toISOString(), by, agents: [], failed: [] };
146
+ for (const project of p.projects) {
147
+ try {
148
+ const [a] = this.manager.createMany([intent], { project, model: p.model ?? undefined, permissionMode: p.permissionMode ?? undefined, priority: p.priority });
149
+ this.manager.patch(a.id, { playbook: { id: p.id, name: p.name, at: run.at } });
150
+ run.agents.push({ id: a.id, name: a.name, project });
151
+ } catch (e) {
152
+ run.failed.push({ project, reason: e.message });
153
+ }
154
+ }
155
+ p.lastRunAt = run.at;
156
+ p.runs = [...(p.runs ?? []), run].slice(-20);
157
+ this.store.save();
158
+ this.log(`playbooks: ran "${p.name}" (${by}): ${run.agents.length} agent(s)${run.failed.length ? `, ${run.failed.length} failed` : ''}`);
159
+ return run;
160
+ }
161
+
162
+ /** Run what is due. A run missed while the daemon was down runs once when it is back. */
163
+ async tick(now = new Date()) {
164
+ for (const p of Object.values(this.all)) {
165
+ if (!p.enabled || !p.schedule) continue;
166
+ const since = new Date(Date.parse(p.lastRunAt ?? p.createdAt) || now.getTime());
167
+ const due = nextRun(p.schedule, since);
168
+ if (!due || due > now) continue;
169
+ try {
170
+ this.run(p.id, { by: 'schedule' });
171
+ } catch (e) {
172
+ p.lastRunAt = now.toISOString();
173
+ p.runs = [...(p.runs ?? []), { at: p.lastRunAt, by: 'schedule', agents: [], failed: [{ project: '*', reason: e.message }] }].slice(-20);
174
+ this.store.save();
175
+ }
176
+ }
177
+ }
178
+ }