@phnx-labs/agents-cli 1.22.33 → 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 (73) hide show
  1. package/CHANGELOG.md +49 -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.d.ts +54 -4
  13. package/dist/commands/sessions.js +252 -46
  14. package/dist/commands/ssh.js +17 -5
  15. package/dist/commands/teams.d.ts +28 -0
  16. package/dist/commands/teams.js +148 -13
  17. package/dist/commands/upgrade.d.ts +7 -0
  18. package/dist/commands/upgrade.js +10 -0
  19. package/dist/commands/watchdog.d.ts +2 -0
  20. package/dist/commands/watchdog.js +112 -27
  21. package/dist/index.js +51 -59
  22. package/dist/lib/agents.js +6 -0
  23. package/dist/lib/browser/sessions-list.d.ts +81 -0
  24. package/dist/lib/browser/sessions-list.js +179 -4
  25. package/dist/lib/daemon.js +45 -5
  26. package/dist/lib/devices/connect.d.ts +33 -0
  27. package/dist/lib/devices/connect.js +61 -3
  28. package/dist/lib/devices/doctor-findings.d.ts +4 -2
  29. package/dist/lib/devices/doctor-findings.js +4 -2
  30. package/dist/lib/exec.js +63 -6
  31. package/dist/lib/help.d.ts +3 -2
  32. package/dist/lib/help.js +4 -0
  33. package/dist/lib/hosts/dispatch.d.ts +2 -32
  34. package/dist/lib/hosts/dispatch.js +6 -61
  35. package/dist/lib/hosts/tasks.d.ts +7 -0
  36. package/dist/lib/hosts/tasks.js +9 -0
  37. package/dist/lib/mailbox-target.d.ts +27 -0
  38. package/dist/lib/mailbox-target.js +21 -0
  39. package/dist/lib/mcp.d.ts +10 -0
  40. package/dist/lib/mcp.js +21 -2
  41. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  42. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  43. package/dist/lib/migrate.d.ts +11 -0
  44. package/dist/lib/migrate.js +40 -0
  45. package/dist/lib/project-root.d.ts +47 -0
  46. package/dist/lib/project-root.js +68 -0
  47. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  48. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  49. package/dist/lib/secrets/agent.d.ts +9 -2
  50. package/dist/lib/secrets/agent.js +52 -7
  51. package/dist/lib/secrets/reaper.d.ts +24 -3
  52. package/dist/lib/secrets/reaper.js +55 -6
  53. package/dist/lib/session/discover.js +12 -0
  54. package/dist/lib/session/render.d.ts +2 -0
  55. package/dist/lib/session/render.js +1 -1
  56. package/dist/lib/shims.js +9 -2
  57. package/dist/lib/startup/command-registry.d.ts +1 -0
  58. package/dist/lib/startup/command-registry.js +8 -3
  59. package/dist/lib/startup/root-command.d.ts +3 -0
  60. package/dist/lib/startup/root-command.js +10 -0
  61. package/dist/lib/teams/agents.d.ts +136 -6
  62. package/dist/lib/teams/agents.js +324 -58
  63. package/dist/lib/teams/worktree.d.ts +39 -2
  64. package/dist/lib/teams/worktree.js +60 -4
  65. package/dist/lib/types.d.ts +11 -0
  66. package/dist/lib/versions.js +2 -2
  67. package/dist/lib/watchdog/history.d.ts +20 -0
  68. package/dist/lib/watchdog/history.js +46 -0
  69. package/dist/lib/watchdog/log.d.ts +16 -1
  70. package/dist/lib/watchdog/log.js +82 -2
  71. package/dist/lib/watchdog/runner.d.ts +12 -0
  72. package/dist/lib/watchdog/runner.js +20 -0
  73. package/package.json +1 -1
@@ -1,3 +1,4 @@
1
+ import type { SessionMeta } from '../session/types.js';
1
2
  export type ArtifactKind = 'screenshot' | 'pdf' | 'recording' | 'download';
2
3
  export interface BrowserArtifact {
3
4
  kind: ArtifactKind;
@@ -19,6 +20,10 @@ export declare function listProfileArtifacts(profile: string): BrowserArtifact[]
19
20
  * when empty); otherwise every profile dir on disk that has at least one capture.
20
21
  */
21
22
  export declare function listBrowserSessions(only?: string): ProfileArtifacts[];
23
+ export declare function formatBytes(n: number): string;
24
+ /** Per-kind counts over a flat artifact list — shared by the printed table and
25
+ * the interactive row labels. */
26
+ export declare function countByKind(artifacts: BrowserArtifact[]): Record<ArtifactKind, number>;
22
27
  /** Human table for the CLI. Returns lines (no trailing newline). */
