@phnx-labs/agents-cli 1.20.63 → 1.20.64
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 +9 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +55 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/routines.js +20 -14
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions.js +4 -0
- package/dist/commands/ssh.js +98 -3
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/index.js +2 -1
- package/dist/lib/agents.d.ts +18 -0
- package/dist/lib/agents.js +27 -17
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +6 -1
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +21 -2
- package/dist/lib/routines.js +35 -12
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +54 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/sync.d.ts +23 -2
- package/dist/lib/session/sync/sync.js +177 -74
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +2 -0
- package/dist/lib/startup/command-registry.js +5 -0
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- package/package.json +1 -1
|
@@ -18,10 +18,10 @@ import * as fs from 'fs';
|
|
|
18
18
|
import * as path from 'path';
|
|
19
19
|
import { R2Client } from './r2.js';
|
|
20
20
|
import { loadR2Config, machineId } from './config.js';
|
|
21
|
-
import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, SESSIONS_PREFIX, } from './agents.js';
|
|
21
|
+
import { SYNC_AGENTS, listLocalTranscripts, localSessionIds, mirrorPath, objectKey, manifestKey, isMergeableFile, SESSIONS_PREFIX, } from './agents.js';
|
|
22
22
|
import { mergeTranscripts, transcriptStats } from './crdt.js';
|
|
23
23
|
import { resolveSyncEncKey, encryptTranscript, decryptTranscriptBody } from './transcript-crypto.js';
|
|
24
|
-
import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, } from './manifest.js';
|
|
24
|
+
import { emptyManifest, parseManifest, hashContent, loadLedger, saveLedger, ledgerUnchanged, ledgerRecord, loadLocalManifest, saveLocalManifest, loadPullState, savePullState, sourceSignature, manifestEntries, } from './manifest.js';
|
|
25
25
|
const nowIso = () => new Date().toISOString();
|
|
26
26
|
function specById(id) {
|
|
27
27
|
return SYNC_AGENTS.find(s => s.id === id);
|
|
@@ -42,45 +42,59 @@ async function pushOwn(r2, me, encKey, opts, result) {
|
|
|
42
42
|
for (const spec of SYNC_AGENTS) {
|
|
43
43
|
const agentManifest = {};
|
|
44
44
|
for (const t of listLocalTranscripts(spec)) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
45
|
+
// A session is one file (file-shaped) or many (dir-shaped). Push each file
|
|
46
|
+
// that changed, reusing the prior manifest entry (keyed by relKey) for the
|
|
47
|
+
// ones the ledger shows unchanged. Object keys nest under the session id for
|
|
48
|
+
// dir-shaped agents and stay flat for file-shaped ones (unchanged keys).
|
|
49
|
+
const prevVal = prev?.agents?.[spec.id]?.[t.sessionId];
|
|
50
|
+
const prevByRel = new Map((prevVal ? manifestEntries(prevVal) : []).map(e => [e.relKey, e]));
|
|
51
|
+
const entries = [];
|
|
52
|
+
for (const f of t.files) {
|
|
53
|
+
let stat;
|
|
54
|
+
try {
|
|
55
|
+
stat = fs.statSync(f.absPath);
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const prevEntry = prevByRel.get(f.relKey);
|
|
61
|
+
if (prevEntry && ledgerUnchanged(ledger, f.absPath, stat.size, stat.mtimeMs)) {
|
|
62
|
+
entries.push(prevEntry); // unchanged: reuse, no read, no upload
|
|
63
|
+
result.pushSkipped++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
let content;
|
|
67
|
+
try {
|
|
68
|
+
content = fs.readFileSync(f.absPath, 'utf-8');
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
// Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
|
|
74
|
+
// non-deterministic), so hash + manifest are computed on cleartext; only
|
|
75
|
+
// the stored object body is sealed.
|
|
76
|
+
const hash = hashContent(content);
|
|
77
|
+
const lastTs = deriveLastTs(spec, f.relKey, content, stat.mtimeMs);
|
|
78
|
+
const entry = { relKey: f.relKey, size: stat.size, hash, lastTs };
|
|
79
|
+
const body = encKey ? encryptTranscript(content, encKey) : content;
|
|
80
|
+
const contentType = encKey ? 'application/json' : 'application/x-ndjson';
|
|
81
|
+
try {
|
|
82
|
+
await r2.put(objectKey(me, spec.id, t.sessionId, spec.dirShaped ? f.relKey : undefined), body, contentType);
|
|
83
|
+
ledgerRecord(ledger, f.absPath, stat.size, stat.mtimeMs, hash);
|
|
84
|
+
entries.push(entry);
|
|
85
|
+
result.pushed++;
|
|
86
|
+
const label = spec.dirShaped ? `${t.sessionId.slice(0, 8)}/${f.relKey}` : t.sessionId.slice(0, 8);
|
|
87
|
+
if (opts.verbose)
|
|
88
|
+
opts.log?.(` push ${spec.id}/${label} (${stat.size}B)`);
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
result.errors.push(`push ${spec.id}/${t.sessionId}/${f.relKey}: ${err.message}`);
|
|
92
|
+
}
|
|
83
93
|
}
|
|
94
|
+
// File-shaped: store the single entry (byte-identical to the old format so
|
|
95
|
+
// older CLIs read it unchanged). Dir-shaped: store the per-file array.
|
|
96
|
+
if (entries.length > 0)
|
|
97
|
+
agentManifest[t.sessionId] = spec.dirShaped ? entries : entries[0];
|
|
84
98
|
}
|
|
85
99
|
if (Object.keys(agentManifest).length > 0)
|
|
86
100
|
manifest.agents[spec.id] = agentManifest;
|
|
@@ -115,17 +129,60 @@ export function selectSessionsToFetch(copies, localIdsByAgent, pullState) {
|
|
|
115
129
|
return pending;
|
|
116
130
|
}
|
|
117
131
|
/**
|
|
118
|
-
* Resolve the mirror destination +
|
|
132
|
+
* Resolve the mirror destination + reconciled content for ONE file across its
|
|
133
|
+
* copies (every copy here is the same file — same relKey — held by a different
|
|
134
|
+
* machine). Pure.
|
|
135
|
+
*
|
|
119
136
|
* The canonical path comes from the lexicographically-smallest machine so every
|
|
120
|
-
* puller derives an identical location
|
|
137
|
+
* puller derives an identical location. The content depends on the file's kind
|
|
138
|
+
* (see `isMergeableFile`):
|
|
139
|
+
* - append-only logs (a transcript `.jsonl`) take the CRDT G-Set union — every
|
|
140
|
+
* machine converges to byte-identical output regardless of order.
|
|
141
|
+
* - mutable blobs (Kimi `state.json`) can't be line-unioned without corruption,
|
|
142
|
+
* so they resolve **last-writer-wins**: the copy with the latest event
|
|
143
|
+
* timestamp, tie-broken by content hash so the pick is deterministic fleet-wide.
|
|
121
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* The `lastTs` a manifest entry carries for one file. Append-only logs (a
|
|
147
|
+
* conversation `.jsonl`) embed per-line event timestamps, so their recency is
|
|
148
|
+
* the latest line timestamp (`transcriptStats`). Mutable blobs (Kimi
|
|
149
|
+
* `state.json`, the per-tool `tasks/*.json` sidecars) carry no event timestamp —
|
|
150
|
+
* their own `updatedAt`/`createdAt` fields are agent-specific and unreliable — so
|
|
151
|
+
* their "last written" signal is the file mtime. Without this, `transcriptStats`
|
|
152
|
+
* returns `''` for every blob and the last-writer-wins branch in
|
|
153
|
+
* `resolveMirrorWrite` silently degrades to "highest-hash-wins", which can pick a
|
|
154
|
+
* stale copy over the genuinely newer one.
|
|
155
|
+
*/
|
|
156
|
+
export function deriveLastTs(spec, relKey, content, mtimeMs) {
|
|
157
|
+
if (isMergeableFile(spec, relKey))
|
|
158
|
+
return transcriptStats(content).lastTs;
|
|
159
|
+
return new Date(mtimeMs).toISOString();
|
|
160
|
+
}
|
|
122
161
|
export function resolveMirrorWrite(spec, copies, contents) {
|
|
123
162
|
const canonical = [...copies].sort((a, b) => (a.machine < b.machine ? -1 : a.machine > b.machine ? 1 : 0))[0];
|
|
124
|
-
|
|
163
|
+
let content;
|
|
164
|
+
if (contents.length === 1) {
|
|
165
|
+
content = contents[0];
|
|
166
|
+
}
|
|
167
|
+
else if (isMergeableFile(spec, canonical.entry.relKey)) {
|
|
168
|
+
content = mergeTranscripts(contents);
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
// Last-writer-wins: highest (lastTs, hash) among the copies.
|
|
172
|
+
let win = 0;
|
|
173
|
+
for (let i = 1; i < copies.length; i++) {
|
|
174
|
+
const a = copies[i].entry, b = copies[win].entry;
|
|
175
|
+
if (a.lastTs > b.lastTs || (a.lastTs === b.lastTs && a.hash > b.hash))
|
|
176
|
+
win = i;
|
|
177
|
+
}
|
|
178
|
+
content = contents[win];
|
|
179
|
+
}
|
|
125
180
|
return {
|
|
126
181
|
dest: mirrorPath(spec, canonical.machine, canonical.entry.relKey),
|
|
127
182
|
content,
|
|
128
|
-
merged
|
|
183
|
+
// "merged" means an actual CRDT line-union happened — not a last-writer-wins
|
|
184
|
+
// pick of one mutable blob over another (that discards a copy, it doesn't merge).
|
|
185
|
+
merged: contents.length > 1 && isMergeableFile(spec, canonical.entry.relKey),
|
|
129
186
|
};
|
|
130
187
|
}
|
|
131
188
|
/**
|
|
@@ -176,9 +233,13 @@ async function pullAndReconcile(r2, me, encKey, opts, result) {
|
|
|
176
233
|
let byAgent = copies.get(agentId);
|
|
177
234
|
if (!byAgent)
|
|
178
235
|
copies.set(agentId, (byAgent = new Map()));
|
|
179
|
-
for (const [sessionId,
|
|
236
|
+
for (const [sessionId, value] of Object.entries(sessions)) {
|
|
180
237
|
const list = byAgent.get(sessionId) ?? [];
|
|
181
|
-
|
|
238
|
+
// One RemoteCopy per (machine, file): file-shaped sessions contribute one,
|
|
239
|
+
// dir-shaped ones contribute an entry per constituent file. `manifestEntries`
|
|
240
|
+
// also normalizes a single-object entry written by an older CLI.
|
|
241
|
+
for (const entry of manifestEntries(value))
|
|
242
|
+
list.push({ machine: m, entry });
|
|
182
243
|
byAgent.set(sessionId, list);
|
|
183
244
|
}
|
|
184
245
|
}
|
|
@@ -197,43 +258,85 @@ async function pullAndReconcile(r2, me, encKey, opts, result) {
|
|
|
197
258
|
result.pullSkipped += candidates - pending.length; // local-owned or unchanged
|
|
198
259
|
for (const { agentId, sessionId, copies: list, sig } of pending) {
|
|
199
260
|
const spec = specById(agentId);
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
const
|
|
261
|
+
// A dir-shaped session spans several files; reconcile each file independently
|
|
262
|
+
// (its copies across machines share one relKey). File-shaped sessions have a
|
|
263
|
+
// single group, so this collapses to the original one-file path.
|
|
264
|
+
const byRel = new Map();
|
|
204
265
|
for (const c of list) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
266
|
+
const g = byRel.get(c.entry.relKey);
|
|
267
|
+
if (g)
|
|
268
|
+
g.push(c);
|
|
269
|
+
else
|
|
270
|
+
byRel.set(c.entry.relKey, [c]);
|
|
271
|
+
}
|
|
272
|
+
// Resolve every file's write before touching disk. If ANY file's fetch is
|
|
273
|
+
// incomplete (a copy 404s / consistency lag), abandon the WHOLE session this
|
|
274
|
+
// tick — no writes, no pull-state stamp — so it retries intact next time
|
|
275
|
+
// rather than materializing half a session and stamping it done.
|
|
276
|
+
const writes = [];
|
|
277
|
+
let incomplete = false;
|
|
278
|
+
try {
|
|
279
|
+
for (const group of byRel.values()) {
|
|
280
|
+
const fetched = [];
|
|
281
|
+
for (const c of group) {
|
|
282
|
+
try {
|
|
283
|
+
const body = await r2.get(objectKey(c.machine, agentId, sessionId, spec.dirShaped ? c.entry.relKey : undefined));
|
|
284
|
+
// Decrypt before the body reaches the CRDT union — merge + mirror always
|
|
285
|
+
// operate on plaintext. A legacy plaintext object passes through
|
|
286
|
+
// untouched; an envelope without a key throws (surfaced below).
|
|
287
|
+
fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
|
|
288
|
+
}
|
|
289
|
+
catch (err) {
|
|
290
|
+
result.errors.push(`get ${c.machine}/${sessionId}/${c.entry.relKey}: ${err.message}`);
|
|
291
|
+
fetched.push(null);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
const resolved = reconcileCopies(spec, group, fetched);
|
|
295
|
+
if (!resolved) {
|
|
296
|
+
incomplete = true;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
writes.push(resolved);
|
|
215
300
|
}
|
|
216
301
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
302
|
+
catch (err) {
|
|
303
|
+
// `reconcileCopies` -> `mirrorPath` now rejects unsafe peer-controlled
|
|
304
|
+
// machine/relKey (C1 containment). That rejection must stay scoped to this
|
|
305
|
+
// one session: without this catch a single malicious/malformed manifest
|
|
306
|
+
// entry would throw out of the whole `pending` loop, skip the
|
|
307
|
+
// `savePullState` below, and re-throw every tick — a peer-triggered DoS on
|
|
308
|
+
// everyone else's session-sync. Record it and skip; never stamp pull-state
|
|
309
|
+
// for a rejected entry (so nothing marks the bad session "done").
|
|
310
|
+
result.errors.push(`resolve mirror ${agentId}/${sessionId}: ${err.message}`);
|
|
221
311
|
continue;
|
|
222
|
-
|
|
312
|
+
}
|
|
313
|
+
if (incomplete)
|
|
314
|
+
continue; // retry next tick, session intact
|
|
223
315
|
try {
|
|
224
|
-
let
|
|
225
|
-
|
|
226
|
-
|
|
316
|
+
let changed = false;
|
|
317
|
+
let mergedAny = false;
|
|
318
|
+
for (const { dest, content, merged } of writes) {
|
|
319
|
+
let existing = null;
|
|
320
|
+
try {
|
|
321
|
+
existing = fs.readFileSync(dest, 'utf-8');
|
|
322
|
+
}
|
|
323
|
+
catch { /* not present yet */ }
|
|
324
|
+
if (existing !== content) {
|
|
325
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
326
|
+
fs.writeFileSync(dest, content, 'utf-8');
|
|
327
|
+
changed = true;
|
|
328
|
+
if (merged)
|
|
329
|
+
mergedAny = true;
|
|
330
|
+
}
|
|
227
331
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
231
|
-
fs.writeFileSync(dest, content, 'utf-8');
|
|
232
|
-
if (merged)
|
|
332
|
+
if (changed) {
|
|
333
|
+
if (mergedAny)
|
|
233
334
|
result.merged++;
|
|
234
335
|
result.pulled++;
|
|
235
336
|
if (opts.verbose) {
|
|
236
|
-
|
|
337
|
+
const machines = [...new Set(list.map(c => c.machine))].join('+');
|
|
338
|
+
const files = byRel.size > 1 ? ` (${byRel.size} files)` : '';
|
|
339
|
+
opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)}${files} <- ${machines}`);
|
|
237
340
|
}
|
|
238
341
|
}
|
|
239
342
|
else {
|
package/dist/lib/ssh-tunnel.js
CHANGED
|
@@ -24,7 +24,7 @@ import { fileURLToPath } from 'url';
|
|
|
24
24
|
import { randomBytes, createHash } from 'crypto';
|
|
25
25
|
import { Readable } from 'stream';
|
|
26
26
|
import { pipeline } from 'stream/promises';
|
|
27
|
-
import { sshExec, SSH_OPTS } from './ssh-exec.js';
|
|
27
|
+
import { sshExec, SSH_OPTS, assertValidSshTarget } from './ssh-exec.js';
|
|
28
28
|
import { backgroundSpawnOptions } from './platform/process.js';
|
|
29
29
|
import { encodePowerShell } from './browser/drivers/ssh.js';
|
|
30
30
|
import { getDevice } from './devices/registry.js';
|
|
@@ -57,6 +57,18 @@ export function buildTunnelArgs(user, host, localPort, remotePort) {
|
|
|
57
57
|
*/
|
|
58
58
|
export function startSSHTunnel(user, host, localPort, remotePort, opts = {}) {
|
|
59
59
|
return new Promise((resolve, reject) => {
|
|
60
|
+
// `user`/`host` can originate from a browser ssh:// profile or a device
|
|
61
|
+
// record. buildTunnelArgs places `${user}@${host}` before `-N`/SSH_OPTS, so
|
|
62
|
+
// a `-`-leading user would be parsed as an ssh option flag (option
|
|
63
|
+
// injection). Validate at the spawn sink so every caller is covered; reject
|
|
64
|
+
// (rather than throw synchronously) to keep the Promise contract.
|
|
65
|
+
try {
|
|
66
|
+
assertValidSshTarget(`${user}@${host}`);
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
reject(err);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
60
72
|
const args = buildTunnelArgs(user, host, localPort, remotePort);
|
|
61
73
|
const tunnel = spawn('ssh', args, {
|
|
62
74
|
stdio: opts.detached ? 'ignore' : ['ignore', 'ignore', 'pipe'],
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagents detector. The installed-name enumeration for every agent's on-disk
|
|
3
|
+
* layout is declared once in the subagent registry; this detector is generic
|
|
4
|
+
* and delegates to `listInstalledSubagentNames` instead of a per-agent builder.
|
|
5
|
+
*/
|
|
1
6
|
import type { AgentId } from '../../types.js';
|
|
2
7
|
import type { ResourceDetector } from './types.js';
|
|
3
8
|
export declare const subagentsDetectors: Partial<Record<AgentId, ResourceDetector>>;
|
|
@@ -1,205 +1,18 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Subagents detector. Claude/Gemini/Grok: flat .md files under `<agentDir>/agents/`.
|
|
3
|
-
* Codex: flat .toml files under `<versionHome>/.codex/agents/`.
|
|
4
|
-
* Droid: flat .md files under `<versionHome>/.factory/droids/`.
|
|
5
|
-
* Cursor: flat .md files under `<versionHome>/.cursor/agents/`.
|
|
6
|
-
* ForgeCode: flat .md files under `<versionHome>/.forge/agents/`.
|
|
7
|
-
* OpenClaw: subdirectories containing AGENTS.md under `<versionHome>/.openclaw/`.
|
|
8
|
-
* Mirrors versions.ts:521-539.
|
|
9
|
-
*/
|
|
10
|
-
import * as fs from 'fs';
|
|
11
|
-
import * as path from 'path';
|
|
12
1
|
import { capableAgents } from '../../capabilities.js';
|
|
2
|
+
import { listInstalledSubagentNames } from '../../subagents-registry.js';
|
|
13
3
|
import { lazyAgentMap } from '../writers/lazy-map.js';
|
|
14
|
-
function
|
|
4
|
+
function buildSubagentsDetector(agent) {
|
|
15
5
|
return {
|
|
16
6
|
kind: 'subagents',
|
|
17
7
|
agent,
|
|
18
8
|
list({ versionHome }) {
|
|
19
|
-
|
|
20
|
-
if (!fs.existsSync(agentsDir))
|
|
21
|
-
return [];
|
|
22
|
-
return fs.readdirSync(agentsDir)
|
|
23
|
-
.filter(f => f.endsWith('.md'))
|
|
24
|
-
.map(f => f.replace('.md', ''));
|
|
9
|
+
return listInstalledSubagentNames(agent, versionHome);
|
|
25
10
|
},
|
|
26
11
|
};
|
|
27
12
|
}
|
|
28
|
-
function buildClaudeDetector() {
|
|
29
|
-
return buildFlatMdAgentsDetector('claude', '.claude');
|
|
30
|
-
}
|
|
31
|
-
function buildGrokDetector() {
|
|
32
|
-
return buildFlatMdAgentsDetector('grok', '.grok');
|
|
33
|
-
}
|
|
34
|
-
function buildGeminiDetector() {
|
|
35
|
-
return buildFlatMdAgentsDetector('gemini', '.gemini');
|
|
36
|
-
}
|
|
37
|
-
function buildCodexDetector() {
|
|
38
|
-
return {
|
|
39
|
-
kind: 'subagents',
|
|
40
|
-
agent: 'codex',
|
|
41
|
-
list({ versionHome }) {
|
|
42
|
-
const agentsDir = path.join(versionHome, '.codex', 'agents');
|
|
43
|
-
if (!fs.existsSync(agentsDir))
|
|
44
|
-
return [];
|
|
45
|
-
return fs.readdirSync(agentsDir)
|
|
46
|
-
.filter(f => f.endsWith('.toml'))
|
|
47
|
-
.map(f => f.replace(/\.toml$/, ''));
|
|
48
|
-
},
|
|
49
|
-
};
|
|
50
|
-
}
|
|
51
|
-
function buildDroidDetector() {
|
|
52
|
-
return {
|
|
53
|
-
kind: 'subagents',
|
|
54
|
-
agent: 'droid',
|
|
55
|
-
list({ versionHome }) {
|
|
56
|
-
const droidsDir = path.join(versionHome, '.factory', 'droids');
|
|
57
|
-
if (!fs.existsSync(droidsDir))
|
|
58
|
-
return [];
|
|
59
|
-
return fs.readdirSync(droidsDir)
|
|
60
|
-
.filter(f => f.endsWith('.md'))
|
|
61
|
-
.map(f => f.replace('.md', ''));
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
}
|
|
65
|
-
function buildCopilotDetector() {
|
|
66
|
-
return {
|
|
67
|
-
kind: 'subagents',
|
|
68
|
-
agent: 'copilot',
|
|
69
|
-
list({ versionHome }) {
|
|
70
|
-
const agentsDir = path.join(versionHome, '.copilot', 'agents');
|
|
71
|
-
if (!fs.existsSync(agentsDir))
|
|
72
|
-
return [];
|
|
73
|
-
return fs.readdirSync(agentsDir)
|
|
74
|
-
.filter(f => f.endsWith('.agent.md'))
|
|
75
|
-
.map(f => f.replace('.agent.md', ''));
|
|
76
|
-
},
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
function buildCursorDetector() {
|
|
80
|
-
return {
|
|
81
|
-
kind: 'subagents',
|
|
82
|
-
agent: 'cursor',
|
|
83
|
-
list({ versionHome }) {
|
|
84
|
-
const agentsDir = path.join(versionHome, '.cursor', 'agents');
|
|
85
|
-
if (!fs.existsSync(agentsDir))
|
|
86
|
-
return [];
|
|
87
|
-
return fs.readdirSync(agentsDir)
|
|
88
|
-
.filter(f => f.endsWith('.md'))
|
|
89
|
-
.map(f => f.replace(/\.md$/, ''));
|
|
90
|
-
},
|
|
91
|
-
};
|
|
92
|
-
}
|
|
93
|
-
function buildOpenclawDetector() {
|
|
94
|
-
return {
|
|
95
|
-
kind: 'subagents',
|
|
96
|
-
agent: 'openclaw',
|
|
97
|
-
list({ versionHome }) {
|
|
98
|
-
const openclawDir = path.join(versionHome, '.openclaw');
|
|
99
|
-
if (!fs.existsSync(openclawDir))
|
|
100
|
-
return [];
|
|
101
|
-
return fs.readdirSync(openclawDir, { withFileTypes: true })
|
|
102
|
-
.filter(d => d.isDirectory() && fs.existsSync(path.join(openclawDir, d.name, 'AGENTS.md')))
|
|
103
|
-
.map(d => d.name);
|
|
104
|
-
},
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
function buildKimiDetector() {
|
|
108
|
-
return {
|
|
109
|
-
kind: 'subagents',
|
|
110
|
-
agent: 'kimi',
|
|
111
|
-
list({ versionHome }) {
|
|
112
|
-
const agentsDir = path.join(versionHome, '.kimi-code', 'agents');
|
|
113
|
-
if (!fs.existsSync(agentsDir))
|
|
114
|
-
return [];
|
|
115
|
-
// Parent is `_agents-cli.yaml` (underscore-prefixed reserved name).
|
|
116
|
-
return fs.readdirSync(agentsDir)
|
|
117
|
-
.filter(f => f.endsWith('.yaml') && !f.startsWith('_'))
|
|
118
|
-
.map(f => f.replace(/\.yaml$/, ''));
|
|
119
|
-
},
|
|
120
|
-
};
|
|
121
|
-
}
|
|
122
|
-
function buildKiroDetector() {
|
|
123
|
-
return {
|
|
124
|
-
kind: 'subagents',
|
|
125
|
-
agent: 'kiro',
|
|
126
|
-
list({ versionHome }) {
|
|
127
|
-
const agentsDir = path.join(versionHome, '.kiro', 'agents');
|
|
128
|
-
if (!fs.existsSync(agentsDir))
|
|
129
|
-
return [];
|
|
130
|
-
return fs.readdirSync(agentsDir)
|
|
131
|
-
.filter(f => f.endsWith('.json'))
|
|
132
|
-
.map(f => f.replace(/\.json$/, ''));
|
|
133
|
-
},
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
function buildForgeDetector() {
|
|
137
|
-
return buildFlatMdAgentsDetector('forge', '.forge');
|
|
138
|
-
}
|
|
139
|
-
function buildGooseDetector() {
|
|
140
|
-
return {
|
|
141
|
-
kind: 'subagents',
|
|
142
|
-
agent: 'goose',
|
|
143
|
-
list({ versionHome }) {
|
|
144
|
-
const agentsDir = path.join(versionHome, '.config', 'goose', 'agents');
|
|
145
|
-
if (!fs.existsSync(agentsDir))
|
|
146
|
-
return [];
|
|
147
|
-
return fs.readdirSync(agentsDir)
|
|
148
|
-
.filter(f => f.endsWith('.yaml'))
|
|
149
|
-
.map(f => f.replace(/\.yaml$/, ''));
|
|
150
|
-
},
|
|
151
|
-
};
|
|
152
|
-
}
|
|
153
|
-
function buildOpenCodeDetector() {
|
|
154
|
-
return {
|
|
155
|
-
kind: 'subagents',
|
|
156
|
-
agent: 'opencode',
|
|
157
|
-
list({ versionHome }) {
|
|
158
|
-
const agentsDir = path.join(versionHome, '.config', 'opencode', 'agents');
|
|
159
|
-
if (!fs.existsSync(agentsDir))
|
|
160
|
-
return [];
|
|
161
|
-
return fs.readdirSync(agentsDir)
|
|
162
|
-
.filter(f => f.endsWith('.md'))
|
|
163
|
-
.map(f => f.replace(/\.md$/, ''));
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
}
|
|
167
|
-
function buildAntigravityDetector() {
|
|
168
|
-
return {
|
|
169
|
-
kind: 'subagents',
|
|
170
|
-
agent: 'antigravity',
|
|
171
|
-
list({ versionHome }) {
|
|
172
|
-
const agentsDir = path.join(versionHome, '.gemini', 'config', 'agents');
|
|
173
|
-
if (!fs.existsSync(agentsDir))
|
|
174
|
-
return [];
|
|
175
|
-
return fs.readdirSync(agentsDir, { withFileTypes: true })
|
|
176
|
-
.filter(d => d.isDirectory() && fs.existsSync(path.join(agentsDir, d.name, 'agent.md')))
|
|
177
|
-
.map(d => d.name);
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
const handlers = {
|
|
182
|
-
claude: buildClaudeDetector,
|
|
183
|
-
copilot: buildCopilotDetector,
|
|
184
|
-
gemini: buildGeminiDetector,
|
|
185
|
-
grok: buildGrokDetector,
|
|
186
|
-
codex: buildCodexDetector,
|
|
187
|
-
kimi: buildKimiDetector,
|
|
188
|
-
opencode: buildOpenCodeDetector,
|
|
189
|
-
antigravity: buildAntigravityDetector,
|
|
190
|
-
droid: buildDroidDetector,
|
|
191
|
-
openclaw: buildOpenclawDetector,
|
|
192
|
-
kiro: buildKiroDetector,
|
|
193
|
-
cursor: buildCursorDetector,
|
|
194
|
-
forge: buildForgeDetector,
|
|
195
|
-
goose: buildGooseDetector,
|
|
196
|
-
};
|
|
197
13
|
export const subagentsDetectors = lazyAgentMap(() => {
|
|
198
14
|
const m = {};
|
|
199
|
-
for (const agent of capableAgents('subagents'))
|
|
200
|
-
|
|
201
|
-
if (f)
|
|
202
|
-
m[agent] = f();
|
|
203
|
-
}
|
|
15
|
+
for (const agent of capableAgents('subagents'))
|
|
16
|
+
m[agent] = buildSubagentsDetector(agent);
|
|
204
17
|
return m;
|
|
205
18
|
});
|
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subagents writer. Every agent's on-disk layout (target dir, file/dir shape,
|
|
3
|
+
* transform, and any post-sync finalize such as Kimi's parent index) is
|
|
4
|
+
* declared once in the subagent registry; this writer is generic and iterates
|
|
5
|
+
* the registry instead of a per-agent `else if` chain.
|
|
6
|
+
*
|
|
7
|
+
* Source-side discovery is `listInstalledSubagents` from lib/subagents.ts —
|
|
8
|
+
* it reads user + system layers only (project layer excluded for the same
|
|
9
|
+
* defense as commands/skills/hooks).
|
|
10
|
+
*/
|
|
1
11
|
import type { AgentId } from '../../types.js';
|
|
2
12
|
import type { ResourceWriter } from './types.js';
|
|
3
13
|
export declare const subagentsWriters: Partial<Record<AgentId, ResourceWriter<string[]>>>;
|