@phnx-labs/agents-cli 1.20.35 → 1.20.36

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 (225) hide show
  1. package/CHANGELOG.md +91 -0
  2. package/LICENSE +185 -21
  3. package/README.md +8 -4
  4. package/dist/commands/audit.d.ts +14 -0
  5. package/dist/commands/audit.js +68 -0
  6. package/dist/commands/browser.js +82 -8
  7. package/dist/commands/check.d.ts +15 -0
  8. package/dist/commands/check.js +84 -0
  9. package/dist/commands/cloud.js +143 -3
  10. package/dist/commands/computer.js +81 -0
  11. package/dist/commands/daemon.js +4 -1
  12. package/dist/commands/doctor.js +1 -89
  13. package/dist/commands/events.js +3 -3
  14. package/dist/commands/exec.d.ts +19 -0
  15. package/dist/commands/exec.js +277 -11
  16. package/dist/commands/hosts.js +10 -6
  17. package/dist/commands/inspect.js +8 -9
  18. package/dist/commands/lock.d.ts +12 -0
  19. package/dist/commands/lock.js +70 -0
  20. package/dist/commands/message.d.ts +15 -0
  21. package/dist/commands/message.js +56 -0
  22. package/dist/commands/routines.js +101 -5
  23. package/dist/commands/secrets-migrate.js +106 -57
  24. package/dist/commands/secrets.d.ts +31 -18
  25. package/dist/commands/secrets.js +156 -75
  26. package/dist/commands/serve.d.ts +10 -0
  27. package/dist/commands/serve.js +37 -0
  28. package/dist/commands/sessions-inject.d.ts +14 -0
  29. package/dist/commands/sessions-inject.js +111 -0
  30. package/dist/commands/sessions-picker.d.ts +2 -0
  31. package/dist/commands/sessions-picker.js +24 -3
  32. package/dist/commands/sessions-resume.js +20 -8
  33. package/dist/commands/sessions.d.ts +71 -1
  34. package/dist/commands/sessions.js +365 -37
  35. package/dist/commands/setup.js +4 -2
  36. package/dist/commands/sync.d.ts +3 -1
  37. package/dist/commands/sync.js +156 -4
  38. package/dist/commands/teams.js +217 -0
  39. package/dist/commands/versions.js +2 -4
  40. package/dist/commands/watchdog.d.ts +18 -0
  41. package/dist/commands/watchdog.js +238 -0
  42. package/dist/index.js +25 -2
  43. package/dist/lib/audit/log.d.ts +92 -0
  44. package/dist/lib/audit/log.js +177 -0
  45. package/dist/lib/auto-pull.js +2 -1
  46. package/dist/lib/browser/chrome.d.ts +10 -0
  47. package/dist/lib/browser/chrome.js +18 -7
  48. package/dist/lib/browser/drivers/ssh.js +2 -1
  49. package/dist/lib/browser/har.d.ts +84 -0
  50. package/dist/lib/browser/har.js +77 -0
  51. package/dist/lib/browser/ipc.js +24 -3
  52. package/dist/lib/browser/profiles.d.ts +1 -1
  53. package/dist/lib/browser/profiles.js +8 -10
  54. package/dist/lib/browser/refs.d.ts +65 -0
  55. package/dist/lib/browser/refs.js +73 -1
  56. package/dist/lib/browser/runtime-state.js +1 -0
  57. package/dist/lib/browser/service.d.ts +38 -2
  58. package/dist/lib/browser/service.js +112 -8
  59. package/dist/lib/browser/types.d.ts +14 -1
  60. package/dist/lib/budget/live-cloud.d.ts +42 -0
  61. package/dist/lib/budget/live-cloud.js +79 -0
  62. package/dist/lib/budget/live-team.d.ts +31 -0
  63. package/dist/lib/budget/live-team.js +115 -0
  64. package/dist/lib/cloud/codex.js +4 -0
  65. package/dist/lib/cloud/rush.d.ts +12 -1
  66. package/dist/lib/cloud/rush.js +13 -3
  67. package/dist/lib/cloud/types.d.ts +9 -0
  68. package/dist/lib/computer/dispatch.d.ts +8 -0
  69. package/dist/lib/computer/dispatch.js +125 -0
  70. package/dist/lib/computer/loop.d.ts +62 -0
  71. package/dist/lib/computer/loop.js +98 -0
  72. package/dist/lib/computer/model.d.ts +44 -0
  73. package/dist/lib/computer/model.js +157 -0
  74. package/dist/lib/concurrency.d.ts +19 -0
  75. package/dist/lib/concurrency.js +33 -0
  76. package/dist/lib/daemon.d.ts +57 -0
  77. package/dist/lib/daemon.js +192 -16
  78. package/dist/lib/devices/registry.d.ts +7 -0
  79. package/dist/lib/devices/registry.js +24 -0
  80. package/dist/lib/devices/tailscale.js +1 -1
  81. package/dist/lib/drift.d.ts +52 -0
  82. package/dist/lib/drift.js +112 -0
  83. package/dist/lib/events.d.ts +1 -1
  84. package/dist/lib/events.js +31 -13
  85. package/dist/lib/exec.d.ts +17 -0
  86. package/dist/lib/exec.js +79 -13
  87. package/dist/lib/git.d.ts +27 -0
  88. package/dist/lib/git.js +56 -1
  89. package/dist/lib/hooks/cache.d.ts +6 -0
  90. package/dist/lib/hooks/cache.js +54 -12
  91. package/dist/lib/hooks.d.ts +27 -0
  92. package/dist/lib/hooks.js +127 -8
  93. package/dist/lib/hosts/dispatch.d.ts +15 -0
  94. package/dist/lib/hosts/dispatch.js +39 -6
  95. package/dist/lib/hosts/logs.js +30 -1
  96. package/dist/lib/hosts/option.js +1 -1
  97. package/dist/lib/hosts/passthrough.js +3 -1
  98. package/dist/lib/hosts/ready.d.ts +29 -6
  99. package/dist/lib/hosts/ready.js +66 -15
  100. package/dist/lib/hosts/registry.d.ts +19 -2
  101. package/dist/lib/hosts/registry.js +58 -2
  102. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  103. package/dist/lib/hosts/remote-cmd.js +70 -1
  104. package/dist/lib/hosts/remote-os.d.ts +17 -0
  105. package/dist/lib/hosts/remote-os.js +30 -0
  106. package/dist/lib/hosts/session-index.d.ts +34 -0
  107. package/dist/lib/hosts/session-index.js +56 -0
  108. package/dist/lib/hosts/tasks.d.ts +14 -0
  109. package/dist/lib/hosts/tasks.js +15 -0
  110. package/dist/lib/lock.d.ts +93 -0
  111. package/dist/lib/lock.js +207 -0
  112. package/dist/lib/loop.js +16 -1
  113. package/dist/lib/machine-id.d.ts +21 -0
  114. package/dist/lib/machine-id.js +26 -0
  115. package/dist/lib/mailbox-target.d.ts +36 -0
  116. package/dist/lib/mailbox-target.js +45 -0
  117. package/dist/lib/mailbox.d.ts +47 -0
  118. package/dist/lib/mailbox.js +194 -0
  119. package/dist/lib/mcp.d.ts +5 -0
  120. package/dist/lib/mcp.js +24 -8
  121. package/dist/lib/migrate.d.ts +19 -0
  122. package/dist/lib/migrate.js +134 -26
  123. package/dist/lib/overdue.js +3 -0
  124. package/dist/lib/picker.d.ts +2 -0
  125. package/dist/lib/picker.js +4 -1
  126. package/dist/lib/platform/exec.d.ts +46 -0
  127. package/dist/lib/platform/exec.js +74 -0
  128. package/dist/lib/platform/process.d.ts +31 -0
  129. package/dist/lib/platform/process.js +34 -1
  130. package/dist/lib/platform/winpath.js +2 -0
  131. package/dist/lib/plugins.js +16 -6
  132. package/dist/lib/profiles.d.ts +25 -0
  133. package/dist/lib/profiles.js +22 -6
  134. package/dist/lib/pty-client.js +2 -1
  135. package/dist/lib/rotate.d.ts +61 -0
  136. package/dist/lib/rotate.js +52 -0
  137. package/dist/lib/routines.d.ts +40 -2
  138. package/dist/lib/routines.js +66 -8
  139. package/dist/lib/runner.d.ts +11 -2
  140. package/dist/lib/runner.js +49 -7
  141. package/dist/lib/scheduler.js +6 -1
  142. package/dist/lib/secrets/bundles.d.ts +60 -4
  143. package/dist/lib/secrets/bundles.js +131 -12
  144. package/dist/lib/secrets/filestore.d.ts +3 -0
  145. package/dist/lib/secrets/filestore.js +42 -16
  146. package/dist/lib/secrets/index.d.ts +43 -2
  147. package/dist/lib/secrets/index.js +102 -3
  148. package/dist/lib/secrets/mcp.d.ts +93 -0
  149. package/dist/lib/secrets/mcp.js +205 -0
  150. package/dist/lib/secrets/remote.js +12 -5
  151. package/dist/lib/secrets/sync.js +83 -4
  152. package/dist/lib/secrets/windows.js +14 -3
  153. package/dist/lib/serve/data.d.ts +81 -0
  154. package/dist/lib/serve/data.js +91 -0
  155. package/dist/lib/serve/page.d.ts +7 -0
  156. package/dist/lib/serve/page.js +140 -0
  157. package/dist/lib/serve/server.d.ts +46 -0
  158. package/dist/lib/serve/server.js +115 -0
  159. package/dist/lib/session/active.d.ts +54 -0
  160. package/dist/lib/session/active.js +190 -19
  161. package/dist/lib/session/discover.d.ts +37 -0
  162. package/dist/lib/session/discover.js +111 -28
  163. package/dist/lib/session/inject.d.ts +18 -0
  164. package/dist/lib/session/inject.js +21 -0
  165. package/dist/lib/session/parse.js +23 -20
  166. package/dist/lib/session/pid-registry.d.ts +1 -0
  167. package/dist/lib/session/pid-registry.js +24 -0
  168. package/dist/lib/session/provenance.d.ts +14 -2
  169. package/dist/lib/session/provenance.js +39 -8
  170. package/dist/lib/session/remote-active.js +19 -7
  171. package/dist/lib/session/remote-list.d.ts +51 -0
  172. package/dist/lib/session/remote-list.js +213 -0
  173. package/dist/lib/session/remote.d.ts +7 -1
  174. package/dist/lib/session/remote.js +16 -2
  175. package/dist/lib/session/sync/config.d.ts +1 -15
  176. package/dist/lib/session/sync/config.js +4 -20
  177. package/dist/lib/session/types.d.ts +17 -0
  178. package/dist/lib/shims.d.ts +36 -6
  179. package/dist/lib/shims.js +91 -29
  180. package/dist/lib/ssh-exec.js +2 -0
  181. package/dist/lib/ssh-tunnel.js +2 -1
  182. package/dist/lib/startup/command-registry.d.ts +6 -0
  183. package/dist/lib/startup/command-registry.js +13 -1
  184. package/dist/lib/state.d.ts +13 -0
  185. package/dist/lib/state.js +103 -9
  186. package/dist/lib/sync-umbrella.d.ts +14 -7
  187. package/dist/lib/sync-umbrella.js +17 -9
  188. package/dist/lib/teams/forEach.d.ts +110 -0
  189. package/dist/lib/teams/forEach.js +186 -0
  190. package/dist/lib/teams/index.d.ts +1 -0
  191. package/dist/lib/teams/index.js +1 -0
  192. package/dist/lib/teams/pr-watch.d.ts +226 -0
  193. package/dist/lib/teams/pr-watch.js +371 -0
  194. package/dist/lib/teams/supervisor.d.ts +14 -1
  195. package/dist/lib/teams/supervisor.js +19 -0
  196. package/dist/lib/teams/worktree.d.ts +9 -0
  197. package/dist/lib/teams/worktree.js +32 -0
  198. package/dist/lib/terminal/backends/index.d.ts +2 -1
  199. package/dist/lib/terminal/backends/index.js +3 -1
  200. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  201. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  202. package/dist/lib/terminal/index.d.ts +4 -1
  203. package/dist/lib/terminal/index.js +4 -1
  204. package/dist/lib/terminal/inject.d.ts +204 -0
  205. package/dist/lib/terminal/inject.js +247 -0
  206. package/dist/lib/terminal/resolve.d.ts +64 -0
  207. package/dist/lib/terminal/resolve.js +90 -0
  208. package/dist/lib/terminal/types.d.ts +1 -1
  209. package/dist/lib/triggers/webhook.d.ts +85 -0
  210. package/dist/lib/triggers/webhook.js +141 -0
  211. package/dist/lib/versions.d.ts +23 -0
  212. package/dist/lib/versions.js +119 -13
  213. package/dist/lib/watchdog/index.d.ts +3 -0
  214. package/dist/lib/watchdog/index.js +5 -0
  215. package/dist/lib/watchdog/read.d.ts +35 -0
  216. package/dist/lib/watchdog/read.js +149 -0
  217. package/dist/lib/watchdog/runner.d.ts +127 -0
  218. package/dist/lib/watchdog/runner.js +322 -0
  219. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  220. package/dist/lib/watchdog/watchdog.js +166 -0
  221. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  222. package/dist/lib/watchdog/watchdogTail.js +154 -0
  223. package/dist/lib/workflows.d.ts +166 -0
  224. package/dist/lib/workflows.js +193 -0
  225. package/package.json +5 -4
