ai-agentmanager 1.4.0 → 1.6.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 +17 -0
- package/README.md +12 -8
- package/docs/engines.md +2 -1
- package/package.json +1 -1
- package/public/app.js +110 -16
- package/public/attach.js +100 -0
- package/public/hub/hub.js +1 -1
- package/public/index.html +28 -7
- package/public/machine.js +8 -2
- package/public/menu.js +11 -0
- package/public/setup.js +41 -14
- package/public/style.css +30 -2
- package/public/transport.js +19 -3
- package/src/backlog.js +4 -1
- package/src/brief.js +144 -0
- package/src/cli.js +44 -6
- package/src/engines/index.js +10 -2
- package/src/engines/muse.js +303 -0
- package/src/manager.js +171 -17
- package/src/review.js +3 -0
- package/src/server.js +33 -3
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
3
4
|
import { Command, Option } from 'commander';
|
|
4
5
|
import WebSocket from 'ws';
|
|
5
6
|
import { HOST, PORT } from './config.js';
|
|
@@ -192,6 +193,20 @@ async function attach(ref, { resume = true, kind = 'claude' } = {}) {
|
|
|
192
193
|
});
|
|
193
194
|
}
|
|
194
195
|
|
|
196
|
+
/** Upload local files for a request (a screenshot, a log); returns the staged paths the daemon gets. */
|
|
197
|
+
async function uploadFiles(files = []) {
|
|
198
|
+
const out = [];
|
|
199
|
+
for (const f of files) {
|
|
200
|
+
const data = fs.readFileSync(f);
|
|
201
|
+
const res = await fetch(`${BASE}/api/uploads?name=${encodeURIComponent(path.basename(f))}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'x-agentmgr': '1' }, body: data });
|
|
202
|
+
const body = await res.json().catch(() => ({}));
|
|
203
|
+
if (!res.ok) throw new Error(`Could not attach ${f}: ${body.error || `HTTP ${res.status}`}`);
|
|
204
|
+
out.push(body.path);
|
|
205
|
+
}
|
|
206
|
+
return out.length ? out : undefined;
|
|
207
|
+
}
|
|
208
|
+
const collect = (v, list = []) => [...list, v];
|
|
209
|
+
|
|
195
210
|
function readIntentsFile(file) {
|
|
196
211
|
const text = file === '-' ? fs.readFileSync(0, 'utf8') : fs.readFileSync(file, 'utf8');
|
|
197
212
|
return text
|
|
@@ -295,6 +310,11 @@ export async function main(argv) {
|
|
|
295
310
|
const st = await api('GET', '/remote').catch(() => null);
|
|
296
311
|
const phone = st?.running ? st.urls.find((u) => !u.vpn) : null;
|
|
297
312
|
if (phone) console.log(dim(`phone: ${phone.url} (sign-in QR: agentmgr remote status)`));
|
|
313
|
+
// Without the login service, nothing starts the daemon after a reboot.
|
|
314
|
+
if (['darwin', 'linux'].includes(process.platform)) {
|
|
315
|
+
const svc = await import('./service.js');
|
|
316
|
+
if (!(await svc.status().catch(() => ({ installed: true }))).installed) console.log(dim('tip: it won\'t start by itself after a restart; turn on Settings → Start at login, or run: agentmgr service install'));
|
|
317
|
+
}
|
|
298
318
|
execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [BASE], () => {});
|
|
299
319
|
});
|
|
300
320
|
|
|
@@ -321,7 +341,8 @@ export async function main(argv) {
|
|
|
321
341
|
]),
|
|
322
342
|
)
|
|
323
343
|
.option('-m, --model <model>', 'model (for the chosen engine)')
|
|
324
|
-
.option('-e, --engine <engine>', 'agent engine: claude (default) or
|
|
344
|
+
.option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
|
|
345
|
+
.option('--file-attach <path>', 'attach a file to the request (a screenshot, a log); repeat for more', collect)
|
|
325
346
|
.option('--plan', 'while a repo URL clones, plan the task with Claude (moves into the repo when ready)')
|
|
326
347
|
.option('--no-start', 'create without launching')
|
|
327
348
|
.option('-a, --attach', 'attach to the agent after launch (single intent only)')
|
|
@@ -332,8 +353,10 @@ export async function main(argv) {
|
|
|
332
353
|
await daemon();
|
|
333
354
|
// commander sets worktree=true for --no-worktree's default; only send it when the user chose.
|
|
334
355
|
const worktreeSource = cmd.getOptionValueSource('worktree');
|
|
356
|
+
const attachments = await uploadFiles(opts.fileAttach);
|
|
335
357
|
const created = await api('POST', '/agents', {
|
|
336
358
|
intents,
|
|
359
|
+
attachments,
|
|
337
360
|
repo: opts.repo,
|
|
338
361
|
dir: opts.dir,
|
|
339
362
|
name: opts.name,
|
|
@@ -362,14 +385,17 @@ export async function main(argv) {
|
|
|
362
385
|
|
|
363
386
|
program
|
|
364
387
|
.command('ask')
|
|
365
|
-
.description('Open a general
|
|
388
|
+
.description('Open a general session (no repo or folder) on any engine and attach to it')
|
|
366
389
|
.argument('[question...]', 'optional first question')
|
|
367
390
|
.option('-n, --name <name>', 'display name')
|
|
368
|
-
.option('-m, --model <model>', '
|
|
391
|
+
.option('-m, --model <model>', 'model (for the chosen engine)')
|
|
392
|
+
.option('-e, --engine <engine>', 'agent engine: claude (default), codex, antigravity or muse')
|
|
393
|
+
.option('--file-attach <path>', 'attach a file to the question (a screenshot, a log); repeat for more', collect)
|
|
369
394
|
.option('--no-attach', 'start it in the background only')
|
|
370
395
|
.action(async (words, opts) => {
|
|
371
396
|
await daemon();
|
|
372
|
-
|
|
397
|
+
const attachments = await uploadFiles(opts.fileAttach);
|
|
398
|
+
let a = await api('POST', '/general', { question: words.join(' '), name: opts.name, model: opts.model, engine: opts.engine, attachments });
|
|
373
399
|
console.log(`${green('+')} ${bold(a.name)} ${dim(a.id)} session ${cyan(a.sessionId)}`);
|
|
374
400
|
for (let i = 0; i < 100 && a.status === 'preparing'; i++) {
|
|
375
401
|
await new Promise((r) => setTimeout(r, 100));
|
|
@@ -404,9 +430,9 @@ export async function main(argv) {
|
|
|
404
430
|
|
|
405
431
|
program
|
|
406
432
|
.command('doc')
|
|
407
|
-
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal
|
|
433
|
+
.description('Write a stakeholder document from the agent work: analysis, design, flow, proposal, humanai (Human vs AI), diagrams (diagrams only) or walkthrough (its changes explained one by one)')
|
|
408
434
|
.argument('<agent>')
|
|
409
|
-
.argument('<kind>', 'analysis, design, flow, proposal, humanai, or all (background)')
|
|
435
|
+
.argument('<kind>', 'analysis, design, flow, proposal, humanai, diagrams, walkthrough, or all (background; diagrams and walkthrough not included)')
|
|
410
436
|
.option('-o, --output <file>', 'also save the Markdown to this file')
|
|
411
437
|
.option('--cached', 'print the last version instead of writing a new one')
|
|
412
438
|
.option('-b, --background', 'start writing and return at once (kind "all" writes all five)')
|
|
@@ -2020,10 +2046,21 @@ export async function main(argv) {
|
|
|
2020
2046
|
console.log(`${green('✓')} saved ${r.agents} agents (${r.running} running), scrollback for ${r.scrollback}, at ${r.at}`);
|
|
2021
2047
|
});
|
|
2022
2048
|
|
|
2049
|
+
// The daemon's own terminals (agent shells, Open Terminal, the agents themselves) close when it
|
|
2050
|
+
// stops, and the hangup would end a restart typed there halfway: daemon stopped, never started.
|
|
2051
|
+
// So a restart from one ignores the hangup and its closed output, and finishes without it.
|
|
2052
|
+
const outliveTerminal = () => {
|
|
2053
|
+
if (!process.env.AGENTMGR_TERMINAL) return;
|
|
2054
|
+
console.log(dim('This terminal belongs to the daemon and closes while it restarts; the restart carries on. Reopen the terminal afterwards.'));
|
|
2055
|
+
process.on('SIGHUP', () => {});
|
|
2056
|
+
for (const out of [process.stdout, process.stderr]) out.on('error', () => {});
|
|
2057
|
+
};
|
|
2058
|
+
|
|
2023
2059
|
program
|
|
2024
2060
|
.command('restart')
|
|
2025
2061
|
.description('Flush, stop the daemon, and start it again; running agents resume their sessions')
|
|
2026
2062
|
.action(async () => {
|
|
2063
|
+
outliveTerminal();
|
|
2027
2064
|
const svc = await import('./service.js');
|
|
2028
2065
|
const before = await health();
|
|
2029
2066
|
if (before && !before.closing) {
|
|
@@ -2075,6 +2112,7 @@ export async function main(argv) {
|
|
|
2075
2112
|
const r = await up.installUpdate({ log: (m) => console.log(dim(m)) });
|
|
2076
2113
|
console.log(`${green('✓')} installed ${r.to} (was ${r.from})`);
|
|
2077
2114
|
if (!(await health())) return console.log(dim('The daemon is not running; it starts on the new version next time'));
|
|
2115
|
+
outliveTerminal();
|
|
2078
2116
|
// The new version's own restart, not the copy of it loaded in this process.
|
|
2079
2117
|
const { spawnSync } = await import('node:child_process');
|
|
2080
2118
|
const res = spawnSync(process.execPath, [process.argv[1], 'restart'], { stdio: 'inherit' });
|
package/src/engines/index.js
CHANGED
|
@@ -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) =>
|
|
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
|
+
};
|