@phnx-labs/agents-cli 1.22.25 → 1.22.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +427 -0
  2. package/README.md +35 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/browser.js +14 -4
  5. package/dist/commands/apply.js +52 -8
  6. package/dist/commands/bench.d.ts +2 -0
  7. package/dist/commands/bench.js +101 -0
  8. package/dist/commands/browser.js +35 -0
  9. package/dist/commands/doctor.js +32 -10
  10. package/dist/commands/exec.js +17 -16
  11. package/dist/commands/insights.d.ts +25 -19
  12. package/dist/commands/insights.js +146 -38
  13. package/dist/commands/output.js +100 -25
  14. package/dist/commands/reconnect.d.ts +46 -0
  15. package/dist/commands/reconnect.js +109 -0
  16. package/dist/commands/resume.d.ts +11 -0
  17. package/dist/commands/resume.js +51 -0
  18. package/dist/commands/routines.js +2 -2
  19. package/dist/commands/secrets.d.ts +2 -8
  20. package/dist/commands/secrets.js +29 -105
  21. package/dist/commands/sessions-picker.js +12 -7
  22. package/dist/commands/sessions.d.ts +47 -1
  23. package/dist/commands/sessions.js +349 -37
  24. package/dist/commands/setup-secrets.d.ts +1 -0
  25. package/dist/commands/setup-secrets.js +1 -1
  26. package/dist/commands/setup.d.ts +26 -3
  27. package/dist/commands/setup.js +105 -46
  28. package/dist/commands/ssh.js +184 -11
  29. package/dist/commands/teams.d.ts +6 -0
  30. package/dist/commands/teams.js +46 -3
  31. package/dist/commands/trends.d.ts +8 -0
  32. package/dist/commands/trends.js +10 -156
  33. package/dist/index.js +17 -21
  34. package/dist/lib/agents.d.ts +11 -0
  35. package/dist/lib/agents.js +29 -2
  36. package/dist/lib/analytics/dashboard.d.ts +10 -6
  37. package/dist/lib/analytics/dashboard.js +6 -4
  38. package/dist/lib/analytics/mix-commands.d.ts +53 -0
  39. package/dist/lib/analytics/mix-commands.js +229 -0
  40. package/dist/lib/analytics/recipes.d.ts +19 -14
  41. package/dist/lib/analytics/recipes.js +4 -2
  42. package/dist/lib/auth-health.d.ts +47 -3
  43. package/dist/lib/auth-health.js +74 -15
  44. package/dist/lib/bench/index.d.ts +4 -0
  45. package/dist/lib/bench/index.js +4 -0
  46. package/dist/lib/bench/runner.d.ts +16 -0
  47. package/dist/lib/bench/runner.js +111 -0
  48. package/dist/lib/bench/schema.d.ts +5 -0
  49. package/dist/lib/bench/schema.js +91 -0
  50. package/dist/lib/bench/storage.d.ts +5 -0
  51. package/dist/lib/bench/storage.js +32 -0
  52. package/dist/lib/bench/types.d.ts +40 -0
  53. package/dist/lib/bench/types.js +1 -0
  54. package/dist/lib/browser/ipc.d.ts +26 -0
  55. package/dist/lib/browser/ipc.js +139 -24
  56. package/dist/lib/browser/profiles.d.ts +11 -0
  57. package/dist/lib/browser/profiles.js +1 -1
  58. package/dist/lib/browser/stream.d.ts +14 -0
  59. package/dist/lib/browser/stream.js +71 -0
  60. package/dist/lib/channels/owner-sink.d.ts +27 -0
  61. package/dist/lib/channels/owner-sink.js +93 -0
  62. package/dist/lib/claude-account-token.d.ts +2 -0
  63. package/dist/lib/claude-account-token.js +27 -1
  64. package/dist/lib/crabbox/cli.d.ts +2 -0
  65. package/dist/lib/crabbox/cli.js +2 -0
  66. package/dist/lib/crabbox/lease.js +7 -1
  67. package/dist/lib/daemon.js +20 -0
  68. package/dist/lib/devices/connect.d.ts +2 -0
  69. package/dist/lib/devices/connect.js +7 -0
  70. package/dist/lib/devices/doctor-findings.d.ts +10 -1
  71. package/dist/lib/devices/doctor-findings.js +47 -1
  72. package/dist/lib/devices/harness-inventory.d.ts +97 -0
  73. package/dist/lib/devices/harness-inventory.js +0 -0
  74. package/dist/lib/devices/registry.d.ts +2 -0
  75. package/dist/lib/devices/ssh-config.js +3 -0
  76. package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
  77. package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
  78. package/dist/lib/exec.bench.d.ts +1 -0
  79. package/dist/lib/exec.bench.js +186 -0
  80. package/dist/lib/exec.js +18 -8
  81. package/dist/lib/fleet/apply.d.ts +59 -3
  82. package/dist/lib/fleet/apply.js +191 -12
  83. package/dist/lib/fleet/remote-login.d.ts +4 -3
  84. package/dist/lib/fleet/remote-login.js +11 -9
  85. package/dist/lib/fleet/types.d.ts +21 -2
  86. package/dist/lib/gemini-settings.d.ts +0 -1
  87. package/dist/lib/gemini-settings.js +12 -7
  88. package/dist/lib/hooks/cache.js +15 -0
  89. package/dist/lib/hooks.d.ts +8 -5
  90. package/dist/lib/hooks.js +10 -6
  91. package/dist/lib/hosts/dispatch.js +7 -3
  92. package/dist/lib/hosts/passthrough.d.ts +45 -0
  93. package/dist/lib/hosts/passthrough.js +59 -8
  94. package/dist/lib/hosts/progress.d.ts +2 -1
  95. package/dist/lib/hosts/progress.js +4 -3
  96. package/dist/lib/hosts/providers/devices.js +1 -0
  97. package/dist/lib/hosts/ready.d.ts +6 -2
  98. package/dist/lib/hosts/ready.js +33 -8
  99. package/dist/lib/hosts/reconcile.d.ts +1 -1
  100. package/dist/lib/hosts/reconcile.js +16 -7
  101. package/dist/lib/hosts/reconnect.d.ts +56 -21
  102. package/dist/lib/hosts/reconnect.js +128 -71
  103. package/dist/lib/hosts/registry.d.ts +2 -2
  104. package/dist/lib/hosts/registry.js +3 -5
  105. package/dist/lib/hosts/remote-cmd.d.ts +17 -0
  106. package/dist/lib/hosts/remote-cmd.js +29 -0
  107. package/dist/lib/hosts/tasks.d.ts +2 -0
  108. package/dist/lib/hosts/types.d.ts +1 -0
  109. package/dist/lib/hosts/types.js +3 -0
  110. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  111. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  112. package/dist/lib/menubar/install-menubar.d.ts +9 -6
  113. package/dist/lib/menubar/install-menubar.js +20 -9
  114. package/dist/lib/models.d.ts +30 -1
  115. package/dist/lib/models.js +42 -144
  116. package/dist/lib/pricing/cost.d.ts +9 -0
  117. package/dist/lib/pricing/cost.js +24 -0
  118. package/dist/lib/pricing/index.d.ts +1 -1
  119. package/dist/lib/pricing/index.js +1 -1
  120. package/dist/lib/redact.js +8 -3
  121. package/dist/lib/remote-agents-json.d.ts +32 -0
  122. package/dist/lib/remote-agents-json.js +47 -16
  123. package/dist/lib/resource-profiles.js +1 -2
  124. package/dist/lib/routine-notify-owner.d.ts +102 -0
  125. package/dist/lib/routine-notify-owner.js +232 -0
  126. package/dist/lib/routines.d.ts +10 -0
  127. package/dist/lib/routines.js +14 -2
  128. package/dist/lib/runner.d.ts +9 -4
  129. package/dist/lib/runner.js +182 -30
  130. package/dist/lib/sandbox.d.ts +0 -2
  131. package/dist/lib/sandbox.js +2 -19
  132. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  133. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  134. package/dist/lib/secrets/agent.js +3 -2
  135. package/dist/lib/secrets/lease.d.ts +25 -0
  136. package/dist/lib/secrets/lease.js +44 -0
  137. package/dist/lib/secrets/push.d.ts +94 -0
  138. package/dist/lib/secrets/push.js +145 -0
  139. package/dist/lib/secrets/reaper.d.ts +15 -1
  140. package/dist/lib/secrets/reaper.js +30 -3
  141. package/dist/lib/self-update.d.ts +20 -5
  142. package/dist/lib/self-update.js +93 -16
  143. package/dist/lib/session/db.d.ts +38 -3
  144. package/dist/lib/session/db.js +273 -16
  145. package/dist/lib/session/discover.d.ts +22 -1
  146. package/dist/lib/session/discover.js +239 -51
  147. package/dist/lib/session/insights.d.ts +37 -0
  148. package/dist/lib/session/insights.js +219 -9
  149. package/dist/lib/session/recovery.d.ts +17 -1
  150. package/dist/lib/session/recovery.js +111 -4
  151. package/dist/lib/session/remote-list.d.ts +10 -9
  152. package/dist/lib/session/remote-list.js +25 -23
  153. package/dist/lib/session/resume-owner.d.ts +55 -0
  154. package/dist/lib/session/resume-owner.js +69 -0
  155. package/dist/lib/session/team-filter.d.ts +65 -0
  156. package/dist/lib/session/team-filter.js +98 -3
  157. package/dist/lib/session/tool-index.js +133 -22
  158. package/dist/lib/session/tool-store.d.ts +26 -2
  159. package/dist/lib/session/tool-store.js +36 -17
  160. package/dist/lib/session/types.d.ts +33 -0
  161. package/dist/lib/smart-launch.d.ts +31 -5
  162. package/dist/lib/smart-launch.js +43 -7
  163. package/dist/lib/ssh-exec.d.ts +27 -0
  164. package/dist/lib/ssh-exec.js +42 -3
  165. package/dist/lib/ssh-tunnel.d.ts +3 -2
  166. package/dist/lib/ssh-tunnel.js +25 -16
  167. package/dist/lib/startup/command-registry.d.ts +2 -0
  168. package/dist/lib/startup/command-registry.js +6 -0
  169. package/dist/lib/teams/agents.d.ts +13 -0
  170. package/dist/lib/teams/agents.js +75 -7
  171. package/dist/lib/teams/placement-probe.d.ts +21 -0
  172. package/dist/lib/teams/placement-probe.js +135 -0
  173. package/dist/lib/teams/scheduler.d.ts +74 -1
  174. package/dist/lib/teams/scheduler.js +187 -10
  175. package/dist/lib/tmux/session.d.ts +8 -0
  176. package/dist/lib/tmux/session.js +22 -0
  177. package/dist/lib/types.d.ts +2 -0
  178. 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 → least-loaded pick
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-aware: a device at its `agents.max-concurrent` cap (from the
15
- * device doc, passed in via PlacementOptions) is excluded from the auto-pick,
16
- * and an all-capped pool fails loud. Pins and pools of one are the user's own
17
- * choice and are never second-guessed.
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 → least-loaded across the pool (cap-aware when caps are provided).
120
- const picked = pickLeastLoaded(pool, roster, opts?.maxConcurrent);
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
  }
