@phnx-labs/agents-cli 1.22.51 → 1.22.53

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.
Files changed (117) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/attach.js +7 -0
  5. package/dist/commands/browser.js +118 -56
  6. package/dist/commands/daemon.d.ts +2 -0
  7. package/dist/commands/daemon.js +8 -4
  8. package/dist/commands/detach.js +1 -1
  9. package/dist/commands/exec.js +16 -9
  10. package/dist/commands/fleet-capture.js +7 -0
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +16 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +65 -6
  15. package/dist/commands/monitors.js +1 -1
  16. package/dist/commands/repo.js +31 -3
  17. package/dist/commands/sessions-inject.js +8 -3
  18. package/dist/commands/sessions-picker.js +2 -1
  19. package/dist/commands/sessions-resume.d.ts +1 -0
  20. package/dist/commands/sessions-resume.js +13 -2
  21. package/dist/commands/sessions-stop.js +1 -1
  22. package/dist/commands/sessions.d.ts +23 -13
  23. package/dist/commands/sessions.js +69 -39
  24. package/dist/commands/setup-browser.d.ts +5 -2
  25. package/dist/commands/setup-browser.js +14 -29
  26. package/dist/commands/setup-preferences.d.ts +22 -3
  27. package/dist/commands/setup-preferences.js +25 -8
  28. package/dist/commands/share.js +12 -8
  29. package/dist/commands/ssh.js +35 -12
  30. package/dist/commands/status.js +5 -0
  31. package/dist/commands/sync.js +102 -2
  32. package/dist/commands/tmux.d.ts +8 -1
  33. package/dist/commands/tmux.js +167 -17
  34. package/dist/lib/account-registry.d.ts +15 -5
  35. package/dist/lib/account-registry.js +150 -50
  36. package/dist/lib/answer-router.js +2 -1
  37. package/dist/lib/browser/ipc.d.ts +44 -0
  38. package/dist/lib/browser/ipc.js +120 -8
  39. package/dist/lib/browser/profiles.d.ts +57 -17
  40. package/dist/lib/browser/profiles.js +77 -53
  41. package/dist/lib/browser/registry.d.ts +44 -14
  42. package/dist/lib/browser/registry.js +141 -45
  43. package/dist/lib/browser/runtime-state.d.ts +4 -2
  44. package/dist/lib/browser/runtime-state.js +4 -2
  45. package/dist/lib/browser/service.js +4 -3
  46. package/dist/lib/channels/owner-forward.d.ts +88 -0
  47. package/dist/lib/channels/owner-forward.js +116 -0
  48. package/dist/lib/channels/owner-sink.js +7 -0
  49. package/dist/lib/daemon/runner.js +10 -2
  50. package/dist/lib/device-config.js +3 -2
  51. package/dist/lib/devices/config-migration.js +147 -1
  52. package/dist/lib/devices/device-docs.d.ts +35 -0
  53. package/dist/lib/devices/device-docs.js +163 -0
  54. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  55. package/dist/lib/devices/discovery-policy.js +31 -21
  56. package/dist/lib/devices/registry.d.ts +11 -5
  57. package/dist/lib/devices/registry.js +46 -18
  58. package/dist/lib/exec.d.ts +66 -28
  59. package/dist/lib/exec.js +71 -26
  60. package/dist/lib/feed/feed.d.ts +10 -2
  61. package/dist/lib/feed/feed.js +12 -1
  62. package/dist/lib/feed-broadcast.js +15 -1
  63. package/dist/lib/git.d.ts +93 -0
  64. package/dist/lib/git.js +232 -0
  65. package/dist/lib/hosts/dispatch.d.ts +4 -3
  66. package/dist/lib/hosts/dispatch.js +12 -8
  67. package/dist/lib/hosts/providers/local.d.ts +9 -3
  68. package/dist/lib/hosts/providers/local.js +23 -12
  69. package/dist/lib/hosts/reconnect.d.ts +7 -4
  70. package/dist/lib/hosts/reconnect.js +29 -25
  71. package/dist/lib/hosts/registry.js +4 -1
  72. package/dist/lib/hosts/remote-os.js +3 -1
  73. package/dist/lib/monitors/remote.d.ts +18 -1
  74. package/dist/lib/monitors/remote.js +15 -2
  75. package/dist/lib/notify.d.ts +7 -0
  76. package/dist/lib/notify.js +15 -1
  77. package/dist/lib/session/active.d.ts +10 -1
  78. package/dist/lib/session/active.js +7 -1
  79. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  80. package/dist/lib/session/actor-sidecar.js +2 -0
  81. package/dist/lib/session/db.d.ts +1 -1
  82. package/dist/lib/session/db.js +39 -3
  83. package/dist/lib/session/discover.js +7 -12
  84. package/dist/lib/session/live-metadata.js +1 -0
  85. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  86. package/dist/lib/session/local-tmux-attach.js +164 -0
  87. package/dist/lib/session/pid-registry.d.ts +7 -0
  88. package/dist/lib/session/prompt.d.ts +15 -0
  89. package/dist/lib/session/prompt.js +21 -0
  90. package/dist/lib/session/remote-active.d.ts +8 -0
  91. package/dist/lib/session/remote-active.js +1 -0
  92. package/dist/lib/session/types.d.ts +17 -0
  93. package/dist/lib/session/types.js +10 -0
  94. package/dist/lib/share/publish.d.ts +8 -11
  95. package/dist/lib/share/publish.js +16 -20
  96. package/dist/lib/share/worker-template.js +104 -12
  97. package/dist/lib/state.d.ts +8 -0
  98. package/dist/lib/state.js +143 -11
  99. package/dist/lib/sync-status.d.ts +17 -0
  100. package/dist/lib/sync-status.js +21 -2
  101. package/dist/lib/terminal/resolve.d.ts +7 -0
  102. package/dist/lib/terminal/resolve.js +41 -2
  103. package/dist/lib/tmux/index.d.ts +1 -1
  104. package/dist/lib/tmux/index.js +1 -1
  105. package/dist/lib/tmux/session.d.ts +10 -0
  106. package/dist/lib/tmux/session.js +29 -0
  107. package/dist/lib/traces/insights.d.ts +67 -0
  108. package/dist/lib/traces/insights.js +178 -0
  109. package/dist/lib/traces/phenotype.d.ts +67 -0
  110. package/dist/lib/traces/phenotype.js +437 -0
  111. package/dist/lib/traces/segments.d.ts +133 -0
  112. package/dist/lib/traces/segments.js +301 -0
  113. package/dist/lib/traces/sync.d.ts +33 -0
  114. package/dist/lib/traces/sync.js +11 -2
  115. package/dist/lib/types.d.ts +47 -1
  116. package/dist/lib/watchdog/runner.js +18 -4
  117. package/package.json +1 -1
