@phnx-labs/agents-cli 1.20.87 → 1.20.89

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 (94) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +12 -4
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/commands.js +7 -7
  5. package/dist/commands/doctor.d.ts +0 -19
  6. package/dist/commands/doctor.js +219 -305
  7. package/dist/commands/exec.js +7 -19
  8. package/dist/commands/factory.js +26 -2
  9. package/dist/commands/funnel.js +16 -1
  10. package/dist/commands/inspect.js +3 -5
  11. package/dist/commands/menubar.js +117 -34
  12. package/dist/commands/routines.js +25 -3
  13. package/dist/commands/secrets-rotate-passphrase.d.ts +17 -0
  14. package/dist/commands/secrets-rotate-passphrase.js +96 -0
  15. package/dist/commands/secrets.js +2 -0
  16. package/dist/commands/sessions.d.ts +7 -1
  17. package/dist/commands/sessions.js +40 -12
  18. package/dist/commands/ssh.js +3 -3
  19. package/dist/commands/usage.d.ts +3 -2
  20. package/dist/commands/usage.js +2 -9
  21. package/dist/commands/webhook.js +7 -2
  22. package/dist/lib/agents.d.ts +31 -1
  23. package/dist/lib/agents.js +55 -0
  24. package/dist/lib/command-skills.d.ts +10 -0
  25. package/dist/lib/command-skills.js +14 -0
  26. package/dist/lib/commands.js +28 -2
  27. package/dist/lib/daemon.d.ts +29 -0
  28. package/dist/lib/daemon.js +75 -6
  29. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  30. package/dist/lib/devices/doctor-findings.js +893 -0
  31. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  32. package/dist/lib/devices/fleet-divergence.js +34 -10
  33. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  34. package/dist/lib/devices/fleet-inventory.js +56 -8
  35. package/dist/lib/events.d.ts +1 -1
  36. package/dist/lib/exec.d.ts +14 -3
  37. package/dist/lib/exec.js +41 -8
  38. package/dist/lib/factory/snapshot.d.ts +78 -0
  39. package/dist/lib/factory/snapshot.js +209 -0
  40. package/dist/lib/fs-atomic.d.ts +14 -1
  41. package/dist/lib/fs-atomic.js +35 -3
  42. package/dist/lib/funnel.d.ts +1 -0
  43. package/dist/lib/funnel.js +8 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -2
  47. package/dist/lib/menubar/install-menubar.d.ts +53 -2
  48. package/dist/lib/menubar/install-menubar.js +183 -28
  49. package/dist/lib/platform/process.d.ts +2 -0
  50. package/dist/lib/platform/process.js +5 -3
  51. package/dist/lib/project-resources.js +34 -20
  52. package/dist/lib/resources.d.ts +8 -0
  53. package/dist/lib/resources.js +34 -1
  54. package/dist/lib/routines-placement.d.ts +2 -1
  55. package/dist/lib/routines-placement.js +8 -4
  56. package/dist/lib/routines.d.ts +57 -1
  57. package/dist/lib/routines.js +74 -1
  58. package/dist/lib/runner.d.ts +16 -1
  59. package/dist/lib/runner.js +58 -16
  60. package/dist/lib/sandbox.d.ts +2 -0
  61. package/dist/lib/sandbox.js +38 -0
  62. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  63. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  64. package/dist/lib/secrets/bundles.js +9 -34
  65. package/dist/lib/secrets/filestore.d.ts +152 -34
  66. package/dist/lib/secrets/filestore.js +676 -123
  67. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  68. package/dist/lib/secrets/rc-hygiene.js +0 -24
  69. package/dist/lib/session/active.d.ts +6 -6
  70. package/dist/lib/session/active.js +6 -6
  71. package/dist/lib/session/discover.d.ts +5 -0
  72. package/dist/lib/session/discover.js +137 -1
  73. package/dist/lib/session/parse.d.ts +2 -0
  74. package/dist/lib/session/parse.js +76 -37
  75. package/dist/lib/session/remote-active.d.ts +4 -1
  76. package/dist/lib/session/remote-active.js +8 -2
  77. package/dist/lib/session/sync/agents.js +0 -0
  78. package/dist/lib/session/types.d.ts +1 -1
  79. package/dist/lib/session/types.js +1 -1
  80. package/dist/lib/session/viewing-in.d.ts +31 -0
  81. package/dist/lib/session/viewing-in.js +47 -0
  82. package/dist/lib/staleness/detectors/commands.js +14 -5
  83. package/dist/lib/staleness/types.d.ts +2 -0
  84. package/dist/lib/staleness/writers/commands.js +13 -7
  85. package/dist/lib/state.d.ts +17 -0
  86. package/dist/lib/state.js +30 -2
  87. package/dist/lib/triggers/handlers.d.ts +95 -0
  88. package/dist/lib/triggers/handlers.js +384 -0
  89. package/dist/lib/triggers/webhook.d.ts +10 -2
  90. package/dist/lib/triggers/webhook.js +65 -11
  91. package/dist/lib/usage.d.ts +72 -1
  92. package/dist/lib/usage.js +21 -27
  93. package/dist/lib/versions.js +30 -13
  94. 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
  }
