@phnx-labs/agents-cli 1.20.34 → 1.20.35

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 (121) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -1
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/cost.js +17 -12
  6. package/dist/commands/doctor.d.ts +1 -0
  7. package/dist/commands/doctor.js +59 -14
  8. package/dist/commands/events.d.ts +16 -0
  9. package/dist/commands/events.js +173 -0
  10. package/dist/commands/exec.js +6 -1
  11. package/dist/commands/hosts.js +28 -24
  12. package/dist/commands/inspect.d.ts +2 -0
  13. package/dist/commands/inspect.js +54 -7
  14. package/dist/commands/logs.d.ts +17 -0
  15. package/dist/commands/logs.js +139 -0
  16. package/dist/commands/models.d.ts +3 -0
  17. package/dist/commands/models.js +27 -8
  18. package/dist/commands/permissions.js +9 -2
  19. package/dist/commands/repo.d.ts +34 -0
  20. package/dist/commands/repo.js +243 -65
  21. package/dist/commands/resource-view.d.ts +20 -0
  22. package/dist/commands/resource-view.js +90 -28
  23. package/dist/commands/secrets.js +40 -16
  24. package/dist/commands/sessions-resume.d.ts +2 -0
  25. package/dist/commands/sessions-resume.js +209 -0
  26. package/dist/commands/sessions-tail.d.ts +10 -0
  27. package/dist/commands/sessions-tail.js +11 -0
  28. package/dist/commands/sessions.d.ts +73 -1
  29. package/dist/commands/sessions.js +273 -53
  30. package/dist/commands/view.d.ts +3 -0
  31. package/dist/commands/view.js +19 -8
  32. package/dist/commands/wallet.d.ts +6 -0
  33. package/dist/commands/wallet.js +22 -5
  34. package/dist/index.js +58 -31
  35. package/dist/lib/agents.js +30 -9
  36. package/dist/lib/browser/cdp.js +5 -1
  37. package/dist/lib/browser/drivers/ssh.d.ts +8 -0
  38. package/dist/lib/browser/drivers/ssh.js +76 -16
  39. package/dist/lib/browser/ipc.d.ts +3 -0
  40. package/dist/lib/browser/ipc.js +13 -9
  41. package/dist/lib/browser/service.js +71 -15
  42. package/dist/lib/daemon.d.ts +35 -0
  43. package/dist/lib/daemon.js +33 -5
  44. package/dist/lib/devices/connect.d.ts +3 -2
  45. package/dist/lib/devices/connect.js +5 -3
  46. package/dist/lib/events.d.ts +9 -2
  47. package/dist/lib/events.js +70 -11
  48. package/dist/lib/exec.js +22 -0
  49. package/dist/lib/hooks.js +8 -2
  50. package/dist/lib/hosts/dispatch.js +6 -7
  51. package/dist/lib/hosts/logs.d.ts +16 -0
  52. package/dist/lib/hosts/logs.js +45 -0
  53. package/dist/lib/hosts/progress.d.ts +66 -0
  54. package/dist/lib/hosts/progress.js +125 -17
  55. package/dist/lib/hosts/ready.d.ts +23 -2
  56. package/dist/lib/hosts/ready.js +35 -13
  57. package/dist/lib/hosts/reconcile.d.ts +53 -0
  58. package/dist/lib/hosts/reconcile.js +81 -0
  59. package/dist/lib/hosts/tasks.d.ts +8 -0
  60. package/dist/lib/hosts/tasks.js +14 -0
  61. package/dist/lib/permissions.d.ts +4 -0
  62. package/dist/lib/permissions.js +35 -0
  63. package/dist/lib/picker.d.ts +24 -0
  64. package/dist/lib/picker.js +124 -0
  65. package/dist/lib/plugin-marketplace.d.ts +30 -0
  66. package/dist/lib/plugin-marketplace.js +215 -2
  67. package/dist/lib/plugins.d.ts +5 -0
  68. package/dist/lib/plugins.js +45 -4
  69. package/dist/lib/session/active.d.ts +7 -0
  70. package/dist/lib/session/active.js +11 -3
  71. package/dist/lib/session/discover.js +94 -0
  72. package/dist/lib/session/parse.d.ts +6 -0
  73. package/dist/lib/session/parse.js +283 -2
  74. package/dist/lib/session/pid-registry.d.ts +21 -0
  75. package/dist/lib/session/pid-registry.js +80 -0
  76. package/dist/lib/session/remote-active.d.ts +26 -0
  77. package/dist/lib/session/remote-active.js +141 -0
  78. package/dist/lib/session/remote.js +2 -6
  79. package/dist/lib/session/render.js +1 -1
  80. package/dist/lib/session/sync/config.d.ts +8 -0
  81. package/dist/lib/session/sync/config.js +11 -2
  82. package/dist/lib/session/types.d.ts +1 -1
  83. package/dist/lib/session/types.js +1 -1
  84. package/dist/lib/shims.d.ts +11 -3
  85. package/dist/lib/shims.js +54 -30
  86. package/dist/lib/ssh-exec.d.ts +26 -3
  87. package/dist/lib/ssh-exec.js +45 -3
  88. package/dist/lib/ssh-tunnel.d.ts +24 -5
  89. package/dist/lib/ssh-tunnel.js +60 -62
  90. package/dist/lib/staleness/writers/hooks.js +1 -1
  91. package/dist/lib/startup/command-registry.d.ts +2 -0
  92. package/dist/lib/startup/command-registry.js +4 -0
  93. package/dist/lib/teams/registry.js +25 -9
  94. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  95. package/dist/lib/terminal/backends/ghostty.js +69 -0
  96. package/dist/lib/terminal/backends/index.d.ts +18 -0
  97. package/dist/lib/terminal/backends/index.js +29 -0
  98. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  99. package/dist/lib/terminal/backends/iterm.js +62 -0
  100. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  101. package/dist/lib/terminal/backends/tmux.js +23 -0
  102. package/dist/lib/terminal/engine.d.ts +39 -0
  103. package/dist/lib/terminal/engine.js +54 -0
  104. package/dist/lib/terminal/index.d.ts +14 -0
  105. package/dist/lib/terminal/index.js +5 -0
  106. package/dist/lib/terminal/policy.d.ts +11 -0
  107. package/dist/lib/terminal/policy.js +11 -0
  108. package/dist/lib/terminal/quote.d.ts +11 -0
  109. package/dist/lib/terminal/quote.js +13 -0
  110. package/dist/lib/terminal/shell.d.ts +6 -0
  111. package/dist/lib/terminal/shell.js +23 -0
  112. package/dist/lib/terminal/transport.d.ts +15 -0
  113. package/dist/lib/terminal/transport.js +40 -0
  114. package/dist/lib/terminal/types.d.ts +59 -0
  115. package/dist/lib/terminal/types.js +13 -0
  116. package/dist/lib/types.d.ts +7 -0
  117. package/dist/lib/versions.d.ts +7 -3
  118. package/dist/lib/versions.js +10 -5
  119. package/dist/lib/whats-new.d.ts +9 -0
  120. package/dist/lib/whats-new.js +35 -0
  121. package/package.json +1 -1
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Cross-machine fan-out for `agents sessions --active`.
3
+ *
4
+ * A single `getActiveSessions()` only sees the local machine. To show the whole
5
+ * fleet in one view, we run `agents sessions --active --json --local` on each
6
+ * peer over SSH and merge the parsed results, tagging every row with the machine
7
+ * it came from so the renderer can bucket by computer.
8
+ *
9
+ * Peers are the registered, online devices from `ag devices` (or an explicit
10
+ * `--host` list). `--local` on the remote invocation is critical: it stops the
11
+ * peer from fanning out to *its* devices, so the sweep never recurses.
12
+ *
13
+ * A dead or slow host is skipped with a stderr note, never fatal — one asleep
14
+ * laptop must not blank the whole view. SSH runs are async + parallel (a fresh
15
+ * `spawn`, not the sync `sshExec`) so N peers cost one round-trip, not N.
16
+ */
17
+ import { spawn } from 'child_process';
18
+ import chalk from 'chalk';
19
+ import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
20
+ import { sshTargetFor } from '../devices/connect.js';
21
+ import { loadDevices } from '../devices/registry.js';
22
+ import { machineId, normalizeHost } from './sync/config.js';
23
+ /** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
24
+ * reachable-but-slow remote still answers before we give up. */
25
+ const REMOTE_TIMEOUT_MS = 12_000;
26
+ /**
27
+ * Recursion guard, passed as an env var (not a CLI flag) so an OLDER remote
28
+ * `agents` that predates this feature ignores it harmlessly instead of erroring
29
+ * on an unknown option. A remote new enough to fan out reads it and stays local.
30
+ */
31
+ export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
32
+ /** The command run on each peer: answer for itself, as JSON, without recursing. */
33
+ function remoteActiveCommand() {
34
+ const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
35
+ return `bash -lc ${shellQuote(inner)}`;
36
+ }
37
+ /**
38
+ * Parse a peer's `--active --json` stdout into active sessions, tagging each
39
+ * with `machine`. Defensive against version skew / partial output: non-JSON or
40
+ * a non-array yields `[]`, and non-object entries are dropped rather than
41
+ * throwing. Exported for unit testing without a live tailnet.
42
+ */
43
+ export function parseRemoteActive(stdout, machine) {
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(stdout);
47
+ }
48
+ catch {
49
+ return [];
50
+ }
51
+ if (!Array.isArray(parsed))
52
+ return [];
53
+ const out = [];
54
+ for (const x of parsed) {
55
+ if (x && typeof x === 'object' && !Array.isArray(x)) {
56
+ out.push({ ...x, machine });
57
+ }
58
+ }
59
+ return out;
60
+ }
61
+ /** Run one remote `agents sessions --active --json --local` and capture stdout.
62
+ * Resolves `{ code: null }` on spawn error or timeout (host treated as dead). */
63
+ function sshCapture(target, remoteCmd, timeoutMs) {
64
+ assertValidSshTarget(target);
65
+ return new Promise((resolve) => {
66
+ const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
67
+ const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
68
+ let stdout = '';
69
+ let settled = false;
70
+ const done = (code) => {
71
+ if (settled)
72
+ return;
73
+ settled = true;
74
+ clearTimeout(timer);
75
+ resolve({ code, stdout });
76
+ };
77
+ const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
78
+ child.stdout.on('data', (d) => { stdout += d.toString(); });
79
+ child.on('error', () => done(null));
80
+ child.on('close', (code) => done(code));
81
+ });
82
+ }
83
+ async function fetchByTarget(target, machine, display) {
84
+ const { code, stdout } = await sshCapture(target, remoteActiveCommand(), REMOTE_TIMEOUT_MS);
85
+ if (code !== 0) {
86
+ process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
87
+ return [];
88
+ }
89
+ return parseRemoteActive(stdout, machine);
90
+ }
91
+ /**
92
+ * Gather active sessions from other machines. With an explicit `hosts` list
93
+ * (from `--host`), fan out to exactly those. Otherwise sweep the registered,
94
+ * online devices from `ag devices`, excluding this machine and any without an
95
+ * address. Results from all peers run in parallel and are flattened.
96
+ */
97
+ export async function gatherRemoteActive(hosts) {
98
+ const self = machineId();
99
+ const targets = [];
100
+ if (hosts && hosts.length > 0) {
101
+ for (const h of hosts) {
102
+ try {
103
+ assertValidSshTarget(h);
104
+ }
105
+ catch {
106
+ process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
107
+ continue;
108
+ }
109
+ const bareHost = h.split('@').pop() || h;
110
+ targets.push({ target: h, machine: normalizeHost(bareHost), name: h });
111
+ }
112
+ }
113
+ else {
114
+ let reg;
115
+ try {
116
+ reg = await loadDevices();
117
+ }
118
+ catch {
119
+ return { sessions: [], deviceCount: 0 };
120
+ }
121
+ for (const d of Object.values(reg)) {
122
+ if (d.tailscale?.online !== true)
123
+ continue;
124
+ if (normalizeHost(d.name) === self)
125
+ continue;
126
+ // Only machines that can actually run the CLI. iOS/tablet nodes register as
127
+ // `unknown` platform and can never answer, so skip them rather than burn a
128
+ // full ConnectTimeout on each.
129
+ if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
130
+ continue;
131
+ try {
132
+ targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name });
133
+ }
134
+ catch {
135
+ // No address on the profile — nothing to dial; skip silently.
136
+ }
137
+ }
138
+ }
139
+ const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name)));
140
+ return { sessions: results.flat(), deviceCount: targets.length };
141
+ }
@@ -27,6 +27,7 @@ import { join } from 'path';
27
27
  import { createHash } from 'crypto';
