@phnx-labs/agents-cli 1.20.50 → 1.20.51

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 (82) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/commands/browser-picker.js +1 -18
  3. package/dist/commands/cloud.js +1 -25
  4. package/dist/commands/computer.d.ts +1 -0
  5. package/dist/commands/computer.js +129 -8
  6. package/dist/commands/exec.js +49 -6
  7. package/dist/commands/factory.js +1 -4
  8. package/dist/commands/inspect.js +1 -11
  9. package/dist/commands/mcp.js +2 -6
  10. package/dist/commands/message.js +1 -4
  11. package/dist/commands/profiles.js +1 -18
  12. package/dist/commands/repo.js +33 -14
  13. package/dist/commands/resource-view.d.ts +1 -0
  14. package/dist/commands/resource-view.js +5 -17
  15. package/dist/commands/secrets.d.ts +1 -0
  16. package/dist/commands/secrets.js +1 -28
  17. package/dist/commands/sessions-picker.js +1 -18
  18. package/dist/commands/sessions.js +6 -8
  19. package/dist/commands/teams-picker.js +1 -32
  20. package/dist/commands/teams.js +1 -27
  21. package/dist/commands/tmux.js +1 -3
  22. package/dist/commands/view.js +1 -9
  23. package/dist/commands/worktree.js +1 -4
  24. package/dist/lib/agents.d.ts +0 -4
  25. package/dist/lib/agents.js +20 -33
  26. package/dist/lib/auto-dispatch-linear.d.ts +18 -0
  27. package/dist/lib/auto-dispatch-linear.js +107 -0
  28. package/dist/lib/auto-dispatch-provider.d.ts +10 -0
  29. package/dist/lib/auto-dispatch-provider.js +25 -0
  30. package/dist/lib/auto-dispatch.d.ts +87 -0
  31. package/dist/lib/auto-dispatch.js +142 -0
  32. package/dist/lib/browser/cdp.js +11 -2
  33. package/dist/lib/browser/drivers/ssh.d.ts +28 -10
  34. package/dist/lib/browser/drivers/ssh.js +57 -18
  35. package/dist/lib/browser/refs.js +1 -5
  36. package/dist/lib/cli-resources.d.ts +0 -2
  37. package/dist/lib/cli-resources.js +30 -13
  38. package/dist/lib/cloud/rush.d.ts +0 -24
  39. package/dist/lib/cloud/rush.js +0 -31
  40. package/dist/lib/crabbox/cli.js +4 -1
  41. package/dist/lib/crabbox/lease.js +29 -1
  42. package/dist/lib/daemon.js +41 -0
  43. package/dist/lib/exec.js +31 -14
  44. package/dist/lib/format.d.ts +38 -0
  45. package/dist/lib/format.js +108 -0
  46. package/dist/lib/git.d.ts +21 -0
  47. package/dist/lib/git.js +92 -0
  48. package/dist/lib/hooks/cache.d.ts +9 -2
  49. package/dist/lib/hooks/cache.js +220 -8
  50. package/dist/lib/hooks.js +17 -8
  51. package/dist/lib/platform/exec.d.ts +4 -1
  52. package/dist/lib/platform/exec.js +8 -2
  53. package/dist/lib/resources.d.ts +0 -8
  54. package/dist/lib/resources.js +0 -10
  55. package/dist/lib/runner.js +10 -2
  56. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  57. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  58. package/dist/lib/session/active.d.ts +11 -1
  59. package/dist/lib/session/active.js +3 -0
  60. package/dist/lib/session/db.d.ts +1 -4
  61. package/dist/lib/session/db.js +20 -25
  62. package/dist/lib/session/discover.d.ts +2 -2
  63. package/dist/lib/session/discover.js +61 -48
  64. package/dist/lib/session/parse.js +35 -34
  65. package/dist/lib/session/render.d.ts +7 -3
  66. package/dist/lib/session/render.js +15 -9
  67. package/dist/lib/session/state.d.ts +55 -0
  68. package/dist/lib/session/state.js +87 -10
  69. package/dist/lib/session/types.d.ts +9 -0
  70. package/dist/lib/shims.d.ts +9 -3
  71. package/dist/lib/shims.js +44 -8
  72. package/dist/lib/ssh-tunnel.d.ts +33 -2
  73. package/dist/lib/ssh-tunnel.js +94 -7
  74. package/dist/lib/staleness/types.d.ts +0 -1
  75. package/dist/lib/types.d.ts +14 -1
  76. package/dist/lib/versions.d.ts +0 -26
  77. package/dist/lib/versions.js +2 -145
  78. package/dist/lib/warn-unpushed.d.ts +40 -0
  79. package/dist/lib/warn-unpushed.js +128 -0
  80. package/package.json +3 -1
  81. package/dist/lib/resources/index.d.ts +0 -53
  82. package/dist/lib/resources/index.js +0 -76
