@phnx-labs/agents-cli 1.20.67 → 1.20.68

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 (47) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/check.js +96 -2
  5. package/dist/commands/doctor.js +37 -9
  6. package/dist/commands/exec.js +39 -1
  7. package/dist/commands/plugins.js +11 -2
  8. package/dist/commands/repo.js +47 -2
  9. package/dist/commands/ssh.js +103 -2
  10. package/dist/commands/teams.d.ts +11 -0
  11. package/dist/commands/teams.js +39 -1
  12. package/dist/commands/view.d.ts +1 -0
  13. package/dist/commands/view.js +12 -8
  14. package/dist/lib/agents.d.ts +11 -5
  15. package/dist/lib/agents.js +27 -24
  16. package/dist/lib/browser/profiles.js +4 -25
  17. package/dist/lib/cli-entry.d.ts +23 -0
  18. package/dist/lib/cli-entry.js +116 -0
  19. package/dist/lib/daemon.d.ts +2 -2
  20. package/dist/lib/daemon.js +8 -89
  21. package/dist/lib/devices/fleet.d.ts +23 -0
  22. package/dist/lib/devices/fleet.js +38 -0
  23. package/dist/lib/devices/health-report.d.ts +46 -0
  24. package/dist/lib/devices/health-report.js +159 -0
  25. package/dist/lib/devices/health.d.ts +14 -4
  26. package/dist/lib/devices/health.js +49 -8
  27. package/dist/lib/git.d.ts +10 -0
  28. package/dist/lib/git.js +23 -0
  29. package/dist/lib/hosts/option.js +1 -1
  30. package/dist/lib/hosts/ready.js +5 -3
  31. package/dist/lib/hosts/remote-cmd.d.ts +12 -0
  32. package/dist/lib/hosts/remote-cmd.js +17 -1
  33. package/dist/lib/mcp.d.ts +21 -0
  34. package/dist/lib/mcp.js +278 -13
  35. package/dist/lib/resources/mcp.js +20 -342
  36. package/dist/lib/routines.d.ts +13 -0
  37. package/dist/lib/routines.js +21 -0
  38. package/dist/lib/runner.js +18 -40
  39. package/dist/lib/secrets/agent.js +11 -15
  40. package/dist/lib/share/capture.js +21 -3
  41. package/dist/lib/signin-badge.d.ts +41 -0
  42. package/dist/lib/signin-badge.js +64 -0
  43. package/dist/lib/ssh-exec.d.ts +5 -0
  44. package/dist/lib/ssh-exec.js +55 -1
  45. package/dist/lib/usage.d.ts +17 -3
  46. package/dist/lib/usage.js +63 -16
  47. package/package.json +1 -1
@@ -42,6 +42,18 @@ export function planFleetTargets(reg) {
42
42
  return { device };
43
43
  });
44
44
  }
45
+ /**
46
+ * Remote fan-out targets for the fleet health/drift gates (`fleet status`,
47
+ * `check --devices`): every planned device except this machine and control-only
48
+ * cockpits. A control device never runs agents (mirrors doctor's fan-out, which
49
+ * drops it via `isControlDevice`), so counting it as unreachable/drift would make
50
+ * the CI gate fail on every run for a fleet that merely has a cockpit registered.
51
+ * Offline / no-address devices are kept — those are genuine faults a gate should
52
+ * surface — so their `skip` reason still flows through as an `unreachable` row.
53
+ */
54
+ export function remoteFleetTargets(planned, self) {
55
+ return planned.filter((t) => t.device.name !== self && t.skip !== 'control');
56
+ }
45
57
  /** Human label for a skip reason. */
