@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.
@@ -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
- return planned.filter((t) => t.device.name !== self && t.skip !== 'control');
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
- // `machineId()` is the same self-identifier the device registry and session
447
- // sync use (lowercased short hostname); compare case-insensitively.
448
- // Strip the routing flags from process.argv so the local command never sees
449
- // an unregistered `--host`/`--device` and dies with "unknown option".
450
- if (hostName.toLowerCase() === machineId()) {
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;
@@ -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
- if (!opts.forceReinstall && fs.existsSync(dest)) {
236
- const sourceIsDevId = hasDeveloperIdSignature(src);
237
- const destIsDevId = hasDeveloperIdSignature(dest);
238
- if (!(sourceIsDevId && !destIsDevId)) {
239
- return installedExecutablePath();
240
- }
241
- }
242
- copyAppBundle(src, dest);
243
- // A fresh copy is exactly when the bundle's icon can be new (first install) or
244
- // superseded (upgrade) — register it so LaunchServices knows the bundle and can
245
- // resolve its AppIcon for the left-hand slot of daemon notifications.
246
- refreshBundleIconRegistration(dest);
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
  *
@@ -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 (so definitions sync across
9
- * machines for free via `agents push/pull`). A defined project can name itself
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.
@@ -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 (so definitions sync across
9
- * machines for free via `agents push/pull`). A defined project can name itself
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
- for (const def of defs) {
239
- const raw = def.root ?? def.defaultPath;
247
+ const push = (name, raw) => {
240
248
  if (!raw)
241
- continue;
242
- out.push({ name: def.name, abs: path.resolve(expandLocalHome(raw)) });
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
- for (const { name, abs: root } of projectRootsAbs(defs)) {
273
- if (isUnder(abs, root) && root.length > bestLen) {
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
@@ -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
- if (!opts.forceReinstall && fs.existsSync(dest)) {
139
- const { ok } = codesignVerify(dest);
140
- if (ok && !installedHelperIsStale())
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
- const src = sourceAppPath();
144
- copyAppBundle(src, dest);
145
- const verify = codesignVerify(dest);
146
- if (!verify.ok) {
147
- throw new Error(`Installed helper failed codesign verification at ${dest}.\n${verify.output}\n` +
148
- 'The bundle may be corrupted. Try `agents helper install` to reinstall, or reinstall agents-cli.');
149
- }
150
- const assess = spctlAssess(dest);
151
- if (!assess.ok) {
152
- // Warn, do not fail. Gatekeeper ticket lookup needs network; offline
153
- // installs and CI runners commonly fail this check. The ACL semantics
154
- // we care about depend on codesign, not spctl.
155
- process.stderr.write(`agents-cli: notarization check (spctl) did not pass for ${dest}: ${assess.output}\n`);
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