@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 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
- return withMetaLock(() => {
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;
@@ -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): void;
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
- writeMeta(meta);
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
- return withMetaLock(() => {
1637
+ let centralChanged = false;
1638
+ const newMeta = withMetaLock(() => {
1555
1639
  const meta = readMeta();
1556
- const newMeta = typeof updates === 'function'
1640
+ const nm = typeof updates === 'function'
1557
1641
  ? updates(meta)
1558
1642
  : { ...meta, ...updates };
1559
- writeMetaUnlocked(newMeta);
1560
- return newMeta;
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.76",
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",