@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -333,6 +333,11 @@ export function arcCreateMarker() {
333
333
  export function isArcCreateMarker(url) {
334
334
  return url.startsWith(ARC_CREATE_MARKER_PREFIX);
335
335
  }
336
+ /** Run `fn` once per pass and share its promise, rejections included. */
337
+ function onceAsync(fn) {
338
+ let pending;
339
+ return () => (pending ??= fn());
340
+ }
336
341
  export class BrowserService {
337
342
  static SOURCE_PREFIX = {
338
343
  'rush-app': 'rush-app-',
@@ -2198,9 +2203,20 @@ export class BrowserService {
2198
2203
  * it used to return (RUSH-2709).
2199
2204
  */
2200
2205
  async status(profileRef) {
2206
+ // Built before rehydration: a cold pass rehydrates every saved runtime dir
2207
+ // first, and each Arc one probed Arc on its own, spending the budget before
2208
+ // the snapshot existed. One enumeration and one liveness read cover the pass.
2209
+ const arc = {
2210
+ spaces: onceAsync(() => enumerateArcSpaces()),
2211
+ running: onceAsync(() => isArcRunning()),
2212
+ };
2201
2213
  // Reconnect live browsers after a daemon restart so status is not empty
2202
- // while tasks.json still holds open work.
2203
- await this.rehydrateAllFromDisk();
2214
+ // while tasks.json still holds open work. Rehydration is per runtime dir:
2215
+ // one unreadable saved profile is reported below, never fatal to the pass.
2216
+ const rehydrateErrors = new Map();
2217
+ await this.rehydrateAllFromDisk(arc.running, (key, err) => {
2218
+ rehydrateErrors.set(key, err instanceof Error ? err.message : String(err));
2219
+ });
2204
2220
  // A caller who pasted a runtime key out of an older listing still gets the
2205
2221
  // profile it belongs to, rather than an empty result.
2206
2222
  const profileName = profileRef ? parseConnectionKey(profileRef).profile : undefined;
@@ -2210,25 +2226,99 @@ export class BrowserService {
2210
2226
  ? [...this.connections.keys()].filter((k) => keyBelongsToProfile(k, profileName))
2211
2227
  : Array.from(this.connections.keys());
2212
2228
  for (const key of candidates) {
2213
- const status = await this.getProfileStatus(key);
2229
+ // Read-only: a profile that cannot be read is reported as unreadable, not
2230
+ // allowed to abort the listing. Before this, a single stale Arc task made
2231
+ // `status` throw and EVERY profile vanished, including healthy ones.
2232
+ let status;
2233
+ try {
2234
+ status = await this.getProfileStatus(key, arc);
2235
+ }
2236
+ catch (err) {
2237
+ const parsed = parseConnectionKey(key);
2238
+ statuses.push({
2239
+ name: this.connections.get(key)?.profile ?? parsed.profile,
2240
+ endpoint: parsed.endpoint,
2241
+ key,
2242
+ running: false,
2243
+ tasks: [],
2244
+ unavailable: err instanceof Error ? err.message : String(err),
2245
+ });
2246
+ seenProfiles.add(parsed.profile);
2247
+ continue;
2248
+ }
2214
2249
  if (status) {
2215
2250
  statuses.push(status);
2216
2251
  seenProfiles.add(status.name);
2217
2252
  }
2218
2253
  }
2254
+ // Reported BEFORE the disk reconcile, and keyed by the runtime dir that
2255
+ // actually failed. Reconcile reads the same `tasks.json`, so without this
2256
+ // ordering one corrupt runtime produced two rows for the same profile: one
2257
+ // keyed here and one unkeyed from reconcile, which the dedupe then missed.
2258
+ for (const [key, message] of rehydrateErrors) {
2259
+ const parsed = parseConnectionKey(key);
2260
+ if (profileName && parsed.profile !== profileName)
2261
+ continue;
2262
+ if (statuses.some((status) => status.key === key))
2263
+ continue;
2264
+ statuses.push({
2265
+ name: parsed.profile,
2266
+ endpoint: parsed.endpoint,
2267
+ key,
2268
+ running: false,
2269
+ tasks: [],
2270
+ unavailable: message,
2271
+ });
2272
+ seenProfiles.add(parsed.profile);
2273
+ }
2274
+ // Disk reconcile reads `tasks.json` too, so it fails on exactly the same
2275
+ // corrupt file — report that profile rather than let it empty the table.
2276
+ const reconcile = async (name) => {
2277
+ const failures = new Map();
2278
+ let reconciled = null;
2279
+ try {
2280
+ reconciled = await this.reconcileFromDisk(name, (key, err) => {
2281
+ failures.set(key, err instanceof Error ? err.message : String(err));
2282
+ });
2283
+ }
2284
+ catch (err) {
2285
+ // Listing the profile's runtime dirs failed, so no single runtime is at
2286
+ // fault and none is invented: this is reported at profile level.
2287
+ statuses.push({
2288
+ name,
2289
+ running: false,
2290
+ tasks: [],
2291
+ unavailable: err instanceof Error ? err.message : String(err),
2292
+ });
2293
+ return;
2294
+ }
2295
+ if (reconciled)
2296
+ statuses.push(reconciled);
2297
+ // Keyed by the runtime dir that actually failed, so a profile with several
2298
+ // runtimes names the unreadable one rather than guessing at the first.
2299
+ for (const [key, message] of failures) {
2300
+ if (statuses.some((status) => status.key === key))
2301
+ continue;
2302
+ const parsed = parseConnectionKey(key);
2303
+ statuses.push({
2304
+ name,
2305
+ endpoint: parsed.endpoint,
2306
+ key,
2307
+ running: false,
2308
+ tasks: [],
2309
+ unavailable: message,
2310
+ });
2311
+ }
2312
+ };
2219
2313
  if (!profileName) {
2220
2314
  for (const profile of await listProfiles()) {
2221
2315
  if (seenProfiles.has(profile.name))
2222
2316
  continue;
2223
- const reconciled = await this.reconcileFromDisk(profile.name);
2224
- if (reconciled)
2225
- statuses.push(reconciled);
2317
+ await reconcile(profile.name);
2226
2318
  }
2227
2319
  }
2228
2320
  else if (statuses.length === 0) {
2229
- const reconciled = await this.reconcileFromDisk(profileName);
2230
- if (reconciled)
2231
- statuses.push(reconciled);
2321
+ await reconcile(profileName);
2232
2322
  }
2233
2323
  return statuses;
2234
2324
  }
@@ -2241,14 +2331,27 @@ export class BrowserService {
2241
2331
  * ({@link listProfileCacheDirs}), including the legacy pre-composite dir named
2242
2332
  * exactly the profile.
2243
2333
  */
2244
- async reconcileFromDisk(profileName) {
2334
+ async reconcileFromDisk(profileName,
2335
+ /**
2336
+ * Read-only callers pass a sink so an unreadable runtime dir is attributed
2337
+ * to the dir that actually failed, and the remaining dirs are still tried.
2338
+ * Callers without a sink keep the strict behaviour.
2339
+ */
2340
+ onError) {
2245
2341
  const dirs = listProfileCacheDirs(profileName);
2246
2342
  for (const dir of dirs) {
2247
2343
  const key = asConnectionKey(path.basename(dir));
2248
- const info = getRunningChromeInfo(key);
2249
- if (!info)
2250
- continue;
2251
- return this.profileStatusFromDisk(profileName, key, info.port, info.pid);
2344
+ try {
2345
+ const info = getRunningChromeInfo(key);
2346
+ if (!info)
2347
+ continue;
2348
+ return await this.profileStatusFromDisk(profileName, key, info.port, info.pid);
2349
+ }
2350
+ catch (err) {
2351
+ if (!onError)
2352
+ throw err;
2353
+ onError(key, err);
2354
+ }
2252
2355
  }
2253
2356
  return null;
2254
2357
  }
@@ -3347,7 +3450,13 @@ export class BrowserService {
3347
3450
  * Returns null when nothing is speaking CDP so callers can fall through
3348
3451
  * to disk reconcile.
3349
3452
  */
3350
- async attachRunningProfile(key, diskTasks) {
3453
+ async attachRunningProfile(key, diskTasks,
3454
+ /**
3455
+ * Shared Arc liveness for one status pass; each saved Arc runtime used to
3456
+ * probe on its own. Callers with no pass to share pass nothing and probe
3457
+ * directly, as before.
3458
+ */
3459
+ arcRunning) {
3351
3460
  const { profile: bare, endpoint: endpointFromKey } = parseConnectionKey(key);
3352
3461
  const profile = await getProfile(bare);
3353
3462
  if (!profile)
@@ -3371,7 +3480,7 @@ export class BrowserService {
3371
3480
  // is still running. No port, no CDP — just verify Arc is alive and restore
3372
3481
  // the task state.
3373
3482
  if (resolved.target.startsWith('arc-native:')) {
3374
- if (process.platform !== 'darwin' || !(await isArcRunning()))
3483
+ if (process.platform !== 'darwin' || !(await (arcRunning ?? isArcRunning)()))
3375
3484
  return null;
3376
3485
  const tasks = this.loadTaskState(key);
3377
3486
  for (const [k, t] of diskTasks) {
@@ -3575,7 +3684,14 @@ export class BrowserService {
3575
3684
  }
3576
3685
  return existing;
3577
3686
  }
3578
- async rehydrateAllFromDisk() {
3687
+ async rehydrateAllFromDisk(arcRunning,
3688
+ /**
3689
+ * Read-only callers pass a sink to make rehydration per-runtime: one saved
3690
+ * dir that cannot be read (corrupt `tasks.json`, an Arc Apple Event that
3691
+ * times out) is reported through this instead of aborting the pass. Callers
3692
+ * without a sink keep the strict behaviour and the error propagates.
3693
+ */
3694
+ onError) {
3579
3695
  const runtimeRoot = getBrowserRuntimeDir();
3580
3696
  let dirNames = [];
3581
3697
  try {
@@ -3599,24 +3715,31 @@ export class BrowserService {
3599
3715
  // Skip non-runtime dirs (e.g. profiles/)
3600
3716
  if (dirName === 'profiles' || dirName === 'sessions' || dirName === 'exports')
3601
3717
  continue;
3602
- const tasks = this.loadTaskState(key);
3603
- if (tasks.size === 0)
3604
- continue;
3605
- // Soft attach only — never launch / never clear pid files.
3606
- const conn = await this.attachRunningProfile(key, tasks);
3607
- if (!conn)
3608
- continue;
3609
- // A concurrent rehydrate may have registered one for this key while we
3610
- // awaited the attach; keep the winner and tear our loser's tunnel down.
3611
- const registered = this.registerRehydratedConnection(key, conn);
3612
- if (registered !== conn)
3613
- continue;
3614
3718
  try {
3615
- if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3616
- await this.applyDefaultDownloadBehavior(conn, key);
3719
+ const tasks = this.loadTaskState(key);
3720
+ if (tasks.size === 0)
3721
+ continue;
3722
+ // Soft attach only — never launch / never clear pid files.
3723
+ const conn = await this.attachRunningProfile(key, tasks, arcRunning);
3724
+ if (!conn)
3725
+ continue;
3726
+ // A concurrent rehydrate may have registered one for this key while we
3727
+ // awaited the attach; keep the winner and tear our loser's tunnel down.
3728
+ const registered = this.registerRehydratedConnection(key, conn);
3729
+ if (registered !== conn)
3730
+ continue;
3731
+ try {
3732
+ if (conn.backend !== 'arc-native' && conn.backend !== 'bidi')
3733
+ await this.applyDefaultDownloadBehavior(conn, key);
3734
+ }
3735
+ catch {
3736
+ // Non-fatal for rehydrate.
3737
+ }
3617
3738
  }
3618
- catch {
3619
- // Non-fatal for rehydrate.
3739
+ catch (err) {
3740
+ if (!onError)
3741
+ throw err;
3742
+ onError(key, err);
3620
3743
  }
3621
3744
  }
3622
3745
  }
@@ -3708,14 +3831,29 @@ export class BrowserService {
3708
3831
  return tabs;
3709
3832
  }
3710
3833
  /** Live status for ONE registered connection, addressed by its runtime key. */
3711
- async getProfileStatus(key) {
3834
+ async getProfileStatus(key, arc) {
3712
3835
  const conn = this.connections.get(key);
3713
3836
  if (!conn)
3714
3837
  return null;
3715
3838
  if (conn.backend === 'arc-native') {
3839
+ // One snapshot and one liveness read for the whole pass, shared across
3840
+ // every task and every Arc profile (built in `status`).
3841
+ const spaces = arc?.spaces ?? onceAsync(() => enumerateArcSpaces());
3842
+ const running = arc?.running ?? onceAsync(() => isArcRunning());
3716
3843
  const tasks = [];
3717
3844
  for (const task of conn.tasks.values()) {
3718
- const tabs = await this.listArcTaskTabs(task);
3845
+ // The moved-tab refusal is the ownership guarantee actions rely on and
3846
+ // stays strict. `status` only reads, so one stale task must not take down
3847
+ // its neighbours: report what disk knows plus the reason, and leave
3848
+ // `tabs` ABSENT rather than empty so nothing claims a moved tab is live.
3849
+ let tabs = [];
3850
+ let unavailable;
3851
+ try {
3852
+ tabs = await this.listArcTaskTabsFromSnapshot(task, spaces);
3853
+ }
3854
+ catch (err) {
3855
+ unavailable = err instanceof Error ? err.message : String(err);
3856
+ }
3719
3857
  const domains = tabs.flatMap((tab) => {
3720
3858
  try {
3721
3859
  const domain = new URL(tab.url).hostname.replace(/^www\./, '');
@@ -3734,6 +3872,7 @@ export class BrowserService {
3734
3872
  createdAt: task.createdAt,
3735
3873
  tabs: tabs.length ? tabs : undefined,
3736
3874
  domains: [...new Set(domains)],
3875
+ unavailable,
3737
3876
  });
3738
3877
  }
3739
3878
  const parsed = parseConnectionKey(key);
@@ -3741,7 +3880,7 @@ export class BrowserService {
3741
3880
  name: conn.profile ?? parsed.profile,
3742
3881
  endpoint: parsed.endpoint,
3743
3882
  key,
3744
- running: await isArcRunning(),
3883
+ running: await running(),
3745
3884
  port: 0,
3746
3885
  pid: 0,
3747
3886
  tasks,
@@ -4217,6 +4356,39 @@ export class BrowserService {
4217
4356
  }
4218
4357
  return executeJavaScript(ref, expression);
4219
4358
  }
4359
+ /**
4360
+ * Resolve a task's owned Arc tabs against one shared snapshot of every
4361
+ * window/space/tab, rather than asking Arc per tab as `listArcTaskTabs` does.
4362
+ *
4363
+ * Same ownership rule as the live path — `requireArcTask`, then the recorded
4364
+ * ref must be present in its original window and Space. The snapshot changes
4365
+ * how often Arc is asked, never what counts as owned. Actions keep the live
4366
+ * path, because a snapshot can go stale before it is acted on.
4367
+ */
4368
+ async listArcTaskTabsFromSnapshot(task, spaces) {
4369
+ // Before the snapshot on purpose: a ref naming a window its task does not
4370
+ // own is a moved tab on the record alone, so it needs no round trip and
4371
+ // reports the right reason even when Arc is unreachable.
4372
+ const native = this.requireArcTask(task);
4373
+ const snapshot = await spaces();
4374
+ const tabs = [];
4375
+ for (const [shortId, ref] of Object.entries(native.tabs)) {
4376
+ const live = snapshot
4377
+ .find((space) => space.windowId === ref.windowId && space.spaceId === ref.spaceId)
4378
+ ?.tabs.find((tab) => tab.tabId === ref.tabId);
4379
+ if (!live) {
4380
+ throw new Error(`Owned Arc tab ${shortId} is missing from its original window/Space; refusing to adopt a moved tab.`);
4381
+ }
4382
+ tabs.push({
4383
+ id: shortId,
4384
+ url: live.url,
4385
+ title: live.title,
4386
+ task: task.name,
4387
+ current: shortId === task.currentTabId,
4388
+ });
4389
+ }
4390
+ return tabs;
4391
+ }
4220
4392
  async listArcTaskTabs(task) {
4221
4393
  const native = this.requireArcTask(task);
4222
4394
  const tabs = [];
@@ -358,6 +358,15 @@ export interface ProfileStatus {
358
358
  /** The port declared in the profile's first endpoint, when it differs from the running port. */
359
359
  configuredPort?: number;
360
360
  tasks: TaskStatus[];
361
+ /**
362
+ * Why this profile's live state could not be read, when it could not be.
363
+ *
364
+ * `status` is read-only, so one unreadable profile must not hide the others:
365
+ * the profile is still listed, with what is known from disk, and this says
366
+ * what is missing. Absent means the read succeeded — it is never set to paper
367
+ * over a failure that did not happen.
368
+ */
369
+ unavailable?: string;
361
370
  }
362
371
  export interface TaskStatus {
363
372
  id: string;
@@ -376,6 +385,15 @@ export interface TaskStatus {
376
385
  current?: boolean;
377
386
  task?: string;
378
387
  }>;
388
+ /**
389
+ * Why this task's live tabs could not be enumerated, when they could not be.
390
+ *
391
+ * Set together with an ABSENT {@link tabs} — the recorded {@link tabCount}
392
+ * still reports what the task owns on disk, but nothing claims those tabs are
393
+ * live, because that is exactly what could not be verified. An Arc tab moved
394
+ * to another window or Space is the case this exists for.
395
+ */
396
+ unavailable?: string;
379
397
  }
380
398
  export interface HistoricalTask {
381
399
  id: string;
@@ -16,7 +16,7 @@ export type ConfigScope = 'run' | 'interactive' | 'auto' | 'browser' | 'project'
16
16
  * CONFIG_KEYS (user scope); this list is only the parser's allow-set so an
17
17
  * unknown `menubar.menu.<x>` fails loud here instead of at the store.
18
18
  */
19
- export declare const MENUBAR_MENU_PROPERTIES: readonly ["defaultProject", "workingRowsShown", "showPreviews", "projectPriorityFilter", "hideCompletedMilestones", "bannerWhenNeedsYou", "includeOtherDeviceRequests", "groupBy", "thenBy", "projectScope", "projectSort", "ticketSort", "showPullRequests"];
19
+ export declare const MENUBAR_MENU_PROPERTIES: readonly ["defaultProject", "workingRowsShown", "showPreviews", "projectPriorityFilter", "hideCompletedMilestones", "bannerWhenNeedsYou", "includeOtherDeviceRequests", "groupBy", "thenBy", "projectScope", "projectSort", "ticketSort", "showPullRequests", "sessionUpdates", "deviceSort", "headlessAgent", "headlessFallbackAgent", "headlessPlacement"];
20
20
  /** A run-time default key: model, mode, effort, or tier override. */
21
21
  export interface ParsedRunConfigKey {
22
22
  scope: 'run';
@@ -29,6 +29,11 @@ export const MENUBAR_MENU_PROPERTIES = [
29
29
  'projectSort',
30
30
  'ticketSort',
31
31
  'showPullRequests',
32
+ 'sessionUpdates',
33
+ 'deviceSort',
34
+ 'headlessAgent',
35
+ 'headlessFallbackAgent',
36
+ 'headlessPlacement',
32
37
  ];
33
38
  const DEVICE_CONFIG_PROPERTIES = [
34
39
  'browser.viewer',
@@ -505,6 +505,67 @@ export const CONFIG_KEYS = [
505
505
  defaultValue: true,
506
506
  description: 'AGI Menu: show the open pull requests section for each project.',
507
507
  },
508
+ {
509
+ name: 'menubar.menu.sessionUpdates',
510
+ yamlKey: 'menubarMenuSessionUpdates',
511
+ scope: 'user',
512
+ type: 'bool',
513
+ defaultValue: true,
514
+ description: 'AGI Menu: show the session-updates section on Home. Off leaves Home to requests and progress ' +
515
+ 'only; the full list stays on the Sessions tab either way.',
516
+ },
517
+ {
518
+ name: 'menubar.menu.deviceSort',
519
+ yamlKey: 'menubarMenuDeviceSort',
520
+ scope: 'user',
521
+ type: 'string',
522
+ defaultValue: 'name',
523
+ description: 'AGI Menu: how the device list is ordered — name, role, load, memory, or disk. The spec orders read ' +
524
+ 'the same cached fleet stats the rows render, so a device with no observation sorts last rather than ' +
525
+ 'as zero.',
526
+ validate: oneOf('menubar.menu.deviceSort', ['name', 'role', 'load', 'memory', 'disk']),
527
+ },
528
+ {
529
+ name: 'menubar.menu.headlessAgent',
530
+ yamlKey: 'menubarMenuHeadlessAgent',
531
+ scope: 'user',
532
+ type: 'string',
533
+ description: 'AGI Menu: the harness a Headless run dispatches to by default (an agent id, e.g. claude). Unset ' +
534
+ 'leaves the choice to the menu\'s own default — this key never invents a primary harness.',
535
+ validate: (v) => isAgentId(v) ? null : `menubar.menu.headlessAgent must be an agent id (e.g. claude, codex), got ${JSON.stringify(v)}.`,
536
+ },
537
+ {
538
+ name: 'menubar.menu.headlessFallbackAgent',
539
+ yamlKey: 'menubarMenuHeadlessFallbackAgent',
540
+ scope: 'user',
541
+ type: 'string',
542
+ description: 'AGI Menu: the permitted alternate harness for a Headless run, dispatched as `agents run --fallback ' +
543
+ '<agent>`. It is reached only after every usable account of the primary harness is exhausted (the ' +
544
+ 'run\'s own same-agent account failover runs first). Unset sends no --fallback.',
545
+ validate: (v) => isAgentId(v) ? null : `menubar.menu.headlessFallbackAgent must be an agent id (e.g. codex, grok), got ${JSON.stringify(v)}.`,
546
+ },
547
+ {
548
+ name: 'menubar.menu.headlessPlacement',
549
+ yamlKey: 'menubarMenuHeadlessPlacement',
550
+ scope: 'user',
551
+ type: 'string',
552
+ defaultValue: 'auto',
553
+ description: 'AGI Menu: where a Headless run executes — `auto` (the CLI picks an eligible worker; `agents run ' +
554
+ '--device auto` never picks a personal or desktop box), `local` (this machine, no --device), ' +
555
+ '`interactive` (the box pinned as interactive.host), or a device name to pin one explicitly.',
556
+ validate: (v) => {
557
+ const value = v;
558
+ if (value === 'auto' || value === 'local' || value === 'interactive')
559
+ return null;
560
+ try {
561
+ assertValidDeviceName(value);
562
+ return null;
563
+ }
564
+ catch {
565
+ return 'menubar.menu.headlessPlacement must be auto | local | interactive | a device name.';
566
+ }
567
+ },
568
+ },
508
569
  ];
509
570
  /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
510
571
  /**
@@ -41,7 +41,7 @@ import chalk from 'chalk';
41
41
  import * as path from 'path';
42
42
  import { AGENTS, ALL_AGENT_IDS, supportsAccountInspection } from '../agents.js';
43
43
  import { blocksLocalScripts } from '../platform/winpath.js';
44
- import { loginHint } from '../signin-badge.js';
44
+ import { loginHint, loginSubcommand, SUBCOMMAND_LOGIN_AGENTS } from '../signin-badge.js';
45
45
  import { CONFIG_ENV_ISOLATED_AGENTS } from '../installations/shims.js';
46
46
  import { padToWidth, stringWidth } from '../text/width.js';
47
47
  import { windowsSshEnrollmentProblem } from './windows-ssh-enrollment.js';
@@ -78,11 +78,7 @@ const NO_PER_VERSION_LOGIN = new Set(ALL_AGENT_IDS.filter((a) => !ISOLATED_LOGIN
78
78
  * `subcommand` — `<cli> login` / `<cli> auth login`, runnable via `--`
79
79
  * `in-tui` — claude only: a `/login` slash command inside its own TUI
80
80
  * `on-launch` — the device/oauth flow starts when the agent launches */
81
- const LOGIN_SUBCOMMAND = {
82
- codex: 'login',
83
- grok: 'login',
84
- opencode: 'auth login',
85
- };
81
+ const LOGIN_SUBCOMMAND = Object.fromEntries(SUBCOMMAND_LOGIN_AGENTS.map((agent) => [agent, loginSubcommand(agent)]));
86
82
  /** Kinds whose fix is inherently PER VERSION, so a row collapsed across versions
87
83
  * could not carry a correct remediation. A login is the whole set: there is no
88
84
  * `@all` selector for it, and dropping the version falls back to the bare native