@phnx-labs/agents-cli 1.22.32 → 1.22.34

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 (76) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +2 -1
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/accounts.js +3 -3
  5. package/dist/commands/browser-sessions-picker.d.ts +16 -0
  6. package/dist/commands/browser-sessions-picker.js +179 -0
  7. package/dist/commands/browser.js +9 -4
  8. package/dist/commands/hosts.js +1 -5
  9. package/dist/commands/inspect.js +174 -41
  10. package/dist/commands/message.d.ts +6 -1
  11. package/dist/commands/message.js +60 -3
  12. package/dist/commands/sessions-browser.js +2 -2
  13. package/dist/commands/sessions.d.ts +54 -4
  14. package/dist/commands/sessions.js +274 -58
  15. package/dist/commands/ssh.js +17 -5
  16. package/dist/commands/teams.d.ts +28 -0
  17. package/dist/commands/teams.js +148 -13
  18. package/dist/commands/upgrade.d.ts +7 -0
  19. package/dist/commands/upgrade.js +10 -0
  20. package/dist/commands/watchdog.d.ts +2 -0
  21. package/dist/commands/watchdog.js +112 -27
  22. package/dist/index.js +51 -59
  23. package/dist/lib/agents.js +6 -0
  24. package/dist/lib/browser/sessions-list.d.ts +81 -0
  25. package/dist/lib/browser/sessions-list.js +179 -4
  26. package/dist/lib/daemon.js +45 -5
  27. package/dist/lib/devices/connect.d.ts +33 -0
  28. package/dist/lib/devices/connect.js +61 -3
  29. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  30. package/dist/lib/devices/doctor-findings.js +4 -2
  31. package/dist/lib/exec.js +63 -6
  32. package/dist/lib/help.d.ts +3 -2
  33. package/dist/lib/help.js +4 -0
  34. package/dist/lib/hosts/dispatch.d.ts +2 -32
  35. package/dist/lib/hosts/dispatch.js +6 -61
  36. package/dist/lib/hosts/tasks.d.ts +7 -0
  37. package/dist/lib/hosts/tasks.js +9 -0
  38. package/dist/lib/mailbox-target.d.ts +27 -0
  39. package/dist/lib/mailbox-target.js +21 -0
  40. package/dist/lib/mcp.d.ts +10 -0
  41. package/dist/lib/mcp.js +21 -2
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  44. package/dist/lib/migrate.d.ts +11 -0
  45. package/dist/lib/migrate.js +40 -0
  46. package/dist/lib/project-root.d.ts +47 -0
  47. package/dist/lib/project-root.js +68 -0
  48. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  49. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  50. package/dist/lib/secrets/agent.d.ts +9 -2
  51. package/dist/lib/secrets/agent.js +52 -7
  52. package/dist/lib/secrets/reaper.d.ts +24 -3
  53. package/dist/lib/secrets/reaper.js +55 -6
  54. package/dist/lib/session/discover.js +12 -0
  55. package/dist/lib/session/render.d.ts +2 -0
  56. package/dist/lib/session/render.js +1 -1
  57. package/dist/lib/session/team-filter.d.ts +11 -0
  58. package/dist/lib/session/team-filter.js +10 -0
  59. package/dist/lib/shims.js +9 -2
  60. package/dist/lib/startup/command-registry.d.ts +1 -0
  61. package/dist/lib/startup/command-registry.js +8 -3
  62. package/dist/lib/startup/root-command.d.ts +3 -0
  63. package/dist/lib/startup/root-command.js +10 -0
  64. package/dist/lib/teams/agents.d.ts +136 -6
  65. package/dist/lib/teams/agents.js +324 -58
  66. package/dist/lib/teams/worktree.d.ts +39 -2
  67. package/dist/lib/teams/worktree.js +60 -4
  68. package/dist/lib/types.d.ts +11 -0
  69. package/dist/lib/versions.js +2 -2
  70. package/dist/lib/watchdog/history.d.ts +20 -0
  71. package/dist/lib/watchdog/history.js +46 -0
  72. package/dist/lib/watchdog/log.d.ts +16 -1
  73. package/dist/lib/watchdog/log.js +82 -2
  74. package/dist/lib/watchdog/runner.d.ts +12 -0
  75. package/dist/lib/watchdog/runner.js +20 -0
  76. package/package.json +1 -1