@@ -36,6 +36,7 @@
36
36
  import { spawnSync } from 'child_process';
37
37
  import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
38
  import { lookupTransport } from './channels/resolve.js';
39
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
39
40
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
41
  const LEVEL_RANK = { milestone: 0, important: 1 };
41
42
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -473,7 +474,20 @@ async function runChannelSink(sink, meta) {
473
474
  if (!provider)
474
475
  return { name, ok: false, error };
475
476
  const result = await deliverEnvelope(resolved.envelope, meta);
476
- return result.ok ? { name, ok: true } : { name, ok: false, error: result.error };
477
+ if (result.ok)
478
+ return { name, ok: true };
479
+ // The owner sink failed locally. When this box structurally cannot reach the
480
+ // owner (the rush-backed channel is macOS-only, so a headless Linux worker can
481
+ // never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
482
+ // over SSH rather than stranding the important post. Only the `owner` alias
483
+ // forwards: it resolves the peer's own fleet-synced owner destination, so a
484
+ // non-owner channel sink with an explicit recipient stays local.
485
+ if (owner) {
486
+ const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
487
+ if (forwarded?.ok)
488
+ return { name, ok: true };
489
+ }
490
+ return { name, ok: false, error: result.error };
477
491
  }
478
492
  /**
479
493
  * Run the planned sinks. A `command:` sink is a direct spawn with a bounded
package/dist/lib/git.d.ts CHANGED
@@ -260,6 +260,99 @@ export declare function adoptRepo(source: string, targetDir: string): Promise<{
260
260
  backedUp: string[];
261
261
  error?: string;
262
262
  }>;
263
+ /** Read an origin remote URL from a git dir, or null when there is none. */
264
+ export declare function readOriginUrl(dir: string): string | null;
265
+ /**
266
+ * Persist the user repo's remote URL to device-local runtime state so a future
267
+ * adopt-in-place can recover it after a `.git` loss. Best-effort — a write
268
+ * failure never blocks a sync.
269
+ */
270
+ export declare function recordUserRepoRemote(dir: string, url: string): void;
271
+ /**
272
+ * Resolve the user config repo's remote URL WITHOUT hardcoding it, for the
273
+ * adopt-in-place self-heal. In priority order:
274
+ * 1. an existing `origin` remote on the dir (a partial repo that kept its
275
+ * `.git` but drifted) — the same source `agents repo sync` already reads;
276
+ * 2. the `AGENTS_USER_REPO_URL` env override (a fresh/never-cloned box);
277
+ * 3. the device-local record written by a prior healthy sync (a box that lost
278
+ * its `.git` but kept `.history/` runtime state).
279
+ * Returns null when none is known — the caller then guides the operator to
280
+ * `agents repo pull user <git-url>` instead of crashing.
281
+ */
282
+ export declare function resolveUserRepoRemoteUrl(dir: string): string | null;
283
+ /**
284
+ * Decide whether a local top-level `agents.yaml` is a stale install stub that
285
+ * should be restored from the committed copy, vs. a legitimately customized file
286
+ * that must be preserved.
287
+ *
288
+ * The stub a partial install leaves behind (createDefaultMeta + a few config
289
+ * writes) is strictly SHORTER than the committed config AND missing whole
290
+ * top-level blocks the committed one carries (`config:` / `hooks:` — the fleet
291
+ * browser hub and hook registrations). Device-specific settings live in
292
+ * `devices/<host>/agents.yaml`, never here, so restoring the top-level file is
293
+ * safe. A file that already carries those blocks (or is longer) is treated as a
294
+ * real local edit and left alone — it surfaces as a modified path instead.
295
+ */
296
+ export declare function isStaleAgentsYamlStub(local: string, committed: string): boolean;
297
+ export interface AdoptInPlaceResult {
298
+ success: boolean;
299
+ commit: string;
300
+ /** Tracked files that were absent locally and materialized from origin/main. */
301
+ materialized: number;
302
+ /** True when the stale-stub top-level agents.yaml was restored from origin. */
303
+ reconciledAgentsYaml: boolean;
304
+ /**
305
+ * When agents.yaml was reconciled, the path the PRE-reconcile local copy was
306
+ * saved to first — so even a false-positive stub match (e.g. a user who
307
+ * deliberately removed a whole `hooks:`/`config:` block) is recoverable, never
308
+ * silently lost.
309
+ */
310
+ agentsYamlBackup?: string;
311
+ /**
312
+ * Tracked paths whose local copy differs from origin/main and was NOT touched
313
+ * — un-gitignored local edits surfaced rather than silently overwritten.
314
+ */
315
+ localEdits: string[];
316
+ error?: string;
317
+ }
318
+ /**
319
+ * Adopt an EXISTING, non-git (or origin-less) `~/.agents` directory in place —
320
+ * git-back it against its remote WITHOUT re-cloning and WITHOUT destroying the
321
+ * runtime state it carries (`.cache` / `.history` / `scratch` / `.system`, all
322
+ * gitignored). The self-heal for a partial install (PHNX-3301): the current code
323
+ * hard-fails with "Not a git repo", and the only manual fix is a destructive
324
+ * re-clone that wipes that runtime state.
325
+ *
326
+ * Plumbing-only, so it never trips the fleet git-guard (no `reset` / `checkout
327
+ * <branch>` / `stash` / `git config`):
328
+ * 1. `git init` + point HEAD at `main`.
329
+ * 2. `git remote add origin <url>`.
330
+ * 3. `git fetch origin main`.
331
+ * 4. `git update-ref refs/heads/main origin/main`; set upstream to origin/main.
332
+ * 5. `git read-tree origin/main` — index = origin/main, working tree untouched.
333
+ * 6. Materialize only the tracked files MISSING from the working tree
334
+ * (`checkout-index` on that set) — existing local files are never overwritten.
335
+ * 7. Reconcile the top-level `agents.yaml`: restore it from origin/main only
336
+ * when the local copy is a stale stub ({@link isStaleAgentsYamlStub}).
337
+ *
338
+ * Idempotent: a second run finds the remote/refs already present and simply
339
+ * re-materializes nothing. Any tracked path with real local edits is returned in
340
+ * `localEdits` (surfaced, never clobbered).
341
+ */
342
+ export declare function adoptRepoInPlace(dir: string, remoteUrl: string): Promise<AdoptInPlaceResult>;
343
+ /**
344
+ * Self-heal entry point for the USER config repo: when `dir` is not a git repo
345
+ * (or is a repo with no `origin`), resolve its remote URL and adopt it in place;
346
+ * otherwise return null (nothing to adopt — the normal sync path runs). Returns a
347
+ * failed result carrying `needsUrl` when the URL cannot be resolved, so the
348
+ * caller can print the `agents repo pull user <git-url>` remediation instead of
349
+ * the old "Not a git repo" crash.
350
+ */
351
+ export declare function adoptUserRepoIfNeeded(dir: string, opts?: {
352
+ explicitUrl?: string;
353
+ }): Promise<(AdoptInPlaceResult & {
354
+ needsUrl?: boolean;
355
+ }) | null>;
263
356
  /**
264
357
  * Check if the repo's origin points to the system repo.
265
358
  */
