@phnx-labs/agents-cli 1.20.93 → 1.21.0

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 (112) hide show
  1. package/CHANGELOG.md +264 -0
  2. package/README.md +3 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/beta.js +3 -5
  5. package/dist/commands/doctor.js +23 -0
  6. package/dist/commands/exec.js +13 -0
  7. package/dist/commands/mailboxes.js +39 -1
  8. package/dist/commands/message.js +12 -1
  9. package/dist/commands/packages.js +14 -5
  10. package/dist/commands/projects.d.ts +28 -0
  11. package/dist/commands/projects.js +173 -47
  12. package/dist/commands/routines.js +27 -1
  13. package/dist/commands/rules.js +6 -0
  14. package/dist/commands/sessions.d.ts +17 -0
  15. package/dist/commands/sessions.js +16 -8
  16. package/dist/commands/setup-browser.js +5 -1
  17. package/dist/commands/setup-fleet.js +5 -0
  18. package/dist/commands/setup-preferences.d.ts +53 -0
  19. package/dist/commands/setup-preferences.js +142 -0
  20. package/dist/commands/setup.js +8 -9
  21. package/dist/commands/ssh.js +220 -10
  22. package/dist/commands/sync.d.ts +2 -2
  23. package/dist/commands/sync.js +4 -9
  24. package/dist/commands/watchdog.js +26 -3
  25. package/dist/index.js +22 -6
  26. package/dist/lib/activity.d.ts +7 -0
  27. package/dist/lib/activity.js +19 -2
  28. package/dist/lib/auto-dispatch.d.ts +6 -1
  29. package/dist/lib/auto-dispatch.js +7 -2
  30. package/dist/lib/beta.d.ts +1 -1
  31. package/dist/lib/beta.js +1 -1
  32. package/dist/lib/daemon.d.ts +13 -0
  33. package/dist/lib/daemon.js +80 -60
  34. package/dist/lib/device-config.d.ts +82 -0
  35. package/dist/lib/device-config.js +296 -0
  36. package/dist/lib/event-provenance.d.ts +19 -0
  37. package/dist/lib/event-provenance.js +48 -0
  38. package/dist/lib/events.d.ts +2 -2
  39. package/dist/lib/events.js +4 -53
  40. package/dist/lib/feed.d.ts +1 -1
  41. package/dist/lib/feed.js +2 -0
  42. package/dist/lib/git.d.ts +13 -2
  43. package/dist/lib/git.js +38 -6
  44. package/dist/lib/linear-project-counts.d.ts +72 -5
  45. package/dist/lib/linear-project-counts.js +90 -6
  46. package/dist/lib/linear-projects.d.ts +12 -0
  47. package/dist/lib/linear-projects.js +30 -0
  48. package/dist/lib/mailbox-gc.js +30 -7
  49. package/dist/lib/mailbox.d.ts +14 -1
  50. package/dist/lib/mailbox.js +35 -3
  51. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  52. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  53. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  54. package/dist/lib/menubar/install-menubar.js +35 -44
  55. package/dist/lib/migrate.js +1 -1
  56. package/dist/lib/models.js +21 -11
  57. package/dist/lib/overdue.d.ts +1 -1
  58. package/dist/lib/overdue.js +54 -19
  59. package/dist/lib/project-import.d.ts +96 -0
  60. package/dist/lib/project-import.js +171 -0
  61. package/dist/lib/remote-agents-json.d.ts +14 -1
  62. package/dist/lib/remote-agents-json.js +21 -3
  63. package/dist/lib/routines.d.ts +53 -0
  64. package/dist/lib/routines.js +105 -4
  65. package/dist/lib/rules/run-sync.d.ts +18 -0
  66. package/dist/lib/rules/run-sync.js +92 -0
  67. package/dist/lib/scheduler.js +12 -2
  68. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  69. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  70. package/dist/lib/secrets/index.d.ts +4 -5
  71. package/dist/lib/secrets/index.js +4 -5
  72. package/dist/lib/session/active.d.ts +16 -2
  73. package/dist/lib/session/active.js +8 -4
  74. package/dist/lib/session/bundle.js +5 -1
  75. package/dist/lib/session/remote-list.d.ts +1 -15
  76. package/dist/lib/session/remote-list.js +22 -111
  77. package/dist/lib/session/sync/config.d.ts +13 -5
  78. package/dist/lib/session/sync/config.js +21 -13
  79. package/dist/lib/startup/command-registry.d.ts +0 -2
  80. package/dist/lib/startup/command-registry.js +1 -5
  81. package/dist/lib/state.d.ts +7 -2
  82. package/dist/lib/state.js +85 -10
  83. package/dist/lib/sync-umbrella.d.ts +11 -23
  84. package/dist/lib/sync-umbrella.js +14 -45
  85. package/dist/lib/teams/agents.d.ts +35 -3
  86. package/dist/lib/teams/agents.js +52 -8
  87. package/dist/lib/teams/scheduler.d.ts +29 -2
  88. package/dist/lib/teams/scheduler.js +60 -16
  89. package/dist/lib/types.d.ts +17 -1
  90. package/package.json +1 -2
  91. package/dist/commands/drive.d.ts +0 -10
  92. package/dist/commands/drive.js +0 -183
  93. package/dist/commands/hq.d.ts +0 -2
  94. package/dist/commands/hq.js +0 -58
  95. package/dist/commands/sessions-sync.d.ts +0 -17
  96. package/dist/commands/sessions-sync.js +0 -135
  97. package/dist/commands/sync-provision.d.ts +0 -23
  98. package/dist/commands/sync-provision.js +0 -107
  99. package/dist/lib/drive-sync.d.ts +0 -45
  100. package/dist/lib/drive-sync.js +0 -238
  101. package/dist/lib/hq/floor.d.ts +0 -87
  102. package/dist/lib/hq/floor.js +0 -243
  103. package/dist/lib/session/sync/crdt.d.ts +0 -44
  104. package/dist/lib/session/sync/crdt.js +0 -119
  105. package/dist/lib/session/sync/manifest.d.ts +0 -62
  106. package/dist/lib/session/sync/manifest.js +0 -100
  107. package/dist/lib/session/sync/provision.d.ts +0 -49
  108. package/dist/lib/session/sync/provision.js +0 -91
  109. package/dist/lib/session/sync/r2.d.ts +0 -32
  110. package/dist/lib/session/sync/r2.js +0 -121
  111. package/dist/lib/session/sync/sync.d.ts +0 -106
  112. package/dist/lib/session/sync/sync.js +0 -374
