@phnx-labs/agents-cli 1.20.33 → 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 (159) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +32 -2
  3. package/dist/commands/computer-actions.d.ts +15 -0
  4. package/dist/commands/computer-actions.js +120 -22
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +17 -12
  8. package/dist/commands/doctor.d.ts +11 -0
  9. package/dist/commands/doctor.js +108 -14
  10. package/dist/commands/events.d.ts +16 -0
  11. package/dist/commands/events.js +173 -0
  12. package/dist/commands/exec.js +6 -1
  13. package/dist/commands/hosts.js +28 -24
  14. package/dist/commands/import.js +1 -1
  15. package/dist/commands/inspect.d.ts +2 -0
  16. package/dist/commands/inspect.js +54 -7
  17. package/dist/commands/logs.d.ts +17 -0
  18. package/dist/commands/logs.js +139 -0
  19. package/dist/commands/models.d.ts +3 -0
  20. package/dist/commands/models.js +27 -8
  21. package/dist/commands/permissions.js +9 -2
  22. package/dist/commands/repo.d.ts +34 -0
  23. package/dist/commands/repo.js +243 -65
  24. package/dist/commands/resource-view.d.ts +20 -0
  25. package/dist/commands/resource-view.js +90 -28
  26. package/dist/commands/rules.js +1 -1
  27. package/dist/commands/secrets-migrate.js +23 -11
  28. package/dist/commands/secrets.d.ts +20 -0
  29. package/dist/commands/secrets.js +93 -17
  30. package/dist/commands/sessions-resume.d.ts +2 -0
  31. package/dist/commands/sessions-resume.js +209 -0
  32. package/dist/commands/sessions-tail.d.ts +10 -0
  33. package/dist/commands/sessions-tail.js +11 -0
  34. package/dist/commands/sessions.d.ts +73 -1
  35. package/dist/commands/sessions.js +273 -53
  36. package/dist/commands/status.d.ts +12 -0
  37. package/dist/commands/status.js +81 -0
  38. package/dist/commands/teams.js +70 -6
  39. package/dist/commands/versions.js +2 -1
  40. package/dist/commands/view.d.ts +42 -0
  41. package/dist/commands/view.js +213 -83
  42. package/dist/commands/wallet.d.ts +6 -0
  43. package/dist/commands/wallet.js +22 -5
  44. package/dist/index.js +61 -32
  45. package/dist/lib/acp/harnesses.d.ts +1 -1
  46. package/dist/lib/acp/harnesses.js +2 -2
  47. package/dist/lib/agents.d.ts +12 -0
  48. package/dist/lib/agents.js +145 -41
  49. package/dist/lib/browser/cdp.js +5 -1
  50. package/dist/lib/browser/chrome.js +20 -0
  51. package/dist/lib/browser/drivers/ssh.d.ts +27 -0
  52. package/dist/lib/browser/drivers/ssh.js +94 -19
  53. package/dist/lib/browser/ipc.d.ts +3 -0
  54. package/dist/lib/browser/ipc.js +13 -9
  55. package/dist/lib/browser/service.js +71 -15
  56. package/dist/lib/daemon.d.ts +35 -0
  57. package/dist/lib/daemon.js +33 -5
  58. package/dist/lib/devices/connect.d.ts +3 -2
  59. package/dist/lib/devices/connect.js +5 -3
  60. package/dist/lib/doctor-diff.js +29 -2
  61. package/dist/lib/drift-sync.d.ts +43 -0
  62. package/dist/lib/drift-sync.js +179 -0
  63. package/dist/lib/events.d.ts +9 -2
  64. package/dist/lib/events.js +70 -11
  65. package/dist/lib/exec.d.ts +15 -0
  66. package/dist/lib/exec.js +43 -11
  67. package/dist/lib/hooks.js +8 -2
  68. package/dist/lib/hosts/dispatch.js +6 -7
  69. package/dist/lib/hosts/logs.d.ts +16 -0
  70. package/dist/lib/hosts/logs.js +45 -0
  71. package/dist/lib/hosts/progress.d.ts +66 -0
  72. package/dist/lib/hosts/progress.js +125 -17
  73. package/dist/lib/hosts/ready.d.ts +23 -2
  74. package/dist/lib/hosts/ready.js +35 -13
  75. package/dist/lib/hosts/reconcile.d.ts +53 -0
  76. package/dist/lib/hosts/reconcile.js +81 -0
  77. package/dist/lib/hosts/tasks.d.ts +8 -0
  78. package/dist/lib/hosts/tasks.js +14 -0
  79. package/dist/lib/permissions.d.ts +4 -0
  80. package/dist/lib/permissions.js +35 -0
  81. package/dist/lib/picker.d.ts +24 -0
  82. package/dist/lib/picker.js +124 -0
  83. package/dist/lib/platform/winpath.d.ts +31 -2
  84. package/dist/lib/platform/winpath.js +133 -24
  85. package/dist/lib/plugin-marketplace.d.ts +30 -0
  86. package/dist/lib/plugin-marketplace.js +215 -2
  87. package/dist/lib/plugins.d.ts +5 -0
  88. package/dist/lib/plugins.js +45 -4
  89. package/dist/lib/pwsh.d.ts +11 -0
  90. package/dist/lib/pwsh.js +13 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/agent.d.ts +42 -1
  94. package/dist/lib/secrets/agent.js +89 -11
  95. package/dist/lib/secrets/bundles.js +40 -9
  96. package/dist/lib/secrets/filestore.js +31 -1
  97. package/dist/lib/secrets/index.d.ts +33 -1
  98. package/dist/lib/secrets/index.js +90 -9
  99. package/dist/lib/secrets/windows.d.ts +74 -0
  100. package/dist/lib/secrets/windows.js +440 -0
  101. package/dist/lib/session/active.d.ts +7 -0
  102. package/dist/lib/session/active.js +11 -3
  103. package/dist/lib/session/discover.js +94 -0
  104. package/dist/lib/session/parse.d.ts +6 -0
  105. package/dist/lib/session/parse.js +283 -2
  106. package/dist/lib/session/pid-registry.d.ts +21 -0
  107. package/dist/lib/session/pid-registry.js +80 -0
  108. package/dist/lib/session/remote-active.d.ts +26 -0
  109. package/dist/lib/session/remote-active.js +141 -0
  110. package/dist/lib/session/remote.js +2 -6
  111. package/dist/lib/session/render.js +1 -1
  112. package/dist/lib/session/sync/config.d.ts +8 -0
  113. package/dist/lib/session/sync/config.js +11 -2
  114. package/dist/lib/session/types.d.ts +1 -1
  115. package/dist/lib/session/types.js +1 -1
  116. package/dist/lib/shims.d.ts +30 -2
  117. package/dist/lib/shims.js +107 -50
  118. package/dist/lib/ssh-exec.d.ts +26 -3
  119. package/dist/lib/ssh-exec.js +45 -3
  120. package/dist/lib/ssh-tunnel.d.ts +24 -5
  121. package/dist/lib/ssh-tunnel.js +60 -62
  122. package/dist/lib/staleness/writers/hooks.js +1 -1
  123. package/dist/lib/startup/command-registry.d.ts +3 -0
  124. package/dist/lib/startup/command-registry.js +6 -0
  125. package/dist/lib/sync-status.d.ts +102 -0
  126. package/dist/lib/sync-status.js +135 -0
  127. package/dist/lib/teams/agents.d.ts +24 -0
  128. package/dist/lib/teams/agents.js +30 -1
  129. package/dist/lib/teams/registry.js +25 -9
  130. package/dist/lib/terminal/backends/ghostty.d.ts +6 -0
  131. package/dist/lib/terminal/backends/ghostty.js +69 -0
  132. package/dist/lib/terminal/backends/index.d.ts +18 -0
  133. package/dist/lib/terminal/backends/index.js +29 -0
  134. package/dist/lib/terminal/backends/iterm.d.ts +6 -0
  135. package/dist/lib/terminal/backends/iterm.js +62 -0
  136. package/dist/lib/terminal/backends/tmux.d.ts +14 -0
  137. package/dist/lib/terminal/backends/tmux.js +23 -0
  138. package/dist/lib/terminal/engine.d.ts +39 -0
  139. package/dist/lib/terminal/engine.js +54 -0
  140. package/dist/lib/terminal/index.d.ts +14 -0
  141. package/dist/lib/terminal/index.js +5 -0
  142. package/dist/lib/terminal/policy.d.ts +11 -0
  143. package/dist/lib/terminal/policy.js +11 -0
  144. package/dist/lib/terminal/quote.d.ts +11 -0
  145. package/dist/lib/terminal/quote.js +13 -0
  146. package/dist/lib/terminal/shell.d.ts +6 -0
  147. package/dist/lib/terminal/shell.js +23 -0
  148. package/dist/lib/terminal/transport.d.ts +15 -0
  149. package/dist/lib/terminal/transport.js +40 -0
  150. package/dist/lib/terminal/types.d.ts +59 -0
  151. package/dist/lib/terminal/types.js +13 -0
  152. package/dist/lib/types.d.ts +27 -1
  153. package/dist/lib/usage.d.ts +30 -0
  154. package/dist/lib/usage.js +159 -2
  155. package/dist/lib/versions.d.ts +7 -3
  156. package/dist/lib/versions.js +10 -5
  157. package/dist/lib/whats-new.d.ts +9 -0
  158. package/dist/lib/whats-new.js +35 -0
  159. package/package.json +1 -1