package/dist/lib/git.js CHANGED
@@ -829,6 +829,238 @@ export async function adoptRepo(source, targetDir) {
829
829
  return { success: false, commit: '', backedUp: [], error: err.message };
830
830
  }
831
831
  }
832
+ /**
833
+ * Device-local record of the user config repo's remote URL, kept OUTSIDE the git
834
+ * tree so it survives a lost `.git` (`.history/` is gitignored runtime state).
835
+ * This is what lets `agents repo sync user` adopt-in-place a box that was healthy
836
+ * once and later lost its checkout, without the operator re-typing the URL.
837
+ */
838
+ function userRepoRemoteRecordPath(dir) {
839
+ return path.join(dir, '.history', 'user-repo-remote.json');
840
+ }
841
+ /** Read an origin remote URL from a git dir, or null when there is none. */
842
+ export function readOriginUrl(dir) {
843
+ if (!isGitRepo(dir))
844
+ return null;
845
+ try {
846
+ const url = execFileSync('git', ['-C', dir, 'config', '--get', 'remote.origin.url'], {
847
+ encoding: 'utf-8',
848
+ }).trim();
849
+ return url || null;
850
+ }
851
+ catch {
852
+ return null;
853
+ }
854
+ }
855
+ /**
856
+ * Persist the user repo's remote URL to device-local runtime state so a future
857
+ * adopt-in-place can recover it after a `.git` loss. Best-effort — a write
858
+ * failure never blocks a sync.
859
+ */
860
+ export function recordUserRepoRemote(dir, url) {
861
+ try {
862
+ const file = userRepoRemoteRecordPath(dir);
863
+ fs.mkdirSync(path.dirname(file), { recursive: true });
864
+ fs.writeFileSync(file, JSON.stringify({ url }, null, 2) + '\n', { mode: 0o600 });
865
+ }
866
+ catch {
867
+ /* runtime cache write is best-effort */
868
+ }
869
+ }
870
+ /**
871
+ * Resolve the user config repo's remote URL WITHOUT hardcoding it, for the
872
+ * adopt-in-place self-heal. In priority order:
873
+ * 1. an existing `origin` remote on the dir (a partial repo that kept its
874
+ * `.git` but drifted) — the same source `agents repo sync` already reads;
875
+ * 2. the `AGENTS_USER_REPO_URL` env override (a fresh/never-cloned box);
876
+ * 3. the device-local record written by a prior healthy sync (a box that lost
877
+ * its `.git` but kept `.history/` runtime state).
878
+ * Returns null when none is known — the caller then guides the operator to
879
+ * `agents repo pull user <git-url>` instead of crashing.
880
+ */
881
+ export function resolveUserRepoRemoteUrl(dir) {
882
+ const fromOrigin = readOriginUrl(dir);
883
+ if (fromOrigin)
884
+ return fromOrigin;
885
+ const fromEnv = process.env.AGENTS_USER_REPO_URL?.trim();
886
+ if (fromEnv)
887
+ return fromEnv;
888
+ try {
889
+ const raw = fs.readFileSync(userRepoRemoteRecordPath(dir), 'utf-8');
890
+ const url = JSON.parse(raw).url?.trim();
891
+ if (url)
892
+ return url;
893
+ }
894
+ catch {
895
+ /* no record yet */
896
+ }
897
+ return null;
898
+ }
899
+ /**
900
+ * Decide whether a local top-level `agents.yaml` is a stale install stub that
901
+ * should be restored from the committed copy, vs. a legitimately customized file
902
+ * that must be preserved.
903
+ *
904
+ * The stub a partial install leaves behind (createDefaultMeta + a few config
905
+ * writes) is strictly SHORTER than the committed config AND missing whole
906
+ * top-level blocks the committed one carries (`config:` / `hooks:` — the fleet
907
+ * browser hub and hook registrations). Device-specific settings live in
908
+ * `devices/<host>/agents.yaml`, never here, so restoring the top-level file is
909
+ * safe. A file that already carries those blocks (or is longer) is treated as a
910
+ * real local edit and left alone — it surfaces as a modified path instead.
911
+ */
912
+ export function isStaleAgentsYamlStub(local, committed) {
913
+ if (local.trim() === committed.trim())
914
+ return false;
915
+ const shorter = local.split('\n').length < committed.split('\n').length;
916
+ const missingBlock = (/^config:/m.test(committed) && !/^config:/m.test(local)) ||
917
+ (/^hooks:/m.test(committed) && !/^hooks:/m.test(local));
918
+ return shorter && missingBlock;
919
+ }
920
+ /**
921
+ * Adopt an EXISTING, non-git (or origin-less) `~/.agents` directory in place —
922
+ * git-back it against its remote WITHOUT re-cloning and WITHOUT destroying the
923
+ * runtime state it carries (`.cache` / `.history` / `scratch` / `.system`, all
924
+ * gitignored). The self-heal for a partial install (PHNX-3301): the current code
925
+ * hard-fails with "Not a git repo", and the only manual fix is a destructive
926
+ * re-clone that wipes that runtime state.
927
+ *
928
+ * Plumbing-only, so it never trips the fleet git-guard (no `reset` / `checkout
929
+ * <branch>` / `stash` / `git config`):
930
+ * 1. `git init` + point HEAD at `main`.
931
+ * 2. `git remote add origin <url>`.
932
+ * 3. `git fetch origin main`.
933
+ * 4. `git update-ref refs/heads/main origin/main`; set upstream to origin/main.
934
+ * 5. `git read-tree origin/main` — index = origin/main, working tree untouched.
935
+ * 6. Materialize only the tracked files MISSING from the working tree
936
+ * (`checkout-index` on that set) — existing local files are never overwritten.
937
+ * 7. Reconcile the top-level `agents.yaml`: restore it from origin/main only
938
+ * when the local copy is a stale stub ({@link isStaleAgentsYamlStub}).
939
+ *
940
+ * Idempotent: a second run finds the remote/refs already present and simply
941
+ * re-materializes nothing. Any tracked path with real local edits is returned in
942
+ * `localEdits` (surfaced, never clobbered).
943
+ */
944
+ export async function adoptRepoInPlace(dir, remoteUrl) {
945
+ const empty = {
946
+ success: false,
947
+ commit: '',
948
+ materialized: 0,
949
+ reconciledAgentsYaml: false,
950
+ localEdits: [],
951
+ };
952
+ const trimmed = remoteUrl.trim();
953
+ try {
954
+ // The URL is always a resolved git remote (origin / env / record), never a
955
+ // `gh:` shorthand — so skip parseSource (which THROWS on ssh:// and rewrites
956
+ // git@github -> https, breaking SSH-key-only auth). assertSafeGitTransport
957
+ // still blocks the dangerous transports (ext::, file://, option injection)
958
+ // while permitting https / ssh / scp-style / a local bare repo.
959
+ assertSafeGitTransport(trimmed);
960
+ if (!fs.existsSync(dir)) {
961
+ return { ...empty, error: `Target directory does not exist: ${dir}` };
962
+ }
963
+ // Non-interactive git — fail fast on a missing credential instead of hanging
964
+ // on a prompt (same rationale as adoptRepo).
965
+ process.env.GIT_TERMINAL_PROMPT = '0';
966
+ const git = simpleGit(dir);
967
+ // 1. init + HEAD -> main (idempotent: init on an existing repo is a no-op).
968
+ // Set HEAD via symbolic-ref rather than `init -b main` so it works on git
969
+ // < 2.28, and lands on `main` even if the repo already initialized as
970
+ // `master`.
971
+ if (!isGitRepo(dir))
972
+ await git.init();
973
+ await git.raw(['symbolic-ref', 'HEAD', 'refs/heads/main']);
974
+ // 2. origin — add only when absent, so a re-run keeps the existing remote.
975
+ const remotes = await git.getRemotes(true);
976
+ if (!remotes.some((r) => r.name === 'origin')) {
977
+ await git.raw(['remote', 'add', 'origin', trimmed]);
978
+ }
979
+ // 3-4. fetch, plant the local main on origin/main, set upstream.
980
+ await git.raw(['fetch', 'origin', 'main']);
981
+ await git.raw(['update-ref', 'refs/heads/main', 'origin/main']);
982
+ await git.raw(['branch', '--set-upstream-to=origin/main', 'main']);
983
+ // 5. index = origin/main, working tree untouched.
984
+ await git.raw(['read-tree', 'origin/main']);
985
+ // 6. Materialize only the tracked files MISSING on disk. Passing the explicit
986
+ // missing set (never `checkout-index -a`) guarantees no existing local
987
+ // file — a stub agents.yaml, a modified rule — is overwritten. Chunked to
988
+ // stay under the argv limit on a cold box where most files are missing.
989
+ const tracked = (await git.raw(['ls-files', '-z'])).split('\0').filter(Boolean);
990
+ const missing = tracked.filter((rel) => !fs.existsSync(path.join(dir, rel)));
991
+ for (let i = 0; i < missing.length; i += 500) {
992
+ await git.raw(['checkout-index', '-f', '--', ...missing.slice(i, i + 500)]);
993
+ }
994
+ // 7. Reconcile a stale-stub top-level agents.yaml from origin/main. `restore`
995
+ // is plumbing the git-guard allows; it rewrites only this one path.
996
+ let reconciledAgentsYaml = false;
997
+ let agentsYamlBackup;
998
+ if (tracked.includes('agents.yaml')) {
999
+ const abs = path.join(dir, 'agents.yaml');
1000
+ const local = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf-8') : '';
1001
+ const committed = await git.raw(['show', 'origin/main:agents.yaml']);
1002
+ if (isStaleAgentsYamlStub(local, committed)) {
1003
+ // The stub heuristic can't perfectly distinguish a partial-install stub
1004
+ // from a user who deliberately removed a whole block, so save the local
1005
+ // copy to gitignored runtime state BEFORE restoring — a false positive is
1006
+ // then recoverable and surfaced, never silent data loss.
1007
+ if (local) {
1008
+ agentsYamlBackup = path.join(dir, '.history', 'agents.yaml.pre-adopt.bak');
1009
+ fs.mkdirSync(path.dirname(agentsYamlBackup), { recursive: true });
1010
+ fs.writeFileSync(agentsYamlBackup, local);
1011
+ }
1012
+ await git.raw(['restore', '--source=origin/main', '--', 'agents.yaml']);
1013
+ reconciledAgentsYaml = true;
1014
+ }
1015
+ }
1016
+ // Surface — never silently keep — any tracked path whose local copy still
1017
+ // differs from origin/main after the reconcile (real un-gitignored edits).
1018
+ const dirty = (await git.raw(['status', '--porcelain', '--untracked-files=no']))
1019
+ .split('\n')
1020
+ .map((l) => l.slice(3).trim())
1021
+ .filter(Boolean);
1022
+ installGithooksSymlinks(dir);
1023
+ recordUserRepoRemote(dir, trimmed);
1024
+ const commit = (await git.raw(['rev-parse', '--short', 'HEAD'])).trim();
1025
+ return {
1026
+ success: true,
1027
+ commit,
1028
+ materialized: missing.length,
1029
+ reconciledAgentsYaml,
1030
+ ...(agentsYamlBackup ? { agentsYamlBackup } : {}),
1031
+ localEdits: dirty,
1032
+ };
1033
+ }
1034
+ catch (err) {
1035
+ return { ...empty, error: err.message };
1036
+ }
1037
+ }
1038
+ /**
1039
+ * Self-heal entry point for the USER config repo: when `dir` is not a git repo
1040
+ * (or is a repo with no `origin`), resolve its remote URL and adopt it in place;
1041
+ * otherwise return null (nothing to adopt — the normal sync path runs). Returns a
1042
+ * failed result carrying `needsUrl` when the URL cannot be resolved, so the
1043
+ * caller can print the `agents repo pull user <git-url>` remediation instead of
1044
+ * the old "Not a git repo" crash.
1045
+ */
1046
+ export async function adoptUserRepoIfNeeded(dir, opts = {}) {
1047
+ const hasOrigin = isGitRepo(dir) && readOriginUrl(dir) !== null;
1048
+ if (hasOrigin)
1049
+ return null;
1050
+ const url = opts.explicitUrl?.trim() || resolveUserRepoRemoteUrl(dir);
1051
+ if (!url) {
1052
+ return {
1053
+ success: false,
1054
+ commit: '',
1055
+ materialized: 0,
1056
+ reconciledAgentsYaml: false,
1057
+ localEdits: [],
1058
+ needsUrl: true,
1059
+ error: `${displayHomePath(dir)} is not a git repo and no remote URL is known.`,
1060
+ };
1061
+ }
1062
+ return adoptRepoInPlace(dir, url);
1063
+ }
832
1064
  /**
833
1065
  * Check if the repo's origin points to the system repo.
834
1066
  */
