@phnx-labs/agents-cli 1.20.11 → 1.20.13

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 (61) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +3 -0
  3. package/dist/commands/computer-actions.d.ts +3 -0
  4. package/dist/commands/computer-actions.js +16 -0
  5. package/dist/commands/exec.js +25 -4
  6. package/dist/commands/import.js +17 -6
  7. package/dist/commands/inspect.d.ts +11 -1
  8. package/dist/commands/inspect.js +53 -19
  9. package/dist/commands/mcp.js +3 -3
  10. package/dist/commands/plugins.d.ts +2 -0
  11. package/dist/commands/plugins.js +69 -26
  12. package/dist/commands/sync.js +1 -1
  13. package/dist/commands/teams.js +1 -0
  14. package/dist/commands/trash.d.ts +11 -0
  15. package/dist/commands/trash.js +57 -41
  16. package/dist/commands/versions.js +68 -20
  17. package/dist/commands/view.js +1 -12
  18. package/dist/commands/wallet.d.ts +14 -0
  19. package/dist/commands/wallet.js +199 -0
  20. package/dist/index.js +22 -1
  21. package/dist/lib/agents.js +70 -22
  22. package/dist/lib/browser/ipc.d.ts +7 -0
  23. package/dist/lib/browser/ipc.js +43 -27
  24. package/dist/lib/capabilities.js +7 -1
  25. package/dist/lib/command-skills.d.ts +1 -0
  26. package/dist/lib/command-skills.js +23 -7
  27. package/dist/lib/exec.d.ts +32 -1
  28. package/dist/lib/exec.js +79 -7
  29. package/dist/lib/hooks.js +37 -5
  30. package/dist/lib/mcp.js +33 -0
  31. package/dist/lib/models.js +5 -0
  32. package/dist/lib/picker.d.ts +2 -0
  33. package/dist/lib/picker.js +96 -6
  34. package/dist/lib/platform/index.d.ts +1 -0
  35. package/dist/lib/platform/index.js +1 -0
  36. package/dist/lib/platform/winpath.d.ts +35 -0
  37. package/dist/lib/platform/winpath.js +86 -0
  38. package/dist/lib/plugins.d.ts +14 -0
  39. package/dist/lib/plugins.js +23 -0
  40. package/dist/lib/project-launch.js +110 -5
  41. package/dist/lib/registry.js +15 -2
  42. package/dist/lib/runner.js +14 -0
  43. package/dist/lib/sandbox.js +5 -2
  44. package/dist/lib/settings-manifest.d.ts +39 -0
  45. package/dist/lib/settings-manifest.js +163 -0
  46. package/dist/lib/shims.d.ts +1 -1
  47. package/dist/lib/shims.js +16 -31
  48. package/dist/lib/staleness/detectors/subagents.js +16 -0
  49. package/dist/lib/staleness/writers/subagents.js +11 -3
  50. package/dist/lib/subagents.d.ts +9 -0
  51. package/dist/lib/subagents.js +33 -0
  52. package/dist/lib/teams/agents.js +1 -1
  53. package/dist/lib/teams/parsers.d.ts +1 -1
  54. package/dist/lib/teams/parsers.js +6 -0
  55. package/dist/lib/types.d.ts +1 -1
  56. package/dist/lib/versions.d.ts +15 -3
  57. package/dist/lib/versions.js +88 -19
  58. package/dist/lib/wallet/index.d.ts +78 -0
  59. package/dist/lib/wallet/index.js +253 -0
  60. package/package.json +3 -3
  61. package/scripts/postinstall.js +35 -7
@@ -14,6 +14,86 @@
14
14
  */
15
15
  import { createPrompt, useState, useKeypress, useEffect, useMemo, usePagination, usePrefix, makeTheme, isEnterKey, isUpKey, isDownKey, isSpaceKey, Separator, } from '@inquirer/core';
16
16
  import chalk from 'chalk';
