@ai-devkit/agent-manager 0.40.0 → 0.42.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/dist/AgentManager.d.ts +12 -0
- package/dist/AgentManager.d.ts.map +1 -1
- package/dist/AgentManager.js +45 -10
- package/dist/AgentManager.js.map +1 -1
- package/dist/__tests__/AgentManager.test.js +111 -0
- package/dist/__tests__/AgentManager.test.js.map +1 -1
- package/dist/__tests__/fixtures.test.js +218 -0
- package/dist/__tests__/fixtures.test.js.map +1 -0
- package/dist/__tests__/harnesses/devin/DevinAdapter.test.js.map +1 -1
- package/dist/__tests__/harnesses/devin/DevinSessionParser.test.js.map +1 -1
- package/dist/__tests__/harnesses/muse/MuseAdapter.test.js +247 -0
- package/dist/__tests__/harnesses/muse/MuseAdapter.test.js.map +1 -0
- package/dist/__tests__/harnesses/muse/MuseAgentMapper.test.js +81 -0
- package/dist/__tests__/harnesses/muse/MuseAgentMapper.test.js.map +1 -0
- package/dist/__tests__/harnesses/muse/MuseSessionLocator.test.js +185 -0
- package/dist/__tests__/harnesses/muse/MuseSessionLocator.test.js.map +1 -0
- package/dist/__tests__/harnesses/muse/MuseSessionParser.test.js +328 -0
- package/dist/__tests__/harnesses/muse/MuseSessionParser.test.js.map +1 -0
- package/dist/__tests__/harnesses/opencode/OpenCodeSessionParser.test.js.map +1 -1
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js +42 -1
- package/dist/__tests__/harnesses/readiness/AgentReadiness.test.js.map +1 -1
- package/dist/__tests__/harnesses/runtimeProfiles.test.js +12 -0
- package/dist/__tests__/harnesses/runtimeProfiles.test.js.map +1 -1
- package/dist/__tests__/readiness-fixtures.test.js +54 -0
- package/dist/__tests__/readiness-fixtures.test.js.map +1 -0
- package/dist/__tests__/utils/process.test.js +71 -1
- package/dist/__tests__/utils/process.test.js.map +1 -1
- package/dist/adapters/AgentAdapter.d.ts +8 -1
- package/dist/adapters/AgentAdapter.d.ts.map +1 -1
- package/dist/adapters/AgentAdapter.js +2 -1
- package/dist/adapters/AgentAdapter.js.map +1 -1
- package/dist/fixtures/bundle.d.ts +67 -0
- package/dist/fixtures/bundle.d.ts.map +1 -0
- package/dist/fixtures/bundle.js +86 -0
- package/dist/fixtures/bundle.js.map +1 -0
- package/dist/fixtures/capture.d.ts +11 -0
- package/dist/fixtures/capture.d.ts.map +1 -0
- package/dist/fixtures/capture.js +318 -0
- package/dist/fixtures/capture.js.map +1 -0
- package/dist/harnesses/devin/DevinAdapter.d.ts.map +1 -1
- package/dist/harnesses/devin/DevinAdapter.js.map +1 -1
- package/dist/harnesses/devin/DevinAgentMapper.d.ts.map +1 -1
- package/dist/harnesses/devin/DevinAgentMapper.js.map +1 -1
- package/dist/harnesses/devin/DevinSessionParser.d.ts.map +1 -1
- package/dist/harnesses/devin/DevinSessionParser.js.map +1 -1
- package/dist/harnesses/index.d.ts.map +1 -1
- package/dist/harnesses/index.js +3 -1
- package/dist/harnesses/index.js.map +1 -1
- package/dist/harnesses/muse/MuseAdapter.d.ts +33 -0
- package/dist/harnesses/muse/MuseAdapter.d.ts.map +1 -0
- package/dist/harnesses/muse/MuseAdapter.js +122 -0
- package/dist/harnesses/muse/MuseAdapter.js.map +1 -0
- package/dist/harnesses/muse/MuseAgentMapper.d.ts +15 -0
- package/dist/harnesses/muse/MuseAgentMapper.d.ts.map +1 -0
- package/dist/harnesses/muse/MuseAgentMapper.js +29 -0
- package/dist/harnesses/muse/MuseAgentMapper.js.map +1 -0
- package/dist/harnesses/muse/MuseSessionLocator.d.ts +61 -0
- package/dist/harnesses/muse/MuseSessionLocator.d.ts.map +1 -0
- package/dist/harnesses/muse/MuseSessionLocator.js +233 -0
- package/dist/harnesses/muse/MuseSessionLocator.js.map +1 -0
- package/dist/harnesses/muse/MuseSessionParser.d.ts +77 -0
- package/dist/harnesses/muse/MuseSessionParser.d.ts.map +1 -0
- package/dist/harnesses/muse/MuseSessionParser.js +350 -0
- package/dist/harnesses/muse/MuseSessionParser.js.map +1 -0
- package/dist/harnesses/muse/readiness.d.ts +3 -0
- package/dist/harnesses/muse/readiness.d.ts.map +1 -0
- package/dist/harnesses/muse/readiness.js +37 -0
- package/dist/harnesses/muse/readiness.js.map +1 -0
- package/dist/harnesses/readiness/AgentReadiness.d.ts.map +1 -1
- package/dist/harnesses/readiness/AgentReadiness.js +3 -1
- package/dist/harnesses/readiness/AgentReadiness.js.map +1 -1
- package/dist/harnesses/readiness/types.d.ts +1 -1
- package/dist/harnesses/readiness/types.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.d.ts.map +1 -1
- package/dist/harnesses/runtimeProfiles.js +2 -1
- package/dist/harnesses/runtimeProfiles.js.map +1 -1
- package/dist/harnesses/shared.d.ts +1 -1
- package/dist/harnesses/shared.d.ts.map +1 -1
- package/dist/harnesses/shared.js +7 -6
- package/dist/harnesses/shared.js.map +1 -1
- package/dist/utils/process.d.ts +23 -1
- package/dist/utils/process.d.ts.map +1 -1
- package/dist/utils/process.js +44 -8
- package/dist/utils/process.js.map +1 -1
- package/package.json +1 -1
- package/src/AgentManager.ts +71 -9
- package/src/__tests__/AgentManager.test.ts +84 -0
- package/src/__tests__/fixtures.test.ts +245 -0
- package/src/__tests__/harnesses/devin/DevinAdapter.test.ts +1 -3
- package/src/__tests__/harnesses/devin/DevinSessionParser.test.ts +3 -9
- package/src/__tests__/harnesses/muse/MuseAdapter.test.ts +197 -0
- package/src/__tests__/harnesses/muse/MuseAgentMapper.test.ts +83 -0
- package/src/__tests__/harnesses/muse/MuseSessionLocator.test.ts +177 -0
- package/src/__tests__/harnesses/muse/MuseSessionParser.test.ts +279 -0
- package/src/__tests__/harnesses/opencode/OpenCodeSessionParser.test.ts +3 -2
- package/src/__tests__/harnesses/readiness/AgentReadiness.test.ts +36 -0
- package/src/__tests__/harnesses/runtimeProfiles.test.ts +13 -0
- package/src/__tests__/readiness-fixtures.test.ts +79 -0
- package/src/__tests__/utils/process.test.ts +30 -0
- package/src/adapters/AgentAdapter.ts +9 -0
- package/src/fixtures/bundle.ts +130 -0
- package/src/fixtures/capture.ts +364 -0
- package/src/harnesses/devin/DevinAdapter.ts +3 -15
- package/src/harnesses/devin/DevinAgentMapper.ts +1 -5
- package/src/harnesses/devin/DevinSessionParser.ts +3 -12
- package/src/harnesses/index.ts +2 -0
- package/src/harnesses/muse/MuseAdapter.ts +157 -0
- package/src/harnesses/muse/MuseAgentMapper.ts +33 -0
- package/src/harnesses/muse/MuseSessionLocator.ts +286 -0
- package/src/harnesses/muse/MuseSessionParser.ts +435 -0
- package/src/harnesses/muse/readiness.ts +38 -0
- package/src/harnesses/readiness/AgentReadiness.ts +2 -0
- package/src/harnesses/runtimeProfiles.ts +1 -0
- package/src/harnesses/shared.ts +11 -6
- package/src/utils/process.ts +61 -9
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Capture a live fixture bundle for an adapter: snapshot the real process
|
|
3
|
+
* list, run detectAgents with a frozen clock, and record expected output
|
|
4
|
+
* plus the session files the detection touched.
|
|
5
|
+
*
|
|
6
|
+
* Run via `AI_DEVKIT_FIXTURE_CAPTURE=1 npx vitest run fixtures.capture`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as os from "node:os";
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import Database from "better-sqlite3";
|
|
14
|
+
import type { AgentAdapter, AgentInfo } from "../adapters/AgentAdapter.js";
|
|
15
|
+
import { AgentRegistry } from "../utils/AgentRegistry.js";
|
|
16
|
+
import { captureProcessSnapshot, snapshotOptions } from "../utils/process.js";
|
|
17
|
+
import { FixtureBundle, normalizeAgents, sanitize, withFrozenClock } from "./bundle.js";
|
|
18
|
+
|
|
19
|
+
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../..");
|
|
20
|
+
export const FIXTURES_ROOT = path.join(repoRoot, "fixtures", "harness");
|
|
21
|
+
|
|
22
|
+
/** Session-file trees each harness consults during detection. */
|
|
23
|
+
const HARNESS_DIRS: Record<string, string[]> = {
|
|
24
|
+
claude: [".claude/sessions", ".claude/projects"],
|
|
25
|
+
codex: [".codex/sessions", ".codex/archived_sessions", ".codex/ai-devkit"],
|
|
26
|
+
pi: [".pi/agent/sessions"],
|
|
27
|
+
// tmp/<shortId>/{.project_root,chats/*} — small tree; index-style capture
|
|
28
|
+
// keeps the ownership markers replay needs for slug-dir attribution.
|
|
29
|
+
gemini_cli: [".gemini/tmp"],
|
|
30
|
+
// session-state/<id>/{inuse.*.lock,events.jsonl,workspace.yaml} — the lock
|
|
31
|
+
// files drive attribution, so the whole tree must materialize on replay.
|
|
32
|
+
copilot: [".copilot/session-state"],
|
|
33
|
+
grok_cli: [".grok/sessions"],
|
|
34
|
+
// session_locks/<slug>.lock — pid-holder files read on every detect.
|
|
35
|
+
devin: [".local/share/devin/cli/session_locks"],
|
|
36
|
+
// sessions/cli/<id>.{lock,json,jsonl} — locks drive attribution; the
|
|
37
|
+
// whole flat dir is lightweight enough for index-style capture.
|
|
38
|
+
kiro: [".kiro/sessions/cli"],
|
|
39
|
+
// antigravity-cli/{cache,brain} — last_conversations.json (small .json
|
|
40
|
+
// aux) plus only the transcripts referenced by matched agents; the
|
|
41
|
+
// brain tree is tens of MB so it stays referenced-only (not index).
|
|
42
|
+
antigravity_cli: [".gemini/antigravity-cli"],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Dirs captured wholesale (lock/index trees where every file matters —
|
|
47
|
+
* copilot lock dirs, kiro/devin locks, the codex mapping dir, the gemini
|
|
48
|
+
* tmp tree). Everything else captures agent-referenced session files
|
|
49
|
+
* plus small .json aux files.
|
|
50
|
+
*/
|
|
51
|
+
const HARNESS_INDEX_DIRS = new Set([
|
|
52
|
+
".gemini/tmp",
|
|
53
|
+
".copilot/session-state",
|
|
54
|
+
".codex/ai-devkit",
|
|
55
|
+
".local/share/devin/cli/session_locks",
|
|
56
|
+
".kiro/sessions/cli",
|
|
57
|
+
]);
|
|
58
|
+
|
|
59
|
+
/** Individual files (not dirs) each harness consults during detection. */
|
|
60
|
+
const HARNESS_FILES: Record<string, string[]> = {
|
|
61
|
+
pi: [".pi/agent/sessions.json"],
|
|
62
|
+
grok_cli: [".grok/active_sessions.json"],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Copy the harness's session dirs into `home` — only files the detection
|
|
67
|
+
* actually touched (referenced sessionFilePath) plus index/pid files.
|
|
68
|
+
* Transcript trees can be large; we include full files only for sessions
|
|
69
|
+
* that produced an agent, plus every file in lightweight index dirs.
|
|
70
|
+
*/
|
|
71
|
+
function collectHomeFiles(
|
|
72
|
+
adapterType: string,
|
|
73
|
+
realHome: string,
|
|
74
|
+
agents: AgentInfo[],
|
|
75
|
+
): { home: Record<string, string>; mtimes: Record<string, number> } {
|
|
76
|
+
const home: Record<string, string> = {};
|
|
77
|
+
const mtimes: Record<string, number> = {};
|
|
78
|
+
const referenced = new Set(
|
|
79
|
+
agents.map((a) => a.sessionFilePath).filter((p): p is string => Boolean(p)),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
const put = (rel: string, file: string) => {
|
|
83
|
+
home[rel] = sanitize(fs.readFileSync(file, "utf8"), realHome);
|
|
84
|
+
// Real mtimes are captured too — adapters that read them (Grok
|
|
85
|
+
// lastActive, latest-session pick) replay deterministically.
|
|
86
|
+
mtimes[rel] = fs.statSync(file).mtimeMs;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
for (const relDir of HARNESS_DIRS[adapterType] ?? []) {
|
|
90
|
+
const dir = path.join(realHome, relDir);
|
|
91
|
+
if (!fs.existsSync(dir)) continue;
|
|
92
|
+
// Session trees can hold months of transcripts; files that produced no
|
|
93
|
+
// agent cannot influence replayed output, so only agent-referenced
|
|
94
|
+
// session files plus small aux/index files are copied.
|
|
95
|
+
const isIndexDir = HARNESS_INDEX_DIRS.has(relDir);
|
|
96
|
+
walk(dir, (file) => {
|
|
97
|
+
const rel = path.join(relDir, path.relative(dir, file));
|
|
98
|
+
const isAuxIndex = rel.endsWith("sessions.json") || rel.endsWith(".json");
|
|
99
|
+
if (isIndexDir || referenced.has(file) || (isAuxIndex && fileSize(file) < 64 * 1024)) {
|
|
100
|
+
put(rel, file);
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
for (const relFile of HARNESS_FILES[adapterType] ?? []) {
|
|
105
|
+
const file = path.join(realHome, relFile);
|
|
106
|
+
if (fs.existsSync(file) && fileSize(file) < 64 * 1024) {
|
|
107
|
+
put(relFile, file);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { home, mtimes };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* SQLite stores adapters consult (OpenCode's opencode.db). Sessions are
|
|
115
|
+
* reachable only through captured processes' cwds, so the dump keeps the
|
|
116
|
+
* table DDL plus every session row for those directories and their
|
|
117
|
+
* message/part rows — replay answers identical queries against the same
|
|
118
|
+
* view. Rows are serialized as executable SQL (binary dbs can't travel in
|
|
119
|
+
* the JSON `home` map); the key is the canonical `~/.local/share` relpath,
|
|
120
|
+
* which replay pins via XDG_DATA_HOME.
|
|
121
|
+
*/
|
|
122
|
+
function collectSqliteDumps(
|
|
123
|
+
adapterType: string,
|
|
124
|
+
realHome: string,
|
|
125
|
+
processes: { cwd?: string | null }[],
|
|
126
|
+
): Record<string, string[]> {
|
|
127
|
+
const share = process.env.XDG_DATA_HOME || path.join(realHome, ".local", "share");
|
|
128
|
+
if (adapterType === "opencode") {
|
|
129
|
+
return dumpSqliteDb(
|
|
130
|
+
path.join(share, "opencode", "opencode.db"),
|
|
131
|
+
".local/share/opencode/opencode.db",
|
|
132
|
+
realHome,
|
|
133
|
+
(dump) => {
|
|
134
|
+
const dirs = uniqueCwds(processes);
|
|
135
|
+
if (dirs.length === 0) return;
|
|
136
|
+
const sessions = dump.rows(
|
|
137
|
+
"session",
|
|
138
|
+
`WHERE directory IN ${dump.inList(dirs)} ORDER BY rowid`,
|
|
139
|
+
dirs,
|
|
140
|
+
);
|
|
141
|
+
const sids = sessions.map((s) => s.id);
|
|
142
|
+
dump.insertRows("session", sessions);
|
|
143
|
+
if (sids.length === 0) return;
|
|
144
|
+
dump.insertRows(
|
|
145
|
+
"message",
|
|
146
|
+
dump.rows("message", `WHERE session_id IN ${dump.inList(sids)} ORDER BY rowid`, sids),
|
|
147
|
+
);
|
|
148
|
+
dump.insertRows(
|
|
149
|
+
"part",
|
|
150
|
+
dump.rows("part", `WHERE session_id IN ${dump.inList(sids)} ORDER BY rowid`, sids),
|
|
151
|
+
);
|
|
152
|
+
},
|
|
153
|
+
["session", "message", "part"],
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (adapterType === "devin") {
|
|
157
|
+
const dbPath = path.join(share, "devin", "cli", "sessions.db");
|
|
158
|
+
// Locks are captured separately as text files; slugs here widen the
|
|
159
|
+
// session row set so lock-slug lookups resolve identically on replay.
|
|
160
|
+
const lockSids = fs.existsSync(path.join(path.dirname(dbPath), "session_locks"))
|
|
161
|
+
? fs
|
|
162
|
+
.readdirSync(path.join(path.dirname(dbPath), "session_locks"))
|
|
163
|
+
.filter((f) => f.endsWith(".lock"))
|
|
164
|
+
.map((f) => f.slice(0, -".lock".length))
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
: [];
|
|
167
|
+
return dumpSqliteDb(
|
|
168
|
+
dbPath,
|
|
169
|
+
".local/share/devin/cli/sessions.db",
|
|
170
|
+
realHome,
|
|
171
|
+
(dump) => {
|
|
172
|
+
const dirs = uniqueCwds(processes);
|
|
173
|
+
const clauses: string[] = [];
|
|
174
|
+
const args: unknown[] = [];
|
|
175
|
+
if (dirs.length) {
|
|
176
|
+
clauses.push(`working_directory IN ${dump.inList(dirs)}`);
|
|
177
|
+
args.push(...dirs);
|
|
178
|
+
}
|
|
179
|
+
if (lockSids.length) {
|
|
180
|
+
clauses.push(`id IN ${dump.inList(lockSids)}`);
|
|
181
|
+
args.push(...lockSids);
|
|
182
|
+
}
|
|
183
|
+
const sessions = clauses.length
|
|
184
|
+
? dump.rows("sessions", `WHERE ${clauses.join(" OR ")} ORDER BY rowid`, args)
|
|
185
|
+
: [];
|
|
186
|
+
const sids = sessions.map((s) => s.id);
|
|
187
|
+
dump.insertRows("sessions", sessions);
|
|
188
|
+
if (sids.length === 0) return;
|
|
189
|
+
// Only the rows getSessionStats can reach: the frontier node, the
|
|
190
|
+
// MAX(created_at) node, the 8 newest role='user' nodes, and the
|
|
191
|
+
// newest non-shell non-slash prompt — identical query results on
|
|
192
|
+
// replay without hauling full transcripts into the bundle.
|
|
193
|
+
for (const sid of sids) {
|
|
194
|
+
const reachable = [
|
|
195
|
+
...dump.rows("message_nodes", "WHERE session_id = ? ORDER BY node_id DESC LIMIT 1", [
|
|
196
|
+
sid,
|
|
197
|
+
]),
|
|
198
|
+
// The newest-created_at row — `MAX(created_at)` reads only the
|
|
199
|
+
// value, and `= MAX(...)` unbounded explodes when batch-written
|
|
200
|
+
// nodes share a timestamp.
|
|
201
|
+
...dump.rows(
|
|
202
|
+
"message_nodes",
|
|
203
|
+
"WHERE session_id = ? ORDER BY created_at DESC, node_id DESC LIMIT 1",
|
|
204
|
+
[sid],
|
|
205
|
+
),
|
|
206
|
+
...dump.rows(
|
|
207
|
+
"message_nodes",
|
|
208
|
+
"WHERE session_id = ? AND json_extract(chat_message, '$.role') = 'user' ORDER BY node_id DESC LIMIT 8",
|
|
209
|
+
[sid],
|
|
210
|
+
),
|
|
211
|
+
];
|
|
212
|
+
const seen = new Set<unknown>();
|
|
213
|
+
dump.insertRows(
|
|
214
|
+
"message_nodes",
|
|
215
|
+
reachable.filter((r) => (seen.has(r.node_id) ? false : (seen.add(r.node_id), true))),
|
|
216
|
+
);
|
|
217
|
+
dump.insertRows(
|
|
218
|
+
"prompt_history",
|
|
219
|
+
dump.rows(
|
|
220
|
+
"prompt_history",
|
|
221
|
+
"WHERE session_id = ? AND is_shell = 0 AND content NOT LIKE '/%' ORDER BY timestamp DESC LIMIT 1",
|
|
222
|
+
[sid],
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
},
|
|
227
|
+
["sessions", "message_nodes", "prompt_history"],
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function uniqueCwds(processes: { cwd?: string | null }[]): string[] {
|
|
234
|
+
return [...new Set(processes.map((p) => p.cwd).filter((c): c is string => Boolean(c)))];
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Dump `dbPath`'s tables (DDL + caller-selected rows) as executable SQL,
|
|
239
|
+
* keyed under `relKey`. `fill` receives helpers that append INSERTs for
|
|
240
|
+
* the rows the adapter's detection can reach.
|
|
241
|
+
*/
|
|
242
|
+
function dumpSqliteDb(
|
|
243
|
+
dbPath: string,
|
|
244
|
+
relKey: string,
|
|
245
|
+
realHome: string,
|
|
246
|
+
fill: (dump: {
|
|
247
|
+
inList: (xs: unknown[]) => string;
|
|
248
|
+
rows: (table: string, where: string, args: unknown[]) => Record<string, unknown>[];
|
|
249
|
+
insertRows: (table: string, rs: Record<string, unknown>[]) => void;
|
|
250
|
+
}) => void,
|
|
251
|
+
tables: string[],
|
|
252
|
+
): Record<string, string[]> {
|
|
253
|
+
if (!fs.existsSync(dbPath)) return {};
|
|
254
|
+
const db = new Database(dbPath, { readonly: true });
|
|
255
|
+
try {
|
|
256
|
+
const stmts = (
|
|
257
|
+
db
|
|
258
|
+
.prepare(
|
|
259
|
+
`SELECT sql FROM sqlite_master WHERE type='table' AND name IN (${tables.map(() => "?").join(",")})`,
|
|
260
|
+
)
|
|
261
|
+
.all(...tables) as { sql: string }[]
|
|
262
|
+
).map((r) => `${r.sql};`);
|
|
263
|
+
const dump = {
|
|
264
|
+
inList: (xs: unknown[]) => `(${xs.map(() => "?").join(",")})`,
|
|
265
|
+
rows: (table: string, clause: string, args: unknown[]) =>
|
|
266
|
+
db.prepare(`SELECT * FROM "${table}" ${clause}`).all(...args) as Record<string, unknown>[],
|
|
267
|
+
insertRows: (table: string, rs: Record<string, unknown>[]) => {
|
|
268
|
+
for (const r of rs) {
|
|
269
|
+
stmts.push(`INSERT INTO "${table}" VALUES (${Object.values(r).map(sqlLit).join(",")});`);
|
|
270
|
+
}
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
fill(dump);
|
|
274
|
+
return { [relKey]: stmts.map((s) => sanitize(s, realHome)) };
|
|
275
|
+
} finally {
|
|
276
|
+
db.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function sqlLit(v: unknown): string {
|
|
281
|
+
if (v === null || v === undefined) return "NULL";
|
|
282
|
+
if (typeof v === "number" || typeof v === "bigint") return String(v);
|
|
283
|
+
if (v instanceof Uint8Array) return `X'${Buffer.from(v).toString("hex")}'`;
|
|
284
|
+
return `'${String(v).split("'").join("''")}'`;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Registry rows keyed to captured pids — adapters' registry caches consult
|
|
289
|
+
* them during detection, so replay must see the same view (otherwise
|
|
290
|
+
* ordering/attribution diverges: cache hits precede locator matches).
|
|
291
|
+
*/
|
|
292
|
+
function collectRegistryEntries(
|
|
293
|
+
realHome: string,
|
|
294
|
+
processes: { pid: number }[],
|
|
295
|
+
): Record<string, unknown>[] {
|
|
296
|
+
const pids = new Set(processes.map((p) => p.pid));
|
|
297
|
+
try {
|
|
298
|
+
return AgentRegistry.default()
|
|
299
|
+
.list()
|
|
300
|
+
.filter((e) => pids.has(e.pid))
|
|
301
|
+
.map((e) => sanitize({ ...e }, realHome) as Record<string, unknown>);
|
|
302
|
+
} catch {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function fileSize(file: string): number {
|
|
308
|
+
try {
|
|
309
|
+
return fs.statSync(file).size;
|
|
310
|
+
} catch {
|
|
311
|
+
return Infinity;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function walk(dir: string, visit: (file: string) => void, depth = 0) {
|
|
316
|
+
if (depth > 4) return;
|
|
317
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
318
|
+
const p = path.join(dir, entry.name);
|
|
319
|
+
if (entry.isDirectory()) walk(p, visit, depth + 1);
|
|
320
|
+
else if (entry.isFile()) visit(p);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export async function captureLive(adapter: AgentAdapter, caseName = "live"): Promise<string> {
|
|
325
|
+
const realHome = os.homedir();
|
|
326
|
+
const frozenNow = Date.now();
|
|
327
|
+
const names = adapter.processNames;
|
|
328
|
+
if (!names?.length) {
|
|
329
|
+
throw new Error(`adapter ${adapter.type} has no processNames — can't snapshot`);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const { processes, agents, registry } = await withFrozenClock(frozenNow, async () => {
|
|
333
|
+
const prefixes = adapter.processNamePrefixes ? [...adapter.processNamePrefixes] : [];
|
|
334
|
+
const snapshot = await captureProcessSnapshot(
|
|
335
|
+
[...names],
|
|
336
|
+
snapshotOptions(prefixes, (p) => adapter.canHandle(p)),
|
|
337
|
+
);
|
|
338
|
+
// Registry read must precede detection — detectAgents persists its own
|
|
339
|
+
// results, which would poison the captured view.
|
|
340
|
+
const registryBefore = collectRegistryEntries(realHome, snapshot);
|
|
341
|
+
const detected = await adapter.detectAgents({ processes: snapshot });
|
|
342
|
+
return { processes: snapshot, agents: detected, registry: registryBefore };
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
const { home, mtimes } = collectHomeFiles(adapter.type, realHome, agents);
|
|
346
|
+
const sqlite = collectSqliteDumps(adapter.type, realHome, processes);
|
|
347
|
+
const bundle: FixtureBundle = {
|
|
348
|
+
adapter: adapter.type,
|
|
349
|
+
capturedAt: new Date(frozenNow).toISOString(),
|
|
350
|
+
frozenNow,
|
|
351
|
+
processes: sanitize(processes, realHome),
|
|
352
|
+
home,
|
|
353
|
+
mtimes,
|
|
354
|
+
registry,
|
|
355
|
+
sqlite: Object.keys(sqlite).length > 0 ? sqlite : undefined,
|
|
356
|
+
expected: normalizeAgents(agents, realHome),
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
const dir = path.join(FIXTURES_ROOT, adapter.type);
|
|
360
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
361
|
+
const out = path.join(dir, `${caseName}.json`);
|
|
362
|
+
fs.writeFileSync(out, JSON.stringify(bundle, null, 2));
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
@@ -64,12 +64,7 @@ const UTILITY_SUBCOMMANDS = new Set([
|
|
|
64
64
|
* Flags that always consume the next argv token as their value.
|
|
65
65
|
* (`devin --help`, main-command options.)
|
|
66
66
|
*/
|
|
67
|
-
const REQUIRED_VALUE_FLAGS = new Set([
|
|
68
|
-
"--prompt-file",
|
|
69
|
-
"--config",
|
|
70
|
-
"--permission-mode",
|
|
71
|
-
"--model",
|
|
72
|
-
]);
|
|
67
|
+
const REQUIRED_VALUE_FLAGS = new Set(["--prompt-file", "--config", "--permission-mode", "--model"]);
|
|
73
68
|
|
|
74
69
|
/**
|
|
75
70
|
* Flags whose value is optional: the next token is consumed only when it
|
|
@@ -91,11 +86,7 @@ const OPTIONAL_VALUE_FLAGS = new Set([
|
|
|
91
86
|
*/
|
|
92
87
|
function firstPositionalToken(command: string): string | null {
|
|
93
88
|
const argv0 = executablePath(command);
|
|
94
|
-
const tokens = command
|
|
95
|
-
.trim()
|
|
96
|
-
.slice(argv0.length)
|
|
97
|
-
.trim()
|
|
98
|
-
.split(/\s+/);
|
|
89
|
+
const tokens = command.trim().slice(argv0.length).trim().split(/\s+/);
|
|
99
90
|
for (let i = 0; i < tokens.length; i++) {
|
|
100
91
|
const token = tokens[i];
|
|
101
92
|
if (!token) continue;
|
|
@@ -213,10 +204,7 @@ export class DevinAdapter implements AgentAdapter {
|
|
|
213
204
|
* backend, not a separate agent. Standalone `acp` processes (external ACP
|
|
214
205
|
* clients) keep their own agent entry.
|
|
215
206
|
*/
|
|
216
|
-
private isAcpBackendChild(
|
|
217
|
-
proc: ProcessInfo,
|
|
218
|
-
processByPid: Map<number, ProcessInfo>,
|
|
219
|
-
): boolean {
|
|
207
|
+
private isAcpBackendChild(proc: ProcessInfo, processByPid: Map<number, ProcessInfo>): boolean {
|
|
220
208
|
if (firstPositionalToken(proc.command)?.toLowerCase() !== "acp") return false;
|
|
221
209
|
if (proc.ppid === undefined) return false;
|
|
222
210
|
return processByPid.has(proc.ppid);
|
|
@@ -9,11 +9,7 @@ import { isIdle, processOnlyAgent } from "../shared.js";
|
|
|
9
9
|
export class DevinAgentMapper {
|
|
10
10
|
constructor(private readonly dbPath: string) {}
|
|
11
11
|
|
|
12
|
-
mapSessionToAgent(
|
|
13
|
-
session: DevinSession,
|
|
14
|
-
stats: DevinSessionStats,
|
|
15
|
-
proc: ProcessInfo,
|
|
16
|
-
): AgentInfo {
|
|
12
|
+
mapSessionToAgent(session: DevinSession, stats: DevinSessionStats, proc: ProcessInfo): AgentInfo {
|
|
17
13
|
const lastActive =
|
|
18
14
|
stats.lastTimeUpdated > 0
|
|
19
15
|
? new Date(stats.lastTimeUpdated)
|
|
@@ -78,10 +78,7 @@ export class DevinSessionParser {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
for (const { nodeId, message } of parsed) {
|
|
81
|
-
if (
|
|
82
|
-
message.message_id &&
|
|
83
|
-
latestNodeByMessageId.get(message.message_id) !== nodeId
|
|
84
|
-
) {
|
|
81
|
+
if (message.message_id && latestNodeByMessageId.get(message.message_id) !== nodeId) {
|
|
85
82
|
continue;
|
|
86
83
|
}
|
|
87
84
|
|
|
@@ -98,9 +95,7 @@ export class DevinSessionParser {
|
|
|
98
95
|
|
|
99
96
|
if (role === "assistant") {
|
|
100
97
|
const thinking =
|
|
101
|
-
typeof message.thinking === "string"
|
|
102
|
-
? message.thinking
|
|
103
|
-
: message.thinking?.thinking;
|
|
98
|
+
typeof message.thinking === "string" ? message.thinking : message.thinking?.thinking;
|
|
104
99
|
if (verbose && thinking) {
|
|
105
100
|
messages.push({ role: "assistant", content: `[thinking] ${thinking}` });
|
|
106
101
|
}
|
|
@@ -179,11 +174,7 @@ export class DevinSessionParser {
|
|
|
179
174
|
* shell `!` commands and `/` slash commands are skipped. Falls back to a
|
|
180
175
|
* bounded `is_user_input` node scan in the same direction.
|
|
181
176
|
*/
|
|
182
|
-
private userPrompt(
|
|
183
|
-
db: Database.Database,
|
|
184
|
-
sessionId: string,
|
|
185
|
-
direction: "ASC" | "DESC",
|
|
186
|
-
): string {
|
|
177
|
+
private userPrompt(db: Database.Database, sessionId: string, direction: "ASC" | "DESC"): string {
|
|
187
178
|
try {
|
|
188
179
|
const prompt = db
|
|
189
180
|
.prepare<[string], { content: string }>(`
|
package/src/harnesses/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { KiroAdapter } from "./kiro/KiroAdapter.js";
|
|
|
9
9
|
import { OpenCodeAdapter } from "./opencode/OpenCodeAdapter.js";
|
|
10
10
|
import { PiAdapter } from "./pi/PiAdapter.js";
|
|
11
11
|
import { DevinAdapter } from "./devin/DevinAdapter.js";
|
|
12
|
+
import { MuseAdapter } from "./muse/MuseAdapter.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
15
|
* One instance of every built-in harness adapter, in registration order
|
|
@@ -26,5 +27,6 @@ export function createBuiltinAdapters(): AgentAdapter[] {
|
|
|
26
27
|
new OpenCodeAdapter(),
|
|
27
28
|
new PiAdapter(),
|
|
28
29
|
new DevinAdapter(),
|
|
30
|
+
new MuseAdapter(),
|
|
29
31
|
];
|
|
30
32
|
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import type {
|
|
3
|
+
AgentAdapter,
|
|
4
|
+
AgentDetectionContext,
|
|
5
|
+
AgentInfo,
|
|
6
|
+
ConversationMessage,
|
|
7
|
+
ConversationOptions,
|
|
8
|
+
ListSessionsOptions,
|
|
9
|
+
ProcessInfo,
|
|
10
|
+
SessionSummary,
|
|
11
|
+
} from "../../adapters/AgentAdapter.js";
|
|
12
|
+
import { safeStat } from "../../utils/session.js";
|
|
13
|
+
import { MuseAgentMapper } from "./MuseAgentMapper.js";
|
|
14
|
+
import { MuseSessionLocator } from "./MuseSessionLocator.js";
|
|
15
|
+
import { MuseSessionParser } from "./MuseSessionParser.js";
|
|
16
|
+
import { findHarnessProcesses, homeDir } from "../shared.js";
|
|
17
|
+
|
|
18
|
+
export interface MuseAdapterOptions {
|
|
19
|
+
homeDir?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Muse (Meta Muse Code) adapter.
|
|
24
|
+
*
|
|
25
|
+
* Detection joins two sources:
|
|
26
|
+
* 1. Live runtime entries (`~/.local/share/muse/runtime/muse/sessions/<id>.json`)
|
|
27
|
+
* matched to processes by `process_generation_hint: "pid=<n>"`.
|
|
28
|
+
* 2. The dated transcript archive (`sessions/yyyy/mm/dd/<uuid>/session.jsonl`).
|
|
29
|
+
*
|
|
30
|
+
* The launcher binary is versioned (`muse-bin-<version>-<build>`), so discovery
|
|
31
|
+
* uses `processNamePrefixes`; `canHandle` keeps the precise shape check.
|
|
32
|
+
*/
|
|
33
|
+
export class MuseAdapter implements AgentAdapter {
|
|
34
|
+
readonly type = "muse" as const;
|
|
35
|
+
readonly processNames = ["muse"] as const;
|
|
36
|
+
readonly processNamePrefixes = ["muse"] as const;
|
|
37
|
+
|
|
38
|
+
private readonly home: string;
|
|
39
|
+
private readonly parser: MuseSessionParser;
|
|
40
|
+
private readonly mapper: MuseAgentMapper;
|
|
41
|
+
|
|
42
|
+
constructor(options: MuseAdapterOptions = {}) {
|
|
43
|
+
this.home = options.homeDir ?? homeDir();
|
|
44
|
+
this.parser = new MuseSessionParser();
|
|
45
|
+
this.mapper = new MuseAgentMapper(this.parser);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
canHandle(processInfo: ProcessInfo): boolean {
|
|
49
|
+
const base = path.basename(processInfo.command.trim().split(/\s+/)[0] ?? "").toLowerCase();
|
|
50
|
+
return base === "muse" || base === "muse.exe" || base.startsWith("muse-bin-");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async detectAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
|
|
54
|
+
try {
|
|
55
|
+
return await this.detectRunningAgents(context);
|
|
56
|
+
} finally {
|
|
57
|
+
this.parser.pruneSessionCache();
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private async detectRunningAgents(context?: AgentDetectionContext): Promise<AgentInfo[]> {
|
|
62
|
+
const { processes } = await findHarnessProcesses(this, context);
|
|
63
|
+
if (processes.length === 0) return [];
|
|
64
|
+
|
|
65
|
+
const { direct, legacyMatches } = this.createLocator().matchRunningProcesses(processes);
|
|
66
|
+
const matchedPids = new Set([
|
|
67
|
+
...direct.map((d) => d.process.pid),
|
|
68
|
+
...legacyMatches.map((m) => m.process.pid),
|
|
69
|
+
]);
|
|
70
|
+
const agents: AgentInfo[] = [];
|
|
71
|
+
|
|
72
|
+
for (const match of direct) {
|
|
73
|
+
const sessionData = this.parser.readSessionIncremental(
|
|
74
|
+
match.sessionFile.filePath,
|
|
75
|
+
match.sessionFile.resolvedCwd,
|
|
76
|
+
);
|
|
77
|
+
if (sessionData) {
|
|
78
|
+
agents.push(
|
|
79
|
+
this.mapper.mapSessionToAgent({
|
|
80
|
+
session: sessionData,
|
|
81
|
+
processInfo: match.process,
|
|
82
|
+
sessionFile: match.sessionFile,
|
|
83
|
+
}),
|
|
84
|
+
);
|
|
85
|
+
} else {
|
|
86
|
+
matchedPids.delete(match.process.pid);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const match of legacyMatches) {
|
|
91
|
+
const sessionData = this.parser.readSessionIncremental(
|
|
92
|
+
match.session.filePath,
|
|
93
|
+
match.session.resolvedCwd,
|
|
94
|
+
);
|
|
95
|
+
if (sessionData) {
|
|
96
|
+
agents.push(
|
|
97
|
+
this.mapper.mapSessionToAgent({
|
|
98
|
+
session: sessionData,
|
|
99
|
+
processInfo: match.process,
|
|
100
|
+
sessionFile: match.session,
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
} else {
|
|
104
|
+
matchedPids.delete(match.process.pid);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const proc of processes) {
|
|
109
|
+
if (!matchedPids.has(proc.pid)) {
|
|
110
|
+
agents.push(this.mapper.mapProcessOnlyAgent(proc));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return agents;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
private createLocator(): MuseSessionLocator {
|
|
117
|
+
return new MuseSessionLocator({ homeDir: this.home });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
getConversation(sessionFilePath: string, options?: ConversationOptions): ConversationMessage[] {
|
|
121
|
+
return this.parser.getConversation(sessionFilePath, options);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async listSessions(opts?: ListSessionsOptions): Promise<SessionSummary[]> {
|
|
125
|
+
const filterCwd = opts?.cwd;
|
|
126
|
+
const summaries: SessionSummary[] = [];
|
|
127
|
+
for (const { filePath, defaultCwd } of this.createLocator().discoverHistoricalSessionFiles()) {
|
|
128
|
+
const summary = this.toSessionSummary(filePath, defaultCwd);
|
|
129
|
+
if (!summary) continue;
|
|
130
|
+
if (filterCwd !== undefined && summary.cwd !== filterCwd) continue;
|
|
131
|
+
summaries.push(summary);
|
|
132
|
+
}
|
|
133
|
+
return summaries;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async findSessionsById(sessionId: string): Promise<SessionSummary[]> {
|
|
137
|
+
return this.createLocator()
|
|
138
|
+
.findHistoricalSessionFilesById(sessionId)
|
|
139
|
+
.map(({ filePath, defaultCwd }) => this.toSessionSummary(filePath, defaultCwd))
|
|
140
|
+
.filter((summary): summary is SessionSummary => summary?.sessionId === sessionId);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private toSessionSummary(filePath: string, defaultCwd: string): SessionSummary | null {
|
|
144
|
+
const session = this.parser.readSession(filePath, defaultCwd);
|
|
145
|
+
if (!session?.lastSignal && !session?.firstUserMessage) return null;
|
|
146
|
+
const stat = safeStat(filePath);
|
|
147
|
+
return {
|
|
148
|
+
type: "muse",
|
|
149
|
+
sessionId: session.sessionId,
|
|
150
|
+
cwd: session.projectPath || defaultCwd,
|
|
151
|
+
firstUserMessage: session.firstUserMessage || "",
|
|
152
|
+
lastActive: session.lastActive ?? stat?.mtime ?? new Date(),
|
|
153
|
+
startedAt: session.sessionStart ?? stat?.birthtime ?? stat?.mtime ?? new Date(),
|
|
154
|
+
sessionFilePath: filePath,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AgentInfo, ProcessInfo } from "../../adapters/AgentAdapter.js";
|
|
2
|
+
import { generateAgentName } from "../../utils/matching.js";
|
|
3
|
+
import type { SessionFile } from "../../utils/session.js";
|
|
4
|
+
import { MuseSessionParser, type MuseSession } from "./MuseSessionParser.js";
|
|
5
|
+
import { processOnlyAgent } from "../shared.js";
|
|
6
|
+
|
|
7
|
+
export interface MuseSessionAgentInput {
|
|
8
|
+
session: MuseSession;
|
|
9
|
+
processInfo: ProcessInfo;
|
|
10
|
+
sessionFile: SessionFile;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class MuseAgentMapper {
|
|
14
|
+
constructor(private readonly parser: MuseSessionParser = new MuseSessionParser()) {}
|
|
15
|
+
|
|
16
|
+
mapSessionToAgent({ session, processInfo, sessionFile }: MuseSessionAgentInput): AgentInfo {
|
|
17
|
+
return {
|
|
18
|
+
name: generateAgentName(processInfo.cwd, processInfo.pid),
|
|
19
|
+
type: "muse",
|
|
20
|
+
status: this.parser.determineStatus(session),
|
|
21
|
+
summary: session.lastUserMessage || "Session started",
|
|
22
|
+
pid: processInfo.pid,
|
|
23
|
+
projectPath: session.projectPath || sessionFile.resolvedCwd || processInfo.cwd || "",
|
|
24
|
+
sessionId: sessionFile.sessionId,
|
|
25
|
+
lastActive: session.lastActive,
|
|
26
|
+
sessionFilePath: sessionFile.filePath,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
mapProcessOnlyAgent(processInfo: ProcessInfo): AgentInfo {
|
|
31
|
+
return processOnlyAgent("muse", processInfo, { summary: "Muse process running" });
|
|
32
|
+
}
|
|
33
|
+
}
|