@@ -40,9 +40,10 @@ export declare function withActorEnv(env?: Record<string, string>): Record<strin
40
40
  *
41
41
  * `extra` carries the markers that are true of ONE path rather than both — the
42
42
  * interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
43
- * to decide it must detach. It goes through this builder rather than being
44
- * concatenated on at the call site so every remote env marker is exported the
45
- * same way, in one place.
43
+ * to know its stdio is an ssh link (reconnect target, `--no-follow` pane
44
+ * requirement). It goes through this builder rather than being concatenated on
45
+ * at the call site so every remote env marker is exported the same way, in one
46
+ * place.
46
47
  */
47
48
  export declare function remoteRunShellPrelude(agent: string, extra?: Record<string, string>): string;
48
49
  /**
@@ -78,9 +78,10 @@ export function withActorEnv(env) {
78
78
  *
79
79
  * `extra` carries the markers that are true of ONE path rather than both — the
80
80
  * interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
81
- * to decide it must detach. It goes through this builder rather than being
82
- * concatenated on at the call site so every remote env marker is exported the
83
- * same way, in one place.
81
+ * to know its stdio is an ssh link (reconnect target, `--no-follow` pane
82
+ * requirement). It goes through this builder rather than being concatenated on
83
+ * at the call site so every remote env marker is exported the same way, in one
84
+ * place.
84
85
  */