@@ -10,12 +10,14 @@
10
10
  */
11
11
  import chalk from 'chalk';
12
12
  import * as fs from 'fs';
13
+ import * as path from 'path';
13
14
  import { execFileSync, spawnSync } from 'child_process';
14
15
  import { betaEnableHint, isBetaEnabled } from '../lib/beta.js';
15
16
  import { setHelpSections } from '../lib/help.js';
16
17
  import { getMainRepoRoot } from '../lib/git.js';
17
18
  import { parseOwnerRepoFromRemote } from '../lib/registry.js';
18
- import { toHomeRelative } from '../lib/project-root.js';
19
+ import { truncate } from '../lib/format.js';
20
+ import { expandLocalHome, getProjectRoot, toHomeRelative } from '../lib/project-root.js';
19
21
  import { machineId } from '../lib/machine-id.js';
20
22
  import { getActiveSessions } from '../lib/session/active.js';
21
23
  import { gatherRemoteActive } from '../lib/session/remote-active.js';
@@ -26,6 +28,7 @@ import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, pro
26
28
  import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
27
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
28
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
+ import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
29
32
  /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
30
33
  export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
31
34
  /** Max peers named in the skipped note before the rest collapse to `+N`. */
@@ -63,6 +66,135 @@ function originSlug(cwd) {
63
66
  return undefined;
64
67
  }
65
68
  }
