@phnx-labs/agents-cli 1.20.66 → 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.
- package/CHANGELOG.md +88 -0
- package/README.md +3 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/check.js +96 -2
- package/dist/commands/doctor.js +37 -9
- package/dist/commands/exec.js +45 -1
- package/dist/commands/plugins.js +11 -2
- package/dist/commands/repo.js +47 -2
- package/dist/commands/ssh.js +103 -2
- package/dist/commands/teams.d.ts +11 -0
- package/dist/commands/teams.js +40 -2
- package/dist/commands/view.d.ts +1 -0
- package/dist/commands/view.js +12 -8
- package/dist/lib/agents.d.ts +11 -5
- package/dist/lib/agents.js +35 -24
- package/dist/lib/browser/profiles.js +4 -25
- package/dist/lib/cli-entry.d.ts +23 -0
- package/dist/lib/cli-entry.js +116 -0
- package/dist/lib/daemon.d.ts +2 -2
- package/dist/lib/daemon.js +8 -89
- package/dist/lib/devices/fleet.d.ts +23 -0
- package/dist/lib/devices/fleet.js +38 -0
- package/dist/lib/devices/health-report.d.ts +46 -0
- package/dist/lib/devices/health-report.js +159 -0
- package/dist/lib/devices/health.d.ts +14 -4
- package/dist/lib/devices/health.js +49 -8
- package/dist/lib/exec.d.ts +15 -0
- package/dist/lib/exec.js +31 -6
- package/dist/lib/git.d.ts +10 -0
- package/dist/lib/git.js +23 -0
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/ready.js +5 -3
- package/dist/lib/hosts/remote-cmd.d.ts +12 -0
- package/dist/lib/hosts/remote-cmd.js +17 -1
- package/dist/lib/mcp.d.ts +21 -0
- package/dist/lib/mcp.js +278 -13
- package/dist/lib/resources/mcp.js +20 -342
- package/dist/lib/routines.d.ts +13 -0
- package/dist/lib/routines.js +21 -0
- package/dist/lib/runner.js +24 -47
- package/dist/lib/secrets/agent.js +11 -15
- package/dist/lib/share/capture.js +21 -3
- package/dist/lib/signin-badge.d.ts +41 -0
- package/dist/lib/signin-badge.js +64 -0
- package/dist/lib/ssh-exec.d.ts +5 -0
- package/dist/lib/ssh-exec.js +55 -1
- package/dist/lib/types.d.ts +10 -0
- package/dist/lib/usage.d.ts +17 -3
- package/dist/lib/usage.js +63 -16
- package/package.json +1 -1
package/dist/lib/daemon.d.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* (macOS), systemd (Linux), or as a plain detached process. PID tracking,
|
|
7
7
|
* log output, reload (SIGHUP), and graceful shutdown are handled here.
|
|
8
8
|
*/
|
|
9
|
+
import { getAgentsBinPath } from './cli-entry.js';
|
|
9
10
|
/** Read the stored daemon PID from disk. Returns null if not present or invalid. */
|
|
10
11
|
export declare function readDaemonPid(): number | null;
|
|
11
12
|
/** Write the daemon PID to the pid file. */
|
|
@@ -101,7 +102,7 @@ export declare function generateLaunchdPlist(agentsBin?: string): string;
|
|
|
101
102
|
* store and never touches the unit file.
|
|
102
103
|
*/
|
|
103
104
|
export declare function generateSystemdUnit(agentsBin?: string): string;
|
|
104
|
-
export
|
|
105
|
+
export { getAgentsBinPath };
|
|
105
106
|
/** Start the daemon via launchd, systemd, or as a detached process. */
|
|
106
107
|
export declare function startDaemon(agentsBin?: string): {
|
|
107
108
|
pid: number | null;
|
|
@@ -201,4 +202,3 @@ export declare function getDaemonStatus(): {
|
|
|
201
202
|
export declare function readDaemonLog(lines?: number): string;
|
|
202
203
|
/** Send SIGHUP to the daemon to trigger a job reload. */
|
|
203
204
|
export declare function signalDaemonReload(): boolean;
|
|
204
|
-
export {};
|
package/dist/lib/daemon.js
CHANGED
|
@@ -21,6 +21,7 @@ import { BrowserService } from './browser/service.js';
|
|
|
21
21
|
import { BrowserIPCServer } from './browser/ipc.js';
|
|
22
22
|
import { readAndResolveBundleEnv } from './secrets/bundles.js';
|
|
23
23
|
import { redactSecrets } from './redact.js';
|
|
24
|
+
import { getAgentsBinPath, getCliLaunch, BUN_VIRTUAL_ROOT } from './cli-entry.js';
|
|
24
25
|
const PID_FILE = 'daemon.pid';
|
|
25
26
|
const LOCK_FILE = 'daemon.lock';
|
|
26
27
|
const LOG_FILE = 'logs.jsonl';
|
|
@@ -781,47 +782,11 @@ Environment=PATH=${daemonNodeBinDir()}:/usr/local/bin:/usr/bin:/bin
|
|
|
781
782
|
[Install]
|
|
782
783
|
WantedBy=default.target`;
|
|
783
784
|
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
throw new Error(`Cannot resolve agents CLI: Bun standalone executable not found at ${execPath || '(empty path)'}`);
|
|
790
|
-
}
|
|
791
|
-
return execPath;
|
|
792
|
-
}
|
|
793
|
-
export function getAgentsBinPath(argv1 = process.argv[1], execPath = process.execPath) {
|
|
794
|
-
// Prefer the binary actively executing this code. `which agents` returns
|
|
795
|
-
// whatever happens to be first on PATH, which means a side-by-side dev
|
|
796
|
-
// build at ~/.local/bin would silently spawn the registry-installed
|
|
797
|
-
// daemon and run stale code. For a JS install, process.argv[1] is the
|
|
798
|
-
// absolute entrypoint the user actually invoked. A Bun standalone instead
|
|
799
|
-
// exposes its embedded /$bunfs/root entry at argv[1] and its physical signed
|
|
800
|
-
// executable at process.execPath; Bun reports both as existing paths.
|
|
801
|
-
const runningEntry = argv1 ? resolveBunStandaloneEntry(argv1, execPath) : undefined;
|
|
802
|
-
if (runningEntry && fs.existsSync(runningEntry)) {
|
|
803
|
-
// The package's browser/computer entrypoints are sibling shims without a
|
|
804
|
-
// `daemon` command. A daemon started as their IPC side effect must launch
|
|
805
|
-
// through the main agents entrypoint instead of replaying the shim path.
|
|
806
|
-
const entryName = path.basename(runningEntry);
|
|
807
|
-
const compiledShim = /^(browser|computer)\.(c|m)?js$/.test(entryName);
|
|
808
|
-
const installedShim = /^(browser|computer)$/.test(entryName);
|
|
809
|
-
if (compiledShim || installedShim) {
|
|
810
|
-
const agentsEntry = path.join(path.dirname(runningEntry), compiledShim ? 'index.js' : 'agents');
|
|
811
|
-
if (!fs.existsSync(agentsEntry)) {
|
|
812
|
-
throw new Error(`Cannot start agents daemon: main CLI entry not found at ${agentsEntry}`);
|
|
813
|
-
}
|
|
814
|
-
return agentsEntry;
|
|
815
|
-
}
|
|
816
|
-
return runningEntry;
|
|
817
|
-
}
|
|
818
|
-
try {
|
|
819
|
-
return execFileSync('which', ['agents'], { encoding: 'utf-8' }).trim();
|
|
820
|
-
}
|
|
821
|
-
catch {
|
|
822
|
-
return 'agents';
|
|
823
|
-
}
|
|
824
|
-
}
|
|
785
|
+
// Binary-resolution helpers (getAgentsBinPath / isNodeScriptEntry / getCliLaunch)
|
|
786
|
+
// live in ./cli-entry.js — a leaf module the secrets broker also imports without
|
|
787
|
+
// forming a cycle. Re-exported so existing `from './daemon.js'` importers of
|
|
788
|
+
// getAgentsBinPath keep resolving.
|
|
789
|
+
export { getAgentsBinPath };
|
|
825
790
|
/**
|
|
826
791
|
* Ask the service manager for the daemon's live PID. Used as a fallback when
|
|
827
792
|
* the daemon hasn't yet written its pid file but launchd/systemd already report
|
|
@@ -980,49 +945,7 @@ export function getDaemonLaunch(agentsBin = getAgentsBinPath()) {
|
|
|
980
945
|
const { warnings } = validateDaemonBinary(agentsBin);
|
|
981
946
|
for (const w of warnings)
|
|
982
947
|
process.stderr.write(`[agents] ${w}\n`);
|
|
983
|
-
|
|
984
|
-
return { command: process.execPath, args: [agentsBin, 'daemon', '_run'] };
|
|
985
|
-
}
|
|
986
|
-
return { command: agentsBin, args: ['daemon', '_run'] };
|
|
987
|
-
}
|
|
988
|
-
/**
|
|
989
|
-
* A daemon entry must be launched through the Node runtime when it is a Node
|
|
990
|
-
* script — a `.js`/`.cjs`/`.mjs` file, OR a symlink/extension-less shim whose
|
|
991
|
-
* shebang names `node`. Package installs link `bin/agents` to a `dist/index.js`
|
|
992
|
-
* (a symlink) or drop an extension-less `#!/usr/bin/env node` shim, so an
|
|
993
|
-
* extension check alone misses them and they get run directly. Executing such an
|
|
994
|
-
* entry then relies on the shebang resolving `node` off the daemon's PATH — and
|
|
995
|
-
* when that PATH points at a pruned nvm version (or an ancient system node), the
|
|
996
|
-
* daemon crash-loops at import (`node:util` has no `styleText` on Node 18). A
|
|
997
|
-
* real compiled binary (Mach-O/ELF/PE) has no `#!node` shebang, so it takes the
|
|
998
|
-
* direct branch and owns its own runtime resolution.
|
|
999
|
-
*/
|
|
1000
|
-
function isNodeScriptEntry(agentsBin) {
|
|
1001
|
-
let resolved = agentsBin;
|
|
1002
|
-
try {
|
|
1003
|
-
resolved = fs.realpathSync(agentsBin);
|
|
1004
|
-
}
|
|
1005
|
-
catch {
|
|
1006
|
-
// Unresolvable (e.g. a template path that does not exist on this box): fall
|
|
1007
|
-
// back to the extension check on the path as given.
|
|
1008
|
-
}
|
|
1009
|
-
if (/\.(c|m)?js$/.test(resolved))
|
|
1010
|
-
return true;
|
|
1011
|
-
try {
|
|
1012
|
-
const fd = fs.openSync(resolved, 'r');
|
|
1013
|
-
try {
|
|
1014
|
-
const buf = Buffer.alloc(128);
|
|
1015
|
-
const n = fs.readSync(fd, buf, 0, 128, 0);
|
|
1016
|
-
const firstLine = buf.toString('utf-8', 0, n).split('\n', 1)[0];
|
|
1017
|
-
return firstLine.startsWith('#!') && /\bnode\b/.test(firstLine);
|
|
1018
|
-
}
|
|
1019
|
-
finally {
|
|
1020
|
-
fs.closeSync(fd);
|
|
1021
|
-
}
|
|
1022
|
-
}
|
|
1023
|
-
catch {
|
|
1024
|
-
return false;
|
|
1025
|
-
}
|
|
948
|
+
return getCliLaunch(['daemon', '_run'], agentsBin);
|
|
1026
949
|
}
|
|
1027
950
|
/**
|
|
1028
951
|
* The directory of the Node runtime that generated this service manifest, kept
|
|
@@ -1050,11 +973,7 @@ function daemonNodeBinDir() {
|
|
|
1050
973
|
* getAgentsBinPath() resolves that virtual entry to the physical process.execPath.
|
|
1051
974
|
*/
|
|
1052
975
|
export function getAgentsInvocation(subArgs, agentsBin = getAgentsBinPath()) {
|
|
1053
|
-
|
|
1054
|
-
if (/\.(c|m)?js$/.test(resolvedBin)) {
|
|
1055
|
-
return { command: process.execPath, args: [resolvedBin, ...subArgs] };
|
|
1056
|
-
}
|
|
1057
|
-
return { command: resolvedBin, args: subArgs };
|
|
976
|
+
return getCliLaunch(subArgs, agentsBin);
|
|
1058
977
|
}
|
|
1059
978
|
export function validateDaemonBinary(binPath) {
|
|
1060
979
|
const warnings = [];
|
|
@@ -24,6 +24,17 @@ export interface FleetRunResult {
|
|
|
24
24
|
/** Truncated combined stderr/stdout for failures. */
|
|
25
25
|
detail?: string;
|
|
26
26
|
}
|
|
27
|
+
export interface FanOutDeviceTarget {
|
|
28
|
+
name: string;
|
|
29
|
+
skip?: FleetSkipReason | string;
|
|
30
|
+
}
|
|
31
|
+
export interface FanOutDeviceResult<T> {
|
|
32
|
+
name: string;
|
|
33
|
+
status: 'ok' | 'failed' | 'skipped';
|
|
34
|
+
value?: T;
|
|
35
|
+
error?: string;
|
|
36
|
+
reason?: FleetSkipReason | string;
|
|
37
|
+
}
|
|
27
38
|
/**
|
|
28
39
|
* Classify each registered device for a fleet operation.
|
|
29
40
|
*
|
|
@@ -34,6 +45,16 @@ export interface FleetRunResult {
|
|
|
34
45
|
* it has a registry address — same path as any other box).
|
|
35
46
|
*/
|
|
36
47
|
export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
|
|
48
|
+
/**
|
|
49
|
+
* Remote fan-out targets for the fleet health/drift gates (`fleet status`,
|
|
50
|
+
* `check --devices`): every planned device except this machine and control-only
|
|
51
|
+
* cockpits. A control device never runs agents (mirrors doctor's fan-out, which
|
|
52
|
+
* drops it via `isControlDevice`), so counting it as unreachable/drift would make
|
|
53
|
+
* the CI gate fail on every run for a fleet that merely has a cockpit registered.
|
|
54
|
+
* Offline / no-address devices are kept — those are genuine faults a gate should
|
|
55
|
+
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
56
|
+
*/
|
|
57
|
+
export declare function remoteFleetTargets(planned: FleetTarget[], self: string): FleetTarget[];
|
|
37
58
|
/** Human label for a skip reason. */
|
|
38
59
|
export declare function skipLabel(reason: FleetSkipReason): string;
|
|
39
60
|
/**
|
|
@@ -61,3 +82,5 @@ export declare function upgradeCommand(version?: string): string[];
|
|
|
61
82
|
* the runner are recorded as `failed` so one bad device never aborts the rest.
|
|
62
83
|
*/
|
|
63
84
|
export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
|
|
85
|
+
/** Run one async probe per device in parallel, preserving input order. */
|
|
86
|
+
export declare function fanOutDevices<T, Target extends FanOutDeviceTarget = FanOutDeviceTarget>(targets: Target[], probe: (target: Target) => Promise<T>): Promise<FanOutDeviceResult<T>[]>;
|
|
@@ -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
|
|
6
|
-
*
|
|
7
|
-
* degrades to "no stats" instead
|
|
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
|
-
/**
|
|
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
|
|
6
|
-
*
|
|
7
|
-
* degrades to "no stats" instead
|
|
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
|
-
|
|
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, {
|
|
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/exec.d.ts
CHANGED
|
@@ -52,6 +52,21 @@ export declare function headlessPlanStallCommand(args: {
|
|
|
52
52
|
* explicitly asked to bypass permissions; pretending we did is unsafe.
|
|
53
53
|
*/
|
|
54
54
|
export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
|
|
55
|
+
/**
|
|
56
|
+
* Resolve a requested mode for a run, honoring whether the run is HEADLESS.
|
|
57
|
+
*
|
|
58
|
+
* Wraps resolveMode with one extra rule: an agent may list `plan` in its modes
|
|
59
|
+
* (so interactive plan works) yet declare `capabilities.headlessPlan === false`
|
|
60
|
+
* because plan is broken in a headless `--prompt`/`-p` run — kimi refuses
|
|
61
|
+
* `--prompt` + `--plan`, and grok's `--permission-mode plan` silently stalls at
|
|
62
|
+
* its ExitPlanMode gate. For those agents, a headless plan request degrades to
|
|
63
|
+
* `auto` (kimi -p auto-runs; grok maps auto→edit via resolveMode) with a visible
|
|
64
|
+
* one-line stderr warning, mirroring the graceful plan→edit degrade cursor and
|
|
65
|
+
* antigravity get for having no plan flag at all. Interactive runs are never
|
|
66
|
+
* downgraded. This is the single source of truth shared by buildExecCommand
|
|
67
|
+
* (agents run / teams) and the routine runner.
|
|
68
|
+
*/
|
|
69
|
+
export declare function resolveHeadlessMode(agent: AgentId, requested: Mode, interactive: boolean): Mode;
|
|
55
70
|
/**
|
|
56
71
|
* The mode an agent should run in when the caller has no preference.
|
|
57
72
|
*
|