23
28
  export declare function renderBrowserSessions(groups: ProfileArtifacts[]): string;
24
29
  /**
@@ -28,6 +33,82 @@ export declare function renderBrowserSessions(groups: ProfileArtifacts[]): strin
28
33
  export declare function resolveArtifact(groups: ProfileArtifacts[], selector: string): string | null;
29
34
  /** Open a file in the OS default app. Returns true on success. */
30
35
  export declare function openArtifact(filePath: string): boolean;
36
+ /** The identity fields this module cares about from a profile's `tasks.json`. */
37
+ export interface TaskIdentity {
38
+ owner?: string;
39
+ launchId?: string;
40
+ }
41
+ /**
42
+ * Read a profile's `tasks.json` for the identity of its CURRENTLY LIVE tasks,
43
+ * keyed by task name. A task's entry is deleted on `agents browser stop` (see
44
+ * service.ts `saveTaskState`), so a task whose run has already ended is absent
45
+ * here even though its captures remain on disk — that is exactly the
46
+ * "unlinked legacy task" case {@link groupIntoRows} reports. Same read-straight-
47
+ * from-disk approach as the rest of this module: works whether or not the
48
+ * browser daemon is running.
49
+ */
50
+ export declare function loadTaskIdentities(profile: string): Map<string, TaskIdentity>;
51
+ /** launchId -> indexed session id, built once from every source that records
52
+ * this join, so a task-heavy profile resolves without re-scanning per task. */
53
+ export interface LaunchSessionIndex {
54
+ byLaunchId: Map<string, string>;
55
+ }
56
+ /**
57
+ * Build the launchId -> sessionId index from the same three sources
58
+ * `feed-post.ts` `resolvePostIdentity` already uses for this exact problem —
59
+ * a browser task's `launchId` IS the `AGENT_LAUNCH_ID` of the CLI run that
60
+ * called `agents browser start` (see service.ts `resolveTaskIdentity`), so
61
+ * resolving "which session ran this launch" is the same join, not a new one.
62
+ * Lowest-confidence source first so a later, more specific source overwrites
63
+ * it on a collision:
64
+ * 1. the activity log (durable, but least specific — read last)
65
+ * 2. the SessionStart hook's live-session index (kept for parity with
66
+ * `hook-sessions.ts`; empty on this fleet today, harmless when so)
67
+ * 3. the per-pid launch registry (`ag run`, launch-scoped, most authoritative)
68
+ * Computed once per interactive session, not per row.
69
+ */
70
+ export declare function buildLaunchSessionIndex(): LaunchSessionIndex;
71
+ /** Resolve one launchId through the index to its canonical `SessionMeta`, or
72
+ * `null` when the join has no hit or the resolved session isn't indexed here. */
73
+ export declare function resolveLaunchSession(index: LaunchSessionIndex, launchId: string): SessionMeta | null;
74
+ /** Why a row carries no session digest: `linked` resolved one, `unresolved`
75
+ * has a launchId but no matching indexed session, `unlinked` has no launchId
76
+ * at all (a legacy task, or one whose owning run already stopped). */
77
+ export type BrowserSessionLinkStatus = 'linked' | 'unresolved' | 'unlinked';
78
+ /** One task-first row: every capture for one browser task (or, for
79
+ * `kind: 'downloads'`, one profile's downloads bucket), plus the agent
80
+ * session it links to when resolvable. */
81
+ export interface BrowserSessionRow {
82
+ kind: 'task' | 'downloads';
83
+ profile: string;
84
+ /** Task name; undefined for the downloads row. */
85
+ task?: string;
86
+ owner?: string;
87
+ launchId?: string;
88
+ linkStatus: BrowserSessionLinkStatus;
89
+ linkedSession?: SessionMeta;
90
+ /** Newest first. */
91
+ artifacts: BrowserArtifact[];
92
+ counts: Record<ArtifactKind, number>;
93
+ /** Most recent capture's mtime — the row's sort/age key. */
94
+ latestMtimeMs: number;
95
+ }
96
+ /**
97
+ * Group flat per-profile artifacts into task-first rows, newest first. Pure:
98
+ * identities and the launch resolver are passed in, so this has no filesystem
99
+ * or session-index dependency and is unit-testable with synthetic data (see
100
+ * {@link buildBrowserSessionRows} for the impure disk/index-reading caller).
101
+ */
102
+ export declare function groupIntoRows(groups: ProfileArtifacts[], taskIdentities: Map<string, Map<string, TaskIdentity>>, resolveLaunch?: (launchId: string) => SessionMeta | null): BrowserSessionRow[];
103
+ /** Build task-first rows for one profile (or every profile with captures),
104
+ * reading `tasks.json` and resolving launchIds against the live indexes.
105
+ * The interactive picker's data source. */
106
+ export declare function buildBrowserSessionRows(profile?: string): BrowserSessionRow[];
107
+ /**
108
+ * Search predicate for the interactive picker: task name, profile, the linked
109
+ * session's agent/topic/label, and any artifact filename in the row.
110
+ */
111
+ export declare function matchesBrowserSessionRow(row: BrowserSessionRow, query: string): boolean;
31
112
  /**
32
113
  * Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
33
114
  * `open` is the Commander value for `--open [selector]`: undefined when the flag
@@ -4,12 +4,25 @@
4
4
  * Reads straight from `.cache/browser/<profile>/`, so it works whether or not the
5
5
  * browser daemon is running. Backs both `agents browser sessions` and the
6
6
  * `agents sessions --browser` alias.
7
+ *
8
+ * Also owns the task-first grouping (RUSH-2407): browser captures are per-task
9
+ * (`sessions/<task>/`), and a task persists `owner`/`launchId` in `tasks.json`
10
+ * while it is live (see service.ts `resolveTaskIdentity`). This module groups
11
+ * artifacts by task and, when a launchId is available, resolves it to the
12
+ * agent session that ran it — reusing the session index (`getSessionById`) and
13
+ * the same launchId join keys the rest of the CLI already uses for this exact
14
+ * problem (pid registry, the SessionStart hook index, the activity log; see
15
+ * `feed-post.ts` `resolvePostIdentity`), never a second parser.
7
16
  */
