@phnx-labs/agents-cli 1.20.86 → 1.20.88

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 (82) hide show
  1. package/CHANGELOG.md +172 -0
  2. package/README.md +3 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/doctor.d.ts +0 -19
  5. package/dist/commands/doctor.js +219 -305
  6. package/dist/commands/exec.js +7 -19
  7. package/dist/commands/inspect.js +3 -5
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/sessions-browser.d.ts +18 -0
  10. package/dist/commands/sessions-browser.js +126 -24
  11. package/dist/commands/sessions-picker.d.ts +21 -8
  12. package/dist/commands/sessions-picker.js +83 -7
  13. package/dist/commands/sessions.d.ts +19 -0
  14. package/dist/commands/sessions.js +148 -18
  15. package/dist/commands/ssh.js +62 -4
  16. package/dist/commands/teams-picker.d.ts +2 -0
  17. package/dist/commands/teams-picker.js +2 -1
  18. package/dist/commands/teams.d.ts +4 -1
  19. package/dist/commands/teams.js +106 -70
  20. package/dist/commands/usage.d.ts +3 -2
  21. package/dist/commands/usage.js +2 -9
  22. package/dist/commands/view.js +14 -3
  23. package/dist/index.js +31 -1
  24. package/dist/lib/agents.d.ts +31 -1
  25. package/dist/lib/agents.js +55 -0
  26. package/dist/lib/claude-account-token.d.ts +12 -0
  27. package/dist/lib/claude-account-token.js +63 -0
  28. package/dist/lib/command-skills.d.ts +10 -0
  29. package/dist/lib/command-skills.js +14 -0
  30. package/dist/lib/commands.js +19 -1
  31. package/dist/lib/daemon.js +17 -2
  32. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  33. package/dist/lib/devices/doctor-findings.js +893 -0
  34. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  35. package/dist/lib/devices/fleet-divergence.js +34 -10
  36. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  37. package/dist/lib/devices/fleet-inventory.js +56 -8
  38. package/dist/lib/devices/registry.d.ts +25 -0
  39. package/dist/lib/devices/registry.js +82 -1
  40. package/dist/lib/events.d.ts +8 -1
  41. package/dist/lib/events.js +13 -0
  42. package/dist/lib/exec.d.ts +14 -3
  43. package/dist/lib/exec.js +51 -9
  44. package/dist/lib/format.d.ts +7 -0
  45. package/dist/lib/format.js +11 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  47. package/dist/lib/project-resources.js +34 -20
  48. package/dist/lib/runner.d.ts +14 -1
  49. package/dist/lib/runner.js +37 -8
  50. package/dist/lib/sandbox.d.ts +2 -0
  51. package/dist/lib/sandbox.js +38 -0
  52. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  53. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  54. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  55. package/dist/lib/secrets/rc-hygiene.js +0 -24
  56. package/dist/lib/session/active.d.ts +6 -6
  57. package/dist/lib/session/active.js +6 -6
  58. package/dist/lib/session/db.d.ts +21 -0
  59. package/dist/lib/session/db.js +45 -4
  60. package/dist/lib/session/discover.d.ts +5 -0
  61. package/dist/lib/session/discover.js +137 -1
  62. package/dist/lib/session/parse.d.ts +2 -0
  63. package/dist/lib/session/parse.js +76 -37
  64. package/dist/lib/session/remote-list.d.ts +7 -0
  65. package/dist/lib/session/remote-list.js +8 -4
  66. package/dist/lib/session/state.js +69 -2
  67. package/dist/lib/session/sync/agents.js +0 -0
  68. package/dist/lib/session/team-filter.d.ts +22 -3
  69. package/dist/lib/session/team-filter.js +106 -17
  70. package/dist/lib/session/types.d.ts +9 -1
  71. package/dist/lib/session/types.js +1 -1
  72. package/dist/lib/signin-badge.d.ts +17 -0
  73. package/dist/lib/signin-badge.js +19 -0
  74. package/dist/lib/staleness/detectors/commands.js +14 -5
  75. package/dist/lib/staleness/types.d.ts +2 -0
  76. package/dist/lib/staleness/writers/commands.js +13 -7
  77. package/dist/lib/state.d.ts +2 -0
  78. package/dist/lib/state.js +2 -0
  79. package/dist/lib/usage.d.ts +72 -1
  80. package/dist/lib/usage.js +22 -87
  81. package/dist/lib/versions.js +30 -13
  82. package/package.json +1 -1