package/dist/lib/exec.js CHANGED
@@ -292,10 +292,37 @@ export function buildExecEnv(options) {
292
292
  result.CLAUDE_CONFIG_DIR = path.join(versionHome, '.claude');
293
293
  const setupToken = resolveClaudeSetupToken(versionHome);
294
294
  if (setupToken) {
295
- // A token keyed to this version home's own account replaces any ambient
296
- // shared value inherited from the launcher. options.env still wins below
297
- // for explicit caller overrides.
298
- result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
295
+ // The `auth` bundle's setup-token exists so a run with NO human present
296
+ // authenticates without the Touch-ID-gated login item usage probes,
297
+ // routines, dispatched runs (claude-account-token.ts). An interactive run
298
+ // has a human at the TTY, and their own per-version login is the credential
299
+ // they established and expect; overriding it made `/status` report
300
+ // `Auth token: CLAUDE_CODE_OAUTH_TOKEN` on a personal machine and took every
301
+ // hand-driven session off that login. macOS cannot cheaply confirm a home's
302
+ // login first (probing the Keychain raises an authorization sheet per
303
+ // installed version on the `agents run` hot path — agents.ts
304
+ // `isClaudeCredentialFileBlank`), so interactive simply defers to Claude
305
+ // Code, which prompts a present human to log in if the login is missing.
306
+ if (resolveInteractive(options)) {
307
+ // Drop an INHERITED copy of the same token too: an interactive launch from
308
+ // inside a headless agent's shell inherits that agent's injected value via
309
+ // sanitizeProcessEnv(process.env) and would keep authenticating as it.
310
+ // Matched by VALUE, so a token the user exported deliberately is a
311
+ // different string and is left alone. This is NARROWER than the routines
312
+ // path, which overwrites-or-deletes unconditionally and never inspects the
313
+ // inherited value (`runner.ts:1020-1021`) — the gap between the two is
314
+ // what RUSH-2360 tracks, including a DIFFERENT account's inherited token,
315
+ // which this equality check lets through.
316
+ if (result.CLAUDE_CODE_OAUTH_TOKEN === setupToken) {
317
+ delete result.CLAUDE_CODE_OAUTH_TOKEN;
318
+ }
319
+ }
320
+ else {
321
+ // A token keyed to this version home's own account replaces any ambient
322
+ // shared value inherited from the launcher. options.env still wins below
323
+ // for explicit caller overrides.
324
+ result.CLAUDE_CODE_OAUTH_TOKEN = setupToken;
325
+ }
299
326
  }
300
327
  // A managed pin lives in a per-version dir; Claude Code's own background
301
328
  // auto-updater would rewrite that pinned binary in place (and has left it
@@ -381,6 +408,29 @@ export function buildExecEnv(options) {
381
408
  delete result.COPILOT_HOME;
382
409
  delete result.KIMI_CODE_HOME;
383
410
  }
411
+ else if (options.agent === 'cursor') {
412
+ // Cursor has no config-dir env var (only CURSOR_API_KEY / CURSOR_API_ENDPOINT).
413
+ // Its OAuth token — the login gate — lives at $XDG_CONFIG_HOME/cursor/auth.json
414
+ // (verified empirically: relocating XDG_CONFIG_HOME relocates the login;
415
+ // ~/.cursor/cli-config.json holds only account metadata, not the token). Pin
416
+ // XDG_CONFIG_HOME into the version home so each installed Cursor account
417
+ // authenticates from its own token, isolated per run — no global ~/.cursor
418
+ // symlink swap, so concurrent runs on different accounts never clobber one
419
+ // another. cli-config.json (HOME-relative) has no override and stays on the
420
+ // shared home; only the token is per-account, which is what gates the login.
421
+ const cwd = options.cwd || process.cwd();
422
+ const resolvedVersion = options.version ?? resolveVersion('cursor', cwd);
423
+ const version = options.version
424
+ ? resolvedVersion
425
+ : (resolvedVersion && isVersionInstalled('cursor', resolvedVersion) ? resolvedVersion : null);
426
+ if (version) {
427
+ result.XDG_CONFIG_HOME = path.join(getVersionHomePath('cursor', version), '.config');
428
+ }
429
+ delete result.CLAUDE_CONFIG_DIR;
430
+ delete result.CODEX_HOME;
431
+ delete result.COPILOT_HOME;
432
+ delete result.KIMI_CODE_HOME;
433
+ }
384
434
  else {
385
435
  delete result.CLAUDE_CONFIG_DIR;
386
436
  delete result.CODEX_HOME;
@@ -1139,8 +1189,15 @@ export async function execShimPassthrough(agent, rawArgs, cwd, pinnedVersion) {
1139
1189
  // a shell, while the hook runs under the agent descendant. This is the primary
1140
1190
  // attribution path on Windows (no lsof), so the launchId join matters most here.
1141
1191
  const launchId = randomUUID();
1142
- // mode/effort are required by ExecOptions but unused by buildExecEnv (which only
1143
- // derives the per-version config-dir env); pass the agent's default to satisfy the type.
1192
+ // mode/effort are required by ExecOptions but do not affect the env buildExecEnv
1193
+ // derives; pass the agent's default to satisfy the type. Passing no prompt makes
1194
+ // this resolve INTERACTIVE, so the launch authenticates from the per-version
1195
+ // login rather than the headless `auth` setup-token (EXEC-2a) — right for the
1196
+ // common case, someone invoking the harness binary directly. Known limit: we do
1197
+ // not parse `rawArgs`, so a `-p "task"` passthrough is headless in fact and
1198
+ // interactive by this classification, and loses the token. Windows-only in
1199
+ // practice (POSIX uses the bash shim, which execs the binary and never reaches
1200
+ // buildExecEnv — `shims.ts:749`, `shims.ts:761-764`).
1144
1201
  const env = buildExecEnv({ agent, version, cwd, mode: defaultModeFor(agent), effort: 'auto', env: { AGENT_LAUNCH_ID: launchId } });
1145
1202
  const { command, args, shell } = resolveShimSpawn(process.platform, binary, [...launchArgs, ...rawArgs]);
1146
1203
  return new Promise((resolve) => {
@@ -19,7 +19,7 @@ export interface CommandGroup {
19
19
  */
20
20
  export declare function registerCommandGroups(parent: Command, groups: readonly CommandGroup[]): void;
21
21
  /** Examples + Notes blocks attached to a command via setHelpSections. */
22
- interface HelpSections {
22
+ export interface HelpSections {
23
23
  examples?: string;
24
24
  notes?: string;
25
25
  }
@@ -32,6 +32,7 @@ interface HelpSections {
32
32
  * literals without babysitting whitespace.
33
33
  */
34
34
  export declare function setHelpSections(cmd: Command, sections: HelpSections): void;
35
+ /** Read the workflow-first sections attached to a command for generated references. */
36
+ export declare function getHelpSections(cmd: Command): Readonly<HelpSections>;
35
37
  /** Apply standardized help formatting to the root command and all subcommands. */
36
38
  export declare function applyGlobalHelpConventions(root: Command): void;
37
- export {};
package/dist/lib/help.js CHANGED
@@ -19,6 +19,10 @@ const helpSectionRegistry = new WeakMap();
19
19
  export function setHelpSections(cmd, sections) {
20
20
  helpSectionRegistry.set(cmd, sections);
21
21
  }
22
+ /** Read the workflow-first sections attached to a command for generated references. */
23
+ export function getHelpSections(cmd) {
24
+ return helpSectionRegistry.get(cmd) ?? {};
25
+ }
22
26
  /** Strip a uniform leading indent from a block and trim surrounding blank lines. */
23
27
  function dedent(body) {
24
28
  const lines = body.replace(/^\n+/, '').replace(/\s+$/, '').split('\n');
@@ -11,38 +11,8 @@
11
11
  import type { Host } from './types.js';
12
12
  import { type HostTask } from './tasks.js';
13
13
  import { type HostCredentials } from './credentials.js';
14
- /**
15
- * Derive the remote directory to mirror from the local cwd, for a host run the
16
- * caller gave no `--cwd`/`--remote-cwd`.
17
- *
18
- * Without this a `--host` run lands in the remote `$HOME`, so an agent launched
19
- * from a repo starts with no project context and the user has to `cd` by hand.
20
- * Only a cwd under the LOCAL home is mirrored — that is the part with a
21
- * meaningful remote analogue (`~/src/x` re-roots onto the remote home). A path
22
- * outside home returns undefined: `/opt/thing` on this box says nothing about
23
- * the target's filesystem, so the run keeps the remote home.
24
- */
25
- export declare function deriveMirroredCwd(localCwd: string): string | undefined;
26
- /**
27
- * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
28
- *
29
- * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
30
- * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
31
- * for that segment — the remote login shell expands it — and shell-quote the
32
- * remainder. Any other path (absolute or relative) is quoted verbatim.
33
- *
34
- * `mirror` marks a directory the caller DERIVED from the local cwd rather than
35
- * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
36
- * the same home-relative path on both boxes is the common fleet layout, so
37
- * mirroring lands the remote agent in the project instead of `$HOME`. It is a
38
- * best-effort mirror by definition — the host may simply not have that checkout
39
- * — so a missing directory falls back to the remote home instead of failing the
40
- * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
41
- * directory, so a missing one must surface as a `cd` error.
42
- */
43
- export declare function remoteCdPrefix(remoteCwd?: string, opts?: {
44
- mirror?: boolean;
45
- }): string;
14
+ import { deriveMirroredCwd, homeRemainder, remoteCdPrefix } from '../project-root.js';
15
+ export { deriveMirroredCwd, homeRemainder, remoteCdPrefix };
46
16
  /**
47
17
  * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
48
18
  * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
@@ -19,72 +19,17 @@ import { saveTask, updateTask, terminalPatch } from './tasks.js';
19
19
  import { followHostTask } from './progress.js';
20
20
  import { wrapHostCommandWithCredentials } from './credentials.js';
21
21
  import { hostKeyCheckingOpts } from '../devices/known-hosts.js';
22
- import { toRemotePortable } from '../project-root.js';
22
+ import { deriveMirroredCwd, homeRemainder, remoteCdPrefix } from '../project-root.js';
23
23
  import { RUN_AUTO_KEYWORD, RUN_AUTO_HOST_RESOLVED_ENV } from '../types.js';
24
+ // The home-relative portability helpers live in project-root.js (the canonical
25
+ // home-relative conversion home), reused by the interactive `agents ssh` login
26
+ // shell builder (devices/connect.ts) as well as this dispatch layer. Re-exported
27
+ // here so existing `hosts/dispatch.js` importers and tests keep resolving them.
28
+ export { deriveMirroredCwd, homeRemainder, remoteCdPrefix };
24
29
  // Use $HOME (not ~) so the path is correct whether or not it's quoted and
25
30
  // regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
26
31
  // injection-safe to interpolate unquoted into remote commands.
27
32
  const REMOTE_DIR = '$HOME/.agents/.cache/hosts';
28
- /**
29
- * If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
30
- * remainder (no leading slash), else null. Callers that want a local-home
31
- * absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
32
- * remote home normalize it to `~/x` first (`toRemotePortable`); explicit
33
- * `--remote-cwd` is left literal and so is never re-rooted here.
34
- */
35
- function homeRemainder(p) {
36
- if (p === '~' || p === '$HOME')
37
- return '';
38
- if (p.startsWith('~/'))
39
- return p.slice(2);
40
- if (p.startsWith('$HOME/'))
41
- return p.slice(6);
42
- return null;
43
- }
44
- /**
45
- * Derive the remote directory to mirror from the local cwd, for a host run the
46
- * caller gave no `--cwd`/`--remote-cwd`.
47
- *
48
- * Without this a `--host` run lands in the remote `$HOME`, so an agent launched
49
- * from a repo starts with no project context and the user has to `cd` by hand.
50
- * Only a cwd under the LOCAL home is mirrored — that is the part with a
51
- * meaningful remote analogue (`~/src/x` re-roots onto the remote home). A path
52
- * outside home returns undefined: `/opt/thing` on this box says nothing about
53
- * the target's filesystem, so the run keeps the remote home.
54
- */
55
- export function deriveMirroredCwd(localCwd) {
56
- const portable = toRemotePortable(localCwd);
57
- return homeRemainder(portable) === null ? undefined : portable;
58
- }
59
- /**
60
- * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
61
- *
62
- * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
63
- * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
64
- * for that segment — the remote login shell expands it — and shell-quote the
65
- * remainder. Any other path (absolute or relative) is quoted verbatim.
66
- *
67
- * `mirror` marks a directory the caller DERIVED from the local cwd rather than
68
- * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
69
- * the same home-relative path on both boxes is the common fleet layout, so
70
- * mirroring lands the remote agent in the project instead of `$HOME`. It is a
71
- * best-effort mirror by definition — the host may simply not have that checkout
72
- * — so a missing directory falls back to the remote home instead of failing the
73
- * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
74
- * directory, so a missing one must surface as a `cd` error.
75
- */
76
- export function remoteCdPrefix(remoteCwd, opts = {}) {
77
- if (!remoteCwd)
78
- return '';
79
- const rest = homeRemainder(remoteCwd);
80
- if (rest === '')
81
- return 'cd "$HOME" && ';
82
- if (rest !== null) {
83
- const dir = `"$HOME"/${shellQuote(rest)}`;
84
- return opts.mirror ? `{ cd ${dir} || cd "$HOME"; } && ` : `cd ${dir} && `;
85
- }
86
- return `cd ${shellQuote(remoteCwd)} && `;
87
- }
88
33
  /**
89
34
  * Merge the resolved actor's provenance env UNDER a caller-supplied env, so every
90
35
  * remote `agents …` invocation forwards `AGENTS_ACTOR*` / `GIT_*` across the SSH
@@ -74,3 +74,10 @@ export declare function findTaskBySessionId(sessionId: string): HostTask | null;
74
74
  * reused across dispatches.
75
75
  */
76
76
  export declare function findTaskByName(name: string): HostTask | null;
77
+ /**
78
+ * Resolve a host-task reference the way `agents hosts ps/logs/stop` do: a raw
79
+ * dispatch id first, then a `--name` handle, then the remote agent/session id.
80
+ * Shared so any other caller resolving "does this ref name a detached --device
81
+ * dispatch?" (e.g. `agents message`) uses the identical three-way lookup.
82
+ */
83
+ export declare function resolveTaskRef(ref: string): HostTask | null;
@@ -101,3 +101,12 @@ export function findTaskByName(name) {
101
101
  }
102
102
  return null;
103
103
  }
104
+ /**
105
+ * Resolve a host-task reference the way `agents hosts ps/logs/stop` do: a raw
106
+ * dispatch id first, then a `--name` handle, then the remote agent/session id.
107
+ * Shared so any other caller resolving "does this ref name a detached --device
108
+ * dispatch?" (e.g. `agents message`) uses the identical three-way lookup.
109
+ */
110
+ export function resolveTaskRef(ref) {
111
+ return loadTask(ref) ?? findTaskByName(ref) ?? findTaskBySessionId(ref);
112
+ }
@@ -5,6 +5,7 @@
5
5
  * NEVER guessed; the caller reports it. Pure (no I/O) so it is unit-testable.
6
6
  */
7
7
  import type { ActiveSession } from './session/active.js';
8
+ import type { HostTask } from './hosts/tasks.js';
8
9
  export type MessageResolution = {
9
10
  kind: 'cloud';
10
11
  id: string;
@@ -34,3 +35,29 @@ export declare function mailboxIdForActiveSession(s: ActiveSession): string | un
34
35
  * cloud provider.
35
36
  */
36
37
  export declare function resolveMessageTarget(target: string, sessions: ActiveSession[], isCloudTask: (id: string) => boolean): MessageResolution;
38
+ export type HostTaskRoute = {
39
+ kind: 'reroute';
40
+ remoteRef: string;
41
+ host: string;
42
+ } | {
43
+ kind: 'finished';
44
+ host: string;
45
+ status: string;
46
+ exitCode?: number;
47
+ } | {
48
+ kind: 'not-found';
49
+ };
50
+ /**
51
+ * Decide how `agents message <target>` should handle a target that matched no
52
+ * local/cloud session (RUSH-2366 follow-up): `getActiveSessions()` has no
53
+ * visibility into a detached `agents run --device <host> --no-follow`
54
+ * dispatch, whose only local record is the `~/.agents/.cache/hosts/<id>.json`
55
+ * sidecar `agents hosts ps` reads. Pure — the caller does the actual lookup
56
+ * (`resolveTaskRef`) and I/O (the ssh reroute).
57
+ *
58
+ * `remoteRef` prefers the remote agent's OWN identity (its captured session id
59
+ * or `--name` handle) over the LOCAL dispatch-record id the user typed here —
60
+ * the live process on the host registers itself under the former, never the
61
+ * latter.
62
+ */
63
+ export declare function decideHostTaskRoute(task: HostTask | null, target: string): HostTaskRoute;
@@ -43,3 +43,24 @@ export function resolveMessageTarget(target, sessions, isCloudTask) {
43
43
  candidates: [...byId.entries()].map(([id, s]) => ({ id, label: labelFor(s) })),
44
44
  };
45
45
  }
46
+ /**
47
+ * Decide how `agents message <target>` should handle a target that matched no
48
+ * local/cloud session (RUSH-2366 follow-up): `getActiveSessions()` has no
49
+ * visibility into a detached `agents run --device <host> --no-follow`
50
+ * dispatch, whose only local record is the `~/.agents/.cache/hosts/<id>.json`
51
+ * sidecar `agents hosts ps` reads. Pure — the caller does the actual lookup
52
+ * (`resolveTaskRef`) and I/O (the ssh reroute).
53
+ *
54
+ * `remoteRef` prefers the remote agent's OWN identity (its captured session id
55
+ * or `--name` handle) over the LOCAL dispatch-record id the user typed here —
56
+ * the live process on the host registers itself under the former, never the
57
+ * latter.
58
+ */
59
+ export function decideHostTaskRoute(task, target) {
60
+ if (!task)
61
+ return { kind: 'not-found' };
62
+ if (task.status === 'running') {
63
+ return { kind: 'reroute', remoteRef: task.sessionId ?? task.name ?? target, host: task.host };
64
+ }
65
+ return { kind: 'finished', host: task.host, status: task.status, exitCode: task.exitCode };
66
+ }
package/dist/lib/mcp.d.ts CHANGED
@@ -73,6 +73,16 @@ export declare function untrustProjectMcp(cwd?: string): boolean;
73
73
  export declare function listMcpServerConfigs(cwd?: string, options?: {
74
74
  enforceProjectTrust?: boolean;
75
75
  }): InstalledMcpServer[];
76
+ /**
77
+ * Parse one config during a directory SCAN. `validateMcpYamlConfig` returns null
78
+ * for some malformed shapes but *throws* for others (`args` not a string array,
79
+ * `command`/`env` of the wrong type), and a scan must not be all-or-nothing: a
80
+ * single bad file under `<repo>/mcp/` took down the whole of
81
+ * `agents inspect <repo>` with an unhandled stack trace. Skip the file and name
82
+ * it, exactly as a null return is already skipped. Explicit single-file
83
+ * operations call `parseMcpServerConfig` directly and still throw loudly.
84
+ */
85
+ export declare function parseMcpConfigForScan(filePath: string): McpYamlConfig | null;
76
86
  /**
77
87
  * Scan a repository for MCP server YAML configs.
78
88
  * Looks under <repoPath>/mcp/*.yaml — same on-disk layout as ~/.agents/mcp/.
package/dist/lib/mcp.js CHANGED
@@ -219,7 +219,7 @@ export function listMcpServerConfigs(cwd = process.cwd(), options = {}) {
219
219
  if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
220
220
  continue;
221
221
  const filePath = path.join(dir, entry.name);
222
- const config = parseMcpServerConfig(filePath);
222
+ const config = parseMcpConfigForScan(filePath);
223
223
  if (config && !results.has(config.name)) {
224
224
  results.set(config.name, {
225
225
  name: config.name,
@@ -232,6 +232,25 @@ export function listMcpServerConfigs(cwd = process.cwd(), options = {}) {
232
232
  }
233
233
  return Array.from(results.values());
234
234
  }
235
+ /**
236
+ * Parse one config during a directory SCAN. `validateMcpYamlConfig` returns null
237
+ * for some malformed shapes but *throws* for others (`args` not a string array,
238
+ * `command`/`env` of the wrong type), and a scan must not be all-or-nothing: a
239
+ * single bad file under `<repo>/mcp/` took down the whole of
240
+ * `agents inspect <repo>` with an unhandled stack trace. Skip the file and name
241
+ * it, exactly as a null return is already skipped. Explicit single-file
242
+ * operations call `parseMcpServerConfig` directly and still throw loudly.
243
+ */
244
+ export function parseMcpConfigForScan(filePath) {
245
+ try {
246
+ return parseMcpServerConfig(filePath);
247
+ }
248
+ catch (err) {
249
+ const reason = err instanceof Error ? err.message : String(err);
250
+ console.error(`agents-cli: skipping ${filePath} — ${reason}`);
251
+ return null;
252
+ }
253
+ }
235
254
  /**
236
255
  * Scan a repository for MCP server YAML configs.
237
256
  * Looks under <repoPath>/mcp/*.yaml — same on-disk layout as ~/.agents/mcp/.
@@ -247,7 +266,7 @@ export function discoverMcpConfigsFromRepo(repoPath) {
247
266
  if (!entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml'))
248
267
  continue;
249
268
  const filePath = path.join(dir, entry.name);
250
- const config = parseMcpServerConfig(filePath);
269
+ const config = parseMcpConfigForScan(filePath);
251
270
  if (config) {
252
271
  results.push({ name: config.name, path: filePath, config, scope: 'user' });
253
272
  }
@@ -168,5 +168,16 @@ export declare function migrateWatchdogSentinelToRoutine(sentinelPath?: string,
168
168
  * manually before proceeding. Exported for unit-testing with temp dirs.
169
169
  */
170
170
  export declare function migrateCliDirToClis(agentsDirs: string[]): void;
171
+ /**
172
+ * Cursor's OAuth token historically lived only in the global
173
+ * ~/.config/cursor/auth.json, shared across every version home. Cursor runs now
174
+ * pin XDG_CONFIG_HOME per version home (real per-account isolation — see
175
+ * buildExecEnv), so the current login must be copied into the active account's
176
+ * version home or it would read as logged out after upgrade. The active account
177
+ * is the home the ~/.cursor symlink currently targets. Idempotent: skips when
178
+ * the home already has its own token, and a no-op for unmanaged Cursor installs
179
+ * (where ~/.cursor is a real dir, not a symlink into a version home).
180
+ */
181
+ export declare function seedActiveCursorLoginPerVersion(): void;
171
182
  /** Run all idempotent migrations. Safe to call multiple times. */
172
183
  export declare function runMigration(): Promise<void>;
@@ -2250,6 +2250,43 @@ function migrateHumans() {
2250
2250
  catch { /* best-effort — leave the old key if we can't rewrite */ }
2251
2251
  }
2252
2252
  }
2253
+ /**
2254
+ * Cursor's OAuth token historically lived only in the global
2255
+ * ~/.config/cursor/auth.json, shared across every version home. Cursor runs now
2256
+ * pin XDG_CONFIG_HOME per version home (real per-account isolation — see
2257
+ * buildExecEnv), so the current login must be copied into the active account's
2258
+ * version home or it would read as logged out after upgrade. The active account
2259
+ * is the home the ~/.cursor symlink currently targets. Idempotent: skips when
2260
+ * the home already has its own token, and a no-op for unmanaged Cursor installs
2261
+ * (where ~/.cursor is a real dir, not a symlink into a version home).
2262
+ */
2263
+ export function seedActiveCursorLoginPerVersion() {
2264
+ const realHome = process.env.AGENTS_REAL_HOME || os.homedir();
2265
+ const globalAuth = path.join(realHome, '.config', 'cursor', 'auth.json');
2266
+ let versionHome;
2267
+ try {
2268
+ if (!fs.existsSync(globalAuth))
2269
+ return;
2270
+ // ~/.cursor -> .../versions/cursor/<version>/home/.cursor ; the version home
2271
+ // is that link's parent directory.
2272
+ const link = fs.readlinkSync(path.join(realHome, '.cursor'));
2273
+ const resolved = path.isAbsolute(link) ? link : path.resolve(realHome, link);
2274
+ versionHome = path.dirname(resolved);
2275
+ }
2276
+ catch {
2277
+ return; // not a symlink (unmanaged install) or unreadable — nothing to seed
2278
+ }
2279
+ if (!versionHome.includes(path.join('versions', 'cursor')))
2280
+ return;
2281
+ const dest = path.join(versionHome, '.config', 'cursor', 'auth.json');
2282
+ try {
2283
+ if (fs.existsSync(dest))
2284
+ return;
2285
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
2286
+ fs.copyFileSync(globalAuth, dest);
2287
+ }
2288
+ catch { /* best-effort — a failed seed just means one re-login */ }
2289
+ }
2253
2290
  /** Run all idempotent migrations. Safe to call multiple times. */
2254
2291
  export async function runMigration() {
2255
2292
  // MUST run first: every other migrator reads SYSTEM_DIR (the new path).
@@ -2266,6 +2303,9 @@ export async function runMigration() {
2266
2303
  migratePromptcutsIntoHooks();
2267
2304
  migrateSystemVersionsToUser();
2268
2305
  mergeOverlappingVersionHomes();
2306
+ // Cursor runs now isolate the login per version home; preserve the current
2307
+ // login by seeding the active home's token from the legacy global copy.
2308
+ seedActiveCursorLoginPerVersion();
2269
2309
  migrateRunsIntoRoutines();
2270
2310
  migrateTrashToHidden();
2271
2311
  migrateBackupsToHidden();
@@ -24,6 +24,53 @@ export declare function expandLocalHome(p: string): string;
24
24
  * it is a literal remote path by contract.
25
25
  */
26
26
  export declare function toRemotePortable(p: string): string;
27
+ /**
28
+ * If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
29
+ * remainder (no leading slash), else null. Callers that want a local-home
30
+ * absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
31
+ * remote home normalize it to `~/x` first (`toRemotePortable`); explicit
32
+ * `--remote-cwd` is left literal and so is never re-rooted here.
33
+ *
34
+ * The canonical home-anchor stripper — shared by `remoteCdPrefix`,
35
+ * `deriveMirroredCwd`, and the interactive-login shell builder
36
+ * (`devices/connect.ts`), so there is exactly one notion of "the part below the
37
+ * home dir".
38
+ */
39
+ export declare function homeRemainder(p: string): string | null;
40
+ /**
41
+ * Derive the remote directory to mirror from the local cwd, for a host run the
42
+ * caller gave no `--cwd`/`--remote-cwd` (and for an interactive `agents ssh`
43
+ * login with no command).
44
+ *
45
+ * Without this a `--host` run — or an `agents ssh <device>` login — lands in the
46
+ * remote `$HOME`, so an agent launched from a repo starts with no project
47
+ * context and the user has to `cd` by hand. Only a cwd under the LOCAL home is
48
+ * mirrored — that is the part with a meaningful remote analogue (`~/src/x`
49
+ * re-roots onto the remote home). A path outside home returns undefined:
50
+ * `/opt/thing` on this box says nothing about the target's filesystem, so the
51
+ * run keeps the remote home.
52
+ */
53
+ export declare function deriveMirroredCwd(localCwd: string): string | undefined;
54
+ /**
55
+ * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
56
+ *
57
+ * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
58
+ * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
59
+ * for that segment — the remote login shell expands it — and shell-quote the
60
+ * remainder. Any other path (absolute or relative) is quoted verbatim.
61
+ *
62
+ * `mirror` marks a directory the caller DERIVED from the local cwd rather than
63
+ * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
64
+ * the same home-relative path on both boxes is the common fleet layout, so
65
+ * mirroring lands the remote agent in the project instead of `$HOME`. It is a
66
+ * best-effort mirror by definition — the host may simply not have that checkout
67
+ * — so a missing directory falls back to the remote home instead of failing the
68
+ * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
69
+ * directory, so a missing one must surface as a `cd` error.
70
+ */
71
+ export declare function remoteCdPrefix(remoteCwd?: string, opts?: {
72
+ mirror?: boolean;
73
+ }): string;
27
74
  /** The configured projects root (home-relative or absolute), or undefined when unset. */
28
75
  export declare function getProjectRoot(): string | undefined;
29
76
  /** Set (override) the cached projects root. Stored home-relative when under `$HOME`. */
@@ -18,6 +18,7 @@ import { readMeta, updateMeta } from './state.js';
18
18
  import { getMainRepoRoot } from './git.js';
19
19
  import { toPosix } from './platform/index.js';
20
20
  import { loadProjectDef, resolveDefinedProjectPath } from './projects.js';
21
+ import { shellQuote } from './ssh-exec.js';
21
22
  const HOME = process.env.HOME ?? os.homedir();
22
23
  /** Rewrite an absolute path under the local home to a `~/`-relative string; pass others through. */
23
24
  export function toHomeRelative(abs) {
@@ -53,6 +54,73 @@ export function toRemotePortable(p) {
53
54
  return toHomeRelative(p);
54
55
  return p;
55
56
  }
57
+ /**
58
+ * If `p` is anchored at the home dir — a leading `~` or `$HOME` — return the
59
+ * remainder (no leading slash), else null. Callers that want a local-home
60
+ * absolute (`/Users/<me>/x`, from a shell-expanded `--cwd ~/x`) re-rooted at the
61
+ * remote home normalize it to `~/x` first (`toRemotePortable`); explicit
62
+ * `--remote-cwd` is left literal and so is never re-rooted here.
63
+ *
64
+ * The canonical home-anchor stripper — shared by `remoteCdPrefix`,
65
+ * `deriveMirroredCwd`, and the interactive-login shell builder
66
+ * (`devices/connect.ts`), so there is exactly one notion of "the part below the
67
+ * home dir".
68
+ */
69
+ export function homeRemainder(p) {
70
+ if (p === '~' || p === '$HOME')
71
+ return '';
72
+ if (p.startsWith('~/'))
73
+ return p.slice(2);
74
+ if (p.startsWith('$HOME/'))
75
+ return p.slice(6);
76
+ return null;
77
+ }
78
+ /**
79
+ * Derive the remote directory to mirror from the local cwd, for a host run the
80
+ * caller gave no `--cwd`/`--remote-cwd` (and for an interactive `agents ssh`
81
+ * login with no command).
82
+ *
83
+ * Without this a `--host` run — or an `agents ssh <device>` login — lands in the
84
+ * remote `$HOME`, so an agent launched from a repo starts with no project
85
+ * context and the user has to `cd` by hand. Only a cwd under the LOCAL home is
86
+ * mirrored — that is the part with a meaningful remote analogue (`~/src/x`
87
+ * re-roots onto the remote home). A path outside home returns undefined:
88
+ * `/opt/thing` on this box says nothing about the target's filesystem, so the
89
+ * run keeps the remote home.
90
+ */
91
+ export function deriveMirroredCwd(localCwd) {
92
+ const portable = toRemotePortable(localCwd);
93
+ return homeRemainder(portable) === null ? undefined : portable;
94
+ }
95
+ /**
96
+ * Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
97
+ *
98
+ * A `~`/`$HOME`-anchored path must resolve against the REMOTE user's home, not
99
+ * the local one (`/home/<me>` vs `/Users/<me>`). We emit an unquoted `"$HOME"`
100
+ * for that segment — the remote login shell expands it — and shell-quote the
101
+ * remainder. Any other path (absolute or relative) is quoted verbatim.
102
+ *
103
+ * `mirror` marks a directory the caller DERIVED from the local cwd rather than
104
+ * one the user asked for (see `deriveMirroredCwd`). The same repo checked out at
105
+ * the same home-relative path on both boxes is the common fleet layout, so
106
+ * mirroring lands the remote agent in the project instead of `$HOME`. It is a
107
+ * best-effort mirror by definition — the host may simply not have that checkout
108
+ * — so a missing directory falls back to the remote home instead of failing the
109
+ * run. An explicit `--cwd`/`--remote-cwd` is never mirrored: the user named that
110
+ * directory, so a missing one must surface as a `cd` error.
111
+ */
112
+ export function remoteCdPrefix(remoteCwd, opts = {}) {
113
+ if (!remoteCwd)
114
+ return '';
115
+ const rest = homeRemainder(remoteCwd);
116
+ if (rest === '')
117
+ return 'cd "$HOME" && ';
118
+ if (rest !== null) {
119
+ const dir = `"$HOME"/${shellQuote(rest)}`;
120
+ return opts.mirror ? `{ cd ${dir} || cd "$HOME"; } && ` : `cd ${dir} && `;
121
+ }
122
+ return `cd ${shellQuote(remoteCwd)} && `;
123
+ }
56
124
  /** The configured projects root (home-relative or absolute), or undefined when unset. */
57
125
  export function getProjectRoot() {
58
126
  return readMeta().projectRoot;
@@ -263,7 +263,7 @@ export declare function brokerPidAlive(): boolean;
263
263
  export declare function runSecretsAgent(opts?: {
264
264
  service?: boolean;
265
265
  }): Promise<{
266
- close(): void;
266
+ close(): void | Promise<void>;
267
267
  } | null>;
268
268
  /**
269
269
  * Host the secrets broker inside the always-on daemon (#416).
@@ -284,8 +284,15 @@ export declare function runSecretsAgent(opts?: {
284
284
  * or null off-darwin (nothing to broker without biometry).
285
285
  */
286
286
  export declare function startHostedBroker(): Promise<{
287
- close(): void;
287
+ close(): void | Promise<void>;
288
288
  } | null>;
289
+ /**
290
+ * Call Node's net.Server.close() and wait for the 'close' event (or a bounded
291
+ * timeout). Without this, close() returned while the listen socket could still
292
+ * be held — a successor bind could race EADDRINUSE against a half-closed server
293
+ * (RUSH-2421). Pure side-effect helper; never throws.
294
+ */
295
+ export declare function closeServerBounded(server: net.Server, timeoutMs?: number): Promise<void>;
289
296
  /** True if a broker socket exists at all. Cheap; gates the sync read so the
290
297
  * never-unlocked path stays a single stat. */
291
298
  export declare function agentSocketExists(): boolean;