@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.
- package/CHANGELOG.md +238 -0
- package/README.md +1 -1
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.js +118 -56
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/exec.js +16 -9
- package/dist/commands/fleet-capture.js +7 -0
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +16 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +65 -6
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-inject.js +8 -3
- package/dist/commands/sessions-picker.js +2 -1
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +69 -39
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/ssh.js +35 -12
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +102 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/account-registry.d.ts +15 -5
- package/dist/lib/account-registry.js +150 -50
- package/dist/lib/answer-router.js +2 -1
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +57 -17
- package/dist/lib/browser/profiles.js +77 -53
- package/dist/lib/browser/registry.d.ts +44 -14
- package/dist/lib/browser/registry.js +141 -45
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/daemon/runner.js +10 -2
- package/dist/lib/device-config.js +3 -2
- package/dist/lib/devices/config-migration.js +147 -1
- package/dist/lib/devices/device-docs.d.ts +35 -0
- package/dist/lib/devices/device-docs.js +163 -0
- package/dist/lib/devices/discovery-policy.d.ts +14 -2
- package/dist/lib/devices/discovery-policy.js +31 -21
- package/dist/lib/devices/registry.d.ts +11 -5
- package/dist/lib/devices/registry.js +46 -18
- package/dist/lib/exec.d.ts +66 -28
- package/dist/lib/exec.js +71 -26
- package/dist/lib/feed/feed.d.ts +10 -2
- package/dist/lib/feed/feed.js +12 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/hosts/dispatch.d.ts +4 -3
- package/dist/lib/hosts/dispatch.js +12 -8
- package/dist/lib/hosts/providers/local.d.ts +9 -3
- package/dist/lib/hosts/providers/local.js +23 -12
- package/dist/lib/hosts/reconnect.d.ts +7 -4
- package/dist/lib/hosts/reconnect.js +29 -25
- package/dist/lib/hosts/registry.js +4 -1
- package/dist/lib/hosts/remote-os.js +3 -1
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/active.d.ts +10 -1
- package/dist/lib/session/active.js +7 -1
- package/dist/lib/session/actor-sidecar.d.ts +7 -0
- package/dist/lib/session/actor-sidecar.js +2 -0
- package/dist/lib/session/db.d.ts +1 -1
- package/dist/lib/session/db.js +39 -3
- package/dist/lib/session/discover.js +7 -12
- package/dist/lib/session/live-metadata.js +1 -0
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/pid-registry.d.ts +7 -0
- package/dist/lib/session/prompt.d.ts +15 -0
- package/dist/lib/session/prompt.js +21 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/session/types.d.ts +17 -0
- package/dist/lib/session/types.js +10 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +104 -12
- package/dist/lib/state.d.ts +8 -0
- package/dist/lib/state.js +143 -11
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/terminal/resolve.d.ts +7 -0
- package/dist/lib/terminal/resolve.js +41 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/insights.d.ts +67 -0
- package/dist/lib/traces/insights.js +178 -0
- package/dist/lib/traces/phenotype.d.ts +67 -0
- package/dist/lib/traces/phenotype.js +437 -0
- package/dist/lib/traces/segments.d.ts +133 -0
- package/dist/lib/traces/segments.js +301 -0
- package/dist/lib/traces/sync.d.ts +33 -0
- package/dist/lib/traces/sync.js +11 -2
- package/dist/lib/types.d.ts +47 -1
- package/dist/lib/watchdog/runner.js +18 -4
- 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
|
-
|
|
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
|
|
44
|
-
*
|
|
45
|
-
* same way, in one
|
|
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
|
|
82
|
-
*
|
|
83
|
-
* same way, in one
|
|
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
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
|
87
|
+
const hosts = { ...ownEntries(meta) };
|
|
72
88
|
delete hosts[name];
|
|
73
|
-
|
|
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
|
|
215
|
-
* (`willReconnect`)
|
|
216
|
-
*
|
|
217
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* the
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* fall through to resume so the user is put back
|
|
32
|
-
* re-attach implementation (the peer's focus) to
|
|
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
|
|
318
|
-
* (`willReconnect`)
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
// Cross-box union of the device-scoped host overlays, central legacy as base.
|
|
38
|
+
return ({ ...readMeta().hosts, ...unionDeviceHosts() }[name])?.os;
|
|
37
39
|
}
|