@@ -40,6 +40,23 @@ export interface RepoState {
40
40
  /** True when the working tree has uncommitted changes. */
41
41
  dirty: boolean;
42
42
  }
43
+ /**
44
+ * Per-version sign-in state a device self-reports, so the fleet doctor can show
45
+ * every installed version's account (and a provable logged-out) without a second
46
+ * SSH round-trip. `provable` is true only when the credential is absent from BOTH
47
+ * the version home and the active/global HOME (see `credentialPresence`); an
48
+ * unprovable absence (opaque/keychain agent) is a warning, not a critical.
49
+ */
50
+ export interface FleetVersionSignIn {
51
+ version: string;
52
+ /** A usable local credential was found for this version (or shared globally). */
53
+ signedIn: boolean;
54
+ /** Human account label (email, org badge, or opaque id), when derivable. */
55
+ account: string | null;
56
+ /** True only when a logged-out state is PROVABLE (credential absent per-version
57
+ * AND globally) — the caller gates a critical on this. */
58
+ provable: boolean;
59
+ }
43
60
  /**
44
61
  * The self-reported harness inventory a single device emits in `doctor --json`.
45
62
  * Comparable device-to-device with no further probing.
@@ -55,6 +72,11 @@ export interface FleetInventory {
55
72
  agents: RepoState | null;
56
73
  system: RepoState | null;
57
74
  };
75
+ /** Per-version sign-in state per agent id, for the fleet doctor's accounts
76
+ * line and cross-fleet logged-out criticals. Optional — an older CLI that
77
+ * predates this field omits it, and the caller degrades to a warning
78
+ * ("older agents-cli — can't report per-version sign-in"). */
79
+ signIn?: Record<string, FleetVersionSignIn[]>;
58
80
  }
59
81
  /** A device's inventory paired with its name (and reachability). A device that
60
82
  * was unreachable / failed to report carries `inventory: null` and is skipped
@@ -45,20 +45,25 @@ function repoLabel(repo) {
45
45
  }
46
46
  /** Describe how a remote repo state diverges from the local baseline, or null
47
47
  * when they match. Compares HEAD first (the load-bearing difference), then
48
- * branch, then a dirty tree on either side (naming which side is dirty). */
48
+ * branch, then a dirty tree on either side.
49
+ *
50
+ * HEAD and branch differences are symmetric — by convention the remote is the
51
+ * one "diverged from the baseline" — but a dirty tree belongs to exactly one
52
+ * box, and blaming the wrong one sends the user to a clean machine. */
49
53
  function describeRepoDrift(local, remote) {
50
54
  if (local.head && remote.head && local.head !== remote.head) {
51
- return `HEAD ${remote.head} != local ${local.head}`;
55
+ return { detail: `repo diverged: HEAD ${remote.head} != local ${local.head}`, blame: 'remote' };
52
56
  }
53
57
  if (local.branch !== remote.branch) {
54
- return `branch ${remote.branch ?? 'detached'} != local ${local.branch ?? 'detached'}`;
58
+ return {
59
+ detail: `repo diverged: branch ${remote.branch ?? 'detached'} != local ${local.branch ?? 'detached'}`,
60
+ blame: 'remote',
61
+ };
55
62
  }
56
- // Flag a dirty tree on EITHER side (symmetric with HEAD/branch above), and name
57
- // the side that has the uncommitted changes — the remote, or the local baseline.
58
63
  if (remote.dirty !== local.dirty) {
59
64
  return remote.dirty
60
- ? 'remote tree has uncommitted changes'
61
- : 'local tree has uncommitted changes';
65
+ ? { detail: 'tree has uncommitted changes', blame: 'remote' }
66
+ : { detail: 'tree has uncommitted changes', blame: 'local' };
62
67
  }
63
68
  return null;
64
69
  }
