@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +9 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +55 -28
  5. package/dist/commands/feed.d.ts +4 -0
  6. package/dist/commands/feed.js +27 -8
  7. package/dist/commands/lease.d.ts +23 -0
  8. package/dist/commands/lease.js +201 -0
  9. package/dist/commands/mailboxes.d.ts +20 -0
  10. package/dist/commands/mailboxes.js +390 -0
  11. package/dist/commands/routines.js +20 -14
  12. package/dist/commands/sessions-export.d.ts +2 -0
  13. package/dist/commands/sessions-export.js +279 -0
  14. package/dist/commands/sessions-import.d.ts +2 -0
  15. package/dist/commands/sessions-import.js +230 -0
  16. package/dist/commands/sessions.js +4 -0
  17. package/dist/commands/ssh.js +98 -3
  18. package/dist/commands/usage.d.ts +2 -0
  19. package/dist/commands/usage.js +7 -2
  20. package/dist/commands/view.d.ts +1 -1
  21. package/dist/index.js +2 -1
  22. package/dist/lib/agents.d.ts +18 -0
  23. package/dist/lib/agents.js +27 -17
  24. package/dist/lib/browser/drivers/ssh.js +19 -2
  25. package/dist/lib/comms-render.d.ts +37 -0
  26. package/dist/lib/comms-render.js +89 -0
  27. package/dist/lib/crabbox/cli.d.ts +72 -0
  28. package/dist/lib/crabbox/cli.js +158 -9
  29. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  30. package/dist/lib/crabbox/runtimes.js +24 -0
  31. package/dist/lib/daemon.js +6 -1
  32. package/dist/lib/devices/health.d.ts +77 -0
  33. package/dist/lib/devices/health.js +186 -0
  34. package/dist/lib/mailbox.d.ts +39 -0
  35. package/dist/lib/mailbox.js +112 -0
  36. package/dist/lib/paths.d.ts +13 -0
  37. package/dist/lib/paths.js +26 -4
  38. package/dist/lib/routines.d.ts +21 -2
  39. package/dist/lib/routines.js +35 -12
  40. package/dist/lib/runner.js +255 -13
  41. package/dist/lib/sandbox.d.ts +9 -1
  42. package/dist/lib/sandbox.js +11 -2
  43. package/dist/lib/session/bundle.d.ts +150 -0
  44. package/dist/lib/session/bundle.js +189 -0
  45. package/dist/lib/session/remote-bundle.d.ts +12 -0
  46. package/dist/lib/session/remote-bundle.js +61 -0
  47. package/dist/lib/session/sync/agents.d.ts +54 -6
  48. package/dist/lib/session/sync/agents.js +0 -0
  49. package/dist/lib/session/sync/manifest.d.ts +14 -3
  50. package/dist/lib/session/sync/manifest.js +4 -0
  51. package/dist/lib/session/sync/sync.d.ts +23 -2
  52. package/dist/lib/session/sync/sync.js +177 -74
  53. package/dist/lib/ssh-tunnel.js +13 -1
  54. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  55. package/dist/lib/staleness/detectors/subagents.js +5 -192
  56. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  57. package/dist/lib/staleness/writers/subagents.js +11 -102
  58. package/dist/lib/startup/command-registry.d.ts +2 -0
  59. package/dist/lib/startup/command-registry.js +5 -0
  60. package/dist/lib/subagents-registry.d.ts +85 -0
  61. package/dist/lib/subagents-registry.js +393 -0
  62. package/dist/lib/subagents.d.ts +8 -8
  63. package/dist/lib/subagents.js +32 -663
  64. package/dist/lib/sync-umbrella.d.ts +1 -0
  65. package/dist/lib/sync-umbrella.js +14 -3
  66. package/dist/lib/types.d.ts +9 -0
  67. package/dist/lib/usage.d.ts +42 -3
  68. package/dist/lib/usage.js +162 -22
  69. 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