@@ -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,12 +77,24 @@ 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.
84
84
  */
85
85
  export declare function generateShimScript(agent: AgentId): string;
86
+ /**
87
+ * Which shim files to materialize for a platform. Pure — testable on any host.
88
+ *
89
+ * POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
90
+ * execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
91
+ * form, and the bash file (mode 0o755 is a no-op there) is never executed — so
92
+ * emitting it is dead weight that only ever confuses `where agents`.
93
+ */
94
+ export declare function shimTargetsFor(platform: NodeJS.Platform): {
95
+ bash: boolean;
96
+ cmd: boolean;
97
+ };
86
98
  /**
87
99
  * Create a shim for an agent.
88
100
  */
@@ -114,8 +126,16 @@ export declare function removeShim(agent: AgentId): boolean;
114
126
  * hardcoded node_modules/.bin, which never exists for these three and
115
127
  * made every versioned alias (the path `agents teams` pins to) fail
116
128
  * with "<agent>@<version> not installed". Also emit GROK_HOME.
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.
117
137
  */
118
- export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 8;
138
+ export declare const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
119
139
  /**
120
140
  * Generate a versioned alias script that directly execs a specific version.
121
141
  * e.g., claude@2.0.65 -> directly runs that version's binary
@@ -219,6 +239,14 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
219
239
  /**
220
240
  * Check if shim exists for an agent.
221
241
  */
