ai-agentmanager 1.3.1 → 1.5.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/cleanup.js CHANGED
@@ -5,11 +5,15 @@
5
5
  // and "off". Never touched: agents marked Keep, agents still setting up or working, members of a
6
6
  // team that is still going, and, for deleting, agents with an open pull request or with work
7
7
  // that exists only in their worktree (uncommitted changes or commits on no other branch).
8
+ // Optionally, a cap on open sessions ("maxRunning"): above it, the agents idle longest are closed
9
+ // (they resume when opened), so the machine stays responsive. The same agents are left alone,
10
+ // plus ones waiting for an answer and ones active in the last few minutes.
8
11
  import fs from 'node:fs';
9
12
  import * as git from './git.js';
10
13
 
11
14
  export const STRATEGIES = ['close-delete', 'close', 'manual', 'off'];
12
- export const CLEANUP_DEFAULTS = { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14 };
15
+ export const CLEANUP_DEFAULTS = { strategy: 'close', closeAfterDays: 14, deleteAfterDays: 14, maxRunning: null };
16
+ const CAP_GRACE_MS = 5 * 60_000;
13
17
  const DAY = 86_400_000;
14
18
  const TEAM_DONE = new Set(['done', 'stopped']);
15
19
 
@@ -24,6 +28,7 @@ export function cleanupSettings(config = {}) {
24
28
  strategy: STRATEGIES.includes(c.strategy) ? c.strategy : CLEANUP_DEFAULTS.strategy,
25
29
  closeAfterDays: n(c.closeAfterDays, CLEANUP_DEFAULTS.closeAfterDays),
26
30
  deleteAfterDays: n(c.deleteAfterDays, CLEANUP_DEFAULTS.deleteAfterDays),
31
+ maxRunning: Number(c.maxRunning) >= 1 ? Math.min(500, Math.floor(Number(c.maxRunning))) : null,
27
32
  };
28
33
  }
29
34
 
@@ -56,11 +61,70 @@ export class Cleanup {
56
61
  this.firstTimer.unref?.();
57
62
  this.timer = setInterval(() => this.sweep().catch((e) => this.log(`clean up: ${e.message}`)), (config.cleanupEveryMin ?? 360) * 60_000);
58
63
  this.timer.unref?.();
64
+ // The cap: checked soon after agents change (one starts) and every minute.
65
+ this.onChange = () => {
66
+ if (!this.settings.maxRunning || this.capSoon) return;
67
+ this.capSoon = setTimeout(() => {
68
+ this.capSoon = null;
69
+ this.enforceCap().catch((e) => this.log(`cap: ${e.message}`));
70
+ }, config.capDelayMs ?? 5000);
71
+ this.capSoon.unref?.();
72
+ };
73
+ manager.on?.('change', this.onChange);
74
+ this.capTimer = setInterval(() => this.enforceCap().catch((e) => this.log(`cap: ${e.message}`)), 60_000);
75
+ this.capTimer.unref?.();
59
76
  }
60
77
 
61
78
  close() {
62
79
  clearTimeout(this.firstTimer);
63
80
  clearInterval(this.timer);
81
+ clearTimeout(this.capSoon);
82
+ clearInterval(this.capTimer);
83
+ this.manager.off?.('change', this.onChange);
84
+ }
85
+
86
+ /** Open sessions that count toward the cap (not the assistant's). */
87
+ runningAgents() {
88
+ return this.store.list().filter((a) => a.kind !== 'assistant' && this.manager.runtimes.get(a.id)?.pty);
89
+ }
90
+
91
+ /** Which agents to close to get back under the cap: idle longest first, protected ones skipped. */
92
+ capPlan(now = Date.now()) {
93
+ const max = this.settings.maxRunning;
94
+ const running = this.runningAgents();
95
+ if (!max || running.length <= max) return { max, running: running.length, close: [] };
96
+ const activeAt = (a) => Date.parse(this.manager.activeAt(a, this.manager.runtimes.get(a.id)) ?? a.createdAt) || now;
97
+ const closable = running
98
+ .filter((a) => !this.protectedBy(a) && !this.manager.runtimes.get(a.id)?.attention && now - activeAt(a) >= (this.config.capGraceMs ?? CAP_GRACE_MS))
99
+ .sort((x, y) => activeAt(x) - activeAt(y));
100
+ return { max, running: running.length, close: closable.slice(0, running.length - max).map((a) => ({ id: a.id, name: a.name, idleMin: Math.floor((now - activeAt(a)) / 60_000) })) };
101
+ }
102
+
103
+ async enforceCap() {
104
+ if (this.capping) return null;
105
+ const plan = this.capPlan();
106
+ if (!plan.close.length) return null;
107
+ this.capping = true;
108
+ const closed = [];
109
+ try {
110
+ for (const row of plan.close) {
111
+ try {
112
+ await this.manager.stop(row.id);
113
+ this.manager.patch(row.id, { closedBy: 'cap' });
114
+ closed.push(row);
115
+ } catch (e) {
116
+ this.log(`cap: couldn't close ${row.name}: ${e.message}`);
117
+ }
118
+ }
119
+ } finally {
120
+ this.capping = false;
121
+ }
122
+ if (closed.length) {
123
+ this.log(`cap: ${plan.running} open, limit ${plan.max}; closed ${closed.map((r) => `${r.name} (idle ${r.idleMin} min)`).join(', ')}`);
124
+ this.store.data.cleanup.lastCap = { at: new Date().toISOString(), closed };
125
+ this.store.save();
126
+ }
127
+ return closed;
64
128
  }
