@phnx-labs/agents-cli 1.22.76 → 1.22.77
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 +4 -0
- package/dist/lib/browser/registry.js +10 -3
- package/dist/lib/git.js +6 -0
- package/dist/lib/installations/migrate.d.ts +10 -0
- package/dist/lib/installations/migrate.js +18 -0
- package/dist/lib/project-resources.d.ts +23 -0
- package/dist/lib/project-resources.js +94 -0
- package/dist/lib/state.d.ts +43 -1
- package/dist/lib/state.js +109 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.22.77
|
|
4
|
+
|
|
5
|
+
- **`agents repo pull` / `agents sync` no longer wedge on a CLI-regenerated `~/.agents/agents.yaml` (PHNX-3968).** The shared central config was rewritten by config commands without being committed, so between the daemon's publish ticks the tree sat dirty on `agents.yaml`; an incoming peer commit that also touched it made the pull refuse with *"incoming changes touch uncommitted paths: agents.yaml"*, forcing manual autostash gymnastics on the fleet. Central config edits are now committed synchronously by the command that makes them (after the meta lock releases, skipped for the daemon whose own publish tick owns its commits), so the tree is clean at rest and the trip cannot occur. A genuinely dirty-and-differing central file now *refuses* the pull (data-safe, self-heals on the next commit) rather than silently taking the remote copy. The user-repo `CHANGELOG.md` (a duplicate of the shipped one) is de-tracked via `.git/info/exclude` so it stops churning the tree. Source: `cli/src/lib/state.ts`, `cli/src/lib/git.ts`, `cli/src/lib/browser/registry.ts`, `cli/src/lib/project-resources.ts`.
|
|
6
|
+
|
|
3
7
|
## 1.22.76
|
|
4
8
|
|
|
5
9
|
- **Per-session summarizer: bound the mirror publish (PHNX-3939 follow-up).** The fleet session mirror now caps `checkpoints`/`summaryChecklist` on the **publish** side (50/100 items, 400-char text) exactly as it already did on consume, so a box's own summarizer output can never ride unbounded into the git-synced `daemon-state.json` every peer fetches — matching the `snippet()` cap already applied to `goal`. Source: `cli/src/lib/session/mirror.ts`.
|
|
@@ -2,7 +2,7 @@ import * as fs from 'node:fs';
|
|
|
2
2
|
import * as path from 'node:path';
|
|
3
3
|
import { isDeepStrictEqual } from 'node:util';
|
|
4
4
|
import * as yaml from 'yaml';
|
|
5
|
-
import { getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
|
|
5
|
+
import { commitCentralConfigAfterWrite, getUserAgentsDir, readMeta, updateMeta, withMetaLock, writeMetaUnlocked } from '../state.js';
|
|
6
6
|
function isRecord(value) {
|
|
7
7
|
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
8
8
|
}
|
|
@@ -168,7 +168,8 @@ export function autoEvictCentralBrowserProfiles(canHostHere) {
|
|
|
168
168
|
// window. We write ONLY when something is claimed, so a no-op sync never touches
|
|
169
169
|
// any doc (writeMetaUnlocked would otherwise re-serialize the device doc every
|
|
170
170
|
// run). This mirrors updateMeta's own body, minus the unconditional write.
|
|
171
|
-
|
|
171
|
+
let centralChanged = false;
|
|
172
|
+
const result = withMetaLock(() => {
|
|
172
173
|
const meta = readMeta();
|
|
173
174
|
const central = meta.browser;
|
|
174
175
|
if (!central || Object.keys(central).length === 0)
|
|
@@ -181,9 +182,15 @@ export function autoEvictCentralBrowserProfiles(canHostHere) {
|
|
|
181
182
|
skipped.sort();
|
|
182
183
|
if (claimed.length === 0)
|
|
183
184
|
return { claimed, skipped };
|
|
184
|
-
writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
|
|
185
|
+
centralChanged = writeMetaUnlocked(buildEvictedMeta(meta, toClaim));
|
|
185
186
|
return { claimed, skipped };
|
|
186
187
|
});
|
|
188
|
+
// Commit the central eviction AFTER the meta lock releases (the invariant:
|
|
189
|
+
// every non-daemon central write commits after the lock, so agents.yaml is
|
|
190
|
+
// never left dirty at rest to re-open the pull-trip window). Gated inside the
|
|
191
|
+
// helper on centralChanged && !isDaemonProcess().
|
|
192
|
+
commitCentralConfigAfterWrite(centralChanged);
|
|
193
|
+
return result;
|
|
187
194
|
}
|
|
188
195
|
/**
|
|
189
196
|
* Every profile any device declares, keyed by name to all declaring devices.
|
package/dist/lib/git.js
CHANGED
|
@@ -1427,6 +1427,12 @@ async function dirtyTreeRefusal(git, status, upstreamRef) {
|
|
|
1427
1427
|
.filter(Boolean);
|
|
1428
1428
|
const collisions = incoming.filter((p) => dirty.has(p));
|
|
1429
1429
|
if (collisions.length > 0) {
|
|
1430
|
+
// Central agents.yaml is AUTHORITATIVE (account labels/rows), not regenerable,
|
|
1431
|
+
// and a dirty copy always equals serializeCentral(current meta) — so no
|
|
1432
|
+
// byte check can tell a stranded real edit from a stale one. Refuse rather
|
|
1433
|
+
// than discard: it is data-safe and self-heals, because commit-on-write
|
|
1434
|
+
// (lib/state.ts) and the daemon's publish tick both commit agents.yaml,
|
|
1435
|
+
// after which the very next pull succeeds normally. See PHNX-3968.
|
|
1430
1436
|
const shown = collisions.slice(0, 5).join(', ');
|
|
1431
1437
|
const more = collisions.length > 5 ? ` (+${collisions.length - 5} more)` : '';
|
|
1432
1438
|
return `incoming changes touch uncommitted paths: ${shown}${more}`;
|
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
export { foldLegacySystemRepo } from '../migrate-fold.js';
|
|
3
3
|
/** Migrate a legacy single-repo agents.yaml into ~/.agents/agents.yaml. Leaves the tracked system-mirror copy alone to avoid dirtying the npm-shipped defaults. */
|
|
4
4
|
export declare function migrateAgentsYaml(systemDir?: string, userDir?: string): void;
|
|
5
|
+
/**
|
|
6
|
+
* Stop tracking the user repo's CHANGELOG.md. It duplicates the npm-shipped
|
|
7
|
+
* `.system/CHANGELOG.md` (read the canonical copy from there), and as a tracked
|
|
8
|
+
* file that upstream never carries it can only ever show as `M`/`??`
|
|
9
|
+
* — dirtying the tree and, when a peer's publish commit arrives, tripping the
|
|
10
|
+
* dirty-tree pull guard. De-track it into `.git/info/exclude` (per-clone,
|
|
11
|
+
* uncommitted; never `.gitignore`, which would re-introduce the same failure).
|
|
12
|
+
* Idempotent: a no-op once the path is untracked and excluded.
|
|
13
|
+
*/
|
|
14
|
+
export declare function detrackUserChangelog(userDir?: string): void;
|
|
5
15
|
/** Fold the legacy global browser/sessions/<task>/ tree into the per-profile browser/<profile>/sessions/<task>/ layout. Unclaimed tasks move under `_legacy`. */
|
|
6
16
|
export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): void;
|
|
7
17
|
/** Repair node_modules/.bin/<cli> symlinks that resolve back into our own shims dir (infinite exec-loop fix). */
|
|
@@ -16,6 +16,7 @@ import { setConfigValue } from '../device-config.js';
|
|
|
16
16
|
import { enabledRoutineNames, replaceEnabledRoutines } from '../routine-activation.js';
|
|
17
17
|
import { evaluateActivationReadiness } from '../routine-readiness.js';
|
|
18
18
|
import { migrateDeviceConfigStores } from '../devices/config-migration.js';
|
|
19
|
+
import { detrackViaGitExclude } from '../project-resources.js';
|
|
19
20
|
// Two constants only, never the read/write API — migrations still operate on raw
|
|
20
21
|
// YAML so they never take the meta lock or prime the meta cache mid-migration.
|
|
21
22
|
import { LEGACY_DEFAULT_BROWSER_PROFILE_NAME } from '../browser/profiles.js';
|
|
@@ -70,6 +71,22 @@ function deleteSystemPromptsJson() {
|
|
|
70
71
|
}
|
|
71
72
|
catch { /* best-effort */ }
|
|
72
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* Stop tracking the user repo's CHANGELOG.md. It duplicates the npm-shipped
|
|
76
|
+
* `.system/CHANGELOG.md` (read the canonical copy from there), and as a tracked
|
|
77
|
+
* file that upstream never carries it can only ever show as `M`/`??`
|
|
78
|
+
* — dirtying the tree and, when a peer's publish commit arrives, tripping the
|
|
79
|
+
* dirty-tree pull guard. De-track it into `.git/info/exclude` (per-clone,
|
|
80
|
+
* uncommitted; never `.gitignore`, which would re-introduce the same failure).
|
|
81
|
+
* Idempotent: a no-op once the path is untracked and excluded.
|
|
82
|
+
*/
|
|
83
|
+
export function detrackUserChangelog(userDir = USER_DIR) {
|
|
84
|
+
if (!fs.existsSync(path.join(userDir, '.git')))
|
|
85
|
+
return; // plain dir, not a clone
|
|
86
|
+
const untracked = detrackViaGitExclude(userDir, 'CHANGELOG.md');
|
|
87
|
+
if (untracked)
|
|
88
|
+
console.error('Stopped tracking ~/.agents/CHANGELOG.md (duplicates .system/CHANGELOG.md)');
|
|
89
|
+
}
|
|
73
90
|
/** Delete the legacy ~/.agents-system/config.json (dead teams agent registry). */
|
|
74
91
|
function migrateSystemConfigJson() {
|
|
75
92
|
const src = path.join(SYSTEM_DIR, 'config.json');
|
|
@@ -2242,6 +2259,7 @@ export async function runMigration() {
|
|
|
2242
2259
|
migrateHumans();
|
|
2243
2260
|
deleteSystemPromptsJson();
|
|
2244
2261
|
migrateSystemConfigJson();
|
|
2262
|
+
detrackUserChangelog();
|
|
2245
2263
|
migratePromptcutsIntoHooks();
|
|
2246
2264
|
migrateSystemVersionsToUser();
|
|
2247
2265
|
mergeOverlappingVersionHomes();
|
|
@@ -21,6 +21,29 @@ export declare function syncProjectResourcesToAgent(agent: AgentId, version: str
|
|
|
21
21
|
* commits its own `.claude/CLAUDE.md` keeps it — it is not in the manifest).
|
|
22
22
|
*/
|
|
23
23
|
export declare function managedGitignoreEntries(agentRoot: string, referenceRoot: string, managed: string[]): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Stop git tracking `relPath` in the DotAgents clone at `repoDir` and keep it
|
|
26
|
+
* locally ignored — the PHNX-3718 pattern, applied to a shared config file the
|
|
27
|
+
* user repo should no longer carry (e.g. the duplicated CHANGELOG.md; the
|
|
28
|
+
* canonical copy ships in `.system/`).
|
|
29
|
+
*
|
|
30
|
+
* The ignore entry goes in `.git/info/exclude`, NOT `.gitignore`: a tracked
|
|
31
|
+
* `.gitignore` block would itself show as `M .gitignore` on every clone and
|
|
32
|
+
* reintroduce the exact dirty-tree-blocks-pull failure this whole effort exists
|
|
33
|
+
* to kill. info/exclude is per-clone and never committed, so the working tree
|
|
34
|
+
* stays clean at rest.
|
|
35
|
+
*
|
|
36
|
+
* Idempotent and convergent: when the path is still tracked it is removed from
|
|
37
|
+
* the index (the working file is kept) AND that removal is committed — a bare
|
|
38
|
+
* `git rm --cached` would leave a staged deletion, which is itself a dirty tree
|
|
39
|
+
* that re-arms the very pull trip this exists to prevent. The single removal
|
|
40
|
+
* commit also propagates the de-track fleet-wide: peers pull it and converge,
|
|
41
|
+
* their next run finding nothing to untrack. The exclude block is rewritten in
|
|
42
|
+
* place, unioning `relPath` with any entries already there, so a second run is a
|
|
43
|
+
* no-op. Fails open outside a git repo. Returns whether the path was untracked
|
|
44
|
+
* by this call (a signal for the caller's one-time log).
|
|
45
|
+
*/
|
|
46
|
+
export declare function detrackViaGitExclude(repoDir: string, relPath: string): boolean;
|
|
24
47
|
/**
|
|
25
48
|
* One human line for the files a project sync left alone because you already
|
|
26
49
|
* wrote them. This is the normal steady state — every sync of a project whose
|
|
@@ -284,6 +284,100 @@ function stripLegacyManagedGitignoreBlock(projectRoot, agent) {
|
|
|
284
284
|
fs.writeFileSync(tmp, stripped);
|
|
285
285
|
fs.renameSync(tmp, gitignorePath);
|
|
286
286
|
}
|
|
287
|
+
const DETRACK_BEGIN = '# BEGIN agents-cli detracked (managed)';
|
|
288
|
+
const DETRACK_END = '# END agents-cli detracked (managed)';
|
|
289
|
+
/**
|
|
290
|
+
* Stop git tracking `relPath` in the DotAgents clone at `repoDir` and keep it
|
|
291
|
+
* locally ignored — the PHNX-3718 pattern, applied to a shared config file the
|
|
292
|
+
* user repo should no longer carry (e.g. the duplicated CHANGELOG.md; the
|
|
293
|
+
* canonical copy ships in `.system/`).
|
|
294
|
+
*
|
|
295
|
+
* The ignore entry goes in `.git/info/exclude`, NOT `.gitignore`: a tracked
|
|
296
|
+
* `.gitignore` block would itself show as `M .gitignore` on every clone and
|
|
297
|
+
* reintroduce the exact dirty-tree-blocks-pull failure this whole effort exists
|
|
298
|
+
* to kill. info/exclude is per-clone and never committed, so the working tree
|
|
299
|
+
* stays clean at rest.
|
|
300
|
+
*
|
|
301
|
+
* Idempotent and convergent: when the path is still tracked it is removed from
|
|
302
|
+
* the index (the working file is kept) AND that removal is committed — a bare
|
|
303
|
+
* `git rm --cached` would leave a staged deletion, which is itself a dirty tree
|
|
304
|
+
* that re-arms the very pull trip this exists to prevent. The single removal
|
|
305
|
+
* commit also propagates the de-track fleet-wide: peers pull it and converge,
|
|
306
|
+
* their next run finding nothing to untrack. The exclude block is rewritten in
|
|
307
|
+
* place, unioning `relPath` with any entries already there, so a second run is a
|
|
308
|
+
* no-op. Fails open outside a git repo. Returns whether the path was untracked
|
|
309
|
+
* by this call (a signal for the caller's one-time log).
|
|
310
|
+
*/
|
|
311
|
+
export function detrackViaGitExclude(repoDir, relPath) {
|
|
312
|
+
const target = resolveGitExcludeTarget(repoDir);
|
|
313
|
+
if (!target)
|
|
314
|
+
return false; // not a git repo — nothing to de-track or ignore.
|
|
315
|
+
let untrackedNow = false;
|
|
316
|
+
const abs = path.join(repoDir, relPath);
|
|
317
|
+
if (isTrackedByGit(repoDir, abs)) {
|
|
318
|
+
try {
|
|
319
|
+
// Unstage anything else first so the removal commit records ONLY this
|
|
320
|
+
// path's deletion — a mixed reset (index only, worktree untouched). At
|
|
321
|
+
// migration time the index is already clean; this just makes the scope
|
|
322
|
+
// guaranteed rather than assumed.
|
|
323
|
+
execFileSync('git', ['-C', repoDir, 'reset', '-q'], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
324
|
+
// --cached keeps the working file; the commit records the removal so the
|
|
325
|
+
// tree is clean at rest and the de-track converges across the fleet. A
|
|
326
|
+
// pathspec commit would re-read the still-present worktree file and undo
|
|
327
|
+
// the removal, so the commit takes the staged index (just this deletion).
|
|
328
|
+
execFileSync('git', ['-C', repoDir, 'rm', '--cached', '--quiet', '--', relPath], {
|
|
329
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
330
|
+
});
|
|
331
|
+
execFileSync('git', ['-C', repoDir, '-c', 'commit.gpgsign=false', 'commit', '--no-verify',
|
|
332
|
+
'-m', `chore(config): stop tracking ${relPath}`], { stdio: ['ignore', 'ignore', 'ignore'] });
|
|
333
|
+
untrackedNow = true;
|
|
334
|
+
}
|
|
335
|
+
catch {
|
|
336
|
+
// rm/commit can fail (e.g. mid-rebase, index.lock); the ignore entry below
|
|
337
|
+
// still lands and the next migration run retries the untrack. Fail open —
|
|
338
|
+
// but roll back a staged-but-uncommitted removal so we never LEAVE a dirty
|
|
339
|
+
// staged deletion behind (which would re-arm the pull trip).
|
|
340
|
+
try {
|
|
341
|
+
execFileSync('git', ['-C', repoDir, 'reset', '-q', '--', relPath], {
|
|
342
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
catch { /* nothing staged to reset */ }
|
|
346
|
+
untrackedNow = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
// Anchor to the worktree root so the pattern matches only the top-level file,
|
|
350
|
+
// exactly like git anchors a leading-slash info/exclude entry.
|
|
351
|
+
const entry = '/' + relPath.split(path.sep).join('/');
|
|
352
|
+
let original = '';
|
|
353
|
+
try {
|
|
354
|
+
original = fs.readFileSync(target.excludePath, 'utf-8');
|
|
355
|
+
}
|
|
356
|
+
catch {
|
|
357
|
+
original = '';
|
|
358
|
+
}
|
|
359
|
+
const existing = extractManagedEntries(original, DETRACK_BEGIN, DETRACK_END);
|
|
360
|
+
const entries = existing.includes(entry) ? existing : [...existing, entry].sort();
|
|
361
|
+
const next = applyManagedBlock(original, DETRACK_BEGIN, DETRACK_END, entries);
|
|
362
|
+
if (next !== null && next !== original) {
|
|
363
|
+
fs.mkdirSync(path.dirname(target.excludePath), { recursive: true });
|
|
364
|
+
const tmp = target.excludePath + '.tmp';
|
|
365
|
+
fs.writeFileSync(tmp, next);
|
|
366
|
+
fs.renameSync(tmp, target.excludePath);
|
|
367
|
+
}
|
|
368
|
+
return untrackedNow;
|
|
369
|
+
}
|
|
370
|
+
/** The entries currently inside a `begin`/`end` managed block, or `[]`. */
|
|
371
|
+
function extractManagedEntries(content, begin, end) {
|
|
372
|
+
const lines = content.split('\n');
|
|
373
|
+
const bi = lines.indexOf(begin);
|
|
374
|
+
if (bi === -1)
|
|
375
|
+
return [];
|
|
376
|
+
const ei = lines.indexOf(end, bi + 1);
|
|
377
|
+
if (ei === -1)
|
|
378
|
+
return [];
|
|
379
|
+
return lines.slice(bi + 1, ei).map((l) => l.trim()).filter(Boolean);
|
|
380
|
+
}
|
|
287
381
|
function removeManagedPath(agentRoot, rel) {
|
|
288
382
|
if (path.isAbsolute(rel) || rel.includes('..'))
|
|
289
383
|
return;
|
package/dist/lib/state.d.ts
CHANGED
|
@@ -409,6 +409,33 @@ export declare function getDeviceMetaPath(): string;
|
|
|
409
409
|
*/
|
|
410
410
|
export declare function getVersionResourcesPath(): string;
|
|
411
411
|
export declare function withMetaLock<T>(fn: () => T): T;
|
|
412
|
+
/**
|
|
413
|
+
* Commit the central `agents.yaml` in the user repo, synchronously, so a CLI
|
|
414
|
+
* config mutation never leaves the working tree dirty on that one shared-line
|
|
415
|
+
* file at rest.
|
|
416
|
+
*
|
|
417
|
+
* Why this exists: CLI config commands rewrite the fleet-shared central
|
|
418
|
+
* agents.yaml as a plain file write. Left uncommitted, the tree is dirty on
|
|
419
|
+
* agents.yaml between the write and the daemon's next 15-min publish tick — and
|
|
420
|
+
* a peer's incoming publish commit (which also touches agents.yaml) then trips
|
|
421
|
+
* `dirtyTreeRefusal` ("incoming changes touch uncommitted paths: agents.yaml"),
|
|
422
|
+
* wedging `agents repo pull` fleet-wide (PHNX-3968). Committing the central edit
|
|
423
|
+
* in the same command that made it closes that window: agents.yaml is clean at
|
|
424
|
+
* rest, so nothing incoming can collide with it. Called AFTER the meta lock
|
|
425
|
+
* releases (see {@link commitCentralConfigAfterWrite}) so the git subprocesses
|
|
426
|
+
* never run inside the short, non-heartbeated lockfile window; the user repo
|
|
427
|
+
* already pushes.
|
|
428
|
+
*
|
|
429
|
+
* Scoped tightly: only called when the central bytes actually changed, and never
|
|
430
|
+
* from the daemon (see {@link isDaemonProcess}). A commit failure fails open —
|
|
431
|
+
* a concurrent daemon holding `index.lock`, a mid-rebase repo — leaving
|
|
432
|
+
* agents.yaml dirty; the next successful central write commits it, and until
|
|
433
|
+
* then a pull that would collide refuses rather than losing data. A config
|
|
434
|
+
* command must never fail because git hiccuped.
|
|
435
|
+
*
|
|
436
|
+
* Returns whether a commit was created. Exported for the real-repo tests.
|
|
437
|
+
*/
|
|
438
|
+
export declare function commitCentralConfig(userDir: string): boolean;
|
|
412
439
|
/**
|
|
413
440
|
* True when the top-level `~/.agents/agents.yaml` carries a metadata header that
|
|
414
441
|
* is NOT the canonical {@link META_HEADER} — the P1 frozen-header case a box only
|
|
@@ -431,8 +458,13 @@ export declare function readTopLevelUserMeta(): Record<string, unknown> | null;
|
|
|
431
458
|
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
432
459
|
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
433
460
|
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
461
|
+
*
|
|
462
|
+
* Returns whether the central `agents.yaml` bytes actually changed, so the
|
|
463
|
+
* caller can commit it once — {@link commitCentralConfig} — AFTER releasing the
|
|
464
|
+
* meta lock (the git subprocesses must not run inside the short, non-heartbeated
|
|
465
|
+
* lockfile window). This function never commits.
|
|
434
466
|
*/
|
|
435
|
-
export declare function writeMetaUnlocked(meta: Meta):
|
|
467
|
+
export declare function writeMetaUnlocked(meta: Meta): boolean;
|
|
436
468
|
/**
|
|
437
469
|
* Read and cache ~/.agents/agents.yaml, migrating from legacy locations if needed.
|
|
438
470
|
*
|
|
@@ -448,6 +480,16 @@ export declare function readMeta(): Meta;
|
|
|
448
480
|
export declare function writeMeta(meta: Meta): void;
|
|
449
481
|
/** Update agents.yaml under lock and return the new state. */
|
|
450
482
|
export declare function updateMeta(updates: Partial<Meta> | ((meta: Meta) => Meta)): Meta;
|
|
483
|
+
/**
|
|
484
|
+
* Commit-on-write, invoked AFTER {@link withMetaLock} releases so the git
|
|
485
|
+
* subprocesses never run inside the short, non-heartbeated meta-lock window.
|
|
486
|
+
* A CLI command that actually moved the fleet-shared central agents.yaml commits
|
|
487
|
+
* it so the tree is never left dirty on that file at rest — the window that
|
|
488
|
+
* trips `dirtyTreeRefusal` and wedges pulls fleet-wide (PHNX-3968). Gated on a
|
|
489
|
+
* real byte change and never run in the daemon, whose publish tick owns central
|
|
490
|
+
* commits. See {@link commitCentralConfig}.
|
|
491
|
+
*/
|
|
492
|
+
export declare function commitCentralConfigAfterWrite(centralChanged: boolean): void;
|
|
451
493
|
/** Derive a filesystem-safe local clone path for a package source URL. */
|
|
452
494
|
export declare function getPackageLocalPath(source: string): string;
|
|
453
495
|
import type { AgentId, ResourceType, VersionResources, ResourcePattern } from './types.js';
|
package/dist/lib/state.js
CHANGED
|
@@ -850,7 +850,9 @@ export function withMetaLock(fn) {
|
|
|
850
850
|
});
|
|
851
851
|
}
|
|
852
852
|
/** Atomic write only when the on-disk content differs — avoids needless mtime
|
|
853
|
-
* bumps (which would thrash the meta cache) on no-op field routing.
|
|
853
|
+
* bumps (which would thrash the meta cache) on no-op field routing. Returns
|
|
854
|
+
* whether it actually wrote, so a caller can react only to a real change (e.g.
|
|
855
|
+
* commit the central agents.yaml exactly when its bytes moved). */
|
|
854
856
|
function writeIfChanged(filePath, content) {
|
|
855
857
|
let current = null;
|
|
856
858
|
try {
|
|
@@ -858,8 +860,74 @@ function writeIfChanged(filePath, content) {
|
|
|
858
860
|
}
|
|
859
861
|
catch { /* absent */ }
|
|
860
862
|
if (current === content)
|
|
861
|
-
return;
|
|
863
|
+
return false;
|
|
862
864
|
atomicWriteFileSync(filePath, content);
|
|
865
|
+
return true;
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* True in the always-on daemon process (launched as `agents __daemon-run`,
|
|
869
|
+
* see cli/src/index.ts). The daemon owns central git commits through
|
|
870
|
+
* fleet-shared-repo-sync's publish tick, so a central write from inside the
|
|
871
|
+
* daemon must NOT also commit here — that would race the publisher's own
|
|
872
|
+
* add/commit/rebase/push on the same index. Every ordinary CLI invocation
|
|
873
|
+
* returns false and commits its own central edit synchronously.
|
|
874
|
+
*/
|
|
875
|
+
function isDaemonProcess() {
|
|
876
|
+
return process.argv[2] === '__daemon-run';
|
|
877
|
+
}
|
|
878
|
+
/**
|
|
879
|
+
* Commit the central `agents.yaml` in the user repo, synchronously, so a CLI
|
|
880
|
+
* config mutation never leaves the working tree dirty on that one shared-line
|
|
881
|
+
* file at rest.
|
|
882
|
+
*
|
|
883
|
+
* Why this exists: CLI config commands rewrite the fleet-shared central
|
|
884
|
+
* agents.yaml as a plain file write. Left uncommitted, the tree is dirty on
|
|
885
|
+
* agents.yaml between the write and the daemon's next 15-min publish tick — and
|
|
886
|
+
* a peer's incoming publish commit (which also touches agents.yaml) then trips
|
|
887
|
+
* `dirtyTreeRefusal` ("incoming changes touch uncommitted paths: agents.yaml"),
|
|
888
|
+
* wedging `agents repo pull` fleet-wide (PHNX-3968). Committing the central edit
|
|
889
|
+
* in the same command that made it closes that window: agents.yaml is clean at
|
|
890
|
+
* rest, so nothing incoming can collide with it. Called AFTER the meta lock
|
|
891
|
+
* releases (see {@link commitCentralConfigAfterWrite}) so the git subprocesses
|
|
892
|
+
* never run inside the short, non-heartbeated lockfile window; the user repo
|
|
893
|
+
* already pushes.
|
|
894
|
+
*
|
|
895
|
+
* Scoped tightly: only called when the central bytes actually changed, and never
|
|
896
|
+
* from the daemon (see {@link isDaemonProcess}). A commit failure fails open —
|
|
897
|
+
* a concurrent daemon holding `index.lock`, a mid-rebase repo — leaving
|
|
898
|
+
* agents.yaml dirty; the next successful central write commits it, and until
|
|
899
|
+
* then a pull that would collide refuses rather than losing data. A config
|
|
900
|
+
* command must never fail because git hiccuped.
|
|
901
|
+
*
|
|
902
|
+
* Returns whether a commit was created. Exported for the real-repo tests.
|
|
903
|
+
*/
|
|
904
|
+
export function commitCentralConfig(userDir) {
|
|
905
|
+
const rel = 'agents.yaml';
|
|
906
|
+
try {
|
|
907
|
+
execFileSync('git', ['-C', userDir, 'rev-parse', '--is-inside-work-tree'], { stdio: 'ignore' });
|
|
908
|
+
}
|
|
909
|
+
catch {
|
|
910
|
+
return false; // plain ~/.agents with no git repo — leave it a loose write.
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
execFileSync('git', ['-C', userDir, 'add', '--', rel], { stdio: 'ignore' });
|
|
914
|
+
// Nothing staged for agents.yaml (its bytes matched HEAD after all) → no
|
|
915
|
+
// empty commit. `diff --cached --quiet` exits 0 when the index equals HEAD
|
|
916
|
+
// for this path, 1 when it differs.
|
|
917
|
+
try {
|
|
918
|
+
execFileSync('git', ['-C', userDir, 'diff', '--cached', '--quiet', '--', rel], { stdio: 'ignore' });
|
|
919
|
+
return false;
|
|
920
|
+
}
|
|
921
|
+
catch { /* exit 1 → staged changes present, commit them */ }
|
|
922
|
+
// Pathspec-scoped commit: records ONLY agents.yaml even if other paths are
|
|
923
|
+
// staged, so a config write never sweeps unrelated staged work into its commit.
|
|
924
|
+
execFileSync('git', ['-C', userDir, '-c', 'commit.gpgsign=false', 'commit', '--no-verify',
|
|
925
|
+
'-m', 'chore(config): update agents.yaml', '--', rel], { stdio: 'ignore' });
|
|
926
|
+
return true;
|
|
927
|
+
}
|
|
928
|
+
catch {
|
|
929
|
+
return false; // fail open — see the doc comment.
|
|
930
|
+
}
|
|
863
931
|
}
|
|
864
932
|
/**
|
|
865
933
|
* Partition the in-memory Meta across four files by sync-domain:
|
|
@@ -1149,6 +1217,11 @@ function serializeCentral(central) {
|
|
|
1149
1217
|
* writer that needs to read fresh state, decide, and commit within a SINGLE lock
|
|
1150
1218
|
* acquisition (e.g. browser tombstone eviction) can do so without the
|
|
1151
1219
|
* read-snapshot-then-separately-lock race that {@link updateMeta} would impose.
|
|
1220
|
+
*
|
|
1221
|
+
* Returns whether the central `agents.yaml` bytes actually changed, so the
|
|
1222
|
+
* caller can commit it once — {@link commitCentralConfig} — AFTER releasing the
|
|
1223
|
+
* meta lock (the git subprocesses must not run inside the short, non-heartbeated
|
|
1224
|
+
* lockfile window). This function never commits.
|
|
1152
1225
|
*/
|
|
1153
1226
|
export function writeMetaUnlocked(meta) {
|
|
1154
1227
|
const writesDeviceRoutines = Object.prototype.hasOwnProperty.call(meta, 'deviceRoutines');
|
|
@@ -1306,8 +1379,9 @@ export function writeMetaUnlocked(meta) {
|
|
|
1306
1379
|
fs.mkdirSync(path.dirname(vrPath), { recursive: true });
|
|
1307
1380
|
writeIfChanged(vrPath, JSON.stringify(versions, null, 2) + '\n');
|
|
1308
1381
|
}
|
|
1309
|
-
writeIfChanged(META_FILE, serializeCentral(central));
|
|
1382
|
+
const centralChanged = writeIfChanged(META_FILE, serializeCentral(central));
|
|
1310
1383
|
metaCache = null;
|
|
1384
|
+
return centralChanged;
|
|
1311
1385
|
}
|
|
1312
1386
|
/**
|
|
1313
1387
|
* Overlay this machine's local state onto a central-portable Meta:
|
|
@@ -1493,7 +1567,15 @@ export function readMeta() {
|
|
|
1493
1567
|
if (parsed.registries) {
|
|
1494
1568
|
meta.registries = parsed.registries;
|
|
1495
1569
|
}
|
|
1496
|
-
|
|
1570
|
+
// Lock-safe, commit-free write: withMetaLock is reentrant (see
|
|
1571
|
+
// metaLockDepth), so this writes under the lock when called standalone and
|
|
1572
|
+
// is a no-op re-entry when readMeta runs inside updateMeta/writeMeta's held
|
|
1573
|
+
// lock — and writeMetaUnlocked never spawns git. Calling the PUBLIC
|
|
1574
|
+
// writeMeta here would run commit-on-write's git subprocess inside a held,
|
|
1575
|
+
// non-heartbeated lock. This one-shot legacy migration needs no synchronous
|
|
1576
|
+
// commit: it self-heals on the daemon's next publish tick or the next real
|
|
1577
|
+
// CLI central write.
|
|
1578
|
+
withMetaLock(() => writeMetaUnlocked(meta));
|
|
1497
1579
|
try {
|
|
1498
1580
|
fs.unlinkSync(oldMetaFile);
|
|
1499
1581
|
}
|
|
@@ -1547,18 +1629,36 @@ export function readMeta() {
|
|
|
1547
1629
|
}
|
|
1548
1630
|
/** Serialize and write agents.yaml to the user repo, invalidating the in-memory cache. */
|
|
1549
1631
|
export function writeMeta(meta) {
|
|
1550
|
-
withMetaLock(() => writeMetaUnlocked(meta));
|
|
1632
|
+
const centralChanged = withMetaLock(() => writeMetaUnlocked(meta));
|
|
1633
|
+
commitCentralConfigAfterWrite(centralChanged);
|
|
1551
1634
|
}
|
|
1552
1635
|
/** Update agents.yaml under lock and return the new state. */
|
|
1553
1636
|
export function updateMeta(updates) {
|
|
1554
|
-
|
|
1637
|
+
let centralChanged = false;
|
|
1638
|
+
const newMeta = withMetaLock(() => {
|
|
1555
1639
|
const meta = readMeta();
|
|
1556
|
-
const
|
|
1640
|
+
const nm = typeof updates === 'function'
|
|
1557
1641
|
? updates(meta)
|
|
1558
1642
|
: { ...meta, ...updates };
|
|
1559
|
-
writeMetaUnlocked(
|
|
1560
|
-
return
|
|
1643
|
+
centralChanged = writeMetaUnlocked(nm);
|
|
1644
|
+
return nm;
|
|
1561
1645
|
});
|
|
1646
|
+
commitCentralConfigAfterWrite(centralChanged);
|
|
1647
|
+
return newMeta;
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* Commit-on-write, invoked AFTER {@link withMetaLock} releases so the git
|
|
1651
|
+
* subprocesses never run inside the short, non-heartbeated meta-lock window.
|
|
1652
|
+
* A CLI command that actually moved the fleet-shared central agents.yaml commits
|
|
1653
|
+
* it so the tree is never left dirty on that file at rest — the window that
|
|
1654
|
+
* trips `dirtyTreeRefusal` and wedges pulls fleet-wide (PHNX-3968). Gated on a
|
|
1655
|
+
* real byte change and never run in the daemon, whose publish tick owns central
|
|
1656
|
+
* commits. See {@link commitCentralConfig}.
|
|
1657
|
+
*/
|
|
1658
|
+
export function commitCentralConfigAfterWrite(centralChanged) {
|
|
1659
|
+
if (centralChanged && !isDaemonProcess()) {
|
|
1660
|
+
commitCentralConfig(USER_AGENTS_DIR);
|
|
1661
|
+
}
|
|
1562
1662
|
}
|
|
1563
1663
|
/** Derive a filesystem-safe local clone path for a package source URL. */
|
|
1564
1664
|
export function getPackageLocalPath(source) {
|
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.77",
|
|
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",
|