17
+ import { stripVTControlCharacters } from 'node:util';
18
+ const DEFAULT_TERMINAL_ROWS = 24;
19
+ const DEFAULT_TERMINAL_WIDTH = 80;
20
+ function terminalWidth() {
21
+ return Math.max(1, process.stdout.columns || DEFAULT_TERMINAL_WIDTH);
22
+ }
23
+ function terminalRows() {
24
+ return Math.max(1, process.stdout.rows || DEFAULT_TERMINAL_ROWS);
25
+ }
26
+ function renderedRows(text, width) {
27
+ const normalizedWidth = Math.max(1, width);
28
+ return text.split('\n').reduce((rows, line) => {
29
+ const visible = stripVTControlCharacters(line).length;
30
+ return rows + Math.max(1, Math.ceil(visible / normalizedWidth));
31
+ }, 0);
32
+ }
33
+ function truncateAnsiLine(line, maxVisibleWidth) {
34
+ if (maxVisibleWidth <= 0)
35
+ return '';
36
+ const targetWidth = Math.max(0, maxVisibleWidth - 1);
37
+ const ansiPattern = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/y;
38
+ let out = '';
39
+ let visible = 0;
40
+ for (let i = 0; i < line.length;) {
41
+ ansiPattern.lastIndex = i;
42
+ const ansi = ansiPattern.exec(line);
43
+ if (ansi) {
44
+ out += ansi[0];
45
+ i = ansiPattern.lastIndex;
46
+ continue;
47
+ }
48
+ const char = line[i];
49
+ if (visible >= targetWidth)
50
+ break;
51
+ out += char;
52
+ visible += 1;
53
+ i += char.length;
54
+ }
55
+ return out + '\x1b[0m' + chalk.gray('…');
56
+ }
57
+ function takePreviewRows(preview, rowBudget, width) {
58
+ const lines = preview.split('\n');
59
+ const out = [];
60
+ let used = 0;
61
+ for (const line of lines) {
62
+ const lineRows = renderedRows(line, width);
63
+ if (used + lineRows <= rowBudget) {
64
+ out.push(line);
65
+ used += lineRows;
66
+ continue;
67
+ }
68
+ const remainingRows = rowBudget - used;
69
+ if (remainingRows > 0) {
70
+ out.push(truncateAnsiLine(line, remainingRows * width));
71
+ }
72
+ break;
73
+ }
74
+ return out;
75
+ }
76
+ function previewTruncatedMarker(width) {
77
+ const full = '... preview truncated to fit terminal';
78
+ const short = '... truncated';
79
+ const text = full.length <= width ? full : short;
80
+ if (text.length <= width)
81
+ return chalk.gray(text);
82
+ return chalk.gray(text.slice(0, Math.max(0, width - 1)) + '…');
83
+ }
84
+ /** Clip a picker preview so the full prompt can fit in the terminal viewport. */
85
+ export function limitPreviewHeight(preview, maxRows, width) {
86
+ const normalizedRows = Math.max(0, maxRows);
87
+ if (normalizedRows === 0)
88
+ return '';
89
+ if (renderedRows(preview, width) <= normalizedRows)
90
+ return preview;
91
+ if (normalizedRows === 1)
92
+ return previewTruncatedMarker(width);
93
+ const lines = takePreviewRows(preview, normalizedRows - 1, width);
94
+ lines.push(previewTruncatedMarker(width));
95
+ return lines.join('\n');
96
+ }
17
97
  /** Show an interactive fuzzy-filter picker and return the selected item, or null on cancel. */