65
129
 
66
130
  get settings() {
@@ -73,8 +137,13 @@ export class Cleanup {
73
137
  for (const k of ['closeAfterDays', 'deleteAfterDays']) {
74
138
  if (patch[k] !== undefined && !(Number(patch[k]) >= 1)) throw err(400, `${k} must be 1 or more`);
75
139
  }
140
+ // maxRunning: a number of open sessions, or empty/null/0 for no cap.
141
+ if (patch.maxRunning !== undefined && patch.maxRunning !== null && patch.maxRunning !== '' && Number(patch.maxRunning) !== 0 && !(Number(patch.maxRunning) >= 1)) {
142
+ throw err(400, 'maxRunning must be 1 or more, or empty for no cap');
143
+ }
76
144
  const next = cleanupSettings({ cleanup: { ...this.config.cleanup, ...patch } });
77
145
  this.config.cleanup = next;
146
+ if (next.maxRunning) this.onChange?.();
78
147
  return next;
79
148
  }
80
149
 
@@ -181,6 +250,6 @@ export class Cleanup {
181
250
 
182
251
  async status() {
183
252
  const s = this.settings;
184
- return { ...s, ...(await this.plan()), lastRun: this.store.data.cleanup.lastRun ?? null };
253
+ return { ...s, ...(await this.plan()), running: this.runningAgents().length, lastRun: this.store.data.cleanup.lastRun ?? null, lastCap: this.store.data.cleanup.lastCap ?? null };
185
254
  }
186
255
  }
package/src/cli.js CHANGED
@@ -404,9 +404,9 @@ export async function main(argv) {
404
404
 
405
405
  program
406
406
  .command('doc')
407
- .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal or humanai (Human vs AI)')
407
+ .description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI) or diagrams (diagrams only)')
408
408
  .argument('<agent>')
409
- .argument('<kind>', 'analysis, design, flow, proposal, humanai, or all (background)')
409
+ .argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, or all (background; diagrams not included)')
410
410
  .option('-o, --output <file>', 'also save the Markdown to this file')
411
411
  .option('--cached', 'print the last version instead of writing a new one')
412
412
  .option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
@@ -1346,9 +1346,11 @@ export async function main(argv) {
1346
1346
  .option('--strategy <s>', 'close-delete (both automatically), close (close automatically, delete by hand), manual, or off')
1347
1347
  .option('--close-after <days>', 'close sessions idle this many days')
1348
1348
  .option('--delete-after <days>', 'delete agents closed this many days')
1349
+ .option('--max-open <n>', 'keep at most this many agents open; the ones idle longest are closed above it ("off": no limit)')
1349
1350
  .action(async (opts) => {
1350
1351
  await daemon();
1351
1352
  const patch = {
1353
+ ...(opts.maxOpen ? { maxRunning: /^(off|none|0)$/i.test(opts.maxOpen) ? null : Number(opts.maxOpen) } : {}),
1352
1354
  ...(opts.strategy ? { strategy: opts.strategy } : {}),
1353
1355
  ...(opts.closeAfter ? { closeAfterDays: Number(opts.closeAfter) } : {}),
1354
1356
  ...(opts.deleteAfter ? { deleteAfterDays: Number(opts.deleteAfter) } : {}),
@@ -1365,6 +1367,8 @@ export async function main(argv) {
1365
1367
  const c = await api('GET', '/cleanup');
1366
1368
  const what = { 'close-delete': 'closes and deletes automatically', close: 'closes automatically; delete with --run', manual: 'nothing automatic; use --run', off: 'off' }[c.strategy];
1367
1369
  console.log(`${bold('Clean up')}: ${c.strategy} (${what}) · close after ${c.closeAfterDays} days idle · delete ${c.deleteAfterDays} days after closing`);
1370
+ console.log(`${bold('Open agents')}: ${c.running} open · ${c.maxRunning ? `at most ${c.maxRunning} (the ones idle longest close above it)` : 'no limit (set one: --max-open <n>)'}`);
1371
+ if (c.lastCap) console.log(dim(` last closed for the limit ${new Date(c.lastCap.at).toLocaleString()}: ${c.lastCap.closed.map((x) => x.name).join(', ')}`));
1368
1372
  const rows = [
1369
1373
  ...c.close.map((x) => [yellow('close'), x.name, `idle ${x.idleDays}d`, '']),
1370
1374
  ...c.delete.map((x) => [red('delete'), x.name, `closed ${x.closedDays}d`, '']),
@@ -1691,6 +1695,19 @@ export async function main(argv) {
1691
1695
  .option('--url <url>', 'hub address')
1692
1696
  .action((ref, opts) => fleetAct(opts, ref, action, {}, (a, r) => `${action === 'start' ? 'starting' : 'stopping'} ${r.name}/${a.name}`));
1693
1697
  }
1698
+ fleetCmd
1699
+ .command('move')
1700
+ .description('Move an agent to another machine and continue it there: same conversation, branch and uncommitted work (both machines need "move")')
1701
+ .argument('<agent>', 'machine/agent (or a unique agent id or name)')
1702
+ .requiredOption('--to <machine>', 'the machine to move it to')
1703
+ .option('--url <url>', 'hub address')
1704
+ .action(async (ref, opts) => {
1705
+ const f = await hubApi(opts, 'GET', '/fleet');
1706
+ const { runner, agent } = resolveFleetAgent(f, ref);
1707
+ console.log(dim(`moving ${runner.name}/${agent.name} to ${opts.to}…`));
1708
+ const out = await hubApi(opts, 'POST', `/runners/${runner.id}/agents/${agent.id}/move`, { to: opts.to });
1709
+ console.log(`${green('✓')} ${agent.name} now runs on ${out.to.machine} as ${out.to.agent}${out.agent?.status ? ` (${out.agent.status})` : ''}`);
1710
+ });
1694
1711
  fleetCmd
1695
1712
  .command('new')
1696
1713
  .description('Start a new agent on another machine, in one of its projects (needs "create")')
@@ -2003,10 +2020,21 @@ export async function main(argv) {
2003
2020
  console.log(`${green('✓')} saved ${r.agents} agents (${r.running} running), scrollback for ${r.scrollback}, at ${r.at}`);
2004
2021
  });
2005
2022
 
2023
+ // The daemon's own terminals (agent shells, Open Terminal, the agents themselves) close when it
2024
+ // stops, and the hangup would end a restart typed there halfway: daemon stopped, never started.
2025
+ // So a restart from one ignores the hangup and its closed output, and finishes without it.
2026
+ const outliveTerminal = () => {
2027
+ if (!process.env.AGENTMGR_TERMINAL) return;
2028
+ console.log(dim('This terminal belongs to the daemon and closes while it restarts; the restart carries on. Reopen the terminal afterwards.'));
2029
+ process.on('SIGHUP', () => {});
2030
+ for (const out of [process.stdout, process.stderr]) out.on('error', () => {});
2031
+ };
2032
+
2006
2033
  program
2007
2034
  .command('restart')
2008
2035
  .description('Flush, stop the daemon, and start it again; running agents resume their sessions')
2009
2036
  .action(async () => {
2037
+ outliveTerminal();
2010
2038
  const svc = await import('./service.js');
2011
2039
  const before = await health();
2012
2040
  if (before && !before.closing) {
@@ -2038,6 +2066,33 @@ export async function main(argv) {
2038
2066
  console.log(`${green('✓')} restarted${st.loaded ? ' (login service)' : ''}; ${agents.filter((a) => a.desired === 'running').length} agents set to resume`);
2039
2067
  });
2040
2068
 
2069
+ program
2070
+ .command('update')
2071
+ .description('Install the newest release and restart onto it (agents resume); a git checkout moves to the newest release tag')
2072
+ .option('--check', 'only say whether a newer release exists')
2073
+ .option('--auto-check <on|off>', 'whether the daemon checks for a new release twice a day (shown in the app)')
2074
+ .action(async (opts) => {
2075
+ const up = await import('./update.js');
2076
+ if (opts.autoCheck) {
2077
+ const on = /^(on|true|yes|1)$/i.test(opts.autoCheck);
2078
+ if ((await health())?.pid) await api('PUT', '/update', { check: on });
2079
+ else (await import('./config.js')).saveConfig({ updates: { check: on } });
2080
+ return console.log(`Update checks ${on ? green('on') : dim('off')}`);
2081
+ }
2082
+ const info = await up.checkUpdate();
2083
+ if (!info.newer) return console.log(`${green('✓')} ${info.current} is the newest release`);
2084
+ if (info.blocker) throw new Error(`${info.latest} is available, but: ${info.blocker}`);
2085
+ if (opts.check) return console.log(`${yellow(info.latest)} is available (you have ${info.current}); install it: agentmgr update`);
2086
+ const r = await up.installUpdate({ log: (m) => console.log(dim(m)) });
2087
+ console.log(`${green('✓')} installed ${r.to} (was ${r.from})`);
2088
+ if (!(await health())) return console.log(dim('The daemon is not running; it starts on the new version next time'));
2089
+ outliveTerminal();
2090
+ // The new version's own restart, not the copy of it loaded in this process.
2091
+ const { spawnSync } = await import('node:child_process');
2092
+ const res = spawnSync(process.execPath, [process.argv[1], 'restart'], { stdio: 'inherit' });
2093
+ if (res.status !== 0) process.exitCode = res.status ?? 1;
2094
+ });
2095
+
2041
2096
  program
2042
2097
  .command('shutdown')
2043
2098
  .description('Stop the daemon (running agents are closed; sessions are kept)')
@@ -5,8 +5,9 @@ import path from 'node:path';
5
5
  import antigravity from './antigravity.js';
6
6
  import claude from './claude.js';
7
7
  import codex from './codex.js';
8
+ import muse from './muse.js';
8
9
 
9
- export const ENGINES = { claude, codex, antigravity };
10
+ export const ENGINES = { claude, codex, antigravity, muse };
10
11
  export const DEFAULT_ENGINE = 'claude';
11
12
 
12
13
  /** Adds an engine from a plugin. Built-in engines can't be replaced. */
@@ -33,7 +34,14 @@ export function notInstalled(engine, config) {
33
34
 
34
35
  /** Every engine, with whether it can run here, for pickers and the hub. */
35
36
  export function listEngines(config) {
36
- return Object.values(ENGINES).map((e) => ({ id: e.id, label: e.label, installed: installed(e, config), permissionModes: e.permissionModes, supports: e.supports }));
37
+ return Object.values(ENGINES).map((e) => {
38
+ const ok = installed(e, config);
39
+ let notice = null;
40
+ try {
41
+ notice = ok ? (e.notice?.(config) ?? null) : null;
42
+ } catch {}
43
+ return { id: e.id, label: e.label, installed: ok, permissionModes: e.permissionModes, supports: e.supports, notice };
44
+ });
37
45
  }
38
46
 
39
47
  /** The engine for an agent (or an engine id). */
@@ -0,0 +1,303 @@
1
+ // Meta's Muse Code CLI (`muse`) as an agent engine (checked against Muse Code 1.4.14; fixtures in
2
+ // test/fixtures/muse). Differences from Claude Code that shape this adapter:
3
+ // - Muse picks its own session IDs; the manager adopts the one it creates. Sessions live in
4
+ // $XDG_DATA_HOME/muse/sessions/YYYY/MM/DD/<id>/session.jsonl, and the runtime.session.metadata
5
+ // record near the top holds the folder it ran in (workspace_root).
6
+ // - `muse resume <id>` takes no message, so a message for a resumed session is typed once it
7
+ // is idle (supports.resumePrompt: false).
8
+ // - No flag for extra instructions: they go in the first message of a new session.
9
+ // - The log is Muse's own event log; this reads only the run events (prompt, reply, usage).
10
+ // Tool calls aren't read yet, so stuck detection has no commands or edits for Muse agents.
11
+ // - Approval questions ("Would you like to run the following command?") are matched from the
12
+ // wording in the binary; the trust question was seen on a real screen.
13
+ import fs from 'node:fs';
14
+ import os from 'node:os';
15
+ import path from 'node:path';
16
+ import { extractRefs } from '../tracker.js';
17
+
18
+ // MUSE_DATA_HOME is AI Agent Manager's own override (tests); Muse itself uses the XDG folder.
19
+ const dataHome = () => process.env.MUSE_DATA_HOME || path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'muse');
20
+ const sessionsDir = () => path.join(dataHome(), 'sessions');
21
+ const configHome = () => path.join(process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config'), 'muse');
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
23
+ const real = (p) => {
24
+ try {
25
+ return fs.realpathSync(p);
26
+ } catch {
27
+ return path.resolve(p);
28
+ }
29
+ };
30
+ const ANSI = /\x1b(\[[0-9;?<>=]*[ -\/]*[@-~]|\][^\x07\x1b]*(\x07|\x1b\\)|[()][0-9A-B]|[=>78c])/g;
31
+ const readdir = (d) => {
32
+ try {
33
+ return fs.readdirSync(d);
34
+ } catch {
35
+ return [];
36
+ }
37
+ };
38
+
39
+ /** Day folders from `sinceMs` to today (sessions are filed by their start date). */
40
+ function dayDirs(sinceMs) {
41
+ const out = [];
42
+ const d = new Date(Math.min(sinceMs, Date.now()));
43
+ d.setHours(0, 0, 0, 0);
44
+ for (let i = 0; i < 400 && d.getTime() <= Date.now(); i++, d.setDate(d.getDate() + 1)) {
45
+ out.push(path.join(sessionsDir(), String(d.getFullYear()), String(d.getMonth() + 1).padStart(2, '0'), String(d.getDate()).padStart(2, '0')));
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /** The folder a session ran in: workspace_root in its runtime.session.metadata record (one of the first lines). */
51
+ function sessionCwd(file) {
52
+ try {
53
+ const fd = fs.openSync(file, 'r');
54
+ try {
55
+ const buf = Buffer.alloc(256 * 1024);
56
+ const n = fs.readSync(fd, buf, 0, buf.length, 0);
57
+ for (const line of buf.subarray(0, n).toString('utf8').split('\n')) {
58
+ if (!line.includes('"runtime.session.metadata"')) continue;
59
+ const m = /"(?:workspace_root|cwd)"\s*:\s*("(?:[^"\\]|\\.)*")/.exec(line);
60
+ return m ? JSON.parse(m[1]) : null;
61
+ }
62
+ return null;
63
+ } finally {
64
+ fs.closeSync(fd);
65
+ }
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ const found = new Map(); // session id -> file
72
+ function findSession(sessionId) {
73
+ if (!UUID.test(String(sessionId))) return null;
74
+ const hit = found.get(sessionId);
75
+ if (hit && fs.existsSync(hit)) return hit;
76
+ // Newest days first; a session resumed today is still filed under the day it started.
77
+ for (const y of readdir(sessionsDir()).filter((n) => /^\d{4}$/.test(n)).sort().reverse()) {
78
+ for (const m of readdir(path.join(sessionsDir(), y)).sort().reverse()) {
79
+ for (const d of readdir(path.join(sessionsDir(), y, m)).sort().reverse()) {
80
+ const file = path.join(sessionsDir(), y, m, d, sessionId, 'session.jsonl');
81
+ if (fs.existsSync(file)) {
82
+ found.set(sessionId, file);
83
+ return file;
84
+ }
85
+ }
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /** Read the complete lines added since the cursor's offset. */
92
+ function newRecords(file, cursor) {
93
+ let fd;
94
+ try {
95
+ fd = fs.openSync(file, 'r');
96
+ } catch {
97
+ return null;
98
+ }
99
+ try {
100
+ const { size, mtime } = fs.fstatSync(fd);
101
+ cursor.lastActivity = mtime.toISOString();
102
+ if (size < cursor.offset) cursor.offset = 0;
103
+ if (size === cursor.offset) return [];
104
+ const buf = Buffer.alloc(size - cursor.offset);
105
+ fs.readSync(fd, buf, 0, buf.length, cursor.offset);
106
+ const lastNl = buf.lastIndexOf(0x0a);
107
+ if (lastNl < 0) return [];
108
+ const start = cursor.offset;
109
+ cursor.offset += lastNl + 1;
110
+ const out = [];
111
+ let at = start;
112
+ for (const line of buf.subarray(0, lastNl).toString('utf8').split('\n')) {
113
+ const here = at;
114
+ at += Buffer.byteLength(line) + 1;
115
+ // Only the run events matter here; skip the rest without parsing it.
116
+ if (!line.includes('"runtime.session"') || !line.includes('"kind":"run"')) continue;
117
+ try {
118
+ out.push({ rec: JSON.parse(line), at: here });
119
+ } catch {}
120
+ }
121
+ return out;
122
+ } finally {
123
+ fs.closeSync(fd);
124
+ }
125
+ }
126
+
127
+ /** A run event: { kind, ...fields } for the person's message, the reply, usage and the end of a turn. */
128
+ const runEvent = (rec) => (rec.payload_type === 'runtime.session' && rec.payload?.kind === 'run' ? rec.payload.event ?? null : null);
129
+ const atOf = (rec) => (rec.recorded_at ? Math.floor(rec.recorded_at / 1000) : Date.now());
130
+
131
+ /**
132
+ * Permission modes in Muse terms: a built-in permission profile, or no approval and no sandbox.
133
+ * Muse's own default is the Auto-review profile (a model reviews what isn't plainly safe).
134
+ */
135
+ const MODES = {
136
+ default: [],
137
+ acceptEdits: [],
138
+ plan: ['--permission-profile', ':read-only'],
139
+ auto: ['--permission-profile', ':auto-review'],
140
+ bypassPermissions: ['--yolo'],
141
+ };
142
+
143
+ /** The model Muse is set to use (its settings file), or null. */
144
+ function configuredModel() {
145
+ try {
146
+ return JSON.parse(fs.readFileSync(path.join(configHome(), 'settings.json'), 'utf8')).model ?? null;
147
+ } catch {
148
+ return null;
149
+ }
150
+ }
151
+
152
+ export default {
153
+ id: 'muse',
154
+ label: 'Muse Code',
155
+ supports: { chooseSessionId: false, cost: false, planHandoff: false, fork: false, oneShot: true, resumePrompt: false },
156
+ permissionModes: Object.keys(MODES),
157
+ // Muse reads the first of these it finds, walking up to the repo root.
158
+ rulesFiles: ['AGENTS.md', 'CLAUDE.md'],
159
+ install: 'Install Muse Code (curl -fsSL https://dev.meta.ai/install.sh | bash) and run `muse login`',
160
+
161
+ // On a hangup Muse records no clean end and warns on the next resume; SIGTERM ends it cleanly.
162
+ stopSignal: 'SIGTERM',
163
+
164
+ bin: (config) => config.engines?.muse?.bin ?? 'muse',
165
+ extraArgs: (config) => [...(config.engines?.muse?.args ?? [])],
166
+
167
+ /** Something the person should know before choosing Muse, or null. */
168
+ notice() {
169
+ const model = configuredModel();
170
+ return /contributor/i.test(model ?? '') ? `Contributor mode (${model}): Meta may use your code and conversations to improve its products.` : null;
171
+ },
172
+
173
+ /**
174
+ * Full arguments for a launch. The folder is trusted for the run (nothing is saved), so the
175
+ * trust question doesn't stop a new agent. Resume takes no message; it is typed when idle.
176
+ */
177
+ launchArgs({ extra = [], permissionMode, model, instructions, sessionId, resume, prompt }) {
178
+ const opts = [...extra, '--trust-workspace', ...(MODES[permissionMode] ?? MODES.default)];
179
+ if (model) opts.push('--model', model);
180
+ if (resume) return ['resume', sessionId, ...opts];
181
+ const first = [instructions, prompt && (instructions ? `Your task:\n${prompt}` : prompt)].filter(Boolean).join('\n\n---\n\n');
182
+ // "--" so a message starting with "-" isn't read as a flag.
183
+ return [...opts, ...(first ? ['--', first] : [])];
184
+ },
185
+
186
+ sessionFile: (cwd, sessionId) => findSession(sessionId) ?? path.join(sessionsDir(), `missing-${sessionId}`, 'session.jsonl'),
187
+ hasSession: (cwd, sessionId) => Boolean(findSession(sessionId)),
188
+ subagentFiles: () => [],
189
+
190
+ /** Sessions Muse started in this folder since a time; the manager adopts a new one. */
191
+ recentSessions(cwd, sinceMs) {
192
+ const want = real(cwd);
193
+ const out = [];
194
+ for (const dir of dayDirs(sinceMs)) {
195
+ for (const id of readdir(dir)) {
196
+ if (!UUID.test(id)) continue;
197
+ const file = path.join(dir, id, 'session.jsonl');
198
+ let st;
199
+ try {
200
+ st = fs.statSync(file);
201
+ } catch {
202
+ continue;
203
+ }
204
+ if (st.mtimeMs < sinceMs) continue;
205
+ const where = sessionCwd(file);
206
+ if (!where || real(where) !== want) continue;
207
+ found.set(id, file);
208
+ out.push({ sessionId: id, file, mtimeMs: st.mtimeMs, birthtimeMs: st.birthtimeMs });
209
+ }
210
+ }
211
+ return out.sort((a, b) => b.mtimeMs - a.mtimeMs);
212
+ },
213
+
214
+ /** Usage (one entry per model call), PR and bug links from the person's messages and the replies. */
215
+ scan(file, cursor, bugPatterns = []) {
216
+ const result = { prs: [], bugs: [], title: null, lastPrompt: null, cost: null, lastActivity: null };
217
+ cursor.usage ??= new Map();
218
+ cursor.offset ??= 0;
219
+ const recs = newRecords(file, cursor);
220
+ result.lastActivity = cursor.lastActivity ?? null;
221
+ if (!recs) return result;
222
+ for (const { rec, at } of recs) {
223
+ const e = runEvent(rec);
224
+ if (!e) continue;
225
+ if (e.kind === 'model_completed' && e.usage) {
226
+ const u = e.usage;
227
+ const cached = u.cached_tokens ?? 0;
228
+ // Cached tokens are counted inside input_tokens, as with OpenAI's API.
229
+ cursor.usage.set(`r${at}`, { input: Math.max(0, (u.input_tokens ?? 0) - cached), output: (u.output_tokens ?? 0) + (u.reasoning_tokens ?? 0), cacheCreation: 0, cacheRead: cached });
230
+ } else if (e.kind === 'started' || e.kind === 'assistant_message_committed') {
231
+ const text = String(e.prompt ?? e.text ?? '');
232
+ if (e.kind === 'started' && text) result.lastPrompt = text.slice(0, 500);
233
+ const refs = extractRefs(text, bugPatterns);
234
+ result.prs.push(...refs.prs);
235
+ result.bugs.push(...refs.bugs);
236
+ }
237
+ }
238
+ return result;
239
+ },
240
+
241
+ /** Commands and edits for stuck detection: not read from Muse's log yet. */
242
+ activity(file, state) {
243
+ state.events ??= [];
244
+ return state;
245
+ },
246
+
247
+ /** A readable digest of the conversation, for Explain and documents. */
248
+ digest(file, { maxChars = 24_000 } = {}) {
249
+ const events = [];
250
+ for (const { rec } of newRecords(file, { offset: 0 }) ?? []) {
251
+ const e = runEvent(rec);
252
+ if (e?.kind === 'started' && e.prompt) events.push(`[user] ${String(e.prompt).slice(0, 1200)}`);
253
+ else if (e?.kind === 'assistant_message_committed' && e.text) events.push(`[agent] ${String(e.text).slice(0, 1800)}`);
254
+ }
255
+ const head = events.find((e) => e.startsWith('[user]'));
256
+ const tail = [];
257
+ let used = head?.length ?? 0;
258
+ for (let i = events.length - 1; i >= 0 && events[i] !== head; i--) {
259
+ if (used + events[i].length > maxChars) {
260
+ tail.unshift('… (earlier events omitted) …');
261
+ break;
262
+ }
263
+ tail.unshift(events[i]);
264
+ used += events[i].length + 1;
265
+ }
266
+ return { digest: [head, ...tail].filter(Boolean).join('\n'), events: events.length, edited: [] };
267
+ },
268
+
269
+ copySession: () => false,
270
+
271
+ attention: [
272
+ [/Doyoutrustthisworkspace\?/i, 'Folder trust prompt'],
273
+ [/Wouldyouliketo(run|allow)/i, 'Permission prompt'],
274
+ ],
275
+
276
+ /** Muse's menus: "Do you trust this workspace? … ⟩ 1 Trust and continue 2 Quit Use Up/Down…". */
277
+ promptChoices(screen) {
278
+ const text = String(screen ?? '')
279
+ .replace(/\x1b\[(\d*)C/g, ' ')
280
+ .replace(ANSI, ' ')
281
+ .replace(/\s+/g, ' ');
282
+ const at = Math.max(text.lastIndexOf('Would you like'), text.lastIndexOf('Do you trust this workspace'));
283
+ if (at < 0) return { question: '', choices: [] };
284
+ const tail = text.slice(at);
285
+ const question = (/^(.*?\?)/.exec(tail)?.[1] ?? '').slice(0, 200);
286
+ const choices = new Map();
287
+ for (const m of tail.matchAll(/(?:⟩\s*)?\b(\d)\.?\s+(.+?)(?=\s+(?:⟩\s*)?\d\.?\s+[A-Z]|\s+Use Up\/Down|\s+Esc |$)/g)) {
288
+ if (!choices.has(m[1])) choices.set(m[1], m[2].trim().slice(0, 100));
289
+ }
290
+ return { question, choices: [...choices.entries()].sort(([a], [b]) => a - b).map(([key, label]) => ({ key, label })) };
291
+ },
292
+
293
+ /** Nothing to save: each launch passes --trust-workspace (trust for that run only). */
294
+ trust: () => false,
295
+
296
+ /** One-shot in the read-only profile (no writes or side-effecting commands), no web tools, not logged; the prompt is the last argument. */
297
+ oneShotArgs: (config, { model, prompt = '', extra = [] } = {}) => ['exec', ...extra, '--no-session-log', '--permission-profile', ':read-only', '--disable-web-tools', ...(model ? ['--model', model] : []), '--', prompt],
298
+ oneShotStdin: false,
299
+ // `muse exec` takes options only after the subcommand, configured extra arguments included.
300
+ oneShotExtra: true,
301
+
302
+ stripEnv: [],
303
+ };
package/src/fleet.js CHANGED
@@ -45,6 +45,7 @@ export function fleetAgent(a) {
45
45
  stuck: Array.isArray(a.stuck) ? a.stuck.length : num(a.stuck) ?? 0,
46
46
  conflicts: Array.isArray(a.conflicts) ? a.conflicts.length : num(a.conflicts) ?? 0,
47
47
  raceId: str(a.raceId, 20),
48
+ movedTo: str(typeof a.movedTo === 'string' ? a.movedTo : a.movedTo?.machine, 60),
48
49
  error: str(a.error, 300),
49
50
  createdAt: iso(a.createdAt),
50
51
  updatedAt: iso(a.updatedAt),
package/src/git.js CHANGED
@@ -7,7 +7,7 @@ const baseEnv = () => ({ ...process.env, GIT_TERMINAL_PROMPT: '0' });
7
7
 
8
8
  const cancelled = () => Object.assign(new Error('Cancelled'), { cancelled: true });
9
9
 
10
- export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
10
+ export function run(cmd, args, { cwd, timeout = 60_000, signal, env, trim = true } = {}) {
11
11
  return new Promise((resolve, reject) => {
12
12
  execFile(cmd, args, { cwd, timeout, signal, env: { ...baseEnv(), ...env }, maxBuffer: 10 * 1024 * 1024 }, (err, stdout, stderr) => {
13
13
  if (err) {
@@ -15,7 +15,7 @@ export function run(cmd, args, { cwd, timeout = 60_000, signal, env } = {}) {
15
15
  err.message = `${cmd} ${args.join(' ')} failed: ${(stderr || err.message).trim()}`;
16
16
  reject(err);
17
17
  } else {
18
- resolve(stdout.trim());
18
+ resolve(trim ? stdout.trim() : stdout);
19
19
  }
20
20
  });
21
21
  });