@phnx-labs/agents-cli 1.20.32 → 1.20.34

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 (110) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/README.md +28 -2
  3. package/dist/commands/commands.js +3 -3
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/computer.d.ts +23 -0
  6. package/dist/commands/computer.js +45 -3
  7. package/dist/commands/cost.js +2 -2
  8. package/dist/commands/doctor.d.ts +10 -0
  9. package/dist/commands/doctor.js +51 -2
  10. package/dist/commands/exec.js +56 -1
  11. package/dist/commands/hooks.js +3 -3
  12. package/dist/commands/import.js +1 -1
  13. package/dist/commands/inspect.js +13 -17
  14. package/dist/commands/mcp.js +3 -3
  15. package/dist/commands/permissions.js +3 -3
  16. package/dist/commands/rules.js +3 -3
  17. package/dist/commands/secrets-migrate.js +23 -11
  18. package/dist/commands/secrets.d.ts +20 -0
  19. package/dist/commands/secrets.js +53 -1
  20. package/dist/commands/sessions.js +18 -1
  21. package/dist/commands/skills.js +3 -3
  22. package/dist/commands/status.d.ts +12 -0
  23. package/dist/commands/status.js +81 -0
  24. package/dist/commands/sync.js +2 -2
  25. package/dist/commands/teams.js +77 -18
  26. package/dist/commands/usage.js +2 -2
  27. package/dist/commands/utils.d.ts +8 -0
  28. package/dist/commands/utils.js +20 -0
  29. package/dist/commands/versions.js +4 -3
  30. package/dist/commands/view.d.ts +39 -0
  31. package/dist/commands/view.js +217 -84
  32. package/dist/commands/workflows.js +3 -3
  33. package/dist/index.js +16 -2
  34. package/dist/lib/acp/harnesses.d.ts +1 -1
  35. package/dist/lib/acp/harnesses.js +2 -2
  36. package/dist/lib/agent-spec/index.d.ts +18 -0
  37. package/dist/lib/agent-spec/index.js +35 -0
  38. package/dist/lib/agent-spec/primitives.d.ts +28 -0
  39. package/dist/lib/agent-spec/primitives.js +57 -0
  40. package/dist/lib/agent-spec/provider.d.ts +2 -0
  41. package/dist/lib/agent-spec/provider.js +9 -0
  42. package/dist/lib/agent-spec/resolve.d.ts +33 -0
  43. package/dist/lib/agent-spec/resolve.js +174 -0
  44. package/dist/lib/agent-spec/types.d.ts +57 -0
  45. package/dist/lib/agent-spec/types.js +18 -0
  46. package/dist/lib/agents.d.ts +12 -0
  47. package/dist/lib/agents.js +115 -32
  48. package/dist/lib/browser/chrome.js +20 -0
  49. package/dist/lib/browser/drivers/ssh.d.ts +19 -0
  50. package/dist/lib/browser/drivers/ssh.js +18 -3
  51. package/dist/lib/crabbox/cli.d.ts +98 -0
  52. package/dist/lib/crabbox/cli.js +218 -0
  53. package/dist/lib/crabbox/lease.d.ts +41 -0
  54. package/dist/lib/crabbox/lease.js +73 -0
  55. package/dist/lib/crabbox/runtimes.d.ts +57 -0
  56. package/dist/lib/crabbox/runtimes.js +109 -0
  57. package/dist/lib/doctor-diff.js +29 -2
  58. package/dist/lib/drift-sync.d.ts +43 -0
  59. package/dist/lib/drift-sync.js +179 -0
  60. package/dist/lib/exec.d.ts +15 -0
  61. package/dist/lib/exec.js +21 -11
  62. package/dist/lib/hosts/dispatch.d.ts +27 -10
  63. package/dist/lib/hosts/dispatch.js +55 -19
  64. package/dist/lib/hosts/option.d.ts +14 -0
  65. package/dist/lib/hosts/option.js +19 -0
  66. package/dist/lib/hosts/passthrough.d.ts +30 -0
  67. package/dist/lib/hosts/passthrough.js +141 -0
  68. package/dist/lib/hosts/remote-cmd.d.ts +36 -0
  69. package/dist/lib/hosts/remote-cmd.js +56 -0
  70. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  71. package/dist/lib/platform/winpath.d.ts +31 -2
  72. package/dist/lib/platform/winpath.js +133 -24
  73. package/dist/lib/pwsh.d.ts +11 -0
  74. package/dist/lib/pwsh.js +13 -0
  75. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  76. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  77. package/dist/lib/secrets/agent.d.ts +42 -1
  78. package/dist/lib/secrets/agent.js +89 -11
  79. package/dist/lib/secrets/bundles.js +61 -21
  80. package/dist/lib/secrets/filestore.js +31 -1
  81. package/dist/lib/secrets/index.d.ts +44 -1
  82. package/dist/lib/secrets/index.js +108 -10
  83. package/dist/lib/secrets/linux.d.ts +14 -0
  84. package/dist/lib/secrets/linux.js +21 -0
  85. package/dist/lib/secrets/windows.d.ts +74 -0
  86. package/dist/lib/secrets/windows.js +440 -0
  87. package/dist/lib/session/active.d.ts +8 -0
  88. package/dist/lib/session/active.js +18 -1
  89. package/dist/lib/session/provenance.d.ts +56 -0
  90. package/dist/lib/session/provenance.js +157 -0
  91. package/dist/lib/shims.d.ts +20 -0
  92. package/dist/lib/shims.js +53 -20
  93. package/dist/lib/ssh-exec.d.ts +22 -0
  94. package/dist/lib/ssh-exec.js +59 -2
  95. package/dist/lib/ssh-tunnel.d.ts +0 -5
  96. package/dist/lib/ssh-tunnel.js +65 -8
  97. package/dist/lib/startup/command-registry.d.ts +1 -0
  98. package/dist/lib/startup/command-registry.js +2 -0
  99. package/dist/lib/sync-status.d.ts +102 -0
  100. package/dist/lib/sync-status.js +135 -0
  101. package/dist/lib/teams/agents.d.ts +24 -0
  102. package/dist/lib/teams/agents.js +30 -1
  103. package/dist/lib/types.d.ts +20 -1
  104. package/dist/lib/usage.d.ts +30 -0
  105. package/dist/lib/usage.js +159 -2
  106. package/dist/lib/versions.d.ts +2 -4
  107. package/dist/lib/versions.js +7 -20
  108. package/package.json +2 -1
  109. package/dist/lib/agent-spec.d.ts +0 -36
  110. package/dist/lib/agent-spec.js +0 -157
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Session provenance — where an active agent process actually lives.
3
+ *
4
+ * `detectHost()` in active.ts walks the ppid chain to name the *terminal app*
5
+ * (iterm / code / tmux). That answers "what UI is above it" but not the three
6
+ * things the Agent Feed needs to group and route:
7
+ *
8
+ * 1. Which machine — os.hostname(), for the HOSTS sidebar.
9
+ * 2. Local vs SSH — is SSH_CONNECTION in the process env?
10
+ * 3. Exact tmux pane — TMUX_PANE ('%3'), the send-keys target.
11
+ *
12
+ * All three are inherited env vars, so we read them straight off the running
13
+ * process (no cooperation from the agent needed): `/proc/<pid>/environ` on
14
+ * Linux, `ps eww` on macOS. The read is best-effort — a process we can't stat
15
+ * (gone, or owned by another uid) yields `undefined`, never a guess.
16
+ *
17
+ * `reply` is a read-only hint, not a send channel: it reports whether a rail
18
+ * that can type back into this session exists today (tmux pane => addressable;
19
+ * inherited/ignored stdin => null). The feed uses it to decide whether to show
20
+ * a Send box. Actually delivering the keystrokes is Gap 2 (pty/tmux send-keys).
21
+ */
22
+ import * as os from 'os';
23
+ import { execFile } from 'child_process';
24
+ import { promisify } from 'util';
25
+ import { readFile } from 'fs/promises';
26
+ const execFileAsync = promisify(execFile);
27
+ /** Env vars that carry provenance. Kept small so the macOS `ps` scan stays cheap. */
28
+ export const PROVENANCE_ENV_KEYS = [
29
+ 'SSH_CONNECTION',
30
+ 'SSH_TTY',
31
+ 'TMUX',
32
+ 'TMUX_PANE',
33
+ 'TERM_PROGRAM',
34
+ 'STY',
35
+ ];
36
+ /** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
37
+ export function parseProcEnviron(buf) {
38
+ const env = {};
39
+ for (const pair of buf.split('\0')) {
40
+ if (!pair)
41
+ continue;
42
+ const eq = pair.indexOf('=');
43
+ if (eq <= 0)
44
+ continue;
45
+ env[pair.slice(0, eq)] = pair.slice(eq + 1);
46
+ }
47
+ return env;
48
+ }
49
+ /**
50
+ * How many whitespace-separated tokens each key's value spans. macOS
51
+ * `ps eww -o command=` space-joins the env after the command, so a value that
52
+ * itself contains spaces (SSH_CONNECTION is four fields) can't be recovered by
53
+ * boundary-guessing when the next token is an *unknown* var. Every provenance
54
+ * key except SSH_CONNECTION is a single token, so we read exactly its arity.
55
+ */
56
+ const ENV_VALUE_TOKENS = { SSH_CONNECTION: 4 };
57
+ /**
58
+ * Pull known env vars out of a macOS `ps eww` command+env line. For each
59
+ * `KEY=` match we consume the declared number of tokens (default 1), so
60
+ * SSH_CONNECTION's internal spaces survive while a following unknown var
61
+ * (e.g. `PWD=…`) is not swallowed into the previous value.
62
+ */
63
+ export function extractKnownEnv(text, keys) {
64
+ const alt = keys.map((k) => k.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|');
65
+ const boundary = new RegExp(`(?:^|\\s)(${alt})=`, 'g');
66
+ const env = {};
67
+ let m;
68
+ while ((m = boundary.exec(text)) !== null) {
69
+ const key = m[1];
70
+ const rest = text.slice(m.index + m[0].length);
71
+ const tokens = rest.split(/\s+/);
72
+ const want = ENV_VALUE_TOKENS[key] ?? 1;
73
+ env[key] = tokens.slice(0, want).join(' ');
74
+ }
75
+ return env;
76
+ }
77
+ /** `<client_ip> <client_port> <server_ip> <server_port>` → structured origin. */
78
+ export function parseSshConnection(value) {
79
+ const parts = value.trim().split(/\s+/);
80
+ if (parts.length < 4)
81
+ return undefined;
82
+ const clientPort = parseInt(parts[1], 10);
83
+ const serverPort = parseInt(parts[3], 10);
84
+ if (!Number.isFinite(clientPort) || !Number.isFinite(serverPort))
85
+ return undefined;
86
+ return { clientIp: parts[0], clientPort, serverIp: parts[2], serverPort };
87
+ }
88
+ /** Build a SessionProvenance from a raw env map + the local hostname. Pure. */
89
+ export function deriveProvenance(env, hostname) {
90
+ const ssh = env.SSH_CONNECTION ? parseSshConnection(env.SSH_CONNECTION) : undefined;
91
+ let mux;
92
+ if (env.TMUX) {
93
+ mux = {
94
+ kind: 'tmux',
95
+ socket: env.TMUX.split(',')[0] || undefined,
96
+ pane: env.TMUX_PANE || undefined,
97
+ };
98
+ }
99
+ else if (env.STY) {
100
+ mux = { kind: 'screen', session: env.STY };
101
+ }
102
+ // A tmux pane is the one rail that lets an external process type into an
103
+ // already-running interactive agent (`tmux send-keys -t <pane>`). Everything
104
+ // else (inherited stdin from `agents run`, ignored stdin from teams) is not
105
+ // externally addressable without relaunching under a pty/tmux rail.
106
+ const reply = mux?.kind === 'tmux' && mux.pane
107
+ ? { rail: 'tmux', target: mux.pane, socket: mux.socket }
108
+ : null;
109
+ return {
110
+ host: hostname,
111
+ transport: ssh ? 'ssh' : 'local',
112
+ ssh,
113
+ term: env.TERM_PROGRAM || undefined,
114
+ mux,
115
+ reply,
116
+ };
117
+ }
118
+ /** Read a process's environment. Linux: /proc. macOS: `ps eww`. Best-effort. */
119
+ async function readProcEnv(pid) {
120
+ if (process.platform === 'linux') {
121
+ try {
122
+ const buf = await readFile(`/proc/${pid}/environ`, 'utf8');
123
+ return parseProcEnviron(buf);
124
+ }
125
+ catch {
126
+ return undefined;
127
+ }
128
+ }
129
+ if (process.platform === 'darwin') {
130
+ try {
131
+ const { stdout } = await execFileAsync('ps', ['eww', '-p', String(pid), '-o', 'command='], {
132
+ encoding: 'utf8',
133
+ maxBuffer: 1024 * 1024,
134
+ });
135
+ if (!stdout.trim())
136
+ return undefined;
137
+ return extractKnownEnv(stdout, PROVENANCE_ENV_KEYS);
138
+ }
139
+ catch {
140
+ return undefined;
141
+ }
142
+ }
143
+ return undefined;
144
+ }
145
+ /**
146
+ * Resolve provenance for a live pid. Returns undefined when the process env
147
+ * can't be read (process gone, foreign uid, unsupported platform) — we never
148
+ * fabricate a 'local' answer we can't back with the env.
149
+ */
150
+ export async function detectProvenance(pid) {
151
+ if (!pid || pid < 1)
152
+ return undefined;
153
+ const env = await readProcEnv(pid);
154
+ if (!env)
155
+ return undefined;
156
+ return deriveProvenance(env, os.hostname());
157
+ }
@@ -83,6 +83,18 @@ export declare const SHIM_SCHEMA_VERSION = 19;
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
  */
@@ -219,6 +231,14 @@ export declare function getConfigSymlinkVersion(agent: AgentId): string | null;
219
231
  /**
220
232
  * Check if shim exists for an agent.
221
233
  */
234
+ /**
235
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
236
+ * write-side source of truth) so the exists/remove/version checks can never
237
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
238
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
239
+ * host.
240
+ */
241
+ export declare function onDiskShimFile(cliCommand: string, platform: NodeJS.Platform): string;
222
242
  export declare function shimExists(agent: AgentId): boolean;
223
243
  /**
224
244
  * Regenerate the shim if it's missing or outdated. Returns a status describing
package/dist/lib/shims.js CHANGED
@@ -528,6 +528,19 @@ fi
528
528
  exec "$BINARY"${launchArgs} "$@"
529
529
  `;
530
530
  }
531
+ /**
532
+ * Which shim files to materialize for a platform. Pure — testable on any host.
533
+ *
534
+ * POSIX writes the extensionless `#!/bin/bash` shim — the file PATH resolution
535
+ * execs. Windows writes only the `.cmd` companion: PATHEXT makes it the runnable
536
+ * form, and the bash file (mode 0o755 is a no-op there) is never executed — so
537
+ * emitting it is dead weight that only ever confuses `where agents`.
538
+ */
539
+ export function shimTargetsFor(platform) {
540
+ if (platform === 'win32')
541
+ return { bash: false, cmd: true };
542
+ return { bash: true, cmd: false };
543
+ }
531
544
  /**
532
545
  * Create a shim for an agent.
533
546
  */
@@ -536,12 +549,15 @@ export function createShim(agent) {
536
549
  const shimsDir = getShimsDir();
537
550
  const agentConfig = AGENTS[agent];
538
551
  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) {
552
+ const targets = shimTargetsFor(process.platform);
553
+ if (targets.bash) {
554
+ fs.writeFileSync(shimPath, generateShimScript(agent), { mode: 0o755 });
555
+ }
556
+ // Windows can't execute the bash shim directly. Drop a `.cmd` companion which
557
+ // delegates to the node-side transparent resolver (`agents __shim`) so version
558
+ // resolution stays single-sourced instead of reimplemented in batch — and skip
559
+ // the vestigial bash file entirely.
560
+ if (targets.cmd) {
545
561
  writeWindowsCmdShim(shimPath + '.cmd', agentConfig.cliCommand);
546
562
  }
547
563
  return shimPath;
@@ -557,6 +573,7 @@ function writeWindowsCmdShim(cmdPath, spec) {
557
573
  const indexJs = getAgentsBinForGeneratedShim();
558
574
  const content = `@echo off\r\n` +
559
575
  `rem Auto-generated by agents-cli - do not edit\r\n` +
576
+ `rem ${SHIM_VERSION_MARKER} ${SHIM_SCHEMA_VERSION}\r\n` +
560
577
  `node "${indexJs}" __shim ${spec} %*\r\n`;
561
578
  fs.writeFileSync(cmdPath, content);
562
579
  }
@@ -567,17 +584,18 @@ export function removeShim(agent) {
567
584
  const shimsDir = getShimsDir();
568
585
  const agentConfig = AGENTS[agent];
569
586
  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 { }
587
+ // Remove whichever companions exist: the extensionless script (POSIX, or a
588
+ // legacy Windows install that wrote it) AND the `.cmd` (Windows). Keying only
589
+ // off the extensionless path would orphan the `.cmd` on Windows, where
590
+ // createShim now writes only the `.cmd`.
591
+ let removed = false;
592
+ for (const p of [shimPath, shimPath + '.cmd']) {
593
+ if (fs.existsSync(p)) {
594
+ fs.unlinkSync(p);
595
+ removed = true;
577
596
  }
578
- return true;
579
597
  }
580
- return false;
598
+ return removed;
581
599
  }
582
600
  /**
583
601
  * Current versioned-alias schema. Bump whenever `generateVersionedAliasScript`
@@ -1376,11 +1394,26 @@ async function copyDirContents(src, dest, strategy = 'keep-dest', context) {
1376
1394
  /**
1377
1395
  * Check if shim exists for an agent.
1378
1396
  */
1397
+ /**
1398
+ * The on-disk shim FILENAME for a platform — derived from `shimTargetsFor` (the
1399
+ * write-side source of truth) so the exists/remove/version checks can never
1400
+ * drift from what `createShim` actually writes: `<cmd>.cmd` on Windows (the only
1401
+ * file written there), the bare `<cmd>` script on POSIX. Pure — testable on any
1402
+ * host.
1403
+ */
1404
+ export function onDiskShimFile(cliCommand, platform) {
1405
+ return shimTargetsFor(platform).cmd ? `${cliCommand}.cmd` : cliCommand;
1406
+ }
1407
+ /**
1408
+ * The actual on-disk shim path for the current platform. This is what
1409
+ * exists/version checks must stat — `getShimPath` returns the logical
1410
+ * (extensionless) launch path, which is not always a real file on Windows.
1411
+ */
1412
+ function onDiskShimPath(agent) {
1413
+ return path.join(getShimsDir(), onDiskShimFile(AGENTS[agent].cliCommand, process.platform));
1414
+ }
1379
1415
  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);
1416
+ return fs.existsSync(onDiskShimPath(agent));
1384
1417
  }
