@phnx-labs/agents-cli 1.22.24 → 1.22.26
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 +350 -1
- package/README.md +31 -6
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/browser.js +35 -0
- package/dist/commands/cloud.js +9 -5
- package/dist/commands/doctor.d.ts +24 -0
- package/dist/commands/doctor.js +108 -9
- package/dist/commands/exec.js +19 -16
- package/dist/commands/feed.d.ts +5 -0
- package/dist/commands/feed.js +21 -1
- package/dist/commands/focus.js +2 -2
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +107 -33
- package/dist/commands/menubar.js +8 -0
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/routines.d.ts +3 -0
- package/dist/commands/routines.js +72 -49
- package/dist/commands/run-cloud.js +1 -1
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-browser.d.ts +1 -1
- package/dist/commands/sessions-browser.js +27 -7
- package/dist/commands/sessions-resume.js +3 -2
- package/dist/commands/sessions.d.ts +13 -1
- package/dist/commands/sessions.js +28 -2
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup-watchdog.js +5 -10
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +106 -47
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -2
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/commands/watchdog.d.ts +3 -4
- package/dist/commands/watchdog.js +26 -66
- package/dist/index.js +37 -2
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +155 -23
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- package/dist/lib/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -0
- package/dist/lib/cloud/cursor.d.ts +79 -0
- package/dist/lib/cloud/cursor.js +228 -0
- package/dist/lib/cloud/registry.js +2 -0
- package/dist/lib/cloud/types.d.ts +7 -2
- package/dist/lib/cloud/types.js +14 -0
- package/dist/lib/crabbox/cli.d.ts +2 -2
- package/dist/lib/crabbox/config.d.ts +7 -8
- package/dist/lib/crabbox/config.js +14 -14
- package/dist/lib/crabbox/lease.d.ts +11 -4
- package/dist/lib/crabbox/lease.js +40 -8
- package/dist/lib/crabbox/setup-copy.d.ts +5 -0
- package/dist/lib/crabbox/setup-copy.js +17 -1
- package/dist/lib/daemon.js +27 -1
- package/dist/lib/device-config.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +13 -1
- package/dist/lib/devices/doctor-findings.js +73 -2
- package/dist/lib/events.d.ts +9 -0
- package/dist/lib/events.js +58 -0
- package/dist/lib/exec.d.ts +3 -3
- package/dist/lib/exec.js +24 -10
- package/dist/lib/feed-outcome.d.ts +3 -0
- package/dist/lib/feed-outcome.js +18 -10
- package/dist/lib/feed.d.ts +4 -0
- package/dist/lib/fleet/apply.d.ts +59 -3
- package/dist/lib/fleet/apply.js +183 -6
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/hooks/cache.js +15 -0
- package/dist/lib/hosts/passthrough.d.ts +44 -0
- package/dist/lib/hosts/passthrough.js +84 -12
- package/dist/lib/hosts/ready.d.ts +2 -0
- package/dist/lib/hosts/ready.js +10 -1
- package/dist/lib/hosts/reconnect.d.ts +14 -12
- package/dist/lib/hosts/reconnect.js +41 -40
- package/dist/lib/mcp.js +5 -1
- 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/snapshot.d.ts +15 -0
- package/dist/lib/menubar/snapshot.js +40 -0
- package/dist/lib/plugins.js +13 -1
- package/dist/lib/resources/mcp.js +3 -0
- package/dist/lib/routine-process-cleanup.d.ts +9 -0
- package/dist/lib/routine-process-cleanup.js +73 -0
- package/dist/lib/routines.js +14 -2
- package/dist/lib/runner.d.ts +0 -3
- package/dist/lib/runner.js +6 -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/bundles.js +1 -20
- package/dist/lib/secrets/filestore.d.ts +2 -0
- package/dist/lib/secrets/filestore.js +13 -0
- package/dist/lib/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/rc-hygiene.d.ts +14 -0
- package/dist/lib/secrets/rc-hygiene.js +14 -1
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/session/active.d.ts +4 -0
- package/dist/lib/session/db.d.ts +21 -3
- package/dist/lib/session/db.js +229 -15
- package/dist/lib/session/discover.d.ts +1 -0
- package/dist/lib/session/discover.js +115 -19
- package/dist/lib/session/insights.d.ts +18 -0
- package/dist/lib/session/insights.js +143 -1
- package/dist/lib/session/remote-list.d.ts +2 -0
- package/dist/lib/session/remote-list.js +1 -0
- package/dist/lib/session/session-cache.d.ts +4 -4
- package/dist/lib/session/session-cache.js +4 -4
- package/dist/lib/session/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- package/dist/lib/shims.js +21 -1
- package/dist/lib/signin-badge.js +2 -0
- package/dist/lib/ssh-exec.js +8 -2
- package/dist/lib/startup/command-registry.d.ts +16 -0
- package/dist/lib/startup/command-registry.js +46 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +76 -8
- package/dist/lib/teams/parsers.d.ts +1 -1
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/versions.js +16 -1
- package/dist/lib/watchdog/service.d.ts +17 -0
- package/dist/lib/watchdog/service.js +39 -0
- package/package.json +1 -1
|
@@ -1,3 +1,26 @@
|
|
|
1
|
+
import type { Headroom } from '../devices/health.js';
|
|
2
|
+
/**
|
|
3
|
+
* Live signal for one pool device, gathered by the caller (SSH probes) and
|
|
4
|
+
* passed into the pure pick. Every field is optional/best-effort: a device with
|
|
5
|
+
* no signal is neither excluded nor preferred — it degrades to the roster-count
|
|
6
|
+
* path. `reachable === false` and `installed === false` are the only two
|
|
7
|
+
* exclusion signals here; `headroom` drives the overload exclusion + the load
|
|
8
|
+
* tier of the rank. See {@link DeviceStats}/{@link headroom} in devices/health.
|
|
9
|
+
*/
|
|
10
|
+
export interface DevicePlacementSignal {
|
|
11
|
+
/** SSH probe answered. `false` → excluded (unreachable). undefined → unknown. */
|
|
12
|
+
reachable?: boolean;
|
|
13
|
+
/** Headroom bucket from load+memory. `'loaded'` → excluded (overloaded). */
|
|
14
|
+
headroom?: Headroom;
|
|
15
|
+
/** Normalized CPU load percent (finer rank tiebreak within a headroom tier). */
|
|
16
|
+
loadPercent?: number;
|
|
17
|
+
/** Memory pressure percent (used with loadPercent for the load cost). */
|
|
18
|
+
memPercent?: number;
|
|
19
|
+
/** Requested agent installed on the device. `false` → excluded (can't run). */
|
|
20
|
+
installed?: boolean;
|
|
21
|
+
/** Requested agent signed in (best-effort). Ranks a signed-in device first. */
|
|
22
|
+
signedIn?: boolean;
|
|
23
|
+
}
|
|
1
24
|
/** Team fields the placement cascade reads (a subset of TeamMeta). */
|
|
2
25
|
export interface PlacementTeam {
|
|
3
26
|
devices?: string[];
|
|
@@ -14,7 +37,37 @@ export interface PlacementTeam {
|
|
|
14
37
|
*/
|
|
15
38
|
export interface PlacementOptions {
|
|
16
39
|
maxConcurrent?: Record<string, number>;
|
|
40
|
+
/**
|
|
41
|
+
* Live per-device signals (RUSH-2002). When present, the many-device pick
|
|
42
|
+
* filters unreachable/overloaded/not-installed devices and ranks the rest by
|
|
43
|
+
* health + harness + load. Absent → the pick stays a pure roster count.
|
|
44
|
+
*/
|
|
45
|
+
signals?: Map<string, DevicePlacementSignal>;
|
|
46
|
+
/** Human label of the requested agent (e.g. `claude@2.1.112`) for the
|
|
47
|
+
* fail-loud message. */
|
|
48
|
+
agentLabel?: string;
|
|
49
|
+
}
|
|
50
|
+
/** Why a device was excluded from the viable set, for the fail-loud message. */
|
|
51
|
+
export type ExclusionReason = 'unreachable' | 'overloaded' | 'capped' | 'not-installed';
|
|
52
|
+
/** A pool device dropped from the auto-pick, with the reason + live detail. */
|
|
53
|
+
export interface ExcludedDevice {
|
|
54
|
+
device: string;
|
|
55
|
+
reason: ExclusionReason;
|
|
56
|
+
/** Present for `capped`: `running/cap`. */
|
|
57
|
+
detail?: string;
|
|
17
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* No device in the pool can host the teammate. Carries the per-device reasons so
|
|
61
|
+
* the caller can print exactly why the pool is unusable instead of silently
|
|
62
|
+
* degrading to a local run the user did not ask for.
|
|
63
|
+
*/
|
|
64
|
+
export declare class NoViableDeviceError extends Error {
|
|
65
|
+
readonly excluded: ExcludedDevice[];
|
|
66
|
+
readonly agentLabel?: string;
|
|
67
|
+
constructor(excluded: ExcludedDevice[], agentLabel?: string);
|
|
68
|
+
}
|
|
69
|
+
/** Build the user-facing fail-loud message from the per-device reasons. */
|
|
70
|
+
export declare function formatNoViableMessage(excluded: ExcludedDevice[], agentLabel?: string): string;
|
|
18
71
|
/**
|
|
19
72
|
* A roster entry the load counter reads — the shape any teammate satisfies
|
|
20
73
|
* (AgentProcess included). `status` is compared against `'running'` (the
|
|
@@ -45,11 +98,31 @@ export declare function cappedDevices(devices: string[], roster: RosterEntry[],
|
|
|
45
98
|
* teammate silently landing on a machine its operator capped.
|
|
46
99
|
*/
|
|
47
100
|
export declare function pickLeastLoaded(devices: string[], roster: RosterEntry[], maxConcurrent?: Record<string, number>): string;
|
|
101
|
+
/**
|
|
102
|
+
* Split the pool into the viable set and the excluded set (with reasons), from
|
|
103
|
+
* the caps + live signals. Pure — the caller decides what to do with an empty
|
|
104
|
+
* viable set. Reasons are checked in a stable order (unreachable → not-installed
|
|
105
|
+
* → overloaded → capped) so a device reports its most fundamental blocker first.
|
|
106
|
+
*/
|
|
107
|
+
export declare function classifyExclusions(devices: string[], roster: RosterEntry[], opts?: PlacementOptions): {
|
|
108
|
+
eligible: string[];
|
|
109
|
+
excluded: ExcludedDevice[];
|
|
110
|
+
};
|
|
111
|
+
/**
|
|
112
|
+
* Health-, harness-, and load-aware pick over a pool (RUSH-2002). Filters
|
|
113
|
+
* unreachable / overloaded / capped / not-installed devices, then ranks the
|
|
114
|
+
* survivors by: (a) agent signed in, (b) lower load (headroom tier, then raw
|
|
115
|
+
* load cost), (c) fewer running teammates, ties broken by pool order. Throws
|
|
116
|
+
* {@link NoViableDeviceError} when nothing survives — never returns a local
|
|
117
|
+
* fallback. Pure: all I/O is pre-resolved into `opts.signals`.
|
|
118
|
+
*/
|
|
119
|
+
export declare function pickBestDevice(devices: string[], roster: RosterEntry[], opts?: PlacementOptions): string;
|
|
48
120
|
/**
|
|
49
121
|
* Resolve where a teammate runs. Returns `{ device: null }` for a local run
|
|
50
122
|
* (no pin, no pool, or the chosen device is the local machine) and
|
|
51
123
|
* `{ device: <name> }` for a remote placement. See the cascade in the module
|
|
52
|
-
* header.
|
|
124
|
+
* header. Throws {@link NoViableDeviceError} when a required pool has no viable
|
|
125
|
+
* host — the caller surfaces it at `teams start` rather than falling back local.
|
|
53
126
|
*/
|
|
54
127
|
export declare function resolvePlacement(team: PlacementTeam, explicitDevice: string | null, roster: RosterEntry[], opts?: PlacementOptions): {
|
|
55
128
|
device: string | null;
|
|
@@ -8,19 +8,68 @@
|
|
|
8
8
|
*
|
|
9
9
|
* 1. teammate has an explicit `--device` pin → that device
|
|
10
10
|
* 2. else the team pool has exactly one device → that device (whole team)
|
|
11
|
-
* 3. else the team pool has many devices →
|
|
11
|
+
* 3. else the team pool has many devices → best-viable pick
|
|
12
12
|
* 4. else (no pin, no pool) → null == run local
|
|
13
13
|
*
|
|
14
|
-
* Step 3 is cap
|
|
15
|
-
*
|
|
16
|
-
* and
|
|
17
|
-
*
|
|
14
|
+
* Step 3 is cap-, health-, and harness-aware (RUSH-2002). When the caller
|
|
15
|
+
* supplies live {@link DevicePlacementSignal}s (probed reachability, headroom,
|
|
16
|
+
* load/mem, and whether the requested agent is installed + signed in), the pick:
|
|
17
|
+
* - EXCLUDES an unreachable device, an overloaded one (headroom `loaded`), a
|
|
18
|
+
* device at its `agents.max-concurrent` cap, and one the requested agent is
|
|
19
|
+
* not installed on;
|
|
20
|
+
* - RANKS the survivors by (a) agent installed + signed in, (b) lower load /
|
|
21
|
+
* memory (by headroom tier, then raw), (c) fewer running teammates, ties
|
|
22
|
+
* broken by pool order.
|
|
23
|
+
* With no signals it degrades to the original cap-aware least-loaded pick, so
|
|
24
|
+
* the pure roster-count path (and its tests) are unchanged.
|
|
25
|
+
*
|
|
26
|
+
* When every pool device is excluded, the pick FAILS LOUD ({@link
|
|
27
|
+
* NoViableDeviceError}) naming each device's reason — never a silent fall back
|
|
28
|
+
* to local, since the user asked for a remote pool. Pins are the user's own
|
|
29
|
+
* choice and are never second-guessed; a pool of one is respected for
|
|
30
|
+
* load/cap/reachability but still fails loud when the agent is provably not
|
|
31
|
+
* installed there (running it would be futile).
|
|
18
32
|
*
|
|
19
33
|
* A device whose name equals the local machine id is treated as "local" — it
|
|
20
34
|
* resolves to a null placement so the existing local spawn path runs unchanged,
|
|
21
35
|
* letting the local machine participate in a pool as just another member.
|
|
22
36
|
*/
|
|
23
37
|
import { machineId } from '../session/sync/config.js';
|
|
38
|
+
/**
|
|
39
|
+
* No device in the pool can host the teammate. Carries the per-device reasons so
|
|
40
|
+
* the caller can print exactly why the pool is unusable instead of silently
|
|
41
|
+
* degrading to a local run the user did not ask for.
|
|
42
|
+
*/
|
|
43
|
+
export class NoViableDeviceError extends Error {
|
|
44
|
+
excluded;
|
|
45
|
+
agentLabel;
|
|
46
|
+
constructor(excluded, agentLabel) {
|
|
47
|
+
super(formatNoViableMessage(excluded, agentLabel));
|
|
48
|
+
this.name = 'NoViableDeviceError';
|
|
49
|
+
this.excluded = excluded;
|
|
50
|
+
this.agentLabel = agentLabel;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Build the user-facing fail-loud message from the per-device reasons. */
|
|
54
|
+
export function formatNoViableMessage(excluded, agentLabel) {
|
|
55
|
+
const anyNotInstalled = excluded.some((e) => e.reason === 'not-installed');
|
|
56
|
+
const agent = agentLabel ?? 'the requested agent';
|
|
57
|
+
const perDevice = excluded
|
|
58
|
+
.map((e) => (e.detail ? `${e.device} (${e.reason} ${e.detail})` : `${e.device} (${e.reason})`))
|
|
59
|
+
.join(', ');
|
|
60
|
+
// When the whole pool lacks the agent, lead with the harness message the
|
|
61
|
+
// ticket specifies; otherwise summarize the mixed reasons.
|
|
62
|
+
const head = anyNotInstalled
|
|
63
|
+
? `No device in the team pool can run ${agent}.`
|
|
64
|
+
: `No viable device in the team pool for ${agent}.`;
|
|
65
|
+
const anyUnreachable = excluded.some((e) => e.reason === 'unreachable');
|
|
66
|
+
const hint = anyNotInstalled
|
|
67
|
+
? " Run 'agents devices ping' to see which devices have the agent installed + signed in, or add the agent to a pool device."
|
|
68
|
+
: anyUnreachable
|
|
69
|
+
? " Add a device to the pool, raise a cap, or wait for the pool to free up / come back online ('agents devices ping')."
|
|
70
|
+
: ' Add a device to the pool, raise a cap, or bring an overloaded box under load.';
|
|
71
|
+
return `${head} ${perDevice}.${hint}`;
|
|
72
|
+
}
|
|
24
73
|
/** True when `device` names the local machine (case-insensitive). */
|
|
25
74
|
function isLocalDevice(device) {
|
|
26
75
|
return device.toLowerCase() === machineId();
|
|
@@ -97,14 +146,134 @@ export function pickLeastLoaded(devices, roster, maxConcurrent) {
|
|
|
97
146
|
}
|
|
98
147
|
return best;
|
|
99
148
|
}
|
|
149
|
+
/** Load cost for a signal: worst of load% and mem%, or undefined when neither
|
|
150
|
+
* was probed. The "how full is this box" number the finer rank tiebreak reads. */
|
|
151
|
+
function loadCost(s) {
|
|
152
|
+
if (!s)
|
|
153
|
+
return undefined;
|
|
154
|
+
const vals = [s.loadPercent, s.memPercent].filter((v) => typeof v === 'number');
|
|
155
|
+
return vals.length ? Math.max(...vals) : undefined;
|
|
156
|
+
}
|
|
157
|
+
/** Rank a headroom bucket into a load TIER (lower is less loaded). An unprobed
|
|
158
|
+
* device sits between `light` and `busy` — preferred over a known-busy box but
|
|
159
|
+
* not over a known-idle one. `loaded` is excluded before ranking, so it never
|
|
160
|
+
* reaches here. */
|
|
161
|
+
function headroomTier(h) {
|
|
162
|
+
switch (h) {
|
|
163
|
+
case 'idle': return 0;
|
|
164
|
+
case 'light': return 1;
|
|
165
|
+
case 'busy': return 3;
|
|
166
|
+
default: return 2; // 'unknown' / undefined
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Split the pool into the viable set and the excluded set (with reasons), from
|
|
171
|
+
* the caps + live signals. Pure — the caller decides what to do with an empty
|
|
172
|
+
* viable set. Reasons are checked in a stable order (unreachable → not-installed
|
|
173
|
+
* → overloaded → capped) so a device reports its most fundamental blocker first.
|
|
174
|
+
*/
|
|
175
|
+
export function classifyExclusions(devices, roster, opts) {
|
|
176
|
+
const load = loadByDevice(devices, roster);
|
|
177
|
+
const caps = opts?.maxConcurrent ?? {};
|
|
178
|
+
const signals = opts?.signals;
|
|
179
|
+
const eligible = [];
|
|
180
|
+
const excluded = [];
|
|
181
|
+
for (const d of devices) {
|
|
182
|
+
const s = signals?.get(d);
|
|
183
|
+
if (s?.reachable === false) {
|
|
184
|
+
excluded.push({ device: d, reason: 'unreachable' });
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (s?.installed === false) {
|
|
188
|
+
excluded.push({ device: d, reason: 'not-installed' });
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (s?.headroom === 'loaded') {
|
|
192
|
+
excluded.push({ device: d, reason: 'overloaded' });
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
const cap = caps[d];
|
|
196
|
+
if (cap !== undefined && (load.get(d) ?? 0) >= cap) {
|
|
197
|
+
excluded.push({ device: d, reason: 'capped', detail: `${load.get(d) ?? 0}/${cap}` });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
eligible.push(d);
|
|
201
|
+
}
|
|
202
|
+
return { eligible, excluded };
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Health-, harness-, and load-aware pick over a pool (RUSH-2002). Filters
|
|
206
|
+
* unreachable / overloaded / capped / not-installed devices, then ranks the
|
|
207
|
+
* survivors by: (a) agent signed in, (b) lower load (headroom tier, then raw
|
|
208
|
+
* load cost), (c) fewer running teammates, ties broken by pool order. Throws
|
|
209
|
+
* {@link NoViableDeviceError} when nothing survives — never returns a local
|
|
210
|
+
* fallback. Pure: all I/O is pre-resolved into `opts.signals`.
|
|
211
|
+
*/
|
|
212
|
+
export function pickBestDevice(devices, roster, opts) {
|
|
213
|
+
if (devices.length === 0) {
|
|
214
|
+
throw new Error('pickBestDevice called with an empty device pool');
|
|
215
|
+
}
|
|
216
|
+
const load = loadByDevice(devices, roster);
|
|
217
|
+
const { eligible, excluded } = classifyExclusions(devices, roster, opts);
|
|
218
|
+
if (eligible.length === 0) {
|
|
219
|
+
// A pool excluded ONLY for unreachability is a probe MISS / transient infra
|
|
220
|
+
// blip (a worker went briefly unresponsive during `teams start`), not proof
|
|
221
|
+
// the agent can't RUN there. Per the fail-loud contract — fire only on
|
|
222
|
+
// positive evidence (not-installed / saturated), never on a bare
|
|
223
|
+
// reachability miss — degrade to a best-effort roster-count pick so the wave
|
|
224
|
+
// retries and the real error surfaces at SSH dispatch, instead of a hard
|
|
225
|
+
// exit-1 that strands an all-remote pool on a transient blip.
|
|
226
|
+
const onlyUnreachable = excluded.every((e) => e.reason === 'unreachable');
|
|
227
|
+
if (!onlyUnreachable) {
|
|
228
|
+
throw new NoViableDeviceError(excluded, opts?.agentLabel);
|
|
229
|
+
}
|
|
230
|
+
let fallback = devices[0];
|
|
231
|
+
let fallbackLoad = load.get(fallback) ?? 0;
|
|
232
|
+
for (const d of devices) {
|
|
233
|
+
const l = load.get(d) ?? 0;
|
|
234
|
+
if (l < fallbackLoad) {
|
|
235
|
+
fallback = d;
|
|
236
|
+
fallbackLoad = l;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
return fallback;
|
|
240
|
+
}
|
|
241
|
+
const signals = opts?.signals;
|
|
242
|
+
const order = new Map(devices.map((d, i) => [d, i]));
|
|
243
|
+
// Ascending composite sort — the first element is the best placement.
|
|
244
|
+
return [...eligible].sort((a, b) => {
|
|
245
|
+
const sa = signals?.get(a);
|
|
246
|
+
const sb = signals?.get(b);
|
|
247
|
+
// (a) signed-in agent first.
|
|
248
|
+
const signedIn = (sa?.signedIn === true ? 0 : 1) - (sb?.signedIn === true ? 0 : 1);
|
|
249
|
+
if (signedIn !== 0)
|
|
250
|
+
return signedIn;
|
|
251
|
+
// (b) lower load — coarse headroom tier, then raw load cost.
|
|
252
|
+
const tier = headroomTier(sa?.headroom) - headroomTier(sb?.headroom);
|
|
253
|
+
if (tier !== 0)
|
|
254
|
+
return tier;
|
|
255
|
+
// (c) fewer running teammates.
|
|
256
|
+
const teammates = (load.get(a) ?? 0) - (load.get(b) ?? 0);
|
|
257
|
+
if (teammates !== 0)
|
|
258
|
+
return teammates;
|
|
259
|
+
// finer load tiebreak: a known-lower load wins; unknown sits mid (50).
|
|
260
|
+
const cost = (loadCost(sa) ?? 50) - (loadCost(sb) ?? 50);
|
|
261
|
+
if (cost !== 0)
|
|
262
|
+
return cost;
|
|
263
|
+
// stable: first declared in the pool wins.
|
|
264
|
+
return (order.get(a) ?? 0) - (order.get(b) ?? 0);
|
|
265
|
+
})[0];
|
|
266
|
+
}
|
|
100
267
|
/**
|
|
101
268
|
* Resolve where a teammate runs. Returns `{ device: null }` for a local run
|
|
102
269
|
* (no pin, no pool, or the chosen device is the local machine) and
|
|
103
270
|
* `{ device: <name> }` for a remote placement. See the cascade in the module
|
|
104
|
-
* header.
|
|
271
|
+
* header. Throws {@link NoViableDeviceError} when a required pool has no viable
|
|
272
|
+
* host — the caller surfaces it at `teams start` rather than falling back local.
|
|
105
273
|
*/
|
|
106
274
|
export function resolvePlacement(team, explicitDevice, roster, opts) {
|
|
107
|
-
// 1. Explicit pin wins — even without a pool
|
|
275
|
+
// 1. Explicit pin wins — even without a pool — and is never second-guessed
|
|
276
|
+
// (the remote `agents run` surfaces any install/health issue there).
|
|
108
277
|
if (explicitDevice) {
|
|
109
278
|
return { device: isLocalDevice(explicitDevice) ? null : explicitDevice };
|
|
110
279
|
}
|
|
@@ -112,11 +281,19 @@ export function resolvePlacement(team, explicitDevice, roster, opts) {
|
|
|
112
281
|
// 4. No pool → local, exactly like today.
|
|
113
282
|
if (pool.length === 0)
|
|
114
283
|
return { device: null };
|
|
115
|
-
// 2. Pool of one → the whole team runs there.
|
|
284
|
+
// 2. Pool of one → the whole team runs there. Respect the choice for
|
|
285
|
+
// load/cap/reachability, but still fail loud when the agent is provably
|
|
286
|
+
// NOT installed there — running it would be futile.
|
|
116
287
|
if (pool.length === 1) {
|
|
288
|
+
if (opts?.signals?.get(pool[0])?.installed === false) {
|
|
289
|
+
throw new NoViableDeviceError([{ device: pool[0], reason: 'not-installed' }], opts.agentLabel);
|
|
290
|
+
}
|
|
117
291
|
return { device: isLocalDevice(pool[0]) ? null : pool[0] };
|
|
118
292
|
}
|
|
119
|
-
// 3. Many →
|
|
120
|
-
|
|
293
|
+
// 3. Many → best viable pick. With live signals, this is health-, harness-,
|
|
294
|
+
// and load-aware; without them it degrades to the cap-aware roster count.
|
|
295
|
+
const picked = opts?.signals
|
|
296
|
+
? pickBestDevice(pool, roster, opts)
|
|
297
|
+
: pickLeastLoaded(pool, roster, opts?.maxConcurrent);
|
|
121
298
|
return { device: isLocalDevice(picked) ? null : picked };
|
|
122
299
|
}
|
package/dist/lib/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { CloudProviderId } from './cloud/types.js';
|
|
9
9
|
import type { FeedBroadcastConfig } from './feed-broadcast.js';
|
|
10
10
|
/** Unique identifier for a current or legacy AI coding agent. */
|
|
11
|
-
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi' | 'muse';
|
|
11
|
+
export type AgentId = 'claude' | 'codex' | 'gemini' | 'cursor' | 'opencode' | 'openclaw' | 'copilot' | 'amp' | 'kiro' | 'goose' | 'antigravity' | 'grok' | 'kimi' | 'droid' | 'hermes' | 'pi' | 'muse' | 'warp';
|
|
12
12
|
/** How `agents run <agent>` chooses an installed version when none is pinned. */
|
|
13
13
|
export type RunStrategy = 'pinned' | 'available' | 'balanced';
|
|
14
14
|
export type RunEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
|
package/dist/lib/versions.js
CHANGED
|
@@ -928,6 +928,21 @@ export function getBinaryPath(agent, version) {
|
|
|
928
928
|
? path.join(getHomeDir(), 'bin', 'muse.exe')
|
|
929
929
|
: path.join(getHomeDir(), '.local', 'bin', 'muse');
|
|
930
930
|
}
|
|
931
|
+
if (agent === 'warp') {
|
|
932
|
+
// Warp Agent CLI installs a single global, self-updating `oz` binary — brew
|
|
933
|
+
// cask on macOS, the `oz-stable` apt|yum|pacman package on Linux — so, unlike
|
|
934
|
+
// droid/muse (which land at ~/.local/bin), its install location is
|
|
935
|
+
// platform/package specific. Resolve the real binary on PATH (findInPath
|
|
936
|
+
// skips our own shims dir) so isVersionInstalled / agents view agree with
|
|
937
|
+
// what executes. When oz is not installed, fall back to a deterministic path
|
|
938
|
+
// that won't exist, so isVersionInstalled reports uninstalled honestly.
|
|
939
|
+
const onPath = findInPath('oz');
|
|
940
|
+
if (onPath)
|
|
941
|
+
return onPath;
|
|
942
|
+
return IS_WINDOWS
|
|
943
|
+
? path.join(getHomeDir(), 'bin', 'oz.exe')
|
|
944
|
+
: '/opt/warpdotdev/oz-stable/oz';
|
|
945
|
+
}
|
|
931
946
|
const versionDir = getVersionDir(agent, version);
|
|
932
947
|
return path.join(versionDir, 'node_modules', '.bin', agentConfig.cliCommand);
|
|
933
948
|
}
|
|
@@ -1539,7 +1554,7 @@ export async function installVersion(agent, version, onProgress, opts) {
|
|
|
1539
1554
|
// exec itself forever. So we skip the resolver-backed agents here AND, for
|
|
1540
1555
|
// everyone else, filter the shims dir out of the `which` candidates so the
|
|
1541
1556
|
// same race can't bite a non-special-cased installScript agent.
|
|
1542
|
-
if (agent !== 'grok' && agent !== 'droid' && agent !== 'muse') {
|
|
1557
|
+
if (agent !== 'grok' && agent !== 'droid' && agent !== 'muse' && agent !== 'warp') {
|
|
1543
1558
|
// findInPath is a pure-Node PATH scan that already skips our own shims
|
|
1544
1559
|
// dir — so it returns the genuine install, never our dispatcher shim
|
|
1545
1560
|
// (which sits ahead of ~/.local/bin on PATH and would otherwise be
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ActiveSession } from '../session/active.js';
|
|
2
|
+
import { type WatchdogThresholds, type WatchdogTickResult } from './runner.js';
|
|
3
|
+
export interface WatchdogPassOptions {
|
|
4
|
+
nudge: boolean;
|
|
5
|
+
nudgeText?: string;
|
|
6
|
+
smart?: boolean;
|
|
7
|
+
smartAgent?: string;
|
|
8
|
+
thresholds?: WatchdogThresholds;
|
|
9
|
+
allowGhosttyFocus?: boolean;
|
|
10
|
+
sessions?: ActiveSession[];
|
|
11
|
+
stateDir?: string;
|
|
12
|
+
mailboxGc?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function loadWatchdogSessions(): Promise<ActiveSession[]>;
|
|
15
|
+
export declare function runWatchdogMailboxGc(sessions: ActiveSession[]): void;
|
|
16
|
+
/** Execute one watchdog pass from the daemon or the explicit CLI command. */
|
|
17
|
+
export declare function runWatchdogPass(opts: WatchdogPassOptions): Promise<WatchdogTickResult>;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
import { gcMailbox } from '../mailbox-gc.js';
|
|
3
|
+
import { mailboxIdForActiveSession } from '../mailbox-target.js';
|
|
4
|
+
import { getActiveSessions } from '../session/active.js';
|
|
5
|
+
import { loadLocalActiveSessions } from '../session/session-cache.js';
|
|
6
|
+
import { getRuntimeStateDir } from '../state.js';
|
|
7
|
+
import { runWatchdogTick } from './runner.js';
|
|
8
|
+
export async function loadWatchdogSessions() {
|
|
9
|
+
const loaded = await loadLocalActiveSessions({
|
|
10
|
+
gather: () => getActiveSessions({ localOnly: true }),
|
|
11
|
+
});
|
|
12
|
+
return loaded.sessions;
|
|
13
|
+
}
|
|
14
|
+
export function runWatchdogMailboxGc(sessions) {
|
|
15
|
+
const activeBoxIds = new Set(sessions.map(mailboxIdForActiveSession).filter((id) => Boolean(id)));
|
|
16
|
+
try {
|
|
17
|
+
gcMailbox(activeBoxIds);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Housekeeping is retried by the next daemon tick.
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Execute one watchdog pass from the daemon or the explicit CLI command. */
|
|
24
|
+
export async function runWatchdogPass(opts) {
|
|
25
|
+
const sessions = opts.sessions ?? await loadWatchdogSessions();
|
|
26
|
+
const result = await runWatchdogTick({
|
|
27
|
+
nudge: opts.nudge,
|
|
28
|
+
nudgeText: opts.nudgeText,
|
|
29
|
+
smart: opts.smart,
|
|
30
|
+
smartAgent: opts.smartAgent,
|
|
31
|
+
thresholds: opts.thresholds,
|
|
32
|
+
allowGhosttyFocus: opts.allowGhosttyFocus,
|
|
33
|
+
stateDir: opts.stateDir ?? path.join(getRuntimeStateDir(), 'watchdog'),
|
|
34
|
+
sessions,
|
|
35
|
+
});
|
|
36
|
+
if (opts.mailboxGc !== false)
|
|
37
|
+
runWatchdogMailboxGc(sessions);
|
|
38
|
+
return result;
|
|
39
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.26",
|
|
4
4
|
"description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|