- let stat;
46
- try {
47
- stat = fs.statSync(t.absPath);
48
- }
49
- catch {
50
- continue;
51
- }
52
- const prevEntry = prev?.agents?.[spec.id]?.[t.sessionId];
53
- if (prevEntry && ledgerUnchanged(ledger, t.absPath, stat.size, stat.mtimeMs)) {
54
- agentManifest[t.sessionId] = prevEntry; // unchanged: reuse, no read, no upload
55
- result.pushSkipped++;
56
- continue;
57
- }
58
- let content;
59
- try {
60
- content = fs.readFileSync(t.absPath, 'utf-8');
61
- }
62
- catch {
63
- continue;
64
- }
65
- // Identity for CRDT merge is the PLAINTEXT hash (ciphertext is
66
- // non-deterministic), so hash + manifest are computed on cleartext; only
67
- // the stored object body is sealed.
68
- const hash = hashContent(content);
69
- const { lastTs } = transcriptStats(content);
70
- const entry = { relKey: t.relKey, size: stat.size, hash, lastTs };
71
- const body = encKey ? encryptTranscript(content, encKey) : content;
72
- const contentType = encKey ? 'application/json' : 'application/x-ndjson';
73
- try {
74
- await r2.put(objectKey(me, spec.id, t.sessionId), body, contentType);
75
- ledgerRecord(ledger, t.absPath, stat.size, stat.mtimeMs, hash);
76
- agentManifest[t.sessionId] = entry;
77
- result.pushed++;
78
- if (opts.verbose)
79
- opts.log?.(` push ${spec.id}/${t.sessionId.slice(0, 8)} (${stat.size}B)`);
80
- }
81
- catch (err) {
82
- result.errors.push(`push ${spec.id}/${t.sessionId}: ${err.message}`);
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 + merged content for one session. Pure.
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; the content is the CRDT union of copies.
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
- const content = contents.length === 1 ? contents[0] : mergeTranscripts(contents);
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: contents.length > 1,
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, entry] of Object.entries(sessions)) {
236
+ for (const [sessionId, value] of Object.entries(sessions)) {
180
237
  const list = byAgent.get(sessionId) ?? [];
181
- list.push({ machine: m, entry });
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
- // Download each copy (could be a fork across >1 machine), keeping the
201
- // result positionally aligned to `list` null marks a copy we couldn't
202
- // fetch this tick (404 / consistency lag / error).
203
- const fetched = [];
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
- try {
206
- const body = await r2.get(objectKey(c.machine, agentId, sessionId));
207
- // Decrypt before the body reaches the CRDT union — the merge and the
208
- // mirror always operate on plaintext. A legacy plaintext object passes
209
- // through untouched; an envelope without a key throws (surfaced below).
210
- fetched.push(body === null ? null : decryptTranscriptBody(body, encKey));
211
- }
212
- catch (err) {
213
- result.errors.push(`get ${c.machine}/${sessionId}: ${err.message}`);
214
- fetched.push(null);
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
- // null ⇒ an incomplete fetch: skip the write AND the pull-state stamp so we
218
- // retry next tick instead of persisting a partial union / abandoning a branch.
219
- const resolved = reconcileCopies(spec, list, fetched);
220
- if (!resolved)
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
- const { dest, content, merged } = resolved;
312
+ }
313
+ if (incomplete)
314
+ continue; // retry next tick, session intact
223
315
  try {
224
- let existing = null;
225
- try {
226
- existing = fs.readFileSync(dest, 'utf-8');
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
- catch { /* not present yet */ }
229
- if (existing !== content) {
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
- opts.log?.(` pull ${agentId}/${sessionId.slice(0, 8)} <- ${list.map(c => c.machine).join('+')}`);
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 {
@@ -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 buildFlatMdAgentsDetector(agent, agentsRoot) {
4
+ function buildSubagentsDetector(agent) {
15
5
  return {
16
6
  kind: 'subagents',
17
7
  agent,
18
8
  list({ versionHome }) {
19
- const agentsDir = path.join(versionHome, agentsRoot, 'agents');
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
- const f = handlers[agent];
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[]>>>;