package/dist/lib/mcp.js CHANGED
@@ -34,6 +34,18 @@ export function parseMcpServerConfig(filePath) {
34
34
  }
35
35
  return validateMcpYamlConfig(parsed);
36
36
  }
37
+ /**
38
+ * Validate an MCP server name. Rejects names that could be misinterpreted as
39
+ * command-line options or that contain characters unsafe for argv/identifier use.
40
+ */
41
+ export function validateMcpServerName(name) {
42
+ if (name.startsWith('-')) {
43
+ throw new Error(`Invalid MCP server name '${name}': names cannot start with '-'`);
44
+ }
45
+ if (/[\s\0-\x1f\x7f]/.test(name)) {
46
+ throw new Error(`Invalid MCP server name '${name}': names cannot contain whitespace or control characters`);
47
+ }
48
+ }
37
49
  function validateMcpYamlConfig(parsed) {
38
50
  if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
39
51
  return null;
@@ -41,6 +53,7 @@ function validateMcpYamlConfig(parsed) {
41
53
  const config = parsed;
42
54
  if (typeof config.name !== 'string' || config.name.length === 0)
43
55
  return null;
56
+ validateMcpServerName(config.name);
44
57
  if (config.transport !== 'stdio' && config.transport !== 'http')
45
58
  return null;
46
59
  const result = {
@@ -205,12 +218,12 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
205
218
  envArgs.push('--env', `${key}=${value}`);
206
219
  }
207
220
  }
208
- // claude mcp add --scope user --transport stdio <name> [--env K=V]... -- <cmd> [args...]
221
+ // claude mcp add --scope user --transport stdio [--env K=V]... -- <name> <cmd> [args...]
209
222
  const args = [
210
223
  'mcp', 'add', '--scope', 'user', '--transport', 'stdio',
211
- server.name,
212
224
  ...envArgs,
213
225
  '--',
226
+ server.name,
214
227
  server.config.command,
215
228
  ...(server.config.args || [])
216
229
  ];
@@ -222,8 +235,8 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
222
235
  });