28
28
  import chalk from 'chalk';
29
29
  import { getCacheDir } from '../state.js';
30
+ import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
30
31
  import { formatRelativeTime } from './relative-time.js';
31
32
  import { terminalWidth } from './width.js';
32
33
  /**
@@ -96,11 +97,6 @@ export function buildRemoteCommand(forwardedArgs, columns) {
96
97
  const withCols = columns && columns > 0 ? `COLUMNS=${columns} ${inner}` : inner;
97
98
  return `bash -lc ${shellQuote(withCols)}`;
98
99
  }
99
- const SSH_OPTS = [
100
- '-o', 'BatchMode=yes',
101
- '-o', 'StrictHostKeyChecking=accept-new',
102
- '-o', 'ConnectTimeout=10',
103
- ];
104
100
  /**
105
101
  * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
106
102
  * connection-layer failures (host down, timeout, refused, auth, changed host
@@ -186,7 +182,7 @@ export function runRemoteSessions(hosts, argv = process.argv) {
186
182
  for (const host of hosts) {
187
183
  if (multi)
188
184
  process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
189
- const res = spawnSync('ssh', [...SSH_OPTS, host, remoteCmd], {
185
+ const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
190
186
  encoding: 'utf8',
191
187
  maxBuffer: 64 * 1024 * 1024,
192
188
  });
@@ -541,7 +541,7 @@ export function renderSummary(events, cwd) {
541
541
  if (p)
542
542
  filesReadAbs.add(p);
543
543
  }
544
- else if (['Write', 'Edit', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool)) {
544
+ else if (['Write', 'Edit', 'Create', 'write_file', 'edit_file', 'create_file', 'replace', 'patch'].includes(tool)) {
545
545
  if (p) {
546
546
  if (p.includes('.claude/plans/') && p.endsWith('.md')) {
547
547
  planFilePath = p;
@@ -33,6 +33,14 @@ export declare function loadR2Config(): R2Config;
33
33
  * does not re-storm every cycle. `now` is injectable for tests.
34
34
  */
