@phnx-labs/agents-cli 1.20.35 → 1.20.37

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 (236) 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-sync.d.ts +3 -0
  34. package/dist/commands/sessions-sync.js +44 -4
  35. package/dist/commands/sessions.d.ts +78 -1
  36. package/dist/commands/sessions.js +506 -66
  37. package/dist/commands/setup.js +4 -2
  38. package/dist/commands/sync.d.ts +3 -1
  39. package/dist/commands/sync.js +156 -4
  40. package/dist/commands/teams.js +217 -0
  41. package/dist/commands/versions.js +2 -4
  42. package/dist/commands/watchdog.d.ts +18 -0
  43. package/dist/commands/watchdog.js +238 -0
  44. package/dist/index.js +25 -2
  45. package/dist/lib/audit/log.d.ts +92 -0
  46. package/dist/lib/audit/log.js +177 -0
  47. package/dist/lib/auto-pull.js +2 -1
  48. package/dist/lib/browser/chrome.d.ts +10 -0
  49. package/dist/lib/browser/chrome.js +18 -7
  50. package/dist/lib/browser/drivers/ssh.js +2 -1
  51. package/dist/lib/browser/har.d.ts +84 -0
  52. package/dist/lib/browser/har.js +77 -0
  53. package/dist/lib/browser/ipc.js +24 -3
  54. package/dist/lib/browser/profiles.d.ts +1 -1
  55. package/dist/lib/browser/profiles.js +8 -10
  56. package/dist/lib/browser/refs.d.ts +65 -0
  57. package/dist/lib/browser/refs.js +73 -1
  58. package/dist/lib/browser/runtime-state.js +1 -0
  59. package/dist/lib/browser/service.d.ts +38 -2
  60. package/dist/lib/browser/service.js +112 -8
  61. package/dist/lib/browser/types.d.ts +14 -1
  62. package/dist/lib/budget/live-cloud.d.ts +42 -0
  63. package/dist/lib/budget/live-cloud.js +79 -0
  64. package/dist/lib/budget/live-team.d.ts +31 -0
  65. package/dist/lib/budget/live-team.js +115 -0
  66. package/dist/lib/cloud/codex.js +4 -0
  67. package/dist/lib/cloud/rush.d.ts +12 -1
  68. package/dist/lib/cloud/rush.js +13 -3
  69. package/dist/lib/cloud/types.d.ts +9 -0
  70. package/dist/lib/computer/dispatch.d.ts +8 -0
  71. package/dist/lib/computer/dispatch.js +125 -0
  72. package/dist/lib/computer/loop.d.ts +62 -0
  73. package/dist/lib/computer/loop.js +98 -0
  74. package/dist/lib/computer/model.d.ts +44 -0
  75. package/dist/lib/computer/model.js +157 -0
  76. package/dist/lib/concurrency.d.ts +19 -0
  77. package/dist/lib/concurrency.js +33 -0
  78. package/dist/lib/daemon.d.ts +57 -0
  79. package/dist/lib/daemon.js +196 -18
  80. package/dist/lib/devices/registry.d.ts +7 -0
  81. package/dist/lib/devices/registry.js +24 -0
  82. package/dist/lib/devices/resolve-target.d.ts +24 -0
  83. package/dist/lib/devices/resolve-target.js +80 -0
  84. package/dist/lib/devices/tailscale.js +1 -1
  85. package/dist/lib/drift.d.ts +52 -0
  86. package/dist/lib/drift.js +112 -0
  87. package/dist/lib/events.d.ts +1 -1
  88. package/dist/lib/events.js +31 -13
  89. package/dist/lib/exec.d.ts +17 -0
  90. package/dist/lib/exec.js +79 -13
  91. package/dist/lib/git.d.ts +27 -0
  92. package/dist/lib/git.js +56 -1
  93. package/dist/lib/hooks/cache.d.ts +6 -0
  94. package/dist/lib/hooks/cache.js +54 -12
  95. package/dist/lib/hooks.d.ts +27 -0
  96. package/dist/lib/hooks.js +127 -8
  97. package/dist/lib/hosts/dispatch.d.ts +15 -0
  98. package/dist/lib/hosts/dispatch.js +39 -6
  99. package/dist/lib/hosts/logs.js +30 -1
  100. package/dist/lib/hosts/option.js +1 -1
  101. package/dist/lib/hosts/passthrough.js +3 -1
  102. package/dist/lib/hosts/ready.d.ts +29 -6
  103. package/dist/lib/hosts/ready.js +66 -15
  104. package/dist/lib/hosts/registry.d.ts +19 -2
  105. package/dist/lib/hosts/registry.js +58 -2
  106. package/dist/lib/hosts/remote-cmd.d.ts +62 -1
  107. package/dist/lib/hosts/remote-cmd.js +70 -1
  108. package/dist/lib/hosts/remote-os.d.ts +17 -0
  109. package/dist/lib/hosts/remote-os.js +30 -0
  110. package/dist/lib/hosts/session-index.d.ts +34 -0
  111. package/dist/lib/hosts/session-index.js +56 -0
  112. package/dist/lib/hosts/tasks.d.ts +14 -0
  113. package/dist/lib/hosts/tasks.js +15 -0
  114. package/dist/lib/lock.d.ts +93 -0
  115. package/dist/lib/lock.js +207 -0
  116. package/dist/lib/loop.js +16 -1
  117. package/dist/lib/machine-id.d.ts +21 -0
  118. package/dist/lib/machine-id.js +26 -0
  119. package/dist/lib/mailbox-target.d.ts +36 -0
  120. package/dist/lib/mailbox-target.js +45 -0
  121. package/dist/lib/mailbox.d.ts +47 -0
  122. package/dist/lib/mailbox.js +194 -0
  123. package/dist/lib/mcp.d.ts +5 -0
  124. package/dist/lib/mcp.js +24 -8
  125. package/dist/lib/migrate.d.ts +19 -0
  126. package/dist/lib/migrate.js +134 -26
  127. package/dist/lib/overdue.js +3 -0
  128. package/dist/lib/picker.d.ts +2 -0
  129. package/dist/lib/picker.js +4 -1
  130. package/dist/lib/platform/exec.d.ts +46 -0
  131. package/dist/lib/platform/exec.js +74 -0
  132. package/dist/lib/platform/process.d.ts +31 -0
  133. package/dist/lib/platform/process.js +34 -1
  134. package/dist/lib/platform/winpath.js +2 -0
  135. package/dist/lib/plugins.js +16 -6
  136. package/dist/lib/profiles.d.ts +25 -0
  137. package/dist/lib/profiles.js +22 -6
  138. package/dist/lib/pty-client.js +2 -1
  139. package/dist/lib/rotate.d.ts +61 -0
  140. package/dist/lib/rotate.js +52 -0
  141. package/dist/lib/routines.d.ts +40 -2
  142. package/dist/lib/routines.js +66 -8
  143. package/dist/lib/runner.d.ts +11 -2
  144. package/dist/lib/runner.js +49 -7
  145. package/dist/lib/scheduler.js +6 -1
  146. package/dist/lib/secrets/bundles.d.ts +60 -4
  147. package/dist/lib/secrets/bundles.js +131 -12
  148. package/dist/lib/secrets/filestore.d.ts +3 -0
  149. package/dist/lib/secrets/filestore.js +42 -16
  150. package/dist/lib/secrets/index.d.ts +43 -2
  151. package/dist/lib/secrets/index.js +102 -3
  152. package/dist/lib/secrets/mcp.d.ts +93 -0
  153. package/dist/lib/secrets/mcp.js +205 -0
  154. package/dist/lib/secrets/remote.js +12 -5
  155. package/dist/lib/secrets/sync.js +83 -4
  156. package/dist/lib/secrets/windows.js +14 -3
  157. package/dist/lib/serve/data.d.ts +81 -0
  158. package/dist/lib/serve/data.js +91 -0
  159. package/dist/lib/serve/page.d.ts +7 -0
  160. package/dist/lib/serve/page.js +140 -0
  161. package/dist/lib/serve/server.d.ts +46 -0
  162. package/dist/lib/serve/server.js +115 -0
  163. package/dist/lib/session/active.d.ts +73 -0
  164. package/dist/lib/session/active.js +199 -22
  165. package/dist/lib/session/db.d.ts +2 -1
  166. package/dist/lib/session/db.js +41 -5
  167. package/dist/lib/session/discover.d.ts +39 -0
  168. package/dist/lib/session/discover.js +127 -29
  169. package/dist/lib/session/ghostty-tabs.d.ts +33 -0
  170. package/dist/lib/session/ghostty-tabs.js +126 -0
  171. package/dist/lib/session/inject.d.ts +18 -0
  172. package/dist/lib/session/inject.js +21 -0
  173. package/dist/lib/session/parse.js +23 -20
  174. package/dist/lib/session/pid-registry.d.ts +1 -0
  175. package/dist/lib/session/pid-registry.js +24 -0
  176. package/dist/lib/session/provenance.d.ts +14 -2
  177. package/dist/lib/session/provenance.js +39 -8
  178. package/dist/lib/session/relative-time.js +6 -2
  179. package/dist/lib/session/remote-active.js +19 -17
  180. package/dist/lib/session/remote-list.d.ts +51 -0
  181. package/dist/lib/session/remote-list.js +205 -0
  182. package/dist/lib/session/remote.d.ts +7 -1
  183. package/dist/lib/session/remote.js +20 -4
  184. package/dist/lib/session/sync/config.d.ts +14 -15
  185. package/dist/lib/session/sync/config.js +60 -20
  186. package/dist/lib/session/types.d.ts +23 -0
  187. package/dist/lib/shims.d.ts +36 -6
  188. package/dist/lib/shims.js +91 -29
  189. package/dist/lib/ssh-exec.js +2 -0
  190. package/dist/lib/ssh-tunnel.js +2 -1
  191. package/dist/lib/startup/command-registry.d.ts +6 -0
  192. package/dist/lib/startup/command-registry.js +13 -1
  193. package/dist/lib/state.d.ts +13 -0
  194. package/dist/lib/state.js +103 -9
  195. package/dist/lib/sync-umbrella.d.ts +14 -7
  196. package/dist/lib/sync-umbrella.js +21 -13
  197. package/dist/lib/teams/forEach.d.ts +110 -0
  198. package/dist/lib/teams/forEach.js +186 -0
  199. package/dist/lib/teams/index.d.ts +1 -0
  200. package/dist/lib/teams/index.js +1 -0
  201. package/dist/lib/teams/pr-watch.d.ts +226 -0
  202. package/dist/lib/teams/pr-watch.js +371 -0
  203. package/dist/lib/teams/supervisor.d.ts +14 -1
  204. package/dist/lib/teams/supervisor.js +19 -0
  205. package/dist/lib/teams/worktree.d.ts +9 -0
  206. package/dist/lib/teams/worktree.js +32 -0
  207. package/dist/lib/terminal/backends/index.d.ts +2 -1
  208. package/dist/lib/terminal/backends/index.js +3 -1
  209. package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
  210. package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
  211. package/dist/lib/terminal/index.d.ts +4 -1
  212. package/dist/lib/terminal/index.js +4 -1
  213. package/dist/lib/terminal/inject.d.ts +204 -0
  214. package/dist/lib/terminal/inject.js +247 -0
  215. package/dist/lib/terminal/resolve.d.ts +64 -0
  216. package/dist/lib/terminal/resolve.js +90 -0
  217. package/dist/lib/terminal/types.d.ts +1 -1
  218. package/dist/lib/tmux/session.d.ts +10 -0
  219. package/dist/lib/tmux/session.js +31 -0
  220. package/dist/lib/triggers/webhook.d.ts +85 -0
  221. package/dist/lib/triggers/webhook.js +141 -0
  222. package/dist/lib/versions.d.ts +23 -0
  223. package/dist/lib/versions.js +119 -13
  224. package/dist/lib/watchdog/index.d.ts +3 -0
  225. package/dist/lib/watchdog/index.js +5 -0
  226. package/dist/lib/watchdog/read.d.ts +35 -0
  227. package/dist/lib/watchdog/read.js +149 -0
  228. package/dist/lib/watchdog/runner.d.ts +127 -0
  229. package/dist/lib/watchdog/runner.js +322 -0
  230. package/dist/lib/watchdog/watchdog.d.ts +40 -0
  231. package/dist/lib/watchdog/watchdog.js +166 -0
  232. package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
  233. package/dist/lib/watchdog/watchdogTail.js +154 -0
  234. package/dist/lib/workflows.d.ts +166 -0
  235. package/dist/lib/workflows.js +193 -0
  236. package/package.json +5 -4
