@phnx-labs/agents-cli 1.20.92 → 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 (146) hide show
  1. package/CHANGELOG.md +385 -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/events.js +91 -1
  7. package/dist/commands/exec.js +13 -0
  8. package/dist/commands/mailboxes.js +39 -1
  9. package/dist/commands/message.js +12 -1
  10. package/dist/commands/packages.js +14 -5
  11. package/dist/commands/projects.d.ts +38 -0
  12. package/dist/commands/projects.js +355 -48
  13. package/dist/commands/routines.js +27 -1
  14. package/dist/commands/rules.js +6 -0
  15. package/dist/commands/secrets.d.ts +17 -0
  16. package/dist/commands/secrets.js +198 -7
  17. package/dist/commands/send.d.ts +14 -12
  18. package/dist/commands/send.js +105 -35
  19. package/dist/commands/sessions.d.ts +17 -0
  20. package/dist/commands/sessions.js +16 -8
  21. package/dist/commands/setup-browser.js +5 -1
  22. package/dist/commands/setup-fleet.js +5 -0
  23. package/dist/commands/setup-preferences.d.ts +53 -0
  24. package/dist/commands/setup-preferences.js +142 -0
  25. package/dist/commands/setup.js +8 -9
  26. package/dist/commands/ssh.js +220 -10
  27. package/dist/commands/sync.d.ts +2 -2
  28. package/dist/commands/sync.js +13 -12
  29. package/dist/commands/view.js +4 -0
  30. package/dist/commands/watchdog.js +26 -3
  31. package/dist/index.js +38 -6
  32. package/dist/lib/activity.d.ts +15 -0
  33. package/dist/lib/activity.js +26 -2
  34. package/dist/lib/auto-dispatch.d.ts +6 -1
  35. package/dist/lib/auto-dispatch.js +7 -2
  36. package/dist/lib/beta.d.ts +1 -1
  37. package/dist/lib/beta.js +1 -1
  38. package/dist/lib/channels/send.d.ts +83 -0
  39. package/dist/lib/channels/send.js +112 -0
  40. package/dist/lib/daemon.d.ts +13 -0
  41. package/dist/lib/daemon.js +80 -60
  42. package/dist/lib/device-config.d.ts +82 -0
  43. package/dist/lib/device-config.js +296 -0
  44. package/dist/lib/event-provenance.d.ts +19 -0
  45. package/dist/lib/event-provenance.js +48 -0
  46. package/dist/lib/events-ingest.d.ts +46 -0
  47. package/dist/lib/events-ingest.js +182 -0
  48. package/dist/lib/events.d.ts +17 -5
  49. package/dist/lib/events.js +59 -56
  50. package/dist/lib/feed.d.ts +1 -1
  51. package/dist/lib/feed.js +2 -0
  52. package/dist/lib/git.d.ts +13 -2
  53. package/dist/lib/git.js +38 -6
  54. package/dist/lib/linear-project-counts.d.ts +129 -0
  55. package/dist/lib/linear-project-counts.js +206 -0
  56. package/dist/lib/linear-projects.d.ts +62 -0
  57. package/dist/lib/linear-projects.js +144 -0
  58. package/dist/lib/mailbox-gc.js +30 -7
  59. package/dist/lib/mailbox.d.ts +14 -1
  60. package/dist/lib/mailbox.js +35 -3
  61. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  62. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  63. package/dist/lib/menubar/install-menubar.d.ts +5 -21
  64. package/dist/lib/menubar/install-menubar.js +35 -44
  65. package/dist/lib/menubar/notify-desktop.d.ts +17 -2
  66. package/dist/lib/menubar/notify-desktop.js +8 -2
  67. package/dist/lib/migrate.js +1 -1
  68. package/dist/lib/models.js +21 -11
  69. package/dist/lib/overdue.d.ts +1 -1
  70. package/dist/lib/overdue.js +54 -19
  71. package/dist/lib/project-import.d.ts +96 -0
  72. package/dist/lib/project-import.js +171 -0
  73. package/dist/lib/project-probe.d.ts +75 -0
  74. package/dist/lib/project-probe.js +160 -0
  75. package/dist/lib/project-resources.d.ts +8 -0
  76. package/dist/lib/project-resources.js +31 -3
  77. package/dist/lib/project-status.d.ts +32 -1
  78. package/dist/lib/project-status.js +82 -1
  79. package/dist/lib/projects.d.ts +6 -0
  80. package/dist/lib/projects.js +12 -0
  81. package/dist/lib/remote-agents-json.d.ts +14 -1
  82. package/dist/lib/remote-agents-json.js +21 -3
  83. package/dist/lib/routine-notify.d.ts +11 -0
  84. package/dist/lib/routine-notify.js +22 -0
  85. package/dist/lib/routines.d.ts +53 -0
  86. package/dist/lib/routines.js +105 -4
  87. package/dist/lib/rules/run-sync.d.ts +18 -0
  88. package/dist/lib/rules/run-sync.js +92 -0
  89. package/dist/lib/run-notify.js +3 -0
  90. package/dist/lib/scheduler.js +12 -2
  91. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/audit.d.ts +1 -1
  94. package/dist/lib/secrets/audit.js +53 -10
  95. package/dist/lib/secrets/index.d.ts +4 -5
  96. package/dist/lib/secrets/index.js +4 -5
  97. package/dist/lib/secrets/list-filter.d.ts +20 -5
  98. package/dist/lib/secrets/list-filter.js +22 -6
  99. package/dist/lib/secrets/usage-db.d.ts +106 -0
  100. package/dist/lib/secrets/usage-db.js +236 -0
  101. package/dist/lib/session/active.d.ts +16 -2
  102. package/dist/lib/session/active.js +8 -4
  103. package/dist/lib/session/bundle.js +5 -1
  104. package/dist/lib/session/remote-active.d.ts +5 -1
  105. package/dist/lib/session/remote-active.js +4 -1
  106. package/dist/lib/session/remote-list.d.ts +1 -15
  107. package/dist/lib/session/remote-list.js +22 -111
  108. package/dist/lib/session/sync/config.d.ts +13 -5
  109. package/dist/lib/session/sync/config.js +21 -13
  110. package/dist/lib/sqlite.js +28 -1
  111. package/dist/lib/startup/command-registry.d.ts +0 -2
  112. package/dist/lib/startup/command-registry.js +1 -5
  113. package/dist/lib/state.d.ts +19 -2
  114. package/dist/lib/state.js +99 -10
  115. package/dist/lib/sync-umbrella.d.ts +11 -23
  116. package/dist/lib/sync-umbrella.js +14 -45
  117. package/dist/lib/teams/agents.d.ts +35 -3
  118. package/dist/lib/teams/agents.js +52 -8
  119. package/dist/lib/teams/scheduler.d.ts +29 -2
  120. package/dist/lib/teams/scheduler.js +60 -16
  121. package/dist/lib/types.d.ts +22 -5
  122. package/dist/lib/versions.d.ts +6 -0
  123. package/dist/lib/versions.js +6 -4
  124. package/package.json +1 -2
  125. package/dist/commands/drive.d.ts +0 -10
  126. package/dist/commands/drive.js +0 -183
  127. package/dist/commands/hq.d.ts +0 -2
  128. package/dist/commands/hq.js +0 -58
  129. package/dist/commands/sessions-sync.d.ts +0 -17
  130. package/dist/commands/sessions-sync.js +0 -135
  131. package/dist/commands/sync-provision.d.ts +0 -23
  132. package/dist/commands/sync-provision.js +0 -107
  133. package/dist/lib/drive-sync.d.ts +0 -45
  134. package/dist/lib/drive-sync.js +0 -238
  135. package/dist/lib/hq/floor.d.ts +0 -87
  136. package/dist/lib/hq/floor.js +0 -243
  137. package/dist/lib/session/sync/crdt.d.ts +0 -44
  138. package/dist/lib/session/sync/crdt.js +0 -119
  139. package/dist/lib/session/sync/manifest.d.ts +0 -62
  140. package/dist/lib/session/sync/manifest.js +0 -100
  141. package/dist/lib/session/sync/provision.d.ts +0 -49
  142. package/dist/lib/session/sync/provision.js +0 -91
  143. package/dist/lib/session/sync/r2.d.ts +0 -32
  144. package/dist/lib/session/sync/r2.js +0 -121
  145. package/dist/lib/session/sync/sync.d.ts +0 -106
  146. package/dist/lib/session/sync/sync.js +0 -374
