@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
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Singleflight + short-TTL disk cache for the `agents doctor --json` OVERVIEW
|
|
3
|
+
* payload (the bare, no-target form the menu-bar helper and other pollers read).
|
|
4
|
+
*
|
|
5
|
+
* Why this exists (RUSH-2153): the bare `doctor --json` overview is expensive —
|
|
6
|
+
* it probes every host CLI, spawns every installed agent CLI for its sign-in,
|
|
7
|
+
* and diffs every agent×version against its source. On an idle box that is a few
|
|
8
|
+
* seconds; on a loaded one it is minutes. The menu-bar helper polls it on a 60s
|
|
9
|
+
* timer with a per-*process* in-flight guard, so nothing coalesces ACROSS
|
|
10
|
+
* processes: a helper relaunch (or any second poller) each launches its own
|
|
11
|
+
* live compute, and a helper killed mid-run orphans its `doctor --json` child,
|
|
12
|
+
* which keeps spinning. In steady state this stacked to dozens of concurrent
|
|
13
|
+
* `doctor --json` processes pinning ~14 cores and driving load to ~300.
|
|
14
|
+
*
|
|
15
|
+
* The fix mirrors the {@link readStatsCache}/`writeStatsCache` mirror-file
|
|
16
|
+
* convention: reads are cache-first, and when a live compute IS needed exactly
|
|
17
|
+
* ONE runs at a time. The singleflight is the shared `proper-lockfile` lock (via
|
|
18
|
+
* `ensureLockTarget` + `lockfile.lock`) — the SAME battle-tested lock the rest of
|
|
19
|
+
* the CLI uses (fs-atomic.ts) — which owns two things a hand-rolled lock got
|
|
20
|
+
* wrong: (1) it auto-refreshes the lock's mtime on a timer while held, so a live
|
|
21
|
+
* computer whose compute runs for minutes is never mistaken for a crashed one
|
|
22
|
+
* and stolen; (2) `release()` only ever releases the lock THIS caller acquired,
|
|
23
|
+
* so a slow computer can't delete a successor's lock. Waiters block on the lock
|
|
24
|
+
* up to a bounded budget, then serve the last snapshot rather than pile on.
|
|
25
|
+
*
|
|
26
|
+
* The cache write is tmp+rename so a concurrent reader never sees a partial file.
|
|
27
|
+
* All IO is best-effort: a failure degrades to a live compute, never a throw.
|
|
28
|
+
*/
|
|
29
|
+
import * as fs from 'fs';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
import lockfile from 'proper-lockfile';
|
|
32
|
+
import { getCacheDir } from '../state.js';
|
|
33
|
+
import { ensureLockTarget } from '../fs-atomic.js';
|
|
34
|
+
const CACHE_FILE = '.doctor-overview.json';
|
|
35
|
+
const LOCK_TARGET_FILE = '.doctor-overview.lock-target';
|
|
36
|
+
/** Serve a cached snapshot without recomputing while it is younger than this. */
|
|
37
|
+
export const DOCTOR_OVERVIEW_FRESH_MS = 90_000;
|
|
38
|
+
/**
|
|
39
|
+
* A held lock older than this is treated as a crashed computer and broken. The
|
|
40
|
+
* lock's mtime is auto-refreshed by proper-lockfile every `stale/2` while a live
|
|
41
|
+
* computer holds it (the event loop turns during the compute's `await`ed
|
|
42
|
+
* subprocess spawns), so this only ever breaks a genuinely dead holder.
|
|
43
|
+
*/
|
|
44
|
+
const LOCK_STALE_MS = 60_000;
|
|
45
|
+
/**
|
|
46
|
+
* How long a waiter blocks on the lock before giving up and serving the last
|
|
47
|
+
* snapshot. Sized to comfortably exceed a slow (multi-second-to-minutes) compute
|
|
48
|
+
* so a waiter normally gets the winner's fresh write; capped so a truly wedged
|
|
49
|
+
* holder never hangs the CLI (it serves stale instead).
|
|
50
|
+
*/
|
|
51
|
+
const LOCK_RETRIES = { retries: 240, factor: 1, minTimeout: 500, maxTimeout: 500 };
|
|
52
|
+
function cachePath(dir) {
|
|
53
|
+
return path.join(dir, CACHE_FILE);
|
|
54
|
+
}
|
|
55
|
+
/** Read the last snapshot (best-effort; missing/corrupt/wrong-version → null). */
|
|
56
|
+
export function readDoctorOverviewCache(deps = {}) {
|
|
57
|
+
const dir = deps.dir ?? getCacheDir();
|
|
58
|
+
try {
|
|
59
|
+
const parsed = JSON.parse(fs.readFileSync(cachePath(dir), 'utf-8'));
|
|
60
|
+
if (parsed && parsed.version === 1 && typeof parsed.fetchedAt === 'number') {
|
|
61
|
+
return { fetchedAt: parsed.fetchedAt, payload: parsed.payload };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// missing or corrupt — treat as no snapshot
|
|
66
|
+
}
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** Persist a fresh overview payload (best-effort; tmp+rename so reads are atomic). */
|
|
70
|
+
export function writeDoctorOverviewCache(payload, deps = {}) {
|
|
71
|
+
const dir = deps.dir ?? getCacheDir();
|
|
72
|
+
const now = deps.now ?? Date.now;
|
|
73
|
+
try {
|
|
74
|
+
if (!fs.existsSync(dir))
|
|
75
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
76
|
+
const body = { version: 1, fetchedAt: now(), payload };
|
|
77
|
+
const tmp = `${cachePath(dir)}.tmp.${process.pid}`;
|
|
78
|
+
fs.writeFileSync(tmp, JSON.stringify(body, null, 2));
|
|
79
|
+
fs.renameSync(tmp, cachePath(dir));
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// best-effort; a failed write just means the next read falls back to live
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Enter the doctor-overview singleflight gate. Returns a cached string to print,
|
|
87
|
+
* or a lock token telling the caller to compute (and then write + release).
|
|
88
|
+
*
|
|
89
|
+
* Contract:
|
|
90
|
+
* - Fresh snapshot present (and not `forceRefresh`) → `{ cached }`, no lock.
|
|
91
|
+
* - Otherwise exactly one caller holds the lock and gets `{ cached: null,
|
|
92
|
+
* release }`; everyone else blocks on the lock, then (on acquiring it)
|
|
93
|
+
* double-checks and serves the winner's fresh write — or, if the winner runs
|
|
94
|
+
* past the wait budget, serves the last snapshot — rather than recomputing.
|
|
95
|
+
* - Never throws: any IO/lock failure degrades to a compute token or a served
|
|
96
|
+
* snapshot.
|
|
97
|
+
*/
|
|
98
|
+
export async function enterDoctorOverviewGate(opts = {}, deps = {}) {
|
|
99
|
+
const dir = deps.dir ?? getCacheDir();
|
|
100
|
+
const now = deps.now ?? Date.now;
|
|
101
|
+
const freshMs = opts.freshMs ?? DOCTOR_OVERVIEW_FRESH_MS;
|
|
102
|
+
const serveFresh = () => {
|
|
103
|
+
if (opts.forceRefresh)
|
|
104
|
+
return null;
|
|
105
|
+
const c = readDoctorOverviewCache({ dir });
|
|
106
|
+
if (c && now() - c.fetchedAt < freshMs)
|
|
107
|
+
return JSON.stringify(c.payload, null, 2);
|
|
108
|
+
return null;
|
|
109
|
+
};
|
|
110
|
+
// 1. Fast path: a fresh snapshot serves without any compute or lock.
|
|
111
|
+
const fast = serveFresh();
|
|
112
|
+
if (fast !== null)
|
|
113
|
+
return { cached: fast };
|
|
114
|
+
try {
|
|
115
|
+
if (!fs.existsSync(dir))
|
|
116
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
// Can't even make the cache dir — fall back to an unguarded compute.
|
|
120
|
+
return { cached: null, release: () => { } };
|
|
121
|
+
}
|
|
122
|
+
const lockTarget = path.join(dir, LOCK_TARGET_FILE);
|
|
123
|
+
ensureLockTarget(lockTarget);
|
|
124
|
+
// 2. Singleflight via proper-lockfile: one caller holds the lock and computes;
|
|
125
|
+
// the rest block here until it releases.
|
|
126
|
+
let release = null;
|
|
127
|
+
try {
|
|
128
|
+
release = await lockfile.lock(lockTarget, {
|
|
129
|
+
stale: LOCK_STALE_MS,
|
|
130
|
+
retries: LOCK_RETRIES,
|
|
131
|
+
// A peer broke our lock (only possible if we somehow went stale). Don't
|
|
132
|
+
// crash on the async callback; we re-check the cache and serve/recompute.
|
|
133
|
+
onCompromised: () => { },
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
// 3. Winner held the lock past our wait budget. Serve the last snapshot
|
|
138
|
+
// (even if stale) rather than pile on; only if there is genuinely none do
|
|
139
|
+
// we compute unguarded (rare cold-start under sustained load).
|
|
140
|
+
const c = readDoctorOverviewCache({ dir });
|
|
141
|
+
if (c)
|
|
142
|
+
return { cached: JSON.stringify(c.payload, null, 2) };
|
|
143
|
+
return { cached: null, release: () => { } };
|
|
144
|
+
}
|
|
145
|
+
// 4. Acquired. The winner may have written a fresh snapshot while we waited —
|
|
146
|
+
// serve it and release, instead of recomputing.
|
|
147
|
+
const afterWait = serveFresh();
|
|
148
|
+
if (afterWait !== null) {
|
|
149
|
+
// AWAIT, don't fire-and-forget: returning while the lockfile is still on
|
|
150
|
+
// disk makes the next caller retry against a lock that is logically free —
|
|
151
|
+
// the same pile-up this gate exists to prevent, just narrowed to the window
|
|
152
|
+
// between return and unlink. We are already in an async function, so the
|
|
153
|
+
// wait costs one unlink.
|
|
154
|
+
await release().catch(() => { });
|
|
155
|
+
return { cached: afterWait };
|
|
156
|
+
}
|
|
157
|
+
const rel = release;
|
|
158
|
+
let released = false;
|
|
159
|
+
return {
|
|
160
|
+
cached: null,
|
|
161
|
+
release: () => {
|
|
162
|
+
if (released)
|
|
163
|
+
return;
|
|
164
|
+
released = true;
|
|
165
|
+
void rel();
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { spawnSync } from 'child_process';
|
|
11
11
|
import { isControlDevice } from './registry.js';
|
|
12
|
+
import { isSelfHost } from './self-host.js';
|
|
12
13
|
import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
|
|
13
14
|
/** npm dist-tags / semver pins only — rejects shell metacharacters. */
|
|
14
15
|
export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
|
|
@@ -52,7 +53,11 @@ export function planFleetTargets(reg) {
|
|
|
52
53
|
* surface — so their `skip` reason still flows through as an `unreachable` row.
|
|
53
54
|
*/
|
|
54
55
|
export function remoteFleetTargets(planned, self) {
|
|
55
|
-
|
|
56
|
+
// Exclude self by name AND by full identity (tailscale dnsName, loopback): a
|
|
57
|
+
// device referenced by its dnsName slipped past the bare name check and got a
|
|
58
|
+
// remote version+doctor dial back to THIS box, which orphaned on timeout and
|
|
59
|
+
// piled up (RUSH-2114). `isSelfHost` matches every alias the box answers to.
|
|
60
|
+
return planned.filter((t) => t.device.name !== self && !isSelfHost(t.device.name) && t.skip !== 'control');
|
|
56
61
|
}
|
|
57
62
|
/**
|
|
58
63
|
* Decide whether a fleet-health target should skip the expensive version+doctor
|
|
@@ -189,7 +194,7 @@ export function runFleet(targets, cmd, opts = {}) {
|
|
|
189
194
|
continue;
|
|
190
195
|
}
|
|
191
196
|
try {
|
|
192
|
-
const isSelf = opts.self !== undefined && t.device.name === opts.self;
|
|
197
|
+
const isSelf = (opts.self !== undefined && t.device.name === opts.self) || isSelfHost(t.device.name);
|
|
193
198
|
const res = isSelf ? localRunner(cmd) : runner(t.device, cmd);
|
|
194
199
|
const ok = res.code === 0;
|
|
195
200
|
const detail = (res.stderr || res.stdout).trim().slice(0, 200);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `name` refers to the local machine. Case-insensitive and
|
|
3
|
+
* trailing-dot-tolerant. Use this everywhere a `--host`/fleet target is compared
|
|
4
|
+
* against "self" so a tailscale-name reference short-circuits to a local run
|
|
5
|
+
* instead of self-SSHing.
|
|
6
|
+
*/
|
|
7
|
+
export declare function isSelfHost(name: string | undefined | null): boolean;
|
|
8
|
+
/** Test hook: drop the memoized alias set so a fresh registry/env is re-read. */
|
|
9
|
+
export declare function resetSelfHostCache(): void;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Is this hostname the local machine?" — matched against every identity the box
|
|
3
|
+
* answers to, not just its short id.
|
|
4
|
+
*
|
|
5
|
+
* The self-checks that gate `--host` dispatch and the fleet-health fan-out used to
|
|
6
|
+
* compare only against {@link machineId} (the lowercased short hostname, e.g.
|
|
7
|
+
* `zion`). A caller that referenced the box by its **tailscale MagicDNS name**
|
|
8
|
+
* (`zion.tail1a85a1.ts.net`) — which is exactly what `fleetDialTarget` and the
|
|
9
|
+
* Factory floor's `--host` probes use — slipped past the check and SSH'd to the
|
|
10
|
+
* LOCAL box over its own tailscale name. On a loaded machine that self-SSH'd
|
|
11
|
+
* `doctor --json` orphaned on timeout and piled up until the host was crushed
|
|
12
|
+
* (RUSH-2114). Matching the full identity set closes that gap at the source.
|
|
13
|
+
*/
|
|
14
|
+
import { machineId } from '../machine-id.js';
|
|
15
|
+
import { loadDevicesSync } from './registry.js';
|
|
16
|
+
const LOOPBACK = ['localhost', '127.0.0.1', '::1'];
|
|
17
|
+
/** Lowercase + strip a trailing dot (FQDNs are equivalent with or without it). */
|
|
18
|
+
function normalize(name) {
|
|
19
|
+
return name.trim().toLowerCase().replace(/\.$/, '');
|
|
20
|
+
}
|
|
21
|
+
let cached = null;
|
|
22
|
+
/**
|
|
23
|
+
* Every name that resolves to THIS machine: the short id, loopback, and the self
|
|
24
|
+
* device's tailscale dnsName plus its short form. Computed once per process — the
|
|
25
|
+
* self identity does not change under a running CLI — and reads the registry
|
|
26
|
+
* best-effort (an unreadable registry still leaves the short id + loopback).
|
|
27
|
+
*/
|
|
28
|
+
function selfAliases() {
|
|
29
|
+
if (cached)
|
|
30
|
+
return cached;
|
|
31
|
+
const aliases = new Set([machineId(), ...LOOPBACK]);
|
|
32
|
+
try {
|
|
33
|
+
const dns = loadDevicesSync()[machineId()]?.address?.dnsName;
|
|
34
|
+
if (dns) {
|
|
35
|
+
const d = normalize(dns);
|
|
36
|
+
aliases.add(d);
|
|
37
|
+
aliases.add(d.split('.')[0]); // the short form of the FQDN
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* registry unreadable — the short id + loopback aliases still hold */
|
|
42
|
+
}
|
|
43
|
+
cached = aliases;
|
|
44
|
+
return cached;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* True when `name` refers to the local machine. Case-insensitive and
|
|
48
|
+
* trailing-dot-tolerant. Use this everywhere a `--host`/fleet target is compared
|
|
49
|
+
* against "self" so a tailscale-name reference short-circuits to a local run
|
|
50
|
+
* instead of self-SSHing.
|
|
51
|
+
*/
|
|
52
|
+
export function isSelfHost(name) {
|
|
53
|
+
if (!name)
|
|
54
|
+
return false;
|
|
55
|
+
const n = normalize(name);
|
|
56
|
+
return n.length > 0 && selfAliases().has(n);
|
|
57
|
+
}
|
|
58
|
+
/** Test hook: drop the memoized alias set so a fresh registry/env is re-read. */
|
|
59
|
+
export function resetSelfHostCache() {
|
|
60
|
+
cached = null;
|
|
61
|
+
}
|
|
@@ -25,6 +25,7 @@ import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } f
|
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
26
26
|
import { machineId } from '../session/sync/config.js';
|
|
27
27
|
import { loadDevices } from '../devices/registry.js';
|
|
28
|
+
import { isSelfHost } from '../devices/self-host.js';
|
|
28
29
|
import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
|
|
29
30
|
import { platformGroupLabel } from '../devices/health-report.js';
|
|
30
31
|
/**
|
|
@@ -334,7 +335,7 @@ export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
|
|
|
334
335
|
const localRunner = opts.localRunner ?? runLocalCommand;
|
|
335
336
|
const results = await fanOutDevices(targets, async (target) => {
|
|
336
337
|
const cmd = ['agents', ...forwarded];
|
|
337
|
-
const isSelf = target.device.name.toLowerCase() === self.toLowerCase();
|
|
338
|
+
const isSelf = target.device.name.toLowerCase() === self.toLowerCase() || isSelfHost(target.device.name);
|
|
338
339
|
const res = isSelf ? localRunner(cmd) : runner(target.device, cmd);
|
|
339
340
|
if (res.code !== 0) {
|
|
340
341
|
const detail = (res.stderr || res.stdout || 'unreachable').trim().slice(0, 200);
|
|
@@ -443,11 +444,12 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
443
444
|
if (!hostName)
|
|
444
445
|
return false;
|
|
445
446
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
447
|
+
// Match EVERY identity the box answers to (short id, loopback, tailscale
|
|
448
|
+
// dnsName), not just machineId() — a `--host <self-dnsName>` used to slip past a
|
|
449
|
+
// short-hostname-only check and self-SSH (RUSH-2114). Strip the routing flags
|
|
450
|
+
// from process.argv so the local command never sees an unregistered
|
|
451
|
+
// `--host`/`--device` and dies with "unknown option".
|
|
452
|
+
if (isSelfHost(hostName)) {
|
|
451
453
|
const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
452
454
|
process.argv = [process.argv[0], process.argv[1], ...stripped];
|
|
453
455
|
return false;
|
|
Binary file
|
|
Binary file
|
|
@@ -22,6 +22,7 @@ import * as path from 'path';
|
|
|
22
22
|
import { sleepSync } from '../fs-atomic.js';
|
|
23
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
24
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
25
|
+
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
25
26
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
26
27
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
27
28
|
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
@@ -154,17 +155,6 @@ function resolveCliEntry() {
|
|
|
154
155
|
}
|
|
155
156
|
return null;
|
|
156
157
|
}
|
|
157
|
-
function copyAppBundle(src, dest) {
|
|
158
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
159
|
-
if (fs.existsSync(dest))
|
|
160
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
161
|
-
// `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
|
|
162
|
-
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
163
|
-
if (r.status !== 0) {
|
|
164
|
-
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
165
|
-
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
158
|
/**
|
|
169
159
|
* Register the freshly-installed bundle with LaunchServices.
|
|
170
160
|
*
|
|
@@ -232,18 +222,32 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
232
222
|
if (!src)
|
|
233
223
|
return null;
|
|
234
224
|
const dest = installedAppPath();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
225
|
+
// Heal an older install that was ad-hoc re-signed over a Developer ID source:
|
|
226
|
+
// that unstable identity made Accessibility re-prompt on every upgrade.
|
|
227
|
+
const needsInstall = () => {
|
|
228
|
+
if (opts.forceReinstall)
|
|
229
|
+
return true;
|
|
230
|
+
if (!fs.existsSync(dest))
|
|
231
|
+
return true;
|
|
232
|
+
return hasDeveloperIdSignature(src) && !hasDeveloperIdSignature(dest);
|
|
233
|
+
};
|
|
234
|
+
// Fast path: nothing to do.
|
|
235
|
+
if (!needsInstall())
|
|
236
|
+
return installedExecutablePath();
|
|
237
|
+
// Serialize the atomic install so concurrent `agents` invocations (this runs
|
|
238
|
+
// on the darwin startup path) don't race the swap or each re-copy — the
|
|
239
|
+
// stampede that transiently corrupted MenubarHelper.app and tripped the
|
|
240
|
+
// "damaged" dialog.
|
|
241
|
+
withInstallLock(dest, (heartbeat) => {
|
|
242
|
+
if (!needsInstall())
|
|
243
|
+
return;
|
|
244
|
+
copyAppBundle(src, dest);
|
|
245
|
+
heartbeat(); // cp -R done; keep the lock fresh across lsregister
|
|
246
|
+
// A fresh copy is exactly when the bundle's icon can be new (first install) or
|
|
247
|
+
// superseded (upgrade) — register it so LaunchServices knows the bundle and can
|
|
248
|
+
// resolve its AppIcon for the left-hand slot of daemon notifications.
|
|
249
|
+
refreshBundleIconRegistration(dest);
|
|
250
|
+
});
|
|
247
251
|
return installedExecutablePath();
|
|
248
252
|
}
|
|
249
253
|
function xmlEscape(s) {
|
|
@@ -54,6 +54,13 @@ export interface ProjectSessionRollup {
|
|
|
54
54
|
* with the full definition list to show zero-agent projects.
|
|
55
55
|
*/
|
|
56
56
|
export declare function rollupSessionsByProject(defs: ProjectDef[], sessions: ActiveSession[]): Map<string, ProjectSessionRollup>;
|
|
57
|
+
/**
|
|
58
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
59
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
60
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
61
|
+
* makes the reader distrust both numbers.
|
|
62
|
+
*/
|
|
63
|
+
export declare function isDeadStatus(status: string): boolean;
|
|
57
64
|
/** Live vs finished sessions on a project. */
|
|
58
65
|
export interface LiveDeadSplit {
|
|
59
66
|
live: number;
|
|
@@ -93,6 +93,15 @@ export function rollupSessionsByProject(defs, sessions) {
|
|
|
93
93
|
* that are running unattended.
|
|
94
94
|
*/
|
|
95
95
|
const DEAD_STATUSES = new Set(['closed', 'crashed']);
|
|
96
|
+
/**
|
|
97
|
+
* True when a session's status means it is over. Exported so the card can keep
|
|
98
|
+
* the `agents` roster to live sessions: the headline and the `dead` row already
|
|
99
|
+
* separate the two, and a roster that reads `crashed ×25` beside `23 live`
|
|
100
|
+
* makes the reader distrust both numbers.
|
|
101
|
+
*/
|
|
102
|
+
export function isDeadStatus(status) {
|
|
103
|
+
return DEAD_STATUSES.has(status);
|
|
104
|
+
}
|
|
96
105
|
/**
|
|
97
106
|
* Split a rollup's sessions into what is working and what is wreckage.
|
|
98
107
|
*
|
package/dist/lib/projects.d.ts
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
package/dist/lib/projects.js
CHANGED
|
@@ -5,8 +5,17 @@
|
|
|
5
5
|
* directory by pure convention (`<projectRoot>/<slug>`, see `project-root.ts`).
|
|
6
6
|
* This module adds editable definitions on top: one YAML file per project under
|
|
7
7
|
* `~/.agents/projects/<name>.yaml`, sitting beside the existing `routines/`,
|
|
8
|
-
* `monitors/`, and `teams/` dirs in the user repo
|
|
9
|
-
*
|
|
8
|
+
* `monitors/`, and `teams/` dirs in the user repo.
|
|
9
|
+
*
|
|
10
|
+
* That location makes definitions SYNCABLE, not automatically synced: they ride
|
|
11
|
+
* the user repo only once they are committed to it, via `agents repo push user`
|
|
12
|
+
* (`agents push` was removed). Until then the directory is untracked, and a
|
|
13
|
+
* reconcile that cleans the working tree deletes it — observed twice on one
|
|
14
|
+
* machine, taking four definitions with it each time. The recovery is an
|
|
15
|
+
* orphaned `chore(local): save …-sync drift` commit, which is not a guarantee:
|
|
16
|
+
* unreachable objects are collected. Say "commit them" rather than "for free".
|
|
17
|
+
*
|
|
18
|
+
* A defined project can name itself
|
|
10
19
|
* independently of its folder, bind more than one repo, pin a monorepo subpath,
|
|
11
20
|
* describe context subdirectories an agent should start from, carry a Linear
|
|
12
21
|
* link and external integrations, and set an explicit default path.
|
|
@@ -235,11 +244,40 @@ export function projectBasePath(def, forRemote) {
|
|
|
235
244
|
}
|
|
236
245
|
function projectRootsAbs(defs) {
|
|
237
246
|
const out = [];
|
|
238
|
-
|
|
239
|
-
const raw = def.root ?? def.defaultPath;
|
|
247
|
+
const push = (name, raw) => {
|
|
240
248
|
if (!raw)
|
|
241
|
-
|
|
242
|
-
out.push({ name
|
|
249
|
+
return;
|
|
250
|
+
out.push({ name, abs: path.resolve(expandLocalHome(raw)) });
|
|
251
|
+
};
|
|
252
|
+
for (const def of defs) {
|
|
253
|
+
// `root` says where the CHECKOUT is; `defaultPath` says which work is this
|
|
254
|
+
// project's. For a monorepo subproject those differ, and only the narrower
|
|
255
|
+
// one is a membership claim — a project scoped to `rush/apps/cli` does not
|
|
256
|
+
// own `rush/apps/web`.
|
|
257
|
+
//
|
|
258
|
+
// The old `root ?? defaultPath` collapsed such a subproject onto the
|
|
259
|
+
// monorepo root, the same path its umbrella anchors at, so the longest-match
|
|
260
|
+
// tiebreak below had nothing to separate them and a session in
|
|
261
|
+
// `rush/apps/cli` counted toward whichever definition was listed first.
|
|
262
|
+
const rootAbs = def.root ? path.resolve(expandLocalHome(def.root)) : undefined;
|
|
263
|
+
const defaultAbs = def.defaultPath ? path.resolve(expandLocalHome(def.defaultPath)) : undefined;
|
|
264
|
+
const narrowed = !!(rootAbs && defaultAbs && defaultAbs !== rootAbs && isUnder(defaultAbs, rootAbs));
|
|
265
|
+
// A narrowed project's root is a WEAK claim: it still covers the rest of the
|
|
266
|
+
// checkout when nobody else wants it, but yields to any project that claims
|
|
267
|
+
// a path outright. Dropping it entirely regressed the single-project case —
|
|
268
|
+
// `add foo --root ~/src/foo --path apps/web` stopped attributing work
|
|
269
|
+
// anywhere else in its own repo, and `--path` means where agents START, not
|
|
270
|
+
// which work counts.
|
|
271
|
+
if (rootAbs)
|
|
272
|
+
out.push({ name: def.name, abs: rootAbs, weak: narrowed });
|
|
273
|
+
if (defaultAbs && defaultAbs !== rootAbs)
|
|
274
|
+
out.push({ name: def.name, abs: defaultAbs });
|
|
275
|
+
for (const r of def.repos ?? []) {
|
|
276
|
+
push(def.name, r.path);
|
|
277
|
+
// A repo pinned to a monorepo subpath anchors at that subpath too.
|
|
278
|
+
if (r.path && r.subpath)
|
|
279
|
+
push(def.name, path.join(expandLocalHome(r.path), r.subpath));
|
|
280
|
+
}
|
|
243
281
|
}
|
|
244
282
|
return out;
|
|
245
283
|
}
|
|
@@ -269,13 +307,23 @@ export function projectNameForCwd(cwd, defs) {
|
|
|
269
307
|
const abs = path.resolve(expandLocalHome(cwd));
|
|
270
308
|
let best;
|
|
271
309
|
let bestLen = -1;
|
|
272
|
-
|
|
273
|
-
|
|
310
|
+
let weakBest;
|
|
311
|
+
let weakLen = -1;
|
|
312
|
+
for (const { name, abs: root, weak } of projectRootsAbs(defs)) {
|
|
313
|
+
if (!isUnder(abs, root))
|
|
314
|
+
continue;
|
|
315
|
+
if (weak) {
|
|
316
|
+
if (root.length > weakLen) {
|
|
317
|
+
weakBest = name;
|
|
318
|
+
weakLen = root.length;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
else if (root.length > bestLen) {
|
|
274
322
|
best = name;
|
|
275
323
|
bestLen = root.length;
|
|
276
324
|
}
|
|
277
325
|
}
|
|
278
|
-
return best;
|
|
326
|
+
return best ?? weakBest;
|
|
279
327
|
}
|
|
280
328
|
/**
|
|
281
329
|
* The canonical project label for a cwd, for every surface that buckets work by
|
|
Binary file
|
|
Binary file
|
|
@@ -18,6 +18,7 @@ import { createHash } from 'crypto';
|
|
|
18
18
|
import * as fs from 'fs';
|
|
19
19
|
import * as os from 'os';
|
|
20
20
|
import * as path from 'path';
|
|
21
|
+
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
21
22
|
const APP_BUNDLE_NAME = 'Agents CLI.app';
|
|
22
23
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
23
24
|
let installRootOverride = null;
|
|
@@ -107,19 +108,6 @@ function spctlAssess(appPath) {
|
|
|
107
108
|
});
|
|
108
109
|
return { ok: r.status === 0, output: (r.stderr || r.stdout || '').toString().trim() };
|
|
109
110
|
}
|
|
110
|
-
function copyAppBundle(src, dest) {
|
|
111
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
112
|
-
if (fs.existsSync(dest))
|
|
113
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
114
|
-
// `cp -R` preserves the bundle's signature, symlinks, and resource forks.
|
|
115
|
-
// `fs.cpSync({recursive: true})` works on simple trees but has historically
|
|
116
|
-
// mishandled extended attributes on `.app` bundles, breaking codesign.
|
|
117
|
-
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
118
|
-
if (r.status !== 0) {
|
|
119
|
-
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
120
|
-
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
111
|
/**
|
|
124
112
|
* Idempotent install. Copies the bundled `.app` to the stable user path. Skips
|
|
125
113
|
* if the destination already exists, `codesign --verify` passes, AND the
|
|
@@ -135,25 +123,34 @@ function copyAppBundle(src, dest) {
|
|
|
135
123
|
export function ensureKeychainHelperInstalled(opts = {}) {
|
|
136
124
|
assertDarwin();
|
|
137
125
|
const dest = installedAppPath();
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
126
|
+
const upToDate = () => fs.existsSync(dest) && codesignVerify(dest).ok && !installedHelperIsStale();
|
|
127
|
+
// Fast path: already installed, valid, and current — no lock, no copy.
|
|
128
|
+
if (!opts.forceReinstall && upToDate())
|
|
129
|
+
return;
|
|
130
|
+
// Serialize the install so concurrent `agents` invocations don't race the
|
|
131
|
+
// atomic swap (and don't each run a redundant `cp`). The re-check inside the
|
|
132
|
+
// lock means a burst of callers copies once, not N times — the stampede that
|
|
133
|
+
// transiently corrupted the bundle and tripped the "damaged" dialog.
|
|
134
|
+
withInstallLock(dest, (heartbeat) => {
|
|
135
|
+
if (!opts.forceReinstall && upToDate())
|
|
141
136
|
return;
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
137
|
+
const src = sourceAppPath();
|
|
138
|
+
copyAppBundle(src, dest);
|
|
139
|
+
heartbeat(); // cp -R done; keep the lock fresh across the codesign/spctl spawns
|
|
140
|
+
const verify = codesignVerify(dest);
|
|
141
|
+
if (!verify.ok) {
|
|
142
|
+
throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
|
|
143
|
+
'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
|
|
144
|
+
}
|
|
145
|
+
heartbeat(); // codesign done; spctl does a network Gatekeeper lookup — refresh again
|
|
146
|
+
const assess = spctlAssess(dest);
|
|
147
|
+
if (!assess.ok) {
|
|
148
|
+
// Warn, do not fail. Gatekeeper ticket lookup needs network; offline
|
|
149
|
+
// installs and CI runners commonly fail this check. The ACL semantics
|
|
150
|
+
// we care about depend on codesign, not spctl.
|
|
151
|
+
process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
|
|
152
|
+
}
|
|
153
|
+
});
|
|
157
154
|
}
|
|
158
155
|
/**
|
|
159
156
|
* Return the absolute path to the helper executable. If the installed bundle
|