@phnx-labs/agents-cli 1.22.0 → 1.22.1

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,74 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.1
4
+
5
+ - **`agents doctor` de-noise: never-synced and cross-version hook drift are warnings, not criticals (RUSH-2162).** The CRITICAL section now holds only "needs you now" problems — a logged-out account, or a hook/plugin missing from a version you keep synced. A version that was never synced (an old/unused install with nothing installed) and a hook that merely *differs* across versions (installed but stale) are surfaced as WARNINGs instead, cutting the critical count on a busy machine from ~11 to the handful that actually need action. Source: `apps/cli/src/lib/devices/doctor-findings.ts`.
6
+
7
+ - **The "… is damaged and can't be opened" dialog stops — both helper `.app` bundles now install atomically and serialized.** The secrets keychain helper (`Agents CLI.app`) and the menu-bar helper (`MenubarHelper.app`) are each (re)installed on the hot path of ordinary `agents` invocations, and both did a non-atomic `rm -rf dest` + `cp -R src dest` straight onto the live bundle. On a busy box dozens of concurrent invocations raced that path, so a reader (Gatekeeper, or an exec of the bundle) could see a half-written `.app` — a truncated Mach-O / mismatched code signature — which macOS reports as damaged. A new shared installer (`lib/app-bundle-install.ts`, replacing the two duplicated copy functions) stages the copy in a sibling dir and swaps it in with renames (the live bundle is only ever a complete, signed `.app`, and a failed copy never touches it), and serializes concurrent installers behind the shared `withFileLock` with a double-checked skip so a burst copies once instead of stampeding. Source: `apps/cli/src/lib/app-bundle-install.ts`, `apps/cli/src/lib/secrets/install-helper.ts`, `apps/cli/src/lib/menubar/install-menubar.ts`.
8
+
9
+ - **`agents doctor --json` no longer stampedes into dozens of concurrent runs — the overview is singleflighted and cached, with a new `--refresh` to force a live recompute.** The bare `doctor --json` overview probes every host CLI, every agent's sign-in, and every agent×version diff — seconds on an idle box, minutes on a loaded one. The menu-bar helper polls it on a timer with only a per-*process* in-flight guard, so a helper relaunch (or any second poller) each launched its own live compute, and a helper killed mid-run orphaned a `doctor --json` that kept spinning — stacking to dozens of concurrent runs pinning the CPU. Now a fresh snapshot (< 90s) serves instantly from a disk cache, and when a live compute IS needed exactly one runs while every other caller serves its result (a lock-directory singleflight that self-heals if the computer dies). `agents doctor --json --refresh` bypasses the cache. Source: `apps/cli/src/lib/devices/doctor-overview-cache.ts`, `apps/cli/src/commands/doctor.ts`.
10
+
11
+ - **`doctor --json` releases its singleflight lock before it returns.** The overview gate
12
+ fired the lock release without awaiting it on the path where a waiter serves the winner's
13
+ fresh snapshot, so the call returned with the lockfile still on disk. The next caller then
14
+ retried against a lock that was already logically free — the pile-up the gate exists to
15
+ prevent, narrowed to the window between return and unlink. The release is now awaited.
16
+ The existing coalescing test failed 5 times in 15 runs before this and 0 in 15 after.
17
+ Source: `apps/cli/src/lib/devices/doctor-overview-cache.ts`.
18
+
19
+ - **`agents add grok@latest` no longer strands the freshly-downloaded binary in the old version's home.** When the post-install version probe (`<cli> --version`) transiently failed right after grok's self-updating installer exited, `installVersion` silently fell back to the literal string `'latest'` as the resolved version — creating a bogus `versions/grok/latest/` directory and defeating `relocateGrokBinaryToVersionHome`'s exact-filename match (its regex could never match `grok-latest-...`, since the real file is named `grok-<semver>-<platform>`). The real multi-hundred-MB binary was left behind in the PREVIOUS default's downloads dir, and `agents view grok` never listed the new version as installed even though `agents add` reported success. The probe now retries briefly instead of silently falling back, and fails loudly if it still can't resolve a version rather than corrupting the version bookkeeping. Relocation also now self-heals: if the current `~/.grok` symlink target has nothing matching, it sweeps every other installed grok version home for a binary stranded by a past occurrence of this bug. Source: `apps/cli/src/lib/versions.ts`.
20
+
21
+ - **A blocked menu-bar row now takes you to the session (RUSH-2110).** A NEEDS-YOU row
22
+ exists because an agent is waiting on you, but its only action was "Reveal working
23
+ dir", which unblocks nothing — you still had to go find the session by hand. Blocked
24
+ rows now lead with **Focus session**, which runs `agents focus <id>`: attach the live
25
+ terminal, or open a new tab and resume, cross-host. Reveal stays underneath. Both
26
+ render paths are covered — the single inline row and each entry inside a collapsed
27
+ multi-waiter group. A row the engine could not identify (a cloud task, a stale
28
+ sentinel) simply omits the item rather than offering an action that would do nothing.
29
+ Source: `apps/cli/menubar/Sources/MenubarHelper/StatusItemController.swift`,
30
+ `AgentsCLI.swift`.
31
+
32
+ - **Two projects sharing one monorepo checkout are no longer indistinguishable.** Session,
33
+ activity, and feed attribution anchored a project on `root ?? defaultPath`, so a subproject
34
+ whose `root` is the monorepo and whose `defaultPath` is a subdir collapsed onto the same
35
+ path as its umbrella — the longest-match tiebreak had nothing to separate them, and work in
36
+ `rush/apps/cli` counted toward whichever definition happened to be listed first. A
37
+ `defaultPath` nested under `root` now takes precedence over that `root` (the root says where
38
+ the checkout is; `defaultPath` says which work is this project's), and each bound repo's
39
+ checkout and subpath anchor too. A narrowed `root` still covers the rest of its checkout as
40
+ a fallback, so a lone project defined with `--path` keeps attributing work across its own
41
+ repo instead of only inside the subdir. Source: `apps/cli/src/lib/projects.ts`.
42
+
43
+ - **`agents projects view <name>` now shows more than `status`, not less.** The command you
44
+ open to learn everything about one project built its own short list — root, repos, a raw
45
+ Linear project id, an issue count, milestones — and never called the card renderer, so it
46
+ omitted the agents roster, merged PRs and release, focus areas, the schedule verdict,
47
+ tickets, and artifacts that `status` had shown all along. `view` and `status` now gather
48
+ through one function and render through one card; `view` adds every milestone (instead of
49
+ just the next) and the stored definition in full underneath — each repo with its subpath and
50
+ checkout, each context with its purpose, each integration with its URL. It also takes
51
+ `--window <days>` to match `status`. Source: `apps/cli/src/commands/projects.ts`.
52
+ - **The `agents` roster on the card lists live sessions only.** It included every matched
53
+ session, so a card headed `23 live` went on to print `claude · crashed ×25` — the corpses the
54
+ `dead` row already reports, counted twice and contradicting the headline. Both now derive
55
+ from one `isDeadStatus` predicate, pinned by a test across every `ActiveStatus`. Source:
56
+ `apps/cli/src/lib/project-status.ts`.
57
+
58
+ - **`--host <self>` and the fleet-health fan-out now short-circuit ALL of the
59
+ local machine's names, not just its short hostname (RUSH-2114).** A `--host`
60
+ target or fleet probe that referenced this box by its **tailscale dnsName**
61
+ (`zion.tail1a85a1.ts.net`) slipped past a `=== machineId()` check and SSH'd to
62
+ the local box over its own name; on a loaded machine that self-SSH'd `doctor
63
+ --json` orphaned on timeout and piled up until the host was crushed. A new
64
+ `isSelfHost()` matches every identity the box answers to (short id, loopback,
65
+ tailscale dnsName + its short form) and gates all four self-checks — the
66
+ generic `--host` passthrough (`maybeRunOnHost`), the `--devices`-all fan-out
67
+ (`runFleetPassthrough`), `remoteFleetTargets`, and `runFleet` — so a
68
+ self-reference runs locally instead of self-SSHing. Source:
69
+ `apps/cli/src/lib/devices/self-host.ts`, `apps/cli/src/lib/hosts/passthrough.ts`,
70
+ `apps/cli/src/lib/devices/fleet.ts`.
71
+
3
72
  ## 1.22.0
4
73
 
5
74
  - **`agents run auto` — full-auto dispatch (RUSH-2132).** `run auto` composes all three routing layers: host (14d launch affinity, unless `--host` is given), harness (installed CLIs weighted by best-account headroom), and account (the configured strategy). `balanced`/`available` now exit nonzero when every installed account is unhealthy — naming each excluded account, the earliest window reset, and the `--strategy pinned` escape hatch — instead of warning "falling back to defaults" and launching the exhausted pinned default. The error text is a machine-readable contract (`no healthy` + `resets <iso-time>`) the Factory watchdog tail-detects for rotate cooldowns. Source: `apps/cli/src/lib/rotate.ts`, `apps/cli/src/commands/exec.ts`, `apps/cli/src/lib/runner.ts`.
package/dist/bin/agents CHANGED
Binary file
@@ -4,6 +4,7 @@ import { addHostOption } from '../lib/hosts/option.js';
4
4
  import { buildRemoteAgentsInvocation } from '../lib/hosts/remote-cmd.js';
5
5
  import { loadDevices, isControlDevice } from '../lib/devices/registry.js';
6
6
  import { fanOutDevices, planFleetTargets, remoteFleetTargets } from '../lib/devices/fleet.js';
7
+ import { enterDoctorOverviewGate, writeDoctorOverviewCache } from '../lib/devices/doctor-overview-cache.js';
7
8
  import { fleetDialTarget } from '../lib/devices/connect.js';
8
9
  import { compareFleetInventories } from '../lib/devices/fleet-divergence.js';
9
10
  import { collectLocalFleetInventory } from '../lib/devices/fleet-inventory.js';
@@ -1155,12 +1156,18 @@ export function registerDoctorCommand(program) {
1155
1156
  .option('--devices', 'Check agent readiness AND cross-device harness divergence (missing resources/versions, repo drift) on every registered device (alias --hosts)')
1156
1157
  .option('--hosts', 'Alias of --devices')
1157
1158
  .option('--check', 'CI drift gate: exit non-zero when any installed version is out of sync (stale or never-synced), zero when clean. Combine with --devices to gate the whole fleet.')
1159
+ .option('--refresh', 'Bypass the cached overview snapshot: recompute the bare `doctor --json` overview live and refresh the shared cache that the menu-bar and other pollers read')
1158
1160
  .option('-q, --quiet', 'With --check, suppress per-version lines; print only the one-line verdict');
1159
1161
  setHelpSections(doctorCmd, {
1160
1162
  examples: `
1161
1163
  # Overview: CLI availability + sync status + orphans across all defaults
1162
1164
  agents doctor
1163
1165
 
1166
+ # Machine-readable overview (served from a ~90s cache for pollers like the
1167
+ # menu-bar helper); --refresh recomputes live and refreshes that cache
1168
+ agents doctor --json
1169
+ agents doctor --json --refresh
1170
+
1164
1171
  # Full per-resource report for the active default
1165
1172
  agents doctor claude@default
1166
1173
 
@@ -1288,6 +1295,26 @@ export function registerDoctorCommand(program) {
1288
1295
  return;
1289
1296
  }
1290
1297
  if (!target) {
1298
+ // Singleflight + short-TTL cache for the bare `doctor --json` overview.
1299
+ // This overview probes every host CLI, every agent's sign-in, and every
1300
+ // agent×version diff — seconds on an idle box, minutes on a loaded one.
1301
+ // The menu-bar helper polls it with only a per-*process* in-flight guard,
1302
+ // so a helper relaunch (or any second poller) each launched its own live
1303
+ // compute, and a helper killed mid-run orphaned a `doctor --json` that
1304
+ // kept spinning — stacking to dozens of concurrent runs pinning the CPU
1305
+ // (RUSH-2153). Now: a fresh snapshot serves instantly, and when a compute
1306
+ // IS needed exactly one runs while every other caller serves its result.
1307
+ // A crashed computer never wedges the gate — the lock is stolen once its
1308
+ // directory mtime goes stale (see enterDoctorOverviewGate).
1309
+ let releaseOverviewGate;
1310
+ if (opts.json) {
1311
+ const gate = await enterDoctorOverviewGate({ forceRefresh: !!opts.refresh });
1312
+ if (gate.cached !== null) {
1313
+ console.log(gate.cached);
1314
+ return;
1315
+ }
1316
+ releaseOverviewGate = gate.release;
1317
+ }
1291
1318
  const clis = checkAllClis();
1292
1319
  const syncRows = checkSyncStatus(cwd);
1293
1320
  const orphanRows = countOrphans();
@@ -1355,7 +1382,7 @@ export function registerDoctorCommand(program) {
1355
1382
  isolatedVersions,
1356
1383
  });
1357
1384
  if (opts.json) {
1358
- console.log(JSON.stringify({
1385
+ const overviewPayload = {
1359
1386
  clis,
1360
1387
  signIn,
1361
1388
  // Cached auth-health rollup for THIS host — lets `agents fleet status`
@@ -1398,7 +1425,12 @@ export function registerDoctorCommand(program) {
1398
1425
  branch: m.branch,
1399
1426
  fetchedAt: m.fetchedAt,
1400
1427
  })),
1401
- }, null, 2));
1428
+ };
1429
+ // Persist for the next poller and release the singleflight lock BEFORE
1430
+ // printing, so a concurrent caller picks up the fresh snapshot at once.
1431
+ writeDoctorOverviewCache(overviewPayload);
1432
+ releaseOverviewGate?.();
1433
+ console.log(JSON.stringify(overviewPayload, null, 2));
1402
1434
  return;