@@ -179,6 +179,14 @@ export declare const AGENT_HOOK_SCHEMA = 5;
179
179
  export declare function agentPaneDiedHook(sessionName: string, agentPane: string): string;
180
180
  /** Stamp a session's hook-schema marker to the current version. */
181
181
  export declare function markSessionHookSchema(name: string, socket?: string): Promise<void>;
182
+ /**
183
+ * Decide whether a native resume may attach an existing managed tmux session.
184
+ * Only a positively resolved, living agent pane is reusable. Metadata-less
185
+ * legacy sessions and stale metadata resolve their real first pane through
186
+ * tmux before the decision; a dead or unreadable session is reaped so the
187
+ * caller can create a fresh wrapper for the resumed harness.
188
+ */
189
+ export declare function prepareSessionForResume(name: string, socket?: string): Promise<'attach' | 'create'>;
182
190
  /**
183
191
  * Retrofit the current guarded `pane-died` hook onto every managed `agents run`
184
192
  * session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
@@ -359,6 +359,28 @@ async function lowestPaneId(name, socket) {
359
359
  return undefined;
360
360
  return ids.reduce((lo, id) => (parseInt(id.slice(1), 10) < parseInt(lo.slice(1), 10) ? id : lo));
361
361
  }
362
+ /**
363
+ * Decide whether a native resume may attach an existing managed tmux session.
364
+ * Only a positively resolved, living agent pane is reusable. Metadata-less
365
+ * legacy sessions and stale metadata resolve their real first pane through
366
+ * tmux before the decision; a dead or unreadable session is reaped so the
367
+ * caller can create a fresh wrapper for the resumed harness.
368
+ */
369
+ export async function prepareSessionForResume(name, socket) {
370
+ const sock = socket ?? getDefaultSocketPath();
371
+ if (!(await hasSession(name, sock)))
372
+ return 'create';
373
+ const recordedPane = readSessionMeta(name)?.pane;
374
+ const recordedState = recordedPane ? await paneExitStatus(recordedPane, sock) : undefined;
375
+ const pane = recordedPane && recordedState?.found
376
+ ? recordedPane
377
+ : await lowestPaneId(name, sock);
378
+ const state = pane ? await paneExitStatus(pane, sock) : undefined;
379
+ if (pane && state?.found && !state.dead)
380
+ return 'attach';
381
+ await killSession(name, sock);
382
+ return 'create';
383
+ }
362
384
  /**
363
385
  * Retrofit the current guarded `pane-died` hook onto every managed `agents run`
364
386
  * session whose hook predates AGENT_HOOK_SCHEMA. Idempotent and NON-DESTRUCTIVE:
@@ -1064,6 +1064,8 @@ export interface HostEntry {
1064
1064
  address?: string;
1065
1065
  /** SSH user — inline hosts only. */
1066
1066
  user?: string;
1067
+ /** Explicit private key inherited from a fleet device profile. */
1068
+ identityFile?: string;
1067
1069
  /** Captured at enroll probe. */
1068
1070
  os?: string;
1069
1071
  /** Free-form capability tags for routing (e.g. ['gpu']). */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.25",
3
+ "version": "1.22.27",
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",