69
+ /**
70
+ * Read the Factory registry off disk and plan its import. Every read failure is
71
+ * fatal and named — an unreadable or malformed registry must not read as "0
72
+ * projects to import".
73
+ */
74
+ function runFactoryImport(existing, opts) {
75
+ const src = factoryProjectsPath();
76
+ let rawText;
77
+ try {
78
+ rawText = fs.readFileSync(src, 'utf8');
79
+ }
80
+ catch {
81
+ console.error(chalk.red(`No Factory registry at ${src}`));
82
+ process.exit(1);
83
+ }
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(rawText);
87
+ }
88
+ catch {
89
+ console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
90
+ process.exit(1);
91
+ }
92
+ const rows = Array.isArray(parsed)
93
+ ? parsed
94
+ : Array.isArray(parsed?.projects)
95
+ ? parsed.projects
96
+ : [];
97
+ return buildFactoryImportCandidates(rows, existing, opts);
98
+ }
99
+ /**
100
+ * List the workspace's Linear projects and plan their import, binding each to a
101
+ * local checkout under the configured projects root when the names match
102
+ * exactly. A missing / logged-out `linear` CLI is loud (this is an explicit user
103
+ * command), and nothing is written before the whole plan is built.
104
+ */
105
+ function runLinearImport(existing, opts) {
106
+ let list;
107
+ try {
108
+ list = listLinearProjects();
109
+ }
110
+ catch (e) {
111
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
112
+ process.exit(1);
113
+ }
114
+ // No projects root configured (or unreadable) → no local matching; every def
115
+ // still imports, carrying its Linear link and nothing it can't prove.
116
+ const rootAbs = getProjectRoot() ? expandLocalHome(getProjectRoot()) : undefined;
117
+ let localDirs = [];
118
+ if (rootAbs) {
119
+ try {
120
+ localDirs = fs
121
+ .readdirSync(rootAbs, { withFileTypes: true })
122
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
123
+ .map((d) => d.name);
124
+ }
125
+ catch {
126
+ localDirs = [];
127
+ }
128
+ }
129
+ return buildLinearImportCandidates(list, existing, {
130
+ localDirs,
131
+ resolveRoot: (dir) => (rootAbs ? toHomeRelative(path.join(rootAbs, dir)) : undefined),
132
+ resolveOrigin: (dir) => (rootAbs ? originSlug(path.join(rootAbs, dir)) : undefined),
133
+ }, opts);
134
+ }
135
+ /** Longest path a `list` row shows before it truncates. */
136
+ const LIST_PATH_MAX = 48;
137
+ /**
138
+ * Column widths for `projects list`, sized to the rows actually being printed.
139
+ * Fixed padding was the bug: home-relative roots run past 50 characters, so a
140
+ * hardcoded 32 pushed the repo column off its gridline on every long path.
141
+ * Paths longer than {@link LIST_PATH_MAX} truncate rather than widen the table.
142
+ */
143
+ export function computeProjectListWidths(rows) {
144
+ const widest = (pick, cap) => Math.min(cap, rows.reduce((w, r) => Math.max(w, pick(r).length), 0));
145
+ return {
146
+ name: widest((r) => r.name, 64),
147
+ path: widest((r) => r.path, LIST_PATH_MAX),
148
+ repo: widest((r) => r.repo, 64),
149
+ };
150
+ }
151
+ /**
152
+ * A milestone's target date as a person would say it — "due tomorrow",
153
+ * "overdue by 3 days", "due Aug 21" — never a raw `2026-08-21` or a duration in
154
+ * hours. Linear stores a calendar date with no timezone, so both sides are
155
+ * compared at LOCAL midnight; parsing `YYYY-MM-DD` with `new Date(str)` would
156
+ * read it as UTC and shift the answer by a day for anyone west of Greenwich.
157
+ */
158
+ export function formatMilestoneDue(targetDate, nowMs) {
159
+ const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(targetDate.trim());
160
+ if (!m)
161
+ return undefined;
162
+ const due = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
163
+ if (Number.isNaN(due.getTime()))
164
+ return undefined;
165
+ const now = new Date(nowMs);
166
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
167
+ const days = Math.round((due.getTime() - today.getTime()) / 86_400_000);
168
+ if (days === 0)
169
+ return 'due today';
170
+ if (days === 1)
171
+ return 'due tomorrow';
172
+ if (days === -1)
173
+ return 'overdue by a day';
174
+ if (days < 0)
175
+ return `overdue by ${-days} days`;
176
+ if (days <= 14)
177
+ return `due in ${days} days`;
178
+ const sameYear = due.getFullYear() === today.getFullYear();
179
+ const label = due.toLocaleDateString('en-US', {
180
+ month: 'short',
181
+ day: 'numeric',
182
+ ...(sameYear ? {} : { year: 'numeric' }),
183
+ });
184
+ return `due ${label}`;
185
+ }
186
+ /** The `next` card line: what this project is due to hit, and how far along it is. */
187
+ export function formatNextMilestone(ms, nowMs) {
188
+ const parts = [chalk.bold(ms.name)];
189
+ // A milestone with nothing filed under it yet has no progress to report —
190
+ // `0/0` is noise, not information.
191
+ if (ms.total > 0)
192
+ parts.push(`${ms.done}/${ms.total}`);
193
+ const due = ms.targetDate ? formatMilestoneDue(ms.targetDate, nowMs) : undefined;
194
+ if (due)
195
+ parts.push(due.startsWith('overdue') ? chalk.yellow(due) : chalk.dim(due));
196
+ return parts.join(chalk.dim(' · '));
197
+ }
66
198
  function statusBar(r) {
67
199
  const parts = [];
68
200
  const push = (n, label, color) => {
@@ -80,7 +212,7 @@ function statusBar(r) {
80
212
  parts.push(chalk.gray(`+${r.agents - shown} other`));
81
213
  return parts.join(' · ') || chalk.gray('no live agents');
82
214
  }
83
- function renderCard(def, r, remote, fleet, linear) {
215
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
84
216
  const agents = r?.agents ?? 0;
85
217
  const pct = r ? planPct(r.plan) : undefined;
86
218
  const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
@@ -104,6 +236,9 @@ function renderCard(def, r, remote, fleet, linear) {
104
236
  if (linear) {
105
237
  console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
106
238
  }
239
+ if (linear?.nextMilestone) {
240
+ console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
241
+ }
107
242
  if (r && r.tickets.length) {
108
243
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
109
244
  }
@@ -138,6 +273,7 @@ export function registerProjectsCommands(program) {
138
273
  .description('Named multi-repo projects with a progress rollup.');
139
274
  setHelpSections(projects, {
140
275
  examples: `
276
+ agents projects import --from-linear # the projects you actually track
141
277
  agents projects add rush --repo phnx-labs/rush --path apps/web
142
278
  agents projects list
143
279
  agents projects status # progress card for every project
@@ -149,6 +285,11 @@ export function registerProjectsCommands(program) {
149
285
  notes: `
150
286
  Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
151
287
  machines with 'agents push/pull'. Enable with: agents beta enable projects.
288
+
289
+ 'import --from-factory' reads Factory's auto-detected registry, which
290
+ guesses from checkouts on disk — it imports only 'high' confidence rows by
291
+ default. Widen with --min-confidence medium or --all, and drop a bad guess
292
+ with 'agents projects rm <name>'.
152
293
  `,
153
294
  });
154
295
  projects.hook('preAction', (_thisCommand, actionCommand) => {
@@ -180,10 +321,16 @@ export function registerProjectsCommands(program) {
180
321
  return;
181
322
  }
182
323
  const roll = rollupSessionsByProject(defs, await getActiveSessions());
183
- for (const d of defs) {
324
+ const rows = defs.map((d) => ({
325
+ name: d.name,
326
+ path: truncate(d.root ?? d.defaultPath ?? '', LIST_PATH_MAX),
327
+ repo: d.repo ?? d.repos?.[0]?.slug ?? '',
328
+ }));
329
+ const w = computeProjectListWidths(rows);
330
+ for (const [i, d] of defs.entries()) {
184
331
  const agents = roll.get(d.name)?.agents ?? 0;
185
- const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
186
- console.log(` ${chalk.bold(d.name.padEnd(16))} ${chalk.dim((d.root ?? d.defaultPath ?? '').padEnd(32))} ${chalk.cyan(repo.padEnd(24))} ${agents} agents`);
332
+ const row = rows[i];
333
+ console.log(` ${chalk.bold(row.name.padEnd(w.name))} ${chalk.dim(row.path.padEnd(w.path))} ${chalk.cyan(row.repo.padEnd(w.repo))} ${agents} agents`);
187
334
  }
188
335
  });
189
336
  // ---- add ----
@@ -384,7 +531,7 @@ export function registerProjectsCommands(program) {
384
531
  return;
385
532
  }
386
533
  for (const d of defs) {
387
- renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name));
534
+ renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs);
388
535
  }
389
536
  if (fleetSkipped.length > 0)
390
537
  process.stdout.write(formatFleetSkippedNote(fleetSkipped));
@@ -402,54 +549,33 @@ export function registerProjectsCommands(program) {
402
549
  // ---- import ----
403
550
  projects
404
551
  .command('import')
405
- .description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
406
- .requiredOption('--from-factory', 'Import from the Factory projects.json registry')
552
+ .description('Import project definitions from Linear (preferred) or the Factory auto-detection registry.')
553
+ .option('--from-linear', 'Import the workspace\'s Linear projects (via the `linear` CLI)')
554
+ .option('--from-factory', 'Import the Factory registry (~/.agents/factory/projects.json)')
555
+ .option('--min-confidence <level>', '--from-factory only: lowest detection confidence to import — low|medium|high (default: high)')
556
+ .option('--all', '--from-factory only: import every row regardless of confidence')
407
557
  .option('--force', 'Overwrite existing definitions')
408
- .action((opts) => {
409
- const src = factoryProjectsPath();
410
- let rawText;
411
- try {
412
- rawText = fs.readFileSync(src, 'utf8');
413
- }
414
- catch {
415
- console.error(chalk.red(`No Factory registry at ${src}`));
416
- process.exit(1);
417
- }
418
- let rows;
558
+ .action((raw) => {
559
+ let opts;
419
560
  try {
420
- rows = JSON.parse(rawText);
561
+ opts = validateImportOpts(raw);
421
562
  }
422
- catch {
423
- console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
563
+ catch (e) {
564
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
424
565
  process.exit(1);
425
566
  }
426
- const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
427
- let created = 0;
428
- let skipped = 0;
429
- for (const raw of list) {
430
- if (!raw || typeof raw !== 'object')
431
- continue;
432
- const o = raw;
433
- const name = typeof o.name === 'string' ? o.name : undefined;
434
- if (!name || !isSafeProjectName(name)) {
435
- skipped++;
436
- continue;
437
- }
438
- if (loadProjectDef(name) && !opts.force) {
439
- skipped++;
440
- continue;
441
- }
442
- const def = { name };
443
- if (typeof o.path === 'string')
444
- def.root = toHomeRelative(o.path);
445
- if (typeof o.repoSlug === 'string')
446
- def.repo = o.repoSlug;
447
- if (typeof o.linearProjectId === 'string')
448
- def.linear = { projectId: o.linearProjectId };
567
+ const existing = new Map(listProjectDefs().map((d) => [d.name, d]));
568
+ const result = opts.source === 'linear' ? runLinearImport(existing, opts) : runFactoryImport(existing, opts);
569
+ for (const def of result.defs)
449
570
  writeProjectDef(def);
450
- created++;
571
+ const n = result.defs.length;
572
+ const s = result.skipped.length;
573
+ console.log(chalk.green(`Imported ${n} project${n === 1 ? '' : 's'}${s ? chalk.gray(` (${s} skipped)`) : ''}`));
574
+ for (const skip of result.skipped)
575
+ console.log(chalk.gray(` skip ${skip.name}: ${skip.reason}`));
576
+ if (opts.source === 'factory' && s > 0 && opts.minConfidence === 'high') {
577
+ console.log(chalk.gray(' (widen with --min-confidence medium or --all)'));
451
578
  }
452
- console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
453
579
  });
454
580
  // ---- link ----
455
581
  projects
@@ -11,6 +11,7 @@ import * as fs from 'fs';
11
11
  import * as path from 'path';
12
12
  import * as yaml from 'yaml';
13
13
  import { isDaemonRunning, signalDaemonReload, startDaemon, stopDaemon, readDaemonLog, getDaemonStatus, } from '../lib/daemon.js';
14
+ import { assertSchedulerEnabled } from '../lib/device-config.js';
14
15
  import { resolveAgentName, isAgentHardDeprecated, hardDeprecationError } from '../lib/agents.js';
15
16
  import { humanizeCron, humanizeNextRun, formatRepoLink, REPO_DISPLAY_MAX } from '../lib/routines-format.js';
16
17
  import { listJobs as listAllJobs, deleteJob, readJob, validateJob, writeJob, setJobEnabled, listRuns, getLatestRun, getRunDir, getJobPath, parseAtTime, hasCompletedOneShotRun, isOneShotLikeSchedule, isOneShotRoutine, isPastOneShotRoutine, jobRunsOnThisDevice, checkJobDeviceEligibility, normalizeTriggerEvent, parseHostStrategy, resolveHostStrategy, placementRequiresFiringPin, HOST_STRATEGIES, } from '../lib/routines.js';
@@ -295,9 +296,21 @@ function parseRoutineTrigger(options) {
295
296
  /**
296
297
  * Start or reload the background scheduler so newly-added jobs fire on time.
297
298
  * `quiet` suppresses human status lines for JSON callers.
299
+ *
300
+ * When this device has `scheduler.enabled=false` the auto-start is skipped with
301
+ * the stated reason (the add itself already succeeded — the job is config and
302
+ * stays valid fleet-wide); the refusal message names the setting and the fix.
298
303
  */
299
304
  function ensureSchedulerRunning(opts = {}) {
300
305
  const log = opts.stderr ? console.error : console.log;
306
+ try {
307
+ assertSchedulerEnabled();
308
+ }
309
+ catch (err) {
310
+ // Loud stated skip, on stderr so --json stdout stays clean.
311
+ console.error(chalk.yellow(err.message));
312
+ return;
313
+ }
301
314
  if (isDaemonRunning()) {
302
315
  signalDaemonReload();
303
316
  if (!opts.quiet)
@@ -1480,9 +1493,22 @@ export function registerRoutinesCommands(program) {
1480
1493
  .command('start')
1481
1494
  .description('Start the background scheduler. Usually unnecessary — it auto-starts when you add your first routine.')
1482
1495
  .action(() => {
1496
+ try {
1497
+ // A manual start on a scheduler-disabled device refuses with the same
1498
+ // message the auto-start surfaces give.
1499
+ assertSchedulerEnabled();
1500
+ }
1501
+ catch (err) {
1502
+ console.error(chalk.red(err.message));
1503
+ process.exit(1);
1504
+ }
1483
1505
  const result = startDaemon();
1484
1506
  if (result.method === 'already-running') {
1485
- console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid})`));
1507
+ // Signal a reload even here: if the daemon booted while this device had
1508
+ // scheduler.enabled=false, the reload re-evaluates the gate and boots
1509
+ // the scheduler — a manual start heals a scheduler-less daemon.
1510
+ signalDaemonReload();
1511
+ console.log(chalk.yellow(`Scheduler already running (PID: ${result.pid}) — reloaded`));
1486
1512
  }
1487
1513
  else if (result.pid) {
1488
1514
  console.log(chalk.green(`Scheduler started (PID: ${result.pid})`));
@@ -512,6 +512,12 @@ Examples:
512
512
  .addHelpText('after', `
513
513
  Targets are <agent>@<version>. Use 'default' for the alias of the global default version.
514
514
 
515
+ This re-syncs the version home immediately, AND the choice sticks: every later
516
+ 'agents run' for this agent+version re-applies the active preset before launch
517
+ (skip-fast when nothing changed), so a preset set here never goes stale even if
518
+ something changes it again without running 'switch' — you don't need to re-run
519
+ 'switch' or 'sync' by hand before the next launch.
520
+
515
521
  Examples:
516
522
  # Persist the cautious preset for claude 2.1.111
517
523
  agents rules switch claude@2.1.111 --preset cautious
@@ -315,6 +315,13 @@ export declare function isBareBrowserListing(options: SessionsOptions, query: st
315
315
  * `runSessionBrowser` picker cannot represent.
316
316
  */
317
317
  export declare function hasNoBrowserDisqualifyingFlags(options: SessionsOptions, query: string | undefined): boolean;
318
+ /** Resolve a session by id/query globally and print its compact preview (no pager).
319
+ * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
320
+ export declare function renderSessionPreview(query: string, scope: {
321
+ agent?: string;
322
+ project?: string;
323
+ local?: boolean;
324
+ }): Promise<void>;
318
325
  /**
319
326
  * The one-line live status banner shown above a session preview: the glyph, the
320
327
  * status word, and — when the session needs a human or has LOST one — a plain
@@ -351,6 +358,16 @@ export declare function teamBadge(session: SessionMeta): {
351
358
  * when `showTicket` — otherwise a listing with no refs would waste a column of
352
359
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
353
360
  export declare function flatSessionRow(session: SessionMeta, live?: ActiveSession, showTicket?: boolean, cols?: PickerColumns, favorite?: boolean): string;
361
+ /**
362
+ * Live-session index for enriching the default listing, or undefined when
363
+ * enrichment is off (`--no-live`) or irrelevant (`--json`, which serializes
364
+ * SessionMeta). Full detection (incl. the headless `ps` scan) is deliberate:
365
+ * bare-CLI and tmux agents are the common case here, and skipping them would
366
+ * leave the glyph almost never showing. The listing is a one-shot user action,
367
+ * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
368
+ * escape hatch. Never throws — a probe failure just yields a plain listing.
369
+ */
370
+ export declare function maybeLiveIndex(options: SessionsOptions): Promise<Map<string, ActiveSession> | undefined>;
354
371
  /**
355
372
  * Group key for the overview: resolve the cwd through the same canonical
356
373
  * resolver the `agents activity` timeline groups by — a defined project's name
@@ -46,7 +46,6 @@ import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
46
46
  import { sessionPicker, buildPreview, formatTodoCompact, githubRepoUrlFromCwd, } from './sessions-picker.js';
47
47
  import { setHelpSections } from '../lib/help.js';
48
48
  import { registerSessionsTailCommand } from './sessions-tail.js';
49
- import { registerSessionsSyncCommand } from './sessions-sync.js';
50
49
  import { registerSessionsResumeCommand } from './sessions-resume.js';
51
50
  import { registerSessionsFavoriteCommand } from './sessions-favorite.js';
52
51
  import { isFavorite, listFavorites } from '../lib/session/favorites.js';
@@ -1044,7 +1043,11 @@ export async function gatherActiveSessions(opts = {}) {
1044
1043
  const self = machineId();
1045
1044
  // An explicit --host/--device list scopes the view: seed local sessions only
1046
1045
  // when no hosts are named, or when this machine is one of the named targets.
1047
- const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions() : [];
1046
+ // `localOnly: opts.local` (RUSH-2118) keeps a `--local` query from dialing a
1047
+ // remote-host teammate over ssh even for this machine's OWN local gather —
1048
+ // "this machine only" must mean zero ssh, not just "skip the cross-machine
1049
+ // device fan-out below".
1050
+ const local = shouldIncludeLocal(opts.hosts, self) ? await getActiveSessions({ localOnly: opts.local }) : [];
1048
1051
  for (const s of local)
1049
1052
  if (!s.machine)
1050
1053
  s.machine = self;
@@ -1218,7 +1221,7 @@ function canonicalSessionsCommand(query, options) {
1218
1221
  }
1219
1222
  /** Resolve a session by id/query globally and print its compact preview (no pager).
1220
1223
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
1221
- async function renderSessionPreview(query, scope) {
1224
+ export async function renderSessionPreview(query, scope) {
1222
1225
  const discovered = await discoverSessions({ all: true, cwd: process.cwd(), limit: 5000 });
1223
1226
  const pool = applyScopeFilters(discovered, scope);
1224
1227
  const { matches, completeId } = resolveSessionQuery(pool, query);
@@ -1234,9 +1237,11 @@ async function renderSessionPreview(query, scope) {
1234
1237
  }
1235
1238
  // Lead with the live status when the session is still running, so the preview
1236
1239
  // says working / waiting / idle up front — not just the historical transcript.
1240
+ // `--local --preview` is freely combinable with `--local` (RUSH-2118): thread
1241
+ // it through so this probe never dials a remote-host teammate either.
1237
1242
  let live;
1238
1243
  try {
1239
- live = indexActiveBySessionId(await getActiveSessions()).get(session.id);
1244
+ live = indexActiveBySessionId(await getActiveSessions({ localOnly: scope.local === true })).get(session.id);
1240
1245
  }
1241
1246
  catch { /* plain preview on any probe failure */ }
1242
1247
  const headline = formatLiveStatusHeadline(live, isFavorite(session.id));
@@ -1390,7 +1395,7 @@ limitSource) {
1390
1395
  console.error(chalk.red('--preview requires a session id or query.'));
1391
1396
  process.exit(1);
1392
1397
  }
1393
- await renderSessionPreview(query, { agent: options.agent, project: options.project });
1398
+ await renderSessionPreview(query, { agent: options.agent, project: options.project, local: options.local });
1394
1399
  return;
1395
1400
  }
1396
1401
  if (options.active) {
@@ -1907,11 +1912,15 @@ function treeSessionRow(session, live) {
1907
1912
  * not a hot loop, so the `ps`/`lsof` cost is acceptable; `--no-live` is the
1908
1913
  * escape hatch. Never throws — a probe failure just yields a plain listing.
1909
1914
  */
1910
- async function maybeLiveIndex(options) {
1915
+ export async function maybeLiveIndex(options) {
1911
1916
  if (options.live === false || options.json)
1912
1917
  return undefined;
1913
1918
  try {
1914
- return indexActiveBySessionId(await getActiveSessions());
1919
+ // `--local` promises "this machine only" for the default listing too (see
1920
+ // the `--local` help text), so it must gate the live-enrichment probe the
1921
+ // same way `--active --local` does — never dial a remote-host teammate
1922
+ // over ssh here either (RUSH-2118).
1923
+ return indexActiveBySessionId(await getActiveSessions({ localOnly: options.local === true }));
1915
1924
  }
1916
1925
  catch {
1917
1926
  return undefined;
@@ -3387,7 +3396,6 @@ export function registerSessionsCommands(program) {
3387
3396
  await sessionsAction(query, options, command.getOptionValueSource('limit'));
3388
3397
  });
3389
3398
  registerSessionsTailCommand(sessionsCmd);
3390
- registerSessionsSyncCommand(sessionsCmd);
3391
3399
  registerSessionsResumeCommand(sessionsCmd);
3392
3400
  registerSessionsFavoriteCommand(sessionsCmd);
3393
3401
  registerGoCommand(sessionsCmd);
@@ -13,6 +13,7 @@ import { findFirstInstalledBrowser, listInstalledBrowsers } from '../lib/browser
13
13
  import { DEFAULT_BROWSER_PROFILE_NAME, createProfile, findFreeProfilePort, getConfiguredDefaultProfileName, getProfile, } from '../lib/browser/profiles.js';
14
14
  import { DEFAULT_VIEWPORT } from '../lib/browser/devices.js';
15
15
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
16
+ import { defaultBrowserChoice } from './setup-preferences.js';
16
17
  const INSTALL_HINT = 'Install one of: Google Chrome, Brave, Microsoft Edge, Chromium, or Comet, then re-run `agents setup browser`.\n' +
17
18
  '(Safari and Firefox are not supported — agents browser drives over the Chrome DevTools Protocol.)';
18
19
  /**
@@ -58,11 +59,14 @@ export async function runBrowserWizard() {
58
59
  const { deleteProfile } = await import('../lib/browser/profiles.js');
59
60
  await deleteProfile(existing.name);
60
61
  }
61
- // Pick which installed browser to pin (auto-select if only one).
62
+ // Pick which installed browser to pin (auto-select if only one). The picker
63
+ // highlights the same browser auto-detect would win — choosing is an
64
+ // override of the default, never a guess.
62
65
  let chosen = installed[0];
63
66
  if (installed.length > 1) {
64
67
  const value = await select({
65
68
  message: 'Which browser should the default profile use?',
69
+ default: defaultBrowserChoice(installed) ?? undefined,
66
70
  choices: installed.map((b) => ({ name: `${b.browserType} ${chalk.dim(b.binary)}`, value: b.browserType })),
67
71
  });
68
72
  chosen = installed.find((b) => b.browserType === value) ?? installed[0];
@@ -12,6 +12,7 @@ import { loadDevices } from '../lib/devices/registry.js';
12
12
  import { runDeviceSync } from '../lib/devices/sync.js';
13
13
  import { tailscaleStatusJson } from '../lib/devices/tailscale.js';
14
14
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
15
+ import { maybePickInteractiveHost } from './setup-preferences.js';
15
16
  function parseAuth(raw) {
16
17
  const value = (raw ?? 'key').toLowerCase();
17
18
  if (value === 'key' || value === 'password')
@@ -171,6 +172,10 @@ export async function runFleetSetupWizard(opts = {}) {
171
172
  if (choices.fleetUpdate)
172
173
  runAgentsSubcommand(['fleet', 'update']);
173
174
  printOnboardingSummary(names, choices.auth);
175
+ // With a fleet registered, the next wrong-machine trap is artifacts opening on
176
+ // some other box — offer to pin the interactive host (TTY-only, skippable,
177
+ // silent non-TTY and when already set).
178
+ await maybePickInteractiveHost();
174
179
  return true;
175
180
  }
176
181
  /** Register `agents setup fleet` under the parent `setup` command. */
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Preferences onboarding — the guided path over the same device-config keys the
3
+ * `agents devices …` commands write (lib/device-config.ts). Two questions, each
4
+ * TTY-only, skippable, and absent non-interactively:
5
+ *
6
+ * 1. "Which machine do you sit at?" → `interactive.host` (central config:)
7
+ * 2. "Which browser should agents drive on THIS machine?"
8
+ * → `browser.profile` (device-local default)
9
+ *
10
+ * Shared by the bare `agents setup` flow (a short step after the capability
11
+ * hub) and by `agents setup fleet` (interactive host after a successful sync).
12
+ * Unset always keeps today's behavior — skipping is a real choice, not a
13
+ * partial state.
14
+ */
15
+ import { type DeviceRegistry } from '../lib/devices/registry.js';
16
+ /** Registered macOS device names, sorted — the interactive-host candidates. */
17
+ export declare function macDeviceNames(reg: DeviceRegistry): string[];
18
+ /**
19
+ * The interactive-host picker's highlighted default: this machine when it is a
20
+ * candidate (the common case — you run setup on the box you sit at), else the
21
+ * first candidate. null when there are no candidates.
22
+ */
23
+ export declare function defaultInteractiveHostChoice(candidates: string[], self?: string): string | null;
24
+ /**
25
+ * The browser picker's highlighted default — the same browser auto-detect
26
+ * would win, since `listInstalledBrowsers` returns platform priority order
27
+ * (macOS: chrome first). null when nothing is installed.
28
+ */
29
+ export declare function defaultBrowserChoice<T extends {
30
+ browserType: string;
31
+ }>(installed: T[]): T['browserType'] | null;
32
+ /**
33
+ * Offer to set the interactive host when none is configured and the registry
34
+ * has more than one macOS device. Returns true when a host was set. Silent
35
+ * no-op non-TTY, when already set, or with fewer than two candidates (the
36
+ * answer is obvious or absent).
37
+ */
38
+ export declare function maybePickInteractiveHost(): Promise<boolean>;
39
+ /**
40
+ * Offer to pin this machine's default browser profile to a chosen browser.
41
+ * Only asked when there is nothing to preserve: no configured device default
42
+ * AND no existing `default` profile (an existing profile is the user's earlier
43
+ * choice — never re-pinned behind their back). Returns true when a profile was
44
+ * created and set as the device default. Silent no-op non-TTY.
45
+ */
46
+ export declare function maybePickBrowserProfile(): Promise<boolean>;
47
+ /**
48
+ * The bare-`agents setup` preferences step: interactive host, then browser.
49
+ * Runs AFTER the capability hub so it never delays the bootstrap. Never
50
+ * throws — a prompt cancel or a failed pick ends the step quietly and lets
51
+ * setup complete (the same semantics as the capability hub).
52
+ */
53
+ export declare function runPreferencesStep(): Promise<void>;