@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -1,3 +1,5 @@
1
+ import type { Meta } from '../types.js';
2
+ import type { IgnoredDeviceEntry } from '../fleet/types.js';
1
3
  /** Operating-system family of a device, used to pick the remote shell. */
2
4
  export type DevicePlatform = 'windows' | 'linux' | 'macos' | 'unknown';
3
5
  /** Remote shell dialect derived from the platform. */
@@ -84,8 +86,36 @@ export interface DeviceProfile {
84
86
  export declare function isDialableDevice(d: DeviceProfile): boolean;
85
87
  /** Map of device name to profile. */
86
88
  export type DeviceRegistry = Record<string, DeviceProfile>;
87
- /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
89
+ /**
90
+ * `--device` values that mean "resolve me", not "connect to a box with this
91
+ * name". A real device registered under one of these would be unreachable,
92
+ * and pinning `interactive.host` to one is a misconfiguration that has to fail
93
+ * at WRITE time — otherwise the read side can only report "none is set", which
94
+ * tells the user to run the command they just ran.
95
+ */
96
+ export declare const RESERVED_DEVICE_NAMES: Set<string>;
97
+ /**
98
+ * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
99
+ *
100
+ * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
101
+ * already registered — including one this version would refuse to create.
102
+ */
88
103
  export declare function assertValidDeviceName(name: string): void;
104
+ /**
105
+ * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
106
+ * routing sentinel.
107
+ *
108
+ * Deliberately separate from {@link assertValidDeviceName}, and called from
109
+ * exactly two kinds of place: `agents devices add <name>`, and the config keys
110
+ * that point AT a device (`interactive.host`, `usage.primary-host`).
111
+ *
112
+ * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
113
+ * receive tailnet node names the user never typed — `devices sync` upserts every
114
+ * observed node in a loop with no per-node catch, so one node named `auto` would
115
+ * abort the entire sync and register nothing after it. A name the fleet OBSERVES
116
+ * is not a name anyone CHOSE; only the second kind is policy's business.
117
+ */
118
+ export declare function assertRegistrableDeviceName(name: string): void;
89
119
  /** Map a Tailscale `OS` field to our platform enum. */
90
120
  export declare function platformFromOs(os: string | undefined): DevicePlatform;
91
121
  /** The remote shell a platform speaks. */
@@ -132,12 +162,39 @@ export declare function upsertDevice(name: string, input: DeviceInput): Promise<
132
162
  export declare function writeReachability(updates: Record<string, DeviceReachability>): Promise<string[]>;
133
163
  /** Remove a device. Returns false if it was not registered. */
134
164
  export declare function removeDevice(name: string): Promise<boolean>;
135
- /** Load the set of ignored node names. Missing file => empty set. A malformed
136
- * file is a hard error for the same reason the registry is: silently returning
137
- * [] would let the next write wipe the user's dismissals. */
165
+ /**
166
+ * The ignore-list: tailscale node names the user explicitly dismissed from
167
+ * auto-discovery. A dismissed node is deliberately NOT a device (it never
168
+ * enters the registry), so it has no per-device doc — its home is the central,
169
+ * TRACKED `~/.agents/agents.yaml` under a `fleet.ignored` list, which syncs
170
+ * fleet-wide via `agents repo push/pull`: a dismissal on one box stops the
171
+ * suggestion on every box (RUSH-3062). Auto-discovery (`runDeviceSync`'s
172
+ * pending diff) subtracts this set, so an ignored node never re-surfaces as a
173
+ * suggestion. Writes go through `updateMeta` — the central-config write path
174
+ * (withMetaLock + atomic write) — so two agents writing concurrently cannot
175
+ * corrupt it, and no second lock is hand-rolled here.
176
+ */
177
+ export type { IgnoredDeviceEntry } from '../fleet/types.js';
178
+ /**
179
+ * The full ignore-list entries — who dismissed a node, when, and on which box —
180
+ * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
181
+ * []. A malformed block is a hard error for the same reason the registry is:
182
+ * silently returning [] would let the next write wipe the user's dismissals.
183
+ */
184
+ export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
185
+ /** Load the set of ignored node names. Same corruption contract as
186
+ * {@link loadIgnoredEntries}. */
138
187
  export declare function loadIgnored(): Promise<Set<string>>;
139
188
  /** True if `name` is on the ignore-list. */
140
189
  export declare function isIgnored(name: string): Promise<boolean>;
190
+ /**
191
+ * Union `names` into the meta's ignore-list, stamping new entries with
192
+ * `ignoredAt` and THIS machine's id. Existing entries keep their original
193
+ * who/when, so re-adding a name is a true no-op. Returns the input unchanged
194
+ * when no name is new. Exported for the one-shot legacy-store migration in
195
+ * lib/devices/config-migration.ts.
196
+ */
197
+ export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
141
198
  /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
142
199
  export declare function addIgnored(name: string): Promise<Set<string>>;
143
200
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
@@ -17,8 +17,9 @@ import * as fs from 'fs/promises';
17
17
  import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import lockfile from 'proper-lockfile';
20
- import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
20
+ import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
21
21
  import { atomicWriteJsonSync } from '../fs-atomic.js';
22
+ import { machineId } from '../machine-id.js';
22
23
  /**
23
24
  * Whether a fan-out should dial this device, honouring the preference stated on
24
25
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -62,12 +63,46 @@ function registryPath() {
62
63
  /** Valid logical device name: the ssh-alias charset, so it renders into an
63
64
  * unambiguous `Host` stanza and is safe as an ssh target. */
64
65
  const DEVICE_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;
65
- /** Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.). */
66
+ /**
67
+ * `--device` values that mean "resolve me", not "connect to a box with this
68
+ * name". A real device registered under one of these would be unreachable,
69
+ * and pinning `interactive.host` to one is a misconfiguration that has to fail
70
+ * at WRITE time — otherwise the read side can only report "none is set", which
71
+ * tells the user to run the command they just ran.
72
+ */
73
+ export const RESERVED_DEVICE_NAMES = new Set(['auto', 'interactive', 'all']);
74
+ /**
75
+ * Throw if `name` is not usable as an ssh alias (no spaces, quotes, etc.).
76
+ *
77
+ * SHAPE ONLY. Safe on read paths, which must keep working for a name that is
78
+ * already registered — including one this version would refuse to create.
79
+ */
66
80
  export function assertValidDeviceName(name) {
67
81
  if (!DEVICE_NAME_RE.test(name)) {
68
82
  throw new Error(`Invalid device name ${JSON.stringify(name)}. Use letters, digits, '.', '_', '-' (no spaces) — e.g. 'win-mini'.`);
69
83
  }
70
84
  }
85
+ /**
86
+ * Throw if `name` cannot be used for a NEW device: bad shape, or a reserved
87
+ * routing sentinel.
88
+ *
89
+ * Deliberately separate from {@link assertValidDeviceName}, and called from
90
+ * exactly two kinds of place: `agents devices add <name>`, and the config keys
91
+ * that point AT a device (`interactive.host`, `usage.primary-host`).
92
+ *
93
+ * Not from `upsertDevice`, `addIgnored` or the discovery writers. Those all
94
+ * receive tailnet node names the user never typed — `devices sync` upserts every
95
+ * observed node in a loop with no per-node catch, so one node named `auto` would
96
+ * abort the entire sync and register nothing after it. A name the fleet OBSERVES
97
+ * is not a name anyone CHOSE; only the second kind is policy's business.
98
+ */
99
+ export function assertRegistrableDeviceName(name) {
100
+ assertValidDeviceName(name);
101
+ if (RESERVED_DEVICE_NAMES.has(name.trim().toLowerCase())) {
102
+ throw new Error(`${JSON.stringify(name)} is a reserved --device value, not a device name. ` +
103
+ `Reserved: ${[...RESERVED_DEVICE_NAMES].join(', ')}.`);
104
+ }
105
+ }
71
106
  /** Map a Tailscale `OS` field to our platform enum. */
72
107
  export function platformFromOs(os) {
73
108
  switch ((os ?? '').toLowerCase()) {
@@ -246,55 +281,76 @@ export async function removeDevice(name) {
246
281
  return true;
247
282
  });
248
283
  }
249
- function ignoredPath() {
250
- return getDevicesIgnoredPath();
284
+ /**
285
+ * The full ignore-list entries — who dismissed a node, when, and on which box —
286
+ * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
287
+ * []. A malformed block is a hard error for the same reason the registry is:
288
+ * silently returning [] would let the next write wipe the user's dismissals.
289
+ */
290
+ export function loadIgnoredEntries(meta = readMeta()) {
291
+ const raw = meta.fleet?.ignored;
292
+ if (raw === undefined)
293
+ return [];
294
+ if (!Array.isArray(raw) ||
295
+ raw.some((e) => !e ||
296
+ typeof e.name !== 'string' ||
297
+ typeof e.ignoredAt !== 'string' ||
298
+ typeof e.ignoredOn !== 'string')) {
299
+ throw new Error(`Device ignore-list corrupted in agents.yaml (fleet.ignored): expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair ~/.agents/agents.yaml.`);
300
+ }
301
+ return raw;
251
302
  }
252
- /** Load the set of ignored node names. Missing file => empty set. A malformed
253
- * file is a hard error for the same reason the registry is: silently returning
254
- * [] would let the next write wipe the user's dismissals. */
303
+ /** Load the set of ignored node names. Same corruption contract as
304
+ * {@link loadIgnoredEntries}. */
255
305
  export async function loadIgnored() {
256
- const p = ignoredPath();
257
- let raw;
258
- try {
259
- raw = await fs.readFile(p, 'utf-8');
260
- }
261
- catch (err) {
262
- if (err && err.code === 'ENOENT')
263
- return new Set();
264
- throw err;
265
- }
266
- try {
267
- const parsed = JSON.parse(raw);
268
- return new Set(Array.isArray(parsed.ignored) ? parsed.ignored : []);
269
- }
270
- catch (err) {
271
- throw new Error(`Device ignore-list corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
272
- }
306
+ return new Set(loadIgnoredEntries().map((e) => e.name));
273
307
  }
274
308
  /** True if `name` is on the ignore-list. */
275
309
  export async function isIgnored(name) {
276
310
  return (await loadIgnored()).has(name);
277
311
  }
312
+ /**
313
+ * Union `names` into the meta's ignore-list, stamping new entries with
314
+ * `ignoredAt` and THIS machine's id. Existing entries keep their original
315
+ * who/when, so re-adding a name is a true no-op. Returns the input unchanged
316
+ * when no name is new. Exported for the one-shot legacy-store migration in
317
+ * lib/devices/config-migration.ts.
318
+ */
319
+ export function withIgnoredAdded(meta, names, ignoredAt) {
320
+ const entries = loadIgnoredEntries(meta); // throws on a corrupted block — never wipe it
321
+ const have = new Set(entries.map((e) => e.name));
322
+ const fresh = names.filter((n) => !have.has(n));
323
+ if (fresh.length === 0)
324
+ return meta;
325
+ const fleet = (meta.fleet ?? { devices: {} });
326
+ const ignored = [
327
+ ...entries,
328
+ ...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
329
+ ].sort((a, b) => a.name.localeCompare(b.name));
330
+ const nextFleet = { ...fleet, ignored };
331
+ return { ...meta, fleet: nextFleet };
332
+ }
278
333
  /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
279
334
  export async function addIgnored(name) {
280
335
  assertValidDeviceName(name);
281
- const p = ignoredPath();
282
- return withRegistryLock(p, async () => {
283
- const set = await loadIgnored();
284
- set.add(name);
285
- atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
286
- return set;
287
- });
336
+ const meta = updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
337
+ return new Set(loadIgnoredEntries(meta).map((e) => e.name));
288
338
  }
289
339
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
290
340
  * not ignored. */
291
341
  export async function removeIgnored(name) {
292
- const p = ignoredPath();
293
- return withRegistryLock(p, async () => {
294
- const set = await loadIgnored();
295
- if (!set.delete(name))
296
- return false;
297
- atomicWriteJsonSync(p, { ignored: [...set].sort(), updatedAt: new Date().toISOString() });
298
- return true;
342
+ let removed = false;
343
+ updateMeta((m) => {
344
+ const fleet = m.fleet;
345
+ if (!fleet?.ignored)
346
+ return m;
347
+ const entries = loadIgnoredEntries(m);
348
+ const next = entries.filter((e) => e.name !== name);
349
+ if (next.length === entries.length)
350
+ return m;
351
+ removed = true;
352
+ const nextFleet = { ...fleet, ignored: next };
353
+ return { ...m, fleet: nextFleet };
299
354
  });
355
+ return removed;
300
356
  }
@@ -8,8 +8,12 @@ import type { DeviceProfile } from './registry.js';
8
8
  * rewrites the cache, unreachable boxes included).
9
9
  */
10
10
  export declare const STATS_STALE_MS: number;
11
+ /** Static hardware totals are valid for seven days. */
12
+ export declare const SPECS_STALE_MS: number;
11
13
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
12
14
  export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
15
+ /** True when cached core, RAM-total, and disk-total facts remain current. */
16
+ export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
13
17
  /** Read the whole cache (best-effort; a missing/corrupt file yields an empty map). */
14
18
  export declare function readStatsCache(): Record<string, DeviceStats>;
15
19
  /**
@@ -30,6 +34,8 @@ export interface FleetStatsResult {
30
34
  export interface LoadFleetStatsOptions {
31
35
  /** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
32
36
  forceRefresh?: boolean;
37
+ /** Read only static hardware facts, whose cache lifetime is seven days. */
38
+ specsOnly?: boolean;
33
39
  /** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
34
40
  selfName?: string;
35
41
  /** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
@@ -37,10 +37,17 @@ const CACHE_FILE = '.fleet-stats.json';
37
37
  * rewrites the cache, unreachable boxes included).
38
38
  */
39
39
  export const STATS_STALE_MS = 3 * 60_000;
40
+ /** Static hardware totals are valid for seven days. */
41
+ export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
40
42
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
41
43
  export function isFreshDeviceStats(stats, now = Date.now()) {
42
44
  return now - stats.fetchedAt <= STATS_STALE_MS;
43
45
  }
46
+ /** True when cached core, RAM-total, and disk-total facts remain current. */
47
+ export function isFreshDeviceSpecs(stats, now = Date.now()) {
48
+ const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
49
+ return now - fetchedAt <= SPECS_STALE_MS;
50
+ }
44
51
  function cacheFilePath() {
45
52
  return path.join(getCacheDir(), CACHE_FILE);
46
53
  }
@@ -93,13 +100,14 @@ export async function loadFleetStats(devices, opts = {}) {
93
100
  const toProbe = [];
94
101
  let servedFromCache = false;
95
102
  for (const d of devices) {
96
- if (d.name === self) {
103
+ if (d.name === self && !opts.specsOnly) {
97
104
  // This machine is always probed locally — cheap, no ssh, always live.
98
105
  toProbe.push(d);
99
106
  continue;
100
107
  }
101
108
  const cached = cache[d.name];
102
- if (cached && isFreshDeviceStats(cached, now)) {
109
+ const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
110
+ if (cached && cacheFresh) {
103
111
  stats.set(d.name, cached);
104
112
  servedFromCache = true;
105
113
  }
@@ -94,6 +94,8 @@ export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
94
94
  export interface ExecOptions {
95
95
  agent: AgentId;
96
96
  version?: string;
97
+ /** Version home whose native auth/config is overlaid onto this run's binary. */
98
+ configVersion?: string;
97
99
  /** Omit to launch the CLI interactively -- no prompt, no --print, stdio fully inherited. */
98
100
  prompt?: string;
99
101
  /** Force interactive mode even when a prompt is provided. Wins over `headless`. */
@@ -398,7 +400,7 @@ export interface TmuxWrapContext {
398
400
  raw: boolean;
399
401
  /** The AGENTS_NO_TMUX=1 escape hatch. */
400
402
  noTmuxEnv: boolean;
401
- /** This device's `tmux.enabled` config — false turns the wrap off for every launch on this box. */
403
+ /** This device's `tmux.enabled` config — true opts every eligible launch on this box into the wrap. */
402
404
  configEnabled: boolean;
403
405
  /** Whether a tmux binary is on PATH. */
404
406
  tmuxAvailable: boolean;
@@ -407,7 +409,7 @@ export interface TmuxWrapContext {
407
409
  * Decide whether to run an interactive agent INSIDE a detached tmux session on
408
410
  * the shared socket (then attach the current TTY) instead of a bare spawn.
409
411
  *
410
- * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
412
+ * Opting into tmux wrapping gives every interactive agent an exact, unique `%pane` handle so
411
413
  * `agents sessions --active` can tell co-located agents apart, and lets `agents
412
414
  * focus` re-attach a live session without forking it. Pure so the gate is unit-
413
415
  * tested independently of the (side-effecting) spawn.
@@ -418,7 +420,7 @@ export interface TmuxWrapContext {
418
420
  * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
419
421
  * - not --raw — explicit opt-out.
420
422
  * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
421
- * - tmux.enabled — this device's durable opt-out, for a box whose tmux is broken.
423
+ * - tmux.enabled — this device explicitly opted into addressable tmux panes.
422
424
  * - tmux installed — otherwise there is nothing to wrap with.
423
425
  */
424
426
  export declare function shouldWrapInTmux(ctx: TmuxWrapContext): boolean;
@@ -482,6 +484,26 @@ export declare const UNKNOWN_OUTCOME_EXIT_CODE = 1;
482
484
  export declare const RATE_LIMIT_PATTERNS: RegExp[];
483
485
  /** Return true if the text contains any known rate-limit or overload indicator. */
484
486
  export declare function detectRateLimit(text: string): boolean;
487
+ export declare function detectOutOfCredits(text: string): boolean;
488
+ /**
489
+ * Classify what a Claude run's output + exit code means for the account's
490
+ * persisted refusal marker. Pure and exported so the persist/clear decision is
491
+ * unit-tested on the real path (runWithFallback can't be driven with a real
492
+ * `claude` spawn in tests). Precedence: a session-limit reset wins (it carries a
493
+ * clock), then a clock-less billing exhaustion, then a clean success clears any
494
+ * stale marker; anything else leaves the marker untouched.
495
+ */
496
+ export type ClaudeRefusalAction = {
497
+ action: 'note_session';
498
+ resetsAt: Date;
499
+ } | {
500
+ action: 'note_out_of_credits';
501
+ } | {
502
+ action: 'clear';
503
+ } | {
504
+ action: 'none';
505
+ };
506
+ export declare function classifyClaudeRunRefusal(output: string, exitCode: number): ClaudeRefusalAction;
485
507
  /**
486
508
  * Patterns that indicate an authentication failure — the agent is logged out,
487
509
  * its token was revoked, or the session expired. These are the user-visible
package/dist/lib/exec.js CHANGED
@@ -28,7 +28,7 @@ import { recordRunName } from './session/run-names.js';
28
28
  import { mailboxDir, isValidMailboxId } from './mailbox.js';
29
29
  import { composeWin32CommandLine } from './platform/index.js';
30
30
  import { isTmuxInstalled } from './tmux/binary.js';
31
- import { isTmuxEnabled } from './device-config.js';
31
+ import { isTmuxEnabled, selfConfiguredDeviceRole } from './device-config.js';
32
32
  import { machineId } from './machine-id.js';
33
33
  import { shellQuote } from './ssh-exec.js';
34
34
  import { codexEditWritableRoots, codexPolicyArgs } from './codex-policy.js';
@@ -38,7 +38,7 @@ import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
38
38
  import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
39
39
  import { resolveConfigVersion } from './harness/exec-config-version.js';
40
40
  import { getAccountInfo } from './agents.js';
41
- import { getUsageLookupKey, noteClaudeSessionLimit, parseClaudeSessionLimitReset } from './accounting/usage.js';
41
+ import { getUsageLookupKey, noteClaudeSessionLimit, noteClaudeOutOfCredits, clearClaudeAccountRefusal, parseClaudeSessionLimitReset } from './accounting/usage.js';
42
42
  /**
43
43
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
44
44
  *
@@ -293,17 +293,28 @@ export function buildExecEnv(options) {
293
293
  // Resolve version/versionHome here (this module already imports
294
294
  // installations/versions); the adapters must not import it themselves or they
295
295
  // close a versions -> shims -> harness -> adapter import cycle.
296
- const { version, versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.version);
296
+ const { version, versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.configVersion ?? options.version);
297
297
  configAdapter.applyExecConfigEnv(result, {
298
298
  agent: options.agent,
299
299
  version,
300
300
  versionHome,
301
301
  interactive: resolveInteractive(options),
302
+ deviceRole: selfConfiguredDeviceRole(),
302
303
  resolveClaudeSetupToken,
303
304
  });
304
305
  }
305
306
  else {
306
307
  stripForeignConfigDir(result);
308
+ // Grok's adapter owns the same GROK_HOME export in generated shims, but a
309
+ // labeled account deliberately runs one version's binary with another
310
+ // version's config. Apply that overlay here because the shim's binary-slot
311
+ // export cannot express a distinct account slot.
312
+ if (options.agent === 'grok' && options.configVersion) {
313
+ const { versionHome } = resolveConfigVersion(options.agent, options.cwd || process.cwd(), options.configVersion);
314
+ if (!versionHome)
315
+ throw new Error(`Cannot resolve Grok account config home for ${options.configVersion}.`);
316
+ result.GROK_HOME = path.join(versionHome, '.grok');
317
+ }
307
318
  }
308
319
  // Point the agent at its own mailbox so the PreToolUse `mailbox-inject` hook
309
320
  // knows which box to drain and inject mid-run. Keyed by the session id — the
@@ -322,6 +333,7 @@ export function buildExecEnv(options) {
322
333
  // every event the child emits. `options.sessionId` is the CHILD's id, so read the
323
334
  // spawner from the live env; guard a same-session resume from naming itself parent.
324
335
  // Local-spawn scope here; forwarding it across the `--device` SSH hop is Phase 4.
336
+ delete result.AGENTS_PARENT_SESSION_ID;
325
337
  const spawnerSessionId = process.env.AGENTS_SESSION_ID || process.env.AGENT_SESSION_ID;
326
338
  if (spawnerSessionId && spawnerSessionId !== options.sessionId) {
327
339
  result.AGENTS_PARENT_SESSION_ID = spawnerSessionId;
@@ -388,6 +400,7 @@ export const AGENT_COMMANDS = {
388
400
  // independent; legacy --sandbox flags cannot express that combination.
389
401
  plan: [],
390
402
  edit: [],
403
+ auto: [],
391
404
  // skip = codex --yolo: drops the sandbox entirely and approves anything.
392
405
  skip: ['--dangerously-bypass-approvals-and-sandbox'],
393
406
  },
@@ -1163,7 +1176,7 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1163
1176
  * Decide whether to run an interactive agent INSIDE a detached tmux session on
1164
1177
  * the shared socket (then attach the current TTY) instead of a bare spawn.
1165
1178
  *
1166
- * tmux-wrapping gives every interactive agent an exact, unique `%pane` handle so
1179
+ * Opting into tmux wrapping gives every interactive agent an exact, unique `%pane` handle so
1167
1180
  * `agents sessions --active` can tell co-located agents apart, and lets `agents
1168
1181
  * focus` re-attach a live session without forking it. Pure so the gate is unit-
1169
1182
  * tested independently of the (side-effecting) spawn.
@@ -1174,7 +1187,7 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1174
1187
  * - not already in tmux — nesting tmux-in-tmux is pointless and confusing.
1175
1188
  * - not --raw — explicit opt-out.
1176
1189
  * - not AGENTS_NO_TMUX=1 — env opt-out (CI, scripts, the shim passthrough path).
1177
- * - tmux.enabled — this device's durable opt-out, for a box whose tmux is broken.
1190
+ * - tmux.enabled — this device explicitly opted into addressable tmux panes.
1178
1191
  * - tmux installed — otherwise there is nothing to wrap with.
1179
1192
  */
1180
1193
  export function shouldWrapInTmux(ctx) {
@@ -1282,11 +1295,14 @@ export function formatPaneTail(raw, maxLines = 30) {
1282
1295
  * Lifecycle:
1283
1296
  * 1. createSession() launches `sh -c 'exec env … agent'` detached, remain-on-exit
1284
1297
  * on (global), and returns the pane id.
1285
- * 2. A per-session `pane-died` hook detaches the attach client the instant the
1286
- * AGENT pane exits, so attach returns instead of parking on a dead pane. The
1287
- * hook is guarded on `#{hook_pane}` so it fires ONLY for the agent pane —
1288
- * user-created splits (Ctrl-b " / %) that the user exits are closed in place
1289
- * (`kill-pane`) instead of tearing down the whole client, so exiting one
1298
+ * 2. A per-session `pane-died` hook tears the session down the instant the AGENT
1299
+ * pane exits: with a client attached it `detach-client`s so attach returns
1300
+ * instead of parking on a dead pane (then resolveAfterAttach reads the status
1301
+ * and kills); with no client attached it `kill-session`s outright, so a
1302
+ * wrapped agent that exits unattended can't leave a dead husk lingering on the
1303
+ * socket. The hook is guarded on `#{hook_pane}` so it fires ONLY for the agent
1304
+ * pane — user-created splits (Ctrl-b " / %) that the user exits are closed in
1305
+ * place (`kill-pane`) instead of tearing down the whole client, so exiting one
1290
1306
  * split leaves the agent running full-window rather than kicking you out.
1291
1307
  * 3. We record the agent pane's pid → session mapping (WITH the tmux pane) so the
1292
1308
  * headless active-scan attributes it, then attach the TTY (blocking).
@@ -1303,7 +1319,7 @@ async function runInTmux(options, executable, args) {
1303
1319
  const idSeed = (options.sessionId ?? randomUUID()).slice(0, 8);
1304
1320
  const name = slugifyName(`ag-${options.agent}-${idSeed}`);
1305
1321
  const RED = '\x1b[31m', GRAY = '\x1b[90m', OFF = '\x1b[0m';
1306
- const NO_TMUX_TIP = `${GRAY} Tip: re-run with --no-tmux to launch the agent directly and see its full output.\n If tmux is broken on this machine, turn the wrap off for good: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
1322
+ const NO_TMUX_TIP = `${GRAY} This run used the opt-in tmux wrap. Re-run with --no-tmux for a direct launch, or turn the wrap off: agents config set devices.${machineId()}.tmux off${OFF}\n\n`;
1307
1323
  // Recap a dead pane's tail into THIS shell's stderr. The pane-died hook
1308
1324
  // detaches the client the instant the agent exits, so a fast failure (a
1309
1325
  // gutted install that dies with ENOENT, a bad flag, a crash on startup) would
@@ -1600,11 +1616,10 @@ async function spawnAgent(options) {
1600
1616
  command: executable,
1601
1617
  args: redactArgs(args.slice(0, 10)),
1602
1618
  });
1603
- // Interactive spawn-wrap: on macOS/Linux, run the agent INSIDE a shared-socket
1604
- // tmux session (then attach this TTY) so it gets a unique, addressable %pane.
1605
- // Headless runs, Windows, already-in-tmux, --raw, and AGENTS_NO_TMUX=1 keep the
1606
- // bare spawn below. See shouldWrapInTmux / runInTmux.
1607
- if (shouldWrapInTmux({
1619
+ // Interactive spawn-wrap: when this device opts in on macOS/Linux, run the
1620
+ // agent INSIDE a shared-socket tmux session (then attach this TTY) so it gets
1621
+ // a unique, addressable %pane. Every failed guard keeps the bare spawn below.
1622
+ const wrapInTmux = shouldWrapInTmux({
1608
1623
  interactive,
1609
1624
  platform: process.platform,
1610
1625
  inTmux: !!process.env.TMUX,
@@ -1612,7 +1627,8 @@ async function spawnAgent(options) {
1612
1627
  noTmuxEnv: process.env.AGENTS_NO_TMUX === '1',
1613
1628
  configEnabled: isTmuxEnabled(),
1614
1629
  tmuxAvailable: isTmuxInstalled(),
1615
- })) {
1630
+ });
1631
+ if (wrapInTmux) {
1616
1632
  timer.mark('startup');
1617
1633
  try {
1618
1634
  const result = await runInTmux(options, executable, args);
@@ -1865,6 +1881,29 @@ export const RATE_LIMIT_PATTERNS = [
1865
1881
  export function detectRateLimit(text) {
1866
1882
  return RATE_LIMIT_PATTERNS.some(pattern => pattern.test(text));
1867
1883
  }
1884
+ /**
1885
+ * Narrow detector for a BILLING exhaustion — tokens/credits run out or the
1886
+ * monthly spend cap is hit — as opposed to a time-window rate limit. This class
1887
+ * does NOT recover on a clock, so rotation must remember it per-account
1888
+ * (noteClaudeOutOfCredits) until a later successful run clears it.
1889
+ */
1890
+ const OUT_OF_CREDITS_PATTERNS = [
1891
+ /out of (?:usage )?credits/i,
1892
+ /spend[\s-]?limit/i,
1893
+ ];
1894
+ export function detectOutOfCredits(text) {
1895
+ return OUT_OF_CREDITS_PATTERNS.some(pattern => pattern.test(text));
1896
+ }
1897
+ export function classifyClaudeRunRefusal(output, exitCode) {
1898
+ const sessionLimitReset = parseClaudeSessionLimitReset(output);
1899
+ if (sessionLimitReset)
1900
+ return { action: 'note_session', resetsAt: sessionLimitReset };
1901
+ if (detectOutOfCredits(output))
1902
+ return { action: 'note_out_of_credits' };
1903
+ if (exitCode === 0)
1904
+ return { action: 'clear' };
1905
+ return { action: 'none' };
1906
+ }
1868
1907
  /**
1869
1908
  * Patterns that indicate an authentication failure — the agent is logged out,
1870
1909
  * its token was revoked, or the session expired. These are the user-visible
@@ -2086,12 +2125,26 @@ export async function runWithFallback(options) {
2086
2125
  throw err;
2087
2126
  }
2088
2127
  const output = `${result.stderr}\n${result.stdout}`;
2128
+ // Persist a per-account refusal marker so rotation stops re-picking a
2129
+ // known-dead account: a session-limit recovers on its clock, a billing
2130
+ // exhaustion (tokens/credits) recovers only on a later successful run, and a
2131
+ // clean run clears any stale marker. Decision extracted + unit-tested in
2132
+ // classifyClaudeRunRefusal.
2089
2133
  const sessionLimitReset = agent === 'claude' ? parseClaudeSessionLimitReset(output) : null;
2090
- if (sessionLimitReset && version) {
2091
- const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
2092
- const usageKey = getUsageLookupKey(account);
2093
- if (usageKey)
2094
- noteClaudeSessionLimit(usageKey, sessionLimitReset);
2134
+ if (agent === 'claude' && version) {
2135
+ const refusal = classifyClaudeRunRefusal(output, result.exitCode ?? 1);
2136
+ if (refusal.action !== 'none') {
2137
+ const account = await getAccountInfo(agent, getVersionHomePath(agent, version));
2138
+ const usageKey = getUsageLookupKey(account);
2139
+ if (usageKey) {
2140
+ if (refusal.action === 'note_session')
2141
+ noteClaudeSessionLimit(usageKey, refusal.resetsAt);
2142
+ else if (refusal.action === 'note_out_of_credits')
2143
+ noteClaudeOutOfCredits(usageKey);
2144
+ else if (refusal.action === 'clear')
2145
+ clearClaudeAccountRefusal(usageKey);
2146
+ }
2147
+ }
2095
2148
  }
2096
2149
  if (result.exitCode === 0 && !sessionLimitReset)
2097
2150
  return 0;
@@ -0,0 +1,29 @@
1
+ import { type ActiveSession } from '../session/active.js';
2
+ import { type MessageReceipt } from './feed.js';
3
+ export interface VerifiedOperator {
4
+ id?: string;
5
+ verified: boolean;
6
+ label?: string;
7
+ }
8
+ export interface FeedAnswerResult {
9
+ status: 'delivered' | 'already_answered';
10
+ receipt: MessageReceipt;
11
+ }
12
+ /** Atomically claim the first answer, then route it over the session's recorded reply rail. */
13
+ export declare function claimAndRouteAttentionAnswer(input: {
14
+ attentionKey: string;
15
+ choiceId?: string;
16
+ text?: string;
17
+ operator: VerifiedOperator;
18
+ feedRoot?: string;
19
+ mailboxRoot?: string;
20
+ sessions?: ActiveSession[];
21
+ }): Promise<FeedAnswerResult>;
22
+ /** Forward a fleet attention answer to the device that owns its scope. */
23
+ export declare function forwardFeedAnswer(input: {
24
+ host: string;
25
+ attentionKey: string;
26
+ choiceId?: string;
27
+ text?: string;
28
+ operatorId?: string;
29
+ }): Promise<FeedAnswerResult>;