8
17
  import * as fs from 'fs';
9
18
  import * as path from 'path';
10
19
  import { spawnSync } from 'child_process';
11
20
  import { getBrowserRuntimeDir, getProfileRuntimeDir } from './profiles.js';
12
21
  import { formatRelativeTime } from '../session/relative-time.js';
22
+ import { getSessionById } from '../session/db.js';
23
+ import { listPidSessionEntries } from '../session/pid-registry.js';
24
+ import { loadHookSessionIndex } from '../session/hook-sessions.js';
25
+ import { readRecentActivity } from '../activity.js';
13
26
  const EXT_KIND = {
14
27
  '.png': 'screenshot',
15
28
  '.jpg': 'screenshot',
@@ -100,7 +113,7 @@ export function listBrowserSessions(only) {
100
113
  .map((p) => ({ profile: p, artifacts: listProfileArtifacts(p) }))
101
114
  .filter((r) => !!only || r.artifacts.length > 0);
102
115
  }
103
- function formatBytes(n) {
116
+ export function formatBytes(n) {
104
117
  if (n < 1024)
105
118
  return `${n} B`;
106
119
  const units = ['KB', 'MB', 'GB'];
@@ -112,15 +125,21 @@ function formatBytes(n) {
112
125
  }
113
126
  return `${val < 10 ? val.toFixed(1) : Math.round(val)} ${units[i]}`;
114
127
  }
128
+ /** Per-kind counts over a flat artifact list — shared by the printed table and
129
+ * the interactive row labels. */
130
+ export function countByKind(artifacts) {
131
+ const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
132
+ for (const a of artifacts)
133
+ counts[a.kind]++;
134
+ return counts;
135
+ }
115
136
  /** Human table for the CLI. Returns lines (no trailing newline). */
116
137
  export function renderBrowserSessions(groups) {
117
138
  if (groups.length === 0)
118
139
  return 'No browser profiles found.';
119
140
  const lines = [];
120
141
  for (const g of groups) {
121
- const counts = { screenshot: 0, pdf: 0, recording: 0, download: 0 };
122
- for (const a of g.artifacts)
123
- counts[a.kind]++;
142
+ const counts = countByKind(g.artifacts);
124
143
  lines.push(`${g.profile} ` +
125
144
  `screenshots ${counts.screenshot} pdfs ${counts.pdf} recordings ${counts.recording} downloads ${counts.download}`);
126
145
  if (g.artifacts.length === 0) {
@@ -161,6 +180,162 @@ export function openArtifact(filePath) {
161
180
  }
162
181
  return false;
163
182
  }
183
+ /**
184
+ * Read a profile's `tasks.json` for the identity of its CURRENTLY LIVE tasks,
185
+ * keyed by task name. A task's entry is deleted on `agents browser stop` (see
186
+ * service.ts `saveTaskState`), so a task whose run has already ended is absent
187
+ * here even though its captures remain on disk — that is exactly the
188
+ * "unlinked legacy task" case {@link groupIntoRows} reports. Same read-straight-
189
+ * from-disk approach as the rest of this module: works whether or not the
190
+ * browser daemon is running.
191
+ */
192
+ export function loadTaskIdentities(profile) {
193
+ const out = new Map();
194
+ let raw;
195
+ try {
196
+ raw = fs.readFileSync(path.join(getProfileRuntimeDir(profile), 'tasks.json'), 'utf8');
197
+ }
198
+ catch {
199
+ return out;
200
+ }
201
+ let parsed;
202
+ try {
203
+ parsed = JSON.parse(raw);
204
+ }
205
+ catch {
206
+ return out;
207
+ }
208
+ if (!parsed || typeof parsed !== 'object')
209
+ return out;
210
+ for (const [name, value] of Object.entries(parsed)) {
211
+ if (!value || typeof value !== 'object')
212
+ continue;
213
+ const t = value;
214
+ out.set(name, {
215
+ owner: typeof t.owner === 'string' ? t.owner : undefined,
216
+ launchId: typeof t.launchId === 'string' ? t.launchId : undefined,
217
+ });
218
+ }
219
+ return out;
220
+ }
221
+ /**
222
+ * Build the launchId -> sessionId index from the same three sources
223
+ * `feed-post.ts` `resolvePostIdentity` already uses for this exact problem —
224
+ * a browser task's `launchId` IS the `AGENT_LAUNCH_ID` of the CLI run that
225
+ * called `agents browser start` (see service.ts `resolveTaskIdentity`), so
226
+ * resolving "which session ran this launch" is the same join, not a new one.
227
+ * Lowest-confidence source first so a later, more specific source overwrites
228
+ * it on a collision:
229
+ * 1. the activity log (durable, but least specific — read last)
230
+ * 2. the SessionStart hook's live-session index (kept for parity with
231
+ * `hook-sessions.ts`; empty on this fleet today, harmless when so)
232
+ * 3. the per-pid launch registry (`ag run`, launch-scoped, most authoritative)
233
+ * Computed once per interactive session, not per row.
234
+ */
235
+ export function buildLaunchSessionIndex() {
236
+ const byLaunchId = new Map();
237
+ for (const ev of readRecentActivity({ maxBytesPerSession: 64 * 1024 })) {
238
+ if (ev.launchId && ev.sessionId)
239
+ byLaunchId.set(ev.launchId, ev.sessionId);
240
+ }
241
+ for (const [launchId, rec] of loadHookSessionIndex().byLaunchId) {
242
+ if (rec.session_id)
243
+ byLaunchId.set(launchId, rec.session_id);
244
+ }
245
+ for (const entry of listPidSessionEntries()) {
246
+ if (entry.launchId && entry.sessionId)
247
+ byLaunchId.set(entry.launchId, entry.sessionId);
248
+ }
249
+ return { byLaunchId };
250
+ }
251
+ /** Resolve one launchId through the index to its canonical `SessionMeta`, or
252
+ * `null` when the join has no hit or the resolved session isn't indexed here. */
253
+ export function resolveLaunchSession(index, launchId) {
254
+ const sessionId = index.byLaunchId.get(launchId);
255
+ return sessionId ? getSessionById(sessionId) : null;
256
+ }
257
+ /**
258
+ * Group flat per-profile artifacts into task-first rows, newest first. Pure:
259
+ * identities and the launch resolver are passed in, so this has no filesystem
260
+ * or session-index dependency and is unit-testable with synthetic data (see
261
+ * {@link buildBrowserSessionRows} for the impure disk/index-reading caller).
262
+ */
263
+ export function groupIntoRows(groups, taskIdentities, resolveLaunch) {
264
+ const rows = [];
265
+ for (const g of groups) {
266
+ const identities = taskIdentities.get(g.profile) ?? new Map();
267
+ const byTask = new Map();
268
+ const downloads = [];
269
+ for (const a of g.artifacts) {
270
+ if (a.kind === 'download' || !a.task) {
271
+ downloads.push(a);
272
+ continue;
273
+ }
274
+ const list = byTask.get(a.task) ?? [];
275
+ list.push(a);
276
+ byTask.set(a.task, list);
277
+ }
278
+ for (const [task, artifacts] of byTask) {
279
+ artifacts.sort((a, b) => b.mtimeMs - a.mtimeMs);
280
+ const identity = identities.get(task);
281
+ const linkedSession = identity?.launchId ? resolveLaunch?.(identity.launchId) ?? null : null;
282
+ rows.push({
283
+ kind: 'task',
284
+ profile: g.profile,
285
+ task,
286
+ owner: identity?.owner,
287
+ launchId: identity?.launchId,
288
+ linkStatus: linkedSession ? 'linked' : identity?.launchId ? 'unresolved' : 'unlinked',
289
+ linkedSession: linkedSession ?? undefined,
290
+ artifacts,
291
+ counts: countByKind(artifacts),
292
+ latestMtimeMs: artifacts[0]?.mtimeMs ?? 0,
293
+ });
294
+ }
295
+ if (downloads.length > 0) {
296
+ downloads.sort((a, b) => b.mtimeMs - a.mtimeMs);
297
+ rows.push({
298
+ kind: 'downloads',
299
+ profile: g.profile,
300
+ linkStatus: 'unlinked',
301
+ artifacts: downloads,
302
+ counts: countByKind(downloads),
303
+ latestMtimeMs: downloads[0]?.mtimeMs ?? 0,
304
+ });
305
+ }
306
+ }
307
+ rows.sort((a, b) => b.latestMtimeMs - a.latestMtimeMs);
308
+ return rows;
309
+ }
310
+ /** Build task-first rows for one profile (or every profile with captures),
311
+ * reading `tasks.json` and resolving launchIds against the live indexes.
312
+ * The interactive picker's data source. */
313
+ export function buildBrowserSessionRows(profile) {
314
+ const groups = listBrowserSessions(profile);
315
+ const taskIdentities = new Map(groups.map((g) => [g.profile, loadTaskIdentities(g.profile)]));
316
+ const index = buildLaunchSessionIndex();
317
+ return groupIntoRows(groups, taskIdentities, (launchId) => resolveLaunchSession(index, launchId));
318
+ }
319
+ /**
320
+ * Search predicate for the interactive picker: task name, profile, the linked
321
+ * session's agent/topic/label, and any artifact filename in the row.
322
+ */
323
+ export function matchesBrowserSessionRow(row, query) {
324
+ const q = query.trim().toLowerCase();
325
+ if (!q)
326
+ return true;
327
+ if (row.task?.toLowerCase().includes(q))
328
+ return true;
329
+ if (row.profile.toLowerCase().includes(q))
330
+ return true;
331
+ if (row.kind === 'downloads' && 'downloads'.includes(q))
332
+ return true;
333
+ const s = row.linkedSession;
334
+ if (s && (s.agent.toLowerCase().includes(q) || s.topic?.toLowerCase().includes(q) || s.label?.toLowerCase().includes(q))) {
335
+ return true;
336
+ }
337
+ return row.artifacts.some((a) => a.name.toLowerCase().includes(q));
338
+ }
164
339
  /**
165
340
  * Shared CLI action for `agents browser sessions` and `agents sessions --browser`.
166
341
  * `open` is the Commander value for `--open [selector]`: undefined when the flag
@@ -1176,11 +1176,22 @@ export function startDaemon(agentsBin) {
1176
1176
  const pid = waitForPid(3000);
1177
1177
  return { pid, method: 'already-starting' };
1178
1178
  }
1179
+ // Released by startDaemonLocked the moment the launch has been ISSUED, and
1180
+ // again here as the backstop for every path that returned before reaching
1181
+ // that point (a throw, or the platform default branch). Idempotent so the
1182
+ // double call is a no-op rather than unlinking a lock a later claimer owns.
1183
+ let released = false;
1184
+ const releaseOnce = () => {
1185
+ if (released)
1186
+ return;
1187
+ released = true;
1188
+ releaseLock();
1189
+ };
1179
1190
  try {
1180
- return startDaemonLocked(agentsBin ?? getAgentsBinPath());
1191
+ return startDaemonLocked(agentsBin ?? getAgentsBinPath(), releaseOnce);
1181
1192
  }
1182
1193
  finally {
1183
- releaseLock();
1194
+ releaseOnce();
1184
1195
  }
1185
1196
  }
1186
1197
  /**
@@ -1207,8 +1218,33 @@ export function ensureDaemonStarted() {
1207
1218
  return null;
1208
1219
  }
1209
1220
  }
1210
- function startDaemonLocked(agentsBin) {
1221
+ /**
1222
+ * Issue the launch, then wait for the child to record its pid.
1223
+ *
1224
+ * RUSH-2417: the wait phase MUST NOT hold the start lock. `acquireStartLock`
1225
+ * and `claimDaemonInstance` resolve the same `<daemonDir>/daemon.lock`, so a
1226
+ * parent that busy-waits on `waitForPid` while still holding it deterministically
1227
+ * defeats the child it just launched: the child's `claimDaemonInstance` hits
1228
+ * EEXIST, reads a holder pid that IS alive (this process), and exits with the
1229
+ * false "another daemon is mid-takeover" warning — every launchd/systemd start
1230
+ * on a fresh install. The lock's job is to keep two concurrent `startDaemon()`
1231
+ * calls from both launching, and that is done once `launchctl load` /
1232
+ * `systemctl start` / the detached spawn has been issued, so `releaseLock()` is
1233
+ * called there rather than in the caller's `finally`.
1234
+ *
1235
+ * Releasing early cannot produce two daemons: launchd (one plist label) and
1236
+ * systemd (one unit) are singletons that no-op a second start, and the detached
1237
+ * path is covered by `claimDaemonInstance`'s last-wins takeover (SING-11) —
1238
+ * a second claimer evicts the incumbent rather than running beside it.
1239
+ */
1240
+ function startDaemonLocked(agentsBin, releaseLock) {
1211
1241
  const platform = os.platform();
1242
+ // Same contract on the fallback path: the spawn IS the launch, so the lock is
1243
+ // dropped before the child exists rather than in a `finally` the child races.
1244
+ const detachedFallback = () => {
1245
+ releaseLock();
1246
+ return startDetached({ agentsBin });
1247
+ };
1212
1248
  if (platform === 'darwin') {
1213
1249
  try {
1214
1250
  const plistPath = getLaunchdPlistPath();
@@ -1229,6 +1265,8 @@ function startDaemonLocked(agentsBin) {
1229
1265
  // of success. If no pid materializes within the window, give up on
1230
1266
  // launchd and fall through to a plain detached spawn.
1231
1267
  execFileSync('launchctl', ['load', plistPath], { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
1268
+ // Launch issued — the child needs this lock to claim (RUSH-2417).
1269
+ releaseLock();
1232
1270
  const pid = waitForPid(3000) ?? readServiceManagerPid();
1233
1271
  if (pid)
1234
1272
  return { pid, method: 'launchd' };
@@ -1237,7 +1275,7 @@ function startDaemonLocked(agentsBin) {
1237
1275
  catch {
1238
1276
  // load threw — fall through to detached spawn
1239
1277
  }
1240
- return startDetached({ agentsBin });
1278
+ return detachedFallback();
1241
1279
  }
1242
1280
  if (platform === 'linux') {
1243
1281
  try {
@@ -1252,6 +1290,8 @@ function startDaemonLocked(agentsBin) {
1252
1290
  execFileSync('systemctl', ['--user', 'daemon-reload'], { encoding: 'utf-8' });
1253
1291
  execFileSync('systemctl', ['--user', 'enable', SYSTEMD_UNIT], { encoding: 'utf-8' });
1254
1292
  execFileSync('systemctl', ['--user', 'start', SYSTEMD_UNIT], { encoding: 'utf-8' });
1293
+ // Launch issued — the child needs this lock to claim (RUSH-2417).
1294
+ releaseLock();
1255
1295
  const pid = waitForPid(3000) ?? readServiceManagerPid();
1256
1296
  if (pid)
1257
1297
  return { pid, method: 'systemd' };
@@ -1261,7 +1301,7 @@ function startDaemonLocked(agentsBin) {
1261
1301
  catch {
1262
1302
  // start threw — fall through to detached spawn
1263
1303
  }
1264
- return startDetached({ agentsBin });
1304
+ return detachedFallback();
1265
1305
  }
1266
1306
  return startDetached({ agentsBin });
1267
1307
  }
@@ -37,6 +37,30 @@ export declare function fleetDialTarget(device: DeviceProfile): string;
37
37
  * login).
38
38
  */
39
39
  export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]): string | undefined;
40
+ /**
41
+ * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
42
+ * mirrored project directory, falling back to the remote home when that
43
+ * directory is absent. Returns undefined when there is nothing to mirror
44
+ * (`mirrorCwd` is undefined, or resolves to the home root itself) so the caller
45
+ * keeps the plain no-command interactive login.
46
+ *
47
+ * This is the interactive analogue of `agents run --host`'s cwd mirroring, and
48
+ * it reuses the SAME machinery so there is no second resolver: the portable
49
+ * `mirrorCwd` comes from `deriveMirroredCwd`, and — for POSIX — the best-effort
50
+ * `cd` comes from `remoteCdPrefix({ mirror: true })`, whose `|| cd "$HOME"`
51
+ * guarantees a missing checkout never fails the login (acceptance #2). It then
52
+ * replaces the wrapper with a login shell (`exec "$SHELL" -l`); running under
53
+ * the forced tty (`-tt`, see {@link buildSshInvocation}) makes that login shell
54
+ * interactive, so prompt, startup files, and login behavior match a plain
55
+ * `ssh <host>` (acceptance #1).
56
+ *
57
+ * PowerShell hosts get a profile-loading interactive shell (`-NoExit`, and
58
+ * deliberately NOT `-NoProfile` so the user's profile still runs) that
59
+ * `Set-Location`s into the mirrored dir when it exists. The path is carried
60
+ * through `-EncodedCommand` (base64 UTF-16LE), so it is literal and
61
+ * injection-safe regardless of spaces or shell metacharacters (acceptance #3).
62
+ */
63
+ export declare function buildInteractiveShellCommand(device: DeviceProfile, mirrorCwd: string | undefined): string | undefined;
40
64
  /** Host-key posture for {@link buildSshInvocation}. */
41
65
  export interface SshHostKeyOptions {
42
66
  /**
@@ -65,9 +89,18 @@ export declare function deviceIdentityArgs(device: DeviceProfile): string[];
65
89
  * `opts.agentOnly` marks the connection as a read-only probe: for password auth
66
90
  * it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
67
91
  * stays broker-only and never pops a foreground biometric (RUSH-1970).
92
+ *
93
+ * `opts.interactiveCwd` is the portable (`~/…`) directory to mirror on an
94
+ * interactive login (no `cmd`) — from `deriveMirroredCwd(process.cwd())`. When
95
+ * it names a real home-relative sub-path, the login starts there via
96
+ * {@link buildInteractiveShellCommand} (best-effort — a missing dir falls back
97
+ * to the remote home), matching `agents run --host`. It is ignored when a `cmd`
98
+ * is given: an explicit command keeps its current cwd (the remote home) and its
99
+ * behavior unchanged (RUSH-2412).
68
100
  */
69
101
  export declare function buildSshInvocation(device: DeviceProfile, cmd: string[], askpassShimPath: string, hostKey?: SshHostKeyOptions, opts?: {
70
102
  agentOnly?: boolean;
103
+ interactiveCwd?: string;
71
104
  }): {
72
105
  args: string[];
73
106
  env: Record<string, string>;
@@ -18,6 +18,7 @@ import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
19
  import { getCliLaunch } from '../cli-entry.js';
20
20
  import { encodePwshBase64 } from '../pwsh.js';
21
+ import { homeRemainder, remoteCdPrefix } from '../project-root.js';
21
22
  import { getCacheDir } from '../state.js';
22
23
  import { hostKeyCheckingOpts } from './known-hosts.js';
23
24
  import { hostNameFor } from './ssh-config.js';
@@ -82,6 +83,48 @@ export function wrapRemoteCommand(device, cmd) {
82
83
  }
83
84
  return joined;
84
85
  }
86
+ /**
87
+ * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
88
+ * mirrored project directory, falling back to the remote home when that
89
+ * directory is absent. Returns undefined when there is nothing to mirror
90
+ * (`mirrorCwd` is undefined, or resolves to the home root itself) so the caller
91
+ * keeps the plain no-command interactive login.
92
+ *
93
+ * This is the interactive analogue of `agents run --host`'s cwd mirroring, and
94
+ * it reuses the SAME machinery so there is no second resolver: the portable
95
+ * `mirrorCwd` comes from `deriveMirroredCwd`, and — for POSIX — the best-effort
96
+ * `cd` comes from `remoteCdPrefix({ mirror: true })`, whose `|| cd "$HOME"`
97
+ * guarantees a missing checkout never fails the login (acceptance #2). It then
98
+ * replaces the wrapper with a login shell (`exec "$SHELL" -l`); running under
99
+ * the forced tty (`-tt`, see {@link buildSshInvocation}) makes that login shell
100
+ * interactive, so prompt, startup files, and login behavior match a plain
101
+ * `ssh <host>` (acceptance #1).
102
+ *
103
+ * PowerShell hosts get a profile-loading interactive shell (`-NoExit`, and
104
+ * deliberately NOT `-NoProfile` so the user's profile still runs) that
105
+ * `Set-Location`s into the mirrored dir when it exists. The path is carried
106
+ * through `-EncodedCommand` (base64 UTF-16LE), so it is literal and
107
+ * injection-safe regardless of spaces or shell metacharacters (acceptance #3).
108
+ */
109
+ export function buildInteractiveShellCommand(device, mirrorCwd) {
110
+ if (!mirrorCwd)
111
+ return undefined;
112
+ const rest = homeRemainder(mirrorCwd);
113
+ // Only a real sub-path of the home dir is worth mirroring; the home root
114
+ // itself (rest === '') is where a plain login already lands, and a
115
+ // non-home-anchored path (rest === null) has no meaningful remote analogue.
116
+ if (rest === null || rest === '')
117
+ return undefined;
118
+ if (device.shell === 'powershell') {
119
+ // Single-quoted PowerShell literal: the only escape inside '…' is '' for a
120
+ // literal quote, so this is injection-safe for any path.
121
+ const literal = rest.replace(/'/g, "''");
122
+ const script = `$d = Join-Path -Path $HOME -ChildPath '${literal}'; ` +
123
+ `if (Test-Path -LiteralPath $d) { Set-Location -LiteralPath $d }`;
124
+ return `powershell -NoLogo -NoExit -EncodedCommand ${encodePwshBase64(script)}`;
125
+ }
126
+ return `${remoteCdPrefix(mirrorCwd, { mirror: true })}exec "$SHELL" -l`;
127
+ }
85
128
  /** OpenSSH argv that makes an explicit device key authoritative. */
86
129
  export function deviceIdentityArgs(device) {
87
130
  return device.auth?.method === 'key' && device.auth.identityFile
@@ -101,10 +144,24 @@ export function deviceIdentityArgs(device) {
101
144
  * `opts.agentOnly` marks the connection as a read-only probe: for password auth
102
145
  * it sets {@link ASKPASS_AGENT_ONLY_ENV} in the overlay so the askpass resolve
103
146
  * stays broker-only and never pops a foreground biometric (RUSH-1970).
147
+ *
148
+ * `opts.interactiveCwd` is the portable (`~/…`) directory to mirror on an
149
+ * interactive login (no `cmd`) — from `deriveMirroredCwd(process.cwd())`. When
150
+ * it names a real home-relative sub-path, the login starts there via
151
+ * {@link buildInteractiveShellCommand} (best-effort — a missing dir falls back
152
+ * to the remote home), matching `agents run --host`. It is ignored when a `cmd`
153
+ * is given: an explicit command keeps its current cwd (the remote home) and its
154
+ * behavior unchanged (RUSH-2412).
104
155
  */
105
156
  export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, opts = {}) {
106
157
  const target = sshTargetFor(device);
107
- const remote = wrapRemoteCommand(device, cmd);
158
+ // No cmd interactive login. It may still carry a derived cd+login-shell
159
+ // wrapper (interactiveCwd), which is an interactive login too and still needs
160
+ // a real tty below.
161
+ const interactive = cmd.length === 0;
162
+ const remote = interactive
163
+ ? buildInteractiveShellCommand(device, opts.interactiveCwd)
164
+ : wrapRemoteCommand(device, cmd);
108
165
  const env = {};
109
166
  const args = [
110
167
  ...hostKeyCheckingOpts(hostKey.pinned ?? false, hostKey.knownHostsFile),
@@ -126,8 +183,9 @@ export function buildSshInvocation(device, cmd, askpassShimPath, hostKey = {}, o
126
183
  args.push('-o', 'BatchMode=yes');
127
184
  args.push(...deviceIdentityArgs(device));
128
185
  }
129
- // An interactive login (no remote command) needs a real tty.
130
- if (!remote)
186
+ // An interactive login needs a real tty — whether it starts a bare login
187
+ // shell (no remote command) or the derived cd+login-shell mirror.
188
+ if (interactive)
131
189
  args.push('-tt');
132
190
  args.push(target);
133
191
  if (remote)
@@ -55,8 +55,10 @@ export interface DoctorFinding {
55
55
  * The exact remediation for a finding. Login fixes are harness-native
56
56
  * (`loginHint`); a per-version login is offered ONLY for agents that isolate the
57
57
  * credential per home (`agents run <agent>@<version>` then log in) — for
58
- * gemini/antigravity/droid/cursor the login is shared, so we say so instead of
59
- * faking a per-version fix. Every other kind maps to its canonical command.
58
+ * gemini/antigravity/droid the login is shared, so we say so instead of
59
+ * faking a per-version fix. (Cursor now isolates its token per version home via
60
+ * XDG_CONFIG_HOME — see buildExecEnv — so it gets the per-version run.) Every
61
+ * other kind maps to its canonical command.
60
62
  */
61
63
  export declare function remediationFor(finding: DoctorFinding): string;
62
64
  export interface LocalFindingInputs {
@@ -168,8 +168,10 @@ function sortedAgentIds(ids) {
168
168
  * The exact remediation for a finding. Login fixes are harness-native
169
169
  * (`loginHint`); a per-version login is offered ONLY for agents that isolate the
170
170
  * credential per home (`agents run <agent>@<version>` then log in) — for
171
- * gemini/antigravity/droid/cursor the login is shared, so we say so instead of
172
- * faking a per-version fix. Every other kind maps to its canonical command.
171
+ * gemini/antigravity/droid the login is shared, so we say so instead of
172
+ * faking a per-version fix. (Cursor now isolates its token per version home via
173
+ * XDG_CONFIG_HOME — see buildExecEnv — so it gets the per-version run.) Every
174
+ * other kind maps to its canonical command.
173
175
  */
174
176
  export function remediationFor(finding) {
175
177
  const { kind, agent, version } = finding;