@@ -21,6 +21,7 @@ import * as path from 'path';
21
21
  import { spawnSync } from 'child_process';
22
22
  import * as yaml from 'yaml';
23
23
  import { listResources, resolveResource } from './resources.js';
24
+ import { composeWin32CommandLine } from './platform/index.js';
24
25
  // ─── Validation primitives ───────────────────────────────────────────────────
25
26
  /** Token allowed inside `check:` strings — letters, digits, underscore, dot, slash, dash. */
26
27
  const SAFE_CHECK_TOKEN = /^[a-zA-Z0-9_./-]+$/;
@@ -253,19 +254,27 @@ export function resolveCliManifest(name, cwd) {
253
254
  // ─── Host detection ──────────────────────────────────────────────────────────
254
255
  /**
255
256
  * Return true if a command resolves on the current PATH. Uses POSIX `command -v`
256
- * via spawn argv (no shell); results are cached for the lifetime of the process.
257
+ * (or `where` on Windows) via spawn argv (no shell); results are cached for the
258
+ * lifetime of the process.
257
259
  */
258
260
  const cmdExistsCache = new Map();
259
261
  export function hasCommand(cmd) {
260
262
  if (cmdExistsCache.has(cmd))
261
263
  return cmdExistsCache.get(cmd);
262
- // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
263
- // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
264
- // by callers that haven't validated it, so we route via argv to neutralize metas.
265
- const result = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
266
- stdio: 'ignore',
267
- });
268
- const ok = result.status === 0;
264
+ let ok;
265
+ if (process.platform === 'win32') {
266
+ // `sh` only exists when Git Bash is installed; `where` is the native PATH
267
+ // probe (resolves .exe/.cmd/.bat via PATHEXT). Argv keeps `cmd` uninterpolated.
268
+ ok = spawnSync('where', [cmd], { stdio: 'ignore' }).status === 0;
269
+ }
270
+ else {
271
+ // `command` is a shell builtin on most POSIX shells; invoking `sh -c 'command -v X'`
272
+ // with X as an *argument* (not interpolated) is the safe path. `cmd` may be passed
273
+ // by callers that haven't validated it, so we route via argv to neutralize metas.
274
+ ok = spawnSync('sh', ['-c', 'command -v "$1" >/dev/null 2>&1', '_', cmd], {
275
+ stdio: 'ignore',
276
+ }).status === 0;
277
+ }
269
278
  cmdExistsCache.set(cmd, ok);
270
279
  return ok;
271
280
  }
@@ -280,7 +289,19 @@ export function isCliInstalled(manifest) {
280
289
  return hasCommand(c.cmd);
281
290
  }
282
291
  const result = spawnSync(c.cmd, c.args, { stdio: 'ignore', timeout: 10_000 });
283
- return result.status === 0;
292
+ if (result.status === 0)
293
+ return true;
294
+ // On Windows the PATH entry point is often a `.cmd`/`.bat` shim (npm installs,
295
+ // script installers), which Node refuses to spawn without a shell (ENOENT /
296
+ // EINVAL). A failed *spawn* — not a failed run — gets one retry through the
297
+ // shell, as a single pre-composed line (DEP0190-safe). Check tokens are
298
+ // SAFE_CHECK_TOKEN-validated at parse time, so the line cannot inject.
299
+ if (process.platform === 'win32' && result.error) {
300
+ const line = composeWin32CommandLine(c.cmd, c.args);
301
+ const retry = spawnSync(line, { stdio: 'ignore', timeout: 10_000, shell: true });
302
+ return retry.status === 0;
303
+ }
304
+ return false;
284
305
  }
285
306
  // ─── Method selection ────────────────────────────────────────────────────────
286
307
  /**
@@ -480,7 +501,3 @@ export function listCliStatus(cwd) {
480
501
  }));
481
502
  return { statuses, errors };
482
503
  }
483
- /** Names of CLIs that are declared but not currently installed on the host. */
484
- export function getMissingClis(cwd) {
485
- return listCliStatus(cwd).statuses.filter((s) => !s.installed).map((s) => s.manifest);
486
- }
@@ -75,30 +75,6 @@ export declare function buildDispatchBody(input: {
75
75
  */
76
76
  images?: ImageAttachment[] | null;
77
77
  }): Record<string, unknown>;