85
86
  export function remoteRunShellPrelude(agent, extra = {}) {
86
87
  const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
@@ -497,11 +498,14 @@ export async function runInteractiveOnHost(host, opts) {
497
498
  // than re-resolving from this box's SSH_CONNECTION (RUSH-2028); a `run auto`
498
499
  // dispatch also gets the chain-hop guard (remoteRunShellPrelude).
499
500
  //
500
- // REMOTE_INTERACTIVE_ENV rides the same prelude and is what makes the remote
501
- // agent DETACHED: its stdio is this ssh link, so without the tmux wrap a
502
- // blink SIGHUPs it and the in-flight turn is gone — while reconnect.ts is
503
- // built to re-attach a pane it assumes survived (RUSH-3125). Set here, on the
504
- // interactive path only: `launchDetached` already setsids the headless one.
501
+ // REMOTE_INTERACTIVE_ENV rides the same prelude and tells the remote CLI its
502
+ // stdio is this ssh link: it marks the run as reconnect-managed (a drop is
503
+ // answered by reconnect.ts, which rejoins the live pane or resumes the
504
+ // session in place) and, when the launcher has no TTY (CI, scripts), requires
505
+ // the detached pane that is the run's only interface. Since PHNX-3316 it no
506
+ // longer forces the tmux wrap on a TTY-followed run — the peer's
507
+ // tmux.enabled decides that. Set here, on the interactive path only:
508
+ // `launchDetached` already setsids the headless one.
505
509
  const prelude = remoteRunShellPrelude(opts.agent, { [REMOTE_INTERACTIVE_ENV]: '1' });
506
510
  let remoteCmd = `${prelude}${cwd}${invocation}`;
507
511
  if (opts.copyCreds) {
@@ -2,9 +2,15 @@
2
2
  * Local host provider: the v1 directory.
3
3
  *
4
4
  * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
- * details owned by ssh) and inline entries the user registered in agents.yaml.
6
- * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
- * never copy or rewrite ssh config.
5
+ * details owned by ssh) and inline entries the user registered. The host
6
+ * overlay (caps/os, keyed by name) is merged onto both. We never copy or
7
+ * rewrite ssh config.
8
+ *
9
+ * PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
10
+ * `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
11
+ * so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
12
+ * Reads are the cross-box UNION of every device doc, plus any lingering central
13
+ * legacy entries drained by the migration.
8
14
  */
9
15
  import type { Host, HostProvider, HostProviderCapabilities } from '../types.js';
10
16
  export declare class LocalHostProvider implements HostProvider {
@@ -2,14 +2,28 @@
2
2
  * Local host provider: the v1 directory.
3
3
  *
4
4
  * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
- * details owned by ssh) and inline entries the user registered in agents.yaml.
6
- * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
- * never copy or rewrite ssh config.
5
+ * details owned by ssh) and inline entries the user registered. The host
6
+ * overlay (caps/os, keyed by name) is merged onto both. We never copy or
7
+ * rewrite ssh config.
8
+ *
9
+ * PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
10
+ * `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
11
+ * so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
12
+ * Reads are the cross-box UNION of every device doc, plus any lingering central
13
+ * legacy entries drained by the migration.
8
14
  */
9
15
  import { readMeta, updateMeta } from '../../state.js';
16
+ import { unionDeviceHosts } from '../../devices/device-docs.js';
10
17
  import { listSshConfigHosts, isSshConfigHost } from '../ssh-config.js';
18
+ /** The EFFECTIVE host overlay: the cross-box union of every device doc's
19
+ * `hosts:` block, with any lingering central-legacy `hosts:` entries as a base
20
+ * (a device doc wins on a name collision). Newest `addedAt` wins across boxes. */
11
21
  function entries() {
12
- return readMeta().hosts ?? {};
22
+ return { ...readMeta().hosts, ...unionDeviceHosts() };
23
+ }
24
+ /** THIS box's OWN host registrations (the writable slice in the device doc). */
25
+ function ownEntries(meta = readMeta()) {
26
+ return meta.deviceHosts ?? {};
13
27
  }
14
28
  function toHost(name, entry, enrolled) {
15
29
  return {
@@ -63,19 +77,16 @@ export class LocalHostProvider {
63
77
  ...(spec.caps && spec.caps.length ? { caps: spec.caps } : {}),
64
78
  addedAt: spec.addedAt ?? new Date().toISOString(),
65
79
  };
66
- updateMeta((meta) => ({ ...meta, hosts: { ...(meta.hosts ?? {}), [spec.name]: entry } }));
80
+ // Device-scoped: land in THIS box's device doc, never the shared central map.
81
+ updateMeta((meta) => ({ ...meta, deviceHosts: { ...ownEntries(meta), [spec.name]: entry } }));
67
82
  return toHost(spec.name, entry, true);
68
83
  }
69
84
  async remove(name) {
85
+ // A box only owns the registrations in its own device doc; drop from there.
70
86
  updateMeta((meta) => {
71
- const hosts = { ...(meta.hosts ?? {}) };
87
+ const hosts = { ...ownEntries(meta) };
72
88
  delete hosts[name];
73
- // Drop the key entirely when empty so we don't leave `hosts: {}` behind.
74
- if (Object.keys(hosts).length === 0) {
75
- const { hosts: _omit, ...rest } = meta;
76
- return rest;
77
- }
78
- return { ...meta, hosts };
89
+ return { ...meta, deviceHosts: hosts };
79
90
  });
80
91
  }
81
92
  }
@@ -211,10 +211,13 @@ export declare function startConnectionTarget(opts: {
211
211
  /**
212
212
  * Decide what happens after an interactive `--device` stream returns.
213
213
  *
214
- * Auto-reconnect only when the link dropped (255) AND the run is tmux-hosted
215
- * (`willReconnect`). `--raw` is not wrapped, so it never reconnects — but it
216
- * still prints the session id: the user is at a shell, and EXEC-55 does not
217
- * exempt raw (RUSH-3227). Bundling the notice behind `!isRaw` was the miss.
214
+ * Auto-reconnect fires whenever the link dropped (255) and the run did not opt
215
+ * out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
216
+ * survived, so the reattach rejoins it; a bare run's agent died with the link,
217
+ * so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
218
+ * never reconnects — but it still prints the session id: the user is at a
219
+ * shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
220
+ * behind `!isRaw` was the miss.
218
221
  */
219
222
  export declare function afterInteractiveRemoteExit(opts: {
220
223
  target?: ReconnectTarget;
@@ -2,34 +2,35 @@
2
2
  * Auto-reconnect for an interactive `agents run --device` session whose
3
3
  * SSH link dropped.
4
4
  *
5
- * A remote interactive agent runs in a DETACHED tmux session on the peer (see
6
- * lib/exec.ts `runInTmux`), so a network blink kills only the local ssh client —
7
- * the agent keeps running.
8
- *
9
- * **That premise is a guarantee, not a hope, only since RUSH-3125.** It used to
10
- * rest on the peer's `tmux.enabled`, an ergonomics preference that defaults OFF
11
- * — so on a default box the agent was a child of the sshd session, a blink
12
- * SIGHUPed it, and this file reconnected to a corpse while telling the user it
13
- * was "still running there." The interactive dispatch now exports
14
- * REMOTE_INTERACTIVE_ENV and exec.ts `resolveTmuxWrap` wraps on it regardless of
15
- * that toggle, so what is written below actually holds. Anything that would let
16
- * a remote interactive run reach the peer unwrapped breaks this whole file.
5
+ * What the reconnect lands on depends on the peer's `tmux.enabled`
6
+ * (PHNX-3316). With the wrap opted in, the agent runs in a DETACHED tmux
7
+ * session on the peer (see lib/exec.ts `runInTmux`), so a network blink kills
8
+ * only the local ssh client and the reattach below rejoins the live pane.
9
+ * With the wrap off — the fleet default — the remote agent is a child of the
10
+ * sshd session and a blink SIGHUPs it: the in-flight turn is lost, and the
11
+ * reattach RESUMES the harness session from disk instead. Both outcomes go
12
+ * through the same verb below; what changed in PHNX-3316 is that "resumed"
13
+ * is once again an honest, expected result rather than a corpse this file
14
+ * pretended was alive (the pre-RUSH-3125 bug), and RUSH-3125's forced wrap —
15
+ * which made the pane a guarantee by overriding the operator's tmux.enabled —
16
+ * is gone with it.
17
17
  *
18
18
  * `sshStream` reports that drop as exit code 255 (ssh's
19
19
  * own connection-layer failure; see ssh-exec.ts). Without this, exec.ts would
20
20
  * `process.exit(255)` and the user would have to notice, find the session id, and
21
- * `agents sessions focus` by hand. Instead we re-attach the live remote pane over
22
- * SSH automatically, with bounded backoff, until the user detaches cleanly (the
23
- * remote returns 0), the agent exits (the tmux session is gone; a non-255 code),
24
- * or the user interrupts the wait with Ctrl-C ({@link waitOrInterrupt} → 130).
21
+ * `agents sessions focus` by hand. Instead we re-attach over SSH automatically,
22
+ * with bounded backoff, until the user detaches cleanly (the remote returns 0),
23
+ * the agent exits (a non-255 code), or the user interrupts the wait with
24
+ * Ctrl-C ({@link waitOrInterrupt} → 130).
25
25
  *
26
26
  * The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
27
27
  * --local` — which JOINS the live local tmux pane there (a second client, no fork)
28
- * when it still exists, and RESUMES the session in place when the pane is already
29
- * gone. Dropping `--attach-only` is deliberate: a reattach that lands after the
30
- * remote pane died must not dead-end at a bare shell (the RUSH-2085 bug), it must
31
- * fall through to resume so the user is put back into the agent. There is one
32
- * re-attach implementation (the peer's focus) to keep in sync.
28
+ * when it exists, and RESUMES the session in place when there is no pane — which
29
+ * is every drop on a default (wrap-off) box. Dropping `--attach-only` is
30
+ * deliberate: a reattach that finds no pane must not dead-end at a bare shell
31
+ * (the RUSH-2085 bug), it must fall through to resume so the user is put back
32
+ * into the agent. There is one re-attach implementation (the peer's focus) to
33
+ * keep in sync.
33
34
  *
34
35
  * **What it takes to refill the budget: reached the host AND held the pane.** ssh
35
36
  * returns 255 for BOTH "couldn't connect at all" and "connected, then the link
@@ -314,10 +315,13 @@ export function startConnectionTarget(opts) {
314
315
  /**
315
316
  * Decide what happens after an interactive `--device` stream returns.
316
317
  *
317
- * Auto-reconnect only when the link dropped (255) AND the run is tmux-hosted
318
- * (`willReconnect`). `--raw` is not wrapped, so it never reconnects — but it
319
- * still prints the session id: the user is at a shell, and EXEC-55 does not
320
- * exempt raw (RUSH-3227). Bundling the notice behind `!isRaw` was the miss.
318
+ * Auto-reconnect fires whenever the link dropped (255) and the run did not opt
319
+ * out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
320
+ * survived, so the reattach rejoins it; a bare run's agent died with the link,
321
+ * so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
322
+ * never reconnects — but it still prints the session id: the user is at a
323
+ * shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
324
+ * behind `!isRaw` was the miss.
321
325
  */
322
326
  export function afterInteractiveRemoteExit(opts) {
323
327
  if (!opts.target)
@@ -22,6 +22,7 @@ import { DevicesHostProvider } from './providers/devices.js';
22
22
  import { assertValidSshTarget } from '../ssh-exec.js';
23
23
  import { normalizeHost } from '../machine-id.js';
24
24
  import { readMeta } from '../state.js';
25
+ import { unionDeviceHosts } from '../devices/device-docs.js';
25
26
  import { isSshConfigHost } from './ssh-config.js';
26
27
  import { resolveRemoteOsSync } from './remote-os.js';
27
28
  import { loadDevices } from '../devices/registry.js';
@@ -187,7 +188,9 @@ export async function matchHost(name, opts = {}) {
187
188
  catch {
188
189
  reg = {};
189
190
  }
190
- const overlay = readMeta().hosts?.[host];
191
+ // The effective host overlay is the cross-box union of every device doc's
192
+ // `hosts:` block (PHNX-3315), plus any lingering central-legacy entry.
193
+ const overlay = { ...readMeta().hosts, ...unionDeviceHosts() }[host];
191
194
  // 1. A registered device (normalized match) — its live address/OS/presence win.
192
195
  const device = matchDevice(host, reg);
193
196
  if (device)
@@ -19,6 +19,7 @@
19
19
  import { loadDevicesSync } from '../devices/registry.js';
20
20
  import { readDeviceConfigValues } from '../device-config.js';
21
21
  import { readMeta } from '../state.js';
22
+ import { unionDeviceHosts } from '../devices/device-docs.js';
22
23
  /** Resolve the OS/platform string for a host name, or undefined if unknown. */
23
24
  export function resolveRemoteOsSync(name) {
24
25
  try {
@@ -33,5 +34,6 @@ export function resolveRemoteOsSync(name) {
33
34
  // A corrupt/unreadable device registry must never break command building —
34
35
  // fall through to the host overlay and ultimately the POSIX default.
35
36
  }
36
- return readMeta().hosts?.[name]?.os;
37
+ // Cross-box union of the device-scoped host overlays, central legacy as base.
38
+ return ({ ...readMeta().hosts, ...unionDeviceHosts() }[name])?.os;
37
39
  }