35
35
  export declare function isSyncConfigured(now?: number): boolean;
36
+ /**
37
+ * Normalize a raw hostname into a stable device id: first label only,
38
+ * lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
39
+ * `ZION` both become `zion`. The single source for this transform — machineId()
40
+ * and cross-machine session grouping must agree or the local machine won't
41
+ * match its own registry key.
42
+ */
43
+ export declare function normalizeHost(raw: string): string;
36
44
  /**
37
45
  * This machine's stable, human-readable id, used as its R2 prefix and mirror
38
46
  * directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already
@@ -93,6 +93,16 @@ export function isSyncConfigured(now = Date.now()) {
93
93
  return false;
94
94
  }
95
95
  }
96
+ /**
97
+ * Normalize a raw hostname into a stable device id: first label only,
98
+ * lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
99
+ * `ZION` both become `zion`. The single source for this transform — machineId()
100
+ * and cross-machine session grouping must agree or the local machine won't
101
+ * match its own registry key.
102
+ */
103
+ export function normalizeHost(raw) {
104
+ return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
105
+ }
96
106
  /**
97
107
  * This machine's stable, human-readable id, used as its R2 prefix and mirror
98
108
  * directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already
@@ -100,6 +110,5 @@ export function isSyncConfigured(now = Date.now()) {
100
110
  * via AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
101
111
  */
