@phnx-labs/agents-cli 1.22.50 → 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 +219 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +191 -54
- package/dist/commands/config.js +20 -0
- 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/feedback.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/menubar.js +6 -4
- 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/commands/traces.js +32 -4
- 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/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -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/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- 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/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- 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/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +91 -10
- package/package.json +3 -3
|
@@ -25,7 +25,7 @@ import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../vers
|
|
|
25
25
|
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
26
26
|
import { compareVersions } from '../agent-spec/primitives.js';
|
|
27
27
|
import { namespacedServiceLabel, serviceManifestHomeEnv, serviceManagerRegistrationAllowed } from '../service-manifest.js';
|
|
28
|
-
import { downloadMenubarHelperApp } from './download-menubar.js';
|
|
28
|
+
import { downloadMenubarHelperApp, menubarHelperCacheDir } from './download-menubar.js';
|
|
29
29
|
import { helperFloor } from '../helper-versions.js';
|
|
30
30
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
31
31
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
@@ -89,13 +89,42 @@ function installedAppPath() {
|
|
|
89
89
|
function installedVersionMarkerPath() {
|
|
90
90
|
return path.join(installDir(), '.menubar-version');
|
|
91
91
|
}
|
|
92
|
-
|
|
92
|
+
/**
|
|
93
|
+
* What the installed helper IS — deliberately not the CLI's version.
|
|
94
|
+
*
|
|
95
|
+
* `source` matters because the two install paths have different notions of
|
|
96
|
+
* "changed": a release bundle is identified by its helper version, while a local
|
|
97
|
+
* dev build has none (menubar/scripts/build.sh hardcodes CFBundleShortVersionString),
|
|
98
|
+
* so it is identified by the source path + mtime it was copied from.
|
|
99
|
+
*/
|
|
100
|
+
/** Version label for a bundle that has none of its own (a local dev build). */
|
|
101
|
+
export const LOCAL_BUILD_LABEL = 'local';
|
|
102
|
+
/**
|
|
103
|
+
* Read the stamp, tolerating the pre-JSON format.
|
|
104
|
+
*
|
|
105
|
+
* Older installs wrote a bare version string — and wrote the CLI's version into
|
|
106
|
+
* it, which is the bug this replaces. Such a stamp cannot be compared on the
|
|
107
|
+
* helper axis at all, so it reports `legacy` and is treated as stale exactly
|
|
108
|
+
* once, which re-stamps it in the new format. That mirrors the existing
|
|
109
|
+
* null-is-stale rule and is why the migration cannot loop.
|
|
110
|
+
*/
|
|
111
|
+
function readInstalledMenubarStamp() {
|
|
112
|
+
let raw;
|
|
93
113
|
try {
|
|
94
|
-
|
|
114
|
+
raw = fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim();
|
|
95
115
|
}
|
|
96
116
|
catch {
|
|
97
117
|
return null;
|
|
98
118
|
}
|
|
119
|
+
if (!raw)
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
const parsed = JSON.parse(raw);
|
|
123
|
+
if (parsed && (parsed.source === 'release' || parsed.source === 'local'))
|
|
124
|
+
return parsed;
|
|
125
|
+
}
|
|
126
|
+
catch { /* fall through to legacy */ }
|
|
127
|
+
return { source: 'legacy', raw };
|
|
99
128
|
}
|
|
100
129
|
/** Executable inside the installed bundle. */
|
|
101
130
|
function installedExecutablePath() {
|
|
@@ -425,7 +454,17 @@ export function restartMenubarHelperAfterSwap(uid, ownProcesses, exec = execFile
|
|
|
425
454
|
function startMenubarServiceFromSource(opts = {}) {
|
|
426
455
|
if (!onDarwin())
|
|
427
456
|
return false;
|
|
428
|
-
|
|
457
|
+
// Resolve the source HERE, once, and hand the SAME value to the installer and
|
|
458
|
+
// the stamp. Passing `opts.sourceAppPath` to both let them disagree: the
|
|
459
|
+
// installer falls back to `sourceAppPath()` internally, so on the self-heal
|
|
460
|
+
// path (which passes nothing) it would install a LOCAL build while the stamp
|
|
461
|
+
// recorded a release version. The next invocation then computed `local`, saw a
|
|
462
|
+
// kind mismatch, and reinstalled — every time, forever. That is the #2109
|
|
463
|
+
// storm this whole change exists to stop, so the two must read one variable.
|
|
464
|
+
const src = opts.sourceAppPath ?? sourceAppPath();
|
|
465
|
+
if (!src)
|
|
466
|
+
return false;
|
|
467
|
+
const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: src });
|
|
429
468
|
if (!exec)
|
|
430
469
|
return false;
|
|
431
470
|
// Never bootstrap a helper macOS will reject at launch: an invalid signature
|
|
@@ -441,7 +480,7 @@ function startMenubarServiceFromSource(opts = {}) {
|
|
|
441
480
|
}
|
|
442
481
|
if (opts.clearOptOut)
|
|
443
482
|
clearMenubarOptOut();
|
|
444
|
-
installAndStartService(exec);
|
|
483
|
+
installAndStartService(exec, stampFor(src));
|
|
445
484
|
return true;
|
|
446
485
|
}
|
|
447
486
|
/**
|
|
@@ -481,33 +520,121 @@ function clearMenubarOptOut() {
|
|
|
481
520
|
* particular is what the upgrade self-heal reads to decide staleness, and a
|
|
482
521
|
* path that skipped it would make every later `agents` invocation reinstall.
|
|
483
522
|
*/
|
|
484
|
-
function installAndStartService(exec) {
|
|
523
|
+
function installAndStartService(exec, stamp) {
|
|
485
524
|
const plist = servicePlistPath();
|
|
486
525
|
fs.mkdirSync(path.dirname(plist), { recursive: true });
|
|
487
526
|
fs.writeFileSync(plist, generateServicePlist(exec));
|
|
488
527
|
restartMenubarLaunchAgent(process.getuid?.() ?? 0, plist);
|
|
489
528
|
try {
|
|
490
|
-
fs.writeFileSync(installedVersionMarkerPath(),
|
|
529
|
+
fs.writeFileSync(installedVersionMarkerPath(), JSON.stringify(stamp));
|
|
530
|
+
}
|
|
531
|
+
catch { /* best effort */ }
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* Render a stamp as a comparable/displayable version string.
|
|
535
|
+
*
|
|
536
|
+
* A local build has no version of its own, so it reports `local`; the ownership
|
|
537
|
+
* contest treats that as "not comparable" and falls through to its owner arm,
|
|
538
|
+
* which is the correct outcome — a dev build must never win a version contest
|
|
539
|
+
* against a release.
|
|
540
|
+
*/
|
|
541
|
+
export function stampVersionLabel(stamp) {
|
|
542
|
+
if (!stamp)
|
|
543
|
+
return null;
|
|
544
|
+
if (stamp.source === 'release')
|
|
545
|
+
return stamp.helperVersion;
|
|
546
|
+
if (stamp.source === 'legacy')
|
|
547
|
+
return null;
|
|
548
|
+
return LOCAL_BUILD_LABEL;
|
|
549
|
+
}
|
|
550
|
+
/** What this install would put on disk right now, as a stamp. */
|
|
551
|
+
function availableStamp() {
|
|
552
|
+
const src = sourceAppPath();
|
|
553
|
+
// No local bundle means the release path: what would be installed is the
|
|
554
|
+
// helper version the floor names.
|
|
555
|
+
return src ? stampFor(src) : { source: 'release', helperVersion: helperFloor('menubar') };
|
|
556
|
+
}
|
|
557
|
+
/** The helper version this install would put on disk right now, for display. */
|
|
558
|
+
function availableHelperLabel() {
|
|
559
|
+
return stampVersionLabel(availableStamp()) ?? LOCAL_BUILD_LABEL;
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Identify the bundle about to be installed, for the stamp.
|
|
563
|
+
*
|
|
564
|
+
* A bundle is a RELEASE iff it sits under the helper's own download cache —
|
|
565
|
+
* asked of `menubarHelperCacheDir`, not pattern-matched out of the path. A regex
|
|
566
|
+
* for `/v<x.y.z>/` gets this wrong in both directions: a checkout living under
|
|
567
|
+
* any directory that happens to contain a version-shaped segment reads as a
|
|
568
|
+
* release, and a release cache laid out differently reads as local. Either
|
|
569
|
+
* misclassification flips `source` between invocations, and a kind change is
|
|
570
|
+
* unconditionally stale — which is a reinstall loop.
|
|
571
|
+
*/
|
|
572
|
+
export function stampFor(resolvedSourceAppPath) {
|
|
573
|
+
const version = releaseVersionOfCachedBundle(resolvedSourceAppPath);
|
|
574
|
+
if (version)
|
|
575
|
+
return { source: 'release', helperVersion: version };
|
|
576
|
+
let mtime = 0;
|
|
577
|
+
try {
|
|
578
|
+
mtime = fs.statSync(resolvedSourceAppPath).mtimeMs;
|
|
491
579
|
}
|
|
492
580
|
catch { /* best effort */ }
|
|
581
|
+
return { source: 'local', sourceStamp: `${resolvedSourceAppPath}@${mtime}` };
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* The helper version a path denotes, iff it is inside that version's cache dir.
|
|
585
|
+
* Returns null for anything else — including a version-shaped path that is not
|
|
586
|
+
* actually the cache.
|
|
587
|
+
*/
|
|
588
|
+
export function releaseVersionOfCachedBundle(appPath, cacheDirFor = menubarHelperCacheDir) {
|
|
589
|
+
// Try EVERY version-shaped segment, not just the leftmost. A cached bundle
|
|
590
|
+
// under a home or mount path that itself contains an unrelated `vX.Y.Z`
|
|
591
|
+
// (an nvm dir, a versioned volume) would otherwise match that first segment,
|
|
592
|
+
// fail the prefix check, and be misclassified as a local build.
|
|
593
|
+
const resolved = path.resolve(appPath);
|
|
594
|
+
for (const m of appPath.matchAll(/v(\d+\.\d+\.\d+)/g)) {
|
|
595
|
+
const expected = path.resolve(cacheDirFor(m[1]));
|
|
596
|
+
if (resolved === expected || resolved.startsWith(expected + path.sep))
|
|
597
|
+
return m[1];
|
|
598
|
+
}
|
|
599
|
+
return null;
|
|
493
600
|
}
|
|
494
601
|
/**
|
|
495
|
-
* Pure staleness decision (no I/O) so the truth table is unit-testable.
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
*
|
|
500
|
-
*
|
|
602
|
+
* Pure staleness decision (no I/O) so the truth table is unit-testable.
|
|
603
|
+
*
|
|
604
|
+
* This compares the HELPER axis, not the CLI's. It used to compare the installed
|
|
605
|
+
* stamp against `getCliVersion()`, which was wrong in both directions once the
|
|
606
|
+
* helpers gained their own version line: every CLI release made an unchanged
|
|
607
|
+
* helper look stale and reinstalled it (the #2109 restart storm), while a
|
|
608
|
+
* genuinely newer helper at the same CLI version never looked stale at all.
|
|
609
|
+
*
|
|
610
|
+
* Stale when: the executable is gone; nothing is stamped; the stamp predates the
|
|
611
|
+
* JSON format (`legacy` — re-stamped once); the install KIND changed
|
|
612
|
+
* (local <-> release), since a dev build and a release bundle are not
|
|
613
|
+
* interchangeable; a release install whose available helper version is newer;
|
|
614
|
+
* or a local install whose source path or mtime moved.
|
|
501
615
|
*/
|
|
502
616
|
export function isMenubarStale(opts) {
|
|
503
617
|
if (!opts.execExists)
|
|
504
618
|
return true;
|
|
505
|
-
|
|
619
|
+
const { installed, available } = opts;
|
|
620
|
+
if (!installed)
|
|
621
|
+
return true;
|
|
622
|
+
if (installed.source === 'legacy')
|
|
623
|
+
return true;
|
|
624
|
+
if (installed.source !== available.source)
|
|
625
|
+
return true;
|
|
626
|
+
if (installed.source === 'release' && available.source === 'release') {
|
|
627
|
+
return compareVersions(available.helperVersion, installed.helperVersion) > 0;
|
|
628
|
+
}
|
|
629
|
+
if (installed.source === 'local' && available.source === 'local') {
|
|
630
|
+
return installed.sourceStamp !== available.sourceStamp;
|
|
631
|
+
}
|
|
632
|
+
return true;
|
|
506
633
|
}
|
|
507
634
|
function menubarSetupStale() {
|
|
508
635
|
return isMenubarStale({
|
|
509
|
-
|
|
510
|
-
|
|
636
|
+
installed: readInstalledMenubarStamp(),
|
|
637
|
+
available: availableStamp(),
|
|
511
638
|
execExists: fs.existsSync(installedExecutablePath()),
|
|
512
639
|
});
|
|
513
640
|
}
|
|
@@ -662,7 +789,14 @@ export function mayInstallMenubarHelper(opts) {
|
|
|
662
789
|
// running, nothing deadlocks.
|
|
663
790
|
if (!opts.ownerEntryExists)
|
|
664
791
|
return opts.sourceIsDeveloperId;
|
|
665
|
-
|
|
792
|
+
// `local` is a KIND marker, not a version — a dev build has none. Comparing it
|
|
793
|
+
// with compareVersions would order it against real semver arbitrarily and let a
|
|
794
|
+
// dev build win (or lose) a contest it should not enter. When either side is a
|
|
795
|
+
// local build the version arm is skipped entirely and the owner arm decides,
|
|
796
|
+
// which is the correct outcome: ownership, not version, distinguishes them.
|
|
797
|
+
const comparableVersions = opts.installedVersion && opts.currentVersion &&
|
|
798
|
+
opts.installedVersion !== LOCAL_BUILD_LABEL && opts.currentVersion !== LOCAL_BUILD_LABEL;
|
|
799
|
+
if (comparableVersions) {
|
|
666
800
|
const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
|
|
667
801
|
if (versionOrder > 0)
|
|
668
802
|
return opts.sourceIsDeveloperId;
|
|
@@ -730,8 +864,8 @@ function mayHealMenubar(needsDevIdHeal) {
|
|
|
730
864
|
ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
|
|
731
865
|
helperExecMissing: !fs.existsSync(installedExecutablePath()),
|
|
732
866
|
needsDevIdHeal,
|
|
733
|
-
installedVersion:
|
|
734
|
-
currentVersion:
|
|
867
|
+
installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
|
|
868
|
+
currentVersion: availableHelperLabel(),
|
|
735
869
|
msSinceLastHeal: msSinceLastMenubarHeal(),
|
|
736
870
|
cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
|
|
737
871
|
sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
|
|
@@ -931,7 +1065,24 @@ export async function runMenubarSetup() {
|
|
|
931
1065
|
step('bundle', 'failed', 'could not install the helper bundle');
|
|
932
1066
|
return { steps, configured: false, status: getMenubarStatus() };
|
|
933
1067
|
}
|
|
934
|
-
|
|
1068
|
+
// The helper axis here too: this label read "ok" only when the installed
|
|
1069
|
+
// helper's stamp happened to equal the CLI's version, which after this change
|
|
1070
|
+
// is never true on a release install.
|
|
1071
|
+
//
|
|
1072
|
+
// Compare the STAMPS, not their labels. `stampVersionLabel` collapses every
|
|
1073
|
+
// local build to the literal 'local', discarding the mtime — so a real dev
|
|
1074
|
+
// rebuild (which the surrounding logic does correctly reinstall) would print
|
|
1075
|
+
// "ok" and hide that anything changed. That is the same collapse already
|
|
1076
|
+
// excluded in `versionMatches` and `mayInstallMenubarHelper`; this was the
|
|
1077
|
+
// third site and the only one still reading through the lossy label.
|
|
1078
|
+
const bundleStamp = readInstalledMenubarStamp();
|
|
1079
|
+
// Reuse the file's canonical comparator rather than a second, serialization
|
|
1080
|
+
// -based one: `JSON.stringify` was sound only because both stamps come from
|
|
1081
|
+
// `stampFor`, which is an implicit key-order dependency with no reason to
|
|
1082
|
+
// exist when isMenubarStale already answers exactly this question.
|
|
1083
|
+
const bundleUnchanged = bundleStamp !== null &&
|
|
1084
|
+
!isMenubarStale({ installed: bundleStamp, available: availableStamp(), execExists: true });
|
|
1085
|
+
step('bundle', bundleUnchanged ? 'ok' : 'changed', `${installedAppPath()} (${stampVersionLabel(bundleStamp) ?? 'unknown'})`);
|
|
935
1086
|
if (!(codesignVerifies(installedAppPath()) && gatekeeperAssesses(installedAppPath()))) {
|
|
936
1087
|
step('signature', 'failed', 'not notarized/valid on this machine — refusing to start it (Gatekeeper rejects an ' +
|
|
937
1088
|
'un-notarized helper as "damaged"). Upgrade to a notarized build of agents-cli.');
|
|
@@ -941,7 +1092,7 @@ export async function runMenubarSetup() {
|
|
|
941
1092
|
// Clear the sticky opt-out: running `setup` is an explicit request for the
|
|
942
1093
|
// menu bar, so a stale `menubar disable` must not silently win.
|
|
943
1094
|
clearMenubarOptOut();
|
|
944
|
-
installAndStartService(exec);
|
|
1095
|
+
installAndStartService(exec, stampFor(src ?? undefined));
|
|
945
1096
|
step('login item', before.serviceInstalled ? 'ok' : 'changed', `${serviceLabel()} — starts at login, restarts if it dies`);
|
|
946
1097
|
// launchd's bootstrap+kickstart is asynchronous; give the status item a beat
|
|
947
1098
|
// to claim the lock before counting instances, or `setup` reports zero on a
|
|
@@ -1043,8 +1194,9 @@ export function getMenubarStatus() {
|
|
|
1043
1194
|
platform: process.platform,
|
|
1044
1195
|
source: sourceAppPath(),
|
|
1045
1196
|
installedApp: fs.existsSync(dest) ? dest : null,
|
|
1046
|
-
installedVersion:
|
|
1047
|
-
currentVersion:
|
|
1197
|
+
installedVersion: stampVersionLabel(readInstalledMenubarStamp()),
|
|
1198
|
+
currentVersion: availableHelperLabel(),
|
|
1199
|
+
cliVersion: getCliVersion(),
|
|
1048
1200
|
stale: onDarwin() && serviceInstalled && menubarSetupStale(),
|
|
1049
1201
|
serviceInstalled,
|
|
1050
1202
|
running: own.length > 0,
|
|
@@ -1105,6 +1257,7 @@ export function buildMenubarDoctorReport() {
|
|
|
1105
1257
|
installPath: null,
|
|
1106
1258
|
installedVersion: null,
|
|
1107
1259
|
currentVersion: getCliVersion(),
|
|
1260
|
+
cliVersion: getCliVersion(),
|
|
1108
1261
|
versionMatches: false,
|
|
1109
1262
|
signingIdentity: 'unknown',
|
|
1110
1263
|
running: false,
|
|
@@ -1137,7 +1290,13 @@ export function buildMenubarDoctorReport() {
|
|
|
1137
1290
|
installPath: appPath,
|
|
1138
1291
|
installedVersion: status.installedVersion,
|
|
1139
1292
|
currentVersion: status.currentVersion,
|
|
1140
|
-
|
|
1293
|
+
cliVersion: status.cliVersion,
|
|
1294
|
+
// Two local builds are not a version "mismatch" — neither carries a version.
|
|
1295
|
+
// Reporting one told the user to run `setup` for a difference that does not
|
|
1296
|
+
// exist, which is what made the old hint fire forever.
|
|
1297
|
+
versionMatches: status.installedVersion === LOCAL_BUILD_LABEL && status.currentVersion === LOCAL_BUILD_LABEL
|
|
1298
|
+
? true
|
|
1299
|
+
: status.installedVersion === status.currentVersion,
|
|
1141
1300
|
signingIdentity,
|
|
1142
1301
|
running: status.running,
|
|
1143
1302
|
staleRunningProcess,
|
|
@@ -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>;
|