@@ -16,9 +16,25 @@ import * as path from 'path';
16
16
  import * as os from 'os';
17
17
  import { createHash } from 'node:crypto';
18
18
  import { parseSshConnection } from './session/provenance.js';
19
+ import { getLogsDir } from './state.js';
19
20
  // ─── Constants ────────────────────────────────────────────────────────────────
20
- // Logs live under the cache bucket — they're regenerable telemetry.
21
- const LOGS_DIR = path.join(os.homedir(), '.agents', '.cache', 'logs');
21
+ // Logs live under the cache bucket — they're regenerable telemetry. Route
22
+ // through state's canonical home anchor (HOME override → os.homedir()) rather
23
+ // than a bare os.homedir(): on Windows os.homedir() reads USERPROFILE and
24
+ // ignores a HOME override, so a test (or any caller) that redirects HOME would
25
+ // have its events silently written to the real profile instead. state.getLogsDir()
26
+ // honors the HOME override on every platform while falling back to os.homedir()
27
+ // (== USERPROFILE on Windows) in production where HOME is unset.
28
+ //
29
+ // Resolved lazily + memoized: importing this module must NOT call getLogsDir()
30
+ // at eval time. events.ts is pulled in transitively (skills/versions/exec/
31
+ // runner), and several tests mock './state.js' with partial factories that omit
32
+ // getLogsDir — an eager call would crash those on import. Deferring to first use
33
+ // keeps a bare import side-effect-free while staying a one-time resolution.
34
+ let _logsDir;
35
+ function logsDir() {
36
+ return (_logsDir ??= getLogsDir());
37
+ }
22
38
  /** Default retention period in days. */