102
112
  export function machineId() {
103
- const raw = process.env.AGENTS_SYNC_MACHINE_ID || os.hostname();
104
- return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
113
+ return normalizeHost(process.env.AGENTS_SYNC_MACHINE_ID || os.hostname());
105
114
  }
@@ -7,7 +7,7 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** Agents that store session data on disk and can be discovered by `agents sessions`. */
10
- export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
10
+ export type SessionAgentId = 'claude' | 'codex' | 'gemini' | 'antigravity' | 'opencode' | 'openclaw' | 'rush' | 'hermes' | 'grok' | 'kimi' | 'droid';
11
11
  /** All agents with session discovery support, in display order. */
12
12
  export declare const SESSION_AGENTS: SessionAgentId[];
13
13
  /** A single normalized event within a session (message, tool call, thinking, etc.). */
@@ -7,4 +7,4 @@
7
7
  * speaks these types.
8
8
  */
9
9
  /** All agents with session discovery support, in display order. */
10
- export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
10
+ export const SESSION_AGENTS = ['claude', 'codex', 'gemini', 'antigravity', 'opencode', 'openclaw', 'rush', 'hermes', 'grok', 'kimi', 'droid'];
@@ -77,7 +77,7 @@ export interface ConflictInfo {
77
77
  * top-level entry add/remove — deep edits to plugin contents won't
78
78
  * trigger auto-resync, run `agents sync` for that.
79
79
  */
80
- export declare const SHIM_SCHEMA_VERSION = 19;
80
+ export declare const SHIM_SCHEMA_VERSION = 21;
81
81
  /**
82
82
  * Generate the full bash shim script for the given agent. The returned string
83
83
  * is written to ~/.agents/shims/{cliCommand} and made executable.
@@ -126,8 +126,16 @@ export declare function removeShim(agent: AgentId): boolean;
126
126
  * hardcoded node_modules/.bin, which never exists for these three and
127
127
  * made every versioned alias (the path `agents teams` pins to) fail
128
128
  * with "<agent>@<version> not installed". Also emit GROK_HOME.
129
- */
130
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 8;
129
+ * v9 — kimi was wrong in v8: it npm-installs @moonshot-ai/kimi-code into
130
+ * node_modules/.bin/kimi (grok/droid ship native binaries elsewhere,
131
+ * kimi does not). The ~/.kimi-code/bin path never existed for an npm
132
+ * install and the `command -v kimi` fallback resolved to this alias's
133
+ * sibling dispatcher shim, re-exec-looping forever. Resolve kimi via the
134
+ * generic node_modules/.bin branch.
135
+ * v10 — guard grok's `command -v grok` fallback against resolving to our own
136
+ * shims dir (same infinite re-exec loop), mirroring droid.
137
+ */
138
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
131
139
  /**
132
140
  * Generate a versioned alias script that directly execs a specific version.
133
141
  * e.g., claude@2.0.65 -> directly runs that version's binary
package/dist/lib/shims.js CHANGED
@@ -202,7 +202,13 @@ async function promptConflictStrategy(conflictInfos) {
202
202
  * top-level entry add/remove — deep edits to plugin contents won't
203
203
  * trigger auto-resync, run `agents sync` for that.
204
204
  */
205
- export const SHIM_SCHEMA_VERSION = 19;
205
+ // v20 stop treating kimi like grok/droid: it npm-installs into
206
+ // node_modules/.bin/kimi, so resolve it via the generic branch. The old
207
+ // ~/.kimi-code/bin special-case never existed for npm installs and
208
+ // re-exec-looped through `command -v kimi` (the dispatcher itself).
209
+ // v21 — guard grok's `command -v grok` fallback against resolving to our own
210
+ // shims dir (same infinite re-exec loop), mirroring droid.
211
+ export const SHIM_SCHEMA_VERSION = 21;
206
212
  /** Internal marker string used to embed the schema version in shim scripts. */
207
213
  const SHIM_VERSION_MARKER = 'agents-shim-version:';
208
214
  function shellQuote(value) {
@@ -401,19 +407,25 @@ if [ "$AGENT" = "grok" ]; then
401
407
  fi
402
408
  fi
403
409
  if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
404
- # Last resort: whatever is on PATH (user may have installed grok globally)
410
+ # Last resort: whatever is on PATH (user may have installed grok globally).
411
+ # Refuse anything under our own shims dir: the shims dir sits ahead of
412
+ # ~/.local/bin on PATH, so "command -v grok" resolves to THIS dispatcher.
413
+ # exec-ing it would re-enter and spin in an infinite re-exec loop (the same
414
+ # bug the droid branch below guards against). Fall through to the clean
415
+ # "not installed" error instead.
405
416
  BINARY=$(command -v grok 2>/dev/null || echo "")
417
+ case "$BINARY" in
418
+ "$AGENTS_USER_DIR/.cache/shims/"*) BINARY="" ;;
419
+ esac
406
420
  fi
407
- # Kimi special case: binary lives in ~/.kimi-code/bin/, not node_modules.
408
- # We still use the agents-cli version dir purely for KIMI_CODE_HOME isolation.
409
- elif [ "$AGENT" = "kimi" ]; then
410
- KIMI_BINARY="$HOME/.kimi-code/bin/kimi"
411
- if [ -x "$KIMI_BINARY" ]; then
412
- BINARY="$KIMI_BINARY"
413
- else
414
- # Last resort: whatever is on PATH
415
- BINARY=$(command -v kimi 2>/dev/null || echo "")
416
- fi
421
+ # Kimi is a normal npm agent: "agents add kimi" npm-installs
422
+ # @moonshot-ai/kimi-code into the version dir and the binary lands at
423
+ # node_modules/.bin/kimi (a curl-installed kimi is symlinked to the same spot
424
+ # by installVersion). So kimi resolves via the generic node_modules branch
425
+ # below -- never a bespoke ~/.kimi-code/bin path that does not exist for npm
426
+ # installs and fell back to "command -v kimi", which resolves to THIS
427
+ # dispatcher (shims dir is ahead on PATH) and re-execs forever. Only
428
+ # KIMI_CODE_HOME (config isolation) stays special-cased, separately below.
417
429
  # Droid (Factory AI) special case: the official installer drops a standalone
418
430
  # native binary at ~/.local/bin/droid — there is no npm package and nothing
419
431
  # lands in node_modules/.bin. Resolve the fixed install path directly. The
@@ -620,8 +632,16 @@ export function removeShim(agent) {
620
632
  * hardcoded node_modules/.bin, which never exists for these three and
621
633
  * made every versioned alias (the path `agents teams` pins to) fail
622
634
  * with "<agent>@<version> not installed". Also emit GROK_HOME.
635
+ * v9 — kimi was wrong in v8: it npm-installs @moonshot-ai/kimi-code into
636
+ * node_modules/.bin/kimi (grok/droid ship native binaries elsewhere,
637
+ * kimi does not). The ~/.kimi-code/bin path never existed for an npm
638
+ * install and the `command -v kimi` fallback resolved to this alias's
639
+ * sibling dispatcher shim, re-exec-looping forever. Resolve kimi via the
640
+ * generic node_modules/.bin branch.
641
+ * v10 — guard grok's `command -v grok` fallback against resolving to our own
642
+ * shims dir (same infinite re-exec loop), mirroring droid.
623
643
  */
624
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 8;
644
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
625
645
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
626
646
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
627
647
  // The version string is interpolated into a generated bash script and into
@@ -680,12 +700,16 @@ export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/
680
700
  : '';
681
701
  const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
682
702
  // Resolve the binary the same way the main shim does (see generateShimScript).
683
- // Grok, Kimi, and Droid do NOT ship into node_modules/.bin — Grok downloads a
684
- // native binary to ~/.grok/downloads, Kimi to ~/.kimi-code/bin, and Droid
685
- // (Factory AI) installs a standalone binary to ~/.local/bin. Hardcoding the
686
- // node_modules path made every versioned alias for these three fail with
687
- // "<agent>@<version> not installed", which is exactly the path `agents teams`
688
- // takes once it pins a teammate's version.
703
+ // Grok and Droid do NOT ship into node_modules/.bin — Grok downloads a native
704
+ // binary to ~/.grok/downloads and Droid (Factory AI) installs a standalone
705
+ // binary to ~/.local/bin. Hardcoding the node_modules path made every
706
+ // versioned alias for those two fail with "<agent>@<version> not installed",
707
+ // which is exactly the path `agents teams` takes once it pins a teammate's
708
+ // version. Kimi is NOT one of them: `agents add kimi` npm-installs
709
+ // @moonshot-ai/kimi-code so its binary is at node_modules/.bin/kimi (the
710
+ // generic branch below). The old ~/.kimi-code/bin path never exists for an
711
+ // npm install and fell back to `command -v kimi`, which resolves to this
712
+ // alias's sibling dispatcher shim and re-execs forever.
689
713
  // This template is unix-only — on Windows the .cmd companion delegates to
690
714
  // "agents __shim" which resolves via getBinaryPath() instead.
691
715
  const versionDir = `$HOME/.agents/.history/versions/${agent}/${version}`;
@@ -697,17 +721,17 @@ if [ -d "$GROK_DOWNLOADS" ]; then
697
721
  BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | grep -i "${version}" | head -1)
698
722
  [ -n "$BINARY" ] || BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | head -1)