@@ -78,6 +83,9 @@ export function compareFleetInventories(devices, baselineName) {
78
83
  const comparedDevices = [];
79
84
  const skippedDevices = [];
80
85
  const divergences = [];
86
+ /** Repos already reported as the BASELINE's problem — one dirty local tree is
87
+ * one finding, not one per remote compared against. */
88
+ const localBlamed = new Set();
81
89
  if (!baseline) {
82
90
  // No local baseline to compare against — record every remote as skipped so
83
91
  // the caller can say so, but emit no divergences (we can't know the truth).
@@ -163,15 +171,31 @@ export function compareFleetInventories(devices, baselineName) {
163
171
  if (!localRepo || !remoteRepo)
164
172
  continue; // one side isn't a readable repo
165
173
  const drift = describeRepoDrift(localRepo, remoteRepo);
166
- if (drift) {
174
+ if (!drift)
175
+ continue;
176
+ if (drift.blame === 'local') {
177
+ // The BASELINE owns this one. File it against the baseline, and only
178
+ // once: the local tree being dirty is a single fact about this machine,
179
+ // not one problem per remote we happened to compare against.
180
+ if (localBlamed.has(repo))
181
+ continue;
182
+ localBlamed.add(repo);
167
183
  divergences.push({
168
184
  kind: 'repo-drift',
169
- device: remote.name,
185
+ device: baselineName,
170
186
  category: repo,
171
187
  name: repoLabel(repo),
172
- message: `${remote.name} ${repoLabel(repo)} repo diverged: ${drift}`,
188
+ message: `${baselineName} ${repoLabel(repo)} ${drift.detail}`,
173
189
  });
190
+ continue;
174
191
  }
192
+ divergences.push({
193
+ kind: 'repo-drift',
194
+ device: remote.name,
195
+ category: repo,
196
+ name: repoLabel(repo),
197
+ message: `${remote.name} ${repoLabel(repo)} ${drift.detail}`,
198
+ });
175
199
  }
176
200
  }
177
201
  divergences.sort((a, b) => a.device.localeCompare(b.device) ||
@@ -8,12 +8,23 @@
8
8
  * FleetInventory} that both the local baseline and every remote box serialize
9
9
  * into their doctor payload; the comparator then diffs those payloads.
10
10
  */
11
- import { type FleetInventory } from './fleet-divergence.js';
11
+ import { type FleetInventory, type FleetVersionSignIn } from './fleet-divergence.js';
12
+ /**
13
+ * Probe every installed version's sign-in state, per agent. For each version we
14
+ * read its own home's account (via the shim-set config dir) and, when logged
15
+ * out, decide whether that is PROVABLE: an agent that can't be inspected
16
+ * (`!supportsAccountInspection`) never claims logged out, and a version that
17
+ * merely lacks its own credential but shares the global login (`active`) is
18
+ * signed in, not out. Pure reads (file-presence + cheap account parse), no
19
+ * network, no keychain prompt. Only agents with at least one installed version
20
+ * appear, so the map lines up with {@link FleetInventory.agentVersions}.
21
+ */
22
+ export declare function collectLocalFleetSignIn(): Promise<Record<string, FleetVersionSignIn[]>>;
12
23
  /**
13
24
  * Collect this machine's harness inventory: installed resources per kind,
14
- * installed version ids per agent, and `.agents`/`.system` repo state. Pure
15
- * reads — never mutates the install. `promptcuts` (a single present/absent bit
16
- * in {@link getAvailableResources}) is surfaced as a one-element list so it
17
- * compares like any other named resource.
25
+ * installed version ids per agent, `.agents`/`.system` repo state, and
26
+ * per-version sign-in. Pure reads — never mutates the install. `promptcuts` (a
27
+ * single present/absent bit in {@link getAvailableResources}) is surfaced as a
28
+ * one-element list so it compares like any other named resource.
18
29
  */
19
- export declare function collectLocalFleetInventory(cwd?: string): FleetInventory;
30
+ export declare function collectLocalFleetInventory(cwd?: string): Promise<FleetInventory>;
@@ -8,24 +8,71 @@
8
8
  * FleetInventory} that both the local baseline and every remote box serialize
9
9
  * into their doctor payload; the comparator then diffs those payloads.
10
10
  */
11
- import { getAvailableResources, listInstalledVersions } from '../versions.js';
11
+ import { getAvailableResources, getVersionHomePath, listInstalledVersions } from '../versions.js';
12
12
  import { getUserAgentsDir, getSystemAgentsDir } from '../state.js';
13
13
  import { readRepoState } from '../git.js';
14
- import { ALL_AGENT_IDS } from '../agents.js';
15
- import { FLEET_RESOURCE_KINDS } from './fleet-divergence.js';
14
+ import { ALL_AGENT_IDS, accountDisplayLabel, credentialPresence, getAccountInfo, supportsAccountInspection, } from '../agents.js';
15
+ import { FLEET_RESOURCE_KINDS, } from './fleet-divergence.js';
16
16
  function toRepoState(snap) {
17
17
  if (!snap)
18
18
  return null;
19
19
  return { branch: snap.branch, head: snap.head, dirty: snap.dirty };
20
20
  }
21
+ /**
22
+ * Probe every installed version's sign-in state, per agent. For each version we
23
+ * read its own home's account (via the shim-set config dir) and, when logged
24
+ * out, decide whether that is PROVABLE: an agent that can't be inspected
25
+ * (`!supportsAccountInspection`) never claims logged out, and a version that
26
+ * merely lacks its own credential but shares the global login (`active`) is
27
+ * signed in, not out. Pure reads (file-presence + cheap account parse), no
28
+ * network, no keychain prompt. Only agents with at least one installed version
29
+ * appear, so the map lines up with {@link FleetInventory.agentVersions}.
30
+ */
31
+ export async function collectLocalFleetSignIn() {
32
+ const out = {};
33
+ await Promise.all(ALL_AGENT_IDS.map(async (agent) => {
34
+ const versions = listInstalledVersions(agent);
35
+ if (versions.length === 0)
36
+ return;
37
+ const rows = await Promise.all(versions.map(async (version) => {
38
+ const home = getVersionHomePath(agent, version);
39
+ let signedIn = false;
40
+ let account = null;
41
+ try {
42
+ const info = await getAccountInfo(agent, home);
43
+ signedIn = info.signedIn;
44
+ account = accountDisplayLabel(info) || null;
45
+ }
46
+ catch {
47
+ /* advisory only — treat as logged out, provability decided below */
48
+ }
49
+ // Provable logout: the agent is inspectable AND the credential is absent
50
+ // from BOTH the version home and the active/global HOME. An opaque or
51
+ // keychain-only agent, or one sharing the global login, is never a
52
+ // provable logout.
53
+ let provable = false;
54
+ if (!signedIn && supportsAccountInspection(agent)) {
55
+ const presence = credentialPresence(agent, home);
56
+ // `knownLocation` is load-bearing: an agent can sit in the inspection
57
+ // set with no credential path (cursor does), and then both probes are
58
+ // false only because there is nothing to look for. Treating that as a
59
+ // provable logout prints a CRITICAL for a version that is signed in.
60
+ provable = presence.knownLocation && !presence.perVersion && !presence.active;
61
+ }
62
+ return { version, signedIn, account, provable };
63
+ }));
64
+ out[agent] = rows;
65
+ }));
66
+ return out;
67
+ }
21
68
  /**
22
69
  * Collect this machine's harness inventory: installed resources per kind,
23
- * installed version ids per agent, and `.agents`/`.system` repo state. Pure
24
- * reads — never mutates the install. `promptcuts` (a single present/absent bit
25
- * in {@link getAvailableResources}) is surfaced as a one-element list so it
26
- * compares like any other named resource.
70
+ * installed version ids per agent, `.agents`/`.system` repo state, and
71
+ * per-version sign-in. Pure reads — never mutates the install. `promptcuts` (a
72
+ * single present/absent bit in {@link getAvailableResources}) is surfaced as a
73
+ * one-element list so it compares like any other named resource.
27
74
  */
28
- export function collectLocalFleetInventory(cwd = process.cwd()) {
75
+ export async function collectLocalFleetInventory(cwd = process.cwd()) {
29
76
  const available = getAvailableResources(cwd);
30
77
  const resources = {};
31
78
  for (const kind of FLEET_RESOURCE_KINDS) {
@@ -53,5 +100,6 @@ export function collectLocalFleetInventory(cwd = process.cwd()) {
53
100
  agents: toRepoState(readRepoState(getUserAgentsDir())),
54
101
  system: toRepoState(readRepoState(getSystemAgentsDir())),
55
102
  },
103
+ signIn: await collectLocalFleetSignIn(),
56
104
  };
57
105
  }
@@ -142,3 +142,28 @@ export declare function addIgnored(name: string): Promise<Set<string>>;
142
142
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
143
143
  * not ignored. */
144
144
  export declare function removeIgnored(name: string): Promise<boolean>;
145
+ /**
146
+ * Auto-launch preferences: which registered devices are eligible for Factory's
147
+ * auto-host selection, and which are preferred. Stored as a sibling to the
148
+ * registry and ignore-list under ~/.agents/.history/devices/.
149
+ */
150
+ export interface AutoLaunchPreference {
151
+ enabled?: boolean;
152
+ preferred?: boolean;
153
+ }
154
+ export interface AutoLaunchPreferences {
155
+ devices: Record<string, AutoLaunchPreference>;
156
+ updatedAt: string;
157
+ }
158
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
159
+ export declare function loadAutoLaunchPreferences(): Promise<Record<string, AutoLaunchPreference>>;
160
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
161
+ export declare function isAutoLaunchEnabled(name: string): Promise<boolean>;
162
+ /** Set whether a device is enabled for auto-launch. Setting to the default
163
+ * (enabled) removes the entry to keep the file minimal. */
164
+ export declare function setAutoLaunchEnabled(name: string, enabled: boolean): Promise<void>;
165
+ /** True if the device is preferred for auto-launch ranking. */
166
+ export declare function isAutoLaunchPreferred(name: string): Promise<boolean>;
167
+ /** Set whether a device is preferred for auto-launch. Setting to the default
168
+ * (not preferred) removes the flag to keep the file minimal. */
169
+ export declare function setAutoLaunchPreferred(name: string, preferred: boolean): Promise<void>;
@@ -18,7 +18,7 @@ import * as fsSync from 'fs';
18
18
  import * as path from 'path';
19
19
  import { randomBytes } from 'crypto';
20
20
  import lockfile from 'proper-lockfile';
21
- import { getDevicesRegistryPath, getDevicesIgnoredPath } from '../state.js';
21
+ import { getDevicesRegistryPath, getDevicesIgnoredPath, getDevicesAutoLaunchPath } from '../state.js';
22
22
  /** A device's effective role, defaulting to `worker` when unset. */
23
23
  export function deviceRole(d) {
24
24
  return d.role ?? 'worker';
@@ -288,3 +288,84 @@ export async function removeIgnored(name) {
288
288
  return true;
289
289
  });
290
290
  }
291
+ function autoLaunchPath() {
292
+ return getDevicesAutoLaunchPath();
293
+ }
294
+ /** Load auto-launch preferences. Missing or malformed file => empty map. */
295
+ export async function loadAutoLaunchPreferences() {
296
+ const p = autoLaunchPath();
297
+ let raw;
298
+ try {
299
+ raw = await fs.readFile(p, 'utf-8');
300
+ }
301
+ catch (err) {
302
+ if (err && err.code === 'ENOENT')
303
+ return {};
304
+ throw err;
305
+ }
306
+ try {
307
+ const parsed = JSON.parse(raw);
308
+ return parsed.devices && typeof parsed.devices === 'object' ? parsed.devices : {};
309
+ }
310
+ catch (err) {
311
+ throw new Error(`Device auto-launch preferences corrupted at ${p}: ${err?.message ?? err}. Inspect and restore from backup.`);
312
+ }
313
+ }
314
+ /** True if the device is enabled for auto-launch. Missing entry defaults to true. */
315
+ export async function isAutoLaunchEnabled(name) {
316
+ assertValidDeviceName(name);
317
+ const prefs = await loadAutoLaunchPreferences();
318
+ return prefs[name]?.enabled !== false;
319
+ }
320
+ /** Set whether a device is enabled for auto-launch. Setting to the default
321
+ * (enabled) removes the entry to keep the file minimal. */
322
+ export async function setAutoLaunchEnabled(name, enabled) {
323
+ assertValidDeviceName(name);
324
+ const p = autoLaunchPath();
325
+ await withRegistryLock(p, async () => {
326
+ const prefs = await loadAutoLaunchPreferences();
327
+ if (enabled) {
328
+ if (prefs[name]) {
329
+ const { enabled: _, ...rest } = prefs[name];
330
+ if (Object.keys(rest).length === 0) {
331
+ delete prefs[name];
332
+ }
333
+ else {
334
+ prefs[name] = rest;
335
+ }
336
+ }
337
+ }
338
+ else {
339
+ prefs[name] = { ...prefs[name], enabled: false };
340
+ }
341
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
342
+ });
343
+ }
344
+ /** True if the device is preferred for auto-launch ranking. */
345
+ export async function isAutoLaunchPreferred(name) {
346
+ assertValidDeviceName(name);
347
+ const prefs = await loadAutoLaunchPreferences();
348
+ return prefs[name]?.preferred === true;
349
+ }
350
+ /** Set whether a device is preferred for auto-launch. Setting to the default
351
+ * (not preferred) removes the flag to keep the file minimal. */
352
+ export async function setAutoLaunchPreferred(name, preferred) {
353
+ assertValidDeviceName(name);
354
+ const p = autoLaunchPath();
355
+ await withRegistryLock(p, async () => {
356
+ const prefs = await loadAutoLaunchPreferences();
357
+ if (preferred) {
358
+ prefs[name] = { ...prefs[name], preferred: true };
359
+ }
360
+ else if (prefs[name]) {
361
+ const { preferred: _, ...rest } = prefs[name];
362
+ if (Object.keys(rest).length === 0) {
363
+ delete prefs[name];
364
+ }
365
+ else {
366
+ prefs[name] = rest;
367
+ }
368
+ }
369
+ await atomicWriteJson(p, { devices: prefs, updatedAt: new Date().toISOString() });
370
+ });
371
+ }
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import { type ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  export declare function levelFor(event: EventType): EventLevel;
18
18
  export interface EventMeta {
19
19
  ts: string;
@@ -156,6 +156,13 @@ export declare function emitCommand(command: string, args?: string[], payload?:
156
156
  * Emit an error event with full details.
157
157
  */
158
158
  export declare function emitError(err: Error | string, payload?: EventPayload): void;
159
+ /**
160
+ * Emit a friction event — a structured, point-of-use record of a failure or
161
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
162
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
163
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
164
+ */
165
+ export declare function emitFriction(surface: string, failureId: string, payload?: EventPayload): void;
159
166
  /**
160
167
  * Remove log files older than the retention period.
161
168
  * Removes numbered gzip archives whose filesystem mtime exceeds retention.
@@ -523,6 +523,19 @@ export function emitError(err, payload = {}) {
523
523
  errorStack: truncate(error.stack, 1000),
524
524
  });
525
525
  }
526
+ /**
527
+ * Emit a friction event — a structured, point-of-use record of a failure or
528
+ * block the CLI just hit. `surface` is the subsystem (teams, browser, secrets,
529
+ * guard, …); `failureId` is a stable slug that lets the nightly routine group
530
+ * the same failure across sessions (e.g. 'remote-cwd-on-add', 'not-installed').
531
+ */
532
+ export function emitFriction(surface, failureId, payload = {}) {
533
+ emit('friction', {
534
+ ...payload,
535
+ surface,
536
+ failureId,
537
+ });
538
+ }
526
539
  // ─── Gzip rotation ──────────────────────────────────────────────────────────
527
540
  /** Rotate the active file while its append lock is held. */
528
541
  function maybeGzipRotateLocked(logPath) {
@@ -66,7 +66,14 @@ export declare function resolveMode(agent: AgentId, requested: Mode): Mode;
66
66
  * downgraded. This is the single source of truth shared by buildExecCommand
67
67
  * (agents run / teams) and the routine runner.
68
68
  */
69
- export declare function resolveHeadlessMode(agent: AgentId, requested: Mode, interactive: boolean): Mode;
69
+ export declare function resolveHeadlessMode(agent: AgentId, requested: Mode, interactive: boolean, warningContext?: string, warningState?: ModeWarningState): Mode;
70
+ export interface ModeWarningState {
71
+ /** Agents already warned about, so one run warns once per agent. A fallback
72
+ * chain degrades each agent independently and the agent that actually ran is
73
+ * usually not the first, so this cannot be a single boolean. */
74
+ emitted?: Set<AgentId>;
75
+ quiet?: boolean;
76
+ }
70
77
  /**
71
78
  * The mode an agent should run in when the caller has no preference.
72
79
  *
@@ -94,6 +101,10 @@ export interface ExecOptions {
94
101
  cwd?: string;
95
102
  /** Force headless mode even when no prompt is provided (e.g. piping via stdin). */
96
103
  headless?: boolean;
104
+ /** Prefix for mode-degradation warnings emitted by shared headless paths. */
105
+ modeWarningContext?: string;
106
+ /** Shared across command previews/spawns/loop iterations so degradation warns once. */
107
+ modeWarningState?: ModeWarningState;
97
108
  json?: boolean;
98
109
  model?: string;
99
110
  addDirs?: string[];
@@ -400,8 +411,8 @@ export declare function detectAuthFailure(text: string): boolean;
400
411
  * reason logic can never catch it — the `error:"authentication_failed"` marker
401
412
  * and the `result`+`is_error` text are the reliable signals.
402
413
  *
403
- * Gated on the Claude stream-json shape; other agents don't emit these fields,
404
- * so callers pass their agent and this returns false for non-claude.
414
+ * Gated on the Claude-compatible stream-json shape emitted by Claude and Cursor;
415
+ * callers pass their agent so unrelated stream formats cannot match by accident.
405
416
  */
406
417
  export declare function detectAuthFailureEvent(logText: string, agent: AgentId): boolean;
407
418
  /**
package/dist/lib/exec.js CHANGED
@@ -28,6 +28,7 @@ import { mailboxDir, isValidMailboxId } from './mailbox.js';
28
28
  import { composeWin32CommandLine } from './platform/index.js';
29
29
  import { isTmuxInstalled } from './tmux/binary.js';
30
30
  import { shellQuote } from './ssh-exec.js';
31
+ import { resolveClaudeSetupToken } from './claude-account-token.js';
31
32
  /**
32
33
  * Map a raw mode string (CLI flag, YAML field, env var) to the canonical Mode.
33
34
  *
@@ -123,10 +124,35 @@ export function resolveMode(agent, requested) {
123
124
  * downgraded. This is the single source of truth shared by buildExecCommand
124
125
  * (agents run / teams) and the routine runner.
125
126
  */
126
- export function resolveHeadlessMode(agent, requested, interactive) {
127
+ export function resolveHeadlessMode(agent, requested, interactive, warningContext, warningState) {
127
128
  const mode = resolveMode(agent, requested);
129
+ const warn = (message) => {
130
+ if (warningState?.quiet)
131
+ return;
132
+ if (warningState) {
133
+ warningState.emitted ??= new Set();
134
+ if (warningState.emitted.has(agent))
135
+ return;
136
+ warningState.emitted.add(agent);
137
+ }
138
+ process.stderr.write(message);
139
+ };
140
+ if (mode !== requested) {
141
+ const subject = warningContext ? `${warningContext}: ` : '';
142
+ if (requested === 'plan') {
143
+ const limitation = agent === 'cursor'
144
+ ? "cursor's read-only plan mode is not enabled in this build"
145
+ : `${agent} has no read-only 'plan' mode`;
146
+ warn(`[agents] ${subject}${limitation}; ` +
147
+ `running '${mode}' (writable) instead${agent === 'cursor' ? ' (RUSH-2101)' : ''}. ` +
148
+ `Pass --mode ${mode} to silence this.\n`);
149
+ }
150
+ else {
151
+ warn(`[agents] ${subject}${agent} has no '${requested}' mode; using '${mode}'.\n`);
152
+ }
153
+ }
128
154
  if (!interactive && mode === 'plan' && AGENTS[agent].capabilities.headlessPlan === false) {
129
- process.stderr.write(`warning: ${agent} has no headless plan mode; running --mode auto instead\n`);
155
+ warn(`warning: ${agent} has no headless plan mode; running --mode auto instead\n`);
130
156
  return resolveMode(agent, 'auto');
131
157
  }
132
158
  return mode;
@@ -259,7 +285,15 @@ export function buildExecEnv(options) {
259
285
  ? resolvedVersion
260
286
  : (resolvedVersion && isVersionInstalled('claude', resolvedVersion) ? resolvedVersion : null);
261
287
  if (version) {
262
- result.CLAUDE_CONFIG_DIR = path.join(getVersionHomePath('claude', version), '.claude');
288
+ const versionHome = getVersionHomePath('claude', version);
289
+ result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
290
+ const setupToken = resolveClaudeSetupToken(versionHome);
291
+ if (setupToken) {
292
+ // A token keyed to this version home's own account replaces any ambient
293
+ // shared value inherited from the launcher. options.env still wins below
294
+ // for explicit caller overrides.
295
+ result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
296
+ }
263
297
  // A managed pin lives in a per-version dir; Claude Code's own background
264
298
  // auto-updater would rewrite that pinned binary in place (and has left it
265
299
  // half-swapped and broken). Disable it so a pin stays a pin. Honor an
@@ -416,7 +450,6 @@ export const AGENT_COMMANDS = {
416
450
  base: ['cursor-agent'],
417
451
  promptFlag: '-p',
418
452
  modeFlags: {
419
- // cursor-agent has no read-only flag; we only expose edit + skip.
420
453
  edit: [],
421
454
  skip: ['-f'],
422
455
  },
@@ -660,13 +693,18 @@ export function buildExecCommand(options) {
660
693
  // degrades to `auto` with a stderr warning (see resolveHeadlessMode)
661
694
  // - `skip` on an unsupported agent → throws a clear error
662
695
  // After resolution, the chosen mode is guaranteed to be in template.modeFlags.
663
- const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive);
696
+ const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive, options.modeWarningContext, options.modeWarningState);
664
697
  const modeFlags = template.modeFlags[resolvedMode];
665
698
  if (!modeFlags) {
666
699
  // Defense in depth: would only fire if AGENTS.capabilities.modes and
667
700
  // AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
668
701
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
669
702
  }
703
+ if (options.agent === 'cursor' && resolvedMode === 'edit' && !interactive) {
704
+ // A configured headless run is the workspace trust decision. Keep this
705
+ // narrower than --yolo/-f, which also bypasses permission checks.
706
+ cmd.push('--trust');
707
+ }
670
708
  // Codex's workspace-write sandbox blocks $HOME (verified against the live CLI
671
709
  // and OpenAI's sandbox docs: writable roots extend scope "without removing the
672
710
  // sandbox entirely"). But the model routinely shells out to `agents ...`, whose
@@ -1260,7 +1298,10 @@ async function spawnAgent(options) {
1260
1298
  agent: options.agent,
1261
1299
  version: options.version,
1262
1300
  cwd: options.cwd || process.cwd(),
1263
- mode: options.mode,
1301
+ // The mode that ran, not the one requested — `agents run` passes the
1302
+ // requested mode so the resolver can warn, but telemetry must agree with
1303
+ // the audit log. See RUSH-2106 for removing that ambiguity at the source.
1304
+ mode: resolveMode(options.agent, normalizeMode(options.mode)),
1264
1305
  model: options.model,
1265
1306
  interactive,
1266
1307
  sessionId: options.sessionId,
@@ -1540,6 +1581,7 @@ export function detectRateLimit(text) {
1540
1581
  export const AUTH_FAILURE_PATTERNS = [
1541
1582
  /OAuth (?:access token has been revoked|session expired)/i,
1542
1583
  /(?:Please run|run) \/login/i,
1584
+ /Please run 'agent login' first/i,
1543
1585
  /\bNot logged in\b/i,
1544
1586
  /Invalid authentication credentials/i,
1545
1587
  /Failed to authenticate/i,
@@ -1564,11 +1606,11 @@ export function detectAuthFailure(text) {
1564
1606
  * reason logic can never catch it — the `error:"authentication_failed"` marker
1565
1607
  * and the `result`+`is_error` text are the reliable signals.
1566
1608
  *
1567
- * Gated on the Claude stream-json shape; other agents don't emit these fields,
1568
- * so callers pass their agent and this returns false for non-claude.
1609
+ * Gated on the Claude-compatible stream-json shape emitted by Claude and Cursor;
1610
+ * callers pass their agent so unrelated stream formats cannot match by accident.
1569
1611
  */
1570
1612
  export function detectAuthFailureEvent(logText, agent) {
1571
- if (agent !== 'claude')
1613
+ if (agent !== 'claude' && agent !== 'cursor')
1572
1614
  return false;
1573
1615
  const lines = logText.split('\n');
1574
1616
  for (const line of lines) {
@@ -27,6 +27,13 @@ export declare function formatDie(msg: string, opts?: DieOptions): {
27
27
  * keep the original red-stderr behavior.
28
28
  */
29
29
  export declare function die(msg: string, code?: number, opts?: DieOptions): never;
30
+ /**
31
+ * `die()` with a structured friction event attached. Use this at CLI error
32
+ * chokepoints so the nightly routine can classify and rank recurring failures
33
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
34
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
35
+ */
36
+ export declare function dieFriction(surface: string, failureId: string, msg: string, code?: number, opts?: DieOptions): never;
30
37
  /**
31
38
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
32
39
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import chalk from 'chalk';
11
11
  import { readSync } from 'node:fs';
12
+ import { emitFriction } from './events.js';
12
13
  /**
13
14
  * Render a fatal error to the right stream. Pure — no I/O, no `process.exit` — so
14
15
  * the human-vs-agent split is unit-testable. A `--json` caller gets
@@ -43,6 +44,16 @@ export function die(msg, code = 1, opts = {}) {
43
44
  console.error(text);
44
45
  process.exit(code);
45
46
  }
47
+ /**
48
+ * `die()` with a structured friction event attached. Use this at CLI error
49
+ * chokepoints so the nightly routine can classify and rank recurring failures
50
+ * without re-parsing transcripts. `surface` is the subsystem (teams, browser,
51
+ * secrets, guard, …); `failureId` is a stable slug (e.g. 'remote-cwd-on-add').
52
+ */
53
+ export function dieFriction(surface, failureId, msg, code = 1, opts = {}) {
54
+ emitFriction(surface, failureId, { error: msg });
55
+ die(msg, code, opts);
56
+ }
46
57
  /**
47
58
  * Truncate `s` to at most `max` characters, appending a single-char ellipsis
48
59
  * (`…`) when shortened. Character-count based (not ANSI/width aware — use