18
98
  export function itemPicker(config) {
19
99
  const prompt = createPrompt((cfg, done) => {
@@ -90,18 +170,28 @@ export function itemPicker(config) {
90
170
  pageSize: cfg.pageSize ?? 10,
91
171
  loop: false,
92
172
  });
173
+ const enter = cfg.enterHint ?? 'select';
174
+ const help = previewOpen
175
+ ? chalk.gray(`↑↓ navigate · space: close preview · ⏎ ${enter} · esc: cancel`)
176
+ : chalk.gray(`↑↓ navigate${hasPreview ? ' · space: preview' : ''} · ⏎ ${enter} · esc: cancel`);
93
177
  const parts = [header, page];
94
178
  if (results.length === 0) {
95
179
  parts.push(chalk.gray(` ${cfg.emptyMessage ?? 'No matches.'}`));
96
180
  }
97
181
  if (previewOpen && selected && cfg.buildPreview) {
98
- parts.push(chalk.gray('─'.repeat(Math.min(process.stdout.columns || 80, 80))));
99
- parts.push(cfg.buildPreview(selected.value));
182
+ const width = terminalWidth();
183
+ const separator = chalk.gray('─'.repeat(Math.min(width, 80)));
184
+ const fixedRows = renderedRows(header, width) +
185
+ renderedRows(parts.slice(1).join('\n'), width) +
186
+ renderedRows(separator, width) +
187
+ renderedRows(help, width);
188
+ const availablePreviewRows = terminalRows() - fixedRows;
189
+ const preview = limitPreviewHeight(cfg.buildPreview(selected.value), availablePreviewRows, width);
190
+ if (preview) {
191
+ parts.push(separator);
192
+ parts.push(preview);
193
+ }
100
194
  }
101
- const enter = cfg.enterHint ?? 'select';
102
- const help = previewOpen
103
- ? chalk.gray(`↑↓ navigate · space: close preview · ⏎ ${enter} · esc: cancel`)
104
- : chalk.gray(`↑↓ navigate${hasPreview ? ' · space: preview' : ''} · ⏎ ${enter} · esc: cancel`);
105
195
  parts.push(help);
106
196
  return [header, parts.slice(1).join('\n')];
107
197
  });
@@ -19,3 +19,4 @@ export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
21
  export * from './ipc.js';
22
+ export * from './winpath.js';
@@ -19,3 +19,4 @@ export * from './paths.js';
19
19
  export * from './exec.js';
20
20
  export * from './process.js';
21
21
  export * from './ipc.js';