699
723
  fi
700
- [ -n "$BINARY" ] && [ -x "$BINARY" ] || BINARY=$(command -v grok 2>/dev/null || echo "")`
701
- : agent === 'kimi'
702
- ? `# Kimi ships its binary in ~/.kimi-code/bin, not node_modules.
703
- KIMI_BINARY="$HOME/.kimi-code/bin/kimi"
704
- if [ -x "$KIMI_BINARY" ]; then
705
- BINARY="$KIMI_BINARY"
706
- else
707
- BINARY=$(command -v kimi 2>/dev/null || echo "")
724
+ # Refuse a PATH match under our own shims dir it resolves to this alias's
725
+ # sibling dispatcher shim (shims dir is ahead of ~/.local/bin on PATH) and
726
+ # re-execs forever. Fall through to the clean "not installed" error instead.
727
+ if [ -z "$BINARY" ] || [ ! -x "$BINARY" ]; then
728
+ BINARY=$(command -v grok 2>/dev/null || echo "")
729
+ case "$BINARY" in
730
+ "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
731
+ esac
708
732
  fi`
709
- : agent === 'droid'
710
- ? `# Droid (Factory AI) installs a standalone native binary at ~/.local/bin/droid;
733
+ : agent === 'droid'
734
+ ? `# Droid (Factory AI) installs a standalone native binary at ~/.local/bin/droid;
711
735
  # there is no npm package and nothing lands in node_modules/.bin. The PATH
712
736
  # fallback refuses anything under our shims dir to avoid an infinite re-exec.
713
737
  DROID_BINARY="$HOME/.local/bin/droid"
@@ -719,7 +743,7 @@ else
719
743
  "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
720
744
  esac
721
745
  fi`