@@ -10,16 +10,45 @@
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';
21
+ import { machineId } from '../lib/machine-id.js';
19
22
  import { getActiveSessions } from '../lib/session/active.js';
23
+ import { gatherRemoteActive } from '../lib/session/remote-active.js';
24
+ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
20
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
+ import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
21
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
22
- import { rollupSessionsByProject, planPct, enrichProjectSignals, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, planPct, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
29
+ import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
30
+ import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
+ import { buildFactoryImportCandidates, buildLinearImportCandidates, validateImportOpts, } from '../lib/project-import.js';
32
+ /** Recursion guard: a peer answering a probe fan-out never re-fans-out itself. */
33
+ export const PROJECTS_NO_FANOUT_ENV = 'AGENTS_PROJECTS_LOCAL';
34
+ /** Max peers named in the skipped note before the rest collapse to `+N`. */
35
+ const SKIPPED_NAME_LIMIT = 4;
36
+ /**
37
+ * One compact trailing note for peers that didn't answer the `--fleet`
38
+ * fan-out — unreachable, running an agents-cli too old to carry `projects
39
+ * probe`, or too slow to finish inside the 12s SSH budget. Mirrors
40
+ * `formatUnreachableNote` (activity) with the probe-specific reasons; empty
41
+ * string when everything answered.
42
+ */
43
+ export function formatFleetSkippedNote(skipped) {
44
+ if (skipped.length === 0)
45
+ return '';
46
+ const named = skipped.slice(0, SKIPPED_NAME_LIMIT);
47
+ const rest = skipped.length - named.length;
48
+ const list = rest > 0 ? `${named.join(', ')} +${rest}` : named.join(', ');
49
+ const noun = skipped.length === 1 ? 'device' : 'devices';
50
+ return chalk.gray(` · ${skipped.length} ${noun} didn't answer (unreachable, older agents-cli, or timed out): ${list}\n`);
51
+ }
23
52
  /** `path:purpose` → a context anchor. Purpose may contain colons. */
24
53
  function parseContextFlag(raw) {
25
54
  const i = raw.indexOf(':');
@@ -37,6 +66,135 @@ function originSlug(cwd) {
37
66
  return undefined;
38
67
  }
39
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
+ }
40
198
  function statusBar(r) {
41
199
  const parts = [];
42
200
  const push = (n, label, color) => {
@@ -54,7 +212,7 @@ function statusBar(r) {
54
212
  parts.push(chalk.gray(`+${r.agents - shown} other`));
55
213
  return parts.join(' · ') || chalk.gray('no live agents');
56
214
  }
57
- function renderCard(def, r, remote) {
215
+ function renderCard(def, r, remote, fleet, linear, nowMs = Date.now()) {
58
216
  const agents = r?.agents ?? 0;
59
217
  const pct = r ? planPct(r.plan) : undefined;
60
218
  const planStr = pct === undefined ? '' : ` · ${chalk.cyan(`${pct}% plan`)}`;
@@ -62,6 +220,8 @@ function renderCard(def, r, remote) {
62
220
  if (def.description)
63
221
  console.log(` ${chalk.dim(def.description)}`);
64
222
  console.log(` ${chalk.dim('live')} ${r ? statusBar(r) : chalk.gray('no live agents')}`);
223
+ if (r && r.members.length)
224
+ console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
65
225
  const ships = [];
66
226
  if (remote?.mergedPrs)
67
227
  ships.push(chalk.green(`${remote.mergedPrs} merged (${remote.windowDays}d)`));
@@ -69,11 +229,28 @@ function renderCard(def, r, remote) {
69
229
  ships.push(`${r.openPrs.length} open PR${r.openPrs.length === 1 ? '' : 's'}`);
70
230
  if (r?.worktrees)
71
231
  ships.push(`${r.worktrees} worktree${r.worktrees === 1 ? '' : 's'}`);
232
+ if (remote?.latestRelease)
233
+ ships.push(remote.latestRelease.tag);
72
234
  if (ships.length)
73
235
  console.log(` ${chalk.dim('ships')} ${ships.join(' · ')}`);
236
+ if (linear) {
237
+ console.log(` ${chalk.dim('linear')} ${linear.done}/${linear.total}${linear.truncated ? '+' : ''} done · ${linear.inProgress} in progress`);
238
+ }
239
+ if (linear?.nextMilestone) {
240
+ console.log(` ${chalk.dim('next')} ${formatNextMilestone(linear.nextMilestone, nowMs)}`);
241
+ }
74
242
  if (r && r.tickets.length) {
75
243
  console.log(` ${chalk.dim('tickets')} ${r.tickets.slice(0, 8).join(' · ')}${r.tickets.length > 8 ? ' …' : ''}`);
76
244
  }
245
+ if (fleet) {
246
+ const lines = formatFleetWorkspaces(fleet);
247
+ if (lines.length === 0) {
248
+ console.log(` ${chalk.dim('fleet')} ${chalk.gray('no workspace paths (set root or repos[].path)')}`);
249
+ }
250
+ lines.forEach((line, i) => {
251
+ console.log(` ${chalk.dim((i === 0 ? 'fleet' : '').padEnd(5))} ${line}`);
252
+ });
253
+ }
77
254
  if (remote?.artifacts) {
78
255
  const last = remote.lastArtifact ? ` ${chalk.dim(`· last: ${remote.lastArtifact}`)}` : '';
79
256
  console.log(` ${chalk.dim('proof')} ${remote.artifacts} artifact${remote.artifacts === 1 ? '' : 's'} (${remote.windowDays}d)${last}`);
@@ -96,20 +273,33 @@ export function registerProjectsCommands(program) {
96
273
  .description('Named multi-repo projects with a progress rollup.');
97
274
  setHelpSections(projects, {
98
275
  examples: `
276
+ agents projects import --from-linear # the projects you actually track
99
277
  agents projects add rush --repo phnx-labs/rush --path apps/web
100
278
  agents projects list
101
279
  agents projects status # progress card for every project
102
280
  agents projects status rush --json # one project, machine-readable
281
+ agents projects status --fleet # + per-device workspace drift over SSH
282
+ agents projects link rush --linear # bind the Linear project (auto-suggest)
103
283
  agents run --project rush # land an agent in the project
104
284
  `,
105
285
  notes: `
106
286
  Definitions are hand-editable YAML in ~/.agents/projects/ and sync across
107
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>'.
108
293
  `,
109
294
  });
110
- projects.hook('preAction', () => {
295
+ projects.hook('preAction', (_thisCommand, actionCommand) => {
111
296
  if (enabled)
112
297
  return;
298
+ // `probe` is the peer half of `status --fleet`: it must answer whenever the
299
+ // binary carries it, even where the beta flag is off — a gated peer would
300
+ // look unreachable to every fleet member on a newer CLI.
301
+ if (actionCommand.name() === 'probe')
302
+ return;
113
303
  console.error(chalk.red('agents projects is in beta.'));
114
304
  console.error(chalk.gray(betaEnableHint('projects')));
115
305
  process.exit(1);
@@ -131,10 +321,16 @@ export function registerProjectsCommands(program) {
131
321
  return;
132
322
  }
133
323
  const roll = rollupSessionsByProject(defs, await getActiveSessions());
134
- 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()) {
135
331
  const agents = roll.get(d.name)?.agents ?? 0;
136
- const repo = d.repo ?? d.repos?.[0]?.slug ?? '';
137
- 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`);
138
334
  }
139
335
  });
140
336
  // ---- add ----
@@ -239,6 +435,7 @@ export function registerProjectsCommands(program) {
239
435
  .option('--json', 'Machine-readable output')
240
436
  .option('--window <days>', 'Window for merged PRs and artifacts', '7')
241
437
  .option('--no-remote', 'Skip the GitHub lookup (merged-PR count); faster, offline')
438
+ .option('--fleet', 'Also dial every fleet device for workspace presence, branch, and drift (one SSH per peer)')
242
439
  .action(async (name, opts) => {
243
440
  const all = listProjectDefs();
244
441
  // Named lookup goes through the strict single-def loader so a broken
@@ -257,14 +454,57 @@ export function registerProjectsCommands(program) {
257
454
  }
258
455
  const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
259
456
  const nowMs = Date.now();
260
- const roll = rollupSessionsByProject(all, await getActiveSessions());
457
+ // --fleet: probe each shown def's workspace paths (root + repos[].path)
458
+ // locally and on every peer in one parallel SSH round, and widen the
459
+ // live-session rollup to the whole fleet via the existing sessions
460
+ // fan-out. Both are opt-in — they dial the fleet.
461
+ const fleetTargets = opts.fleet ? [...new Set(defs.flatMap(workspaceTargetsForDef))] : [];
462
+ let fleetWs = [];
463
+ let fleetSkipped = [];
464
+ let fleetSessions = [];
465
+ if (opts.fleet) {
466
+ const self = machineId();
467
+ fleetWs.push(...probeProjectWorkspaces(fleetTargets).map((s) => ({ ...s, host: self })));
468
+ const [probeRes, activeRes] = await Promise.all([
469
+ fleetTargets.length > 0
470
+ ? gatherRemoteAgentsJson({
471
+ args: ['projects', 'probe', '--json', ...fleetTargets],
472
+ noFanoutEnv: PROJECTS_NO_FANOUT_ENV,
473
+ parse: parseRemoteProbe,
474
+ quiet: true,
475
+ })
476
+ : Promise.resolve({ items: [], deviceCount: 0, skipped: [] }),
477
+ gatherRemoteActive(undefined, { quiet: true }),
478
+ ]);
479
+ fleetWs.push(...probeRes.items);
480
+ fleetSkipped = probeRes.skipped;
481
+ fleetSessions = activeRes.sessions;
482
+ }
483
+ const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
261
484
  // Enrich only the shown projects. --no-remote still reads the local artifact
262
- // log but skips the gh call (def.repo left unused mergedPrs stays 0).
485
+ // log but skips the gh calls + Linear counts (both are network).
263
486
  const remote = new Map();
487
+ const linear = new Map();
264
488
  await Promise.all(defs.map(async (d) => {
265
- remote.set(d.name, await enrichProjectSignals(d, windowDays, nowMs, { skipRemote: opts.remote === false }));
489
+ const skipRemote = opts.remote === false;
490
+ const [sig, counts] = await Promise.all([
491
+ enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
492
+ !skipRemote && d.linear?.projectId
493
+ ? fetchLinearProjectCounts(d.linear.projectId)
494
+ : Promise.resolve(undefined),
495
+ ]);
496
+ remote.set(d.name, sig);
497
+ if (counts)
498
+ linear.set(d.name, counts);
266
499
  }));
500
+ /** This def's slice of the fleet probe, in its own target order. */
501
+ const fleetFor = (d) => {
502
+ const targets = new Set(workspaceTargetsForDef(d));
503
+ return fleetWs.filter((s) => targets.has(s.path));
504
+ };
267
505
  if (opts.json) {
506
+ if (fleetSkipped.length > 0)
507
+ process.stderr.write(formatFleetSkippedNote(fleetSkipped));
268
508
  console.log(JSON.stringify(defs.map((d) => {
269
509
  const r = roll.get(d.name);
270
510
  const rem = remote.get(d.name);
@@ -272,74 +512,141 @@ export function registerProjectsCommands(program) {
272
512
  name: d.name,
273
513
  agents: r?.agents ?? 0,
274
514
  byStatus: r?.byStatus ?? {},
515
+ members: r?.members ?? [],
275
516
  plan: r?.plan ?? { done: 0, total: 0 },
276
517
  planPct: r ? planPct(r.plan) ?? null : null,
277
518
  openPrs: r?.openPrs ?? [],
278
519
  mergedPrs: rem?.mergedPrs ?? 0,
520
+ latestRelease: rem?.latestRelease ?? null,
521
+ linear: linear.get(d.name) ?? null,
279
522
  tickets: r?.tickets ?? [],
280
523
  worktrees: r?.worktrees ?? 0,
281
524
  artifacts: rem?.artifacts ?? 0,
282
525
  lastArtifact: rem?.lastArtifact ?? null,
283
526
  windowDays,
284
527
  repos: [d.repo, ...(d.repos ?? []).map((r2) => r2.slug)].filter(Boolean),
528
+ ...(opts.fleet ? { workspaces: fleetFor(d) } : {}),
285
529
  };
286
530
  }), null, 2));
287
531
  return;
288
532
  }
289
- for (const d of defs)
290
- renderCard(d, roll.get(d.name), remote.get(d.name));
533
+ for (const d of defs) {
534
+ renderCard(d, roll.get(d.name), remote.get(d.name), opts.fleet ? fleetFor(d) : undefined, linear.get(d.name), nowMs);
535
+ }
536
+ if (fleetSkipped.length > 0)
537
+ process.stdout.write(formatFleetSkippedNote(fleetSkipped));
538
+ });
539
+ // ---- probe (hidden; the peer half of `status --fleet`) ----
540
+ projects
541
+ .command('probe [paths...]', { hidden: true })
542
+ .description('Probe workspace repos (presence, branch, drift, dirtiness) and print JSON. Answers for this machine only.')
543
+ .option('--json', 'Machine-readable output (the only output format)')
544
+ .action((paths) => {
545
+ // Never fans out — the recursion guard env is set by the parent fan-out,
546
+ // and this command has no remote code path either way.
547
+ console.log(JSON.stringify(probeProjectWorkspaces(paths ?? []), null, 2));
291
548
  });
292
549
  // ---- import ----
293
550
  projects
294
551
  .command('import')
295
- .description('Absorb the Factory project registry (~/.agents/factory/projects.json) into YAML definitions.')
296
- .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')
297
557
  .option('--force', 'Overwrite existing definitions')
298
- .action((opts) => {
299
- const src = factoryProjectsPath();
300
- let rawText;
558
+ .action((raw) => {
559
+ let opts;
301
560
  try {
302
- rawText = fs.readFileSync(src, 'utf8');
561
+ opts = validateImportOpts(raw);
303
562
  }
304
- catch {
305
- console.error(chalk.red(`No Factory registry at ${src}`));
563
+ catch (e) {
564
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
565
+ process.exit(1);
566
+ }
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)
570
+ writeProjectDef(def);
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)'));
578
+ }
579
+ });
580
+ // ---- link ----
581
+ projects
582
+ .command('link <name>')
583
+ .description('Attach an external tracker to a project definition (writes linear.projectId into the YAML).')
584
+ .option('--linear [query]', 'Bind a Linear project by exact name or id; no value auto-suggests from the def name + repo')
585
+ .action((name, opts) => {
586
+ const def = loadProjectDef(name);
587
+ if (!def) {
588
+ console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
589
+ process.exit(1);
590
+ }
591
+ if (opts.linear === undefined) {
592
+ console.error(chalk.red('Nothing to link. Pass a tracker flag, e.g. --linear [query].'));
306
593
  process.exit(1);
307
594
  }
308
- let rows;
595
+ // Explicit user command — a missing/unauthenticated `linear` CLI is loud,
596
+ // unlike the best-effort card enrichment.
597
+ let list;
309
598
  try {
310
- rows = JSON.parse(rawText);
599
+ list = listLinearProjects();
311
600
  }
312
- catch {
313
- console.error(chalk.red(`Factory registry at ${src} is not valid JSON`));
601
+ catch (e) {
602
+ console.error(chalk.red(e instanceof Error ? e.message : String(e)));
314
603
  process.exit(1);
315
604
  }
316
- const list = Array.isArray(rows) ? rows : Array.isArray(rows.projects) ? rows.projects : [];
317
- let created = 0;
318
- let skipped = 0;
319
- for (const raw of list) {
320
- if (!raw || typeof raw !== 'object')
321
- continue;
322
- const o = raw;
323
- const name = typeof o.name === 'string' ? o.name : undefined;
324
- if (!name || !isSafeProjectName(name)) {
325
- skipped++;
326
- continue;
605
+ const query = typeof opts.linear === 'string' ? opts.linear.trim() : '';
606
+ let pick;
607
+ if (query) {
608
+ pick = pickLinearProject(query, list);
609
+ }
610
+ else {
611
+ // Auto-suggest from the def's own identity — the primary repo slug is
612
+ // the sharpest key, then the def name. Only an exact normalized match
613
+ // writes itself; anything weaker asks the user to disambiguate.
614
+ pick = { kind: 'none' };
615
+ for (const hint of [def.repo, def.name].filter((h) => typeof h === 'string' && h.length > 0)) {
616
+ const d = pickLinearProject(hint, list);
617
+ if (d.kind === 'match') {
618
+ pick = d;
619
+ break;
620
+ }
621
+ if (pick.kind === 'none')
622
+ pick = d;
327
623
  }
328
- if (loadProjectDef(name) && !opts.force) {
329
- skipped++;
330
- continue;
624
+ }
625
+ if (pick.kind !== 'match') {
626
+ if (pick.kind === 'candidates') {
627
+ console.error(chalk.yellow(`No confident Linear match${query ? ` for "${query}"` : ` for "${def.name}"`}. Candidates:`));
628
+ for (const c of pick.projects)
629
+ console.error(` ${chalk.cyan(c.id)} ${c.name}`);
331
630
  }
332
- const def = { name };
333
- if (typeof o.path === 'string')
334
- def.root = toHomeRelative(o.path);
335
- if (typeof o.repoSlug === 'string')
336
- def.repo = o.repoSlug;
337
- if (typeof o.linearProjectId === 'string')
338
- def.linear = { projectId: o.linearProjectId };
339
- writeProjectDef(def);
340
- created++;
631
+ else {
632
+ console.error(chalk.yellow(`No Linear project matches${query ? ` "${query}"` : ` "${def.name}"`}. Available:`));
633
+ for (const c of list)
634
+ console.error(` ${chalk.cyan(c.id)} ${c.name}`);
635
+ }
636
+ console.error(chalk.gray(`Re-run with an explicit name or id: agents projects link ${name} --linear "<name-or-id>"`));
637
+ process.exit(1);
638
+ }
639
+ const p = pick.project;
640
+ // Preserve every other field — load, set linear, write back. Keep a
641
+ // previously linked url when the new CLI row carries none.
642
+ if (def.linear?.projectId && def.linear.projectId !== p.id) {
643
+ console.log(chalk.gray(` replacing previous Linear link (${def.linear.projectId})`));
341
644
  }
342
- console.log(chalk.green(`Imported ${created} project${created === 1 ? '' : 's'}${skipped ? chalk.gray(` (${skipped} skipped)`) : ''}`));
645
+ def.linear = { ...def.linear, projectId: p.id };
646
+ if (p.url)
647
+ def.linear.url = p.url;
648
+ writeProjectDef(def);
649
+ console.log(chalk.green(`${def.name} → Linear project "${p.name}" (${p.id})${p.url ? ` ${p.url}` : ''}`));
343
650
  });
344
651
  // ---- rm ----
345
652
  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
@@ -10,6 +10,7 @@ import { SSH_TARGET_RE, assertValidSshTarget } from '../lib/ssh-exec.js';
10
10
  import { quoteWin32ExecArg } from '../lib/platform/index.js';
11
11
  import { type SecretsBackend, type SecretsBundle, type SecretsPolicy } from '../lib/secrets/bundles.js';
12
12
  import { type SecretsListFilterOpts } from '../lib/secrets/list-filter.js';
13
+ import { type BundleUsageSummary } from '../lib/secrets/usage-db.js';
13
14
  /** Read all available data from stdin synchronously, trimmed. */
14
15
  /**
15
16
  * Read the raw `.env` text for `import --from <path|->`. A `-` reads the .env
@@ -125,6 +126,22 @@ export declare function formatHoldWindow(ms: number): string;
125
126
  * shared by the POLICY column, the `--held` filter, and the JSON payload, so the
126
127
  * three can never disagree about the same bundle. */
127
128
  export declare function liveHold(expiresAt: number | undefined, now?: number): number | null;
129
+ /**
130
+ * Compact one-line usage summary for `secrets view` — each recorded event with
131
+ * its count and how long ago it last happened ("accessed 42× (last 2h ago) ·
132
+ * exported 3× (last 1d ago)"). Empty string when nothing has been recorded yet.
133
+ * Pure — unit-tested.
134
+ */
135
+ export declare function formatUsageLine(summary: BundleUsageSummary | undefined): string;
136
+ /**
137
+ * The held-state line for `secrets view`: whether the secrets-agent currently
138
+ * holds this bundle (so reads are prompt-free) and for how long, or that Touch
139
+ * ID is required on the next read. Only meaningful for keychain bundles on macOS
140
+ * — file/vault backends have no broker to hold, and a `never`-policy bundle is
141
+ * always readable — so both return an empty line and the caller skips it.
142
+ * `heldExpiresAt` is the max expiry across every held scope.
143
+ */
144
+ export declare function renderViewStatusLine(b: SecretsBundle, heldExpiresAt: number | null): string;
128
145
  /**
129
146
  * The EXPIRING cell. Counts keys needing attention — already expired plus due
130
147
  * within 30 days — and colours by the worst of the two: red once anything has