22
+ export * from './winpath.js';
@@ -0,0 +1,35 @@
1
+ export interface WinPathResult {
2
+ success: boolean;
3
+ /** True when `dir` was already the first PATH entry (no write performed). */
4
+ alreadyPresent?: boolean;
5
+ error?: string;
6
+ }
7
+ /**
8
+ * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
9
+ * already first; moves it to the front when it exists but is positioned later
10
+ * (e.g. appended by an older install) so it overrides conflicting entries.
11
+ * `dir` is passed via an env var so it is never interpolated into the script
12
+ * text.
13
+ */
14
+ export declare function prependToWindowsUserPath(dir: string): WinPathResult;
15
+ /**
16
+ * The effective PowerShell execution policy (e.g. `Restricted`, `RemoteSigned`),
17
+ * or null if it can't be determined (PowerShell missing / errored).
18
+ */
19
+ export declare function getEffectiveExecutionPolicy(): string | null;
20
+ /**
21
+ * Whether a policy blocks running unsigned local `.ps1` scripts — which is what
22
+ * npm and agents-cli generate (`npm.ps1`, `agents.ps1`). Under these the bare
23
+ * `agents` / `npm` commands fail in PowerShell with a security error even when
24
+ * on PATH. Pure — testable on any host.
25
+ */
26
+ export declare function blocksLocalScripts(policy: string | null): boolean;
27
+ /**
28
+ * Resolve the npm global-bin directory (where the generated `agents` /
29
+ * `agents.cmd` launchers live, and where npm expects PATH to point) from the
30
+ * package entrypoint. On Windows npm places bin launchers directly in the
31
+ * prefix root, so the bin dir is the prefix itself.
32
+ *
33
+ * entry = `<prefix>/node_modules/@phnx-labs/agents-cli/dist/index.js` → `<prefix>`
34
+ */
35
+ export declare function npmGlobalBinFromEntry(entryJsPath: string): string;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Windows User PATH + execution-policy primitives.
3
+ *
4
+ * The single place that mutates the Windows User PATH via the .NET environment
5
+ * API (which writes the registry AND broadcasts WM_SETTINGCHANGE — the correct
6
+ * analog of editing a shell rc file: no `setx` truncation, no manual step).
7
+ * Consumers: `shims.ts` (shims dir) and `scripts/postinstall.js` (npm global-bin
8
+ * dir, so the `agents` command itself resolves).
9
+ *
10
+ * Leaf module — imports only `child_process` and `path` so it is cheap to load
11
+ * from the npm lifecycle script without pulling the rest of the CLI.
12
+ */
13
+ import { execFileSync } from 'child_process';
14
+ import * as path from 'path';
15
+ /**
16
+ * Prepend `dir` to the Windows User PATH. Idempotent: a no-op when `dir` is
17
+ * already first; moves it to the front when it exists but is positioned later
18
+ * (e.g. appended by an older install) so it overrides conflicting entries.
19
+ * `dir` is passed via an env var so it is never interpolated into the script
20
+ * text.
21
+ */
22
+ export function prependToWindowsUserPath(dir) {
23
+ const script = [
24
+ '$d = $env:AGENTS_WINPATH_DIR',
25
+ "$u = [Environment]::GetEnvironmentVariable('Path','User')",
26
+ "if ($null -eq $u) { $u = '' }",
27
+ "$parts = @($u -split ';' | Where-Object { $_ -ne '' })",
28
+ // Already first — nothing to do
29
+ "if ($parts.Count -gt 0 -and $parts[0] -eq $d) { 'present' } else {",
30
+ // Remove any existing occurrence then prepend, matching POSIX `export PATH="${dir}:$PATH"`
31
+ " $newParts = @($d) + @($parts | Where-Object { $_ -ne $d })",
32
+ " [Environment]::SetEnvironmentVariable('Path', ($newParts -join ';'), 'User')",
33
+ " 'added'",
34
+ '}',
35
+ ].join('\n');
36
+ try {
37
+ const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', script], {
38
+ encoding: 'utf-8',
39
+ env: { ...process.env, AGENTS_WINPATH_DIR: dir },
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ }).trim();
42
+ return { success: true, alreadyPresent: out.includes('present') };
43
+ }
44
+ catch (err) {
45
+ return { success: false, error: `Could not update the Windows user PATH: ${err.message}` };
46
+ }
47
+ }
48
+ /**
49
+ * The effective PowerShell execution policy (e.g. `Restricted`, `RemoteSigned`),
50
+ * or null if it can't be determined (PowerShell missing / errored).
51
+ */
52
+ export function getEffectiveExecutionPolicy() {
53
+ try {
54
+ const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy'], {
55
+ encoding: 'utf-8',
56
+ stdio: ['ignore', 'pipe', 'pipe'],
57
+ }).trim();
58
+ return out || null;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Whether a policy blocks running unsigned local `.ps1` scripts — which is what
66
+ * npm and agents-cli generate (`npm.ps1`, `agents.ps1`). Under these the bare
67
+ * `agents` / `npm` commands fail in PowerShell with a security error even when
68
+ * on PATH. Pure — testable on any host.
69
+ */
70
+ export function blocksLocalScripts(policy) {
71
+ if (!policy)
72
+ return false;
73
+ const p = policy.trim().toLowerCase();
74
+ return p === 'restricted' || p === 'allsigned';
75
+ }
76
+ /**
77
+ * Resolve the npm global-bin directory (where the generated `agents` /
78
+ * `agents.cmd` launchers live, and where npm expects PATH to point) from the
79
+ * package entrypoint. On Windows npm places bin launchers directly in the
80
+ * prefix root, so the bin dir is the prefix itself.
81
+ *
82
+ * entry = `<prefix>/node_modules/@phnx-labs/agents-cli/dist/index.js` → `<prefix>`
83
+ */
84
+ export function npmGlobalBinFromEntry(entryJsPath) {
85
+ return path.resolve(path.dirname(entryJsPath), '..', '..', '..', '..');
86
+ }
@@ -41,6 +41,20 @@ export declare function discoverPlugins(opts?: {
41
41
  cwd?: string;
42
42
  }): DiscoveredPlugin[];
43
43
  export declare function buildDiscoveredPlugin(pluginRoot: string, manifest: PluginManifest, spec?: MarketplaceSpec): DiscoveredPlugin;
