@phnx-labs/agents-cli 1.22.26 → 1.22.28
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 +293 -0
- package/README.md +80 -27
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +71 -26
- package/dist/commands/hosts.js +4 -3
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/perf.d.ts +10 -0
- package/dist/commands/perf.js +14 -6
- package/dist/commands/projects.js +24 -10
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +31 -4
- package/dist/commands/run-account-picker.d.ts +37 -0
- package/dist/commands/run-account-picker.js +101 -17
- package/dist/commands/secrets.js +86 -2
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +73 -7
- package/dist/commands/sessions.js +391 -49
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +22 -11
- package/dist/index.js +16 -20
- package/dist/lib/activity.js +45 -99
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +56 -3
- package/dist/lib/cloud/host.js +1 -0
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/resolve-target.d.ts +1 -0
- package/dist/lib/devices/resolve-target.js +9 -2
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +2 -2
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/git.d.ts +9 -3
- package/dist/lib/git.js +39 -22
- package/dist/lib/hooks/profile.d.ts +12 -1
- package/dist/lib/hooks/profile.js +6 -1
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +14 -7
- package/dist/lib/hosts/dispatch.d.ts +10 -0
- package/dist/lib/hosts/dispatch.js +85 -25
- package/dist/lib/hosts/logs.js +11 -1
- package/dist/lib/hosts/passthrough.d.ts +27 -0
- package/dist/lib/hosts/passthrough.js +19 -10
- package/dist/lib/hosts/progress.d.ts +12 -1
- package/dist/lib/hosts/progress.js +41 -8
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +45 -4
- package/dist/lib/hosts/ready.js +124 -20
- package/dist/lib/hosts/reconcile.d.ts +2 -1
- package/dist/lib/hosts/reconcile.js +24 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +94 -36
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +4 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +3 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/menubar/snapshot.js +15 -2
- package/dist/lib/models.d.ts +42 -6
- package/dist/lib/models.js +55 -153
- package/dist/lib/perf/db.js +16 -3
- package/dist/lib/perf/types.d.ts +12 -1
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/project-probe.d.ts +22 -3
- package/dist/lib/project-probe.js +105 -17
- package/dist/lib/project-status.d.ts +9 -0
- package/dist/lib/project-status.js +15 -0
- package/dist/lib/redact.js +8 -3
- package/dist/lib/refresh.js +22 -11
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/rotate.d.ts +22 -0
- package/dist/lib/rotate.js +26 -0
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.d.ts +19 -3
- package/dist/lib/secrets/agent.js +61 -22
- package/dist/lib/secrets/audit.d.ts +1 -1
- package/dist/lib/secrets/audit.js +2 -0
- package/dist/lib/secrets/bundles.d.ts +3 -3
- package/dist/lib/secrets/bundles.js +15 -5
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +4 -0
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/secrets/session-store.d.ts +7 -0
- package/dist/lib/secrets/session-store.js +21 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/active.d.ts +49 -0
- package/dist/lib/session/active.js +175 -11
- package/dist/lib/session/bash-command.d.ts +16 -0
- package/dist/lib/session/bash-command.js +65 -0
- package/dist/lib/session/db.d.ts +23 -2
- package/dist/lib/session/db.js +86 -20
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-bundle.js +1 -1
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/staleness/detectors/skills.d.ts +2 -0
- package/dist/lib/staleness/detectors/skills.js +17 -2
- package/dist/lib/staleness/index.d.ts +10 -1
- package/dist/lib/staleness/index.js +28 -3
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/agents.d.ts +1 -0
- package/dist/lib/teams/agents.js +38 -11
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/teams/remoteWorktree.d.ts +11 -7
- package/dist/lib/teams/remoteWorktree.js +29 -27
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/dist/lib/versions.d.ts +1 -0
- package/dist/lib/versions.js +40 -22
- package/package.json +3 -1
|
@@ -135,6 +135,21 @@ export function liveDeadSplit(byStatus) {
|
|
|
135
135
|
deadByStatus.sort((a, b) => b.n - a.n || a.status.localeCompare(b.status));
|
|
136
136
|
return { live, dead, deadByStatus };
|
|
137
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* The `dead` row body: `41 crashed` when every dead session shares one status,
|
|
140
|
+
* else `12 finished or lost (8 crashed, 4 closed)`. The generic "finished or
|
|
141
|
+
* lost" only earns its keep when the statuses actually differ — with a single
|
|
142
|
+
* status it just hides which one behind a parenthetical that repeats the count.
|
|
143
|
+
* Pure — chalk styling only; the caller adds the `dead` label. Assumes
|
|
144
|
+
* `split.dead > 0` (the caller gates on it).
|
|
145
|
+
*/
|
|
146
|
+
export function formatDeadSummary(split) {
|
|
147
|
+
if (split.deadByStatus.length === 1) {
|
|
148
|
+
return chalk.yellow(`${split.dead} ${split.deadByStatus[0].status}`);
|
|
149
|
+
}
|
|
150
|
+
const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
|
|
151
|
+
return `${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`;
|
|
152
|
+
}
|
|
138
153
|
/**
|
|
139
154
|
* Display order for the members line: the states a human scans for first
|
|
140
155
|
* (running, then idle, then need-input, then queued), everything else after,
|
package/dist/lib/redact.js
CHANGED
|
@@ -13,9 +13,14 @@ const SECRET_PATTERNS = [
|
|
|
13
13
|
[/\bghp_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
14
14
|
[/\bgh[osru]_[A-Za-z0-9]{36}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
15
15
|
[/\bgithub_pat_[A-Za-z0-9_]{22,}\b/g, '[REDACTED_GITHUB_TOKEN]'],
|
|
16
|
-
// Anthropic
|
|
17
|
-
//
|
|
18
|
-
|
|
16
|
+
// Anthropic credentials, before the generic sk- rule so the marker is specific
|
|
17
|
+
// (the generic rule would otherwise swallow them first). Covers every kind of
|
|
18
|
+
// sk-ant token by matching the kind segment generically: API keys
|
|
19
|
+
// (sk-ant-api03-…) AND the long-lived OAuth setup-tokens (sk-ant-oat01-…) that
|
|
20
|
+
// `claude setup-token` mints. The generic sk- rule below CANNOT match an oat01
|
|
21
|
+
// token — the hyphen after `ant` breaks its [A-Za-z0-9]{20,} run — so without
|
|
22
|
+
// this an OAuth setup-token leaks verbatim into any log/export (#1767).
|
|
23
|
+
[/\bsk-ant-[a-z0-9]{3,8}-[A-Za-z0-9_-]{8,}\b/g, '[REDACTED_ANTHROPIC_KEY]'],
|
|
19
24
|
// Stripe live secret / restricted keys.
|
|
20
25
|
[/\b[rs]k_live_[A-Za-z0-9]{20,}\b/g, '[REDACTED_STRIPE_KEY]'],
|
|
21
26
|
[/\bsk-[A-Za-z0-9]{20,}\b/g, '[REDACTED_API_KEY]'],
|
package/dist/lib/refresh.js
CHANGED
|
@@ -135,15 +135,24 @@ export async function refresh(options = {}) {
|
|
|
135
135
|
const versionsToSync = skipPrompts
|
|
136
136
|
? listInstalledVersions(agentId)
|
|
137
137
|
: [defaultVer];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
actuallySynced
|
|
138
|
+
// Interactive-only: getActuallySyncedResources walks every skill tree with
|
|
139
|
+
// content compares (~1s/agent on a full install). The unattended path
|
|
140
|
+
// (`skipPrompts` / `agents sync --yes`) never reads these — it always
|
|
141
|
+
// force-full-syncs — so skip the scan entirely (RUSH-2320 #1).
|
|
142
|
+
let actuallySynced;
|
|
143
|
+
let newResources;
|
|
144
|
+
let hasAnySynced = false;
|
|
145
|
+
if (!skipPrompts) {
|
|
146
|
+
actuallySynced = getActuallySyncedResources(agentId, defaultVer);
|
|
147
|
+
newResources = getNewResources(available, actuallySynced, getProjectOnlyResources());
|
|
148
|
+
hasAnySynced = actuallySynced.commands.length > 0 ||
|
|
149
|
+
actuallySynced.skills.length > 0 ||
|
|
150
|
+
actuallySynced.hooks.length > 0 ||
|
|
151
|
+
actuallySynced.memory.length > 0 ||
|
|
152
|
+
actuallySynced.mcp.length > 0 ||
|
|
153
|
+
actuallySynced.permissions.length > 0 ||
|
|
154
|
+
actuallySynced.plugins.length > 0;
|
|
155
|
+
}
|
|
147
156
|
try {
|
|
148
157
|
let selection;
|
|
149
158
|
let forceFullSync = false;
|
|
@@ -156,7 +165,7 @@ export async function refresh(options = {}) {
|
|
|
156
165
|
if (userSelection)
|
|
157
166
|
selection = userSelection;
|
|
158
167
|
}
|
|
159
|
-
else if (hasNewResources(newResources, agentId, defaultVer)) {
|
|
168
|
+
else if (newResources && hasNewResources(newResources, agentId, defaultVer)) {
|
|
160
169
|
log(chalk.cyan(`\n${agentLabel(agentId)}@${defaultVer}:`));
|
|
161
170
|
const userSelection = await promptNewResourceSelection(agentId, newResources, defaultVer);
|
|
162
171
|
if (userSelection)
|
|
@@ -168,7 +177,9 @@ export async function refresh(options = {}) {
|
|
|
168
177
|
if (forceFullSync || (selection && Object.keys(selection).length > 0)) {
|
|
169
178
|
const kinds = new Set();
|
|
170
179
|
for (const ver of versionsToSync) {
|
|
171
|
-
|
|
180
|
+
// Pass the already-built `available` so each version does not re-scan
|
|
181
|
+
// resource trees (RUSH-2320 #5).
|
|
182
|
+
const syncResult = syncResourcesToVersion(agentId, ver, selection, { available, ...(forceFullSync ? { force: true } : {}) });
|
|
172
183
|
if (syncResult.commands)
|
|
173
184
|
kinds.add('commands');
|
|
174
185
|
if (syncResult.skills)
|
|
@@ -34,6 +34,7 @@ export interface RemoteAgentsJsonOptions<T> {
|
|
|
34
34
|
export type SshCaptureFn = (target: string, remoteCmd: string, opts: {
|
|
35
35
|
timeoutMs: number;
|
|
36
36
|
signal?: AbortSignal;
|
|
37
|
+
extraSshArgs?: string[];
|
|
37
38
|
}) => Promise<{
|
|
38
39
|
code: number | null;
|
|
39
40
|
stdout: string;
|
|
@@ -62,5 +63,36 @@ export declare function parseRemoteAgentsJsonPayload<T>(stdout: string, machine:
|
|
|
62
63
|
};
|
|
63
64
|
/** Build the command one peer runs, with a guard that prevents recursive fan-out. */
|
|
64
65
|
export declare function remoteAgentsJsonCommand(args: string[], noFanoutEnv: string, os?: string): string;
|
|
66
|
+
/**
|
|
67
|
+
* The subset of a spawned `ssh` child this capture loop drives. Structural so a
|
|
68
|
+
* unit test can feed synthetic `data`/`close` events through a fake without a
|
|
69
|
+
* live SSH connection.
|
|
70
|
+
*/
|
|
71
|
+
export interface CapturableChild {
|
|
72
|
+
stdout: {
|
|
73
|
+
on(event: 'data', listener: (chunk: Buffer) => void): unknown;
|
|
74
|
+
} | null;
|
|
75
|
+
on(event: 'error' | 'close', listener: (arg?: number | null) => void): unknown;
|
|
76
|
+
kill(signal?: NodeJS.Signals): unknown;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Stream one child's stdout into memory under a hard per-peer byte ceiling.
|
|
80
|
+
*
|
|
81
|
+
* A cross-machine fan-out awaits every peer's capture under one `Promise.all`,
|
|
82
|
+
* so an unbounded buffer means a single runaway peer (a corrupt or
|
|
83
|
+
* pathologically large payload) exhausts the caller's heap and takes the whole
|
|
84
|
+
* sweep down — RUSH-2065. Once the accumulated bytes would exceed
|
|
85
|
+
* {@link REMOTE_STDOUT_MAX_BYTES}, the child is SIGKILLed and the capture settles
|
|
86
|
+
* as `code: null` (unreachable) rather than trust a truncated body. A definitive
|
|
87
|
+
* early-exit `signal` SIGTERMs the child the same way, and the timer is the
|
|
88
|
+
* per-peer deadline.
|
|
89
|
+
*/
|
|
90
|
+
export declare function captureBoundedStdout(child: CapturableChild, { timeoutMs, signal }: {
|
|
91
|
+
timeoutMs: number;
|
|
92
|
+
signal?: AbortSignal;
|
|
93
|
+
}): Promise<{
|
|
94
|
+
code: number | null;
|
|
95
|
+
stdout: string;
|
|
96
|
+
}>;
|
|
65
97
|
/** Query explicit hosts, or every registered online peer when hosts is omitted. */
|
|
66
98
|
export declare function gatherRemoteAgentsJson<T>(options: RemoteAgentsJsonOptions<T>, deps?: GatherRemoteAgentsJsonDeps): Promise<RemoteAgentsJsonResult<T>>;
|
|
@@ -9,18 +9,20 @@
|
|
|
9
9
|
import { spawn } from 'child_process';
|
|
10
10
|
import { setMaxListeners } from 'node:events';
|
|
11
11
|
import chalk from 'chalk';
|
|
12
|
-
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from './ssh-exec.js';
|
|
13
|
-
import { sshTargetFor } from './devices/connect.js';
|
|
12
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from './ssh-exec.js';
|
|
13
|
+
import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
|
|
14
14
|
import { resolveExplicitTargets } from './devices/resolve-target.js';
|
|
15
15
|
import { loadDevices, isControlDevice, isDialableDevice } from './devices/registry.js';
|
|
16
|
-
import { remoteShellFor, buildWindowsAgentsCommand } from './hosts/remote-cmd.js';
|
|
16
|
+
import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from './hosts/remote-cmd.js';
|
|
17
17
|
import { machineId, normalizeHost } from './machine-id.js';
|
|
18
18
|
const REMOTE_TIMEOUT_MS = 12_000;
|
|
19
19
|
export function normalizeRemoteAgentsJsonParse(parsed) {
|
|
20
20
|
return Array.isArray(parsed) ? { items: parsed, valid: true } : parsed;
|
|
21
21
|
}
|
|
22
22
|
export function parseRemoteAgentsJsonPayload(stdout, machine, parse) {
|
|
23
|
-
|
|
23
|
+
// A Windows peer can prefix the JSON with a PowerShell CLIXML banner; strip it
|
|
24
|
+
// before handing stdout to the command-specific parser (RUSH-2286).
|
|
25
|
+
const parsed = normalizeRemoteAgentsJsonParse(parse(stripClixml(stdout), machine));
|
|
24
26
|
return parsed.valid
|
|
25
27
|
? { items: parsed.items, parseFailed: false }
|
|
26
28
|
: { items: [], parseFailed: true };
|
|
@@ -33,16 +35,22 @@ export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
|
|
|
33
35
|
const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
|
|
34
36
|
return `bash -lc ${shellQuote(inner)}`;
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Stream one child's stdout into memory under a hard per-peer byte ceiling.
|
|
40
|
+
*
|
|
41
|
+
* A cross-machine fan-out awaits every peer's capture under one `Promise.all`,
|
|
42
|
+
* so an unbounded buffer means a single runaway peer (a corrupt or
|
|
43
|
+
* pathologically large payload) exhausts the caller's heap and takes the whole
|
|
44
|
+
* sweep down — RUSH-2065. Once the accumulated bytes would exceed
|
|
45
|
+
* {@link REMOTE_STDOUT_MAX_BYTES}, the child is SIGKILLed and the capture settles
|
|
46
|
+
* as `code: null` (unreachable) rather than trust a truncated body. A definitive
|
|
47
|
+
* early-exit `signal` SIGTERMs the child the same way, and the timer is the
|
|
48
|
+
* per-peer deadline.
|
|
49
|
+
*/
|
|
50
|
+
export function captureBoundedStdout(child, { timeoutMs, signal }) {
|
|
38
51
|
return new Promise((resolve) => {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
|
|
44
|
-
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
45
|
-
let stdout = '';
|
|
52
|
+
const decoded = new RemoteUtf8Accumulator();
|
|
53
|
+
let stdoutBytes = 0;
|
|
46
54
|
let settled = false;
|
|
47
55
|
const done = (code) => {
|
|
48
56
|
if (settled)
|
|
@@ -50,7 +58,10 @@ const sshCapture = (target, remoteCmd, { timeoutMs, signal }) => {
|
|
|
50
58
|
settled = true;
|
|
51
59
|
clearTimeout(timer);
|
|
52
60
|
signal?.removeEventListener('abort', onAbort);
|
|
53
|
-
|
|
61
|
+
// A killed/aborted capture never cleanly ends its decoder; return what
|
|
62
|
+
// decoded so far (the caller treats a non-zero code as unreachable and
|
|
63
|
+
// ignores stdout anyway).
|
|
64
|
+
resolve({ code, stdout: code === null ? decoded.current() : decoded.end() });
|
|
54
65
|
};
|
|
55
66
|
const timer = setTimeout(() => {
|
|
56
67
|
child.kill('SIGKILL');
|
|
@@ -60,10 +71,28 @@ const sshCapture = (target, remoteCmd, { timeoutMs, signal }) => {
|
|
|
60
71
|
// unreachable peer is not left running to its full timeout.
|
|
61
72
|
const onAbort = () => { child.kill('SIGTERM'); done(null); };
|
|
62
73
|
signal?.addEventListener('abort', onAbort, { once: true });
|
|
63
|
-
child.stdout
|
|
74
|
+
child.stdout?.on('data', (data) => {
|
|
75
|
+
// Over the ceiling, SIGKILL the child and treat it as unreachable rather
|
|
76
|
+
// than trust a partial payload — the RUSH-2065 OOM guard.
|
|
77
|
+
if (stdoutBytes + data.byteLength > REMOTE_STDOUT_MAX_BYTES) {
|
|
78
|
+
child.kill('SIGKILL');
|
|
79
|
+
done(null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
stdoutBytes += data.byteLength;
|
|
83
|
+
decoded.write(data);
|
|
84
|
+
});
|
|
64
85
|
child.on('error', () => done(null));
|
|
65
|
-
child.on('close', (code) => done(code));
|
|
86
|
+
child.on('close', (code) => done(code ?? null));
|
|
66
87
|
});
|
|
88
|
+
}
|
|
89
|
+
const sshCapture = (target, remoteCmd, { timeoutMs, signal, extraSshArgs }) => {
|
|
90
|
+
assertValidSshTarget(target);
|
|
91
|
+
if (signal?.aborted)
|
|
92
|
+
return Promise.resolve({ code: null, stdout: '' });
|
|
93
|
+
const args = [...SSH_OPTS, ...controlOpts(), ...(extraSshArgs ?? []), target, remoteCmd];
|
|
94
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
95
|
+
return captureBoundedStdout(child, { timeoutMs, signal });
|
|
67
96
|
};
|
|
68
97
|
/** Query explicit hosts, or every registered online peer when hosts is omitted. */
|
|
69
98
|
export async function gatherRemoteAgentsJson(options, deps = {}) {
|
|
@@ -102,6 +131,7 @@ export async function gatherRemoteAgentsJson(options, deps = {}) {
|
|
|
102
131
|
machine: normalizeHost(device.name),
|
|
103
132
|
name: device.name,
|
|
104
133
|
os: device.platform,
|
|
134
|
+
extraSshArgs: deviceIdentityArgs(device),
|
|
105
135
|
});
|
|
106
136
|
}
|
|
107
137
|
catch {
|
|
@@ -126,6 +156,7 @@ export async function gatherRemoteAgentsJson(options, deps = {}) {
|
|
|
126
156
|
const result = await capture(target.target, command, {
|
|
127
157
|
timeoutMs: options.timeoutMs ?? REMOTE_TIMEOUT_MS,
|
|
128
158
|
signal: controller?.signal,
|
|
159
|
+
extraSshArgs: target.extraSshArgs,
|
|
129
160
|
});
|
|
130
161
|
// A peer we deliberately cancelled is neither a hit nor a failure — it never
|
|
131
162
|
// got to answer, so it must not pollute skipped/parseFailed (which drive the
|
|
@@ -41,11 +41,10 @@ export function getActiveResourceProfileName() {
|
|
|
41
41
|
return readMeta().profiles?.active ?? null;
|
|
42
42
|
}
|
|
43
43
|
export function getActiveResourceProfile() {
|
|
44
|
-
const meta = readMeta();
|
|
45
44
|
const name = getActiveResourceProfileName();
|
|
46
45
|
if (!name)
|
|
47
46
|
return null;
|
|
48
|
-
const preset =
|
|
47
|
+
const preset = readMeta().profiles?.presets?.[name];
|
|
49
48
|
return preset ? { name, preset } : null;
|
|
50
49
|
}
|
|
51
50
|
export function upsertResourceProfilePreset(name, preset) {
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -145,6 +145,28 @@ export type AccountReadiness = {
|
|
|
145
145
|
* reported while the account is actually serving requests.
|
|
146
146
|
*/
|
|
147
147
|
export declare function readinessFromCandidate(candidate: RotateCandidate): AccountReadiness;
|
|
148
|
+
/**
|
|
149
|
+
* Whether a human sitting at a terminal can clear this exclusion by launching
|
|
150
|
+
* the agent and signing in. The two unhealthy classes are opposites, and the
|
|
151
|
+
* zero-healthy callers MUST NOT treat them alike:
|
|
152
|
+
*
|
|
153
|
+
* - `signed_out` / `revoked` — recoverable. There is no credential (or the
|
|
154
|
+
* server rejected it), and the harness's own TUI is the login surface, so
|
|
155
|
+
* launching it is exactly the fix. Refusing to launch strands the user with
|
|
156
|
+
* no way to authenticate through agents-cli at all (RUSH-2334).
|
|
157
|
+
* - `rate_limited` / `out_of_credits` — NOT recoverable. The account is signed
|
|
158
|
+
* in and throttled; launching it just hammers an exhausted account, which is
|
|
159
|
+
* precisely the loop RUSH-2132's fail-loud guard exists to stop. Only a
|
|
160
|
+
* window reset clears these.
|
|
161
|
+
*/
|
|
162
|
+
export declare function isSignInRecoverable(readiness: AccountReadiness): boolean;
|
|
163
|
+
/**
|
|
164
|
+
* The subset of an `exhausted` set whose exclusion a sign-in would clear, so an
|
|
165
|
+
* interactive caller can offer the login instead of dead-ending. Empty means
|
|
166
|
+
* every account is throttled — nothing a human can fix right now, so the caller
|
|
167
|
+
* keeps failing loud.
|
|
168
|
+
*/
|
|
169
|
+
export declare function signInRecoverableCandidates(candidates: RotateCandidate[]): RotateCandidate[];
|
|
148
170
|
/**
|
|
149
171
|
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
150
172
|
* when the version isn't among the collected candidates — absence is the
|
package/dist/lib/rotate.js
CHANGED
|
@@ -169,6 +169,32 @@ export function readinessFromCandidate(candidate) {
|
|
|
169
169
|
const reason = !snapRateLimited && candidate.usageStatus === 'out_of_credits' ? 'out_of_credits' : 'rate_limited';
|
|
170
170
|
return { ready: false, reason, email: candidate.email };
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether a human sitting at a terminal can clear this exclusion by launching
|
|
174
|
+
* the agent and signing in. The two unhealthy classes are opposites, and the
|
|
175
|
+
* zero-healthy callers MUST NOT treat them alike:
|
|
176
|
+
*
|
|
177
|
+
* - `signed_out` / `revoked` — recoverable. There is no credential (or the
|
|
178
|
+
* server rejected it), and the harness's own TUI is the login surface, so
|
|
179
|
+
* launching it is exactly the fix. Refusing to launch strands the user with
|
|
180
|
+
* no way to authenticate through agents-cli at all (RUSH-2334).
|
|
181
|
+
* - `rate_limited` / `out_of_credits` — NOT recoverable. The account is signed
|
|
182
|
+
* in and throttled; launching it just hammers an exhausted account, which is
|
|
183
|
+
* precisely the loop RUSH-2132's fail-loud guard exists to stop. Only a
|
|
184
|
+
* window reset clears these.
|
|
185
|
+
*/
|
|
186
|
+
export function isSignInRecoverable(readiness) {
|
|
187
|
+
return !readiness.ready && (readiness.reason === 'signed_out' || readiness.reason === 'revoked');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The subset of an `exhausted` set whose exclusion a sign-in would clear, so an
|
|
191
|
+
* interactive caller can offer the login instead of dead-ending. Empty means
|
|
192
|
+
* every account is throttled — nothing a human can fix right now, so the caller
|
|
193
|
+
* keeps failing loud.
|
|
194
|
+
*/
|
|
195
|
+
export function signInRecoverableCandidates(candidates) {
|
|
196
|
+
return candidates.filter((c) => isSignInRecoverable(readinessFromCandidate(c)));
|
|
197
|
+
}
|
|
172
198
|
/**
|
|
173
199
|
* Readiness for a specific installed (agent, version). Returns `{ ready: true }`
|
|
174
200
|
* when the version isn't among the collected candidates — absence is the
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner phone notification on routine FAILURE (RUSH-2288).
|
|
3
|
+
*
|
|
4
|
+
* The desktop lifecycle notifications (routine-notify.ts) never leave the
|
|
5
|
+
* machine, so a failed scheduled routine on a headless fleet box was invisible
|
|
6
|
+
* until someone happened to look. The per-routine prompt pattern (a prompt that
|
|
7
|
+
* ends by shelling `agents notify`) does not close the gap either: when the
|
|
8
|
+
* routine's OWN agent fails to spawn (`auth_failed`) that prompt never runs, so
|
|
9
|
+
* the one failure most worth surfacing is the one that goes unheard.
|
|
10
|
+
*
|
|
11
|
+
* So the DAEMON delivers on failure, from the SAME owner channel stack `agents
|
|
12
|
+
* notify` uses (humans.yaml owner channels, or the legacy `notify.owner`),
|
|
13
|
+
* calling the channel providers IN-PROCESS — no shelling out to `ssh mac-mini
|
|
14
|
+
* agents notify`. When the primary owner channel cannot deliver from this box it
|
|
15
|
+
* walks the remaining configured owner channels as fallbacks (OpenClaw, a second
|
|
16
|
+
* channel, …). Telegram is never used.
|
|
17
|
+
*
|
|
18
|
+
* Only failures notify. A green routine of any kind stays silent — this is an
|
|
19
|
+
* ADDITIONAL failures-only lane, and the desktop thresholds (which still ping a
|
|
20
|
+
* green agent/workflow finish on the local screen) are unchanged. Delivery is
|
|
21
|
+
* deduped per job+runId so a run is announced to the owner at most once.
|
|
22
|
+
*
|
|
23
|
+
* The message BUILDERS (`routineFinishOwnerText` / `routineStartFailedOwnerText`)
|
|
24
|
+
* are pure and unit-tested; `notifyOwnerRoutineFinish` /
|
|
25
|
+
* `notifyOwnerRoutineStartFailed` are the daemon glue that reads config and
|
|
26
|
+
* delivers.
|
|
27
|
+
*/
|
|
28
|
+
import type { Meta } from './types.js';
|
|
29
|
+
import type { JobConfig, RunMeta } from './routines.js';
|
|
30
|
+
/**
|
|
31
|
+
* Owner-phone text for a routine FINISH, or null when the run did NOT fail. Only
|
|
32
|
+
* `failed` and `timeout` are failures worth an owner ping — a `completed` run
|
|
33
|
+
* (any kind) is silent, and `running`/`missed` never reach this path from the
|
|
34
|
+
* daemon's finish hook. The text is a short, phone-sized pointer: what failed,
|
|
35
|
+
* why, and on which box (so the owner knows where to look), well under the
|
|
36
|
+
* `user-message-guard` length ceiling.
|
|
37
|
+
*/
|
|
38
|
+
export declare function routineFinishOwnerText(meta: Pick<RunMeta, 'jobName' | 'status' | 'exitCode' | 'errorMessage' | 'agent' | 'workflow' | 'command'>, host: string): string | null;
|
|
39
|
+
/**
|
|
40
|
+
* Owner-phone text for a routine that failed to even START (the daemon's
|
|
41
|
+
* pre-spawn catch — `executeJobDetached` threw before a child existed, e.g.
|
|
42
|
+
* `auth_failed`). Never null: a broken start is always a failure worth the ping,
|
|
43
|
+
* for every routine kind, and it is exactly the case the per-routine `agents
|
|
44
|
+
* notify` prompt can never cover because that prompt never ran.
|
|
45
|
+
*/
|
|
46
|
+
export declare function routineStartFailedOwnerText(config: Pick<JobConfig, 'name' | 'agent' | 'workflow' | 'command'>, error: string, host: string): string;
|
|
47
|
+
/** One resolved owner destination to attempt, in plan order. */
|
|
48
|
+
export interface OwnerDest {
|
|
49
|
+
channel: string;
|
|
50
|
+
to: string;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Ordered owner delivery plan for a failure ping: the primary owner destination
|
|
54
|
+
* (the same one `agents notify` resolves) first, then every other configured
|
|
55
|
+
* owner channel as a fallback. Deduped by (channel, to). Telegram channels and
|
|
56
|
+
* intrusive channels (a voice call is too much for a routine failure) are
|
|
57
|
+
* excluded entirely — so an owner whose ONLY channel is Telegram gets an empty
|
|
58
|
+
* plan and no ping, which is the intended "silence beats Telegram" outcome.
|
|
59
|
+
*
|
|
60
|
+
* The same filters apply to the primary and the fallback list — a policy that
|
|
61
|
+
* points normal severity at a voice channel must not auto-call the owner on
|
|
62
|
+
* every routine failure.
|
|
63
|
+
*/
|
|
64
|
+
export declare function ownerFailureDeliveryPlan(meta: Meta): OwnerDest[];
|
|
65
|
+
/** Outcome of one delivery attempt, for logging/telemetry. */
|
|
66
|
+
export interface OwnerDeliveryAttempt {
|
|
67
|
+
channel: string;
|
|
68
|
+
ok: boolean;
|
|
69
|
+
error?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface OwnerDeliveryResult {
|
|
72
|
+
/** True once any channel in the plan accepted the message. */
|
|
73
|
+
delivered: boolean;
|
|
74
|
+
/** The channel that delivered, when `delivered`. */
|
|
75
|
+
channel?: string;
|
|
76
|
+
/** Every attempt made, in order — empty when no owner channel is configured. */
|
|
77
|
+
attempts: OwnerDeliveryAttempt[];
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Deliver `text` to the owner over the failure plan, in-process, trying each
|
|
81
|
+
* channel until one accepts. Uses the non-dying `lookupTransport` (never
|
|
82
|
+
* `resolveTransport`, which `die()`s the process) so a daemon survives a bad
|
|
83
|
+
* channel name. Best-effort by contract: the daemon wraps this in try/catch, but
|
|
84
|
+
* it also never throws for a delivery failure — it returns the attempt log.
|
|
85
|
+
*/
|
|
86
|
+
export declare function deliverOwnerFailure(text: string, meta: Meta): Promise<OwnerDeliveryResult>;
|
|
87
|
+
/** Test-only: reset the dedup set so cases don't leak state into each other. */
|
|
88
|
+
export declare function __resetOwnerFailureDedup(): void;
|
|
89
|
+
/**
|
|
90
|
+
* Daemon glue: on a routine FINISH, ping the owner IFF the run failed. Green
|
|
91
|
+
* runs return early (no ping). Deduped per job+runId — claimed for the
|
|
92
|
+
* in-flight window so two finish hooks don't double-text, released when no
|
|
93
|
+
* channel accepted so a later sweep can retry. Best-effort.
|
|
94
|
+
*/
|
|
95
|
+
export declare function notifyOwnerRoutineFinish(meta: RunMeta): Promise<OwnerDeliveryResult>;
|
|
96
|
+
/**
|
|
97
|
+
* Daemon glue: on a pre-spawn failure (no run record, so no finish hook and no
|
|
98
|
+
* runId), ping the owner. Not deduped — a pre-spawn failure is one scheduled
|
|
99
|
+
* fire on the cron cadence (never a per-second storm), and each such fire is a
|
|
100
|
+
* distinct failure the owner should hear about. Best-effort.
|
|
101
|
+
*/
|
|
102
|
+
export declare function notifyOwnerRoutineStartFailed(config: JobConfig, error: string): Promise<OwnerDeliveryResult>;
|