722
- : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
746
+ : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
723
747
  return `#!/bin/bash
724
748
  # Auto-generated by agents-cli - do not edit
725
749
  # ${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}
@@ -17,7 +17,16 @@ export declare const SSH_TARGET_RE: RegExp;
17
17
  export declare function assertValidSshTarget(host: string): void;
18
18
  /** POSIX single-quote a string for safe interpolation into a remote shell command. */
19
19
  export declare function shellQuote(s: string): string;
20
- /** Hardened ssh options applied to every connection. */
20
+ /**
21
+ * Hardened ssh options applied to every connection — the single baseline every
22
+ * `ssh` in the codebase composes from (directly here, or as `[...SSH_OPTS, …]`
23
+ * in the few callers that need extra flags like `-L`/`-N`/`ProxyCommand`).
24
+ *
25
+ * `ServerAliveInterval`/`ServerAliveCountMax` add in-connection keepalive: a
26
+ * silently-dropped link (laptop sleeps, Wi-Fi flips) is detected and the ssh
27
+ * process exits within ~45s instead of hanging forever — so a followed run or a
28
+ * long-lived `-N` tunnel can't leave a zombie ssh + socket pinned on the laptop.
29
+ */
21
30
  export declare const SSH_OPTS: readonly string[];
22
31
  export declare function controlOpts(): string[];
23
32
  export interface SshExecOptions {
@@ -27,7 +36,7 @@ export interface SshExecOptions {
27
36
  timeoutMs?: number;
28
37
  /** Extra ssh flags inserted before the target (e.g. `-tt`). */
29
38
  extraSshArgs?: string[];
30
- /** Reuse a persistent control socket across calls (see `controlOpts`). */
39
+ /** Reuse a persistent control socket across calls (default true; see `controlOpts`). */
31
40
  multiplex?: boolean;
32
41
  }
33
42
  export interface SshExecResult {
@@ -44,6 +53,20 @@ export interface SshExecResult {
44
53
  * it); callers that build it from user input must `shellQuote` the pieces.
45
54
  */
46
55
  export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
56
+ export interface SshExecRawResult {
57
+ code: number | null;
58
+ stdout: Buffer;
59
+ stderr: Buffer;
60
+ timedOut: boolean;
61
+ }
62
+ /**
63
+ * Like {@link sshExec} but returns raw stdout/stderr Buffers — no UTF-8 decode.
64
+ *
65
+ * Use when byte-exactness matters, e.g. offset-tracked log tailing: a multibyte
66
+ * character split across a read boundary must stay raw bytes, not collapse to a
67
+ * U+FFFD replacement char (which would desync a byte offset from the wire).
68
+ */
69
+ export declare function sshExecRaw(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecRawResult;
47
70
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
48
71
  export declare function sshReachable(target: string, timeoutMs?: number): boolean;
49
72
  export interface SshStreamOptions {
@@ -54,7 +77,7 @@ export interface SshStreamOptions {
54
77
  * leave it off and forward a non-interactive invocation instead.
55
78
  */
56
79
  tty?: boolean;
57
- /** Reuse a persistent control socket across calls (see `controlOpts`). */
80
+ /** Reuse a persistent control socket across calls (default true; see `controlOpts`). */
58
81
  multiplex?: boolean;
59
82
  }
60
83
  /**
@@ -29,11 +29,22 @@ export function shellQuote(s) {
29
29
  return s;
30
30
  return "'" + s.replace(/'/g, "'\\''") + "'";
31
31
  }
32
- /** Hardened ssh options applied to every connection. */
32
+ /**
33
+ * Hardened ssh options applied to every connection — the single baseline every
34
+ * `ssh` in the codebase composes from (directly here, or as `[...SSH_OPTS, …]`
35
+ * in the few callers that need extra flags like `-L`/`-N`/`ProxyCommand`).
36
+ *
37
+ * `ServerAliveInterval`/`ServerAliveCountMax` add in-connection keepalive: a
38
+ * silently-dropped link (laptop sleeps, Wi-Fi flips) is detected and the ssh
39
+ * process exits within ~45s instead of hanging forever — so a followed run or a
40
+ * long-lived `-N` tunnel can't leave a zombie ssh + socket pinned on the laptop.
41
+ */
33
42
  export const SSH_OPTS = [
34
43
  '-o', 'StrictHostKeyChecking=accept-new',
35
44
  '-o', 'BatchMode=yes',
36
45
  '-o', 'ConnectTimeout=10',
46
+ '-o', 'ServerAliveInterval=15',
47
+ '-o', 'ServerAliveCountMax=3',
37
48
  ];
38
49
  /**
39
50
  * OpenSSH connection-multiplexing options. The first connection to a host opens
@@ -44,6 +55,12 @@ export const SSH_OPTS = [
44
55
  * client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
45
56
  * keeps the socket path well under macOS's 104-char `sun_path` limit.
46
57
  *
58
+ * This is **on by default** for every `sshExec`/`sshStream` call: the poll loops
59
+ * (`followHostTask`), readiness probes, and per-host fan-outs are exactly the
60
+ * high-frequency callers that benefit most from socket reuse, and they should
61
+ * never have to remember to opt in. A caller passes `multiplex: false` only for
62
+ * a genuine one-shot where a lingering 60s master is pure overhead.
63
+ *
47
64
  * The socket directory is created lazily; if ssh can't open the control socket
48
65
  * it falls back to a normal connection (multiplexing is an optimisation, never a
49
66
  * requirement), so this can never make a reachable host unreachable.
@@ -79,7 +96,7 @@ export function controlOpts() {
79
96
  */
80
97
  export function sshExec(target, remoteCmd, opts = {}) {
81
98
  assertValidSshTarget(target);
82
- const mux = opts.multiplex ? controlOpts() : [];
99
+ const mux = opts.multiplex === false ? [] : controlOpts();
83
100
  const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
84
101
  const res = spawnSync('ssh', args, {
85
102
  input: opts.input,
@@ -95,6 +112,31 @@ export function sshExec(target, remoteCmd, opts = {}) {
95
112
  timedOut,
96
113
  };
97
114
  }
115
+ /**
116
+ * Like {@link sshExec} but returns raw stdout/stderr Buffers — no UTF-8 decode.
117
+ *
118
+ * Use when byte-exactness matters, e.g. offset-tracked log tailing: a multibyte
119
+ * character split across a read boundary must stay raw bytes, not collapse to a
120
+ * U+FFFD replacement char (which would desync a byte offset from the wire).
121
+ */
122
+ export function sshExecRaw(target, remoteCmd, opts = {}) {
123
+ assertValidSshTarget(target);
124
+ const mux = opts.multiplex === false ? [] : controlOpts();
125
+ const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
126
+ const res = spawnSync('ssh', args, {
127
+ input: opts.input,
128
+ // No `encoding` → spawnSync returns Buffers.
129
+ timeout: opts.timeoutMs,
130
+ stdio: ['pipe', 'pipe', 'pipe'],
131
+ });
132
+ const timedOut = !!(res.error && res.error.code === 'ETIMEDOUT');
133
+ return {
134
+ code: typeof res.status === 'number' ? res.status : null,
135
+ stdout: res.stdout ?? Buffer.alloc(0),
136
+ stderr: res.stderr ?? Buffer.alloc(0),
137
+ timedOut,
138
+ };
139
+ }
98
140
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
99
141
  export function sshReachable(target, timeoutMs = 10000) {
100
142
  return sshExec(target, 'true', { timeoutMs, multiplex: true }).code === 0;
@@ -108,7 +150,7 @@ export function sshReachable(target, timeoutMs = 10000) {
108
150
  */
109
151
  export function sshStream(target, remoteCmd, opts = {}) {
110
152
  assertValidSshTarget(target);
111
- const mux = opts.multiplex ? controlOpts() : [];
153
+ const mux = opts.multiplex === false ? [] : controlOpts();
112
154
  const tty = opts.tty ? ['-tt'] : [];
113
155
  const args = [...SSH_OPTS, ...mux, ...tty, target, remoteCmd];
114
156
  const res = spawnSync('ssh', args, { stdio: 'inherit' });
@@ -28,7 +28,11 @@ export interface StartTunnelOptions {
28
28
  */
29
29
  detached?: boolean;
30
30
  }
31
- /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure. */
31
+ /** Build the ssh argv (after the `ssh` program name) for an `-L` tunnel. Pure.
32
+ *
33
+ * Composes the shared hardened baseline (`SSH_OPTS`) rather than re-listing it,
34
+ * so the tunnel inherits the same options — crucially the keepalive, which lets
35
+ * a dropped `-N` tunnel exit instead of lingering as a zombie on the laptop. */
32
36
  export declare function buildTunnelArgs(user: string, host: string, localPort: number, remotePort: number): string[];
33
37
  /**
34
38
  * Spawn `ssh -L localPort:127.0.0.1:remotePort -N user@host`.
@@ -74,12 +78,13 @@ export declare function resolveRemoteDevice(name: string): Promise<{
74
78
  host: string;
75
79
  }>;
76
80
  /**
77
- * PowerShell that streams base64 from stdin, decodes it incrementally to
78
- * %LOCALAPPDATA%\agents\computer-helper-win.exe, and stops any running instance
79
- * first so the file isn't locked. The CryptoStream/FromBase64Transform decode
80
- * is streaming — the ~156MB exe never lands in memory whole on the remote.
81
+ * PowerShell that resolves the destination under %LOCALAPPDATA%\agents and
82
+ * stops any running instance first so the file is not locked. The caller copies
83
+ * the exe with scp and then verifies the byte count separately.
81
84
  */
82
85
  export declare function buildPushScript(): string;
86
+ /** PowerShell that verifies scp wrote the expected number of bytes. */
87
+ export declare function buildVerifyPushScript(remotePath: string, expectedBytes: number): string;
83
88
  /**
84
89
  * PowerShell that registers the daemon as a LOGON scheduled task. Interactive
85
90
  * logon type + Highest run level so the daemon runs in the real desktop session
@@ -90,6 +95,20 @@ export declare function buildPushScript(): string;
90
95
  export declare function buildRegisterTaskScript(port: number, taskName: string): string;
91
96
  /** PowerShell that unregisters the task and stops any running daemon process. */
92
97
  export declare function buildUnregisterTaskScript(taskName: string): string;
98
+ /** Convert a Windows path returned by PowerShell into the scp/SFTP path form. */
99
+ export declare function scpRemotePath(remotePath: string): string;
100
+ /**
101
+ * Build the scp argv used for the helper exe transfer. Exported so tests can
102
+ * assert the real binary copy path keeps BatchMode and does not route bytes
103
+ * through a PowerShell decoder.
104
+ */
105
+ export declare function buildScpArgs(target: string, remotePath: string, filePath: string): string[];
106
+ /**
107
+ * `setup --host`: push the exe, then register + start the LOGON task. Remote
108
+ * PowerShell hops go through `sshExec` (BatchMode key auth — the same hardening
109
+ * the browser driver and `agents ssh` use), and the large exe rides a binary
110
+ * scp transfer. Throws with the remote stderr on any failure.
111
+ */
93
112
  export declare function setupRemoteHelper(name: string): Promise<{
94
113
  target: string;
95
114
  taskName: string;