@@ -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' | 'friction' | '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' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.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;
@@ -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
@@ -124,10 +124,35 @@ export function resolveMode(agent, requested) {
124
124
  * downgraded. This is the single source of truth shared by buildExecCommand
125
125
  * (agents run / teams) and the routine runner.
126
126
  */
127
- export function resolveHeadlessMode(agent, requested, interactive) {
127
+ export function resolveHeadlessMode(agent, requested, interactive, warningContext, warningState) {
128
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
+ }
129
154
  if (!interactive && mode === 'plan' && AGENTS[agent].capabilities.headlessPlan === false) {
130
- 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`);
131
156
  return resolveMode(agent, 'auto');
132
157
  }
133
158
  return mode;
@@ -425,7 +450,6 @@ export const AGENT_COMMANDS = {
425
450
  base: ['cursor-agent'],
426
451
  promptFlag: '-p',
427
452
  modeFlags: {
428
- // cursor-agent has no read-only flag; we only expose edit + skip.
429
453
  edit: [],
430
454
  skip: ['-f'],
431
455
  },
@@ -669,13 +693,18 @@ export function buildExecCommand(options) {
669
693
  // degrades to `auto` with a stderr warning (see resolveHeadlessMode)
670
694
  // - `skip` on an unsupported agent → throws a clear error
671
695
  // After resolution, the chosen mode is guaranteed to be in template.modeFlags.
672
- const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive);
696
+ const resolvedMode = resolveHeadlessMode(options.agent, normalizeMode(options.mode), interactive, options.modeWarningContext, options.modeWarningState);
673
697
  const modeFlags = template.modeFlags[resolvedMode];
674
698
  if (!modeFlags) {
675
699
  // Defense in depth: would only fire if AGENTS.capabilities.modes and
676
700
  // AGENT_COMMANDS.modeFlags drifted apart. Tests assert they agree.
677
701
  throw new Error(`Internal error: ${options.agent} declares '${resolvedMode}' in capabilities.modes but has no entry in AGENT_COMMANDS.modeFlags.${resolvedMode}.`);
678
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
+ }
679
708
  // Codex's workspace-write sandbox blocks $HOME (verified against the live CLI
680
709
  // and OpenAI's sandbox docs: writable roots extend scope "without removing the
681
710
  // sandbox entirely"). But the model routinely shells out to `agents ...`, whose
@@ -1269,7 +1298,10 @@ async function spawnAgent(options) {
1269
1298
  agent: options.agent,
1270
1299
  version: options.version,
1271
1300
  cwd: options.cwd || process.cwd(),
1272
- 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)),
1273
1305
  model: options.model,
1274
1306
  interactive,
1275
1307
  sessionId: options.sessionId,
@@ -1549,6 +1581,7 @@ export function detectRateLimit(text) {
1549
1581
  export const AUTH_FAILURE_PATTERNS = [
1550
1582
  /OAuth (?:access token has been revoked|session expired)/i,
1551
1583
  /(?:Please run|run) \/login/i,
1584
+ /Please run 'agent login' first/i,
1552
1585
  /\bNot logged in\b/i,
1553
1586
  /Invalid authentication credentials/i,
1554
1587
  /Failed to authenticate/i,
@@ -1573,11 +1606,11 @@ export function detectAuthFailure(text) {
1573
1606
  * reason logic can never catch it — the `error:"authentication_failed"` marker
1574
1607
  * and the `result`+`is_error` text are the reliable signals.
1575
1608
  *
1576
- * Gated on the Claude stream-json shape; other agents don't emit these fields,
1577
- * 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.
1578
1611
  */
1579
1612
  export function detectAuthFailureEvent(logText, agent) {
1580
- if (agent !== 'claude')
1613
+ if (agent !== 'claude' && agent !== 'cursor')
1581
1614
  return false;
1582
1615
  const lines = logText.split('\n');
1583
1616
  for (const line of lines) {
@@ -0,0 +1,78 @@
1
+ import { type ActiveSession } from '../session/active.js';
2
+ import { serializeActiveSessionsForJson } from '../../commands/sessions.js';
3
+ import { readAuthHealthCache, type AuthVerdict } from '../auth-health.js';
4
+ import { readStatsCache } from '../devices/stats-cache.js';
5
+ export declare const FACTORY_PROJECTS: readonly [{
6
+ readonly name: "Prix";
7
+ readonly repo: "phnx-labs/prix";
8
+ }, {
9
+ readonly name: "Rush App";
10
+ readonly repo: "phnx-labs/rush";
11
+ }, {
12
+ readonly name: "Rush CLI";
13
+ readonly repo: "phnx-labs/rush-cli";
14
+ }, {
15
+ readonly name: "Agents CLI";
16
+ readonly repo: "phnx-labs/agents-cli";
17
+ }, {
18
+ readonly name: "Linear CLI";
19
+ readonly repo: "phnx-labs/linear-cli";
20
+ }];
21
+ export interface FactoryConfig {
22
+ source: 'default' | 'file';
23
+ ceiling: number;
24
+ max_dispatch_per_tick: number;
25
+ per_project: Record<string, {
26
+ weight: number;
27
+ cap: number;
28
+ }>;
29
+ idle_boxes: string[];
30
+ digest: {
31
+ times: string[];
32
+ tz: string;
33
+ };
34
+ }
35
+ export interface FactorySnapshot {
36
+ generatedAt: string;
37
+ sessions: ReturnType<typeof serializeActiveSessionsForJson>;
38
+ queues: Record<string, {
39
+ todo: number;
40
+ inProgress: number;
41
+ blocked: number;
42
+ }>;
43
+ prs: Array<{
44
+ repo: string;
45
+ number: number;
46
+ ci: string;
47
+ review: string;
48
+ mergeable: string;
49
+ }>;
50
+ devices: Array<{
51
+ name: string;
52
+ load: number | null;
53
+ idle: boolean;
54
+ }>;
55
+ recentRuns: Array<{
56
+ routine: string;
57
+ status: string;
58
+ durationMs: number | null;
59
+ }>;
60
+ auth: {
61
+ claude: AuthVerdict | null;
62
+ };
63
+ config: FactoryConfig;
64
+ }
65
+ export interface SnapshotDependencies {
66
+ home: string;
67
+ now: () => Date;
68
+ activeSessions: () => Promise<ActiveSession[]>;
69
+ run: (file: string, args: string[]) => Promise<string>;
70
+ readAuth: () => ReturnType<typeof readAuthHealthCache>;
71
+ readDeviceStats: () => ReturnType<typeof readStatsCache>;
72
+ }
73
+ export declare function readFactoryConfig(home: string): FactoryConfig;
74
+ export declare function queueCounts(todoPayload: unknown, openPayload: unknown): FactorySnapshot['queues'][string];
75
+ export declare function parsePullRequests(repo: string, payload: unknown): FactorySnapshot['prs'];
76
+ export declare function parseDevices(payload: unknown, cached?: ReturnType<typeof readStatsCache>): FactorySnapshot['devices'];
77
+ export declare function readRecentRuns(home: string, limit?: number): FactorySnapshot['recentRuns'];
78
+ export declare function buildFactorySnapshot(overrides?: Partial<SnapshotDependencies>): Promise<FactorySnapshot>;