78
- /** A single account registered in Rush Cloud's multi-account rotation pool. */
79
- export interface RemoteAccount {
80
- id: string;
81
- provider: string;
82
- email: string | null;
83
- subscription_type: string | null;
84
- five_hour_pct: number | null;
85
- seven_day_pct: number | null;
86
- usage_fetched_at: string | null;
87
- created_at: string;
88
- }
89
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
90
- export declare function listRemoteAccounts(): Promise<RemoteAccount[]>;
91
- /**
92
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
93
- * The server validates the token against the Anthropic usage API and stores it
94
- * encrypted in Vault. Returns the account metadata (no token).
95
- */
96
- export declare function addRemoteAccount(provider: string, pastedToken: string): Promise<RemoteAccount & {
97
- five_hour_pct: number | null;
98
- seven_day_pct: number | null;
99
- }>;
100
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
101
- export declare function removeRemoteAccount(id: string): Promise<void>;
102
78
  export declare class RushCloudProvider implements CloudProvider {
103
79
  id: "rush";
104
80
  name: string;
@@ -294,37 +294,6 @@ export function buildDispatchBody(input) {
294
294
  }
295
295
  return body;
296
296
  }
297
- /** Fetch all Claude accounts in this user's Rush Cloud rotation pool (no tokens). */
298
- export async function listRemoteAccounts() {
299
- const token = readToken();
300
- const res = await api('GET', '/api/v1/cloud-accounts', token);
301
- if (!res.ok) {
302
- throw new Error(`Failed to list accounts (${res.status}): ${sanitizeErrorBody(await res.text())}`);
303
- }
304
- const data = await res.json();
305
- return data.accounts ?? [];
306
- }
307
- /**
308
- * Register a CLAUDE_CODE_OAUTH_TOKEN with Rush Cloud's rotation pool.
309
- * The server validates the token against the Anthropic usage API and stores it
310
- * encrypted in Vault. Returns the account metadata (no token).
311
- */
312
- export async function addRemoteAccount(provider, pastedToken) {
313
- const token = readToken();
314
- const res = await api('POST', '/api/v1/cloud-accounts', token, { provider, token: pastedToken });
315
- if (!res.ok) {
316
- throw new Error(`Failed to add account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
317
- }
318
- return await res.json();
319
- }
320
- /** Remove a Claude account from Rush Cloud's rotation pool by its ID. */
321
- export async function removeRemoteAccount(id) {
322
- const token = readToken();
323
- const res = await api('DELETE', `/api/v1/cloud-accounts/${encodeURIComponent(id)}`, token);
324
- if (!res.ok) {
325
- throw new Error(`Failed to remove account (${res.status}): ${sanitizeErrorBody(await res.text())}`);
326
- }
327
- }
328
297
  export class RushCloudProvider {
329
298
  id = 'rush';
330
299
  name = 'Rush Cloud';
@@ -209,7 +209,10 @@ export function crabboxRunScript(slug, script, opts = {}) {
209
209
  /** Release the lease / delete the box. Best-effort; never throws. */
210
210
  export function crabboxStop(slug, opts = {}) {
211
211
  try {
212
- const r = spawnSync('crabbox', ['stop', '--id', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
212
+ // Positional target: crabbox's stop subcommand has no --id flag (unlike
213
+ // status/run/ssh) — `stop --id <slug>` dies with "flag provided but not
214
+ // defined: -id" and the box leaks past the run it was leased for.
215
+ const r = spawnSync('crabbox', ['stop', slug], { encoding: 'utf-8', env: crabboxEnv(opts) });
213
216
  return r.status === 0;
214
217
  }
215
218
  catch {
@@ -12,6 +12,34 @@ import { buildCredentialScript } from './runtimes.js';
12
12
  function q(s) {
13
13
  return "'" + s.replace(/'/g, "'\\''") + "'";
14
14
  }
15
+ /**
16
+ * Bash snippet that guarantees `agents` is runnable on the box. Fresh crabbox
17
+ * images ship without node, and the box user may not own the global npm prefix,
18
+ * so everything installs user-level under ~/.local (node from the official
19
+ * latest-v22.x tarball to satisfy engines.node >=22.5.0). Exits 96 with a
20
+ * diagnostic when the CLI still isn't runnable — a silent `|| true` here used
21
+ * to surface only as `agents: command not found` deep in the script.
22
+ */
23
+ const ENSURE_AGENTS_CLI = [
24
+ 'export PATH="$HOME/.local/bin:$PATH"',
25
+ 'if ! command -v node >/dev/null 2>&1; then',
26
+ ' case "$(uname -m)" in aarch64|arm64) narch=arm64;; *) narch=x64;; esac',
27
+ ' nver=$(curl -fsSL https://nodejs.org/dist/latest-v22.x/ | grep -oE "v22\\.[0-9]+\\.[0-9]+" | head -1)',
28
+ ' mkdir -p "$HOME/.local"',
29
+ ' curl -fsSL "https://nodejs.org/dist/latest-v22.x/node-$nver-linux-$narch.tar.xz" | tar -xJ -C "$HOME/.local" --strip-components=1',
30
+ 'fi',
31
+ 'if ! command -v agents >/dev/null 2>&1; then',
32
+ ' npm config set prefix "$HOME/.local" >/dev/null 2>&1 || true',
33
+ ' npm install -g @phnx-labs/agents-cli >/dev/null 2>&1',
34
+ 'fi',
35
+ 'if ! command -v agents >/dev/null 2>&1; then',
36
+ ' echo "lease bootstrap: agents-cli install failed (node: $(command -v node || echo missing))" >&2',
37
+ ' exit 96',
38
+ 'fi',
39
+ // Same first-run guard the hosts bootstrap uses (hosts/ready.ts) — a fresh
40
+ // install refuses `agents run` with "agents-cli is not set up" until setup ran.
41
+ 'if [ ! -d "$HOME/.agents/.system" ]; then agents setup >/dev/null 2>&1 || true; fi',
42
+ ].join('\n');
15
43
  /**
16
44
  * Build the single bootstrap script run on the box: ensure agents-cli, install
17
45
  * the picked runtime CLIs, write their credentials, run the agent, then shred
@@ -35,7 +63,7 @@ export function buildBootstrapScript(opts) {
35
63
  const installRuntimes = opts.runtimes.map((id) => `agents add ${q(id)} >/dev/null 2>&1 || true`).join('\n');
36
64
  return [
37
65
  'set -uo pipefail',
38
- 'if ! command -v agents >/dev/null 2>&1; then npm install -g @phnx-labs/agents-cli >/dev/null 2>&1 || true; fi',
66
+ ENSURE_AGENTS_CLI,
39
67
  installRuntimes,
40
68
  credScript,
41
69
  `${runParts.join(' ')}`,
@@ -393,6 +393,45 @@ export async function runDaemon() {
393
393
  };
394
394
  const healInterval = setInterval(() => { void runHealCheck(); }, 6 * 60 * 60_000);
395
395
  const healKickoff = setTimeout(() => { void runHealCheck(); }, 30_000);
396
+ // Auto-dispatch: for any managed project that has opted in (autoDispatch:true +
397
+ // maxAgents>0 in ~/.agents/factory/projects.json), pick up Linear tickets that
398
+ // are delegated to an agent and still in Todo, and DISPATCH each through
399
+ // agents-cli's own cloud-provider layer (resolveProvider().dispatch(), same as
400
+ // `agents cloud run`) — then mark it Doing so it isn't re-picked. Capped at
401
+ // maxAgents concurrent per project. No hidden Prix dependency: Rush is one
402
+ // provider among rush/codex/factory, pinned per-project via `provider`. OFF
403
+ // unless a project opts in; no opted-in project or no LINEAR_API_KEY is a clean
404
+ // no-op. Overlap-guarded like the probes above. ~every 3 min.
405
+ let autoDispatching = false;
406
+ const runAutoDispatch = async () => {
407
+ if (autoDispatching)
408
+ return;
409
+ autoDispatching = true;
410
+ try {
411
+ const { readAutoDispatchProjects, isEligible, autoDispatchTick } = await import('./auto-dispatch.js');
412
+ const projects = readAutoDispatchProjects();
413
+ if (!projects.some(isEligible))
414
+ return; // opt-in: nothing enabled → skip
415
+ const { createLinearGateway } = await import('./auto-dispatch-linear.js');
416
+ const linear = createLinearGateway();
417
+ if (!linear)
418
+ return; // no LINEAR_API_KEY configured → skip
419
+ const { createProviderDispatcher } = await import('./auto-dispatch-provider.js');
420
+ const dispatcher = createProviderDispatcher();
421
+ const dispatched = await autoDispatchTick({ projects, linear, dispatcher, log: (lvl, m) => log(lvl, m) });
422
+ if (dispatched.length) {
423
+ log('INFO', `auto-dispatch: started ${dispatched.length} delegated ticket(s): ${dispatched.map((d) => d.identifier).join(', ')}`);
424
+ }
425
+ }
426
+ catch (err) {
427
+ log('ERROR', `auto-dispatch failed: ${err.message}`);
428
+ }
429
+ finally {
430
+ autoDispatching = false;
431
+ }
432
+ };
433
+ const autoDispatchInterval = setInterval(() => { void runAutoDispatch(); }, 3 * 60_000);
434
+ const autoDispatchKickoff = setTimeout(() => { void runAutoDispatch(); }, 45_000);
396
435
  // Device probe: refresh registered devices' reachability and detect newly
397
436
  // appeared tailnet nodes, dropping a sentinel per pending device so the
398
437
  // menu-bar helper can surface "NEW DEVICES → Register / Ignore". Refresh mode
@@ -500,6 +539,8 @@ export async function runDaemon() {
500
539
  clearInterval(syncInterval);
501
540
  clearInterval(healInterval);
502
541
  clearTimeout(healKickoff);
542
+ clearInterval(autoDispatchInterval);
543
+ clearTimeout(autoDispatchKickoff);
503
544
  clearInterval(deviceProbeInterval);
504
545
  clearTimeout(deviceProbeKickoff);
505
546
  clearInterval(tmuxReconcileInterval);
package/dist/lib/exec.js CHANGED
@@ -307,12 +307,12 @@ export const AGENT_COMMANDS = {
307
307
  promptFlag: 'positional',
308
308
  resume: { subcommand: 'resume' },
309
309
  modeFlags: {
310
- // NOTE: codex has no read-only mode in --sandbox; 'plan' here means
311
- // "workspace-write but no auto-approval" closer to plan-as-restraint.
312
- // True read-only requires --sandbox read-only which we haven't wired.
313
- plan: ['--sandbox', 'workspace-write'],
314
- edit: ['--sandbox', 'workspace-write', '--dangerously-bypass-approvals-and-sandbox'],
315
- // skip drops the sandbox entirely; --dangerously-bypass-approvals-and-sandbox then approves anything.
310
+ plan: ['--sandbox', 'read-only'],
311
+ // Sandboxed writes inside the workspace. Network stays on (workspace-write
312
+ // disables it by default) so edit-mode runs can still use git/gh/package
313
+ // installs. No approval bypass here — only `skip` drops the guardrails.
314
+ edit: ['--sandbox', 'workspace-write', '-c', 'sandbox_workspace_write.network_access=true'],
315
+ // skip = codex --yolo: drops the sandbox entirely and approves anything.
316
316
  skip: ['--dangerously-bypass-approvals-and-sandbox'],
317
317
  },
318
318
  jsonFlags: ['--json'],
@@ -563,14 +563,25 @@ export function buildExecCommand(options) {
563
563
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
564
564
  }
565
565
  if (resumeSpec && 'subcommand' in resumeSpec) {
566
- // codex `exec resume` / `resume` does NOT accept `--sandbox <mode>` (only the
567
- // bypass flag, verified against `codex exec resume --help`). So the standard
568
- // codex modeFlags can't be reused on resume. A non-plan HEADLESS resume needs
569
- // the bypass or it stalls on approval prompts; plan and interactive resume
570
- // inherit codex's default sandbox and pass no flag.
571
- if (!interactive && resolvedMode !== 'plan') {
566
+ if (resolvedMode === 'skip') {
567
+ // skip = yolo on resume too; both `codex resume` (TUI) and
568
+ // `codex exec resume` accept the bypass flag.
572
569
  cmd.push('--dangerously-bypass-approvals-and-sandbox');
573
570
  }
571
+ else if (interactive) {
572
+ // `codex resume` (TUI) accepts the same -s/--sandbox flags as a fresh run.
573
+ cmd.push(...modeFlags);
574
+ }
575
+ else {
576
+ // `codex exec resume` rejects `--sandbox <mode>` (verified against
577
+ // `codex exec resume --help` on 0.142.5), but takes -c config overrides —
578
+ // map the mode through sandbox_mode so a non-skip resume never gets the
579
+ // approval/sandbox bypass.
580
+ cmd.push('-c', `sandbox_mode=${resolvedMode === 'plan' ? 'read-only' : 'workspace-write'}`);
581
+ if (resolvedMode !== 'plan') {
582
+ cmd.push('-c', 'sandbox_workspace_write.network_access=true');
583
+ }
584
+ }
574
585
  }
575
586
  else if (options.agent === 'kimi' && !interactive) {
576
587
  // kimi's headless prompt mode (`-p`/`--prompt`) is self-contained and REFUSES
@@ -659,8 +670,14 @@ export function buildExecCommand(options) {
659
670
  cmd.push(template.promptFlag, options.prompt);
660
671
  }
661
672
  }
662
- // Claude-specific: add dirs
663
- if (options.agent === 'claude' && options.addDirs) {
673
+ // Extra writable dirs. Claude and Codex both take `--add-dir`; for Codex it
674
+ // widens the workspace-write sandbox (teams relies on this to let teammates
675
+ // write ~/.agents), so it must actually be forwarded — it used to be
676
+ // claude-only, silently dropped for codex and masked by edit mode carrying
677
+ // the approval/sandbox bypass. Codex's resume forms reject --add-dir, so
678
+ // skip it there (claude's flag-based resume accepts it).
679
+ if (options.addDirs &&
680
+ (options.agent === 'claude' || (options.agent === 'codex' && !options.resume))) {
664
681
  for (const dir of options.addDirs) {
665
682
  cmd.push('--add-dir', dir);
666
683
  }
@@ -0,0 +1,38 @@
1
+ /** Print `msg` in red to stderr and exit the process with `code`. */
2
+ export declare function die(msg: string, code?: number): never;
3
+ /**
4
+ * Truncate `s` to at most `max` characters, appending a single-char ellipsis
5
+ * (`…`) when shortened. Character-count based (not ANSI/width aware — use
6
+ * `truncateToWidth` from `session/width.ts` for colored strings).
7
+ */
8
+ export declare function truncate(s: string, max: number): string;
9
+ /**
10
+ * Format an ISO timestamp as a compact relative age: "just now", "5m ago",
11
+ * "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
12
+ * variant that once lived in `cloud.ts` is deliberately dropped. (For the
13
+ * session-list long form with calendar fallback, see
14
+ * `formatRelativeTime` in `session/relative-time.ts`.)
15
+ */
16
+ export declare function relTime(iso: string): string;
17
+ /** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
18
+ export declare function humanDuration(ms: number): string;
19
+ /**
20
+ * Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
21
+ * wrappers). Matches the full CSI sequence including the `\x1b` escape.
22
+ */
23
+ export declare function visibleWidth(s: string): number;
24
+ /** Pad `s` with trailing spaces to a target character width. */
25
+ export declare function padRight(s: string, width: number): string;
26
+ /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
27
+ export declare function padVisible(s: string, width: number): string;
28
+ /** True when `--json` was passed or stdout is not a TTY. */
29
+ export declare function isJsonMode(opts: {
30
+ json?: boolean;
31
+ }): boolean;
32
+ /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
33
+ export declare function readStdinSync(): string;
34
+ /**
35
+ * Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
36
+ * stdout is a TTY; otherwise return `text` unchanged.
37
+ */
38
+ export declare function termLink(text: string, filePath: string): string;
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Shared terminal-formatting helpers.
3
+ *
4
+ * These small utilities were previously copy-pasted across ~20 command and lib
5
+ * files, and had drifted into behavior differences (truncation ellipsis `...`
6
+ * vs `…` vs `.`; `relTime` long "5 minutes ago" vs short "5m ago"; a
7
+ * `visibleWidth` regex missing its `\x1b` escape). This module is the single
8
+ * canonical home — every consumer imports from here.
9
+ */
10
+ import chalk from 'chalk';
11
+ import { readSync } from 'node:fs';
12
+ /** Print `msg` in red to stderr and exit the process with `code`. */
13
+ export function die(msg, code = 1) {
14
+ console.error(chalk.red(msg));
15
+ process.exit(code);
16
+ }
17
+ /**
18
+ * Truncate `s` to at most `max` characters, appending a single-char ellipsis
19
+ * (`…`) when shortened. Character-count based (not ANSI/width aware — use
20
+ * `truncateToWidth` from `session/width.ts` for colored strings).
21
+ */
22
+ export function truncate(s, max) {
23
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
24
+ }
25
+ /**
26
+ * Format an ISO timestamp as a compact relative age: "just now", "5m ago",
27
+ * "3h ago", "2d ago". The canonical short form — the long "5 minutes ago"
28
+ * variant that once lived in `cloud.ts` is deliberately dropped. (For the
29
+ * session-list long form with calendar fallback, see
30
+ * `formatRelativeTime` in `session/relative-time.ts`.)
31
+ */
32
+ export function relTime(iso) {
33
+ const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
34
+ if (secs < 10)
35
+ return 'just now';
36
+ if (secs < 60)
37
+ return `${secs}s ago`;
38
+ if (secs < 3600)
39
+ return `${Math.floor(secs / 60)}m ago`;
40
+ if (secs < 86400)
41
+ return `${Math.floor(secs / 3600)}h ago`;
42
+ return `${Math.floor(secs / 86400)}d ago`;
43
+ }
44
+ /** Format a millisecond duration as "45s", "3m", "2h 5m", "1d 3h". */
45
+ export function humanDuration(ms) {
46
+ const s = Math.floor(ms / 1000);
47
+ if (s < 60)
48
+ return `${s}s`;
49
+ const m = Math.floor(s / 60);
50
+ if (m < 60)
51
+ return `${m}m`;
52
+ const h = Math.floor(m / 60);
53
+ const mm = m % 60;
54
+ if (h < 24)
55
+ return mm ? `${h}h ${mm}m` : `${h}h`;
56
+ const d = Math.floor(h / 24);
57
+ const hh = h % 24;
58
+ return hh ? `${d}d ${hh}h` : `${d}d`;
59
+ }
60
+ /**
61
+ * Visible column width of `s`, ignoring ANSI SGR color codes (e.g. chalk
62
+ * wrappers). Matches the full CSI sequence including the `\x1b` escape.
63
+ */
64
+ export function visibleWidth(s) {
65
+ // eslint-disable-next-line no-control-regex
66
+ return s.replace(/\x1b\[[0-9;]*m/g, '').length;
67
+ }
68
+ /** Pad `s` with trailing spaces to a target character width. */
69
+ export function padRight(s, width) {
70
+ return s.length >= width ? s : s + ' '.repeat(width - s.length);
71
+ }
72
+ /** Pad `s` with trailing spaces to a target *visible* width (ANSI-aware). */
73
+ export function padVisible(s, width) {
74
+ const w = visibleWidth(s);
75
+ return w >= width ? s : s + ' '.repeat(width - w);
76
+ }
77
+ /** True when `--json` was passed or stdout is not a TTY. */
78
+ export function isJsonMode(opts) {
79
+ return Boolean(opts.json) || !process.stdout.isTTY;
80
+ }
81
+ /** Read all of stdin synchronously and return it UTF-8 decoded and trimmed. */
82
+ export function readStdinSync() {
83
+ const chunks = [];
84
+ const buf = Buffer.alloc(65536);
85
+ while (true) {
86
+ let bytesRead;
87
+ try {
88
+ bytesRead = readSync(0, buf, 0, buf.length, null);
89
+ }
90
+ catch {
91
+ break;
92
+ }
93
+ if (bytesRead === 0)
94
+ break;
95
+ chunks.push(Buffer.from(buf.subarray(0, bytesRead)));
96
+ }
97
+ return Buffer.concat(chunks).toString('utf-8').trim();
98
+ }
99
+ /**
100
+ * Wrap `text` in an OSC 8 hyperlink to `filePath` (as a `file://` URL) when
101
+ * stdout is a TTY; otherwise return `text` unchanged.
102
+ */
103
+ export function termLink(text, filePath) {
104
+ if (!filePath || !process.stdout.isTTY)
105
+ return text;
106
+ const url = `file://${filePath}`;
107
+ return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
108
+ }
package/dist/lib/git.d.ts CHANGED
@@ -104,6 +104,27 @@ export declare function cloneIntoExisting(source: string, targetDir: string): Pr
104
104
  commit: string;
105
105
  error?: string;
106
106
  }>;
107
+ /**
108
+ * Git-back an EXISTING, populated directory from a remote — clone it in place
109
+ * without deleting the local files. Turns a plain `~/.agents` folder (which setup
110
+ * creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
111
+ * into a real clone of the user's config remote, so `agents repo pull/push` and
112
+ * `agents sync` work on a fresh or Windows machine that never got the manual clone.
113
+ *
114
+ * Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
115
+ * BACKS UP every tracked file whose local copy differs from the remote — into a
116
+ * sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
117
+ * before overwriting it. So a box with local edits to agents.yaml/hooks/rules
118
+ * doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
119
+ * all gitignored) is never touched because `checkout .` only restores tracked paths.
120
+ */
121
+ export declare function adoptRepo(source: string, targetDir: string): Promise<{
122
+ success: boolean;
123
+ commit: string;
124
+ backupDir?: string;
125
+ backedUp: string[];
126
+ error?: string;
127
+ }>;
107
128
  /**
108
129
  * Check if the repo's origin points to the system repo.
109
130
  */
package/dist/lib/git.js CHANGED
@@ -462,6 +462,98 @@ export async function cloneIntoExisting(source, targetDir) {
462
462
  return { success: false, commit: '', error: err.message };
463
463
  }
464
464
  }
465
+ /**
466
+ * Git-back an EXISTING, populated directory from a remote — clone it in place
467
+ * without deleting the local files. Turns a plain `~/.agents` folder (which setup
468
+ * creates as a bare `mkdirSync` and never git-clones — see state.ts ensureAgentsDir)
469
+ * into a real clone of the user's config remote, so `agents repo pull/push` and
470
+ * `agents sync` work on a fresh or Windows machine that never got the manual clone.
471
+ *
472
+ * Unlike cloneIntoExisting (which blindly `checkout .`s over local files), this
473
+ * BACKS UP every tracked file whose local copy differs from the remote — into a
474
+ * sibling `<dir>.pre-adopt-backup/` OUTSIDE the repo so it can't be re-committed —
475
+ * before overwriting it. So a box with local edits to agents.yaml/hooks/rules
476
+ * doesn't silently lose them. Untracked runtime state (.cache/.history/.system,
477
+ * all gitignored) is never touched because `checkout .` only restores tracked paths.
478
+ */
479
+ export async function adoptRepo(source, targetDir) {
480
+ const trimmed = source.trim();
481
+ if (fs.existsSync(path.join(targetDir, '.git'))) {
482
+ return { success: false, commit: '', backedUp: [], error: 'Already a git repo — nothing to adopt' };
483
+ }
484
+ // Preserve the user's transport. `parseSource` THROWS for `ssh://` and any
485
+ // non-github `git@host:` URL, and rewrites `git@github.com:x` → https (breaking
486
+ // SSH-key-only auth — the common config-repo setup — so a private clone hangs on
487
+ // a credential prompt). So for an SSH URL, clone it AS-IS and never call
488
+ // parseSource; for everything else, normalize + reject local via parseSource —
489
+ // inside the try, so a malformed URL returns a graceful error, not a stack trace.
490
+ const isSsh = trimmed.startsWith('git@') || trimmed.startsWith('ssh://');
491
+ const tempDir = path.join(targetDir, '.git-adopt-temp');
492
+ try {
493
+ let cloneUrl;
494
+ let ref;
495
+ if (isSsh) {
496
+ cloneUrl = trimmed; // SSH stays SSH; clone the remote's default HEAD.
497
+ }
498
+ else {
499
+ const parsed = parseSource(source);
500
+ if (parsed.type === 'local') {
501
+ return { success: false, commit: '', backedUp: [], error: 'Cannot adopt from a local source' };
502
+ }
503
+ cloneUrl = parsed.url;
504
+ ref = parsed.ref;
505
+ }
506
+ assertSafeGitTransport(cloneUrl);
507
+ fs.mkdirSync(targetDir, { recursive: true });
508
+ // Idempotency: clear a stale temp left by an interrupted prior run.
509
+ if (fs.existsSync(tempDir))
510
+ fs.rmSync(tempDir, { recursive: true, force: true });
511
+ // Clone to temp, then move its .git in so the index == remote HEAD.
512
+ // Fail fast on a missing credential instead of hanging on a prompt: set
513
+ // GIT_TERMINAL_PROMPT=0 on the inherited env directly rather than via
514
+ // simple-git's `.env()`, which validates and rejects command-like vars the
515
+ // harness may set (GIT_EDITOR, PAGER, …) — the child inherits process.env,
516
+ // and non-interactive git is what we always want in the CLI anyway.
517
+ process.env.GIT_TERMINAL_PROMPT = '0';
518
+ await simpleGit().clone(cloneUrl, tempDir);
519
+ const repoGit = simpleGit(tempDir);
520
+ if (ref)
521
+ await repoGit.checkout(ref);
522
+ fs.renameSync(path.join(tempDir, '.git'), path.join(targetDir, '.git'));
523
+ fs.rmSync(tempDir, { recursive: true, force: true });
524
+ const targetGit = simpleGit(targetDir);
525
+ // Back up any TRACKED file whose local copy differs from the remote before the
526
+ // checkout clobbers it. `diff --name-only` (worktree vs the moved-in index) is
527
+ // exactly that set; a deleted-locally file has nothing to preserve.
528
+ const diff = await targetGit.diff(['--name-only']);
529
+ const clobbered = diff.split('\n').map((s) => s.trim()).filter(Boolean);
530
+ let backupDir;
531
+ const backedUp = [];
532
+ if (clobbered.length > 0) {
533
+ backupDir = path.join(path.dirname(targetDir), path.basename(targetDir) + '.pre-adopt-backup');
534
+ for (const rel of clobbered) {
535
+ const src = path.join(targetDir, rel);
536
+ if (!fs.existsSync(src))
537
+ continue;
538
+ const dst = path.join(backupDir, rel);
539
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
540
+ fs.copyFileSync(src, dst);
541
+ backedUp.push(rel);
542
+ }
543
+ }
544
+ // Materialize the remote's tracked files (respects .gitignore, so
545
+ // .cache/.history/.system stay put), overwriting the now-backed-up locals.
546
+ await targetGit.checkout('.');
547
+ installGithooksSymlinks(targetDir);
548
+ const log = await targetGit.log({ maxCount: 1 });
549
+ return { success: true, commit: log.latest?.hash.slice(0, 8) || 'unknown', backupDir, backedUp };
550
+ }
551
+ catch (err) {
552
+ if (fs.existsSync(tempDir))
553
+ fs.rmSync(tempDir, { recursive: true, force: true });
554
+ return { success: false, commit: '', backedUp: [], error: err.message };
555
+ }
556
+ }
465
557
  /**
466
558
  * Check if the repo's origin points to the system repo.
467
559
  */
@@ -1,4 +1,4 @@
1
- import type { HookCache, HookCacheConfig } from '../types.js';
1
+ import type { HookCache, HookCacheConfig, HookMatches } from '../types.js';
2
2
  /**
3
3
  * Parse a `cache:` value from hooks.yaml into the canonical config form.
4
4
  * Accepts the shorthand string ("5m", "30s-bg") or the full object form.
@@ -30,11 +30,18 @@ export interface HookShimPaths {
30
30
  /**
31
31
  * Generate (or refresh) the shim script for a hook. Idempotent — only writes
32
32
  * when the content differs from what's on disk. Returns the absolute shim path.
33
+ *
34
+ * A shim is generated when the hook opts into caching (`cache`) and/or declares
35
+ * `matches:` predicates. When `matches` is present the shim gates execution on
36
+ * those predicates before running the underlying script (see `renderShim`);
37
+ * when `cache` is absent the shim is a thin pass-through wrapper that only
38
+ * applies the gate and forwards stdin/stdout unchanged.
33
39
  */
34
40
  export declare function generateHookShim(args: {
35
41
  name: string;
36
42
  scriptPath: string;
37
- cache: HookCacheConfig;
43
+ cache?: HookCacheConfig | null;
44
+ matches?: HookMatches;
38
45
  paths?: HookShimPaths;
39
46
  }): string;
40
47
  /**