1403
1435
  }
1404
1436
  // Single-machine hybrid: CRITICAL section + one `▸ <machine>` block.
@@ -25,7 +25,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
25
25
  import { factoryProjectsPath } from '../lib/auto-dispatch.js';
26
26
  import { formatFleetWorkspaces, parseRemoteProbe, probeProjectWorkspaces, workspaceTargetsForDef, } from '../lib/project-probe.js';
27
27
  import { listProjectDefs, loadProjectDef, writeProjectDef, removeProjectDef, projectDefPath, isSafeProjectName, } from '../lib/projects.js';
28
- import { rollupSessionsByProject, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
28
+ import { rollupSessionsByProject, isDeadStatus, liveDeadSplit, enrichProjectSignals, formatProjectMembers, } from '../lib/project-status.js';
29
29
  import { fetchLinearProjectCounts } from '../lib/linear-project-counts.js';
30
30
  import { listLinearProjects, pickLinearProject } from '../lib/linear-projects.js';
31
31
  import { checkRepoSlug } from '../lib/project-doctor.js';
@@ -268,11 +268,45 @@ function statusBar(r) {
268
268
  parts.push(chalk.gray(`+${live - shown} other`));
269
269
  return parts.join(' · ') || chalk.gray('no live agents');
270
270
  }
271
+ /**
272
+ * Gather the live/remote/Linear/focus signals for the projects about to be
273
+ * rendered. `status` and `view` share this deliberately: `view` used to build
274
+ * its own thinner picture, so the command you open to learn everything about
275
+ * ONE project showed strictly less than the roll-up across all of them — no
276
+ * agents, no ships, no focus, no schedule verdict. One gatherer means a signal
277
+ * added for either surface appears on both.
278
+ *
279
+ * Only the shown projects are enriched. `skipRemote` still reads the local
280
+ * artifact log and git focus, and skips just the network calls (`gh`, Linear).
281
+ */
282
+ async function enrichProjectsForRender(defs, all, opts) {
283
+ const roll = rollupSessionsByProject(all, [...(await getActiveSessions()), ...(opts.extraSessions ?? [])]);
284
+ const remote = new Map();
285
+ const linear = new Map();
286
+ // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
287
+ const focus = new Map();
288
+ await Promise.all(defs.map(async (d) => {
289
+ const [sig, counts] = await Promise.all([
290
+ enrichProjectSignals(d, opts.windowDays, opts.nowMs, { skipRemote: opts.skipRemote }),
291
+ !opts.skipRemote && d.linear?.projectId
292
+ ? fetchLinearProjectCounts(d.linear.projectId)
293
+ : Promise.resolve(undefined),
294
+ ]);
295
+ remote.set(d.name, sig);
296
+ if (counts)
297
+ linear.set(d.name, counts);
298
+ if (d.root)
299
+ focus.set(d.name, await readFocusAreas(expandLocalHome(d.root), opts.windowDays));
300
+ }));
301
+ return { roll, remote, linear, focus };
302
+ }
271
303
  function renderCard(def, r, remote, fleet, linear, nowMs = Date.now(),
272
304
  /** How many milestones to print. `status` shows the next one; `view` shows all. */
273
305
  milestoneLimit = 1,
274
306
  /** Directories the window's work landed in, from local git. */
275
- focus = []) {
307
+ focus = [],
308
+ /** `view` mode: the caller prints the stored definition in full afterwards. */
309
+ detail = false) {
276
310
  // The headline counts LIVE agents. It used to be every matched session, which
277
311
  // read `39 agents` on a project where 19 had crashed. `planPct` used to sit
278
312
  // here too and is gone: it summed each session's latest checklist snapshot,
@@ -288,8 +322,12 @@ focus = []) {
288
322
  const detail = split.deadByStatus.map((d) => `${d.n} ${d.status}`).join(', ');
289
323
  console.log(` ${chalk.dim('dead')} ${chalk.yellow(`${split.dead} finished or lost`)} ${chalk.dim(`(${detail})`)}`);
290
324
  }
291
- if (r && r.members.length)
292
- console.log(` ${chalk.dim('agents')} ${formatProjectMembers(r.members)}`);
325
+ // Live only. The roster used to list every matched session, so a card headed
326
+ // `23 live` went on to show `crashed ×25` — the corpses the `dead` row above
327
+ // already accounts for, counted twice and contradicting the headline.
328
+ const liveMembers = r?.members.filter((m) => !isDeadStatus(m.status)) ?? [];
329
+ if (liveMembers.length)
330
+ console.log(` ${chalk.dim('agents')} ${formatProjectMembers(liveMembers)}`);
293
331
  const ships = [];
294
332
  if (remote?.mergedPrs) {
295
333
  ships.push(chalk.green(`${remote.mergedPrs}${remote.mergedPrsTruncated ? '+' : ''} merged (${remote.windowDays}d)`));
@@ -343,13 +381,16 @@ focus = []) {
343
381
  console.log(` ${chalk.yellow('!')} ${chalk.yellow(mismatch.message)}`);
344
382
  console.log(` ${' '.repeat(8)}${chalk.gray(mismatch.remediation)}`);
345
383
  }
346
- if (def.contexts?.length) {
384
+ // `view` prints these in full (path + purpose, label + URL) right below, so
385
+ // the compact one-line summaries would just say the same thing twice.
386
+ if (!detail && def.contexts?.length) {
347
387
  console.log(` ${chalk.dim('context')} ${def.contexts.map((c) => c.path).join(' · ')}`);
348
388
  }
349
- if (def.integrations?.length) {
389
+ if (!detail && def.integrations?.length) {
350
390
  console.log(` ${chalk.dim('links')} ${def.integrations.map((i) => i.label ?? i.kind).join(' · ')}`);
351
391
  }
352
- console.log('');
392
+ if (!detail)
393
+ console.log('');
353
394
  }
354
395
  export function registerProjectsCommands(program) {
355
396
  const enabled = isBetaEnabled('projects');
@@ -468,58 +509,70 @@ export function registerProjectsCommands(program) {
468
509
  projects
469
510
  .command('view <name>')
470
511
  .alias('show')
471
- .description('Show a project definition, resolved paths, repos, contexts, links, and milestones.')
512
+ .description('Everything about one project: the full status card, every milestone, and the stored definition.')
472
513
  .option('--json', 'Machine-readable output')
473
- .option('--no-remote', 'Skip the Linear lookup; definition only')
514
+ .option('--window <days>', 'Window for merged PRs, artifacts, and focus areas', '7')
515
+ .option('--no-remote', 'Skip the GitHub and Linear lookups; definition only')
474
516
  .action(async (name, opts) => {
475
517
  const def = loadProjectDef(name);
476
518
  if (!def) {
477
519
  console.error(chalk.red(`No project named "${name}". List them: agents projects list`));
478
520
  process.exit(1);
479
521
  }
480
- // Every milestone the project declares, not just the next one — the whole
481
- // point of opening ONE project is to see the shape of its plan.
482
- const counts = opts.remote !== false && def.linear?.projectId
483
- ? await fetchLinearProjectCounts(def.linear.projectId)
484
- : undefined;
522
+ // `view` is `status` for one project PLUS the stored definition and every
523
+ // milestone. It used to render its own short list — root, repos, a raw
524
+ // Linear id, issues — so opening a single project told you LESS than the
525
+ // roll-up did: no agents, no ships, no focus, no schedule verdict.
526
+ const windowDays = Math.max(1, Number.parseInt(opts.window ?? '7', 10) || 7);
527
+ const nowMs = Date.now();
528
+ const all = listProjectDefs();
529
+ const { roll, remote, linear, focus } = await enrichProjectsForRender([def], all, {
530
+ windowDays,
531
+ nowMs,
532
+ skipRemote: opts.remote === false,
533
+ });
534
+ const r = roll.get(def.name);
535
+ const sig = remote.get(def.name);
536
+ const counts = linear.get(def.name);
485
537
  if (opts.json) {
486
- console.log(JSON.stringify({ ...def, linear: { ...def.linear, ...(counts ?? {}) } }, null, 2));
538
+ console.log(JSON.stringify({
539
+ ...def,
540
+ linear: { ...def.linear, ...(counts ?? {}) },
541
+ schedule: counts?.milestones?.length ? scheduleVerdict(counts.milestones, nowMs) : null,
542
+ live: r ? liveDeadSplit(r.byStatus).live : 0,
543
+ dead: r ? liveDeadSplit(r.byStatus).dead : 0,
544
+ byStatus: r?.byStatus ?? {},
545
+ members: r?.members ?? [],
546
+ openPrs: r?.openPrs ?? [],
547
+ tickets: r?.tickets ?? [],
548
+ mergedPrs: sig?.mergedPrs ?? 0,
549
+ latestRelease: sig?.latestRelease ?? null,
550
+ artifacts: sig?.artifacts ?? 0,
551
+ focus: focus.get(def.name) ?? [],
552
+ }, null, 2));
487
553
  return;
488
554
  }
489
- console.log(chalk.bold(def.name) + (def.description ? chalk.dim(` — ${def.description}`) : ''));
555
+ // The same card `status` prints, with every milestone instead of the next.
556
+ renderCard(def, r, sig, undefined, counts, nowMs, Number.POSITIVE_INFINITY, focus.get(def.name) ?? [], true);
557
+ // Then what only `view` shows: the stored definition, in full.
558
+ console.log();
490
559
  if (def.root)
491
- console.log(` root ${def.root}`);
560
+ console.log(` ${chalk.dim('root')} ${def.root}`);
492
561
  if (def.defaultPath)
493
- console.log(` defaultPath ${def.defaultPath}`);
494
- const repos = [def.repo, ...(def.repos ?? []).map((r) => (r.subpath ? `${r.slug} (${r.subpath})` : r.slug))].filter(Boolean);
495
- if (repos.length)
496
- console.log(` repos ${repos.join(', ')}`);
562
+ console.log(` ${chalk.dim('path')} ${def.defaultPath}`);
563
+ for (const rp of def.repos ?? []) {
564
+ const where = [rp.subpath ? `subpath ${rp.subpath}` : undefined, rp.path].filter(Boolean).join(' · ');
565
+ console.log(` ${chalk.dim('repo')} ${rp.slug}${where ? chalk.dim(` (${where})`) : ''}`);
566
+ }
497
567
  for (const c of def.contexts ?? [])
498
- console.log(` context ${chalk.cyan(c.path)} — ${c.purpose}`);
499
- for (const i of def.integrations ?? [])
500
- console.log(` ${i.kind.padEnd(12)} ${i.url}${i.label ? chalk.dim(` (${i.label})`) : ''}`);
568
+ console.log(` ${chalk.dim('context')} ${chalk.cyan(c.path)} ${chalk.dim('—')} ${c.purpose}`);
569
+ for (const ig of def.integrations ?? []) {
570
+ console.log(` ${chalk.dim(ig.kind.padEnd(8))} ${ig.url}${ig.label ? chalk.dim(` (${ig.label})`) : ''}`);
571
+ }
501
572
  if (def.linear?.url || def.linear?.projectId)
502
- console.log(` linear ${def.linear.url ?? def.linear.projectId}`);
573
+ console.log(` ${chalk.dim('linear')} ${def.linear.url ?? def.linear.projectId}`);
503
574
  for (const d of def.docs ?? [])
504
- console.log(` doc ${d}`);
505
- if (counts) {
506
- const staleNote = counts.stale ? chalk.dim(' (cached)') : '';
507
- console.log(` issues ${counts.done}/${counts.total}${counts.truncated ? '+' : ''} done · ${counts.inProgress} in progress${staleNote}`);
508
- }
509
- const ms = counts?.milestones ?? [];
510
- if (ms.length) {
511
- console.log(` ${chalk.bold('milestones')}`);
512
- for (const m of ms) {
513
- const mark = m.name === counts?.nextMilestone?.name ? chalk.cyan('next') : '';
514
- console.log(` ${mark.padEnd(6)} ${formatNextMilestone(m, Date.now())}`);
515
- }
516
- // A milestone nothing is filed against cannot report progress. Say that
517
- // once, plainly, rather than printing a row of silent 0%s.
518
- const unfiled = ms.filter((m) => m.total === 0).length;
519
- if (unfiled === ms.length) {
520
- console.log(` ${chalk.yellow('!')} ${chalk.yellow(`no issues are assigned to any milestone — progress against them cannot be measured`)}`);
521
- }
522
- }
575
+ console.log(` ${chalk.dim('doc')} ${d}`);
523
576
  console.log(chalk.gray(` ${projectDefPath(name)}`));
524
577
  });
525
578
  // ---- edit ----
@@ -590,27 +643,12 @@ export function registerProjectsCommands(program) {
590
643
  fleetSkipped = probeRes.skipped;
591
644
  fleetSessions = activeRes.sessions;
592
645
  }
593
- const roll = rollupSessionsByProject(all, [...await getActiveSessions(), ...fleetSessions]);
594
- // Enrich only the shown projects. --no-remote still reads the local artifact
595
- // log but skips the gh calls + Linear counts (both are network).
596
- const remote = new Map();
597
- const linear = new Map();
598
- // Local git, no API, no rate limit — measured 0.23s over a 897-commit week.
599
- const focus = new Map();
600
- await Promise.all(defs.map(async (d) => {
601
- const skipRemote = opts.remote === false;
602
- const [sig, counts] = await Promise.all([
603
- enrichProjectSignals(d, windowDays, nowMs, { skipRemote }),
604
- !skipRemote && d.linear?.projectId
605
- ? fetchLinearProjectCounts(d.linear.projectId)
606
- : Promise.resolve(undefined),
607
- ]);
608
- remote.set(d.name, sig);
609
- if (counts)
610
- linear.set(d.name, counts);
611
- if (d.root)
612
- focus.set(d.name, await readFocusAreas(expandLocalHome(d.root), windowDays));
613
- }));
646
+ const { roll, remote, linear, focus } = await enrichProjectsForRender(defs, all, {
647
+ windowDays,
648
+ nowMs,
649
+ skipRemote: opts.remote === false,
650
+ extraSessions: fleetSessions,
651
+ });
614
652
  /** This def's slice of the fleet probe, in its own target order. */
615
653
  const fleetFor = (d) => {
616
654
  const targets = new Set(workspaceTargetsForDef(d));
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Copy an `.app` bundle to `dest` atomically. Stages into a sibling dir, then
3
+ * swaps with renames — the window where `dest` is absent shrinks from the
4
+ * seconds-long `cp` to a single microsecond rename, and a failed copy leaves the
5
+ * existing bundle untouched. Serialize concurrent callers with {@link withInstallLock}
6
+ * so the two-step swap never races another swap.
7
+ */
8
+ export declare function copyAppBundle(src: string, dest: string, io?: {
9
+ renameSync?: (from: string, to: string) => void;
10
+ }): void;
11
+ /**
12
+ * Serialize installs across the many concurrent `agents` invocations that pass
13
+ * through the helper-install path, so a burst copies once instead of stampeding
14
+ * the atomic swap. Locks a sentinel file beside the bundle (the bundle itself may
15
+ * not exist yet on first install) via the shared {@link withFileLock}.
16
+ */
17
+ export declare function withInstallLock(dest: string, fn: (heartbeat: () => void) => void): void;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Atomic, serialized install of a macOS `.app` bundle to a stable user path.
3
+ *
4
+ * Shared by the two helpers agents-cli installs on darwin — the secrets keychain
5
+ * helper (`lib/secrets/install-helper.ts`) and the menu-bar helper
6
+ * (`lib/menubar/install-menubar.ts`) — both of which are (re)installed on the hot
7
+ * path of ordinary `agents` invocations. Both previously did a non-atomic
8
+ * `rm -rf dest` + `cp -R src dest` straight onto the live bundle. That copy takes
9
+ * long enough that a concurrent reader (Gatekeeper, or an exec of the bundle) sees
10
+ * a half-written `.app` — a truncated Mach-O / mismatched `_CodeSignature` hash —
11
+ * which macOS reports as **"is damaged and can't be opened."** On a busy box dozens
12
+ * of concurrent invocations raced the same path, so the dialog fired intermittently.
13
+ *
14
+ * {@link copyAppBundle} stages the copy in a sibling directory and swaps it into
15
+ * place with renames, so a reader sees either the old or the new complete bundle,
16
+ * never a half-written one — the only moment `dest` is briefly absent is the
17
+ * sub-millisecond gap between the two renames (vs the seconds-long `cp`), and a
18
+ * failed copy never touches the live bundle. {@link withInstallLock} serializes concurrent
19
+ * installers (via the shared `withFileLock`) so a burst of invocations installs
20
+ * once instead of stampeding.
21
+ */
22
+ import * as fs from 'fs';
23
+ import * as path from 'path';
24
+ import { spawnSync } from 'child_process';
25
+ import { withFileLock, ensureLockTarget } from './fs-atomic.js';
26
+ // A helper `cp -R` under load can take a few seconds; the lock must outlast it so
27
+ // a peer never treats a live installer as crashed and interleaves a second swap.
28
+ // (fs-atomic's 5s default is tuned for sub-second read-modify-writes.)
29
+ const INSTALL_LOCK_STALE_MS = 60_000;
30
+ const INSTALL_LOCK_ACQUIRE_TIMEOUT_MS = 60_000;
31
+ /**
32
+ * Copy an `.app` bundle to `dest` atomically. Stages into a sibling dir, then
33
+ * swaps with renames — the window where `dest` is absent shrinks from the
34
+ * seconds-long `cp` to a single microsecond rename, and a failed copy leaves the
35
+ * existing bundle untouched. Serialize concurrent callers with {@link withInstallLock}
36
+ * so the two-step swap never races another swap.
37
+ */
38
+ export function copyAppBundle(src, dest, io = {}) {
39
+ const rename = io.renameSync ?? fs.renameSync;
40
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
41
+ const staging = `${dest}.installing.${process.pid}`;
42
+ const backup = `${dest}.replaced.${process.pid}`;
43
+ fs.rmSync(staging, { recursive: true, force: true });
44
+ fs.rmSync(backup, { recursive: true, force: true });
45
+ // `cp -R` preserves the bundle's signature, symlinks, and resource forks;
46
+ // `fs.cpSync({recursive:true})` has historically mishandled xattrs on `.app`
47
+ // bundles, breaking codesign.
48
+ const r = spawnSync('cp', ['-R', src, staging], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
49
+ if (r.status !== 0) {
50
+ fs.rmSync(staging, { recursive: true, force: true });
51
+ const msg = (r.stderr || r.stdout || '').toString().trim();
52
+ throw new Error(`Failed to copy ${src} -> ${staging}: ${msg || 'unknown error'}`);
53
+ }
54
+ // rename(2) cannot replace a non-empty directory, so move the current bundle
55
+ // aside, then move staging into place. If the second rename fails, restore the
56
+ // backup so `dest` is never left missing.
57
+ try {
58
+ if (fs.existsSync(dest))
59
+ rename(dest, backup);
60
+ rename(staging, dest);
61
+ }
62
+ catch (err) {
63
+ if (!fs.existsSync(dest) && fs.existsSync(backup)) {
64
+ try {
65
+ rename(backup, dest);
66
+ }
67
+ catch {
68
+ /* best-effort restore */
69
+ }
70
+ }
71
+ fs.rmSync(staging, { recursive: true, force: true });
72
+ throw new Error(`Failed to install ${src} -> ${dest}: ${err.message}`);
73
+ }
74
+ fs.rmSync(backup, { recursive: true, force: true });
75
+ }
76
+ /**
77
+ * Serialize installs across the many concurrent `agents` invocations that pass
78
+ * through the helper-install path, so a burst copies once instead of stampeding
79
+ * the atomic swap. Locks a sentinel file beside the bundle (the bundle itself may
80
+ * not exist yet on first install) via the shared {@link withFileLock}.
81
+ */
82
+ export function withInstallLock(dest, fn) {
83
+ const lockTarget = `${dest}.install-lock`;
84
+ ensureLockTarget(lockTarget);
85
+ // Pass proper-lockfile's `heartbeat` straight through: the install body is a
86
+ // fully SYNCHRONOUS chain of blocking `spawnSync`s (`cp -R`, then codesign /
87
+ // spctl), so the event loop never turns and proper-lockfile's own async mtime
88
+ // refresh can't fire. Callers invoke heartbeat() between those steps to keep a
89
+ // long hold from ageing past staleMs and being broken by a contending peer.
90
+ withFileLock(lockTarget, (heartbeat) => fn(heartbeat), {
91
+ staleMs: INSTALL_LOCK_STALE_MS,
92
+ acquireTimeoutMs: INSTALL_LOCK_ACQUIRE_TIMEOUT_MS,
93
+ });
94
+ }
@@ -304,8 +304,12 @@ export function buildLocalFindings(input) {
304
304
  }
305
305
  }
306
306
  if (neverSynced) {
307
- // Everything is "missing" because it was never synced — one line, and a
308
- // CRITICAL one: nothing this version declares is actually installed.
307
+ // Everything is "missing" because it was never synced — collapse to ONE
308
+ // line. A never-synced version is almost always an old/unused install (you
309
+ // don't run a version you never synced), so this is a WARNING, not a "needs
310
+ // you now" critical: it isn't hurting anything until you actually launch it.
311
+ // The real criticals are a logged-out account or a hook/plugin missing from
312
+ // a version you DO keep synced (the `else` branch below).
309
313
  const total = missingHooks.length + missingPlugins.length + missingOther.length;
310
314
  if (total > 0) {
311
315
  const breakdown = [
@@ -313,7 +317,7 @@ export function buildLocalFindings(input) {
313
317
  missingPlugins.length ? `${missingPlugins.length} plugin${missingPlugins.length === 1 ? '' : 's'}` : '',
314
318
  ].filter(Boolean).join(', ');
315
319
  out.push(finding({
316
- severity: 'critical', kind: 'never-synced', device, agent, version,
320
+ severity: 'warning', kind: 'never-synced', device, agent, version,
317
321
  message: `never synced — ${total} resource${total === 1 ? '' : 's'}${breakdown ? ` (incl. ${breakdown})` : ''} not installed`,
318
322
  }));
319
323
  }
@@ -464,7 +468,11 @@ function duplicateHookFindings(device, dups) {
464
468
  `${versions.length} version${versions.length === 1 ? '' : 's'} ` +
465
469
  `(incl. ${group.slice(0, 2).map((d) => `'${d.name}'`).join(', ')}) — ${authority}`;
466
470
  out.push({
467
- severity: drift ? 'critical' : 'warning',
471
+ // A hook that DIFFERS across versions is still installed and firing — it's
472
+ // stale/sync drift, not a missing hook. Resolvable by one sync; a WARNING,
473
+ // not a "needs you now" critical. (A genuinely MISSING hook stays critical
474
+ // via the missing-hook path.)
475
+ severity: 'warning',
468
476
  kind: drift ? 'duplicate-hook-drift' : 'duplicate-hook',
469
477
  device, agent, versions, message,
470
478
  remediation: `agents sync ${agent}@all --yes`,
@@ -0,0 +1,45 @@
1
+ /** Serve a cached snapshot without recomputing while it is younger than this. */
2
+ export declare const DOCTOR_OVERVIEW_FRESH_MS = 90000;
3
+ /** Injectable IO + clock so tests exercise the real fs at a temp dir, no mocks. */
4
+ export interface DoctorOverviewCacheDeps {
5
+ /** Cache directory (default: the real `~/.agents/.cache`). */
6
+ dir?: string;
7
+ /** Clock (default: {@link Date.now}). */
8
+ now?: () => number;
9
+ }
10
+ /** Read the last snapshot (best-effort; missing/corrupt/wrong-version → null). */
11
+ export declare function readDoctorOverviewCache(deps?: DoctorOverviewCacheDeps): {
12
+ fetchedAt: number;
13
+ payload: unknown;
14
+ } | null;
15
+ /** Persist a fresh overview payload (best-effort; tmp+rename so reads are atomic). */
16
+ export declare function writeDoctorOverviewCache(payload: unknown, deps?: DoctorOverviewCacheDeps): void;
17
+ /**
18
+ * Result of {@link enterDoctorOverviewGate}.
19
+ * - `cached` non-null → the caller MUST print this string and return; no compute.
20
+ * - `cached` null → the caller holds the singleflight lock: compute the
21
+ * overview, call {@link writeDoctorOverviewCache}, and invoke `release()` on
22
+ * the way out. Call `release()` in a `finally` so a compute that throws still
23
+ * frees the lock promptly (idempotent).
24
+ */
25
+ export interface OverviewGate {
26
+ cached: string | null;
27
+ release?: () => void;
28
+ }
29
+ /**
30
+ * Enter the doctor-overview singleflight gate. Returns a cached string to print,
31
+ * or a lock token telling the caller to compute (and then write + release).
32
+ *
33
+ * Contract:
34
+ * - Fresh snapshot present (and not `forceRefresh`) → `{ cached }`, no lock.
35
+ * - Otherwise exactly one caller holds the lock and gets `{ cached: null,
36
+ * release }`; everyone else blocks on the lock, then (on acquiring it)
37
+ * double-checks and serves the winner's fresh write — or, if the winner runs
38
+ * past the wait budget, serves the last snapshot — rather than recomputing.
39
+ * - Never throws: any IO/lock failure degrades to a compute token or a served
40
+ * snapshot.
41
+ */
42
+ export declare function enterDoctorOverviewGate(opts?: {
43
+ forceRefresh?: boolean;
44
+ freshMs?: number;
45
+ }, deps?: DoctorOverviewCacheDeps): Promise<OverviewGate>;