@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 +69 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/doctor.js +34 -2
- package/dist/commands/projects.js +103 -65
- package/dist/lib/app-bundle-install.d.ts +17 -0
- package/dist/lib/app-bundle-install.js +94 -0
- package/dist/lib/devices/doctor-findings.js +12 -4
- package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
- package/dist/lib/devices/doctor-overview-cache.js +168 -0
- package/dist/lib/devices/fleet.js +7 -2
- package/dist/lib/devices/self-host.d.ts +9 -0
- package/dist/lib/devices/self-host.js +61 -0
- package/dist/lib/hosts/passthrough.js +8 -6
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.js +27 -23
- package/dist/lib/project-status.d.ts +7 -0
- package/dist/lib/project-status.js +9 -0
- package/dist/lib/projects.d.ts +11 -2
- package/dist/lib/projects.js +57 -9
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/install-helper.js +28 -31
- package/dist/lib/versions.js +69 -22
- package/package.json +1 -1
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
|
package/dist/commands/doctor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('
|
|
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('--
|
|
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
|
-
//
|
|
481
|
-
//
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
|
560
|
+
console.log(` ${chalk.dim('root')} ${def.root}`);
|
|
492
561
|
if (def.defaultPath)
|
|
493
|
-
console.log(`
|
|
494
|
-
const
|
|
495
|
-
|
|
496
|
-
console.log(`
|
|
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
|
|
499
|
-
for (const
|
|
500
|
-
console.log(` ${
|
|
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
|
|
573
|
+
console.log(` ${chalk.dim('linear')} ${def.linear.url ?? def.linear.projectId}`);
|
|
503
574
|
for (const d of def.docs ?? [])
|
|
504
|
-
console.log(` doc
|
|
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
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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 —
|
|
308
|
-
//
|
|
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: '
|
|
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
|
-
|
|
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>;
|