@phnx-labs/agents-cli 1.22.99 → 1.22.101
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 +12 -0
- package/dist/commands/exec.js +2 -0
- package/dist/commands/status.js +3 -1
- package/dist/lib/auth-health.d.ts +56 -4
- package/dist/lib/auth-health.js +93 -31
- package/dist/lib/browser/arc-discovery.d.ts +1 -1
- package/dist/lib/browser/arc-discovery.js +14 -4
- package/dist/lib/daemon/auth-sync-service.d.ts +21 -4
- package/dist/lib/daemon/auth-sync-service.js +49 -21
- package/dist/lib/daemon/usage-sync-service.d.ts +26 -7
- package/dist/lib/daemon/usage-sync-service.js +35 -8
- package/dist/lib/daemon-ticks.d.ts +22 -5
- package/dist/lib/daemon-ticks.js +38 -13
- package/dist/lib/drift-sync.js +5 -2
- package/dist/lib/exec.js +2 -0
- package/dist/lib/fleet-shared-repo-sync.d.ts +7 -0
- package/dist/lib/fleet-shared-repo-sync.js +66 -3
- package/dist/lib/helper-versions.js +1 -1
- package/dist/lib/sync-status.d.ts +6 -0
- package/dist/lib/sync-status.js +11 -0
- package/dist/lib/system-run-sync.d.ts +11 -0
- package/dist/lib/system-run-sync.js +114 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.101
|
|
4
|
+
|
|
5
|
+
- **`agents sync status` names what drifted, and a merged system-layer skill or subagent reaches the next `agents run` on every box (PHNX-4056).** The status readout said `11 drifted` per install and nothing more, although `AgentVersionStatus.resources` already carried the rows; both human renderers (`agents sync status`, the drift summary `agents sync` prints) now list each drifted or missing resource as `drifted skills/artifacts` under its install line, with the differ's detail when it has one. And when `~/.agents/.system/skills` or `subagents` changed since the last launch of an (agent, version), `agents run` rewrites those resources into the version home through the registered writers before spawning, filtered by the active resource profile like the full sync, with the same stat-tier sentinel shape as the rules run-sync: on 2026-09-10 a merged skill change left 31 of 33 installed copies stale until the 4-hour umbrella autosync, because the shim's `--launch` path mirrors project resources only. Source: `cli/src/lib/system-run-sync.ts`, `cli/src/lib/sync-status.ts`, `cli/src/commands/status.ts`, `cli/src/lib/drift-sync.ts`, `cli/src/lib/exec.ts`, `cli/src/commands/exec.ts`.
|
|
6
|
+
|
|
7
|
+
## 1.22.100
|
|
8
|
+
|
|
9
|
+
- **The daemon's 20-minute auth-probe reuse works again on a box that has uninstalled a version (PHNX-4051).** `isCachedFleetAuthProbeFresh` required EVERY cached auth row to be under 20 minutes old, including rows left behind for versions that are no longer installed. Nothing re-probes those — the probe only walks homes that exist — so their timestamp froze and the check was permanently false: the 3-minute tick live-probed the rate-limited `/api/oauth/usage` for every account on every tick, re-arming the per-account 429 backoff and keeping the usage refresher parked (the RUSH-2998 failure the reuse window exists to prevent; observed on a worker holding rows dated Sep 2 / Sep 6 for uninstalled Claude versions). Freshness now considers only rows for currently installed `(agent, version)` homes and account slots, and the tick prunes the orphan rows when it writes the cache, so `agents view` and fleet status stop reporting a verdict for a version that is gone. `agents devices ping [--strict]` still forces a genuinely live probe. Source: `cli/src/lib/daemon-ticks.ts` (`installedAuthRows`, `isCachedFleetAuthProbeFresh`, `refreshLocalFleetAuthState`), `cli/src/lib/auth-health.ts` (`authTargetKey`, `enumerateLocalAuthInstalls`, `localAuthTargetKeys`, `writeFleetAuthRows`).
|
|
10
|
+
|
|
11
|
+
- **Fleet usage-sync transport no longer starves `--device auto` (PHNX-4051).** The daemon's shared-state git exchange failed on every tick two ways, so workers never received a fresh Claude usage snapshot and the 40-min placement gate turned every worker into "no ready device". Fixed both at the source: (1) the usage-sync tick is now the single committer — it also publishes the reserved-auth verdict, and auth-sync no longer runs its own `syncFleetSharedStateRepo`, so the two ticks can't contend for the one shared-repo lock ("Lock file is already being held"); (2) the untracked-collision scan is scoped to the tracked roots origin carries (`git ls-tree origin/<branch>`) — every path origin tracks stays in scope, so no path the rebase can overwrite is missed, while an untracked-only tree origin never tracks (like artifacts-cli's `~/.agents/artifact-history/`) can never push `git ls-files --others` past the 1 MiB output cap. auth-sync's credential pushes are also gated on the exchange's freshness marker, so they never push off peer state older than one tick interval. Source: `cli/src/lib/fleet-shared-repo-sync.ts`, `cli/src/lib/daemon/usage-sync-service.ts`, `cli/src/lib/daemon/auth-sync-service.ts`.
|
|
12
|
+
|
|
13
|
+
- **An untitled Arc Space no longer hides every Arc Space from `agents browser`.** `parseSidebarSpaces` treated the display-only Space title as part of a Space's identity and rejected the whole sidebar when one Space had none, so `agents browser start` warned that a configured `arc-gmail` "can't launch on this machine" while `agents browser profiles doctor arc-gmail` saw Arc and the Space. Identity is the stable Space id and its profile binding; a Space Arc stored without a title (key absent or `null`) is now listed as `arc-space` (two or more get their Space-id prefix appended, as same-titled Spaces already do). A mismatched id, a duplicate id, an unknown or malformed profile mapping, and a title that is present but not text still fail discovery closed. Source: `cli/src/lib/browser/arc-discovery.ts`, `cli/src/lib/browser/arc-discovery.test.ts`, `cli/src/lib/browser/testdata/arc/`.
|
|
14
|
+
|
|
3
15
|
## 1.22.99
|
|
4
16
|
|
|
5
17
|
- **`agents setup secrets` installs the published secrets CLI instead of only printing a hint (PHNX-3989).** A missing `secrets` binary tries `agents clis install secrets` then a pinned `npm i -g @phnx-labs/secrets-cli@0.1.2`, then hands off to `secrets migrate`. Docs point at the standalone engine; the broker is `secrets _agent-run` on macOS, not the agents daemon. Source: `cli/src/commands/setup-secrets.ts`, `cli/docs/secrets.md`.
|
package/dist/commands/exec.js
CHANGED
|
@@ -26,6 +26,7 @@ import { spawnSync } from 'child_process';
|
|
|
26
26
|
import { randomUUID } from 'crypto';
|
|
27
27
|
import { isSessionTrackedAgent } from '../lib/session/types.js';
|
|
28
28
|
import { applyActiveRulesPresetAtRun } from '../lib/rules/run-sync.js';
|
|
29
|
+
import { applySystemResourcesAtRun } from '../lib/system-run-sync.js';
|
|
29
30
|
import { handleBroadcast } from './run-broadcast.js';
|
|
30
31
|
import { bootMark } from '../lib/boot-profile.js';
|
|
31
32
|
/** Validate a caller-supplied session id before it reaches tmux, paths, indexes, or remote dispatch. */
|
|
@@ -2714,6 +2715,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
|
|
|
2714
2715
|
// path itself; there's no version home to sync into.
|
|
2715
2716
|
if (defaultVersion) {
|
|
2716
2717
|
applyActiveRulesPresetAtRun(agent, defaultVersion, getVersionHomePath(agent, defaultVersion));
|
|
2718
|
+
applySystemResourcesAtRun(agent, defaultVersion, getVersionHomePath(agent, defaultVersion));
|
|
2717
2719
|
}
|
|
2718
2720
|
bootMark('rules-sync:done');
|
|
2719
2721
|
// Login preflight (advisory, warn + continue). On a local INTERACTIVE
|
package/dist/commands/status.js
CHANGED
|
@@ -13,7 +13,7 @@ import chalk from 'chalk';
|
|
|
13
13
|
import { AGENTS } from '../lib/agents.js';
|
|
14
14
|
import { setHelpSections } from '../lib/help.js';
|
|
15
15
|
import { addHostOption } from '../lib/hosts/option.js';
|
|
16
|
-
import { computeSyncStatus } from '../lib/sync-status.js';
|
|
16
|
+
import { computeSyncStatus, formatDriftRows } from '../lib/sync-status.js';
|
|
17
17
|
import { promptDriftSync } from '../lib/drift-sync.js';
|
|
18
18
|
import { resolveConfiguredModel } from '../lib/models.js';
|
|
19
19
|
import { resolveSurface } from './utils.js';
|
|
@@ -93,6 +93,8 @@ export function registerStatusCommand(syncCmd) {
|
|
|
93
93
|
+ (v.isDefault ? chalk.gray(' (default)') : '');
|
|
94
94
|
const pad = ' '.repeat(Math.max(1, 34 - plain.length));
|
|
95
95
|
console.log(` ${shown}${pad}${versionSummary(v)}`);
|
|
96
|
+
for (const line of formatDriftRows(v))
|
|
97
|
+
console.log(chalk.gray(` ${line}`));
|
|
96
98
|
}
|
|
97
99
|
if (status.totals.orphan > 0) {
|
|
98
100
|
console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
|
|
@@ -134,6 +134,12 @@ export declare function formatCheckedAge(checkedAt: number, now?: number): strin
|
|
|
134
134
|
export declare function authAccountLabel(info: Pick<AccountInfo, 'email' | 'accountId' | 'userId'> | null | undefined): string | undefined;
|
|
135
135
|
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
|
|
136
136
|
export declare function authCacheKey(host: string, agent: AgentId | string, version: string): string;
|
|
137
|
+
/**
|
|
138
|
+
* Host-independent identity of one probe target — the (agent, version) pair
|
|
139
|
+
* {@link authCacheKey} is keyed by. The separator cannot appear in either half,
|
|
140
|
+
* so an agent id can never run into a version the way a `:` join allows.
|
|
141
|
+
*/
|
|
142
|
+
export declare function authTargetKey(agent: AgentId | string, version: string): string;
|
|
137
143
|
/**
|
|
138
144
|
* The `version` slot an account SLOT occupies in the auth cache and the probe
|
|
139
145
|
* rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
|
|
@@ -156,10 +162,38 @@ export interface SlotAuthInstall extends FleetAuthInstall {
|
|
|
156
162
|
* gone is skipped: there is nothing to probe and the row would only say so.
|
|
157
163
|
*/
|
|
158
164
|
export declare function enumerateSlotInstalls(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agentIds: readonly AgentId[]): SlotAuthInstall[];
|
|
165
|
+
/** One probe target on this device: an installed version home, or an account slot. */
|
|
166
|
+
export interface LocalAuthInstall extends FleetAuthInstall {
|
|
167
|
+
home: string;
|
|
168
|
+
accountId?: string;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Every (agent, version) home the local auth probe covers on this device —
|
|
172
|
+
* installed version homes plus account slots. {@link probeLocalFleetAuth} probes
|
|
173
|
+
* exactly this set, so it is also the answer to "which cached rows are still
|
|
174
|
+
* backed by something on disk" (PHNX-4051).
|
|
175
|
+
*/
|
|
176
|
+
export declare function enumerateLocalAuthInstalls(meta: Pick<Meta, 'accounts' | 'deviceAccounts'>, agentIds: readonly AgentId[]): LocalAuthInstall[];
|
|
177
|
+
/**
|
|
178
|
+
* {@link authTargetKey} for every local probe target — what a cached row must
|
|
179
|
+
* match to still be about this device. A row outside it is an ORPHAN: its home
|
|
180
|
+
* was uninstalled, so nothing will ever re-probe it and its `checkedAt` is
|
|
181
|
+
* frozen at whatever the last probe left (PHNX-4051).
|
|
182
|
+
*/
|
|
183
|
+
export declare function localAuthTargetKeys(agentIds?: readonly AgentId[]): Set<string>;
|
|
159
184
|
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
|
|
160
185
|
export declare function readAuthHealthCache(): Record<string, AuthHealth>;
|
|
161
186
|
/** Read one entry, or null. */
|
|
162
187
|
export declare function readAuthHealth(host: string, agent: AgentId | string, version: string): AuthHealth | null;
|
|
188
|
+
/**
|
|
189
|
+
* Split a cache key back into the install it names, for one host. Returns null
|
|
190
|
+
* for a key that belongs to another host or does not name a known agent — the
|
|
191
|
+
* one place the `host:agent:version` join is undone.
|
|
192
|
+
*/
|
|
193
|
+
export declare function parseAuthCacheKey(key: string, host: string): {
|
|
194
|
+
agent: AgentId;
|
|
195
|
+
version: string;
|
|
196
|
+
} | null;
|
|
163
197
|
/** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
|
|
164
198
|
export declare function readFleetAuthRows(host: string): AuthProbeRow[];
|
|
165
199
|
/**
|
|
@@ -170,8 +204,15 @@ export declare function readFleetAuthRows(host: string): AuthProbeRow[];
|
|
|
170
204
|
* behaviour promised by the `error` doc on AuthVerdict ("keep the last known
|
|
171
205
|
* one"). Pure, so it's unit-tested directly. */
|
|
172
206
|
export declare function mergeAuthHealthEntries(current: Record<string, AuthHealth>, incoming: Record<string, AuthHealth>): Record<string, AuthHealth>;
|
|
173
|
-
/**
|
|
174
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Merge one or more entries into the cache (best-effort write).
|
|
209
|
+
*
|
|
210
|
+
* `drop` removes entries the writer knows are gone — it runs on the PRE-merge
|
|
211
|
+
* cache, so an incoming row always wins over a drop of the same key and the two
|
|
212
|
+
* can never fight. Only a writer that knows the full truth for the keys it drops
|
|
213
|
+
* may pass one (see {@link writeFleetAuthRows}).
|
|
214
|
+
*/
|
|
215
|
+
export declare function writeAuthHealthEntries(entries: Record<string, AuthHealth>, drop?: (key: string) => boolean): void;
|
|
175
216
|
/**
|
|
176
217
|
* Complete a live auth probe for one (agent, home). For claude/kimi/droid this
|
|
177
218
|
* hits the provider — unless the account's usage cache already holds a fresh
|
|
@@ -261,5 +302,16 @@ export declare function probeLocalFleetAuth(opts?: {
|
|
|
261
302
|
*/
|
|
262
303
|
signal?: AbortSignal;
|
|
263
304
|
}): Promise<AuthProbeRow[]>;
|
|
264
|
-
/**
|
|
265
|
-
|
|
305
|
+
/**
|
|
306
|
+
* Persist a host's probed rows into the cache (keyed by host+agent+version).
|
|
307
|
+
*
|
|
308
|
+
* `installed` — the {@link authTargetKey} set of homes that still exist on
|
|
309
|
+
* `host` ({@link localAuthTargetKeys}) — prunes this host's ORPHAN entries: rows
|
|
310
|
+
* for a version that has since been uninstalled. Nothing re-probes those, so
|
|
311
|
+
* they never age out on their own; they froze the reuse window permanently false
|
|
312
|
+
* and kept surfacing in `agents view` / fleet status (PHNX-4051). Only a caller
|
|
313
|
+
* that enumerated `host`'s real installs may pass it — the `fleet ping` fan-out
|
|
314
|
+
* writes a PEER's rows and cannot enumerate that box's homes, so it omits it and
|
|
315
|
+
* merges as before.
|
|
316
|
+
*/
|
|
317
|
+
export declare function writeFleetAuthRows(host: string, rows: AuthProbeRow[], installed?: ReadonlySet<string>): void;
|
package/dist/lib/auth-health.js
CHANGED
|
@@ -21,7 +21,7 @@ import * as path from 'path';
|
|
|
21
21
|
import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
|
|
22
22
|
import { listNativeAccounts } from './account-registry.js';
|
|
23
23
|
import { readSlots } from './accounts/slots.js';
|
|
24
|
-
import { getCacheDir } from './state.js';
|
|
24
|
+
import { getCacheDir, readMeta } from './state.js';
|
|
25
25
|
import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
|
|
26
26
|
import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
|
|
27
27
|
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
|
|
@@ -220,6 +220,14 @@ export function authAccountLabel(info) {
|
|
|
220
220
|
export function authCacheKey(host, agent, version) {
|
|
221
221
|
return `${host}:${agent}:${version}`;
|
|
222
222
|
}
|
|
223
|
+
/**
|
|
224
|
+
* Host-independent identity of one probe target — the (agent, version) pair
|
|
225
|
+
* {@link authCacheKey} is keyed by. The separator cannot appear in either half,
|
|
226
|
+
* so an agent id can never run into a version the way a `:` join allows.
|
|
227
|
+
*/
|
|
228
|
+
export function authTargetKey(agent, version) {
|
|
229
|
+
return `${agent}@${version}`;
|
|
230
|
+
}
|
|
223
231
|
/**
|
|
224
232
|
* The `version` slot an account SLOT occupies in the auth cache and the probe
|
|
225
233
|
* rows: `slot:<accountId>`. A slot is a HOME-shaped dir, not an installed
|
|
@@ -251,6 +259,32 @@ export function enumerateSlotInstalls(meta, agentIds) {
|
|
|
251
259
|
}
|
|
252
260
|
return out;
|
|
253
261
|
}
|
|
262
|
+
/**
|
|
263
|
+
* Every (agent, version) home the local auth probe covers on this device —
|
|
264
|
+
* installed version homes plus account slots. {@link probeLocalFleetAuth} probes
|
|
265
|
+
* exactly this set, so it is also the answer to "which cached rows are still
|
|
266
|
+
* backed by something on disk" (PHNX-4051).
|
|
267
|
+
*/
|
|
268
|
+
export function enumerateLocalAuthInstalls(meta, agentIds) {
|
|
269
|
+
const installs = [];
|
|
270
|
+
for (const agent of agentIds) {
|
|
271
|
+
for (const version of listInstalledVersions(agent)) {
|
|
272
|
+
installs.push({ agent, version, home: getVersionHomePath(agent, version), account: undefined });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
for (const slot of enumerateSlotInstalls(meta, agentIds))
|
|
276
|
+
installs.push(slot);
|
|
277
|
+
return installs;
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* {@link authTargetKey} for every local probe target — what a cached row must
|
|
281
|
+
* match to still be about this device. A row outside it is an ORPHAN: its home
|
|
282
|
+
* was uninstalled, so nothing will ever re-probe it and its `checkedAt` is
|
|
283
|
+
* frozen at whatever the last probe left (PHNX-4051).
|
|
284
|
+
*/
|
|
285
|
+
export function localAuthTargetKeys(agentIds = ALL_AGENT_IDS) {
|
|
286
|
+
return new Set(enumerateLocalAuthInstalls(readMeta(), agentIds).map((i) => authTargetKey(i.agent, i.version)));
|
|
287
|
+
}
|
|
254
288
|
function cacheFilePath() {
|
|
255
289
|
return path.join(getCacheDir(), '.auth-health.json');
|
|
256
290
|
}
|
|
@@ -270,23 +304,34 @@ export function readAuthHealthCache() {
|
|
|
270
304
|
export function readAuthHealth(host, agent, version) {
|
|
271
305
|
return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
|
|
272
306
|
}
|
|
307
|
+
/**
|
|
308
|
+
* Split a cache key back into the install it names, for one host. Returns null
|
|
309
|
+
* for a key that belongs to another host or does not name a known agent — the
|
|
310
|
+
* one place the `host:agent:version` join is undone.
|
|
311
|
+
*/
|
|
312
|
+
export function parseAuthCacheKey(key, host) {
|
|
313
|
+
const prefix = `${host}:`;
|
|
314
|
+
if (!key.startsWith(prefix))
|
|
315
|
+
return null;
|
|
316
|
+
const identity = key.slice(prefix.length);
|
|
317
|
+
const separator = identity.indexOf(':');
|
|
318
|
+
if (separator <= 0)
|
|
319
|
+
return null;
|
|
320
|
+
const agent = identity.slice(0, separator);
|
|
321
|
+
if (!ALL_AGENT_IDS.includes(agent))
|
|
322
|
+
return null;
|
|
323
|
+
return { agent: agent, version: identity.slice(separator + 1) };
|
|
324
|
+
}
|
|
273
325
|
/** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
|
|
274
326
|
export function readFleetAuthRows(host) {
|
|
275
|
-
const prefix = `${host}:`;
|
|
276
327
|
const rows = [];
|
|
277
328
|
for (const [key, health] of Object.entries(readAuthHealthCache())) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
const identity = key.slice(prefix.length);
|
|
281
|
-
const separator = identity.indexOf(':');
|
|
282
|
-
if (separator <= 0)
|
|
283
|
-
continue;
|
|
284
|
-
const agent = identity.slice(0, separator);
|
|
285
|
-
if (!ALL_AGENT_IDS.includes(agent))
|
|
329
|
+
const install = parseAuthCacheKey(key, host);
|
|
330
|
+
if (!install)
|
|
286
331
|
continue;
|
|
287
332
|
rows.push({
|
|
288
|
-
agent: agent,
|
|
289
|
-
version:
|
|
333
|
+
agent: install.agent,
|
|
334
|
+
version: install.version,
|
|
290
335
|
account: health.account,
|
|
291
336
|
accountId: health.accountId,
|
|
292
337
|
health,
|
|
@@ -310,15 +355,26 @@ export function mergeAuthHealthEntries(current, incoming) {
|
|
|
310
355
|
}
|
|
311
356
|
return merged;
|
|
312
357
|
}
|
|
313
|
-
/**
|
|
314
|
-
|
|
358
|
+
/**
|
|
359
|
+
* Merge one or more entries into the cache (best-effort write).
|
|
360
|
+
*
|
|
361
|
+
* `drop` removes entries the writer knows are gone — it runs on the PRE-merge
|
|
362
|
+
* cache, so an incoming row always wins over a drop of the same key and the two
|
|
363
|
+
* can never fight. Only a writer that knows the full truth for the keys it drops
|
|
364
|
+
* may pass one (see {@link writeFleetAuthRows}).
|
|
365
|
+
*/
|
|
366
|
+
export function writeAuthHealthEntries(entries, drop) {
|
|
315
367
|
try {
|
|
316
368
|
const target = cacheFilePath();
|
|
317
369
|
ensureLockTarget(target, JSON.stringify({ version: 1, entries: {} }));
|
|
318
370
|
withFileLock(target, () => {
|
|
371
|
+
let current = readAuthHealthCache();
|
|
372
|
+
if (drop) {
|
|
373
|
+
current = Object.fromEntries(Object.entries(current).filter(([key]) => !drop(key)));
|
|
374
|
+
}
|
|
319
375
|
const merged = {
|
|
320
376
|
version: 1,
|
|
321
|
-
entries: mergeAuthHealthEntries(
|
|
377
|
+
entries: mergeAuthHealthEntries(current, entries),
|
|
322
378
|
};
|
|
323
379
|
atomicWriteFileSync(target, JSON.stringify(merged, null, 2));
|
|
324
380
|
});
|
|
@@ -447,20 +503,9 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
447
503
|
// each one's account label. getAccountInfo is a local credential-file read
|
|
448
504
|
// (no network), so this fan-out is cheap and cannot contribute to the rate
|
|
449
505
|
// limit the probe grouping below exists to avoid.
|
|
450
|
-
const
|
|
451
|
-
import('./state.js'),
|
|
452
|
-
import('./account-registry.js'),
|
|
453
|
-
]);
|
|
506
|
+
const { findNativeAccountByIdentity } = await import('./account-registry.js');
|
|
454
507
|
const meta = readMeta();
|
|
455
|
-
const installs =
|
|
456
|
-
for (const agent of agentIds) {
|
|
457
|
-
for (const version of listInstalledVersions(agent)) {
|
|
458
|
-
installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
for (const slot of enumerateSlotInstalls(meta, agentIds)) {
|
|
462
|
-
installs.push({ ...slot, info: null });
|
|
463
|
-
}
|
|
508
|
+
const installs = enumerateLocalAuthInstalls(meta, agentIds).map((inst) => ({ ...inst, info: null }));
|
|
464
509
|
await Promise.all(installs.map(async (inst) => {
|
|
465
510
|
inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
|
|
466
511
|
inst.account = authAccountLabel(inst.info);
|
|
@@ -491,11 +536,28 @@ export async function probeLocalFleetAuth(opts) {
|
|
|
491
536
|
}));
|
|
492
537
|
return perGroup.flat();
|
|
493
538
|
}
|
|
494
|
-
/**
|
|
495
|
-
|
|
539
|
+
/**
|
|
540
|
+
* Persist a host's probed rows into the cache (keyed by host+agent+version).
|
|
541
|
+
*
|
|
542
|
+
* `installed` — the {@link authTargetKey} set of homes that still exist on
|
|
543
|
+
* `host` ({@link localAuthTargetKeys}) — prunes this host's ORPHAN entries: rows
|
|
544
|
+
* for a version that has since been uninstalled. Nothing re-probes those, so
|
|
545
|
+
* they never age out on their own; they froze the reuse window permanently false
|
|
546
|
+
* and kept surfacing in `agents view` / fleet status (PHNX-4051). Only a caller
|
|
547
|
+
* that enumerated `host`'s real installs may pass it — the `fleet ping` fan-out
|
|
548
|
+
* writes a PEER's rows and cannot enumerate that box's homes, so it omits it and
|
|
549
|
+
* merges as before.
|
|
550
|
+
*/
|
|
551
|
+
export function writeFleetAuthRows(host, rows, installed) {
|
|
496
552
|
const entries = {};
|
|
497
553
|
for (const row of rows) {
|
|
498
554
|
entries[authCacheKey(host, row.agent, row.version)] = row.health;
|
|
499
555
|
}
|
|
500
|
-
|
|
556
|
+
const drop = installed
|
|
557
|
+
? (key) => {
|
|
558
|
+
const install = parseAuthCacheKey(key, host);
|
|
559
|
+
return install != null && !installed.has(authTargetKey(install.agent, install.version));
|
|
560
|
+
}
|
|
561
|
+
: undefined;
|
|
562
|
+
writeAuthHealthEntries(entries, drop);
|
|
501
563
|
}
|
|
@@ -65,6 +65,13 @@ function parseProfileId(value, spaceId) {
|
|
|
65
65
|
}
|
|
66
66
|
return profileId;
|
|
67
67
|
}
|
|
68
|
+
function parseTitle(value, spaceId) {
|
|
69
|
+
if (value === undefined || value === null)
|
|
70
|
+
return '';
|
|
71
|
+
if (typeof value === 'string')
|
|
72
|
+
return value;
|
|
73
|
+
return { error: `Arc Space ${JSON.stringify(spaceId)} has a non-text title` };
|
|
74
|
+
}
|
|
68
75
|
export function parseSidebarSpaces(sidebarPath) {
|
|
69
76
|
let parsed;
|
|
70
77
|
try {
|
|
@@ -101,9 +108,12 @@ export function parseSidebarSpaces(sidebarPath) {
|
|
|
101
108
|
if (typeof value.id !== 'string' || value.id !== encodedId) {
|
|
102
109
|
return { error: `Arc Space key ${JSON.stringify(encodedId)} does not match its stable id` };
|
|
103
110
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
111
|
+
// Identity is the stable id and the profile binding. The title is
|
|
112
|
+
// display-only: Arc omits it for an untitled Space, which must still be
|
|
113
|
+
// listed (as `arc-space`) instead of taking every other Space down.
|
|
114
|
+
const title = parseTitle(value.title, encodedId);
|
|
115
|
+
if (typeof title !== 'string')
|
|
116
|
+
return title;
|
|
107
117
|
if (ids.has(encodedId)) {
|
|
108
118
|
return { error: `Arc Space ${JSON.stringify(encodedId)} appears more than once` };
|
|
109
119
|
}
|
|
@@ -111,7 +121,7 @@ export function parseSidebarSpaces(sidebarPath) {
|
|
|
111
121
|
if (typeof profileId !== 'string')
|
|
112
122
|
return profileId;
|
|
113
123
|
ids.add(encodedId);
|
|
114
|
-
spaces.push({ id: encodedId, title
|
|
124
|
+
spaces.push({ id: encodedId, title, profileId });
|
|
115
125
|
}
|
|
116
126
|
}
|
|
117
127
|
return spaces;
|
|
@@ -1,10 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This tick owns only the NON-git duties of auth sync: it materializes worker
|
|
5
|
+
* slots from durable keys already on the box, then (on the elected headed
|
|
6
|
+
* publisher) provisions peers whose LAST-DELIVERED verdict says `missing` by
|
|
7
|
+
* pushing the credential over SSH. The secret never enters Git.
|
|
8
|
+
*
|
|
9
|
+
* It no longer runs its own `syncFleetSharedStateRepo` (PHNX-4051). The verdict
|
|
10
|
+
* this tick's decisions read is published and delivered by the single git
|
|
11
|
+
* committer, the usage-sync tick — folding both publishes into one caller is
|
|
12
|
+
* what stops the two ticks (30 s apart) from contending for the one shared-repo
|
|
13
|
+
* lock and starving the usage snapshot workers depend on. This tick keeps its
|
|
14
|
+
* own deadline and circuit breaker, so a hung peer SSH push parks only auth-sync
|
|
15
|
+
* and never the usage delivery. The pushes read the peer verdicts the last
|
|
16
|
+
* usage-sync exchange wrote into the local checkout; they are idempotent
|
|
17
|
+
* (push only when a peer is missing a key), so acting on at-most-one-tick-old
|
|
18
|
+
* data converges exactly as the in-tick exchange did. To keep "at-most-one-tick-
|
|
19
|
+
* old" true, the pushes are gated on the exchange's freshness marker
|
|
20
|
+
* (`readLastSuccessfulExchangeMs`): when the last usage-sync exchange is missing
|
|
21
|
+
* or older than one usage-sync tick interval (`USAGE_SYNC_TICK_MS`, the
|
|
22
|
+
* producer's cadence — not this service's own `AUTH_SYNC_TICK_MS`), this tick
|
|
23
|
+
* skips the pushes and WARNs instead of acting on peer state that may no longer
|
|
24
|
+
* hold.
|
|
8
25
|
*/
|
|
9
26
|
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
10
27
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
@@ -1,12 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reserved `auth` bundle fleet sync as a `PeriodicService` (PHNX-2371).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* This tick owns only the NON-git duties of auth sync: it materializes worker
|
|
5
|
+
* slots from durable keys already on the box, then (on the elected headed
|
|
6
|
+
* publisher) provisions peers whose LAST-DELIVERED verdict says `missing` by
|
|
7
|
+
* pushing the credential over SSH. The secret never enters Git.
|
|
8
|
+
*
|
|
9
|
+
* It no longer runs its own `syncFleetSharedStateRepo` (PHNX-4051). The verdict
|
|
10
|
+
* this tick's decisions read is published and delivered by the single git
|
|
11
|
+
* committer, the usage-sync tick — folding both publishes into one caller is
|
|
12
|
+
* what stops the two ticks (30 s apart) from contending for the one shared-repo
|
|
13
|
+
* lock and starving the usage snapshot workers depend on. This tick keeps its
|
|
14
|
+
* own deadline and circuit breaker, so a hung peer SSH push parks only auth-sync
|
|
15
|
+
* and never the usage delivery. The pushes read the peer verdicts the last
|
|
16
|
+
* usage-sync exchange wrote into the local checkout; they are idempotent
|
|
17
|
+
* (push only when a peer is missing a key), so acting on at-most-one-tick-old
|
|
18
|
+
* data converges exactly as the in-tick exchange did. To keep "at-most-one-tick-
|
|
19
|
+
* old" true, the pushes are gated on the exchange's freshness marker
|
|
20
|
+
* (`readLastSuccessfulExchangeMs`): when the last usage-sync exchange is missing
|
|
21
|
+
* or older than one usage-sync tick interval (`USAGE_SYNC_TICK_MS`, the
|
|
22
|
+
* producer's cadence — not this service's own `AUTH_SYNC_TICK_MS`), this tick
|
|
23
|
+
* skips the pushes and WARNs instead of acting on peer state that may no longer
|
|
24
|
+
* hold.
|
|
8
25
|
*/
|
|
9
26
|
import { BasePeriodicService } from './service.js';
|
|
27
|
+
import { USAGE_SYNC_TICK_MS } from './usage-sync-service.js';
|
|
10
28
|
const AUTH_SYNC_TICK_MS = 15 * 60_000;
|
|
11
29
|
const AUTH_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
12
30
|
const AUTH_SYNC_KICKOFF_MS = 60_000;
|
|
@@ -22,13 +40,14 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
22
40
|
// Nothing to release — the supervisor's timer teardown is the only cleanup.
|
|
23
41
|
}
|
|
24
42
|
async onTick(ctx) {
|
|
25
|
-
const {
|
|
43
|
+
const { reconcileLocalWorkerSlots, syncReservedAuthBundle, syncReservedStores, } = await import('../secrets-policy.js');
|
|
26
44
|
// Worker-side slot materialization FIRST (PHNX-3940 T6): for each registered
|
|
27
45
|
// account whose durable key is already on this box, create the HOME-shaped
|
|
28
46
|
// slot the picker and spawn read. It touches only local state — the registry
|
|
29
|
-
// copy and the file-backed store — so it never waits on
|
|
30
|
-
//
|
|
31
|
-
// `git rebase timed out` postponed every slot on the box by another
|
|
47
|
+
// copy and the file-backed store — so it never waits on any git exchange. It
|
|
48
|
+
// used to sit after this tick's own `if (!transport.success) return`, so a
|
|
49
|
+
// single `git rebase timed out` postponed every slot on the box by another
|
|
50
|
+
// tick; that exchange has since moved to the usage-sync tick (PHNX-4051).
|
|
32
51
|
// Self-gated on device role: a headed box returns immediately.
|
|
33
52
|
try {
|
|
34
53
|
const slots = reconcileLocalWorkerSlots();
|
|
@@ -48,20 +67,29 @@ export class AuthSyncService extends BasePeriodicService {
|
|
|
48
67
|
catch (err) {
|
|
49
68
|
ctx.log('WARN', `auth-sync: worker slot reconcile: ${err.message}`);
|
|
50
69
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
70
|
+
// The verdict this tick's pushes read is published by the usage-sync tick,
|
|
71
|
+
// the single git committer (PHNX-4051), and delivered into the local checkout
|
|
72
|
+
// by its exchange. The pushes below act on that last-delivered peer state, so
|
|
73
|
+
// they are only sound while that state is fresh. Gate them on the exchange's
|
|
74
|
+
// freshness: if the last successful usage-sync exchange is missing or older
|
|
75
|
+
// than one tick interval, the delivered peer verdicts may be stale — a peer
|
|
76
|
+
// that already received the key still reads `missing`, or a cleared `missing`
|
|
77
|
+
// still reads stale — so skip the pushes and WARN rather than pushing off it.
|
|
78
|
+
// The worker-slot reconcile above is NOT gated: it reads only local durable
|
|
79
|
+
// keys, never delivered peer verdicts.
|
|
80
|
+
const { readLastSuccessfulExchangeMs } = await import('../fleet-shared-repo-sync.js');
|
|
81
|
+
const lastExchangeMs = readLastSuccessfulExchangeMs();
|
|
82
|
+
const ageMs = lastExchangeMs === null ? null : Date.now() - lastExchangeMs;
|
|
83
|
+
// The threshold is the PRODUCER's cadence (USAGE_SYNC_TICK_MS), not this
|
|
84
|
+
// service's own AUTH_SYNC_TICK_MS: the delivered peer verdicts are refreshed
|
|
85
|
+
// once per usage-sync exchange, so "stale" is measured against that tick, not
|
|
86
|
+
// this one. The two constants are equal today, but sourcing it here keeps the
|
|
87
|
+
// gate tracking the usage-sync cadence if either is ever retuned alone.
|
|
88
|
+
if (ageMs === null || ageMs > USAGE_SYNC_TICK_MS) {
|
|
89
|
+
const age = ageMs === null ? 'never completed' : `last completed ${Math.round(ageMs / 1000)}s ago`;
|
|
90
|
+
ctx.log('WARN', `auth-sync: skipping credential push — usage-sync exchange ${age} (need one within ${Math.round(USAGE_SYNC_TICK_MS / 1000)}s); peer verdicts may be stale`);
|
|
64
91
|
return;
|
|
92
|
+
}
|
|
65
93
|
const result = await syncReservedAuthBundle();
|
|
66
94
|
if (result.pushed.length > 0) {
|
|
67
95
|
ctx.log('INFO', `auth-sync: pushed auth to ${result.pushed.join(', ')}`);
|
|
@@ -2,19 +2,38 @@
|
|
|
2
2
|
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
3
|
* PHNX-3792 session mirror).
|
|
4
4
|
*
|
|
5
|
-
* This is the
|
|
5
|
+
* This is the ONE tick that owns the bounded Git exchange over the fleet-synced
|
|
6
6
|
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
7
|
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
8
|
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
-
* usage snapshot,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* usage snapshot, EVERY box's lightweight session digests (PHNX-3792), and the
|
|
10
|
+
* reserved-auth readiness verdict (PHNX-4051, folded in from the auth-sync tick
|
|
11
|
+
* so a single caller holds the shared-repo lock per tick); (2) runs one
|
|
12
|
+
* serialized, timeout-bounded commit/rebase/push; (3) consumes the peer fields
|
|
13
|
+
* the exchange delivered — a worker merges usage newest-wins, and every
|
|
14
|
+
* non-worker box folds peers' session digests into its local index so the picker
|
|
15
|
+
* renders remote-host previews inline. No tick opens a device-to-device SSH mesh.
|
|
16
|
+
*
|
|
17
|
+
* Why the auth verdict publishes here (PHNX-4051): auth-sync used to run its OWN
|
|
18
|
+
* `syncFleetSharedStateRepo`, so on every box two ticks 30 s apart contended for
|
|
19
|
+
* the one `proper-lockfile` lock (20×100 ms ≈ 2 s of retries) while a real
|
|
20
|
+
* fetch/rebase/push on a drifted repo runs far longer — the usage tick then
|
|
21
|
+
* failed with "Lock file is already being held" (zion logged it 95× in 24 h) and
|
|
22
|
+
* workers never received a fresh usage snapshot, which the 40-min placement gate
|
|
23
|
+
* turned into "no ready device". Folding the auth verdict into this single
|
|
24
|
+
* committer removes the second caller entirely. Auth-sync keeps its non-git
|
|
25
|
+
* duties (worker-slot reconcile + the credential SSH pushes) under its own
|
|
26
|
+
* deadline and circuit breaker.
|
|
15
27
|
*/
|
|
16
28
|
import { BasePeriodicService, type DaemonContext } from './service.js';
|
|
17
29
|
import type { DaemonServiceId } from '../daemon-services.js';
|
|
30
|
+
/**
|
|
31
|
+
* The usage-sync tick cadence. Exported because auth-sync's credential-push
|
|
32
|
+
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
33
|
+
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
34
|
+
* must track this constant rather than its own equal-by-coincidence literal.
|
|
35
|
+
*/
|
|
36
|
+
export declare const USAGE_SYNC_TICK_MS: number;
|
|
18
37
|
export declare class UsageSyncService extends BasePeriodicService {
|
|
19
38
|
readonly id: DaemonServiceId;
|
|
20
39
|
readonly intervalMs: number;
|
|
@@ -2,19 +2,37 @@
|
|
|
2
2
|
* Fleet shared-state sync as a `PeriodicService` (PHNX-3392 usage-sync,
|
|
3
3
|
* PHNX-3792 session mirror).
|
|
4
4
|
*
|
|
5
|
-
* This is the
|
|
5
|
+
* This is the ONE tick that owns the bounded Git exchange over the fleet-synced
|
|
6
6
|
* user repo, so every non-secret daemon-state field rides it rather than opening
|
|
7
7
|
* a second committer. Each tick: (1) publishes this box's own fields into its
|
|
8
8
|
* conflict-free `devices/<device>/daemon-state.json` — a headed box's Claude
|
|
9
|
-
* usage snapshot,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* usage snapshot, EVERY box's lightweight session digests (PHNX-3792), and the
|
|
10
|
+
* reserved-auth readiness verdict (PHNX-4051, folded in from the auth-sync tick
|
|
11
|
+
* so a single caller holds the shared-repo lock per tick); (2) runs one
|
|
12
|
+
* serialized, timeout-bounded commit/rebase/push; (3) consumes the peer fields
|
|
13
|
+
* the exchange delivered — a worker merges usage newest-wins, and every
|
|
14
|
+
* non-worker box folds peers' session digests into its local index so the picker
|
|
15
|
+
* renders remote-host previews inline. No tick opens a device-to-device SSH mesh.
|
|
16
|
+
*
|
|
17
|
+
* Why the auth verdict publishes here (PHNX-4051): auth-sync used to run its OWN
|
|
18
|
+
* `syncFleetSharedStateRepo`, so on every box two ticks 30 s apart contended for
|
|
19
|
+
* the one `proper-lockfile` lock (20×100 ms ≈ 2 s of retries) while a real
|
|
20
|
+
* fetch/rebase/push on a drifted repo runs far longer — the usage tick then
|
|
21
|
+
* failed with "Lock file is already being held" (zion logged it 95× in 24 h) and
|
|
22
|
+
* workers never received a fresh usage snapshot, which the 40-min placement gate
|
|
23
|
+
* turned into "no ready device". Folding the auth verdict into this single
|
|
24
|
+
* committer removes the second caller entirely. Auth-sync keeps its non-git
|
|
25
|
+
* duties (worker-slot reconcile + the credential SSH pushes) under its own
|
|
26
|
+
* deadline and circuit breaker.
|
|
15
27
|
*/
|
|
16
28
|
import { BasePeriodicService } from './service.js';
|
|
17
|
-
|
|
29
|
+
/**
|
|
30
|
+
* The usage-sync tick cadence. Exported because auth-sync's credential-push
|
|
31
|
+
* freshness gate is the CONSUMER of this producer's cadence: it skips the
|
|
32
|
+
* pushes when the last exchange is older than one usage-sync interval, so it
|
|
33
|
+
* must track this constant rather than its own equal-by-coincidence literal.
|
|
34
|
+
*/
|
|
35
|
+
export const USAGE_SYNC_TICK_MS = 15 * 60_000;
|
|
18
36
|
const USAGE_SYNC_DEADLINE_MS = 2 * 60_000;
|
|
19
37
|
const USAGE_SYNC_KICKOFF_MS = 90_000;
|
|
20
38
|
export class UsageSyncService extends BasePeriodicService {
|
|
@@ -54,6 +72,15 @@ export class UsageSyncService extends BasePeriodicService {
|
|
|
54
72
|
ctx.log('INFO', `session-mirror: published ${mirrored.count} session digest(s)`);
|
|
55
73
|
if (mirrored.error)
|
|
56
74
|
ctx.log('WARN', `session-mirror: publish: ${mirrored.error}`);
|
|
75
|
+
// The reserved-auth readiness verdict rides this single git exchange too
|
|
76
|
+
// (PHNX-4051): it is a conflict-free field in the same owned daemon-state
|
|
77
|
+
// file, so publishing it here — instead of from a second committer in
|
|
78
|
+
// auth-sync — is what keeps exactly one caller of syncFleetSharedStateRepo on
|
|
79
|
+
// the periodic path.
|
|
80
|
+
const { publishReservedAuthVerdict } = await import('../secrets-policy.js');
|
|
81
|
+
const authVerdict = await publishReservedAuthVerdict();
|
|
82
|
+
if (authVerdict.error)
|
|
83
|
+
ctx.log('WARN', `usage-sync: auth verdict: ${authVerdict.error}`);
|
|
57
84
|
const { syncFleetSharedStateRepo } = await import('../fleet-shared-repo-sync.js');
|
|
58
85
|
const transport = await syncFleetSharedStateRepo();
|
|
59
86
|
if (transport.skipped)
|
|
@@ -36,11 +36,28 @@ export declare function isFreshFleetAuthSnapshot(value: {
|
|
|
36
36
|
* publishes every tick — it does not ride that endpoint.
|
|
37
37
|
*/
|
|
38
38
|
/**
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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 declare function installedAuthRows(authRows: readonly AuthProbeRow[], installedTargets: ReadonlySet<string>): AuthProbeRow[];
|
|
46
|
+
/**
|
|
47
|
+
* True when every cached auth row FOR AN INSTALLED HOME was probed within
|
|
48
|
+
* {@link AUTH_PROBE_MAX_AGE_MS} — i.e. reusing them would not let a verdict get
|
|
49
|
+
* staler than one probe window. No installed row is never fresh (nothing to
|
|
50
|
+
* reuse). Pure — unit-tested.
|
|
51
|
+
*
|
|
52
|
+
* Orphan rows are excluded rather than counted stale (PHNX-4051). Counting them
|
|
53
|
+
* made this permanently false on any box that had ever uninstalled a version:
|
|
54
|
+
* the orphan can never be re-probed, so the tick live-probed the rate-limited
|
|
55
|
+
* `/api/oauth/usage` for every account every 3 minutes, re-arming the per-account
|
|
56
|
+
* 429 backoff and parking the usage refresher — the RUSH-2998 failure the reuse
|
|
57
|
+
* window exists to prevent. Observed on yosemite-m0 (rows dated Sep 2 / Sep 6 for
|
|
58
|
+
* uninstalled Claude versions).
|
|
42
59
|
*/
|
|
43
|
-
export declare function isCachedFleetAuthProbeFresh(authRows: AuthProbeRow[], now: number, maxAgeMs?: number): boolean;
|
|
60
|
+
export declare function isCachedFleetAuthProbeFresh(authRows: readonly AuthProbeRow[], now: number, installedTargets: ReadonlySet<string>, maxAgeMs?: number): boolean;
|
|
44
61
|
/**
|
|
45
62
|
* Whether the tick may reuse the cached auth verdict instead of re-probing the
|
|
46
63
|
* rate-limited endpoint. `force` (an on-demand `agents devices ping`) ALWAYS
|
|
@@ -49,7 +66,7 @@ export declare function isCachedFleetAuthProbeFresh(authRows: AuthProbeRow[], no
|
|
|
49
66
|
* revoked account (the second `runFleetPing` call site, RUSH-2998). Pure —
|
|
50
67
|
* unit-tested so that inversion cannot regress unnoticed.
|
|
51
68
|
*/
|
|
52
|
-
export declare function shouldReuseCachedAuthProbe(force: boolean, cached: AuthProbeRow[], now: number, maxAgeMs?: number): boolean;
|
|
69
|
+
export declare function shouldReuseCachedAuthProbe(force: boolean, cached: readonly AuthProbeRow[], now: number, installedTargets: ReadonlySet<string>, maxAgeMs?: number): boolean;
|
|
53
70
|
/**
|
|
54
71
|
* Fleet cache warm: publish THIS host's row for the caches `agents fleet
|
|
55
72
|
* status` / `agents devices list` read (PUBLISH-OWN / READ-UNION, RUSH-2061).
|
package/dist/lib/daemon-ticks.js
CHANGED
|
@@ -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
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
|
|
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
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
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
|
},
|
package/dist/lib/drift-sync.js
CHANGED
|
@@ -24,7 +24,7 @@ import { pullRepo } from './git.js';
|
|
|
24
24
|
import { repairAfterSync, renderRepairAfterSync } from './reconcile-and-repair.js';
|
|
25
25
|
import { promptAgentVersionSelection } from './installations/versions.js';
|
|
26
26
|
import { isInteractiveTerminal, isPromptCancelled } from './format.js';
|
|
27
|
-
import { computeSyncStatus, } from './sync-status.js';
|
|
27
|
+
import { computeSyncStatus, formatDriftRows } from './sync-status.js';
|
|
28
28
|
const agentName = (id) => AGENTS[id]?.name ?? id;
|
|
29
29
|
/** "claude@2.1.170 2 drifted · 1 missing" for one version. */
|
|
30
30
|
function versionLine(v) {
|
|
@@ -42,8 +42,11 @@ function renderSummary(status, needing) {
|
|
|
42
42
|
if (status.system.behind > 0) {
|
|
43
43
|
console.log(` ${'.system repo'.padEnd(28)} ${chalk.yellow(`${status.system.behind} commit${status.system.behind === 1 ? '' : 's'} behind`)} ${chalk.gray('— pull recommended')}`);
|
|
44
44
|
}
|
|
45
|
-
for (const v of needing)
|
|
45
|
+
for (const v of needing) {
|
|
46
46
|
console.log(versionLine(v));
|
|
47
|
+
for (const line of formatDriftRows(v))
|
|
48
|
+
console.log(chalk.gray(` ${line}`));
|
|
49
|
+
}
|
|
47
50
|
if (status.totals.orphan > 0) {
|
|
48
51
|
console.log(chalk.gray(` (${status.totals.orphan} orphan${status.totals.orphan === 1 ? '' : 's'} — run \`agents prune cleanup\`)`));
|
|
49
52
|
}
|
package/dist/lib/exec.js
CHANGED
|
@@ -39,6 +39,7 @@ import { probeUnprivilegedUserns } from './linux-userns.js';
|
|
|
39
39
|
import { resolveClaudeSetupToken } from './claude-account-token.js';
|
|
40
40
|
import { applyAddDirs } from './add-dir.js';
|
|
41
41
|
import { applyActiveRulesPresetAtRun } from './rules/run-sync.js';
|
|
42
|
+
import { applySystemResourcesAtRun } from './system-run-sync.js';
|
|
42
43
|
import { resolveHarnessAdapter, stripForeignConfigDir } from './harness/index.js';
|
|
43
44
|
import { resolveConfigVersion } from './harness/exec-config-version.js';
|
|
44
45
|
import { getAccountInfo } from './agents.js';
|
|
@@ -2406,6 +2407,7 @@ export async function runWithFallback(options) {
|
|
|
2406
2407
|
const rulesVersion = version ?? resolveVersion(agent);
|
|
2407
2408
|
if (rulesVersion) {
|
|
2408
2409
|
applyActiveRulesPresetAtRun(agent, rulesVersion, getVersionHomePath(agent, rulesVersion));
|
|
2410
|
+
applySystemResourcesAtRun(agent, rulesVersion, getVersionHomePath(agent, rulesVersion));
|
|
2409
2411
|
}
|
|
2410
2412
|
// Record the entry we're about to attempt so the caller (audit log) sees the
|
|
2411
2413
|
// agent+version that actually ran, even after a rate-limit handoff.
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -115,6 +115,12 @@ export interface ResidualDrift {
|
|
|
115
115
|
export declare function verifyVersionConverged(agent: AgentId, version: string, cwd?: string): ResidualDrift | null;
|
|
116
116
|
/** One-line-per-resource description of residual drift, for the sync exit line. */
|
|
117
117
|
export declare function formatResidualDrift(residual: ResidualDrift[]): string[];
|
|
118
|
+
/**
|
|
119
|
+
* The rows behind a "N drifted" count, one line each, for the human renderers.
|
|
120
|
+
* A count alone cannot be acted on; the name says which skill or hook to look
|
|
121
|
+
* at, and `detail` says what differs when the differ knows.
|
|
122
|
+
*/
|
|
123
|
+
export declare function formatDriftRows(v: AgentVersionStatus): string[];
|
|
118
124
|
export interface SyncStatusOptions {
|
|
119
125
|
cwd?: string;
|
|
120
126
|
/** Restrict to specific agent ids; undefined = every supported agent. */
|
package/dist/lib/sync-status.js
CHANGED
|
@@ -54,6 +54,17 @@ export function formatResidualDrift(residual) {
|
|
|
54
54
|
}
|
|
55
55
|
return lines;
|
|
56
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* The rows behind a "N drifted" count, one line each, for the human renderers.
|
|
59
|
+
* A count alone cannot be acted on; the name says which skill or hook to look
|
|
60
|
+
* at, and `detail` says what differs when the differ knows.
|
|
61
|
+
*/
|
|
62
|
+
export function formatDriftRows(v) {
|
|
63
|
+
return v.resources
|
|
64
|
+
.filter((r) => r.status === 'drifted' || r.status === 'missing')
|
|
65
|
+
.sort((a, b) => a.kind.localeCompare(b.kind) || a.name.localeCompare(b.name))
|
|
66
|
+
.map((r) => `${r.status.padEnd(7)} ${r.kind}/${r.name}${r.detail ? ` (${r.detail})` : ''}`);
|
|
67
|
+
}
|
|
57
68
|
const STATUS_MAP = {
|
|
58
69
|
ok: 'synced',
|
|
59
70
|
diff: 'drifted',
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { AgentId } from './types.js';
|
|
2
|
+
export interface SystemRunSyncResult {
|
|
3
|
+
/** Names rewritten per kind; empty when the sentinel matched (skip-fast). */
|
|
4
|
+
skills: string[];
|
|
5
|
+
subagents: string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Refresh the system layer's skills and subagents into the version home when
|
|
9
|
+
* their source directories changed since the last run for (agent, version).
|
|
10
|
+
*/
|
|
11
|
+
export declare function applySystemResourcesAtRun(agent: AgentId, version: string, versionHome: string): SystemRunSyncResult;
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* System-layer skills and subagents refresh at `agents run` launch time.
|
|
3
|
+
*
|
|
4
|
+
* `agents sync <harness>@all system` rewrites every version home, but nothing
|
|
5
|
+
* runs it when the `.system` mirror moves: the shim's `--launch` path compiles
|
|
6
|
+
* project rules and mirrors project resources only, and the SessionStart
|
|
7
|
+
* autosync fires the umbrella at most once per 4 h. Rules already close that
|
|
8
|
+
* gap per run (`rules/run-sync.ts`); skills and subagents did not, so a merged
|
|
9
|
+
* system-layer skill change left 31 of 33 installed copies on the old text
|
|
10
|
+
* (PHNX-4056). This is the same shape as the rules run-sync: a small per-
|
|
11
|
+
* (agent, version) sentinel of source fingerprints, stat-tier comparison, and
|
|
12
|
+
* a write through the registered writers only when a source file changed.
|
|
13
|
+
*
|
|
14
|
+
* Scope: the system layer's `skills/` and `subagents/` directories. The
|
|
15
|
+
* writers resolve each name through the normal precedence (a user-layer
|
|
16
|
+
* shadow still wins), and the active resource profile filters both kinds the
|
|
17
|
+
* same way `syncResourcesToVersion` does. Never blocks a launch: any failure
|
|
18
|
+
* is swallowed and leaves the sentinel stale, so the next run retries.
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from 'fs';
|
|
21
|
+
import * as path from 'path';
|
|
22
|
+
import { getCacheDir, getSystemSkillsDir, getSystemSubagentsDir } from './state.js';
|
|
23
|
+
import { getWriter } from './staleness/registry.js';
|
|
24
|
+
import { fingerprintDir, isDirStale } from './staleness/fingerprint.js';
|
|
25
|
+
import { filterNamesForActiveResourceProfile } from './resource-profiles.js';
|
|
26
|
+
/** ~/.agents/.cache/system-run-sync/ — regenerable; a lost sentinel costs one extra write. */
|
|
27
|
+
function sentinelPath(agent, version) {
|
|
28
|
+
const key = `${agent}@${version}`.replace(/[^a-zA-Z0-9@._-]/g, '_');
|
|
29
|
+
return path.join(getCacheDir(), 'system-run-sync', `${key}.json`);
|
|
30
|
+
}
|
|
31
|
+
function loadSentinel(agent, version) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(fs.readFileSync(sentinelPath(agent, version), 'utf-8'));
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function saveSentinel(agent, version, sentinel) {
|
|
40
|
+
const p = sentinelPath(agent, version);
|
|
41
|
+
try {
|
|
42
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
43
|
+
fs.writeFileSync(p, JSON.stringify(sentinel));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Best-effort — a failed write just means the next run redoes the copy.
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** Subdirectories of `dir` that carry `marker` (SKILL.md / AGENT.md). */
|
|
50
|
+
function namesWithMarker(dir, marker) {
|
|
51
|
+
const out = new Map();
|
|
52
|
+
let entries;
|
|
53
|
+
try {
|
|
54
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
for (const e of entries) {
|
|
60
|
+
if (!e.isDirectory() || e.name.startsWith('.'))
|
|
61
|
+
continue;
|
|
62
|
+
const src = path.join(dir, e.name, marker);
|
|
63
|
+
if (fs.existsSync(src))
|
|
64
|
+
out.set(e.name, src);
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Refresh the system layer's skills and subagents into the version home when
|
|
70
|
+
* their source directories changed since the last run for (agent, version).
|
|
71
|
+
*/
|
|
72
|
+
export function applySystemResourcesAtRun(agent, version, versionHome) {
|
|
73
|
+
const result = { skills: [], subagents: [] };
|
|
74
|
+
const stored = loadSentinel(agent, version);
|
|
75
|
+
const next = { ...stored };
|
|
76
|
+
const kinds = [
|
|
77
|
+
{ kind: 'skills', dir: getSystemSkillsDir(), marker: 'SKILL.md' },
|
|
78
|
+
{ kind: 'subagents', dir: getSystemSubagentsDir(), marker: 'AGENT.md' },
|
|
79
|
+
];
|
|
80
|
+
for (const { kind, dir, marker } of kinds) {
|
|
81
|
+
if (!fs.existsSync(dir))
|
|
82
|
+
continue;
|
|
83
|
+
const prev = stored[kind];
|
|
84
|
+
if (prev && prev.dir === dir && !isDirStale(prev.dir, prev.files, dir))
|
|
85
|
+
continue;
|
|
86
|
+
const writer = getWriter(kind, agent);
|
|
87
|
+
if (!writer)
|
|
88
|
+
continue;
|
|
89
|
+
const sources = namesWithMarker(dir, marker);
|
|
90
|
+
// The active resource profile is the per-client scoping seam (a brand pins
|
|
91
|
+
// one); it filters every kind here exactly as `syncResourcesToVersion` does.
|
|
92
|
+
const names = filterNamesForActiveResourceProfile(kind, Array.from(sources.keys()), sources);
|
|
93
|
+
if (names.length === 0) {
|
|
94
|
+
next[kind] = { dir, files: fingerprintDir(dir) };
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
let written;
|
|
98
|
+
try {
|
|
99
|
+
written = writer.write({ version, versionHome, selection: names, cwd: '' });
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
continue; // leave the sentinel stale so the next run retries
|
|
103
|
+
}
|
|
104
|
+
result[kind] = written.synced;
|
|
105
|
+
// A per-name failure (an AGENT.md that does not parse) also leaves the
|
|
106
|
+
// sentinel stale: the next run retries instead of never seeing it again.
|
|
107
|
+
if (written.errors?.length)
|
|
108
|
+
continue;
|
|
109
|
+
next[kind] = { dir, files: fingerprintDir(dir) };
|
|
110
|
+
}
|
|
111
|
+
if (JSON.stringify(next) !== JSON.stringify(stored))
|
|
112
|
+
saveSentinel(agent, version, next);
|
|
113
|
+
return result;
|
|
114
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@phnx-labs/agents-cli",
|
|
3
|
-
"version": "1.22.
|
|
3
|
+
"version": "1.22.101",
|
|
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",
|