@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.
Files changed (85) hide show
  1. package/CHANGELOG.md +219 -0
  2. package/dist/bootstrap.js +1 -1
  3. package/dist/commands/attach.js +7 -0
  4. package/dist/commands/browser.d.ts +10 -0
  5. package/dist/commands/browser.js +191 -54
  6. package/dist/commands/config.js +20 -0
  7. package/dist/commands/daemon.d.ts +2 -0
  8. package/dist/commands/daemon.js +8 -4
  9. package/dist/commands/detach.js +1 -1
  10. package/dist/commands/feedback.js +1 -1
  11. package/dist/commands/focus.d.ts +1 -10
  12. package/dist/commands/focus.js +14 -79
  13. package/dist/commands/go.d.ts +26 -0
  14. package/dist/commands/go.js +63 -5
  15. package/dist/commands/menubar.js +6 -4
  16. package/dist/commands/monitors.js +1 -1
  17. package/dist/commands/repo.js +31 -3
  18. package/dist/commands/sessions-resume.d.ts +1 -0
  19. package/dist/commands/sessions-resume.js +13 -2
  20. package/dist/commands/sessions-stop.js +1 -1
  21. package/dist/commands/sessions.d.ts +23 -13
  22. package/dist/commands/sessions.js +40 -20
  23. package/dist/commands/setup-browser.d.ts +5 -2
  24. package/dist/commands/setup-browser.js +14 -29
  25. package/dist/commands/setup-preferences.d.ts +22 -3
  26. package/dist/commands/setup-preferences.js +25 -8
  27. package/dist/commands/share.js +12 -8
  28. package/dist/commands/status.js +5 -0
  29. package/dist/commands/sync.js +58 -2
  30. package/dist/commands/tmux.d.ts +8 -1
  31. package/dist/commands/tmux.js +167 -17
  32. package/dist/commands/traces.js +32 -4
  33. package/dist/lib/browser/ipc.d.ts +44 -0
  34. package/dist/lib/browser/ipc.js +120 -8
  35. package/dist/lib/browser/profiles.d.ts +39 -17
  36. package/dist/lib/browser/profiles.js +51 -52
  37. package/dist/lib/browser/runtime-state.d.ts +4 -2
  38. package/dist/lib/browser/runtime-state.js +4 -2
  39. package/dist/lib/browser/service.js +4 -3
  40. package/dist/lib/channels/owner-forward.d.ts +88 -0
  41. package/dist/lib/channels/owner-forward.js +116 -0
  42. package/dist/lib/channels/owner-sink.js +7 -0
  43. package/dist/lib/claude-statusline.d.ts +9 -0
  44. package/dist/lib/claude-statusline.js +45 -4
  45. package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
  46. package/dist/lib/computer/ssh-tunnel.js +13 -8
  47. package/dist/lib/config-keys.d.ts +4 -3
  48. package/dist/lib/config-keys.js +9 -2
  49. package/dist/lib/device-config.js +23 -0
  50. package/dist/lib/exec.d.ts +8 -0
  51. package/dist/lib/exec.js +7 -0
  52. package/dist/lib/factory/snapshot.d.ts +1 -1
  53. package/dist/lib/factory/snapshot.js +1 -1
  54. package/dist/lib/feed-broadcast.js +15 -1
  55. package/dist/lib/git.d.ts +93 -0
  56. package/dist/lib/git.js +232 -0
  57. package/dist/lib/helper-download.d.ts +12 -2
  58. package/dist/lib/helper-download.js +12 -2
  59. package/dist/lib/installations/migrate.js +4 -4
  60. package/dist/lib/menubar/install-menubar.d.ts +71 -8
  61. package/dist/lib/menubar/install-menubar.js +183 -24
  62. package/dist/lib/monitors/remote.d.ts +18 -1
  63. package/dist/lib/monitors/remote.js +15 -2
  64. package/dist/lib/notify.d.ts +7 -0
  65. package/dist/lib/notify.js +15 -1
  66. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  67. package/dist/lib/session/local-tmux-attach.js +164 -0
  68. package/dist/lib/session/remote-active.d.ts +8 -0
  69. package/dist/lib/session/remote-active.js +1 -0
  70. package/dist/lib/share/publish.d.ts +8 -11
  71. package/dist/lib/share/publish.js +16 -20
  72. package/dist/lib/share/worker-template.js +99 -12
  73. package/dist/lib/star-nudge.d.ts +2 -2
  74. package/dist/lib/star-nudge.js +2 -2
  75. package/dist/lib/state.d.ts +1 -1
  76. package/dist/lib/state.js +2 -2
  77. package/dist/lib/sync-status.d.ts +17 -0
  78. package/dist/lib/sync-status.js +21 -2
  79. package/dist/lib/tmux/index.d.ts +1 -1
  80. package/dist/lib/tmux/index.js +1 -1
  81. package/dist/lib/tmux/session.d.ts +10 -0
  82. package/dist/lib/tmux/session.js +29 -0
  83. package/dist/lib/traces/sync.d.ts +30 -0
  84. package/dist/lib/traces/sync.js +91 -10
  85. 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
- function readInstalledMenubarVersion() {
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
- return fs.readFileSync(installedVersionMarkerPath(), 'utf-8').trim() || null;
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
- const exec = ensureMenubarAppInstalled({ forceReinstall: true, sourceAppPath: opts.sourceAppPath });
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(), getCliVersion());
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. The
496
- * installed service is stale when the helper binary is gone, or when it was
497
- * installed by a different CLI version than the one now running — a version
498
- * change is the signal that the plist's baked interpreter/entry/bundle paths
499
- * and the helper binary itself may have drifted. A null installedVersion
500
- * (pre-stamp install) counts as stale so old installs get re-stamped once.
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
- return opts.installedVersion !== opts.currentVersion;
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
- installedVersion: readInstalledMenubarVersion(),
510
- currentVersion: getCliVersion(),
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
- if (opts.installedVersion && opts.currentVersion) {
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: readInstalledMenubarVersion(),
734
- currentVersion: getCliVersion(),
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
- step('bundle', before.installedVersion === getCliVersion() ? 'ok' : 'changed', `${installedAppPath()} (${getCliVersion()})`);
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: readInstalledMenubarVersion(),
1047
- currentVersion: getCliVersion(),
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
- versionMatches: status.installedVersion === status.currentVersion,
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],
@@ -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>;
@@ -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
- return provider.send(text, {
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>;