@phnx-labs/agents-cli 1.22.51 → 1.22.52
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 +92 -0
- 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/focus.d.ts +1 -10
- package/dist/commands/focus.js +14 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +63 -5
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- 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 +40 -20
- 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/status.js +5 -0
- package/dist/commands/sync.js +58 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +39 -17
- package/dist/lib/browser/profiles.js +51 -52
- 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/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- 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/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/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -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 +99 -12
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -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/package.json +1 -1
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
|
*/
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* an agent's per-PR watcher is running state, not config, and syncing would push
|
|
17
17
|
* every one of them onto every box — the accumulation this guard exists to stop.
|
|
18
18
|
*/
|
|
19
|
+
import { type GatherRemoteAgentsJsonDeps } from '../remote-agents-json.js';
|
|
19
20
|
import type { MonitorConfig } from './config.js';
|
|
20
21
|
/** Recursion guard: a peer answering the fan-out must not fan out again. */
|
|
21
22
|
export declare const NO_MONITOR_FANOUT_ENV = "AGENTS_MONITORS_LOCAL";
|
|
@@ -39,9 +40,25 @@ export interface FleetMonitorsResult {
|
|
|
39
40
|
* silently treating "we could not ask" as "there is no duplicate". */
|
|
40
41
|
skipped: string[];
|
|
41
42
|
}
|
|
43
|
+
export interface GatherFleetMonitorsOptions {
|
|
44
|
+
/** When supplied, the fan-out aborts as soon as any peer returns a monitor with
|
|
45
|
+
* this behavioral fingerprint. The miss path still waits for every peer so the
|
|
46
|
+
* guard can prove absence fleet-wide. */
|
|
47
|
+
againstFingerprint?: string;
|
|
48
|
+
/** Optional test seam for the SSH boundary; production uses the real capture. */
|
|
49
|
+
deps?: GatherRemoteAgentsJsonDeps;
|
|
50
|
+
/** Optional explicit host list; production omits it and asks the device registry. */
|
|
51
|
+
hosts?: string[];
|
|
52
|
+
}
|
|
42
53
|
/**
|
|
43
54
|
* Every monitor on every other registered device. Never throws: an unreachable
|
|
44
55
|
* fleet degrades to an empty list plus the names we could not consult, and the
|
|
45
56
|
* caller decides what to say about them.
|
|
57
|
+
*
|
|
58
|
+
* When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
|
|
59
|
+
* returning that fingerprint is a definitive clash: the remaining peers are
|
|
60
|
+
* SIGTERM'd immediately rather than burning the rest of the timeout budget.
|
|
61
|
+
* Absence of a clash still waits for the full fleet, because uniqueness is only
|
|
62
|
+
* knowable once every peer has answered.
|
|
46
63
|
*/
|
|
47
|
-
export declare function gatherFleetMonitors(): Promise<FleetMonitorsResult>;
|
|
64
|
+
export declare function gatherFleetMonitors(options?: GatherFleetMonitorsOptions): Promise<FleetMonitorsResult>;
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* every one of them onto every box — the accumulation this guard exists to stop.
|
|
18
18
|
*/
|
|
19
19
|
import { gatherRemoteAgentsJson } from '../remote-agents-json.js';
|
|
20
|
+
import { monitorFingerprint } from './fingerprint.js';
|
|
20
21
|
/** Recursion guard: a peer answering the fan-out must not fan out again. */
|
|
21
22
|
export const NO_MONITOR_FANOUT_ENV = 'AGENTS_MONITORS_LOCAL';
|
|
22
23
|
/**
|
|
@@ -58,15 +59,27 @@ export function parseRemoteMonitors(stdout, machine) {
|
|
|
58
59
|
* Every monitor on every other registered device. Never throws: an unreachable
|
|
59
60
|
* fleet degrades to an empty list plus the names we could not consult, and the
|
|
60
61
|
* caller decides what to say about them.
|
|
62
|
+
*
|
|
63
|
+
* When {@link GatherFleetMonitorsOptions.againstFingerprint} is provided, a peer
|
|
64
|
+
* returning that fingerprint is a definitive clash: the remaining peers are
|
|
65
|
+
* SIGTERM'd immediately rather than burning the rest of the timeout budget.
|
|
66
|
+
* Absence of a clash still waits for the full fleet, because uniqueness is only
|
|
67
|
+
* knowable once every peer has answered.
|
|
61
68
|
*/
|
|
62
|
-
export async function gatherFleetMonitors() {
|
|
69
|
+
export async function gatherFleetMonitors(options = {}) {
|
|
63
70
|
try {
|
|
64
71
|
const result = await gatherRemoteAgentsJson({
|
|
65
72
|
args: ['monitors', 'list', '--json'],
|
|
66
73
|
noFanoutEnv: NO_MONITOR_FANOUT_ENV,
|
|
74
|
+
hosts: options.hosts,
|
|
67
75
|
parse: parseRemoteMonitors,
|
|
68
76
|
quiet: true,
|
|
69
|
-
|
|
77
|
+
earlyExit: options.againstFingerprint
|
|
78
|
+
? {
|
|
79
|
+
isDefinitive: (item) => monitorFingerprint(item.monitor) === options.againstFingerprint,
|
|
80
|
+
}
|
|
81
|
+
: undefined,
|
|
82
|
+
}, options.deps);
|
|
70
83
|
return {
|
|
71
84
|
monitors: result.items,
|
|
72
85
|
skipped: [...result.skipped, ...result.parseFailed],
|
package/dist/lib/notify.d.ts
CHANGED
|
@@ -48,6 +48,13 @@ export declare function buildOpenClawNotifyArgs(text: string, opts: {
|
|
|
48
48
|
* selects the provider per host. A missing owner config or a delivery failure
|
|
49
49
|
* (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
|
|
50
50
|
* ENOENT — so callers surface a consistent, best-effort failure.
|
|
51
|
+
*
|
|
52
|
+
* When local delivery fails because THIS box structurally cannot reach the owner
|
|
53
|
+
* — the rush-backed owner channel is macOS-only, so a headless Linux worker can
|
|
54
|
+
* never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
|
|
55
|
+
* capable fleet peer that DOES have the provider, mirroring the reroute
|
|
56
|
+
* `agents message` already uses. A successful forward is returned as the result;
|
|
57
|
+
* if no capable peer is reachable, the original clean local error stands.
|
|
51
58
|
*/
|
|
52
59
|
export declare function sendToOwner(text: string, options?: OwnerNotifyOptions): Promise<SendResult>;
|
|
53
60
|
export declare function notifyUrgentBlock(block: OpenBlock, options?: OwnerNotifyOptions): Promise<NotifyResult>;
|
package/dist/lib/notify.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readMeta } from './state.js';
|
|
|
2
2
|
import { getOwnerNotifyFromHumans } from './humans.js';
|
|
3
3
|
import { registerBuiltinProviders } from './channels/providers/index.js';
|
|
4
4
|
import { lookupTransport } from './channels/resolve.js';
|
|
5
|
+
import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
|
|
5
6
|
export function formatUrgentBlockMessage(block) {
|
|
6
7
|
const q = block.questions[0];
|
|
7
8
|
const header = q?.header ? `[${q.header}] ` : '';
|
|
@@ -38,6 +39,13 @@ export function buildOpenClawNotifyArgs(text, opts) {
|
|
|
38
39
|
* selects the provider per host. A missing owner config or a delivery failure
|
|
39
40
|
* (e.g. openclaw not on PATH) returns a clean `SendResult` error — never a raw
|
|
40
41
|
* ENOENT — so callers surface a consistent, best-effort failure.
|
|
42
|
+
*
|
|
43
|
+
* When local delivery fails because THIS box structurally cannot reach the owner
|
|
44
|
+
* — the rush-backed owner channel is macOS-only, so a headless Linux worker can
|
|
45
|
+
* never ring the phone (PHNX-3303) — the notify is forwarded over SSH to a
|
|
46
|
+
* capable fleet peer that DOES have the provider, mirroring the reroute
|
|
47
|
+
* `agents message` already uses. A successful forward is returned as the result;
|
|
48
|
+
* if no capable peer is reachable, the original clean local error stands.
|
|
41
49
|
*/
|
|
42
50
|
export async function sendToOwner(text, options = {}) {
|
|
43
51
|
const meta = options.meta ?? readMeta();
|
|
@@ -57,11 +65,17 @@ export async function sendToOwner(text, options = {}) {
|
|
|
57
65
|
if (!provider) {
|
|
58
66
|
return { ok: false, channel, id: target, error };
|
|
59
67
|
}
|
|
60
|
-
|
|
68
|
+
const local = await provider.send(text, {
|
|
61
69
|
target,
|
|
62
70
|
ownerScoped: options.target === undefined,
|
|
63
71
|
dryRun: options.dryRun,
|
|
64
72
|
});
|
|
73
|
+
// A dry-run never delivers, and an override target is an explicit recipient
|
|
74
|
+
// (not the fleet-wide owner) — neither should hop to a peer.
|
|
75
|
+
if (local.ok || options.dryRun || options.target !== undefined)
|
|
76
|
+
return local;
|
|
77
|
+
const forwarded = await forwardOwnerNotifyToPeer(text, channel, meta);
|
|
78
|
+
return forwarded ?? local;
|
|
65
79
|
}
|
|
66
80
|
export async function notifyUrgentBlock(block, options = {}) {
|
|
67
81
|
if (block.notifiedAt) {
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export type TmuxAliasState = 'not-an-alias' | 'no-server' | 'absent' | 'dead' | 'live';
|
|
2
|
+
/** Shape of the tmux alias the CLI mints for an agent session: `ag-<agent>-<shortid>`.
|
|
3
|
+
* Delegates to the one canonical matcher beside the name parsers in active.ts. */
|
|
4
|
+
export declare function looksLikeTmuxAlias(selector: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Classify a selector against the live tmux server. Split from the attach so the
|
|
7
|
+
* decision is testable against a real tmux server without replacing the caller's
|
|
8
|
+
* shell (attaching is not something a test can undo).
|
|
9
|
+
*/
|
|
10
|
+
export declare function resolveTmuxAliasState(selector: string, socket?: string): Promise<TmuxAliasState>;
|
|
11
|
+
/**
|
|
12
|
+
* A local `ag-<agent>-<8hex>` alias names a pane on THIS box. Attach it
|
|
13
|
+
* without any fleet SSH. `--device` keeps the sweep: the caller scoped
|
|
14
|
+
* identity to another machine.
|
|
15
|
+
*/
|
|
16
|
+
export declare function shouldAttachLocalTmuxAliasBeforeFleet(selector: string | undefined, hosts: string[]): selector is string;
|
|
17
|
+
/**
|
|
18
|
+
* Attach a live tmux session named exactly as the selector, without needing the
|
|
19
|
+
* session index to know anything about it.
|
|
20
|
+
*
|
|
21
|
+
* SES-41 requires a `ag-<agent>-<8hex>` tmux alias to resolve, but the alias's
|
|
22
|
+
* hex is the LAUNCH id, not the harness session id, and for a harness that
|
|
23
|
+
* writes no `state/sessions/<pid>.json` record there is no mapping back to a
|
|
24
|
+
* SessionMeta at all. The pane is the thing the user asked for, and its NAME is
|
|
25
|
+
* sufficient to attach it — so an unattributable session is still reachable
|
|
26
|
+
* instead of being a dead end that forces raw `tmux -S … attach`.
|
|
27
|
+
*
|
|
28
|
+
* Returns false when this is not an alias, the server/session is absent, or
|
|
29
|
+
* every pane is dead — the caller then continues to normal id resolution.
|
|
30
|
+
* SES-39's "re-read `pane_dead` immediately before attach" is honoured here:
|
|
31
|
+
* liveness is queried at attach time, not read from a roster.
|
|
32
|
+
*/
|
|
33
|
+
export declare function attachLiveTmuxAlias(selector: string): Promise<boolean>;
|
|
34
|
+
export type LocalAliasBySuffix = {
|
|
35
|
+
kind: 'alias';
|
|
36
|
+
alias: string;
|
|
37
|
+
} | {
|
|
38
|
+
kind: 'collision';
|
|
39
|
+
aliases: string[];
|
|
40
|
+
} | {
|
|
41
|
+
kind: 'none';
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Resolve a bare 8-hex selector (`agents tmux ls`'s hex column, typed without
|
|
45
|
+
* the `ag-<agent>-` prefix) against LIVE local panes only. Dead panes matching
|
|
46
|
+
* the suffix are excluded before the uniqueness check — a retained, exited pane
|
|
47
|
+
* must never compete with (or block) a live one for the same short id.
|
|
48
|
+
*
|
|
49
|
+
* Exported for direct testing: `attachLocalLiveSelector` composes this with a
|
|
50
|
+
* real `attachTmux()` call, which a unit test cannot safely invoke (it takes
|
|
51
|
+
* over the terminal).
|
|
52
|
+
*/
|
|
53
|
+
export declare function resolveUniqueLocalLiveAliasBySuffix(shortId: string, socket?: string): Promise<LocalAliasBySuffix>;
|
|
54
|
+
/**
|
|
55
|
+
* The full local gate (PHNX-3292 rule 1): a selector that is either a live
|
|
56
|
+
* local tmux alias, or a bare 8-hex short id that names exactly one live local
|
|
57
|
+
* pane, attaches immediately — zero SSH. `--device`/`hosts` scopes identity to
|
|
58
|
+
* another machine and disables this gate entirely (rule 4).
|
|
59
|
+
*
|
|
60
|
+
* Two LIVE local panes matching the same 8-hex suffix fail closed (rule 5): a
|
|
61
|
+
* collision is reported with both names rather than guessing, and the caller
|
|
62
|
+
* must not fall through to fleet resolution for a selector that is genuinely
|
|
63
|
+
* ambiguous ON THIS BOX.
|
|
64
|
+
*
|
|
65
|
+
* Returns `false` (never attached, never reported) when the selector does not
|
|
66
|
+
* name a live local pane at all, so the caller can continue to session-id
|
|
67
|
+
* resolution / the fleet race.
|
|
68
|
+
*/
|
|
69
|
+
export declare function attachLocalLiveSelector(selector: string | undefined, hosts: string[]): Promise<boolean>;
|