@phnx-labs/agents-cli 1.20.87 → 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 (52) hide show
  1. package/CHANGELOG.md +60 -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.js +1 -0
  10. package/dist/commands/ssh.js +3 -3
  11. package/dist/commands/usage.d.ts +3 -2
  12. package/dist/commands/usage.js +2 -9
  13. package/dist/lib/agents.d.ts +31 -1
  14. package/dist/lib/agents.js +55 -0
  15. package/dist/lib/command-skills.d.ts +10 -0
  16. package/dist/lib/command-skills.js +14 -0
  17. package/dist/lib/commands.js +19 -1
  18. package/dist/lib/daemon.js +17 -2
  19. package/dist/lib/devices/doctor-findings.d.ts +167 -0
  20. package/dist/lib/devices/doctor-findings.js +893 -0
  21. package/dist/lib/devices/fleet-divergence.d.ts +22 -0
  22. package/dist/lib/devices/fleet-divergence.js +34 -10
  23. package/dist/lib/devices/fleet-inventory.d.ts +17 -6
  24. package/dist/lib/devices/fleet-inventory.js +56 -8
  25. package/dist/lib/exec.d.ts +14 -3
  26. package/dist/lib/exec.js +41 -8
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/project-resources.js +34 -20
  29. package/dist/lib/runner.d.ts +14 -1
  30. package/dist/lib/runner.js +37 -8
  31. package/dist/lib/sandbox.d.ts +2 -0
  32. package/dist/lib/sandbox.js +38 -0
  33. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  34. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  35. package/dist/lib/secrets/rc-hygiene.d.ts +0 -6
  36. package/dist/lib/secrets/rc-hygiene.js +0 -24
  37. package/dist/lib/session/active.d.ts +6 -6
  38. package/dist/lib/session/active.js +6 -6
  39. package/dist/lib/session/discover.d.ts +5 -0
  40. package/dist/lib/session/discover.js +137 -1
  41. package/dist/lib/session/parse.d.ts +2 -0
  42. package/dist/lib/session/parse.js +76 -37
  43. package/dist/lib/session/sync/agents.js +0 -0
  44. package/dist/lib/session/types.d.ts +1 -1
  45. package/dist/lib/session/types.js +1 -1
  46. package/dist/lib/staleness/detectors/commands.js +14 -5
  47. package/dist/lib/staleness/types.d.ts +2 -0
  48. package/dist/lib/staleness/writers/commands.js +13 -7
  49. package/dist/lib/usage.d.ts +72 -1
  50. package/dist/lib/usage.js +21 -27
  51. package/dist/lib/versions.js +30 -13
  52. 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
  }
