@phnx-labs/agents-cli 1.22.98 → 1.22.100

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.
@@ -14,7 +14,7 @@
14
14
  * by the cross-process refresh lease so an explicit CLI refresh and the daemon
15
15
  * timer converge on the same published result.
16
16
  */
17
- import { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
17
+ import { AUTH_PROBE_MAX_AGE_MS, authTargetKey } from './auth-health.js';
18
18
  export { AUTH_PROBE_MAX_AGE_MS } from './auth-health.js';
19
19
  export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
20
20
  return value.row.capturedAt >= minimumCapturedAt
@@ -36,12 +36,32 @@ export function isFreshFleetAuthSnapshot(value, minimumCapturedAt) {
36
36
  * publishes every tick — it does not ride that endpoint.
37
37
  */
38
38
  /**
39
- * True when every cached auth row was probed within {@link AUTH_PROBE_MAX_AGE_MS}
40
- * — i.e. reusing them would not let a verdict get staler than one probe window.
41
- * Empty cache is never fresh (nothing to reuse). Pure — unit-tested.
39
+ * The cached rows still backed by a home on this device, by `authTargetKey`.
40
+ *
41
+ * A row for an uninstalled version is an ORPHAN: the probe enumerates installed
42
+ * homes + account slots, so nothing re-probes it and its `checkedAt` is frozen
43
+ * at whatever the last probe left. Pure — unit-tested.
44
+ */
45
+ export function installedAuthRows(authRows, installedTargets) {
46
+ return authRows.filter((r) => installedTargets.has(authTargetKey(r.agent, r.version)));
47
+ }
48
+ /**
49
+ * True when every cached auth row FOR AN INSTALLED HOME was probed within
50
+ * {@link AUTH_PROBE_MAX_AGE_MS} — i.e. reusing them would not let a verdict get
51
+ * staler than one probe window. No installed row is never fresh (nothing to
52
+ * reuse). Pure — unit-tested.
53
+ *
54
+ * Orphan rows are excluded rather than counted stale (PHNX-4051). Counting them
55
+ * made this permanently false on any box that had ever uninstalled a version:
56
+ * the orphan can never be re-probed, so the tick live-probed the rate-limited
57
+ * `/api/oauth/usage` for every account every 3 minutes, re-arming the per-account
58
+ * 429 backoff and parking the usage refresher — the RUSH-2998 failure the reuse
59
+ * window exists to prevent. Observed on yosemite-m0 (rows dated Sep 2 / Sep 6 for
60
+ * uninstalled Claude versions).
42
61
  */
43
- export function isCachedFleetAuthProbeFresh(authRows, now, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
44
- return authRows.length > 0 && authRows.every((r) => now - r.health.checkedAt < maxAgeMs);
62
+ export function isCachedFleetAuthProbeFresh(authRows, now, installedTargets, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
63
+ const installed = installedAuthRows(authRows, installedTargets);
64
+ return installed.length > 0 && installed.every((r) => now - r.health.checkedAt < maxAgeMs);
45
65
  }
46
66
  /**
47
67
  * Whether the tick may reuse the cached auth verdict instead of re-probing the
@@ -51,8 +71,8 @@ export function isCachedFleetAuthProbeFresh(authRows, now, maxAgeMs = AUTH_PROBE
51
71
  * revoked account (the second `runFleetPing` call site, RUSH-2998). Pure —
52
72
  * unit-tested so that inversion cannot regress unnoticed.
53
73
  */
54
- export function shouldReuseCachedAuthProbe(force, cached, now, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
55
- return !force && isCachedFleetAuthProbeFresh(cached, now, maxAgeMs);
74
+ export function shouldReuseCachedAuthProbe(force, cached, now, installedTargets, maxAgeMs = AUTH_PROBE_MAX_AGE_MS) {
75
+ return !force && isCachedFleetAuthProbeFresh(cached, now, installedTargets, maxAgeMs);
56
76
  }
57
77
  /**
58
78
  * Fleet cache warm: publish THIS host's row for the caches `agents fleet
@@ -67,7 +87,7 @@ export async function refreshLocalFleetAuthState(opts) {
67
87
  const force = opts?.force === true;
68
88
  const signal = opts?.signal;
69
89
  const { machineId } = await import('./machine-id.js');
70
- const { probeLocalFleetAuth, readFleetAuthRows, writeFleetAuthRows } = await import('./auth-health.js');
90
+ const { probeLocalFleetAuth, readFleetAuthRows, writeFleetAuthRows, localAuthTargetKeys } = await import('./auth-health.js');
71
91
  const { getCliVersion } = await import('./version.js');
72
92
  const self = machineId();
73
93
  const requestedAt = Date.now();
@@ -91,11 +111,16 @@ export async function refreshLocalFleetAuthState(opts) {
91
111
  // Re-probe the rate-limited /oauth/usage endpoint at most every
92
112
  // AUTH_PROBE_MAX_AGE_MS; reuse the last real verdict in between (RUSH-2998).
93
113
  // Fleet status publishes every tick regardless — it does not ride that endpoint.
114
+ const installed = localAuthTargetKeys();
94
115
  const cached = readFleetAuthRows(self);
95
- const reuse = shouldReuseCachedAuthProbe(force, cached, requestedAt);
96
- const authRows = reuse ? cached : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force, signal });
97
- if (!reuse)
98
- writeFleetAuthRows(self, authRows);
116
+ const live = installedAuthRows(cached, installed);
117
+ const reuse = shouldReuseCachedAuthProbe(force, cached, requestedAt, installed);
118
+ const authRows = reuse ? live : await probeLocalFleetAuth({ cliVersion: getCliVersion(), forceLive: force, signal });
119
+ // Write when we probed, and ALSO when the cache holds orphan rows for homes
120
+ // that are gone: a reusing tick is the common case, so leaving the prune on
121
+ // the probe branch would keep them in `agents view` indefinitely (PHNX-4051).
122
+ if (!reuse || live.length !== cached.length)
123
+ writeFleetAuthRows(self, authRows, installed);
99
124
  const row = await publishLocalFleetStatus(self);
100
125
  return { row, authRows };
101
126
  },
@@ -1,5 +1,12 @@
1
1
  export declare const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45000;
2
2
  export declare const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
3
+ export declare const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES: number;
4
+ /**
5
+ * When the shared-state exchange last completed successfully, in epoch ms, or
6
+ * `null` if it never has on this box (no marker yet) or the marker is unreadable.
7
+ * Reads the default daemon-dir marker the periodic exchange writes.
8
+ */
9
+ export declare function readLastSuccessfulExchangeMs(): number | null;
3
10
  export interface BoundedProcessResult {
4
11
  code: number | null;
5
12
  stdout: string;
@@ -19,9 +19,46 @@ import { machineId } from './session/sync/config.js';
19
19
  import { getDaemonDir, getUserAgentsDir } from './state.js';
20
20
  export const FLEET_SHARED_REPO_SYNC_DEADLINE_MS = 45_000;
21
21
  export const FLEET_SHARED_REPO_KILL_GRACE_MS = 250;
22
- const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
22
+ export const FLEET_SHARED_REPO_OUTPUT_MAX_BYTES = 1024 * 1024;
23
23
  const FLEET_SHARED_REPO_PUSH_ATTEMPTS = 3;
24
24
  const FLEET_SHARED_REPO_REBASE_CLEANUP_RESERVE_MS = 5_000;
25
+ /**
26
+ * The exchange records its last SUCCESSFUL completion beside its lock so a
27
+ * sibling service (auth-sync) can gate work on the freshness of the peer state
28
+ * this exchange delivers into the local checkout (PHNX-4051). auth-sync's
29
+ * credential pushes read the peer verdicts the LAST usage-sync exchange wrote; if
30
+ * that exchange has not landed within a tick interval, those verdicts are stale
31
+ * and pushing off them would act on a peer that may already hold the key.
32
+ */
33
+ function exchangeMarkerPath(lockPath) {
34
+ return `${lockPath}.last-success`;
35
+ }
36
+ function writeExchangeMarker(lockPath, nowMs = Date.now()) {
37
+ try {
38
+ const p = exchangeMarkerPath(lockPath);
39
+ fs.mkdirSync(path.dirname(p), { recursive: true });
40
+ fs.writeFileSync(p, JSON.stringify({ at: nowMs }), 'utf-8');
41
+ }
42
+ catch {
43
+ // Best-effort telemetry — never fail the exchange over its own marker.
44
+ }
45
+ }
46
+ /**
47
+ * When the shared-state exchange last completed successfully, in epoch ms, or
48
+ * `null` if it never has on this box (no marker yet) or the marker is unreadable.
49
+ * Reads the default daemon-dir marker the periodic exchange writes.
50
+ */
51
+ export function readLastSuccessfulExchangeMs() {
52
+ try {
53
+ const lockPath = path.join(getDaemonDir(), 'fleet-shared-repo-sync');
54
+ const raw = fs.readFileSync(exchangeMarkerPath(lockPath), 'utf-8');
55
+ const at = JSON.parse(raw).at;
56
+ return typeof at === 'number' && Number.isFinite(at) ? at : null;
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ }
25
62
  function signalProcessTree(child, signal) {
26
63
  const pid = child.pid;
27
64
  if (!pid)
@@ -177,9 +214,30 @@ function retainedAutostash(stashList, objectId) {
177
214
  * cannot create a fresh collision — so nothing is silently destroyed. Then let
178
215
  * the rebase check out origin's version. A file we cannot clear is left in
179
216
  * place; the rebase may still abort, no worse than today and never losing data.
217
+ *
218
+ * The scan is scoped to the TRACKED ROOTS origin carries — the top-level entries
219
+ * of `git ls-tree --name-only origin/<branch>` — NOT the whole working tree
220
+ * (PHNX-4051). Only a file origin tracks can be overwritten by the rebase
221
+ * checkout, so a tracked root is the tightest superset of the collidable paths:
222
+ * every path origin tracks stays in scope (a stale untracked `projects/rush.yaml`
223
+ * under a tracked `projects/` root is still cleared), while an untracked-ONLY tree
224
+ * origin never tracks — artifacts-cli's revision store
225
+ * (`~/.agents/artifact-history/`, ~11k files / >1 MiB of paths) is the live case —
226
+ * is never a tracked root, so it never enters the `git ls-files --others` listing.
227
+ * An unbounded listing there blows past {@link FLEET_SHARED_REPO_OUTPUT_MAX_BYTES}
228
+ * and kills the exchange on every tick; scoping to the tracked roots keeps the
229
+ * PHNX-3923 coverage whole while an unrelated untracked tree cannot push the
230
+ * listing over the cap. `ls-tree`'s own output is bounded — it lists only the root
231
+ * entries, never the recursive tree.
180
232
  */
181
233
  async function clearCollidingUntracked(git, root, branch) {
182
- const others = await git(['ls-files', '--others', '--exclude-standard', '-z']);
234
+ const tree = await git(['ls-tree', '--name-only', '-z', `origin/${branch}`]);
235
+ if (tree.code !== 0)
236
+ return { error: tree };
237
+ const trackedRoots = tree.stdout.split('\0').filter(Boolean);
238
+ if (trackedRoots.length === 0)
239
+ return { cleared: 0, backedUp: [], backupDir: null };
240
+ const others = await git(['ls-files', '--others', '--exclude-standard', '-z', '--', ...trackedRoots]);
183
241
  if (others.code !== 0)
184
242
  return { error: others };
185
243
  const relPaths = others.stdout.split('\0').filter(Boolean);
@@ -403,7 +461,12 @@ export async function syncFleetSharedStateRepo(options = {}) {
403
461
  retries: { retries: 20, factor: 1, minTimeout: 100, maxTimeout: 100 },
404
462
  onCompromised: logAndContinueOnLockCompromised('fleet shared repo sync'),
405
463
  });
406
- return await performFleetSharedRepoSync(root, device, timeoutMs);
464
+ const result = await performFleetSharedRepoSync(root, device, timeoutMs);
465
+ // Record freshness only on a real success, beside the lock so a test-redirected
466
+ // lockPath keeps its marker isolated too (PHNX-4051).
467
+ if (result.success)
468
+ writeExchangeMarker(lockPath);
469
+ return result;
407
470
  }
408
471
  catch (err) {
409
472
  return {
@@ -36,7 +36,7 @@
36
36
  * helper release now, off this table entirely.
37
37
  */
38
38
  export const HELPER_RELEASES = {
39
- menubar: { tagPrefix: 'menubar', floor: '1.1.1' },
39
+ menubar: { tagPrefix: 'menubar', floor: '1.1.4' },
40
40
  'computer-mac': { tagPrefix: 'computer-mac', floor: '1.0.0' },
41
41
  // The Windows helper is a bare .exe, not an .app bundle, so it does not share
42
42
  // helper-download.ts's zip/codesign/notarize machinery -- but it has the same
@@ -18,6 +18,7 @@
18
18
  */
19
19
  import type { AgentId } from './types.js';
20
20
  import { type HealResult } from './heal.js';
21
+ import { type SlotProjection } from './accounts/slots.js';
21
22
  import { type HookRuntimeRepairReport } from './hooks/install.js';
22
23
  import { type RemediateStaleInstallsResult, type FindAgentsCliInstallsOptions } from './self-update.js';
23
24
  export interface HookRewireResult {
@@ -36,6 +37,13 @@ export interface RepairAfterSyncReport {
36
37
  hookRuntimeRepair: HookRuntimeRepairReport;
37
38
  /** Only populated on the umbrella (no-agent) path; null otherwise. */
38
39
  staleInstallPurge: RemediateStaleInstallsResult | null;
40
+ /**
41
+ * Account slots re-projected from their harness's default version home
42
+ * (PHNX-3940): one entry per slot, with the artifacts pruned because the
43
+ * source no longer carries them. Errors are per-harness sentences.
44
+ */
45
+ slotProjection: SlotProjection[];
46
+ slotProjectionErrors: string[];
39
47
  }
40
48
  /**
41
49
  * Test/override hook that scopes the destructive stale-CLI purge to injected
@@ -1,6 +1,7 @@
1
1
  import { AGENTS, ALL_AGENT_IDS } from './agents.js';
2
2
  import chalk from 'chalk';
3
3
  import { heal, healChangedAnything } from './heal.js';
4
+ import { projectAccountSlots } from './accounts/slots.js';
4
5
  import { checkVersionHookWiring, registerHooksToSettings, repairManagedHookRuntimeArtifacts, } from './hooks/install.js';
5
6
  import { getVersionHomePath, listInstalledVersions, isVersionIsolated, } from './installations/versions.js';
6
7
  import { invalidateDoctorOverviewCache } from './devices/doctor-overview-cache.js';
@@ -126,11 +127,27 @@ export async function repairAfterSync(opts) {
126
127
  const staleInstallPurge = (!opts.agent && opts.pruneClis === true)
127
128
  ? purgeStaleAgentsCliCopies(opts.purgeInjection)
128
129
  : null;
130
+ // Account slots follow the version home (PHNX-3940). `agents accounts add`
131
+ // projects a slot once; without this every later sync would update the
132
+ // version home and leave each slot on the resources it was cloned with.
133
+ const slotProjection = [];
134
+ const slotProjectionErrors = [];
135
+ const slotAgents = opts.agent ? [opts.agent] : ALL_AGENT_IDS.filter((a) => listInstalledVersions(a).length > 0);
136
+ for (const agent of slotAgents) {
137
+ try {
138
+ slotProjection.push(...projectAccountSlots(agent));
139
+ }
140
+ catch (err) {
141
+ slotProjectionErrors.push(`${agent}: ${err.message}`);
142
+ }
143
+ }
129
144
  const report = {
130
145
  heal: healResult,
131
146
  hookRewire,
132
147
  hookRuntimeRepair,
133
148
  staleInstallPurge,
149
+ slotProjection,
150
+ slotProjectionErrors,
134
151
  };
135
152
  if (repairChangedAnything(report))
136
153
  invalidateDoctorOverviewCache();
@@ -142,7 +159,9 @@ export function repairChangedAnything(report) {
142
159
  report.hookRewire.some((r) => r.rewired > 0 || r.remaining > 0 || r.failure !== undefined) ||
143
160
  report.hookRuntimeRepair.attempts.length > 0 ||
144
161
  (report.staleInstallPurge !== null &&
145
- (report.staleInstallPurge.removed.length > 0 || report.staleInstallPurge.failed.length > 0)));
162
+ (report.staleInstallPurge.removed.length > 0 || report.staleInstallPurge.failed.length > 0)) ||
163
+ report.slotProjection.length > 0 ||
164
+ report.slotProjectionErrors.length > 0);
146
165
  }
147
166
  /**
148
167
  * True when the RECONCILE repair left a per-version problem a human must fix — an
@@ -174,6 +193,8 @@ export function repairAfterSyncJson(report) {
174
193
  hookRewire: report.hookRewire,
175
194
  hookRuntimeRepair: report.hookRuntimeRepair,
176
195
  staleInstallPurge: report.staleInstallPurge,
196
+ slotProjection: report.slotProjection,
197
+ slotProjectionErrors: report.slotProjectionErrors,
177
198
  hadFailures: repairHadFailures(report),
178
199
  };
179
200
  }
@@ -264,4 +285,14 @@ export function renderRepairAfterSync(report, log = (s) => console.log(s)) {
264
285
  renderHookRuntimeRepairText(report.hookRuntimeRepair, log);
265
286
  if (report.staleInstallPurge)
266
287
  renderStaleInstallPurgeText(report.staleInstallPurge, log);
288
+ renderSlotProjectionText(report, log);
289
+ }
290
+ function renderSlotProjectionText(report, log) {
291
+ for (const slot of report.slotProjection) {
292
+ const pruned = slot.pruned.length > 0 ? chalk.gray(` — removed ${slot.pruned.join(', ')}`) : '';
293
+ log(` ${chalk.green('slot')} account ${chalk.bold(slot.name)} ${chalk.gray(`← ${slot.from}`)}${pruned}`);
294
+ }
295
+ for (const err of report.slotProjectionErrors) {
296
+ log(` ${chalk.yellow('slot')} ${err}`);
297
+ }
267
298
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@phnx-labs/agents-cli",
3
- "version": "1.22.98",
3
+ "version": "1.22.100",
4
4
  "description": "One CLI for all your AI coding agents - versions, config, cloud dispatch, sessions, and teams (now with first-class Grok Build CLI support)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",