223
236
  }
224
237
  else {
225
- // claude mcp add --scope user --transport http <name> <url>
226
- execFileSync(binaryPath, ['mcp', 'add', '--scope', 'user', '--transport', 'http', server.name, server.config.url], {
238
+ // claude mcp add --scope user --transport http -- <name> <url>
239
+ execFileSync(binaryPath, ['mcp', 'add', '--scope', 'user', '--transport', 'http', '--', server.name, server.config.url], {
227
240
  stdio: 'pipe',
228
241
  timeout: 30000,
229
242
  env: execEnv,
@@ -237,10 +250,11 @@ function installMcpViaClaude(binaryPath, server, versionHome) {
237
250
  */
238
251
  function installMcpViaCodex(binaryPath, server, versionHome) {
239
252
  if (server.config.transport === 'stdio') {
240
- // codex mcp add <name> -- <cmd> [args...]
253
+ // codex mcp add -- <name> <cmd> [args...]
241
254
  const args = [
242
- 'mcp', 'add', server.name,
255
+ 'mcp', 'add',
243
256
  '--',
257
+ server.name,
244
258
  server.config.command,
245
259
  ...(server.config.args || [])
246
260
  ];
@@ -272,11 +286,12 @@ export async function registerMcpCommandToTargets(targets, name, commandSpec, sc
272
286
  }
273
287
  function registerMcpCommand(agentId, name, commandSpec, scope, transport, options = {}) {
274
288
  try {
289
+ validateMcpServerName(name);
275
290
  const bin = options.binary || AGENTS[agentId].cliCommand;
276
291
  const commandArgs = [commandSpec.command, ...commandSpec.args];
277
292
  const args = agentId === 'claude'
278
- ? ['mcp', 'add', '--transport', transport, '--scope', scope, name, '--', ...commandArgs]
279
- : ['mcp', 'add', name, '--', ...commandArgs];
293
+ ? ['mcp', 'add', '--transport', transport, '--scope', scope, '--', name, ...commandArgs]
294
+ : ['mcp', 'add', '--', name, ...commandArgs];
280
295
  const env = options.home ? { ...process.env, HOME: options.home } : process.env;
281
296
  execFileSync(bin, args, { stdio: 'pipe', timeout: 30000, env, shell: needsWindowsShell(bin) });
282
297
  return { success: true };
@@ -508,6 +523,7 @@ export function installMcpServers(agentId, version, versionHome, mcpNames, optio
508
523
  * Write an MCP server config to ~/.agents/mcp/.
509
524
  */
510
525
  export function writeMcpServerConfig(config) {
526
+ validateMcpServerName(config.name);
511
527
  const mcpDir = getUserMcpDir();
512
528
  fs.mkdirSync(mcpDir, { recursive: true });
513
529
  const fileName = `${config.name.toLowerCase().replace(/[^a-z0-9]/g, '-')}.yaml`;
@@ -25,6 +25,25 @@
25
25
  * LEGACY_SYSTEM_DIR" without duplicating data.
26
26
  */
27
27
  export declare function foldLegacySystemRepo(): void;
28
+ /**
29
+ * Migrate a legacy single-repo agents.yaml into ~/.agents/agents.yaml.
30
+ *
31
+ * The post-fold system dir (~/.agents/.system) is a pull-only mirror of the
32
+ * npm-shipped defaults. Its agents.yaml is a TRACKED file that readMeta() reads
33
+ * in place as the defaults base (see SYSTEM_META_FILE in state.ts). Deleting or
34
+ * moving a tracked file out of the mirror dirties its working tree, which
35
+ * permanently wedges `agents setup`, `agents sync`, and background auto-pull
36
+ * ("Working tree has uncommitted changes.") because the mirror sync refuses a
37
+ * dirty tree. So when agents.yaml is tracked there, treat the mirror as strictly
38
+ * read-only and leave the file alone.
39
+ *
40
+ * Only an UNTRACKED agents.yaml is residue from the pre-split single-repo layout
41
+ * (or a legacy ~/.agents-system fold) and is safe to move/drop.
42
+ *
43
+ * Params default to the real on-disk locations; they are injectable so tests can
44
+ * drive a fixture tree without touching the user's ~/.agents.
45
+ */
46
+ export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
28
47
  /**
29
48
  * Repair self-referential agent binary symlinks.
30
49
  *
@@ -8,6 +8,9 @@ import * as fs from 'fs';
8
8
  import * as path from 'path';
9
9
  import * as os from 'os';
10
10
  import * as yaml from 'yaml';
11
+ import { execSync } from 'child_process';
12
+ import { atomicWriteFileSync } from './fs-atomic.js';
13
+ import { machineId } from './machine-id.js';
11
14
  import { AGENTS, agentConfigDirName, findInPath } from './agents.js';
12
15
  import { createLink } from './platform/index.js';
13
16
  const HOME = process.env.HOME ?? os.homedir();
@@ -79,21 +82,46 @@ export function foldLegacySystemRepo() {
79
82
  }
80
83
  catch { /* best-effort */ }
81
84
  }
85
+ /** True when `relPath` is a file tracked by a git repo rooted at `repoDir`. */
86
+ function isTrackedInGitRepo(repoDir, relPath) {
87
+ try {
88
+ execSync(`git ls-files --error-unmatch -- ${relPath}`, { cwd: repoDir, stdio: 'ignore' });
89
+ return true;
90
+ }
91
+ catch {
92
+ // Not a git repo, or the file is untracked — either way, not tracked.
93
+ return false;
94
+ }
95
+ }
82
96
  /**
83
- * Move ~/.agents-system/agents.yaml -> ~/.agents/agents.yaml.
84
- * No-op if user file already exists or system file absent.
85
- * (This is also handled inline in state.ts readMeta, but is exposed here
86
- * for explicit migration calls from postinstall / CLI entry points.)
97
+ * Migrate a legacy single-repo agents.yaml into ~/.agents/agents.yaml.
98
+ *
99
+ * The post-fold system dir (~/.agents/.system) is a pull-only mirror of the
100
+ * npm-shipped defaults. Its agents.yaml is a TRACKED file that readMeta() reads
101
+ * in place as the defaults base (see SYSTEM_META_FILE in state.ts). Deleting or
102
+ * moving a tracked file out of the mirror dirties its working tree, which
103
+ * permanently wedges `agents setup`, `agents sync`, and background auto-pull
104
+ * ("Working tree has uncommitted changes.") because the mirror sync refuses a
105
+ * dirty tree. So when agents.yaml is tracked there, treat the mirror as strictly
106
+ * read-only and leave the file alone.
107
+ *
108
+ * Only an UNTRACKED agents.yaml is residue from the pre-split single-repo layout
109
+ * (or a legacy ~/.agents-system fold) and is safe to move/drop.
110
+ *
111
+ * Params default to the real on-disk locations; they are injectable so tests can
112
+ * drive a fixture tree without touching the user's ~/.agents.
87
113
  */
88
- function migrateAgentsYaml() {
89
- const src = path.join(SYSTEM_DIR, 'agents.yaml');
90
- const dest = path.join(USER_DIR, 'agents.yaml');
114
+ export function migrateAgentsYaml(systemDir = SYSTEM_DIR, userDir = USER_DIR) {
115
+ const src = path.join(systemDir, 'agents.yaml');
116
+ const dest = path.join(userDir, 'agents.yaml');
91
117
  if (!fs.existsSync(src))
92
118
  return;
119
+ // Tracked in the system mirror → npm-shipped defaults; never mutate. readMeta()
120
+ // already surfaces it in place, so no user-dir copy is needed either.
121
+ if (isTrackedInGitRepo(systemDir, 'agents.yaml'))
122
+ return;
93
123
  if (fs.existsSync(dest)) {
94
- // User copy is authoritative — drop the stale system leftover. The system
95
- // repo (npm-shipped) does not track agents.yaml; any copy here is residue
96
- // from the pre-split layout.
124
+ // User copy is authoritative — drop the untracked stale system leftover.
97
125
  try {
98
126
  fs.unlinkSync(src);
99
127
  }
@@ -101,7 +129,7 @@ function migrateAgentsYaml() {
101
129
  return;
102
130
  }
103
131
  try {
104
- fs.mkdirSync(USER_DIR, { recursive: true, mode: 0o700 });
132
+ fs.mkdirSync(userDir, { recursive: true, mode: 0o700 });
105
133
  fs.renameSync(src, dest);
106
134
  console.error('Migrated agents.yaml to ~/.agents/');
107
135
  }
@@ -656,22 +684,31 @@ function migratePermissionSetsToPresets() {
656
684
  * switching is owned by `agents use`, not the migrator.
657
685
  */
658
686
  function repairAgentConfigSymlinks() {
659
- let yaml;
660
- try {
661
- yaml = fs.readFileSync(path.join(USER_DIR, 'agents.yaml'), 'utf-8');
662
- }
663
- catch {
664
- return;
665
- }
666
- const agentsBlock = yaml.match(/^agents:\s*\n((?: [^\n]*\n)+)/m);
667
- if (!agentsBlock)
668
- return;
687
+ // Version pins live in the per-device file post-split (~/.agents/devices/<machine>/
688
+ // agents.yaml); central agents.yaml may still carry them mid-transition. Read
689
+ // device first (authoritative), then central, first-seen-agent wins.
669
690
  const defaults = [];
670
- for (const line of agentsBlock[1].split('\n')) {
671
- const m = line.match(/^\s+([a-z][a-z0-9_-]*):\s*([^\s#]+)/);
672
- if (m)
673
- defaults.push({ agent: m[1], version: m[2] });
674
- }
691
+ const collectPins = (file) => {
692
+ let text;
693
+ try {
694
+ text = fs.readFileSync(file, 'utf-8');
695
+ }
696
+ catch {
697
+ return;
698
+ }
699
+ const block = text.match(/^agents:\s*\n((?: [^\n]*\n)+)/m);
700
+ if (!block)
701
+ return;
702
+ for (const line of block[1].split('\n')) {
703
+ const m = line.match(/^\s+([a-z][a-z0-9_-]*):\s*([^\s#]+)/);
704
+ if (m && !defaults.some((d) => d.agent === m[1]))
705
+ defaults.push({ agent: m[1], version: m[2] });
706
+ }
707
+ };
708
+ collectPins(path.join(USER_DIR, 'devices', machineId(), 'agents.yaml'));
709
+ collectPins(path.join(USER_DIR, 'agents.yaml'));
710
+ if (defaults.length === 0)
711
+ return;
675
712
  let repaired = 0;
676
713
  for (const { agent, version } of defaults) {
677
714
  const configDirName = agent in AGENTS ? agentConfigDirName(agent) : `.${agent}`;
@@ -1625,6 +1662,73 @@ function migrateVersionResourcesToPatterns() {
1625
1662
  console.error('Migrated agents.yaml versions: entries to pattern format');
1626
1663
  }
1627
1664
  }
1665
+ /**
1666
+ * Split the machine-local fields out of the committed central agents.yaml so it
1667
+ * becomes portable and syncs cleanly (no more skip-worktree band-aid):
1668
+ * agents: -> ~/.agents/devices/<machineId>/agents.yaml (committed, per-device)
1669
+ * versions: -> ~/.agents/.history/version-resources.json (gitignored, machine-local)
1670
+ * Then clear any externally-set skip-worktree bit. Idempotent: no-op once central
1671
+ * carries neither field. Operates on raw YAML (never through state.ts).
1672
+ */
1673
+ function migrateSplitDeviceLocalMeta() {
1674
+ const metaFile = path.join(USER_DIR, 'agents.yaml');
1675
+ if (!fs.existsSync(metaFile))
1676
+ return;
1677
+ let meta;
1678
+ try {
1679
+ meta = yaml.parse(fs.readFileSync(metaFile, 'utf-8')) || {};
1680
+ }
1681
+ catch {
1682
+ return;
1683
+ }
1684
+ const agents = meta.agents;
1685
+ const versions = meta.versions;
1686
+ const hasLocal = (!!agents && Object.keys(agents).length > 0) || (!!versions && Object.keys(versions).length > 0);
1687
+ const HEADER = '# agents-cli metadata\n# Auto-generated - do not edit manually\n# https://github.com/phnx-labs/agents-cli\n\n';
1688
+ // Only rewrite central when it actually carries machine-local fields — a
1689
+ // machine whose agents.yaml is already portable-only is left untouched.
1690
+ if (hasLocal) {
1691
+ // agents: -> per-device file (merge, existing device entries win).
1692
+ if (agents && Object.keys(agents).length > 0) {
1693
+ const devicePath = path.join(USER_DIR, 'devices', machineId(), 'agents.yaml');
1694
+ let existing = {};
1695
+ try {
1696
+ const dm = yaml.parse(fs.readFileSync(devicePath, 'utf-8'));
1697
+ if (dm?.agents)
1698
+ existing = dm.agents;
1699
+ }
1700
+ catch { /* absent */ }
1701
+ fs.mkdirSync(path.dirname(devicePath), { recursive: true });
1702
+ atomicWriteFileSync(devicePath, HEADER + yaml.stringify({ agents: { ...agents, ...existing } }));
1703
+ }
1704
+ // versions: -> machine-local history JSON (merge, existing wins).
1705
+ if (versions && Object.keys(versions).length > 0) {
1706
+ const vrPath = path.join(USER_DIR, '.history', 'version-resources.json');
1707
+ let existing = {};
1708
+ try {
1709
+ existing = JSON.parse(fs.readFileSync(vrPath, 'utf-8')) || {};
1710
+ }
1711
+ catch { /* absent */ }
1712
+ fs.mkdirSync(path.dirname(vrPath), { recursive: true });
1713
+ atomicWriteFileSync(vrPath, JSON.stringify({ ...versions, ...existing }, null, 2) + '\n');
1714
+ }
1715
+ // Strip machine-local fields from central and rewrite (portable only) — after
1716
+ // the device/history writes above, so a crash never loses data.
1717
+ delete meta.agents;
1718
+ delete meta.versions;
1719
+ atomicWriteFileSync(metaFile, HEADER + yaml.stringify(meta));
1720
+ }
1721
+ // Always clear any skip-worktree bit (idempotent, best-effort) so agents.yaml
1722
+ // syncs cleanly on every machine — even one that had nothing to split. Runs
1723
+ // after any rewrite so the tracked content already matches the split shape.
1724
+ try {
1725
+ execSync('git update-index --no-skip-worktree agents.yaml', { cwd: USER_DIR, stdio: 'ignore' });
1726
+ }
1727
+ catch { /* not a git repo / bit not set */ }
1728
+ if (hasLocal) {
1729
+ console.error('Split agents.yaml: agents: -> devices/, versions: -> .history/version-resources.json');
1730
+ }
1731
+ }
1628
1732
  /**
1629
1733
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
1630
1734
  * inside every installed agent version-home, and rewrite cross-references in
@@ -1805,6 +1909,10 @@ export async function runMigration() {
1805
1909
  foldUserHooksYamlIntoAgentsYaml();
1806
1910
  foldBrowserProfilesIntoAgentsYaml();
1807
1911
  migrateVersionResourcesToPatterns();
1912
+ // Split machine-local fields (agents:/versions:) out of the committed central
1913
+ // agents.yaml. After migrateVersionResourcesToPatterns so versions: is already
1914
+ // in pattern form when it moves to the history file.
1915
+ migrateSplitDeviceLocalMeta();
1808
1916
  // Bucket moves: collapse runtime state into ~/.agents/.history and ~/.agents/.cache.
1809
1917
  migrateRuntimeToHistory();
1810
1918
  migrateRuntimeToCache();
@@ -44,6 +44,9 @@ export function detectOverdueJobs(now = new Date()) {
44
44
  for (const job of listJobs()) {
45
45
  if (!job.enabled || job.runOnce)
46
46
  continue;
47
+ // Trigger-only jobs (no cron schedule) never have an expected fire time.
48
+ if (!job.schedule)
49
+ continue;
47
50
  let expected = null;
48
51
  try {
49
52
  const cronOptions = { paused: true };
@@ -8,6 +8,8 @@
8
8
  /** Configuration for the interactive picker prompt. */
9
9
  export interface PickerConfig<T> {
10
10
  message: string;
11
+ /** Optional dim hint line rendered directly under the header (above the rows). */
12
+ subtitle?: string;
11
13
  items: T[];
12
14
  filter: (query: string) => T[];
13
15
  labelFor: (item: T, query: string) => string;
@@ -174,7 +174,10 @@ export function itemPicker(config) {
174
174
  const help = previewOpen
175
175
  ? chalk.gray(`↑↓ navigate · space: close preview · ⏎ ${enter} · esc: cancel`)
176
176
  : chalk.gray(`↑↓ navigate${hasPreview ? ' · space: preview' : ''} · ⏎ ${enter} · esc: cancel`);
177
- const parts = [header, page];
177
+ const parts = [header];
178
+ if (cfg.subtitle)
179
+ parts.push(cfg.subtitle);
180
+ parts.push(page);
178
181
  if (results.length === 0) {
179
182
  parts.push(chalk.gray(` ${cfg.emptyMessage ?? 'No matches.'}`));
180
183
  }
@@ -17,3 +17,49 @@ export declare function needsWindowsShell(binary: string, platform?: NodeJS.Plat
17
17
  * shell would actually run, matching `which` semantics on POSIX.
18
18
  */
19
19
  export declare function findExecutable(name: string, platform?: NodeJS.Platform): string | null;
20
+ /**
21
+ * Absolute path of the POSIX shell for `sh -c <command string>` composition.
22
+ * `/bin/sh` on POSIX; on Windows there is no /bin/sh, so resolve Git's
23
+ * `sh.exe`/`bash.exe` from PATH (present wherever Git for Windows is — dev
24
+ * boxes and the GitHub windows runners alike). Falls back to the bare name so
25
+ * a missing shell surfaces as a clear spawn ENOENT on `sh` rather than a
26
+ * misleading one on `/bin/sh`.
27
+ */
28
+ export declare function posixShellPath(platform?: NodeJS.Platform): string;
29
+ /**
30
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
31
+ * `spawn(..., { shell: true })` on win32 (the `.cmd` agent shims, `agents secrets
32
+ * exec`, ...). cmd.exe does NO quoting of its own, so an unquoted arg with a space
33
+ * is split into several args, and a cmd metacharacter (`&|<>()^`) would be
34
+ * interpreted by the shell. We wrap any arg with whitespace, a quote, or a
35
+ * metacharacter in double quotes and escape embedded quotes / trailing
36
+ * backslashes per the CommandLineToArgvW rules, so the *child's* argv parse
37
+ * reconstructs the original argument.
38
+ *
39
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
40
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
41
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
42
+ * the callers here run a command whose `%`/`!`-bearing tokens are the caller's
43
+ * own (an agent prompt against the caller's shell, a bundle the caller owns), so
44
+ * caller-controlled `%`/`!` is not a privilege boundary. If that ever changes
45
+ * (composing an untrusted command line), route through a shell that disables
46
+ * expansion rather than relying on this quoter. An empty arg becomes `""`.
47
+ */
48
+ export declare function quoteWin32ExecArg(arg: string): string;
49
+ /**
50
+ * Compose a DEP0190-safe Windows shell command line from a command and its args.
51
+ *
52
+ * Node's `spawn(cmd, args, { shell: true })` on win32 concatenates `cmd` and
53
+ * every element of `args` into a single cmd.exe line WITHOUT escaping — that is
54
+ * both Node's DEP0190 deprecation (a future hard error) and a real injection
55
+ * surface, since user-controlled text (an agent prompt, a secrets-exec command)
56
+ * flows through `args`. Passing the fully-composed line as the SOLE `command`
57
+ * with an EMPTY args array sidesteps both: we quote every token with
58
+ * `quoteWin32ExecArg` so the child's CommandLineToArgvW parse reconstructs the
59
+ * exact original argv, and Node has no args array left to concatenate.
60
+ *
61
+ * Callers spawn the result as `spawn(line, [], { shell: true })`. A simple arg
62
+ * (no whitespace/quote/metachar) is passed through untouched, so the composed
63
+ * line is byte-identical to the old unquoted join for the common case.
64
+ */
65
+ export declare function composeWin32CommandLine(command: string, args: string[]): string;
@@ -39,3 +39,77 @@ export function findExecutable(name, platform = process.platform) {
39
39
  return null;
40
40
  }
41
41
  }
42
+ /**
43
+ * Absolute path of the POSIX shell for `sh -c <command string>` composition.
44
+ * `/bin/sh` on POSIX; on Windows there is no /bin/sh, so resolve Git's
45
+ * `sh.exe`/`bash.exe` from PATH (present wherever Git for Windows is — dev
46
+ * boxes and the GitHub windows runners alike). Falls back to the bare name so
47
+ * a missing shell surfaces as a clear spawn ENOENT on `sh` rather than a
48
+ * misleading one on `/bin/sh`.
49
+ */
50
+ export function posixShellPath(platform = process.platform) {
51
+ if (platform !== 'win32')
52
+ return '/bin/sh';
53
+ return findExecutable('sh', platform) ?? findExecutable('bash', platform) ?? 'sh';
54
+ }
55
+ /**
56
+ * Quote one argument for a Windows `cmd.exe` command line, as built by Node's
57
+ * `spawn(..., { shell: true })` on win32 (the `.cmd` agent shims, `agents secrets
58
+ * exec`, ...). cmd.exe does NO quoting of its own, so an unquoted arg with a space
59
+ * is split into several args, and a cmd metacharacter (`&|<>()^`) would be
60
+ * interpreted by the shell. We wrap any arg with whitespace, a quote, or a
61
+ * metacharacter in double quotes and escape embedded quotes / trailing
62
+ * backslashes per the CommandLineToArgvW rules, so the *child's* argv parse
63
+ * reconstructs the original argument.
64
+ *
65
+ * CAVEAT: cmd.exe expands `%VAR%` (always) and `!VAR!` (under delayed expansion)
66
+ * BEFORE argv parsing, and double-quoting does NOT suppress `%`/`!` (the
67
+ * "BatBadBut" / CVE-2024-1874 class). We deliberately do not escape `%`/`!`:
68
+ * the callers here run a command whose `%`/`!`-bearing tokens are the caller's
69
+ * own (an agent prompt against the caller's shell, a bundle the caller owns), so
70
+ * caller-controlled `%`/`!` is not a privilege boundary. If that ever changes
71
+ * (composing an untrusted command line), route through a shell that disables
72
+ * expansion rather than relying on this quoter. An empty arg becomes `""`.
73
+ */
74
+ export function quoteWin32ExecArg(arg) {
75
+ if (arg.length > 0 && !/[\s"&|<>()^]/.test(arg))
76
+ return arg;
77
+ let result = '"';
78
+ let backslashes = 0;
79
+ for (const ch of arg) {
80
+ if (ch === '\\') {
81
+ backslashes += 1;
82
+ continue;
83
+ }
84
+ if (ch === '"') {
85
+ // Double the run of backslashes, then escape this quote.
86
+ result += '\\'.repeat(backslashes * 2 + 1) + '"';
87
+ backslashes = 0;
88
+ continue;
89
+ }
90
+ result += '\\'.repeat(backslashes) + ch;
91
+ backslashes = 0;
92
+ }
93
+ // Trailing backslashes precede the closing quote → must be doubled.
94
+ result += '\\'.repeat(backslashes * 2) + '"';
95
+ return result;
96
+ }
97
+ /**
98
+ * Compose a DEP0190-safe Windows shell command line from a command and its args.
99
+ *
100
+ * Node's `spawn(cmd, args, { shell: true })` on win32 concatenates `cmd` and
101
+ * every element of `args` into a single cmd.exe line WITHOUT escaping — that is
102
+ * both Node's DEP0190 deprecation (a future hard error) and a real injection
103
+ * surface, since user-controlled text (an agent prompt, a secrets-exec command)
104
+ * flows through `args`. Passing the fully-composed line as the SOLE `command`
105
+ * with an EMPTY args array sidesteps both: we quote every token with
106
+ * `quoteWin32ExecArg` so the child's CommandLineToArgvW parse reconstructs the
107
+ * exact original argv, and Node has no args array left to concatenate.
108
+ *
109
+ * Callers spawn the result as `spawn(line, [], { shell: true })`. A simple arg
110
+ * (no whitespace/quote/metachar) is passed through untouched, so the composed
111
+ * line is byte-identical to the old unquoted join for the common case.
112
+ */
113
+ export function composeWin32CommandLine(command, args) {
114
+ return [command, ...args].map(quoteWin32ExecArg).join(' ');
115
+ }
@@ -9,6 +9,37 @@
9
9
  * process counts as success.
10
10
  */
11
11
  export declare function killTree(pid: number): void;
12
+ /**
13
+ * Spawn options for a long-lived background child (daemon, detached worker,
14
+ * sidecar server, fire-and-forget job).
15
+ *
16
+ * POSIX: `detached: true` — the child leads its own process group, so it
17
+ * survives the parent and group kills (`kill(-pid)`) still reach it.
18
+ *
19
+ * Windows: the child must not share the launcher's console (a console-close
20
+ * event when the launcher exits would tear it down, #556) and must not flash
21
+ * a window. How to get there depends on the child's stdio:
22
+ *
23
+ * - All stdio non-inherited ('ignore'/'pipe'): `windowsHide: true`, NOT
24
+ * detached. CREATE_NO_WINDOW gives the child its own hidden console that
25
+ * every console-subsystem descendant (powershell, git, a .cmd shim's cmd.exe
26
+ * wrapper) inherits — no window anywhere down the tree. `detached` would
27
+ * defeat it: DETACHED_PROCESS makes CreateProcess ignore CREATE_NO_WINDOW.
28
+ *
29
+ * - Any stdio slot redirected to an fd (log files — `fdStdio: true`): libuv
30
+ * skips CREATE_NO_WINDOW whenever a stdio fd is inherited, so windowsHide
31
+ * cannot engage and a non-detached child would share the launcher's console
32
+ * and die with it. Keep DETACHED_PROCESS: the child runs console-less and
33
+ * windowless; its console-tool spawns stay invisible because the leaf call
34
+ * sites pass their own `windowsHide` with piped stdio.
35
+ */
36
+ export declare function backgroundSpawnOptions(opts?: {
37
+ fdStdio?: boolean;
38
+ platform?: NodeJS.Platform;
39
+ }): {
40
+ detached: boolean;
41
+ windowsHide: boolean;
42
+ };
12
43
  /**
13
44
  * Is a process with this PID currently alive?
14
45
  *
@@ -17,7 +17,7 @@ export function killTree(pid) {
17
17
  return;
18
18
  if (process.platform === 'win32') {
19
19
  try {
20
- execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore' });
20
+ execFileSync('taskkill', ['/F', '/T', '/PID', String(pid)], { stdio: 'ignore', windowsHide: true });
21
21
  }
22
22
  catch { /* already gone, or no such pid */ }
23
23
  }
@@ -28,6 +28,39 @@ export function killTree(pid) {
28
28
  catch { /* already gone */ }
29
29
  }
30
30
  }
31
+ /**
32
+ * Spawn options for a long-lived background child (daemon, detached worker,
33
+ * sidecar server, fire-and-forget job).
34
+ *
35
+ * POSIX: `detached: true` — the child leads its own process group, so it
36
+ * survives the parent and group kills (`kill(-pid)`) still reach it.
37
+ *
38
+ * Windows: the child must not share the launcher's console (a console-close
39
+ * event when the launcher exits would tear it down, #556) and must not flash
40
+ * a window. How to get there depends on the child's stdio:
41
+ *
42
+ * - All stdio non-inherited ('ignore'/'pipe'): `windowsHide: true`, NOT
43
+ * detached. CREATE_NO_WINDOW gives the child its own hidden console that
44
+ * every console-subsystem descendant (powershell, git, a .cmd shim's cmd.exe
45
+ * wrapper) inherits — no window anywhere down the tree. `detached` would
46
+ * defeat it: DETACHED_PROCESS makes CreateProcess ignore CREATE_NO_WINDOW.
47
+ *
48
+ * - Any stdio slot redirected to an fd (log files — `fdStdio: true`): libuv
49
+ * skips CREATE_NO_WINDOW whenever a stdio fd is inherited, so windowsHide
50
+ * cannot engage and a non-detached child would share the launcher's console
51
+ * and die with it. Keep DETACHED_PROCESS: the child runs console-less and
52
+ * windowless; its console-tool spawns stay invisible because the leaf call
53
+ * sites pass their own `windowsHide` with piped stdio.
54
+ */
55
+ export function backgroundSpawnOptions(opts = {}) {
56
+ const platform = opts.platform ?? process.platform;
57
+ if (platform === 'win32') {
58
+ return opts.fdStdio
59
+ ? { detached: true, windowsHide: true }
60
+ : { detached: false, windowsHide: true };
61
+ }
62
+ return { detached: true, windowsHide: false };
63
+ }
31
64
  /**
32
65
  * Is a process with this PID currently alive?
33
66
  *
@@ -107,6 +107,7 @@ function runPowerShell(script, extraEnv) {
107
107
  encoding: 'utf-8',
108
108
  env: extraEnv ? { ...process.env, ...extraEnv } : process.env,
109
109
  stdio: ['ignore', 'pipe', 'pipe'],
110
+ windowsHide: true,
110
111
  });
111
112
  }
112
113
  /** Parse the read script's stdout into the original value kind and RAW value. */
@@ -163,6 +164,7 @@ export function getEffectiveExecutionPolicy() {
163
164
  const out = execFileSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', 'Get-ExecutionPolicy'], {
164
165
  encoding: 'utf-8',
165
166
  stdio: ['ignore', 'pipe', 'pipe'],
167
+ windowsHide: true,
166
168
  }).trim();
167
169
  return out || null;
168
170
  }
@@ -172,16 +172,26 @@ export function loadPluginManifest(pluginRoot) {
172
172
  }
173
173
  }
174
174
  export function validatePluginName(name) {
175
- return name.length > 0
176
- && !/[/\\]/.test(name)
177
- && !name.includes('..')
178
- && !name.includes('\0');
175
+ if (!name || name.length === 0 || name === '.' || name === '..') {
176
+ return false;
177
+ }
178
+ const normalized = path.normalize(name);
179
+ if (normalized === '.' || normalized === '' || normalized === '..') {
180
+ return false;
181
+ }
182
+ if (/[/\\]/.test(name) || name.includes('\0')) {
183
+ return false;
184
+ }
185
+ if (path.basename(normalized) !== name) {
186
+ return false;
187
+ }
188
+ return true;
179
189
  }
180
190
  export function assertPluginTargetContained(targetRoot, pluginsDir) {
181
191
  const resolvedPluginsDir = path.resolve(pluginsDir);
182
192
  const resolvedTargetRoot = path.resolve(targetRoot);
183
- if (resolvedTargetRoot !== resolvedPluginsDir
184
- && !resolvedTargetRoot.startsWith(`${resolvedPluginsDir}${path.sep}`)) {
193
+ if (resolvedTargetRoot === resolvedPluginsDir
194
+ || !resolvedTargetRoot.startsWith(`${resolvedPluginsDir}${path.sep}`)) {
185
195
  throw new Error(`Plugin install target escapes plugins directory: ${targetRoot}`);
186
196
  }
187
197
  }