@@ -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) {
@@ -2,7 +2,7 @@ import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
  import { AGENTS, agentConfigDirName, isAgentHardDeprecated } from './agents.js';
4
4
  import { supports } from './capabilities.js';
5
- import { buildCommandSkillContent, commandSkillName, readSkillSourceCommandMarker, shouldInstallCommandAsSkill } from './command-skills.js';
5
+ import { buildCommandSkillContent, commandSkillName, readSkillSourceCommandMarker, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill } from './command-skills.js';
6
6
  import { commandAppliesTo, parseCommandMetadata } from './commands.js';
7
7
  import { markdownToToml } from './convert.js';
8
8
  import { safeJoin } from './paths.js';
@@ -134,6 +134,7 @@ function skip(dest, projectRoot, result) {
134
134
  function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
135
135
  const cfg = AGENTS[agent];
136
136
  const commandsAsSkills = shouldInstallCommandAsSkill(agent, version);
137
+ const commandsAlsoAsSkills = shouldAlsoInstallCommandAsSkill(agent, version);
137
138
  const supportsCommands = supports(agent, 'commands', version).ok;
138
139
  if (!commandsAsSkills && !supportsCommands)
139
140
  return;
@@ -146,37 +147,50 @@ function syncProjectCommands(agent, version, projectAgentsDir, agentRoot, result
146
147
  const metadata = parseCommandMetadata(srcFile);
147
148
  if (!commandAppliesTo(agent, version, metadata).ok)
148
149
  continue;
149
- if (commandsAsSkills) {
150
+ const written = [];
151
+ if (commandsAsSkills || commandsAlsoAsSkills) {
150
152
  const sourceMarker = readSkillSourceCommandMarker(name, [path.join(projectAgentsDir, 'skills')]);
151
- if (pathExists(path.join(projectAgentsDir, 'skills', name)) && sourceMarker !== name)
152
- continue;
153
- const skillName = commandSkillName(name);
154
- const rel = path.join('skills', skillName);
155
- const destDir = path.join(agentRoot, rel);
156
- if (pathExists(destDir)) {
157
- skip(destDir, projectRoot, result);
153
+ if (pathExists(path.join(projectAgentsDir, 'skills', name)) && sourceMarker !== name) {
154
+ if (commandsAsSkills)
155
+ continue;
156
+ }
157
+ else {
158
+ const skillName = commandSkillName(name);
159
+ const rel = path.join('skills', skillName);
160
+ const destDir = path.join(agentRoot, rel);
161
+ if (pathExists(destDir)) {
162
+ skip(destDir, projectRoot, result);
163
+ }
164
+ else {
165
+ fs.mkdirSync(destDir, { recursive: true });
166
+ fs.writeFileSync(path.join(destDir, 'SKILL.md'), buildCommandSkillContent(name, srcFile), 'utf-8');
167
+ written.push(rel);
168
+ }
169
+ }
170
+ if (commandsAsSkills) {
171
+ if (written.length > 0)
172
+ record('commands', name, written, result, manifestPaths);
158
173
  continue;
159
174
  }
160
- fs.mkdirSync(destDir, { recursive: true });
161
- fs.writeFileSync(path.join(destDir, 'SKILL.md'), buildCommandSkillContent(name, srcFile), 'utf-8');
162
- record('commands', name, [rel], result, manifestPaths);
163
- continue;
164
175
  }
165
176
  const ext = cfg.format === 'toml' ? '.toml' : '.md';
166
177
  const rel = path.join(cfg.commandsSubdir, `${name}${ext}`);
167
178
  const destFile = path.join(agentRoot, rel);
168
179
  if (pathExists(destFile)) {
169
180
  skip(destFile, projectRoot, result);
170
- continue;
171
- }
172
- fs.mkdirSync(path.dirname(destFile), { recursive: true });
173
- if (cfg.format === 'toml') {
174
- fs.writeFileSync(destFile, markdownToToml(name, fs.readFileSync(srcFile, 'utf-8')), 'utf-8');
175
181
  }
176
182
  else {
177
- fs.copyFileSync(srcFile, destFile);
183
+ fs.mkdirSync(path.dirname(destFile), { recursive: true });
184
+ if (cfg.format === 'toml') {
185
+ fs.writeFileSync(destFile, markdownToToml(name, fs.readFileSync(srcFile, 'utf-8')), 'utf-8');
186
+ }
187
+ else {
188
+ fs.copyFileSync(srcFile, destFile);
189
+ }
190
+ written.push(rel);
178
191
  }
179
- record('commands', name, [rel], result, manifestPaths);
192
+ if (written.length > 0)
193
+ record('commands', name, written, result, manifestPaths);
180
194
  }
181
195
  }
182
196
  function syncProjectSkills(agent, version, projectAgentsDir, agentRoot, result, manifestPaths) {
@@ -23,6 +23,9 @@ export interface RunResult {
23
23
  meta: RunMeta;
24
24
  reportPath: string | null;
25
25
  }
26
+ /** Agents the daemon can actually run, derived from the command table above
27
+ * so the `--agent` help and any validation can never drift from it. */
28
+ export declare const ROUTINE_AGENT_IDS: readonly string[];
26
29
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
27
30
  export declare function buildJobCommand(config: JobConfig, resolvedPrompt: string): string[];
28
31
  export declare function archiveRoutineTranscripts(meta: Pick<RunMeta, 'jobName' | 'runId' | 'agent'>, runDir: string, overlayHome?: string): void;
@@ -63,7 +66,7 @@ export declare function dispatchesViaAgentsRun(config: Pick<JobConfig, 'workflow
63
66
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
64
67
  * with `agents run`.
65
68
  */
66
- export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string): Record<string, string>;
69
+ export declare function buildRoutineSpawnEnv(baseEnv: Record<string, string>, agent: AgentId, version: string | undefined, timezone?: string, overlayHome?: string): Record<string, string>;
67
70
  export declare function executeJob(config: JobConfig, deps?: LoopDeps): Promise<RunResult>;
68
71
  /**
69
72
  * Optional lifecycle callbacks for a detached routine run. The daemon passes an
@@ -80,5 +83,15 @@ export interface RoutineHooks {
80
83
  export declare function executeJobDetached(config: JobConfig, hooks?: RoutineHooks): Promise<RunMeta>;
81
84
  /** Extract the final assistant message from a stream-JSON log file as a markdown report. */
82
85
  export declare function extractReport(stdoutPath: string, agentType: AgentId): string | null;
86
+ /** Derive the final status of a detached run by reading the agent's stream-json
87
+ * tail. Detached children fire-and-forget, so we never see their exit code
88
+ * directly — but Claude's stream-json terminates with a `type: result` line
89
+ * that carries `is_error`. If we find it, the run completed cleanly (modulo
90
+ * agent-reported error). If not, the process likely died mid-stream and the
91
+ * caller should treat the run as failed. */
92
+ export declare function inferFinalStatusFromLog(stdoutPath: string, agent: AgentId): {
93
+ status: 'completed' | 'failed';
94
+ exitCode: number;
95
+ } | null;
83
96
  /** Scan all runs marked "running" and finalize any whose process has exited. */
84
97
  export declare function monitorRunningJobs(): void;
@@ -32,17 +32,23 @@ import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js
32
32
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, } from './rotate.js';
33
33
  import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
34
  import { machineId } from './machine-id.js';
35
+ import { isSelfUpdatingAgent } from './agents.js';
35
36
  /** CLI command templates per agent, with {prompt} as a placeholder. */
36
37
  const AGENT_COMMANDS = {
37
38
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
38
39
  codex: ['codex', 'exec', '--sandbox', 'workspace-write', '{prompt}', '--json'],
39
40
  gemini: ['gemini', '{prompt}', '--output-format', 'stream-json'],
41
+ cursor: ['cursor-agent', '-p', '{prompt}', '--output-format', 'stream-json'],
40
42
  kimi: ['kimi', '--prompt', '{prompt}', '--output-format', 'stream-json'],
41
43
  droid: ['droid', 'exec', '{prompt}', '-o', 'stream-json'],
42
44
  };
45
+ /** Agents the daemon can actually run, derived from the command table above
46
+ * so the `--agent` help and any validation can never drift from it. */
47
+ export const ROUTINE_AGENT_IDS = Object.freeze(Object.keys(AGENT_COMMANDS));
43
48
  const ROUTINE_TRANSCRIPT_SPECS = {
44
49
  claude: [{ root: ['.claude', 'projects'], ext: '.jsonl' }],
45
50
  codex: [{ root: ['.codex', 'sessions'], ext: '.jsonl' }],
51
+ cursor: [{ root: ['.cursor', 'projects'], ext: '.jsonl' }],
46
52
  };
47
53
  /** Build the full CLI argv for executing a job, applying mode, model, and permission flags. */
48
54
  export function buildJobCommand(config, resolvedPrompt) {
@@ -133,6 +139,20 @@ export function buildJobCommand(config, resolvedPrompt) {
133
139
  }
134
140
  appendModelAndReasoning(cmd, config);
135
141
  }
142
+ if (config.agent === 'cursor') {
143
+ // cursor-agent supports --plan, but the capability registry has not been
144
+ // upgraded yet. RUSH-2101 tracks adding that read-only mode after PR #1721.
145
+ const cursorMode = resolveHeadlessMode('cursor', mode, false, `routine ${config.name}`);
146
+ if (cursorMode === 'skip') {
147
+ cmd.push('-f');
148
+ }
149
+ else {
150
+ // The configured cwd is the user's workspace trust decision. --trust is
151
+ // narrower than --yolo/-f because it does not bypass tool permissions.
152
+ cmd.push('--trust');
153
+ }
154
+ appendModelAndReasoning(cmd, config);
155
+ }
136
156
  if (config.agent === 'kimi') {
137
157
  // kimi daemon jobs always run headless via `--prompt`, which cannot be
138
158
  // combined with any startup-mode flag (--plan/--auto/--yolo all abort with
@@ -385,7 +405,7 @@ export function dispatchesViaAgentsRun(config) {
385
405
  * (CLAUDE_CONFIG_DIR / CODEX_HOME / …) so routines share account isolation
386
406
  * with `agents run`.
387
407
  */
388
- export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
408
+ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayHome) {
389
409
  const execEnv = buildExecEnv({
390
410
  agent,
391
411
  version,
@@ -415,6 +435,11 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone) {
415
435
  // provisioned box does. Drop it here so a routine always uses the login of the
416
436
  // machine it runs on.
417
437
  delete out.CLAUDE_CODE_OAUTH_TOKEN;
438
+ if (agent === 'cursor' && overlayHome) {
439
+ // prepareJobHome links this host's Cursor auth file here. Pin XDG_CONFIG_HOME
440
+ // to the overlay so an ambient value cannot bypass the routine sandbox.
441
+ out.XDG_CONFIG_HOME = path.join(overlayHome, '.config');
442
+ }
418
443
  if (timezone)
419
444
  out.TZ = timezone;
420
445
  return out;
@@ -621,16 +646,20 @@ export async function executeJob(config, deps) {
621
646
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
622
647
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
623
648
  if (config.loop) {
624
- const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone);
649
+ const spawnEnv = buildRoutineSpawnEnv(baseEnv, effectiveAgent, primaryVersion, config.timezone, overlayHome);
625
650
  const execOptions = {
626
651
  agent: effectiveAgent,
627
- version: primaryVersion,
652
+ // Routine-supported self-updating CLIs (Cursor/Droid) use one global
653
+ // binary; a versioned shim would point at a nonexistent isolated install.
654
+ version: isSelfUpdatingAgent(effectiveAgent) ? undefined : primaryVersion,
628
655
  prompt: resolvedPrompt,
629
656
  mode: normalizeMode(config.mode),
630
657
  effort: config.effort,
631
658
  env: spawnEnv,
632
659
  json: true,
633
660
  headless: true,
661
+ modeWarningContext: `routine ${config.name}`,
662
+ modeWarningState: {},
634
663
  ...(config.config?.model ? { model: config.config.model } : {}),
635
664
  ...(config.allow?.dirs ? {
636
665
  addDirs: config.allow.dirs
@@ -681,7 +710,7 @@ export async function executeJob(config, deps) {
681
710
  e.TZ = config.timezone;
682
711
  return e;
683
712
  })()
684
- : buildRoutineSpawnEnv(baseEnv, attemptAgent, attemptVersion, config.timezone);
713
+ : buildRoutineSpawnEnv(baseEnv, attemptAgent, attemptVersion, config.timezone, overlayHome);
685
714
  // Remaining timeout budget shared across failover attempts.
686
715
  const elapsed = Date.now() - Date.parse(meta.startedAt);
687
716
  const remaining = Math.max(1_000, timeoutMs - (Number.isFinite(elapsed) ? elapsed : 0));
@@ -1067,7 +1096,7 @@ export async function executeJobDetached(config, hooks) {
1067
1096
  return e;
1068
1097
  })()
1069
1098
  // Non-command path only: config.agent is always set here (command/workflow branch earlier).
1070
- : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone);
1099
+ : buildRoutineSpawnEnv(baseEnv, config.agent, version, config.timezone, overlayHome);
1071
1100
  const effectiveAgent = config.workflow ? 'claude' : config.agent;
1072
1101
  const meta = {
1073
1102
  jobName: config.name,
@@ -1262,7 +1291,7 @@ export function extractReport(stdoutPath, agentType) {
1262
1291
  for (const line of lines) {
1263
1292
  try {
1264
1293
  const parsed = JSON.parse(line);
1265
- if (agentType === 'claude') {
1294
+ if (agentType === 'claude' || agentType === 'cursor') {
1266
1295
  if (parsed.type === 'assistant' && parsed.message?.content) {
1267
1296
  for (const block of parsed.message.content) {
1268
1297
  if (block.type === 'text' && block.text) {
@@ -1298,7 +1327,7 @@ export function extractReport(stdoutPath, agentType) {
1298
1327
  * that carries `is_error`. If we find it, the run completed cleanly (modulo
1299
1328
  * agent-reported error). If not, the process likely died mid-stream and the
1300
1329
  * caller should treat the run as failed. */
1301
- function inferFinalStatusFromLog(stdoutPath, agent) {
1330
+ export function inferFinalStatusFromLog(stdoutPath, agent) {
1302
1331
  if (!fs.existsSync(stdoutPath))
1303
1332
  return null;
1304
1333
  try {
@@ -1309,7 +1338,7 @@ function inferFinalStatusFromLog(stdoutPath, agent) {
1309
1338
  for (let i = lines.length - 1, scanned = 0; i >= 0 && scanned < 20; i--, scanned++) {
1310
1339
  try {
1311
1340
  const parsed = JSON.parse(lines[i]);
1312
- if (agent === 'claude' && parsed.type === 'result') {
1341
+ if ((agent === 'claude' || agent === 'cursor') && parsed.type === 'result') {
1313
1342
  return parsed.is_error
1314
1343
  ? { status: 'failed', exitCode: 1 }
1315
1344
  : { status: 'completed', exitCode: 0 };
@@ -21,6 +21,8 @@ export declare function buildSpawnEnv(overlayHome: string, extraEnv?: Record<str
21
21
  export declare function getJobHomePath(name: string): string;
22
22
  /** Create a fresh overlay HOME for a job, including agent config and allowed-dir symlinks. */
23
23
  export declare function prepareJobHome(config: JobConfig): string;
24
+ /** Link this host's Cursor login and CLI config into the disposable overlay. */
25
+ export declare function generateCursorConfig(overlayHome: string): void;
24
26
  /** Remove a job's overlay HOME directory entirely. */
25
27
  export declare function cleanJobHome(name: string): void;
26
28
  /** Symlink allowed directories into the overlay HOME, skipping paths outside the real HOME. */