23
39
  const DEFAULT_RETENTION_DAYS = 7;
24
40
  /** Default max length for truncated strings. */
@@ -54,16 +70,16 @@ function getLogFilePath(date = new Date()) {
54
70
  const yyyy = date.getFullYear();
55
71
  const mm = String(date.getMonth() + 1).padStart(2, '0');
56
72
  const dd = String(date.getDate()).padStart(2, '0');
57
- return path.join(LOGS_DIR, `events-${yyyy}-${mm}-${dd}.jsonl`);
73
+ return path.join(logsDir(), `events-${yyyy}-${mm}-${dd}.jsonl`);
58
74
  }
59
75
  function ensureLogsDir() {
60
- if (!fs.existsSync(LOGS_DIR)) {
61
- fs.mkdirSync(LOGS_DIR, { recursive: true, mode: DIR_MODE });
76
+ if (!fs.existsSync(logsDir())) {
77
+ fs.mkdirSync(logsDir(), { recursive: true, mode: DIR_MODE });
62
78
  }
63
79
  else {
64
80
  // Ensure permissions are correct on existing dir
65
81
  try {
66
- fs.chmodSync(LOGS_DIR, DIR_MODE);
82
+ fs.chmodSync(logsDir(), DIR_MODE);
67
83
  }
68
84
  catch {
69
85
  // May fail if not owner
@@ -395,10 +411,10 @@ export function emitError(err, payload = {}) {
395
411
  */
396
412
  export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
397
413
  try {
398
- if (!fs.existsSync(LOGS_DIR))
414
+ if (!fs.existsSync(logsDir()))
399
415
  return 0;
400
416
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
401
- const files = fs.readdirSync(LOGS_DIR).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
417
+ const files = fs.readdirSync(logsDir()).filter(f => f.startsWith('events-') && f.endsWith('.jsonl'));
402
418
  let removed = 0;
403
419
  for (const file of files) {
404
420
  const match = file.match(/^events-(\d{4})-(\d{2})-(\d{2})\.jsonl$/);
@@ -407,7 +423,7 @@ export function rotate(retentionDays = DEFAULT_RETENTION_DAYS) {
407
423
  const [, yyyy, mm, dd] = match;
408
424
  const fileDate = new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
409
425
  if (fileDate.getTime() < cutoff) {
410
- fs.unlinkSync(path.join(LOGS_DIR, file));
426
+ fs.unlinkSync(path.join(logsDir(), file));
411
427
  removed++;
412
428
  }
413
429
  }
@@ -439,9 +455,9 @@ export function maybeRotate() {
439
455
  export function query(options) {
440
456
  const { startDate, endDate = new Date(), eventTypes, agent, command, module, limit } = options;
441
457
  const results = [];
442
- if (!fs.existsSync(LOGS_DIR))
458
+ if (!fs.existsSync(logsDir()))
443
459
  return results;
444
- const files = fs.readdirSync(LOGS_DIR)
460
+ const files = fs.readdirSync(logsDir())
445
461
  .filter(f => f.startsWith('events-') && f.endsWith('.jsonl'))
446
462
  .sort()
447
463
  .reverse();
@@ -467,7 +483,7 @@ export function query(options) {
467
483
  continue;
468
484
  if (endDay && fileDate > endDay)
469
485
  continue;
470
- const content = fs.readFileSync(path.join(LOGS_DIR, file), 'utf-8');
486
+ const content = fs.readFileSync(path.join(logsDir(), file), 'utf-8');
471
487
  const lines = content.trim().split('\n').filter(Boolean);
472
488
  for (const line of lines.reverse()) {
473
489
  try {
@@ -528,4 +544,6 @@ export function getTimingStats(label, options = {}) {
528
544
  };
529
545
  }
530
546
  // ─── Exports ──────────────────────────────────────────────────────────────────
531
- export const LOGS_PATH = LOGS_DIR;
547
+ export function getLogsPath() {
548
+ return logsDir();
549
+ }
@@ -230,6 +230,12 @@ export interface FallbackEntry {
230
230
  agent: AgentId;
231
231
  /** Optional pinned version (e.g. '0.116.0'). When set, takes precedence over the active default. */
232
232
  version?: string;
233
+ /**
234
+ * Env vars merged over options.env for THIS attempt only. Used by profiles
235
+ * with `fallback_model` to swap the model env key (e.g. ANTHROPIC_MODEL) on
236
+ * a same-agent retry without touching auth or base URL.
237
+ */
238
+ envOverride?: Record<string, string>;
233
239
  }
234
240
  /** ExecOptions extended with a fallback chain for rate-limit cascading. */
235
241
  export interface FallbackOptions extends ExecOptions {
@@ -237,6 +243,17 @@ export interface FallbackOptions extends ExecOptions {
237
243
  fallback: FallbackEntry[];
238
244
  /** Fallback requires a prompt -- chain handoff doesn't apply to interactive sessions. */
239
245
  prompt: string;
246
+ /**
247
+ * Optional out-param the caller reads AFTER the call to learn which chain
248
+ * entry actually executed — updated to each entry as it is attempted, so on
249
+ * return it holds the agent+version whose exit code is returned. Lets the
250
+ * audit log record the fallback that really ran, not always the primary
251
+ * (issue #347).
252
+ */
253
+ dispatchSink?: {
254
+ agent?: AgentId;
255
+ version?: string;
256
+ };
240
257
  }
241
258
  /**
242
259
  * Build the prompt handed to the fallback agent when the primary was stopped
package/dist/lib/exec.js CHANGED
@@ -16,7 +16,9 @@ import { resolveModel, buildReasoningFlags } from './models.js';
16
16
  import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
17
17
  import { sanitizeProcessEnv } from './secrets/bundles.js';
18
18
  import { getShimsDir } from './state.js';
19
- import { writePidSessionEntry } from './session/pid-registry.js';
19
+ import { writePidSessionEntry, extractSessionIdArg } from './session/pid-registry.js';
20
+ import { mailboxDir, isValidMailboxId } from './mailbox.js';
21
+ import { composeWin32CommandLine } from './platform/index.js';
20
22
  /**
21
23
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
22
24
  *
@@ -182,6 +184,14 @@ export function buildExecEnv(options) {
182
184
  : (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
183
185
  if (version) {
184
186
  result.CLAUDE_CONFIG_DIR = path.join(getVersionHomePath('claude', version), '.claude');
187
+ // A managed pin lives in a per-version dir; Claude Code's own background
188
+ // auto-updater would rewrite that pinned binary in place (and has left it
189
+ // half-swapped and broken). Disable it so a pin stays a pin. Honor an
190
+ // explicit user value — from process.env (already in result) or from
191
+ // options.env (spread over result below).
192
+ if (result.DISABLE_AUTOUPDATER === undefined) {
193
+ result.DISABLE_AUTOUPDATER = '1';
194
+ }
185
195
  }
186
196
  delete result.CODEX_HOME;
187
197
  delete result.COPILOT_HOME;
@@ -237,6 +247,14 @@ export function buildExecEnv(options) {
237
247
  delete result.COPILOT_HOME;
238
248
  delete result.KIMI_CODE_HOME;
239
249
  }
250
+ // Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
251
+ // knows which box to drain and inject mid-run. Keyed by the session id — the
252
+ // same id the writer resolves via mailboxIdForActiveSession(). A loop run
253
+ // overrides this to its run-level box via options.env (spread below), so all
254
+ // iterations share one inbox.
255
+ if (options.sessionId && isValidMailboxId(options.sessionId)) {
256
+ result.AGENTS_MAILBOX_DIR = mailboxDir(options.sessionId);
257
+ }
240
258
  return {
241
259
  ...result,
242
260
  ...options.env,
@@ -472,11 +490,12 @@ export function buildExecCommand(options) {
472
490
  // on Linux installs where the shims dir isn't on PATH, spawning the bare
473
491
  // versioned name fails with ENOENT even though `agents view` shows the agent.
474
492
  //
475
- // On Windows the shims dir holds a `.cmd` companion next to the bash alias
476
- // (see createVersionedAlias); prefer it so spawn() can launch it (the bash
477
- // script is not directly executable by cmd.exe). When no shim exists on disk
478
- // we fall back to the bare versioned name, which spawnAgent() resolves via
479
- // PATH (+ PATHEXT/shell on Windows).
493
+ // On Windows the alias is materialized as a `.cmd` only (see
494
+ // createVersionedAlias a bash alias next to it would shadow the `.cmd` in
495
+ // cmd.exe/PowerShell name resolution); the extensionless existsSync branch
496
+ // below still matches a legacy install's bash alias. When no shim exists on
497
+ // disk we fall back to the bare versioned name, which spawnAgent() resolves
498
+ // via PATH (+ PATHEXT/shell on Windows).
480
499
  if (options.version && cmd.length > 0) {
481
500
  const versionedName = `${cmd[0]}@${options.version}`;
482
501
  const absPath = path.join(getShimsDir(), versionedName);
@@ -672,7 +691,14 @@ export function resolveShimSpawn(platform, binary, extraArgs) {
672
691
  // Use win32 path semantics regardless of the host running this (the platform
673
692
  // is the parameter, not process.platform) so `C:\...` reads as absolute.
674
693
  const useShell = !path.win32.isAbsolute(binary) || binary.endsWith('.cmd');
675
- return { command: binary, args: extraArgs, shell: useShell };
694
+ if (useShell) {
695
+ // DEP0190-safe: hand cmd.exe ONE fully-quoted command line with an EMPTY
696
+ // args array, so Node never concatenates `extraArgs` (which carry the
697
+ // user's raw prompt/flags) into the shell line unescaped — that concat is
698
+ // both the deprecation and a command-injection surface.
699
+ return { command: composeWin32CommandLine(binary, extraArgs), args: [], shell: true };
700
+ }
701
+ return { command: binary, args: extraArgs, shell: false };
676
702
  }
677
703
  return { command: binary, args: extraArgs, shell: false };
678
704
  }
@@ -705,6 +731,21 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
705
731
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
706
732
  return new Promise((resolve) => {
707
733
  const child = spawn(command, args, { cwd, stdio: 'inherit', env, shell });
734
+ // Record the launch so `ag sessions --active` can attribute the agent
735
+ // process to its cwd (and exact session when the caller passed
736
+ // --session-id). Vital on Windows, where there is no lsof to recover a
737
+ // foreign process's cwd. On the shell path this pid is the cmd.exe
738
+ // wrapper, not the agent binary — the active scan resolves that by
739
+ // walking the candidate's ancestors (readAncestorSessionEntry).
740
+ if (child.pid) {
741
+ writePidSessionEntry({
742
+ pid: child.pid,
743
+ agent,
744
+ sessionId: extractSessionIdArg(rawArgs),
745
+ cwd,
746
+ startedAtMs: Date.now(),
747
+ });
748
+ }
708
749
  child.on('exit', (code, signal) => resolve(code ?? (signal ? 1 : 0)));
709
750
  child.on('error', (err) => {
710
751
  process.stderr.write(`agents: failed to launch ${agent}: ${err.message}\n`);
@@ -772,9 +813,15 @@ async function spawnAgent(options) {
772
813
  ? ['inherit', 'inherit', 'inherit']
773
814
  : ['inherit', tapStdout ? 'pipe' : 'inherit', 'pipe'];
774
815
  // On Windows, .cmd batch wrappers (npm-installed CLIs) require shell:true
775
- // whether addressed by name or absolute path.
816
+ // whether addressed by name or absolute path. On that shell path, compose a
817
+ // single fully-quoted command line and pass an EMPTY args array (see
818
+ // composeWin32CommandLine) so Node never concatenates the args array — which
819
+ // carries the user's prompt — into the cmd.exe line unescaped (DEP0190 +
820
+ // command injection).
776
821
  const useShell = process.platform === 'win32' && (!path.isAbsolute(executable) || executable.endsWith('.cmd'));
777
- const child = spawn(executable, args, {
822
+ const spawnCommand = useShell ? composeWin32CommandLine(executable, args) : executable;
823
+ const spawnArgs = useShell ? [] : args;
824
+ const child = spawn(spawnCommand, spawnArgs, {
778
825
  cwd: options.cwd || process.cwd(),
779
826
  stdio,
780
827
  env: buildExecEnv(options),
@@ -1016,9 +1063,20 @@ export async function runWithFallback(options) {
1016
1063
  }
1017
1064
  }
1018
1065
  for (let i = 0; i < chain.length; i++) {
1019
- const { agent, version } = chain[i];
1066
+ const { agent, version, envOverride } = chain[i];
1067
+ // Record the entry we're about to attempt so the caller (audit log) sees the
1068
+ // agent+version that actually ran, even after a rate-limit handoff.
1069
+ if (options.dispatchSink) {
1070
+ options.dispatchSink.agent = agent;
1071
+ options.dispatchSink.version = version;
1072
+ }
1020
1073
  const pinnedSessionId = agent === 'claude' ? randomUUID() : undefined;
1021
- const prompt = prevAgent
1074
+ // Same-host retry (same agent+version as previous entry — used by profile
1075
+ // `fallback_model` swaps) keeps the original prompt: the model changed,
1076
+ // not the CLI, so a `/continue` handoff prompt would be misleading.
1077
+ const prev = i > 0 ? chain[i - 1] : undefined;
1078
+ const sameHostRetry = !!prev && prev.agent === agent && prev.version === version;
1079
+ const prompt = prevAgent && !sameHostRetry
1022
1080
  ? buildFallbackPrompt(prevAgent, prevSessionId, agent, options.prompt)
1023
1081
  : options.prompt;
1024
1082
  const execOpts = {
@@ -1026,12 +1084,18 @@ export async function runWithFallback(options) {
1026
1084
  agent,
1027
1085
  version,
1028
1086
  prompt,
1087
+ env: envOverride ? { ...(options.env ?? {}), ...envOverride } : options.env,
1029
1088
  sessionId: pinnedSessionId ?? (i === 0 ? options.sessionId : undefined),
1030
1089
  };
1031
1090
  const label = version ? `${agent}@${version}` : agent;
1091
+ const modelSwapNote = sameHostRetry && envOverride
1092
+ ? ` (retry with ${Object.entries(envOverride).map(([k, v]) => `${k}=${v}`).join(', ')})`
1093
+ : '';
1032
1094
  const banner = i === 0
1033
1095
  ? `[agents] running ${label}`
1034
- : `[agents] fallback → ${label}`;
1096
+ : sameHostRetry
1097
+ ? `[agents] retry → ${label}${modelSwapNote}`
1098
+ : `[agents] fallback → ${label}`;
1035
1099
  process.stderr.write(`${banner}${pinnedSessionId ? ` (session ${pinnedSessionId.slice(0, 8)})` : ''}\n`);
1036
1100
  let result;
1037
1101
  try {
@@ -1054,7 +1118,9 @@ export async function runWithFallback(options) {
1054
1118
  }
1055
1119
  const next = chain[i + 1];
1056
1120
  const nextLabel = next.version ? `${next.agent}@${next.version}` : next.agent;
1057
- process.stderr.write(`[agents] ${label} hit rate limit. Handing off to ${nextLabel}...\n`);
1121
+ const nextSameHost = next.agent === agent && next.version === version;
1122
+ const handoffVerb = nextSameHost ? 'Retrying on same host' : 'Handing off';
1123
+ process.stderr.write(`[agents] ${label} hit rate limit. ${handoffVerb} to ${nextLabel}...\n`);
1058
1124
  prevAgent = agent;
1059
1125
  prevSessionId = pinnedSessionId;
1060
1126
  }
package/dist/lib/git.d.ts CHANGED
@@ -112,6 +112,11 @@ export declare function isSystemRepoOrigin(dir: string): Promise<boolean>;
112
112
  * Check if repo has uncommitted changes (including untracked files).
113
113
  */
114
114
  export declare function hasLocalChanges(dir: string): Promise<boolean>;
115
+ /**
116
+ * Render an absolute path in ~-relative form with forward slashes, matching the
117
+ * way the rest of the CLI prints home-anchored paths (e.g. `~/.agents/.system`).
118
+ */
119
+ export declare function displayHomePath(dir: string): string;
115
120
  /**
116
121
  * Pull changes in an existing repo.
117
122
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
@@ -121,6 +126,28 @@ export declare function pullRepo(dir: string): Promise<{
121
126
  commit: string;
122
127
  error?: string;
123
128
  }>;
129
+ /**
130
+ * Rebase a repo onto its remote, optionally pushing local commits back up.
131
+ *
132
+ * The one-repo counterpart to `pullRepo` used by `agents sync <repo>`:
133
+ * 1. Refuse if the working tree is dirty (commit or discard first).
134
+ * 2. `git fetch origin` then `git pull --rebase origin <branch>` — rebase, not
135
+ * merge, so a local commit lands cleanly on top of upstream with no merge
136
+ * bubble.
137
+ * 3. When `push` is set, `git push origin <branch>` to send local commits up.
138
+ *
139
+ * The branch is read from the repo's current HEAD (falls back to `main`) rather
140
+ * than hardcoded. System repos pass `push: false` — they are pull-only mirrors
141
+ * of the npm-shipped upstream.
142
+ */
143
+ export declare function syncRepoGit(dir: string, opts: {
144
+ push: boolean;
145
+ }): Promise<{
146
+ success: boolean;
147
+ commit: string;
148
+ pushed: boolean;
149
+ error?: string;
150
+ }>;
124
151
  /**
125
152
  * Get git status for sync display.
126
153
  * Returns files categorized by their status relative to HEAD.
package/dist/lib/git.js CHANGED
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import simpleGit from 'simple-git';
9
9
  import * as fs from 'fs';
10
+ import * as os from 'os';
10
11
  import * as path from 'path';
11
12
  import { IS_WINDOWS, isWindowsAbsolutePath } from './platform/index.js';
12
13
  import { getPackageLocalPath } from './state.js';
@@ -494,6 +495,15 @@ export async function hasLocalChanges(dir) {
494
495
  return false;
495
496
  }
496
497
  }
498
+ /**
499
+ * Render an absolute path in ~-relative form with forward slashes, matching the
500
+ * way the rest of the CLI prints home-anchored paths (e.g. `~/.agents/.system`).
501
+ */
502
+ export function displayHomePath(dir) {
503
+ const home = os.homedir();
504
+ const rel = dir.startsWith(home) ? '~' + dir.slice(home.length) : dir;
505
+ return rel.replace(/\\/g, '/');
506
+ }
497
507
  /**
498
508
  * Pull changes in an existing repo.
499
509
  * Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
@@ -506,7 +516,7 @@ export async function pullRepo(dir) {
506
516
  return {
507
517
  success: false,
508
518
  commit: '',
509
- error: 'Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ~/.agents && git status',
519
+ error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
510
520
  };
511
521
  }
512
522
  await git.fetch();
@@ -522,6 +532,51 @@ export async function pullRepo(dir) {
522
532
  return { success: false, commit: '', error: err.message };
523
533
  }
524
534
  }
535
+ /**
536
+ * Rebase a repo onto its remote, optionally pushing local commits back up.
537
+ *
538
+ * The one-repo counterpart to `pullRepo` used by `agents sync <repo>`:
539
+ * 1. Refuse if the working tree is dirty (commit or discard first).
540
+ * 2. `git fetch origin` then `git pull --rebase origin <branch>` — rebase, not
541
+ * merge, so a local commit lands cleanly on top of upstream with no merge
542
+ * bubble.
543
+ * 3. When `push` is set, `git push origin <branch>` to send local commits up.
544
+ *
545
+ * The branch is read from the repo's current HEAD (falls back to `main`) rather
546
+ * than hardcoded. System repos pass `push: false` — they are pull-only mirrors
547
+ * of the npm-shipped upstream.
548
+ */
549
+ export async function syncRepoGit(dir, opts) {
550
+ try {
551
+ if (!isGitRepo(dir)) {
552
+ return { success: false, commit: '', pushed: false, error: `Not a git repo: ${dir}` };
553
+ }
554
+ const git = simpleGit(dir);
555
+ const status = await git.status();
556
+ if (!status.isClean()) {
557
+ return {
558
+ success: false,
559
+ commit: '',
560
+ pushed: false,
561
+ error: `Working tree has uncommitted changes. Commit or discard them first.\n\n cd ${dir} && git status`,
562
+ };
563
+ }
564
+ const branch = status.current || 'main';
565
+ await git.fetch('origin');
566
+ await git.pull('origin', branch, { '--rebase': 'true' });
567
+ installGithooksSymlinks(dir);
568
+ let pushed = false;
569
+ if (opts.push) {
570
+ await git.push('origin', branch);
571
+ pushed = true;
572
+ }
573
+ const log = await git.log({ maxCount: 1 });
574
+ return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', pushed };
575
+ }
576
+ catch (err) {
577
+ return { success: false, commit: '', pushed: false, error: err.message };
578
+ }
579
+ }
525
580
  /** Compute the sync status of a git repo, optionally scoped to a subdirectory. */
526
581
  export async function getGitSyncStatus(dir, subdir) {
527
582
  if (!isGitRepo(dir)) {
@@ -7,6 +7,12 @@ import type { HookCache, HookCacheConfig } from '../types.js';
7
7
  export declare function parseCacheConfig(raw: HookCache | undefined): HookCacheConfig | null;
8
8
  /** Parse "30s" | "5m" | "1h" | plain seconds. Returns seconds, or null on failure. */
9
9
  export declare function parseDuration(d: number | string | undefined): number | null;
10
+ /**
11
+ * Reject hook names that could escape the shims directory when interpolated
12
+ * into a filename. Mirrors the containment gate on hook script resolution in
13
+ * hooks.ts (`resolveContainedHookPath`).
14
+ */
15
+ export declare function isValidHookShimName(name: string): boolean;
10
16
  /** Absolute path of the generated shim for a hook name. */
11
17
  export declare function getHookShimPath(name: string): string;
12
18
  /**
@@ -70,9 +70,35 @@ export function parseDuration(d) {
70
70
  return value * 60;
71
71
  return value;
72
72
  }
73
+ /**
74
+ * Reject hook names that could escape the shims directory when interpolated
75
+ * into a filename. Mirrors the containment gate on hook script resolution in
76
+ * hooks.ts (`resolveContainedHookPath`).
77
+ */
78
+ export function isValidHookShimName(name) {
79
+ return (!!name &&
80
+ name !== '.' &&
81
+ name !== '..' &&
82
+ !name.startsWith('-') &&
83
+ !/[\/\\\x00]/.test(name) &&
84
+ name.length <= 255);
85
+ }
86
+ /** Resolve shimsDir + `${name}.sh` and assert the result stays inside shimsDir. */
87
+ function resolveContainedHookShimPath(shimsDir, name) {
88
+ if (!isValidHookShimName(name)) {
89
+ throw new Error(`Invalid hook shim name: ${name}`);
90
+ }
91
+ const resolvedRoot = path.resolve(shimsDir);
92
+ const candidate = path.join(shimsDir, `${name}.sh`);
93
+ const resolved = path.resolve(candidate);
94
+ if (!resolved.startsWith(resolvedRoot + path.sep)) {
95
+ throw new Error(`Invalid hook shim name: ${name}`);
96
+ }
97
+ return resolved;
98
+ }
73
99
  /** Absolute path of the generated shim for a hook name. */
74
100
  export function getHookShimPath(name) {
75
- return path.join(getHookShimsDir(), `${name}.sh`);
101
+ return resolveContainedHookShimPath(getHookShimsDir(), name);
76
102
  }
77
103
  /**
78
104
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
@@ -82,7 +108,7 @@ export function generateHookShim(args) {
82
108
  const shimsDir = args.paths?.shimsDir ?? getHookShimsDir();
83
109
  const cacheDir = args.paths?.cacheDir ?? getHookCacheDir();
84
110
  const logsDir = args.paths?.logsDir ?? getLogsDir();
85
- const shimPath = path.join(shimsDir, `${args.name}.sh`);
111
+ const shimPath = resolveContainedHookShimPath(shimsDir, args.name);
86
112
  const content = renderShim(args.name, args.scriptPath, args.cache, { cacheDir, logsDir });
87
113
  fs.mkdirSync(shimsDir, { recursive: true });
88
114
  let existing = null;
@@ -105,9 +131,10 @@ export function generateHookShim(args) {
105
131
  return shimPath;
106
132
  }
107
133
  /**
108
- * Render the bash shim. Bash 3.2-compatible (macOS default). Uses python3 for
109
- * monotonic-ish nanosecond timing already a hard dependency of other hooks
110
- * in this repo (04-capture-session-start-metadata.sh does the same).
134
+ * Render the bash shim. Bash 3.2-compatible (macOS default). Uses Python for
135
+ * hashing + monotonic-ish nanosecond timing + portable mtime, resolved at
136
+ * runtime (python3, then python) so a Windows Microsoft Store `python3` alias
137
+ * stub — which exits non-zero without running — doesn't silently break caching.
111
138
  */
112
139
  function renderShim(name, scriptPath, cache, paths) {
113
140
  const ttl = typeof cache.ttl === 'number' ? cache.ttl : (parseDuration(cache.ttl) ?? 0);
@@ -133,12 +160,25 @@ KEY_MODE=${q(key)}
133
160
 
134
161
  mkdir -p "$CACHE_DIR" "$LOGS_DIR"
135
162
 
163
+ # Resolve a real Python. On Windows, bare python3 is often a Microsoft Store
164
+ # app-execution alias stub that prints to stderr and exits non-zero (0 bytes on
165
+ # stdout) -- command -v finds it but it cannot run, which silently empties the
166
+ # hash + mtime primitives below and makes EVERY call a cache miss (the hook
167
+ # re-runs every time). Probe by executing, not by lookup, and fall back to python.
168
+ PY=""
169
+ for _cand in python3 python; do
170
+ if command -v "$_cand" >/dev/null 2>&1 && "$_cand" -c 'import sys' >/dev/null 2>&1; then
171
+ PY="$_cand"; break
172
+ fi
173
+ done
174
+ [ -z "$PY" ] && PY=python3
175
+
136
176
  # Read stdin once (Claude/Codex/Gemini pass JSON on stdin to every hook).
137
177
  STDIN_PAYLOAD="$(cat || true)"
138
178
 
139
179
  # Portable sha1 — \`shasum\` is Perl, missing on minimal Linux images;
140
180
  # \`sha1sum\` is coreutils, missing on macOS. Truncate to 12 hex chars.
141
- sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
181
+ sha1_12() { "$PY" -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().encode()).hexdigest()[:12])'; }
142
182
 
143
183
  # Derive cache key suffix from KEY_MODE. All untrusted inputs (cwd, session_id,
144
184
  # project path) are hashed before going into the filename so a malicious stdin
@@ -146,14 +186,14 @@ sha1_12() { python3 -c 'import hashlib,sys; print(hashlib.sha1(sys.stdin.read().
146
186
  cache_suffix=""
147
187
  case "$KEY_MODE" in
148
188
  per-cwd)
149
- cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
189
+ cwd_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
150
190
  try: print(json.load(sys.stdin).get("cwd","") or "")
151
191
  except Exception: pass' 2>/dev/null || true)"
152
192
  [ -z "$cwd_val" ] && cwd_val="$PWD"
153
193
  cache_suffix=".$(printf '%s' "$cwd_val" | sha1_12)"
154
194
  ;;
155
195
  per-session)
156
- sid_val="$(printf '%s' "$STDIN_PAYLOAD" | python3 -c 'import json,sys
196
+ sid_val="$(printf '%s' "$STDIN_PAYLOAD" | "$PY" -c 'import json,sys
157
197
  try: print(json.load(sys.stdin).get("session_id","") or "")
158
198
  except Exception: pass' 2>/dev/null || true)"
159
199
  # Hash + fall back to a sentinel so missing-session doesn't silently
@@ -173,7 +213,7 @@ esac
173
213
  CACHE_FILE="$CACHE_DIR/$HOOK_NAME$cache_suffix.out"
174
214
 
175
215
  # Monotonic-ish nanosecond timer (macOS \`date\` has no %N).
176
- now_ns() { python3 -c 'import time; print(int(time.time()*1e9))'; }
216
+ now_ns() { "$PY" -c 'import time; print(int(time.time()*1e9))'; }
177
217
  START_NS=$(now_ns)
178
218
 
179
219
  CACHE_STATUS=miss
@@ -181,10 +221,10 @@ CACHE_AGE=-1
181
221
  EXIT=0
182
222
 
183
223
  if [ -f "$CACHE_FILE" ]; then
184
- # python3 is already a hard dep (used for now_ns) and gives portable mtime
224
+ # $PY is already resolved (used for now_ns) and gives portable mtime
185
225
  # without the macOS-vs-Linux \`stat\` flag divergence (-f %m vs -c %Y) that
186
226
  # blew up under \`set -u\` when the wrong flag produced literal "%m".
187
- mtime=$(python3 -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
227
+ mtime=$("$PY" -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$CACHE_FILE" 2>/dev/null)
188
228
  mtime=\${mtime:-0}
189
229
  now_s=$(date +%s)
190
230
  CACHE_AGE=$((now_s - mtime))
@@ -231,8 +271,10 @@ exit "$EXIT"
231
271
  * hook is renamed/deleted or has its `cache:` field removed.
232
272
  */
233
273
  export function removeHookShim(name, shimsDir) {
274
+ if (!isValidHookShimName(name))
275
+ return;
234
276
  const dir = shimsDir ?? getHookShimsDir();
235
- const shimPath = path.join(dir, `${name}.sh`);
277
+ const shimPath = resolveContainedHookShimPath(dir, name);
236
278
  if (fs.existsSync(shimPath)) {
237
279
  try {
238
280
  fs.unlinkSync(shimPath);
@@ -8,6 +8,19 @@
8
8
  * and syncing them across version switches.
9
9
  */
10
10
  export declare function resolveHookScriptPath(script: string): string | null;
11
+ /**
12
+ * Convert an absolute path under HOME to a portable ~/... form with forward
13
+ * slashes. Hook commands stored this way work on both macOS and Windows:
14
+ * absolute Windows paths break in bash because backslashes are stripped as
15
+ * escape characters, whereas ~/... paths expand correctly via the ~/.claude
16
+ * symlink/junction on both platforms.
17
+ *
18
+ * `home` and `sep` are injectable so the Windows behavior (backslash sep,
19
+ * drive-letter home) is unit-testable on a POSIX CI host — pass sep='\\' to
20
+ * simulate Windows. With the defaults this is byte-identical to reading
21
+ * os.homedir()/path.sep at the call site.
22
+ */
23
+ export declare function toPortableCommand(absPath: string, home?: string, sep?: string): string;
11
24
  import type { AgentId, InstalledHook, ManifestHook } from './types.js';
12
25
  export type HookEntry = {
13
26
  name: string;
@@ -165,3 +178,17 @@ export declare function registerHooksToSettings(agentId: AgentId, versionHome: s
165
178
  registered: string[];
166
179
  errors: string[];
167
180
  };
181
+ /**
182
+ * Prune every Claude-family (`settings.json`) hook entry whose command lives
183
+ * under a removed version's home
184
+ * (`~/.agents/.history/versions/<agent>/<removedVersion>/home/…`).
185
+ *
186
+ * `agents remove <agent>@<version>` soft-deletes the version's files but leaves
187
+ * the hook entries other version homes registered against it — dead hooks that
188
+ * error on every tool call ("No such file or directory") until the next sync.
189
+ * This clears them from a remaining version's settings immediately. Only the
190
+ * removed version's entries are touched; the current version's entries, system
191
+ * hooks, and the user's own custom hooks are left intact. Returns the number of
192
+ * entries removed.
193
+ */
194
+ export declare function pruneVersionHomeHookEntriesFromSettings(settingsPath: string, agent: AgentId, removedVersion: string): number;