242
+ /**
243
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
244
+ * write-side source of truth) so the exists/remove/version checks can never
245
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
246
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
247
+ * host.
248
+ */
249
+ export declare function onDiskShimFile(cliCommand: string, platform: NodeJS.Platform): string;
222
250
  export declare function shimExists(agent: AgentId): boolean;
223
251
  /**
224
252
  * Regenerate the shim if it's missing or outdated. Returns a status describing
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
@@ -528,6 +540,19 @@ fi
528
540
  exec "$BINARY"${launchArgs} "$@"
529
541
  `;
530
542
  }
543
+ /**
544
+ * Which shim files to materialize for a platform. Pure — testable on any host.
545
+ *
546
+ * POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
547
+ * execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
548
+ * form, and the bash file (mode 0o755 is a no-op there) is never executed — so
549
+ * emitting it is dead weight that only ever confuses `where agents`.
550
+ */
551
+ export function shimTargetsFor(platform) {
552
+ if (platform === 'win32')
553
+ return { bash: false, cmd: true };
554
+ return { bash: true, cmd: false };
555
+ }
531
556
  /**
532
557
  * Create a shim for an agent.
533
558
  */
@@ -536,12 +561,15 @@ export function createShim(agent) {
536
561
  const shimsDir = getShimsDir();
537
562
  const agentConfig = AGENTS[agent];
538
563
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
539
- const script = generateShimScript(agent);
540
- fs.writeFileSync(shimPath, script, { mode: 0o755 });
541
- // Windows can't execute the bash shim directly. Drop a `.cmd` companion next
542
- // to it that delegates to the node-side transparent resolver (`agents __shim`),
543
- // so the version resolution stays single-sourced instead of reimplemented in batch.
544
- if (IS_WINDOWS) {
564
+ const targets = shimTargetsFor(process.platform);
565
+ if (targets.bash) {
566
+ fs.writeFileSync(shimPath, generateShimScript(agent), { mode: 0o755 });
567
+ }
568
+ // Windows can't execute the bash shim directly. Drop a `.cmd` companion which
569
+ // delegates to the node-side transparent resolver (`agents __shim`) so version
570
+ // resolution stays single-sourced instead of reimplemented in batch — and skip
571
+ // the vestigial bash file entirely.
572
+ if (targets.cmd) {
545
573
  writeWindowsCmdShim(shimPath + '.cmd', agentConfig.cliCommand);
546
574
  }
547
575
  return shimPath;
@@ -557,6 +585,7 @@ function writeWindowsCmdShim(cmdPath, spec) {
557
585
  const indexJs = getAgentsBinForGeneratedShim();
558
586
  const content = `@echo off\r\n` +
559
587
  `rem Auto-generated by agents-cli - do not edit\r\n` +
588
+ `rem ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}\r\n` +
560
589
  `node "${indexJs}" __shim ${spec} %*\r\n`;
561
590
  fs.writeFileSync(cmdPath, content);
562
591
  }
@@ -567,17 +596,18 @@ export function removeShim(agent) {
567
596
  const shimsDir = getShimsDir();
568
597
  const agentConfig = AGENTS[agent];
569
598
  const shimPath = path.join(shimsDir, agentConfig.cliCommand);
570
- if (fs.existsSync(shimPath)) {
571
- fs.unlinkSync(shimPath);
572
- if (IS_WINDOWS) {
573
- try {
574
- fs.unlinkSync(shimPath + '.cmd');
575
- }
576
- catch { }
599
+ // Remove whichever companions exist: the extensionless script (POSIX, or a
600
+ // legacy Windows install that wrote it) AND the `.cmd` (Windows). Keying only
601
+ // off the extensionless path would orphan the `.cmd` on Windows, where
602
+ // createShim now writes only the `.cmd`.
603
+ let removed = false;
604
+ for (const p of [shimPath, shimPath + '.cmd']) {
605
+ if (fs.existsSync(p)) {
606
+ fs.unlinkSync(p);
607
+ removed = true;
577
608
  }
578
- return true;
579
609
  }
580
- return false;
610
+ return removed;
581
611
  }
582
612
  /**
583
613
  * Current versioned-alias schema. Bump whenever `generateVersionedAliasScript`
@@ -602,8 +632,16 @@ export function removeShim(agent) {
602
632
  * hardcoded node_modules/.bin, which never exists for these three and
603
633
  * made every versioned alias (the path `agents teams` pins to) fail
604
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.
605
643
  */
606
- export const VERSIONED_ALIAS_SCHEMA_VERSION = 8;
644
+ export const VERSIONED_ALIAS_SCHEMA_VERSION = 10;
607
645
  /** Internal marker string used to embed the schema version in versioned alias scripts. */
608
646
  const VERSIONED_ALIAS_VERSION_MARKER = 'agents-versioned-alias-version:';
609
647
  // The version string is interpolated into a generated bash script and into
@@ -662,12 +700,16 @@ export KIMI_CODE_HOME="$HOME/.agents/.history/versions/${agent}/${version}/home/
662
700
  : '';
663
701
  const launchArgs = agent === 'codex' ? ' -c check_for_update_on_startup=false' : '';
664
702
  // Resolve the binary the same way the main shim does (see generateShimScript).
665
- // Grok, Kimi, and Droid do NOT ship into node_modules/.bin — Grok downloads a
666
- // native binary to ~/.grok/downloads, Kimi to ~/.kimi-code/bin, and Droid
667
- // (Factory AI) installs a standalone binary to ~/.local/bin. Hardcoding the
668
- // node_modules path made every versioned alias for these three fail with
669
- // "<agent>@<version> not installed", which is exactly the path `agents teams`
670
- // 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.
671
713
  // This template is unix-only — on Windows the .cmd companion delegates to
672
714
  // "agents __shim" which resolves via getBinaryPath() instead.
673
715
  const versionDir = `$HOME/.agents/.history/versions/${agent}/${version}`;
@@ -679,17 +721,17 @@ if [ -d "$GROK_DOWNLOADS" ]; then
679
721
  BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | grep -i "${version}" | head -1)
680
722
  [ -n "$BINARY" ] || BINARY=$(ls "$GROK_DOWNLOADS"/grok-* 2>/dev/null | head -1)
681
723
  fi
682
- [ -n "$BINARY" ] && [ -x "$BINARY" ] || BINARY=$(command -v grok 2>/dev/null || echo "")`
683
- : agent === 'kimi'
684
- ? `# Kimi ships its binary in ~/.kimi-code/bin, not node_modules.
685
- KIMI_BINARY="$HOME/.kimi-code/bin/kimi"
686
- if [ -x "$KIMI_BINARY" ]; then
687
- BINARY="$KIMI_BINARY"
688
- else
689
- 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
690
732
  fi`
691
- : agent === 'droid'
692
- ? `# 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;
693
735
  # there is no npm package and nothing lands in node_modules/.bin. The PATH
694
736
  # fallback refuses anything under our shims dir to avoid an infinite re-exec.
695
737
  DROID_BINARY="$HOME/.local/bin/droid"
@@ -701,7 +743,7 @@ else
701
743
  "$HOME/.agents/.cache/shims/"*) BINARY="" ;;
702
744
  esac
703
745
  fi`
704
- : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
746
+ : `BINARY="${versionDir}/node_modules/.bin/${agentConfig.cliCommand}"`;
705
747
  return `#!/bin/bash
706
748
  # Auto-generated by agents-cli - do not edit
707
749
  # ${VERSIONED_ALIAS_VERSION_MARKER} ${VERSIONED_ALIAS_SCHEMA_VERSION}
@@ -1376,11 +1418,26 @@ async function copyDirContents(src, dest, strategy = 'keep-dest', context) {
1376
1418
  /**
1377
1419
  * Check if shim exists for an agent.
1378
1420
  */
1421
+ /**
1422
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
1423
+ * write-side source of truth) so the exists/remove/version checks can never
1424
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
1425
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
1426
+ * host.
1427
+ */
1428
+ export function onDiskShimFile(cliCommand, platform) {
1429
+ return shimTargetsFor(platform).cmd ? `${cliCommand}.cmd` : cliCommand;
1430
+ }
1431
+ /**
1432
+ * The actual on-disk shim path for the current platform. This is what
1433
+ * exists/version checks must stat — `getShimPath` returns the logical
1434
+ * (extensionless) launch path, which is not always a real file on Windows.
1435
+ */
1436
+ function onDiskShimPath(agent) {
1437
+ return path.join(getShimsDir(), onDiskShimFile(AGENTS[agent].cliCommand, process.platform));
1438
+ }
1379
1439
  export function shimExists(agent) {
1380
- const shimsDir = getShimsDir();
1381
- const agentConfig = AGENTS[agent];
1382
- const shimPath = path.join(shimsDir, agentConfig.cliCommand);
1383
- return fs.existsSync(shimPath);
1440
+ return fs.existsSync(onDiskShimPath(agent));
1384
1441
  }
1385
1442
  /**
1386
1443
  * Read the schema version embedded in an existing on-disk shim. Returns
@@ -1390,7 +1447,7 @@ function readShimSchemaVersion(agent) {
1390
1447
  if (!shimExists(agent))
1391
1448
  return null;
1392
1449
  try {
1393
- const content = fs.readFileSync(getShimPath(agent), 'utf8');
1450
+ const content = fs.readFileSync(onDiskShimPath(agent), 'utf8');
1394
1451
  // Look at the first ~10 lines only — the marker lives in the header.
1395
1452
  const header = content.split('\n', 10).join('\n');
1396
1453
  const match = header.match(new RegExp(SHIM_VERSION_MARKER + '\\s*(\\d+)'));
@@ -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;