@phnx-labs/agents-cli 1.22.38 → 1.22.39

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.
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import { queryAffinityRollup } from './session/db.js';
9
9
  import { localMachineId } from './session/origin-machine.js';
10
- import { loadDevicesSync } from './devices/registry.js';
10
+ import { isControlDevice, loadDevicesSync } from './devices/registry.js';
11
+ import { describeAutoPool, filterAutoPool, isAutoPoolMember } from './devices/pool.js';
11
12
  import { normalizeHost } from './machine-id.js';
12
13
  import { probePoolSignals } from './teams/placement-probe.js';
13
14
  import { pickBestDevice } from './teams/scheduler.js';
@@ -45,7 +46,23 @@ export function sampleWeighted(candidates, rng = Math.random) {
45
46
  }
46
47
  return candidates[candidates.length - 1].key;
47
48
  }
48
- /** Online device names from the local registry (+ always include local). */
49
+ /**
50
+ * Online device names from the local registry (+ local), narrowed to the
51
+ * automatic-placement pool.
52
+ *
53
+ * The pool rule lives in `devices/pool.ts` and is an allowlist once any device
54
+ * is marked `role=worker`: this is the single place both automatic-placement
55
+ * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
56
+ * marking workers moves every `--device auto` at once instead of one surface.
57
+ *
58
+ * Paired cockpits (`role: control` in the device registry) are dropped here,
59
+ * where the registry is already being read — they are control surfaces, not
60
+ * compute. It CAN return an empty list — a fleet where every marked worker is
61
+ * offline, or where this box is the only candidate and is marked `personal`. That is a
62
+ * real answer, and both callers fail loud on it rather than falling back to the
63
+ * local machine (which would be the exact box the operator marked personal to
64
+ * keep agents off).
65
+ */
49
66
  export function listOnlineDeviceNames(localName = localMachineId()) {
50
67
  const names = new Set([normalizeHost(localName)]);
51
68
  try {
@@ -57,13 +74,27 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
57
74
  // No tailscale snapshot → treat as candidate (registry-only box).
58
75
  if (online === false)
59
76
  continue;
77
+ // A paired cockpit (iPhone/iPad) is a control surface, not compute — it
78
+ // is never dialed for a session, so it is never a placement candidate.
79
+ if (isControlDevice(d))
80
+ continue;
60
81
  names.add(normalizeHost(name));
61
82
  }
62
83
  }
63
84
  catch {
64
85
  /* registry missing — local only */
65
86
  }
66
- return [...names];
87
+ return filterAutoPool([...names]);
88
+ }
89
+ /**
90
+ * The error both automatic-placement resolvers raise when device roles leave no
91
+ * candidate at all. Fail loud: the alternative — quietly running on the local
92
+ * machine — puts the agent on the box the operator marked `personal`.
93
+ */
94
+ export function formatEmptyAutoPoolError() {
95
+ const marked = describeAutoPool();
96
+ return (`agents: no device is eligible for automatic placement${marked ? ` (${marked})` : ''} — ` +
97
+ 'mark one with `agents devices role <name> worker`, or widen the pool with `agents config set auto.pool all`.');
67
98
  }
68
99
  export function formatNoHealthyDeviceError(pool, signals, agent) {
69
100
  const excluded = pool.map((key) => {
@@ -78,7 +109,11 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
78
109
  return `${key} (${reason})`;
79
110
  }).join(', ');
80
111
  const target = agent ? `can run ${agent}` : "for 'run auto'";
81
- return `agents: no healthy device ${target} excluded: ${excluded}; earliest window resets unknown`;
112
+ // Name the role narrowing when there is one: a fleet where every box but two
113
+ // is filtered out by a worker mark reads as "the fleet is down" without it.
114
+ const marked = describeAutoPool();
115
+ const poolNote = marked ? ` [pool: ${marked}]` : '';
116
+ return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}; earliest window resets unknown`;
82
117
  }
83
118
  /**
84
119
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -90,8 +125,13 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
90
125
  export async function resolveDeviceAuto(agent, opts = {}) {
91
126
  const local = normalizeHost(opts.localMachine ?? localMachineId());
92
127
  const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
93
- if (!pool.includes(local))
128
+ // The local machine participates in the same probe as every peer — unless a
129
+ // role excludes it. Adding it unconditionally would put agents back on the box
130
+ // the operator marked `personal` precisely to keep them off it.
131
+ if (!pool.includes(local) && isAutoPoolMember(local))
94
132
  pool.push(local);
133
+ if (pool.length === 0)
134
+ throw new Error(formatEmptyAutoPoolError());
95
135
  const signals = await (opts.probe ?? probePoolSignals)(pool, agent);
96
136
  if (!agent && !opts.probe) {
97
137
  const { collectFleetHarnesses } = await import('../commands/ssh.js');
@@ -135,6 +175,12 @@ export async function resolveDeviceAuto(agent, opts = {}) {
135
175
  }
136
176
  /**
137
177
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
178
+ *
179
+ * Draws from the same automatic-placement pool as {@link resolveDeviceAuto}, so
180
+ * `agents ssh auto`, the generic `--host auto` passthrough, and `matchHost`'s
181
+ * `auto` sentinel honour device roles too. Throws when roles leave the pool
182
+ * empty — a `null` host here means "run locally", which for a box marked
183
+ * `personal` is the outcome the mark exists to prevent.
138
184
  */
139
185
  export function resolveDeviceAffinity(opts = {}) {
140
186
  const local = normalizeHost(opts.localMachine ?? localMachineId());
@@ -142,9 +188,18 @@ export function resolveDeviceAffinity(opts = {}) {
142
188
  const rng = opts.rng ?? Math.random;
143
189
  const sinceDays = opts.sinceDays ?? 14;
144
190
  const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
191
+ // `listOnlineDeviceNames` always contained the local machine before device
192
+ // roles existed, so an empty default list can only mean roles excluded
193
+ // everything — fail loud, exactly as resolveDeviceAuto does. An explicitly
194
+ // empty `eligibleHosts` is the caller's own list and keeps the historical
195
+ // degrade-to-local behavior.
196
+ const usingDefaultPool = opts.eligibleHosts === undefined;
145
197
  const eligible = new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost));
146
- if (eligible.size === 0)
198
+ if (eligible.size === 0) {
199
+ if (usingDefaultPool)
200
+ throw new Error(formatEmptyAutoPoolError());
147
201
  eligible.add(local);
202
+ }
148
203
  const deviceRows = opts.deviceAffinity ??
149
204
  queryAffinityRollup({
150
205
  groupBy: 'machine',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.38",
3
+ "version": "1.22.39",
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",