44
+ /** One category of resources a plugin packages, for display breakdowns. */
45
+ export interface PluginResourceGroup {
46
+ /** Category key: 'skills' | 'commands' | 'subagents' | 'hooks' | 'mcp' | 'lsp' | 'monitors' | 'bin' | 'scripts' | 'settings'. */
47
+ label: string;
48
+ /** Display names — slash-prefixed for skills/commands (e.g. `/code:dispatch`), raw names otherwise. */
49
+ items: string[];
50
+ }
51
+ /**
52
+ * Ordered, non-empty resource groups a plugin packages. Single source of truth
53
+ * for the breakdown shown by the plugin picker, `agents inspect --plugins`, and
54
+ * its detail view. Empty categories are omitted; `settings` appears only when
55
+ * the plugin merges non-permission settings.
56
+ */
57
+ export declare function pluginResourceGroups(plugin: DiscoveredPlugin): PluginResourceGroup[];
44
58
  export declare function inspectPluginCapabilities(pluginRoot: string): PluginCapabilities;
45
59
  export declare function hasPluginExecSurfaces(capabilities: PluginCapabilities): boolean;
46
60
  export declare function pluginCapabilityLabels(capabilities: PluginCapabilities): string[];
@@ -105,6 +105,29 @@ export function buildDiscoveredPlugin(pluginRoot, manifest, spec = { kind: 'user
105
105
  hasSettings: pluginHasNonPermissionSettings(pluginRoot),
106
106
  };
107
107
  }
108
+ /**
109
+ * Ordered, non-empty resource groups a plugin packages. Single source of truth
110
+ * for the breakdown shown by the plugin picker, `agents inspect --plugins`, and
111
+ * its detail view. Empty categories are omitted; `settings` appears only when
112
+ * the plugin merges non-permission settings.
113
+ */
114
+ export function pluginResourceGroups(plugin) {
115
+ const groups = [
116
+ { label: 'skills', items: plugin.skills.map((s) => `/${plugin.name}:${s}`) },
117
+ { label: 'commands', items: plugin.commands.map((c) => `/${plugin.name}:${c}`) },
118
+ { label: 'subagents', items: plugin.agentDefs },
119
+ { label: 'hooks', items: plugin.hooks },
120
+ { label: 'mcp', items: plugin.mcpServers },
121
+ { label: 'lsp', items: plugin.lspServers },
122
+ { label: 'monitors', items: plugin.monitors },
123
+ { label: 'bin', items: plugin.bin },
124
+ { label: 'scripts', items: plugin.scripts },
125
+ ];
126
+ const out = groups.filter((g) => g.items.length > 0);
127
+ if (plugin.hasSettings)
128
+ out.push({ label: 'settings', items: ['settings.json'] });
129
+ return out;
130
+ }
108
131
  export function inspectPluginCapabilities(pluginRoot) {
109
132
  const manifest = loadPluginManifest(pluginRoot);
110
133
  const plugin = manifest ? buildDiscoveredPlugin(pluginRoot, manifest) : null;
@@ -47,6 +47,7 @@ import * as path from 'path';
47
47
  import { supports } from './capabilities.js';
48
48
  import { getEnabledExtraRepos, getExtraPluginsDir, getPluginsDir, getProjectAgentsDir, getProjectPluginsDir, getSystemPluginsDir, } from './state.js';
49
49
  import { getVersionHomePath } from './versions.js';
50
+ import { transformSubagentForClaude } from './subagents.js';
50
51
  import { compileRulesForProject } from './rules/compile.js';
51
52
  import { discoverPluginsInDir, hasPluginExecSurfaces, inspectPluginCapabilities } from './plugins.js';
52
53
  import { MARKETPLACE_NAME, PROJECT_MARKETPLACE_NAME, SYSTEM_MARKETPLACE_NAME, addPluginToSettings, copyPluginToMarketplace, marketplaceNameFor, marketplaceRoot, pluginInstallDir, registerMarketplace, removePluginFromSettings, syncMarketplaceManifest, } from './plugin-marketplace.js';
@@ -114,10 +115,22 @@ function touchLaunchSentinel(agent, version, cwd) {
114
115
  }
115
116
  }
116
117
  const CLAUDE_MIRROR_PLANS = [
117
- { srcSubdir: 'subagents', destSubdir: 'agents', entriesAreDirs: false },
118
- { srcSubdir: 'commands', destSubdir: 'commands', entriesAreDirs: false },
119
- { srcSubdir: 'skills', destSubdir: 'skills', entriesAreDirs: true },
118
+ { srcSubdir: 'subagents', destSubdir: 'agents', mode: 'subagent-write' },
119
+ { srcSubdir: 'commands', destSubdir: 'commands', mode: 'file-symlink' },
120
+ { srcSubdir: 'skills', destSubdir: 'skills', mode: 'dir-symlink' },
120
121
  ];