1385
1418
  /**
1386
1419
  * Read the schema version embedded in an existing on-disk shim. Returns
@@ -1390,7 +1423,7 @@ function readShimSchemaVersion(agent) {
1390
1423
  if (!shimExists(agent))
1391
1424
  return null;
1392
1425
  try {
1393
- const content = fs.readFileSync(getShimPath(agent), 'utf8');
1426
+ const content = fs.readFileSync(onDiskShimPath(agent), 'utf8');
1394
1427
  // Look at the first ~10 lines only — the marker lives in the header.
1395
1428
  const header = content.split('\n', 10).join('\n');
1396
1429
  const match = header.match(new RegExp(SHIM_VERSION_MARKER + '\\s*(\\d+)'));
@@ -19,6 +19,7 @@ export declare function assertValidSshTarget(host: string): void;
19
19
  export declare function shellQuote(s: string): string;
20
20
  /** Hardened ssh options applied to every connection. */
21
21
  export declare const SSH_OPTS: readonly string[];
22
+ export declare function controlOpts(): string[];
22
23
  export interface SshExecOptions {
23
24
  /** Piped to the remote command's stdin (never interpolated into the shell). */
24
25
  input?: string;
@@ -26,6 +27,8 @@ export interface SshExecOptions {
26
27
  timeoutMs?: number;
27
28
  /** Extra ssh flags inserted before the target (e.g. `-tt`). */
28
29
  extraSshArgs?: string[];
30
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
31
+ multiplex?: boolean;
29
32
  }
30
33
  export interface SshExecResult {
31
34
  /** Remote exit status, or null if ssh itself failed / timed out. */
@@ -43,3 +46,22 @@ export interface SshExecResult {
43
46
  export declare function sshExec(target: string, remoteCmd: string, opts?: SshExecOptions): SshExecResult;
44
47
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
45
48
  export declare function sshReachable(target: string, timeoutMs?: number): boolean;
49
+ export interface SshStreamOptions {
50
+ /**
51
+ * Allocate a remote pseudo-terminal (`ssh -tt`) so an interactive remote
52
+ * command (a picker, a prompt) renders live on the local terminal. Callers
53
+ * pass this when the *local* process is itself a TTY; piped/scripted callers
54
+ * leave it off and forward a non-interactive invocation instead.
55
+ */
56
+ tty?: boolean;
57
+ /** Reuse a persistent control socket across calls (see `controlOpts`). */
58
+ multiplex?: boolean;
59
+ }
60
+ /**
61
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
62
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
63
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
64
+ * until the remote command exits and returns its exit code (255 is ssh's own
65
+ * connection-layer failure; any other non-zero is the remote command's code).
66
+ */
67
+ export declare function sshStream(target: string, remoteCmd: string, opts?: SshStreamOptions): number;
@@ -8,6 +8,9 @@
8
8
  * canonical definition; `commands/secrets.ts` re-exports it.
9
9
  */
10
10
  import { spawnSync } from 'child_process';
11
+ import * as fs from 'fs';
12
+ import * as path from 'path';
13
+ import { getCacheDir } from './state.js';
11
14
  /**
12
15
  * SSH target: a bare ssh-config host alias (e.g. `yosemite-s0`) or `user@host`.
13
16
  * The strict allowlist blocks shell metacharacters so a target can't be
@@ -32,6 +35,42 @@ export const SSH_OPTS = [
32
35
  '-o', 'BatchMode=yes',
33
36
  '-o', 'ConnectTimeout=10',
34
37
  ];
38
+ /**
39
+ * OpenSSH connection-multiplexing options. The first connection to a host opens
40
+ * a control socket; subsequent connections (even from a *separate* `agents`
41
+ * invocation) reuse it, skipping the TCP+auth handshake — so repeated
42
+ * `--host <name>` calls to the same box feel local instead of paying ~100-300ms
43
+ * each. `ControlPersist=60s` keeps the master alive briefly after the last
44
+ * client exits. `%C` (a short fixed-length hash of local-host/remote/port/user)
45
+ * keeps the socket path well under macOS's 104-char `sun_path` limit.
46
+ *
47
+ * The socket directory is created lazily; if ssh can't open the control socket
48
+ * it falls back to a normal connection (multiplexing is an optimisation, never a
49
+ * requirement), so this can never make a reachable host unreachable.
50
+ */
51
+ let controlDirEnsured = false;
52
+ export function controlOpts() {
53
+ // OpenSSH on Windows has no ControlMaster/ControlPath (unix-socket) support —
54
+ // passing those options makes ssh error out. Multiplexing is a pure latency
55
+ // optimisation, so on Windows we simply skip it and use a fresh connection.
56
+ if (process.platform === 'win32')
57
+ return [];
58
+ const dir = path.join(getCacheDir(), 'ssh');
59
+ if (!controlDirEnsured) {
60
+ try {
61
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ }
63
+ catch {
64
+ /* best-effort — ssh degrades to a fresh connection if the dir is missing */
65
+ }
66
+ controlDirEnsured = true;
67
+ }
68
+ return [
69
+ '-o', 'ControlMaster=auto',
70
+ '-o', `ControlPath=${path.join(dir, 'cm-%C')}`,
71
+ '-o', 'ControlPersist=60s',
72
+ ];
73
+ }
35
74
  /**
36
75
  * Run `remoteCmd` on `target` over ssh and capture stdout/stderr/exit.
37
76
  *
@@ -40,7 +79,8 @@ export const SSH_OPTS = [
40
79
  */
41
80
  export function sshExec(target, remoteCmd, opts = {}) {
42
81
  assertValidSshTarget(target);
43
- const args = [...SSH_OPTS, ...(opts.extraSshArgs ?? []), target, remoteCmd];
82
+ const mux = opts.multiplex ? controlOpts() : [];
83
+ const args = [...SSH_OPTS, ...mux, ...(opts.extraSshArgs ?? []), target, remoteCmd];
44
84
  const res = spawnSync('ssh', args, {
45
85
  input: opts.input,
46
86
  encoding: 'utf-8',
@@ -57,5 +97,22 @@ export function sshExec(target, remoteCmd, opts = {}) {
57
97
  }
58
98
  /** True if `target` is reachable over ssh (a passwordless `true` succeeds quickly). */
59
99
  export function sshReachable(target, timeoutMs = 10000) {
60
- return sshExec(target, 'true', { timeoutMs }).code === 0;
100
+ return sshExec(target, 'true', { timeoutMs, multiplex: true }).code === 0;
101
+ }
102
+ /**
103
+ * Foreground counterpart to `sshExec`: run `remoteCmd` on `target` with the
104
+ * local stdio wired straight through (`stdio: 'inherit'`), so output streams as
105
+ * it is produced and — with `tty` — keystrokes reach a remote picker. Blocks
106
+ * until the remote command exits and returns its exit code (255 is ssh's own
107
+ * connection-layer failure; any other non-zero is the remote command's code).
108
+ */
109
+ export function sshStream(target, remoteCmd, opts = {}) {
110
+ assertValidSshTarget(target);
111
+ const mux = opts.multiplex ? controlOpts() : [];
112
+ const tty = opts.tty ? ['-tt'] : [];
113
+ const args = [...SSH_OPTS, ...mux, ...tty, target, remoteCmd];
114
+ const res = spawnSync('ssh', args, { stdio: 'inherit' });
115
+ if (typeof res.status === 'number')
116
+ return res.status;
117
+ return 255; // spawn error / signal — treat as a connection-layer failure
61
118
  }
@@ -90,11 +90,6 @@ export declare function buildPushScript(): string;
90
90
  export declare function buildRegisterTaskScript(port: number, taskName: string): string;
91
91
  /** PowerShell that unregisters the task and stops any running daemon process. */
92
92
  export declare function buildUnregisterTaskScript(taskName: string): string;
93
- /**
94
- * `setup --host`: push the exe, then register + start the LOGON task. Both hops
95
- * go through `sshExec` (BatchMode key auth — the same hardening the browser
96
- * driver and `agents ssh` use). Throws with the remote stderr on any failure.
97
- */
98
93
  export declare function setupRemoteHelper(name: string): Promise<{
99
94
  target: string;
100
95
  taskName: string;
@@ -22,7 +22,8 @@ import * as fs from 'fs';
22
22
  import * as path from 'path';
23
23
  import { fileURLToPath } from 'url';
24
24
  import { randomBytes } from 'crypto';
25
- import { sshExec } from './ssh-exec.js';
25
+ import { Transform } from 'stream';
26
+ import { sshExec, SSH_OPTS } from './ssh-exec.js';
26
27
  import { encodePowerShell } from './browser/drivers/ssh.js';
27
28
  import { getDevice } from './devices/registry.js';
28
29
  import { sshTargetFor } from './devices/connect.js';
@@ -199,20 +200,76 @@ export function buildUnregisterTaskScript(taskName) {
199
200
  * go through `sshExec` (BatchMode key auth — the same hardening the browser
200
201
  * driver and `agents ssh` use). Throws with the remote stderr on any failure.
201
202
  */
203
+ /**
204
+ * Base64-encode a byte stream in 3-byte-aligned chunks so the concatenated
205
+ * output is valid (every chunk boundary lands on a base64 quantum).
206
+ */
207
+ class Base64Encode extends Transform {
208
+ leftover = Buffer.alloc(0);
209
+ _transform(chunk, _enc, cb) {
210
+ const buf = this.leftover.length ? Buffer.concat([this.leftover, chunk]) : chunk;
211
+ const usable = buf.length - (buf.length % 3);
212
+ this.leftover = Buffer.from(buf.subarray(usable));
213
+ if (usable > 0)
214
+ this.push(buf.subarray(0, usable).toString('base64'));
215
+ cb();
216
+ }
217
+ _flush(cb) {
218
+ if (this.leftover.length)
219
+ this.push(this.leftover.toString('base64'));
220
+ cb();
221
+ }
222
+ }
223
+ /**
224
+ * Stream a local file to a remote command's stdin over ssh, base64-encoded on
225
+ * the fly. Async spawn + piping honors backpressure; the previous
226
+ * `spawnSync({ input })` blob deadlocked once the ssh socket buffer filled
227
+ * (~4MB) on large files (the 157MB Windows helper reproduced this reliably),
228
+ * and worse, reported a false success leaving a 0-byte remote file. Rejects on
229
+ * any pipe error so a broken transfer fails loudly instead.
230
+ */
231
+ function streamFileOverSsh(target, remoteCmd, filePath, timeoutMs = 600_000) {
232
+ return new Promise((resolve, reject) => {
233
+ const child = spawn('ssh', [...SSH_OPTS, target, remoteCmd], {
234
+ stdio: ['pipe', 'pipe', 'pipe'],
235
+ });
236
+ let stderr = '';
237
+ let stdout = '';
238
+ child.stderr.on('data', (d) => (stderr += d.toString()));
239
+ child.stdout.on('data', (d) => (stdout += d.toString()));
240
+ const timer = setTimeout(() => {
241
+ child.kill('SIGKILL');
242
+ reject(new Error(`ssh push to ${target} timed out after ${timeoutMs}ms`));
243
+ }, timeoutMs);
244
+ const fail = (e) => {
245
+ clearTimeout(timer);
246
+ child.kill('SIGKILL');
247
+ reject(e);
248
+ };
249
+ child.on('error', fail);
250
+ child.stdin.on('error', fail); // EPIPE if the remote decoder dies mid-stream
251
+ child.on('close', (code) => {
252
+ clearTimeout(timer);
253
+ resolve({ code, stderr: stderr || stdout });
254
+ });
255
+ const src = fs.createReadStream(filePath);
256
+ src.on('error', fail);
257
+ // disk -> aligned base64 -> ssh stdin; .pipe() applies backpressure
258
+ src.pipe(new Base64Encode()).pipe(child.stdin);
259
+ });
260
+ }
202
261
  export async function setupRemoteHelper(name) {
203
262
  const { target } = await resolveRemoteDevice(name);
204
263
  const exe = resolveWinHelperExe();
205
264
  if (!exe) {
206
265
  throw new Error(`Windows helper exe not built. Run: bash scripts/build-win.sh`);
207
266
  }
208
- // Push: base64 the exe locally, stream it over ssh stdin to the decoder.
209
- const b64 = fs.readFileSync(exe).toString('base64');
210
- const push = sshExec(target, encodePowerShell(buildPushScript()), {
211
- input: b64,
212
- timeoutMs: 600_000, // ~156MB over the wire — allow up to 10 minutes
213
- });
267
+ // Push: stream the exe from disk, base64-encoded on the fly, to the remote
268
+ // decoder. Streaming (vs a single spawnSync `input` blob) honors ssh socket
269
+ // backpressure the blob path deadlocks once the socket buffer fills (~4MB).
270
+ const push = await streamFileOverSsh(target, encodePowerShell(buildPushScript()), exe);
214
271
  if (push.code !== 0) {
215
- throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim() || push.stdout.trim()}`);
272
+ throw new Error(`pushing helper exe to '${name}' failed (exit ${push.code ?? 'null'}): ${push.stderr.trim()}`);
216
273
  }
217
274
  // Register + start the LOGON task.
218
275
  const reg = sshExec(target, encodePowerShell(buildRegisterTaskScript(REMOTE_HELPER_PORT, REMOTE_TASK_NAME)), {
@@ -49,6 +49,7 @@ export declare const loadPrune: ModuleLoader;
49
49
  export declare const loadTrash: ModuleLoader;
50
50
  export declare const loadRestore: ModuleLoader;
51
51
  export declare const loadDoctor: ModuleLoader;
52
+ export declare const loadStatus: ModuleLoader;
52
53
  export declare const loadProfiles: ModuleLoader;
53
54
  export declare const loadSecrets: ModuleLoader;
54
55
  export declare const loadWallet: ModuleLoader;
@@ -27,6 +27,7 @@ export const loadPrune = async () => (await import('../../commands/prune.js')).r
27
27
  export const loadTrash = async () => (await import('../../commands/trash.js')).registerTrashCommands;
28
28
  export const loadRestore = async () => (await import('../../commands/trash.js')).registerRestoreCommand;
29
29
  export const loadDoctor = async () => (await import('../../commands/doctor.js')).registerDoctorCommand;
30
+ export const loadStatus = async () => (await import('../../commands/status.js')).registerStatusCommand;
30
31
  export const loadProfiles = async () => (await import('../../commands/profiles.js')).registerProfilesCommands;
31
32
  export const loadSecrets = async () => (await import('../../commands/secrets.js')).registerSecretsCommands;
32
33
  export const loadWallet = async () => (await import('../../commands/wallet.js')).registerWalletCommands;
@@ -110,6 +111,7 @@ export const COMMAND_LOADERS = {
110
111
  trash: [loadTrash],
111
112
  restore: [loadRestore],
112
113
  doctor: [loadDoctor],
114
+ status: [loadStatus],
113
115
  profiles: [loadProfiles],
114
116
  secrets: [loadSecrets],
115
117
  wallet: [loadWallet],
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Unified sync-status engine — the SINGLE source of truth for "is this resource
3
+ * synced to this agent version?" consumed by `agents doctor`, `agents view`, the
4
+ * menu-bar app, and any Agency surface.
5
+ *
6
+ * Why this exists: before this module there were four different notions of
7
+ * "synced" living in four files:
8
+ * - doctor: isStale() vs .sync-manifest.json — catches SOURCE drift only.
9
+ * - view: git working-tree state of ~/.agents/ — a resource can show green
10
+ * while its installed copy is stale/deleted/corrupted (false positive).
11
+ * - lists: file-exists-in-home — never reports content drift at all.
12
+ * - menubar: read doctor --json (so it inherited doctor's source-only blind spot).
13
+ *
14
+ * The reliable signal is diffVersionResources() (src/lib/doctor-diff.ts): it reads
15
+ * the ACTUAL version home and compares it to the resolved sources, so it catches
16
+ * every drift class — source-side changes AND home-side rot (deleted / corrupted /
17
+ * hand-edited installed copies) AND orphans. This module wraps it once, maps its
18
+ * per-resource DiffStatus onto one stable enum, folds in `.system` repo freshness,
19
+ * and lets every surface render the same warnings instead of re-deriving them.
20
+ */
21
+ import { AgentId } from './types.js';
22
+ import { type DoctorKind } from './doctor-diff.js';
23
+ /**
24
+ * One stable status per resource, unified across every surface.
25
+ * - `synced` — installed copy matches the resolved source (DiffStatus 'ok').
26
+ * - `drifted` — installed copy exists but differs from source (DiffStatus 'diff').
27
+ * - `missing` — source exists, nothing installed in the version home ('missing').
28
+ * - `orphan` — installed in the home with no source ('extra'); prune's job, not sync's.
29
+ */
30
+ export type ResourceSyncStatus = 'synced' | 'drifted' | 'missing' | 'orphan';
31
+ export interface ResourceStatusRow {
32
+ agent: AgentId;
33
+ version: string;
34
+ kind: DoctorKind;
35
+ name: string;
36
+ status: ResourceSyncStatus;
37
+ /** Human-readable specifics for a drifted row (e.g. plugin version delta). */
38
+ detail?: string;
39
+ }
40
+ export interface AgentVersionStatus {
41
+ agent: AgentId;
42
+ version: string;
43
+ isDefault: boolean;
44
+ /** False = no .sync-manifest.json: this version was never synced (cold). */
45
+ everSynced: boolean;
46
+ counts: {
47
+ synced: number;
48
+ drifted: number;
49
+ missing: number;
50
+ orphan: number;
51
+ };
52
+ /** drifted + missing > 0 — a real reconcile is owed. Orphans do NOT set this
53
+ * (heal never deletes; orphan removal is `agents prune cleanup`). */
54
+ needsSync: boolean;
55
+ resources: ResourceStatusRow[];
56
+ }
57
+ export interface SystemRepoStatus {
58
+ dir: string;
59
+ /** Commits the local `.system` checkout is behind its tracking branch, as of
60
+ * the last background fetch (no network is performed here). 0 = up to date. */
61
+ behind: number;
62
+ ahead: number;
63
+ branch: string | null;
64
+ /** True when the dir isn't a git repo or has no upstream — behind is unknown. */
65
+ unknown: boolean;
66
+ }
67
+ export interface UnifiedSyncStatus {
68
+ system: SystemRepoStatus;
69
+ agents: AgentVersionStatus[];
70
+ totals: {
71
+ drifted: number;
72
+ missing: number;
73
+ orphan: number;
74
+ /** Versions with a manifest that are behind on content. */
75
+ versionsNeedingSync: number;
76
+ /** Versions that were never synced at all. */
77
+ versionsNeverSynced: number;
78
+ /** Distinct agent ids that own at least one version needing sync. */
79
+ agentsNeedingSync: number;
80
+ };
81
+ }
82
+ export interface SyncStatusOptions {
83
+ cwd?: string;
84
+ /** Restrict to specific agent ids; undefined = every supported agent. */
85
+ agents?: AgentId[];
86
+ /** Restrict to specific resource kinds; undefined = all. */
87
+ kinds?: DoctorKind[];
88
+ }
89
+ /**
90
+ * Read `.system` repo freshness WITHOUT touching the network. `git status`
91
+ * reports ahead/behind against the remote-tracking ref, which the detached
92
+ * auto-pull worker keeps warm via periodic `git fetch`. This is the same number
93
+ * the menu-bar surfaces; we read it once, here, so every surface agrees.
94
+ */
95
+ export declare function getSystemRepoStatus(): Promise<SystemRepoStatus>;
96
+ /**
97
+ * Compute unified sync status across the fleet. Resolves against non-project
98
+ * layers only (`excludeProject: true`) — the GLOBAL version home is never
99
+ * reconciled against per-cwd `<cwd>/.agents/` resources, so counting them as
100
+ * "missing" there would be a false gap (matches doctor's overview semantics).
101
+ */
102
+ export declare function computeSyncStatus(options?: SyncStatusOptions): Promise<UnifiedSyncStatus>;