46
58
  export function skipLabel(reason) {
47
59
  switch (reason) {
@@ -135,3 +147,29 @@ export function runFleet(targets, cmd, runner = runOnDevice) {
135
147
  }
136
148
  return results;
137
149
  }
150
+ /** Run one async probe per device in parallel, preserving input order. */
151
+ export async function fanOutDevices(targets, probe) {
152
+ return Promise.all(targets.map(async (target) => {
153
+ if (target.skip) {
154
+ return {
155
+ name: target.name,
156
+ status: 'skipped',
157
+ reason: target.skip,
158
+ };
159
+ }
160
+ try {
161
+ return {
162
+ name: target.name,
163
+ status: 'ok',
164
+ value: await probe(target),
165
+ };
166
+ }
167
+ catch (err) {
168
+ return {
169
+ name: target.name,
170
+ status: 'failed',
171
+ error: err instanceof Error ? err.message : String(err),
172
+ };
173
+ }
174
+ }));
175
+ }
@@ -0,0 +1,46 @@
1
+ import { type DeviceStats } from './health.js';
2
+ export interface FleetCliStatus {
3
+ installed: boolean;
4
+ path: string | null;
5
+ error: string | null;
6
+ }
7
+ export interface FleetSyncStatus {
8
+ agent: string;
9
+ version: string;
10
+ status: 'fresh' | 'stale' | 'never-synced';
11
+ isDefault: boolean;
12
+ divergence?: string[];
13
+ }
14
+ export interface FleetOrphanStatus {
15
+ agent: string;
16
+ version: string;
17
+ commands: number;
18
+ skills: number;
19
+ hooks: number;
20
+ }
21
+ export interface FleetHealthRow {
22
+ name: string;
23
+ platform?: string;
24
+ version?: string | null;
25
+ stats?: DeviceStats;
26
+ error?: string;
27
+ skipped?: string;
28
+ clis: Record<string, FleetCliStatus>;
29
+ sync: FleetSyncStatus[];
30
+ orphans: FleetOrphanStatus[];
31
+ }
32
+ export interface FleetWarning {
33
+ kind: 'unreachable' | 'drift' | 'cli' | 'version-skew';
34
+ devices: string[];
35
+ message: string;
36
+ }
37
+ export interface FleetHealthReport {
38
+ generatedAt: string;
39
+ devices: FleetHealthRow[];
40
+ warnings: FleetWarning[];
41
+ hasWarnings: boolean;
42
+ hasDrift: boolean;
43
+ }
44
+ export declare function buildFleetHealthReport(rows: FleetHealthRow[], now?: Date): FleetHealthReport;
45
+ export declare function renderFleetWarnings(report: FleetHealthReport): string[];
46
+ export declare function renderFleetMatrix(report: FleetHealthReport): string[];
@@ -0,0 +1,159 @@
1
+ import chalk from 'chalk';
2
+ import { padToWidth, terminalWidth, truncateToWidth } from '../session/width.js';
3
+ import { fmtBytes, headroom } from './health.js';
4
+ function driftRows(row) {
5
+ return row.sync.filter((s) => s.status !== 'fresh');
6
+ }
7
+ function installedCliCount(row) {
8
+ const statuses = Object.values(row.clis);
9
+ return {
10
+ installed: statuses.filter((s) => s.installed).length,
11
+ total: statuses.length,
12
+ };
13
+ }
14
+ export function buildFleetHealthReport(rows, now = new Date()) {
15
+ const warnings = [];
16
+ const unreachable = rows
17
+ .filter((r) => r.error || r.skipped)
18
+ .map((r) => r.name);
19
+ if (unreachable.length > 0) {
20
+ warnings.push({
21
+ kind: 'unreachable',
22
+ devices: unreachable,
23
+ message: `${unreachable.length} device${unreachable.length === 1 ? '' : 's'} unreachable or skipped`,
24
+ });
25
+ }
26
+ const drifted = rows.filter((r) => driftRows(r).length > 0).map((r) => r.name);
27
+ if (drifted.length > 0) {
28
+ warnings.push({
29
+ kind: 'drift',
30
+ devices: drifted,
31
+ message: `${drifted.length} device${drifted.length === 1 ? '' : 's'} have sync drift`,
32
+ });
33
+ }
34
+ const cliIssues = rows
35
+ .filter((r) => {
36
+ const counts = installedCliCount(r);
37
+ return counts.total > 0 && counts.installed < counts.total;
38
+ })
39
+ .map((r) => r.name);
40
+ if (cliIssues.length > 0) {
41
+ warnings.push({
42
+ kind: 'cli',
43
+ devices: cliIssues,
44
+ message: `${cliIssues.length} device${cliIssues.length === 1 ? ' is' : 's are'} missing one or more agent CLIs`,
45
+ });
46
+ }
47
+ const versions = new Map();
48
+ for (const row of rows) {
49
+ if (!row.version)
50
+ continue;
51
+ const list = versions.get(row.version) ?? [];
52
+ list.push(row.name);
53
+ versions.set(row.version, list);
54
+ }
55
+ if (versions.size > 1) {
56
+ warnings.push({
57
+ kind: 'version-skew',
58
+ devices: rows.filter((r) => r.version).map((r) => r.name),
59
+ message: `agents-cli version skew: ${Array.from(versions.keys()).sort().join(', ')}`,
60
+ });
61
+ }
62
+ return {
63
+ generatedAt: now.toISOString(),
64
+ devices: rows,
65
+ warnings,
66
+ hasWarnings: warnings.length > 0,
67
+ hasDrift: drifted.length > 0 || unreachable.length > 0,
68
+ };
69
+ }
70
+ function statusGlyph(row) {
71
+ if (row.error || row.skipped)
72
+ return chalk.red('○');
73
+ if (driftRows(row).length > 0)
74
+ return chalk.yellow('◐');
75
+ return chalk.green('●');
76
+ }
77
+ function headroomLabel(row) {
78
+ const h = headroom(row.stats);
79
+ switch (h) {
80
+ case 'idle':
81
+ return chalk.green('idle');
82
+ case 'light':
83
+ return chalk.green('light');
84
+ case 'busy':
85
+ return chalk.yellow('busy');
86
+ case 'loaded':
87
+ return chalk.red('loaded');
88
+ case 'unknown':
89
+ return chalk.gray('unknown');
90
+ }
91
+ }
92
+ function driftLabel(row) {
93
+ if (row.error)
94
+ return chalk.red('probe failed');
95
+ if (row.skipped)
96
+ return chalk.gray(row.skipped);
97
+ const drift = driftRows(row);
98
+ if (drift.length === 0)
99
+ return chalk.green('fresh');
100
+ const stale = drift.filter((r) => r.status === 'stale').length;
101
+ const cold = drift.filter((r) => r.status === 'never-synced').length;
102
+ const parts = [];
103
+ if (stale)
104
+ parts.push(`${stale} stale`);
105
+ if (cold)
106
+ parts.push(`${cold} cold`);
107
+ return chalk.yellow(parts.join(' · '));
108
+ }
109
+ function cliLabel(row) {
110
+ if (row.error || row.skipped)
111
+ return chalk.gray('-');
112
+ const { installed, total } = installedCliCount(row);
113
+ if (total === 0)
114
+ return chalk.gray('none');
115
+ return installed === total ? chalk.green(`${installed}/${total}`) : chalk.yellow(`${installed}/${total}`);
116
+ }
117
+ function loadLabel(stats) {
118
+ if (!stats?.reachable)
119
+ return chalk.gray('-');
120
+ const load = stats.loadPercent === undefined ? '-' : `${Math.round(stats.loadPercent)}%`;
121
+ const mem = stats.memPercent === undefined ? '-' : `${Math.round(stats.memPercent)}%`;
122
+ return `${load}/${mem}`;
123
+ }
124
+ export function renderFleetWarnings(report) {
125
+ if (report.warnings.length === 0)
126
+ return [chalk.green('Fleet warnings: none')];
127
+ return [
128
+ chalk.bold(`Fleet warnings (${report.warnings.length})`),
129
+ ...report.warnings.map((w) => ` ${chalk.yellow(w.kind.padEnd(12))} ${w.message} ${chalk.gray(`(${w.devices.join(', ')})`)}`),
130
+ ];
131
+ }
132
+ export function renderFleetMatrix(report) {
133
+ if (report.devices.length === 0)
134
+ return [chalk.gray('No registered devices. Run `agents devices` to register some.')];
135
+ const nameW = Math.min(22, Math.max(6, ...report.devices.map((r) => r.name.length)));
136
+ const versionW = Math.min(14, Math.max(7, ...report.devices.map((r) => (r.version ?? '-').length)));
137
+ const width = terminalWidth();
138
+ // 4 = leading " " + the per-row status glyph + its trailing space (rows prefix
139
+ // ` ${statusGlyph} `; the header reserves the same 4 cols so every column lines up).
140
+ const fixed = 4 + nameW + 2 + 8 + 2 + 9 + 2 + 9 + 2 + versionW + 2 + 9 + 2 + 9;
141
+ const noteW = Math.max(12, width - fixed);
142
+ const lines = [
143
+ chalk.bold('Fleet status'),
144
+ chalk.gray(` ${padToWidth('Device', nameW)} ${padToWidth('OS', 8)} ${padToWidth('Health', 9)} ${padToWidth('Sync', 9)} ${padToWidth('CLI', 9)} ${padToWidth('Version', versionW)} ${padToWidth('Load/Mem', 9)} Note`),
145
+ ];
146
+ for (const row of report.devices) {
147
+ const note = row.error ?? row.skipped ?? (row.orphans.length > 0 ? `${row.orphans.length} orphaned version${row.orphans.length === 1 ? '' : 's'}` : '');
148
+ lines.push(` ${statusGlyph(row)} ${padToWidth(truncateToWidth(row.name, nameW), nameW)} ` +
149
+ `${padToWidth(truncateToWidth(row.platform ?? '-', 8), 8)} ` +
150
+ `${padToWidth(headroomLabel(row), 9)} ` +
151
+ `${padToWidth(driftLabel(row), 9)} ` +
152
+ `${padToWidth(cliLabel(row), 9)} ` +
153
+ `${padToWidth(truncateToWidth(row.version ?? '-', versionW), versionW)} ` +
154
+ `${padToWidth(loadLabel(row.stats), 9)} ` +
155
+ chalk.gray(truncateToWidth(note || `free ${fmtBytes(row.stats?.memFreeBytes)}`, noteW)));
156
+ }
157
+ lines.push(chalk.gray(' ● fresh · ◐ drift · ○ unreachable/skipped'));
158
+ return lines;
159
+ }
@@ -2,9 +2,10 @@
2
2
  * Device resource probing for `agents devices list`.
3
3
  *
4
4
  * One SSH round-trip per device gathers load average, memory pressure, and core
5
- * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
- * with a bounded timeout so the list stays responsive a slow or hung box
7
- * degrades to "no stats" instead of blocking the whole table.
5
+ * count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
6
+ * into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
7
+ * the list stays responsive — a slow or hung box degrades to "no stats" instead
8
+ * of blocking the whole table.
8
9
  *
9
10
  * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
11
  * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
@@ -14,12 +15,17 @@ import type { DeviceProfile } from './registry.js';
14
15
  /** Default per-device probe budget. Short enough that the list never hangs on a
15
16
  * wedged box, long enough for a cold relayed SSH handshake. */
16
17
  export declare const PROBE_TIMEOUT_MS = 2500;
18
+ /** Windows probe budget. The first CIM query of a PowerShell session pays a
19
+ * "Preparing modules for first use" cost on top of PowerShell startup, which
20
+ * routinely blows the 2.5s POSIX budget on a relayed connection. */
21
+ export declare const WIN_PROBE_TIMEOUT_MS = 6000;
17
22
  export interface DeviceStats {
18
23
  host: string;
19
24
  reachable: boolean;
20
25
  loadAvg1?: number;
21
26
  ncpu?: number;
22
- /** loadAvg1 / ncpu * 100 — load normalized to core count (the "has room" number). */
27
+ /** Load normalized to core count (the "has room" number): loadAvg1 / ncpu *
28
+ * 100 on mac/linux, CPU utilization % directly on windows (no loadAvg1). */
23
29
  loadPercent?: number;
24
30
  memPercent?: number;
25
31
  memTotalBytes?: number;
@@ -43,6 +49,10 @@ export declare function parseNcpu(out: string): {
43
49
  };
44
50
  /** Assemble a DeviceStats from the three snippet sections. */
45
51
  export declare function parseProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
52
+ /** Assemble a DeviceStats from the windows one-liner. A missing marker line
53
+ * (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
54
+ * numbers, mirroring how garbage POSIX output degrades. */
55
+ export declare function parseWinProbeOutput(host: string, stdout: string, fetchedAt: number): DeviceStats;
46
56
  export interface FleetCapacity {
47
57
  reachable: number;
48
58
  cores: number;
@@ -2,9 +2,10 @@
2
2
  * Device resource probing for `agents devices list`.
3
3
  *
4
4
  * One SSH round-trip per device gathers load average, memory pressure, and core
5
- * count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
6
- * with a bounded timeout so the list stays responsive a slow or hung box
7
- * degrades to "no stats" instead of blocking the whole table.
5
+ * count (mac + linux via a POSIX snippet, windows via a CIM one-liner), parsed
6
+ * into a {@link DeviceStats}. Probes run in parallel with a bounded timeout so
7
+ * the list stays responsive — a slow or hung box degrades to "no stats" instead
8
+ * of blocking the whole table.
8
9
  *
9
10
  * The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
10
11
  * the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
@@ -15,10 +16,21 @@ import { buildSshInvocation, writeAskpassShim } from './connect.js';
15
16
  /** Default per-device probe budget. Short enough that the list never hangs on a
16
17
  * wedged box, long enough for a cold relayed SSH handshake. */
17
18
  export const PROBE_TIMEOUT_MS = 2_500;
19
+ /** Windows probe budget. The first CIM query of a PowerShell session pays a
20
+ * "Preparing modules for first use" cost on top of PowerShell startup, which
21
+ * routinely blows the 2.5s POSIX budget on a relayed connection. */
22
+ export const WIN_PROBE_TIMEOUT_MS = 6_000;
18
23
  const SEP = '---AGSTAT---';
19
24
  /** One-shot remote snapshot: load, then memory (mac vm_stat else linux
20
25
  * meminfo), then core count (linux nproc else mac hw.ncpu). */
21
26
  const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
27
+ /** Windows equivalent, one labeled line via CIM. PowerShell 5.1-safe: no `||`
28
+ * chaining, plain string concatenation. `LoadPercentage` is $null on some
29
+ * hosts/VMs, which concatenates to an empty field — the parser treats that as
30
+ * "no load signal" and headroom falls back to memory pressure alone.
31
+ * wrapRemoteCommand base64-encodes this for powershell-shell devices, so the
32
+ * quoting survives ssh intact. */
33
+ const WIN_PROBE_SNIPPET = `$os = Get-CimInstance Win32_OperatingSystem; $cpu = (Get-CimInstance Win32_Processor | Measure-Object -Property LoadPercentage -Average).Average; Write-Output ('AGWINSTAT load=' + $cpu + ' freeKb=' + $os.FreePhysicalMemory + ' totalKb=' + $os.TotalVisibleMemorySize + ' ncpu=' + $env:NUMBER_OF_PROCESSORS)`;
22
34
  /** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
23
35
  export function fmtBytes(bytes) {
24
36
  if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
@@ -106,6 +118,28 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
106
118
  fetchedAt,
107
119
  };
108
120
  }
121
+ /** Assemble a DeviceStats from the windows one-liner. A missing marker line
122
+ * (e.g. CIM unavailable) keeps `reachable: true` — ssh answered — with no
123
+ * numbers, mirroring how garbage POSIX output degrades. */
124
+ export function parseWinProbeOutput(host, stdout, fetchedAt) {
125
+ const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)/);
126
+ if (!m)
127
+ return { host, reachable: true, fetchedAt };
128
+ const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
129
+ const freeKb = parseInt(m[2], 10);
130
+ const totalKb = parseInt(m[3], 10);
131
+ const ncpu = parseInt(m[4], 10);
132
+ return {
133
+ host,
134
+ reachable: true,
135
+ ncpu: Number.isFinite(ncpu) && ncpu > 0 ? ncpu : undefined,
136
+ loadPercent: loadPercent !== undefined && Number.isFinite(loadPercent) ? loadPercent : undefined,
137
+ memPercent: totalKb > 0 ? Math.max(0, Math.min(100, ((totalKb - freeKb) / totalKb) * 100)) : undefined,
138
+ memTotalBytes: totalKb > 0 ? totalKb * 1024 : undefined,
139
+ memFreeBytes: totalKb > 0 ? freeKb * 1024 : undefined,
140
+ fetchedAt,
141
+ };
142
+ }
109
143
  /** Sum cores and memory across reachable devices for the summary footer. */
110
144
  export function fleetCapacity(statsList) {
111
145
  const cap = { reachable: 0, cores: 0, memTotalBytes: 0, memFreeBytes: 0 };
@@ -139,6 +173,7 @@ export function headroom(stats) {
139
173
  export function probeDeviceStats(device, opts = {}) {
140
174
  const host = device.name;
141
175
  const fetchedAt = opts.now ?? Date.now();
176
+ const isWin = device.shell === 'powershell';
142
177
  let args;
143
178
  let env;
144
179
  try {
@@ -146,16 +181,21 @@ export function probeDeviceStats(device, opts = {}) {
146
181
  // buildSshInvocation joins the cmd with spaces and hands the string to the
147
182
  // remote login shell, which evaluates the snippet's `;`/`||` directly — no
148
183
  // `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
149
- ({ args, env } = buildSshInvocation(device, [PROBE_SNIPPET], shim));
184
+ // For powershell devices it base64-encodes the snippet instead.
185
+ ({ args, env } = buildSshInvocation(device, [isWin ? WIN_PROBE_SNIPPET : PROBE_SNIPPET], shim));
150
186
  }
151
187
  catch {
152
188
  return Promise.resolve({ host, reachable: false, fetchedAt });
153
189
  }
154
190
  return new Promise((resolve) => {
155
- execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
191
+ execFile('ssh', args, {
192
+ encoding: 'utf-8',
193
+ env: { ...process.env, ...env },
194
+ timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS),
195
+ }, (err, stdout) => {
156
196
  if (err || !stdout)
157
197
  return resolve({ host, reachable: false, fetchedAt });
158
- resolve(parseProbeOutput(host, stdout, fetchedAt));
198
+ resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
159
199
  });
160
200
  });
161
201
  }
@@ -164,11 +204,12 @@ export function probeDeviceStats(device, opts = {}) {
164
204
  * from itself. */
165
205
  export function probeLocalStats(host, opts = {}) {
166
206
  const fetchedAt = opts.now ?? Date.now();
207
+ const isWin = process.platform === 'win32';
167
208
  return new Promise((resolve) => {
168
- execFile('sh', ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
209
+ execFile(isWin ? 'powershell' : 'sh', isWin ? ['-NoProfile', '-Command', WIN_PROBE_SNIPPET] : ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? (isWin ? WIN_PROBE_TIMEOUT_MS : PROBE_TIMEOUT_MS) }, (err, stdout) => {
169
210
  if (err || !stdout)
170
211
  return resolve({ host, reachable: false, fetchedAt });
171
- resolve(parseProbeOutput(host, stdout, fetchedAt));
212
+ resolve(isWin ? parseWinProbeOutput(host, stdout, fetchedAt) : parseProbeOutput(host, stdout, fetchedAt));
172
213
  });
173
214
  });
174
215
  }
package/dist/lib/git.d.ts CHANGED
@@ -95,6 +95,16 @@ export declare function getGitHubUsername(): Promise<string | null>;
95
95
  * Get the remote URL for origin in a git repo.
96
96
  */
97
97
  export declare function getRemoteUrl(repoPath: string): Promise<string | null>;
98
+ /**
99
+ * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
100
+ * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
101
+ * `user@`, a trailing `.git`, and folds the scp-style `host:owner/repo` colon to
102
+ * a slash. Lower-cased. Used to decide whether an existing checkout is "the same
103
+ * repo" as a requested source before adopting it.
104
+ */
105
+ export declare function canonicalGitRemote(url: string): string;
106
+ /** True when two git remote URLs point at the same repo across transport forms. */
107
+ export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
98
108
  /**
99
109
  * Set the remote URL for origin in a git repo.
100
110
  */
package/dist/lib/git.js CHANGED
@@ -365,6 +365,29 @@ export async function getRemoteUrl(repoPath) {
365
365
  return null;
366
366
  }
367
367
  }
368
+ /**
369
+ * Canonical `host/owner/repo` form of a git remote, transport-agnostic, so the
370
+ * same repo cloned over SSH vs HTTPS compares equal. Strips protocol, any
371
+ * `user@`, a trailing `.git`, and folds the scp-style `host:owner/repo` colon to
372
+ * a slash. Lower-cased. Used to decide whether an existing checkout is "the same
373
+ * repo" as a requested source before adopting it.
374
+ */
375
+ export function canonicalGitRemote(url) {
376
+ return url
377
+ .trim()
378
+ .replace(/\/+$/, '') // trailing slashes first, so a trailing-slash-after-.git still strips
379
+ .replace(/\.git$/i, '')
380
+ .replace(/^[a-z][a-z0-9+.-]*:\/\//i, '') // strip scheme (https://, ssh://, git://)
381
+ .replace(/^[^@/]+@/, '') // strip user@ (git@, ssh user)
382
+ .replace(':', '/') // scp-style host:owner/repo → host/owner/repo (first colon only)
383
+ .toLowerCase();
384
+ }
385
+ /** True when two git remote URLs point at the same repo across transport forms. */
386
+ export function sameGitRemote(a, b) {
387
+ if (!a || !b)
388
+ return false;
389
+ return canonicalGitRemote(a) === canonicalGitRemote(b);
390
+ }
368
391
  /**
369
392
  * Set the remote URL for origin in a git repo.
370
393
  */
@@ -14,7 +14,7 @@ export function addHostOption(cmd) {
14
14
  return cmd
15
15
  .option('-H, --host <name>', 'Run this command on another machine over SSH instead of locally — a device, a registered host, or user@host. See `agents devices` / `agents hosts`.')
16
16
  .option('--device <name>', 'Alias of --host: run this command on a registered device (from `agents devices`).')
17
- .option('--remote-cwd <dir>', 'Working directory on the host for --host runs.')
17
+ .option('--remote-cwd <dir>', "Working directory on the host for --host runs. Resolves on the REMOTE host — pass a '$HOME'-relative path (single-quoted so your local shell doesn't expand it) or a valid remote absolute path; a local ~ expands here and won't exist there (/Users/you vs /home/you). No effect on 'teams add'.")
18
18
  .option('--no-tty', 'Force non-interactive output for --host runs even from a terminal.')
19
19
  .option('--any', 'With --host <cap> (a capability tag), pick any matching host instead of erroring when several match.');
20
20
  }
@@ -10,7 +10,7 @@ import * as path from 'path';
10
10
  import { fileURLToPath } from 'url';
11
11
  import { sshExec, shellQuote } from '../ssh-exec.js';
12
12
  import { sshTargetFor } from './types.js';
13
- import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote } from './remote-cmd.js';
13
+ import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
14
14
  import { resolveRemoteOsSync } from './remote-os.js';
15
15
  /** Resolve this CLI's own version by walking up to the nearest package.json. */
16
16
  export function localCliVersion() {
@@ -75,7 +75,8 @@ export function remoteAgentsVersion(target, os) {
75
75
  * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
76
76
  export function buildBootstrapCommand(spec, os) {
77
77
  if (remoteShellFor(os) === 'powershell') {
78
- const script = `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
78
+ const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
79
+ `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
79
80
  `if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
80
81
  `agents --version`;
81
82
  return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
@@ -110,7 +111,8 @@ const READY_MARKER = '@@AGENTS_READY@@';
110
111
  */
111
112
  export function buildReadyProbeCommand(os) {
112
113
  if (remoteShellFor(os) === 'powershell') {
113
- const script = `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
114
+ const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
115
+ `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
114
116
  `agents view 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
115
117
  return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
116
118
  }
@@ -117,6 +117,18 @@ export interface WindowsAgentsCommand {
117
117
  * `& agents …` invokes the CLI from the machine PATH (Windows has no login
118
118
  * shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
119
119
  */
120
+ /**
121
+ * PowerShell prelude that silences the progress stream. PowerShell 5.1 serializes
122
+ * progress records ("Preparing modules for first use.") to CLIXML when stderr is a
123
+ * redirected pipe (an ssh capture, not a console), so a remote `agents …` result
124
+ * otherwise comes back wrapped in a raw `#< CLIXML <Objs …>` blob — unreadable to
125
+ * humans and to the JSON parsers that consume doctor / fleet-status output. Prepend
126
+ * this to EVERY Windows script that invokes `agents` (there is more than one builder
127
+ * in this file). Verified against a live win-mini (PowerShell 5.1): this clears it.
128
+ * (`-OutputFormat Text` does NOT — it governs success-stream object serialization,
129
+ * not the progress stream.)
130
+ */
131
+ export declare const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
120
132
  export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
121
133
  /**
122
134
  * Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
@@ -110,6 +110,7 @@ export const RUN_OPTION_FORWARDING = {
110
110
  lease: 'local-only',
111
111
  keepBox: 'local-only',
112
112
  copyCreds: 'local-only', // copies creds TO the host before dispatch — local concern only
113
+ authCheck: 'local-only', // --no-auth-check gates the local interactive login preflight; --host runs skip that preflight entirely
113
114
  };
114
115
  /** Actionable messages for value-aware rejections, keyed by attribute name. */
115
116
  export const RUN_OPTION_REJECT_MESSAGES = {
@@ -190,9 +191,21 @@ export function decodePowershell(encoded) {
190
191
  * `& agents …` invokes the CLI from the machine PATH (Windows has no login
191
192
  * shell, so there is no `bash -lc` equivalent — the shim is simply on PATH).
192
193
  */
194
+ /**
195
+ * PowerShell prelude that silences the progress stream. PowerShell 5.1 serializes
196
+ * progress records ("Preparing modules for first use.") to CLIXML when stderr is a
197
+ * redirected pipe (an ssh capture, not a console), so a remote `agents …` result
198
+ * otherwise comes back wrapped in a raw `#< CLIXML <Objs …>` blob — unreadable to
199
+ * humans and to the JSON parsers that consume doctor / fleet-status output. Prepend
200
+ * this to EVERY Windows script that invokes `agents` (there is more than one builder
201
+ * in this file). Verified against a live win-mini (PowerShell 5.1): this clears it.
202
+ * (`-OutputFormat Text` does NOT — it governs success-stream object serialization,
203
+ * not the progress stream.)
204
+ */
205
+ export const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
193
206
  export function windowsAgentsScript(cmd) {
194
207
  const { args, env, cwd, propagateExit = true } = cmd;
195
- const parts = [];
208
+ const parts = [POWERSHELL_PROGRESS_SILENCE];
196
209
  if (env)
197
210
  for (const [k, v] of Object.entries(env))
198
211
  parts.push(`$env:${k} = ${powershellQuote(v)}`);
@@ -232,6 +245,9 @@ export function buildWindowsStdinImportCommand(bundle, opts = {}) {
232
245
  // the secret-bearing temp file would otherwise be left behind (RUSH-1764). $tmp
233
246
  // starts null so a GetTempFileName that itself throws leaves nothing to remove.
234
247
  const script = [
248
+ // Same CLIXML guard as windowsAgentsScript — this builder also runs `& agents …`
249
+ // and its raw stderr is printed to the user on failure (secrets export --host <win>).
250
+ POWERSHELL_PROGRESS_SILENCE,
235
251
  '$in = [Console]::In.ReadToEnd()',
236
252
  '$tmp = $null',
237
253
  `try { $tmp = [System.IO.Path]::GetTempFileName(); [System.IO.File]::WriteAllText($tmp, $in); ` +
package/dist/lib/mcp.d.ts CHANGED
@@ -108,6 +108,27 @@ export declare function registerMcpCommandToTargets(targets: {
108
108
  directAgents: AgentId[];
109
109
  versionSelections: Map<AgentId, string[]>;
110
110
  }, name: string, commandSpec: McpCommandSpec, scope?: 'user' | 'project', transport?: string): Promise<McpTargetOperationResult[]>;
111
+ /**
112
+ * MCP server shaped for direct config-file serialization.
113
+ */
114
+ export interface WritableMcpServer {
115
+ name: string;
116
+ transport: 'stdio' | 'http' | 'sse';
117
+ command?: string;
118
+ args?: string[];
119
+ env?: Record<string, string>;
120
+ url?: string;
121
+ headers?: Record<string, string>;
122
+ }
123
+ /**
124
+ * Serialize MCP servers into an agent-specific config file.
125
+ *
126
+ * `mode: 'overwrite'` replaces the whole MCP section (used for tool-managed
127
+ * version-home configs). `mode: 'merge'` updates/adds the provided server
128
+ * entries while preserving existing entries (used for project-level configs
129
+ * that users may hand-edit or populate via agent CLI commands).
130
+ */
131
+ export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge'): void;
111
132
  /**
112
133
  * Install MCP servers to an agent.
113
134
  * For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)