122
+ /**
123
+ * Marker prepended-as-trailing-comment to every subagent file WE generate.
124
+ * It's an HTML comment — invisible to the markdown the agent reads — placed on
125
+ * the last line so it never disturbs the leading `---` frontmatter block.
126
+ *
127
+ * Ownership rule (the one don't-clobber decision for written, non-symlink
128
+ * files): we only overwrite a `.claude/agents/<name>.md` whose content carries
129
+ * this marker. A user-authored file (no marker) or a symlink at the dest is
130
+ * left untouched. A marker beats an mtime/sidecar sentinel because it travels
131
+ * with the file across copies and git, and needs no out-of-band state.
132
+ */
133
+ const GENERATED_SUBAGENT_MARKER = '<!-- agents-cli:generated-subagent';
121
134
  function mirrorWorkspaceResources(cwd, agent) {
122
135
  // v1: claude-only. Other agents have workspace conventions we haven't
123
136
  // mapped (amp: ~/.config/amp; antigravity: ~/.gemini/antigravity-cli;
@@ -147,13 +160,20 @@ function mirrorWorkspaceResources(cwd, agent) {
147
160
  continue;
148
161
  const destDir = path.join(agentWorkspaceDir, plan.destSubdir);
149
162
  fs.mkdirSync(destDir, { recursive: true });
163
+ // Subagents flatten N source files into one written .md — not a symlink.
164
+ if (plan.mode === 'subagent-write') {
165
+ const r = writeProjectSubagents(srcDir, destDir, cwd);
166
+ links += r.links;
167
+ skipped.push(...r.skipped);
168
+ continue;
169
+ }
150
170
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
151
171
  for (const entry of entries) {
152
172
  if (entry.name.startsWith('.'))
153
173
  continue;
154
- if (plan.entriesAreDirs && !entry.isDirectory())
174
+ if (plan.mode === 'dir-symlink' && !entry.isDirectory())
155
175
  continue;
156
- if (!plan.entriesAreDirs && !entry.isFile() && !entry.isSymbolicLink())
176
+ if (plan.mode === 'file-symlink' && !entry.isFile() && !entry.isSymbolicLink())
157
177
  continue;
158
178
  const srcPath = path.join(srcDir, entry.name);
159
179
  const destPath = path.join(destDir, entry.name);
@@ -167,6 +187,91 @@ function mirrorWorkspaceResources(cwd, agent) {
167
187
  }
168
188
  return { links, skipped };
169
189
  }
190
+ /**
191
+ * Mirror project subagents into `<cwd>/.claude/agents/`. The canonical source
192
+ * shape is a DIRECTORY containing AGENT.md (e.g. `.agents/subagents/probe/AGENT.md`)
193
+ * — confirmed by the detector (versions.ts) and lister (subagents.ts). Each
194
+ * such directory is flattened via transformSubagentForClaude (the exact writer
195
+ * the version-home sync uses) into a single `<name>.md`, then written under an
196
+ * ownership marker so a re-launch refreshes our file but never clobbers a
197
+ * user-authored one.
198
+ *
199
+ * Returns the same {links, skipped} shape the symlink path reports, so the
200
+ * caller's accounting is uniform across resource kinds.
201
+ */
202
+ function writeProjectSubagents(srcDir, destDir, cwd) {
203
+ let links = 0;
204
+ const skipped = [];
205
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
206
+ if (entry.name.startsWith('.'))
207
+ continue;
208
+ if (!entry.isDirectory())
209
+ continue;
210
+ const subagentDir = path.join(srcDir, entry.name);
211
+ if (!fs.existsSync(path.join(subagentDir, 'AGENT.md')))
212
+ continue;
213
+ const destPath = path.join(destDir, `${entry.name}.md`);
214
+ if (writeSubagentIfOwned(subagentDir, destPath)) {
215
+ links += 1;
216
+ }
217
+ else {
218
+ skipped.push(path.relative(cwd, destPath));
219
+ }
220
+ }
221
+ return { links, skipped };
222
+ }
223
+ /**
224
+ * Write a flattened subagent file at `destPath`, refusing to clobber user state.
225
+ *
226
+ * - dest missing → write fresh.
227
+ * - dest is our generation → overwrite (refresh; carries GENERATED_SUBAGENT_MARKER).
228
+ * - dest is a symlink / any
229
+ * non-regular file → SKIP (user state we don't own).
230
+ * - dest is a regular file
231
+ * without our marker → SKIP (hand-authored .claude/agents/<name>.md).
232
+ *
233
+ * Returns true when our file is present (written now or already current),
234
+ * false when we left a user-owned dest alone.
235
+ */
236
+ function writeSubagentIfOwned(subagentDir, destPath) {
237
+ let existing = null;
238
+ let destLstat = null;
239
+ try {
240
+ destLstat = fs.lstatSync(destPath);
241
+ }
242
+ catch { /* missing — write fresh */ }
243
+ if (destLstat) {
244
+ if (!destLstat.isFile())
245
+ return false; // symlink/dir/etc. — user state
246
+ try {
247
+ existing = fs.readFileSync(destPath, 'utf-8');
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ if (!existing.includes(GENERATED_SUBAGENT_MARKER))
253
+ return false; // hand-authored
254
+ }
255
+ let body;
256
+ try {
257
+ body = transformSubagentForClaude(subagentDir);
258
+ }
259
+ catch {
260
+ return false; // malformed AGENT.md — don't write a broken file
261
+ }
262
+ const content = `${body}\n\n${GENERATED_SUBAGENT_MARKER} — edit .agents/subagents/${path.basename(subagentDir)}/ instead -->\n`;
263
+ // Skip-fast: identical content already on disk → no write (keeps mtime stable).
264
+ if (existing === content)
265
+ return true;
266
+ try {
267
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
268
+ fs.writeFileSync(destPath, content);
269
+ }
270
+ catch {
271
+ return false;
272
+ }
273
+ return true;
274
+ }
170
275
  /**
171
276
  * Create or refresh a symlink at `destPath` pointing at `srcPath`. Returns
172
277
  * true if we wrote (or already had) the link, false if we skipped because
@@ -61,6 +61,13 @@ export function removeRegistry(type, name) {
61
61
  }
62
62
  return false;
63
63
  }
64
+ /**
65
+ * Cap every registry network call. Without this a slow or unreachable registry
66
+ * hangs the calling command indefinitely (`agents add`, `agents mcp`, package
67
+ * resolution) — and makes CI flake when the registry is unreachable. On timeout
68
+ * the fetch aborts, callers fall back to their git/no-match path.
69
+ */
70
+ const REGISTRY_FETCH_TIMEOUT_MS = 8000;
64
71
  async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
65
72
  const params = new URLSearchParams();
66
73
  if (query)
@@ -73,7 +80,10 @@ async function fetchMcpRegistry(url, query, limit = 20, apiKey) {
73
80
  if (apiKey) {
74
81
  headers['Authorization'] = `Bearer ${apiKey}`;
75
82
  }
76
- const response = await fetch(fullUrl, { headers });
83
+ const response = await fetch(fullUrl, {
84
+ headers,
85
+ signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
86
+ });
77
87
  if (!response.ok) {
78
88
  throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
79
89
  }
@@ -191,7 +201,10 @@ async function fetchSkillIndex(url, apiKey) {
191
201
  const headers = { Accept: 'application/json' };
192
202
  if (apiKey)
193
203
  headers['Authorization'] = `Bearer ${apiKey}`;
194
- const response = await fetch(url, { headers });
204
+ const response = await fetch(url, {
205
+ headers,
206
+ signal: AbortSignal.timeout(REGISTRY_FETCH_TIMEOUT_MS),
207
+ });
195
208
  if (!response.ok) {
196
209
  throw new Error(`Registry request failed: ${response.status} ${response.statusText}`);
197
210
  }
@@ -22,6 +22,7 @@ const AGENT_COMMANDS = {
22
22
  codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
23
23
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
24
24
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
25
+ droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
25
26
  };
26
27
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
27
28
  export function buildJobCommand(config, resolvedPrompt) {
@@ -105,6 +106,19 @@ export function buildJobCommand(config, resolvedPrompt) {
105
106
  }
106
107
  appendModelAndReasoning(cmd, config);
107
108
  }
109
+ if (config.agent === 'droid') {
110
+ // droid exec defaults to read-only (plan). Escalate autonomy per mode.
111
+ if (mode === 'edit') {
112
+ cmd.push('--auto', 'low');
113
+ }
114
+ else if (mode === 'auto') {
115
+ cmd.push('--auto', 'high');
116
+ }
117
+ else if (mode === 'skip') {
118
+ cmd.push('--skip-permissions-unsafe');
119
+ }
120
+ appendModelAndReasoning(cmd, config);
121
+ }
108
122
  return cmd;
109
123
  }
110
124
  /**
@@ -10,7 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import * as os from 'os';
12
12
  import { setGeminiAutoUpdateDisabled, updateGeminiSettings } from './gemini-settings.js';
13
- import { getRoutinesDir } from './state.js';
13
+ import { getRoutinesDir, getUserAgentsDir } from './state.js';
14
14
  function resolveRealHome() {
15
15
  const home = os.homedir();
16
16
  try {
@@ -58,7 +58,10 @@ function tomlString(value) {
58
58
  }
59
59
  /** Build a restricted environment for a sandboxed process, setting HOME to the overlay. */
60
60
  export function buildSpawnEnv(overlayHome, extraEnv) {
61
- const env = { HOME: overlayHome };
61
+ const env = {
62
+ HOME: overlayHome,
63
+ AGENTS_USER_DIR: getUserAgentsDir(),
64
+ };
62
65
  for (const key of ENV_ALLOWLIST) {
63
66
  if (process.env[key]) {
64
67
  env[key] = process.env[key];
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Settings carry-forward between version homes.
3
+ *
4
+ * Every installed version gets an isolated `home/`, so user-authored
5
+ * preferences (settings.json, config.toml, keybindings, auth) written while
6
+ * running one version do not exist in a freshly installed one. Resources
7
+ * managed in ~/.agents/ (commands, skills, hooks, rules, MCP YAML, plugins,
8
+ * subagents) are synced into every version home by syncResourcesToVersion and
9
+ * are deliberately NOT listed here — copying them would fight that sync.
10
+ *
11
+ * The manifest below classifies the remaining per-agent files, and
12
+ * carryForwardSettings() fills gaps in a target version home from a source
13
+ * version home. It never overwrites a value the target already has: scalars
14
+ * keep the target's value, objects merge recursively, arrays union. That makes
15
+ * the operation idempotent and safe to run on every `agents add` / `agents use`.
16
+ */
17
+ import type { AgentId } from './types.js';
18
+ export interface CarryForwardResult {
19
+ /** Manifest rel paths that were created or updated in the target home. */
20
+ applied: string[];
21
+ /** Backup directory holding pre-merge copies of modified target files, if any. */
22
+ backupDir?: string;
23
+ }
24
+ /**
25
+ * Fill gaps in `target` from `source` without overwriting target values:
26
+ * missing keys are copied, plain objects recurse, and everything else —
27
+ * scalars AND arrays — keeps the target's value. Arrays deliberately do not
28
+ * union: other writers (factory sync, hooks registration) mutate array entries
29
+ * in place, so a union would keep re-appending stale pre-mutation copies from
30
+ * the source on every carry (e.g. a user hook duplicated after the system
31
+ * hooks were merged into it). Returns a new object.
32
+ */
33
+ export declare function fillGaps(target: Record<string, unknown>, source: Record<string, unknown>): Record<string, unknown>;
34
+ /**
35
+ * Carry user settings forward from one version home into another. Both paths
36
+ * are version-home roots (the directory containing `.claude/` / `.codex/`).
37
+ * Only fills gaps — never overwrites target values — so it is idempotent.
38
+ */
39
+ export declare function carryForwardSettings(agent: AgentId, fromHome: string, toHome: string): CarryForwardResult;