@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/dist/commands/attach.js +7 -0
  3. package/dist/commands/browser.js +118 -56
  4. package/dist/commands/daemon.d.ts +2 -0
  5. package/dist/commands/daemon.js +8 -4
  6. package/dist/commands/detach.js +1 -1
  7. package/dist/commands/focus.d.ts +1 -10
  8. package/dist/commands/focus.js +14 -79
  9. package/dist/commands/go.d.ts +26 -0
  10. package/dist/commands/go.js +63 -5
  11. package/dist/commands/monitors.js +1 -1
  12. package/dist/commands/repo.js +31 -3
  13. package/dist/commands/sessions-resume.d.ts +1 -0
  14. package/dist/commands/sessions-resume.js +13 -2
  15. package/dist/commands/sessions-stop.js +1 -1
  16. package/dist/commands/sessions.d.ts +23 -13
  17. package/dist/commands/sessions.js +40 -20
  18. package/dist/commands/setup-browser.d.ts +5 -2
  19. package/dist/commands/setup-browser.js +14 -29
  20. package/dist/commands/setup-preferences.d.ts +22 -3
  21. package/dist/commands/setup-preferences.js +25 -8
  22. package/dist/commands/share.js +12 -8
  23. package/dist/commands/status.js +5 -0
  24. package/dist/commands/sync.js +58 -2
  25. package/dist/commands/tmux.d.ts +8 -1
  26. package/dist/commands/tmux.js +167 -17
  27. package/dist/lib/browser/ipc.d.ts +44 -0
  28. package/dist/lib/browser/ipc.js +120 -8
  29. package/dist/lib/browser/profiles.d.ts +39 -17
  30. package/dist/lib/browser/profiles.js +51 -52
  31. package/dist/lib/browser/runtime-state.d.ts +4 -2
  32. package/dist/lib/browser/runtime-state.js +4 -2
  33. package/dist/lib/browser/service.js +4 -3
  34. package/dist/lib/channels/owner-forward.d.ts +88 -0
  35. package/dist/lib/channels/owner-forward.js +116 -0
  36. package/dist/lib/channels/owner-sink.js +7 -0
  37. package/dist/lib/exec.d.ts +8 -0
  38. package/dist/lib/exec.js +7 -0
  39. package/dist/lib/feed-broadcast.js +15 -1
  40. package/dist/lib/git.d.ts +93 -0
  41. package/dist/lib/git.js +232 -0
  42. package/dist/lib/monitors/remote.d.ts +18 -1
  43. package/dist/lib/monitors/remote.js +15 -2
  44. package/dist/lib/notify.d.ts +7 -0
  45. package/dist/lib/notify.js +15 -1
  46. package/dist/lib/session/local-tmux-attach.d.ts +69 -0
  47. package/dist/lib/session/local-tmux-attach.js +164 -0
  48. package/dist/lib/session/remote-active.d.ts +8 -0
  49. package/dist/lib/session/remote-active.js +1 -0
  50. package/dist/lib/share/publish.d.ts +8 -11
  51. package/dist/lib/share/publish.js +16 -20
  52. package/dist/lib/share/worker-template.js +99 -12
  53. package/dist/lib/sync-status.d.ts +17 -0
  54. package/dist/lib/sync-status.js +21 -2
  55. package/dist/lib/tmux/index.d.ts +1 -1
  56. package/dist/lib/tmux/index.js +1 -1
  57. package/dist/lib/tmux/session.d.ts +10 -0
  58. package/dist/lib/tmux/session.js +29 -0
  59. package/package.json +1 -1
@@ -2,14 +2,17 @@ import type { BrowserProfile, ProfileName } from './types.js';
2
2
  export type { BrowserProfile } from './types.js';
3
3
  export { declaringDevices, profileKind, profileRegistry, type ProfileDeclaration, } from './registry.js';
4
4
  /**
5
- * Name of the profile `ensureDefaultBrowserProfile` auto-detects and pins.
5
+ * Name of the profile the setup wizards pin as this machine's default browser
6
+ * (`agents setup`, `agents setup browser`). Older builds also auto-created it
7
+ * silently on the first `agents browser start`; PHNX-3296 removed that — see
8
+ * {@link ensureDefaultBrowserProfile}.
6
9
  *
7
10
  * It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
8
11
  * both this concrete profile AND the alias meaning "whatever profile the user
9
- * configured", so `--profile default` landed on a literal auto-detected Chrome
10
- * on one command and on the user's configured Comet on another. The alias now
11
- * lives alone in {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one
12
- * place ({@link resolveProfileRef}).
12
+ * configured", so `--profile default` landed on a literal Chrome on one command
13
+ * and on the user's configured Comet on another. The alias now lives alone in
14
+ * {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
15
+ * ({@link resolveProfileRef}).
13
16
  */
14
17
  export declare const DEFAULT_BROWSER_PROFILE_NAME = "auto-chrome";
15
18
  /**
@@ -106,33 +109,52 @@ export declare function resolveProfileRef(ref?: string): Promise<string | undefi
106
109
  * no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
107
110
  * which additionally verifies the resolved default can launch on THIS machine.
108
111
  * An undeclared configured default is an error. A declared default whose
109
- * browser isn't installed here warns and falls through to auto-detect.
112
+ * browser isn't installed here warns and falls through to an existing profile,
113
+ * else the actionable throw below.
110
114
  *
111
115
  * `start` is the only command that launches a browser, so it is the only one
112
116
  * that may do those things; routing a filter-only command through this would
113
- * warn about, and rewrite, config the user never asked it to touch.
117
+ * warn about config the user never asked it to touch.
114
118
  *
115
- * Throws when the configured default is undeclared, or when no profile exists
116
- * and no supported browser is installed.
119
+ * Throws ({@link noDefaultBrowserError}) when the configured default is
120
+ * undeclared, or when no profile exists that can launch here.
117
121
  */
118
122
  export declare function resolveProfileRefForStart(ref?: string): Promise<string>;
123
+ /**
124
+ * The error a bare `agents browser start` raises when this machine has no
125
+ * launchable default browser. Its own function so the wording — the one thing
126
+ * the user reads when a browser won't start — stays in one place and is
127
+ * testable without spawning anything.
128
+ *
129
+ * Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
130
+ * behavior probed the installed Chromium-family browsers and minted a
131
+ * logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
132
+ * Chrome that popped up on the user's Mac unbidden. Which browser agents drive
133
+ * is a choice the user makes once, in `agents setup` — never one this code
134
+ * makes for them.
135
+ */
136
+ export declare function noDefaultBrowserError(): Error;
119
137
  /**
120
138
  * Resolve the profile a bare `agents browser start` uses.
121
139
  *
122
140
  * Order: (1) the device-local configured default (`agents browser use <name>`)
123
141
  * when it names a profile that exists and can launch here; (2) an existing
124
- * auto-detected profile that can launch here; (3) auto-pick the first installed
125
- * Chromium-family browser and pin `auto-chrome` (or repair a stale legacy
126
- * `default`) to it.
142
+ * auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
143
+ * here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
144
+ * detect-and-create — see that function for why (PHNX-3296).
127
145
  *
128
146
  * Two failure modes at the configured-default step are not the same:
129
147
  * - No device declares the name (including a leftover central `browser:`
130
- * entry that was never claimed) → throw. Auto-creating `auto-chrome` would
131
- * hand the agent a logged-out browser while `browser.profile` still names
132
- * the credentialed one.
148
+ * entry that was never claimed) → throw. Falling back to a minted profile
149
+ * would hand the agent a logged-out browser while `browser.profile` still
150
+ * names the credentialed one.
133
151
  * - The name is declared, but its browser/binary is not installed HERE →
134
- * warn and fall through. That is a missing binary on this box, not a
135
- * missing identity; auto-detect is the existing repair.
152
+ * warn and fall through to an existing profile, else the actionable throw.
153
+ * That is a missing binary on this box, not a missing identity.
154
+ *
155
+ * This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
156
+ * already carry one keep resolving it (and its running browser + runtime dirs),
157
+ * but it never CREATES one.
136
158
  */
137
159
  export declare function ensureDefaultBrowserProfile(): Promise<BrowserProfile>;
138
160
  /**
@@ -4,18 +4,20 @@ import { getBrowserRuntimeDir as getBrowserRuntimeDirRoot, readMeta, updateMeta,
4
4
  import { getConfigValue } from '../device-config.js';
5
5
  import { machineId } from '../machine-id.js';
6
6
  import { declaringDevices, profileRegistry, } from './registry.js';
7
- import { findBrowserPath, findFirstInstalledBrowser, isPortInUse } from './chrome.js';
8
- import { DEFAULT_VIEWPORT } from './devices.js';
7
+ import { findBrowserPath, isPortInUse } from './chrome.js';
9
8
  export { declaringDevices, profileKind, profileRegistry, } from './registry.js';
10
9
  /**
11
- * Name of the profile `ensureDefaultBrowserProfile` auto-detects and pins.
10
+ * Name of the profile the setup wizards pin as this machine's default browser
11
+ * (`agents setup`, `agents setup browser`). Older builds also auto-created it
12
+ * silently on the first `agents browser start`; PHNX-3296 removed that — see
13
+ * {@link ensureDefaultBrowserProfile}.
12
14
  *
13
15
  * It is `auto-chrome`, NOT `default`, since RUSH-2709: `default` used to be
14
16
  * both this concrete profile AND the alias meaning "whatever profile the user
15
- * configured", so `--profile default` landed on a literal auto-detected Chrome
16
- * on one command and on the user's configured Comet on another. The alias now
17
- * lives alone in {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one
18
- * place ({@link resolveProfileRef}).
17
+ * configured", so `--profile default` landed on a literal Chrome on one command
18
+ * and on the user's configured Comet on another. The alias now lives alone in
19
+ * {@link DEFAULT_PROFILE_ALIAS} and resolves in exactly one place
20
+ * ({@link resolveProfileRef}).
19
21
  */
20
22
  export const DEFAULT_BROWSER_PROFILE_NAME = 'auto-chrome';
21
23
  /**
@@ -221,14 +223,15 @@ export async function resolveProfileRef(ref) {
221
223
  * no profile of that name) goes through {@link ensureDefaultBrowserProfile} —
222
224
  * which additionally verifies the resolved default can launch on THIS machine.
223
225
  * An undeclared configured default is an error. A declared default whose
224
- * browser isn't installed here warns and falls through to auto-detect.
226
+ * browser isn't installed here warns and falls through to an existing profile,
227
+ * else the actionable throw below.
225
228
  *
226
229
  * `start` is the only command that launches a browser, so it is the only one
227
230
  * that may do those things; routing a filter-only command through this would
228
- * warn about, and rewrite, config the user never asked it to touch.
231
+ * warn about config the user never asked it to touch.
229
232
  *
230
- * Throws when the configured default is undeclared, or when no profile exists
231
- * and no supported browser is installed.
233
+ * Throws ({@link noDefaultBrowserError}) when the configured default is
234
+ * undeclared, or when no profile exists that can launch here.
232
235
  */
233
236
  export async function resolveProfileRefForStart(ref) {
234
237
  if (ref && ref !== DEFAULT_PROFILE_ALIAS)
@@ -238,23 +241,45 @@ export async function resolveProfileRefForStart(ref) {
238
241
  return ref;
239
242
  return (await ensureDefaultBrowserProfile()).name;
240
243
  }
244
+ /**
245
+ * The error a bare `agents browser start` raises when this machine has no
246
+ * launchable default browser. Its own function so the wording — the one thing
247
+ * the user reads when a browser won't start — stays in one place and is
248
+ * testable without spawning anything.
249
+ *
250
+ * Since PHNX-3296 this is a hard stop, NOT a silent auto-create. The old
251
+ * behavior probed the installed Chromium-family browsers and minted a
252
+ * logged-out `auto-chrome` profile on the spot; agents then drove a signed-out
253
+ * Chrome that popped up on the user's Mac unbidden. Which browser agents drive
254
+ * is a choice the user makes once, in `agents setup` — never one this code
255
+ * makes for them.
256
+ */
257
+ export function noDefaultBrowserError() {
258
+ return new Error('No default browser is configured on this machine. ' +
259
+ 'Run `agents setup` (or `agents browser use <name>`) to pick the browser agents should drive. ' +
260
+ 'If this is a headless worker, use the fleet hub instead: `agents config set browser.device <host>`.');
261
+ }
241
262
  /**
242
263
  * Resolve the profile a bare `agents browser start` uses.
243
264
  *
244
265
  * Order: (1) the device-local configured default (`agents browser use <name>`)
245
266
  * when it names a profile that exists and can launch here; (2) an existing
246
- * auto-detected profile that can launch here; (3) auto-pick the first installed
247
- * Chromium-family browser and pin `auto-chrome` (or repair a stale legacy
248
- * `default`) to it.
267
+ * auto-detected profile (`auto-chrome`, or a legacy `default`) that can launch
268
+ * here. When neither resolves, THROW ({@link noDefaultBrowserError}) rather than
269
+ * detect-and-create — see that function for why (PHNX-3296).
249
270
  *
250
271
  * Two failure modes at the configured-default step are not the same:
251
272
  * - No device declares the name (including a leftover central `browser:`
252
- * entry that was never claimed) → throw. Auto-creating `auto-chrome` would
253
- * hand the agent a logged-out browser while `browser.profile` still names
254
- * the credentialed one.
273
+ * entry that was never claimed) → throw. Falling back to a minted profile
274
+ * would hand the agent a logged-out browser while `browser.profile` still
275
+ * names the credentialed one.
255
276
  * - The name is declared, but its browser/binary is not installed HERE →
256
- * warn and fall through. That is a missing binary on this box, not a
257
- * missing identity; auto-detect is the existing repair.
277
+ * warn and fall through to an existing profile, else the actionable throw.
278
+ * That is a missing binary on this box, not a missing identity.
279
+ *
280
+ * This RECOGNIZES a pre-existing `auto-chrome`/legacy `default` so installs that
281
+ * already carry one keep resolving it (and its running browser + runtime dirs),
282
+ * but it never CREATES one.
258
283
  */
259
284
  export async function ensureDefaultBrowserProfile() {
260
285
  const configured = getConfiguredDefaultProfileName();
@@ -275,7 +300,7 @@ export async function ensureDefaultBrowserProfile() {
275
300
  `Or unset the default with: agents browser use --unset`);
276
301
  }
277
302
  console.warn(`warning: configured default browser profile "${configured}" can't launch on this ` +
278
- `machine (its browser/binary isn't installed here); falling back to auto-detect. ` +
303
+ `machine (its browser/binary isn't installed here). ` +
279
304
  `Fix with: agents browser use <name> (or --unset)`);
280
305
  }
281
306
  // Prefer whichever auto-detected profile this machine already carries: the
@@ -285,38 +310,12 @@ export async function ensureDefaultBrowserProfile() {
285
310
  const existing = await getAutoDetectedProfile();
286
311
  if (existing && isProfileLaunchableHere(existing))
287
312
  return existing;
288
- const detected = findFirstInstalledBrowser();
289
- if (!detected) {
290
- throw new Error('No supported browser found. Install one of: Chrome, Brave, Edge, Chromium, Comet, or Arc, ' +
291
- 'then re-run `agents browser start`. Or create a profile explicitly with ' +
292
- '`agents browser profiles create <name> --browser <chrome|comet|chromium|brave|edge|arc|custom>`. ' +
293
- 'Note: Safari and Firefox are not supported — agents browser drives over the ' +
294
- 'Chrome DevTools Protocol, which they don\'t implement.');
295
- }
296
- const freePort = await findFreeProfilePort();
297
- const profile = {
298
- // Regenerate under the name that is already on disk when there is one, so a
299
- // stale legacy `default` is repaired in place instead of leaving the user
300
- // with two auto-detected profiles.
301
- name: existing?.name ?? DEFAULT_BROWSER_PROFILE_NAME,
302
- description: `Auto-detected ${detected.browserType} profile`,
303
- browser: detected.browserType,
304
- binary: detected.binary,
305
- endpoints: [`cdp://127.0.0.1:${freePort}`],
306
- viewport: {
307
- width: DEFAULT_VIEWPORT.width,
308
- height: DEFAULT_VIEWPORT.height,
309
- },
310
- };
311
- // A stale `default` (auto-created on another OS, unlaunchable here) is
312
- // regenerated in place; otherwise this is the first-run create.
313
- if (existing) {
314
- await updateProfile(profile);
315
- }
316
- else {
317
- await createProfile(profile);
318
- }
319
- return profile;
313
+ // No configured default resolves and no existing profile launches here. We
314
+ // used to auto-detect the first installed browser and silently mint (or
315
+ // regenerate) an `auto-chrome` profile at this point — that is exactly the
316
+ // logged-out-Chrome-on-your-Mac bug PHNX-3296 removed. Stop and tell the user
317
+ // how to choose a browser instead.
318
+ throw noDefaultBrowserError();
320
319
  }
321
320
  /**
322
321
  * Compute the LOCAL port a profile will occupy at runtime:
@@ -203,8 +203,10 @@ export declare const PRUNE_REASON_TEXT: Record<PruneReason, string>;
203
203
  * - **the configured default** — this machine resolves a bare
204
204
  * `agents browser start` to it; removing it breaks that command.
205
205
  * - **the auto-detected profile** (`auto-chrome`, or the `default` an older
206
- * build wrote) — auto-regenerated by `ensureDefaultBrowserProfile`, so
207
- * pruning it is pure churn.
206
+ * build wrote) — still recognized and resolved by
207
+ * `ensureDefaultBrowserProfile` as this machine's default; since PHNX-3296
208
+ * it is no longer re-created on demand, so pruning it forces the user
209
+ * back through `agents setup` to get a default browser again.
208
210
  *
209
211
  * Known limitation: `BrowserProfileConfig` records no creation time, so a
210
212
  * profile created seconds ago and not yet started is indistinguishable from an
@@ -343,8 +343,10 @@ export const PRUNE_REASON_TEXT = {
343
343
  * - **the configured default** — this machine resolves a bare
344
344
  * `agents browser start` to it; removing it breaks that command.
345
345
  * - **the auto-detected profile** (`auto-chrome`, or the `default` an older
346
- * build wrote) — auto-regenerated by `ensureDefaultBrowserProfile`, so
347
- * pruning it is pure churn.
346
+ * build wrote) — still recognized and resolved by
347
+ * `ensureDefaultBrowserProfile` as this machine's default; since PHNX-3296
348
+ * it is no longer re-created on demand, so pruning it forces the user
349
+ * back through `agents setup` to get a default browser again.
348
350
  *
349
351
  * Known limitation: `BrowserProfileConfig` records no creation time, so a
350
352
  * profile created seconds ago and not yet started is indistinguishable from an
@@ -2330,9 +2330,10 @@ export class BrowserService {
2330
2330
  if (!opts.createIfMissing)
2331
2331
  return null;
2332
2332
  // The top-of-function consent gate already refused a fleet-remote create
2333
- // here — before ensureDefaultBrowserProfile() below can probe local browser
2334
- // installs and persist an `auto-chrome` profile — so a refused request never
2335
- // leaves state behind on the target machine.
2333
+ // here — before ensureDefaultBrowserProfile() below resolves a default — so
2334
+ // a refused request never touches the target machine. Since PHNX-3296 that
2335
+ // resolver never mints a profile: with no launchable default it throws, and
2336
+ // the throw surfaces to the caller exactly like any other start failure.
2336
2337
  // Implicit start on the default / named profile.
2337
2338
  let profileName = opts.profile;
2338
2339
  if (!profileName) {
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Forward an owner notification to a capable fleet peer over SSH (PHNX-3303).
3
+ *
4
+ * The owner's delivery provider for the rush-backed channels (imessage /
5
+ * telegram / slack / discord via the `rush` CLI) is macOS-only and
6
+ * keychain-bound, so a headless Linux worker structurally CANNOT ring the
7
+ * owner's phone: `agents feed post --level important` records the post but the
8
+ * owner sink fails with `rush CLI not found on PATH`, and the important post
9
+ * reaches nobody. `probeOwnerSink` (owner-sink.ts) already reports this as the
10
+ * `owner-sink-unreachable` doctor finding; this module is the runtime answer to
11
+ * it — instead of stranding the failure, hand the delivery to a reachable macOS
12
+ * peer that DOES have the provider.
13
+ *
14
+ * This mirrors the SSH reroute `agents message` (decideHostTaskRoute →
15
+ * runOnPeer) and the sessions fan-out already use for work that lives on another
16
+ * box: pick a reachable peer from the device registry and run the same `agents`
17
+ * verb there. Here the verb is `agents send --to owner`, which resolves the
18
+ * peer's own (fleet-synced) owner destination and delivers through its local
19
+ * rush — so the owner is addressed once, from the one box that can reach them.
20
+ *
21
+ * Best-effort seam: it never throws and never blocks the post. When no capable
22
+ * peer is reachable it resolves `undefined` and the caller keeps its original
23
+ * clean local error, exactly as before.
24
+ */
25
+ import type { Meta } from '../types.js';
26
+ import type { SendResult } from './registry.js';
27
+ import type { DeviceProfile } from '../devices/registry.js';
28
+ /**
29
+ * Env marker set on the forwarded `agents send` so a box that received a
30
+ * forwarded owner notify never forwards it onward. `agents send` does not route
31
+ * through this module today, so this is defense-in-depth against a future
32
+ * consumer wiring forwarding into the send path and creating a fan-out loop.
33
+ */
34
+ export declare const OWNER_FORWARD_GUARD_ENV = "AGENTS_OWNER_NO_FORWARD";
35
+ /** Why forwarding did not run, so a caller/test can assert the decision. */
36
+ export type OwnerForwardSkip = 'guarded' | 'not-rush-backed' | 'no-capable-peer';
37
+ export interface OwnerForwardPlan {
38
+ /** Ordered machine ids to try — capable (macOS), reachable, self excluded. */
39
+ candidates: string[];
40
+ /** Set when forwarding does not apply; the caller keeps its local error. */
41
+ skip?: OwnerForwardSkip;
42
+ }
43
+ /**
44
+ * True when the resolved owner transport is the macOS-only rush family — the
45
+ * one case a Linux/headless box structurally cannot deliver and a peer can.
46
+ * `openclaw-telegram` and the local `desktop`/`mailbox` providers are NOT
47
+ * rush-backed, so a failure there is not a wrong-OS problem and is left as-is.
48
+ * Mirrors the same `RUSH_CHANNELS.includes(transport)` gate in owner-sink.ts.
49
+ */
50
+ export declare function isRushBackedTransport(channel: string, meta: Meta): boolean;
51
+ /**
52
+ * Decide which peers can deliver the owner notification, in try order. Pure —
53
+ * no I/O — so the channel gate, the recursion guard, self-exclusion, and the
54
+ * capability/ordering rules are unit-testable without a live tailnet.
55
+ *
56
+ * Only macOS peers are candidates: the rush owner transport is macOS-only, so a
57
+ * Linux/Windows peer could not deliver it either. The configured
58
+ * `interactive.host` (the box the operator sits at, where rush is signed in) is
59
+ * tried first when it is among the candidates.
60
+ */
61
+ export declare function planOwnerForward(channel: string, meta: Meta, devices: DeviceProfile[], self: string, opts?: {
62
+ guarded?: boolean;
63
+ }): OwnerForwardPlan;
64
+ /**
65
+ * Deliver `text` to the owner FROM one peer over SSH. Runs the peer's own
66
+ * `agents send --to owner --text <text> --json`, which resolves that box's
67
+ * fleet-synced owner destination and delivers through its local provider.
68
+ * Resolves the parsed `SendResult`, or `undefined` when the peer is
69
+ * unreachable / not a dialable device / answered with unparseable output —
70
+ * every one of which means "try the next peer".
71
+ */
72
+ export type PeerOwnerSender = (machine: string, text: string) => Promise<SendResult | undefined>;
73
+ /**
74
+ * Try each capable peer in order and return the first successful delivery. A
75
+ * peer that is unreachable or reports its own delivery failure is skipped and
76
+ * the next is tried; the first `ok:true` wins and stops the sweep so the owner's
77
+ * phone rings once. Resolves `undefined` when forwarding does not apply or no
78
+ * peer delivered — the caller then keeps its original local error.
79
+ *
80
+ * The transport (`send`) is injectable so the try-order / first-success / stop
81
+ * orchestration is testable without a live SSH host; the default runs the real
82
+ * `agents send --to owner` over SSH.
83
+ */
84
+ export declare function forwardOwnerNotifyToPeer(text: string, channel: string, meta: Meta, opts?: {
85
+ self?: string;
86
+ devices?: DeviceProfile[];
87
+ send?: PeerOwnerSender;
88
+ }): Promise<SendResult | undefined>;
@@ -0,0 +1,116 @@
1
+ import { loadDevices, isDialableDevice } from '../devices/registry.js';
2
+ import { machineId, normalizeHost } from '../machine-id.js';
3
+ import { RUSH_CHANNELS } from './providers/rush.js';
4
+ import { resolvePeerTarget, sshCapture } from '../session/remote/remote-list.js';
5
+ import { buildRemoteAgentsInvocation, stripClixml } from '../hosts/remote-cmd.js';
6
+ /**
7
+ * Env marker set on the forwarded `agents send` so a box that received a
8
+ * forwarded owner notify never forwards it onward. `agents send` does not route
9
+ * through this module today, so this is defense-in-depth against a future
10
+ * consumer wiring forwarding into the send path and creating a fan-out loop.
11
+ */
12
+ export const OWNER_FORWARD_GUARD_ENV = 'AGENTS_OWNER_NO_FORWARD';
13
+ /** Per-peer SSH deadline for a one-shot owner delivery. */
14
+ const PEER_SEND_TIMEOUT_MS = 15_000;
15
+ /**
16
+ * True when the resolved owner transport is the macOS-only rush family — the
17
+ * one case a Linux/headless box structurally cannot deliver and a peer can.
18
+ * `openclaw-telegram` and the local `desktop`/`mailbox` providers are NOT
19
+ * rush-backed, so a failure there is not a wrong-OS problem and is left as-is.
20
+ * Mirrors the same `RUSH_CHANNELS.includes(transport)` gate in owner-sink.ts.
21
+ */
22
+ export function isRushBackedTransport(channel, meta) {
23
+ const transport = meta.notify?.transports?.[channel] ?? channel;
24
+ return RUSH_CHANNELS.includes(transport);
25
+ }
26
+ /**
27
+ * Decide which peers can deliver the owner notification, in try order. Pure —
28
+ * no I/O — so the channel gate, the recursion guard, self-exclusion, and the
29
+ * capability/ordering rules are unit-testable without a live tailnet.
30
+ *
31
+ * Only macOS peers are candidates: the rush owner transport is macOS-only, so a
32
+ * Linux/Windows peer could not deliver it either. The configured
33
+ * `interactive.host` (the box the operator sits at, where rush is signed in) is
34
+ * tried first when it is among the candidates.
35
+ */
36
+ export function planOwnerForward(channel, meta, devices, self, opts = {}) {
37
+ if (opts.guarded)
38
+ return { candidates: [], skip: 'guarded' };
39
+ if (!isRushBackedTransport(channel, meta))
40
+ return { candidates: [], skip: 'not-rush-backed' };
41
+ const selfId = normalizeHost(self);
42
+ const capable = devices.filter((d) => d.platform === 'macos' && isDialableDevice(d) && normalizeHost(d.name) !== selfId);
43
+ const interactiveHost = typeof meta.config?.interactiveHost === 'string'
44
+ ? normalizeHost(meta.config.interactiveHost)
45
+ : undefined;
46
+ const rank = (name) => (interactiveHost && normalizeHost(name) === interactiveHost ? 0 : 1);
47
+ const candidates = capable
48
+ .map((d) => normalizeHost(d.name))
49
+ .sort((a, b) => rank(a) - rank(b));
50
+ if (candidates.length === 0)
51
+ return { candidates: [], skip: 'no-capable-peer' };
52
+ return { candidates };
53
+ }
54
+ async function sendOnPeer(machine, text) {
55
+ const peer = await resolvePeerTarget(machine);
56
+ if (!peer)
57
+ return undefined;
58
+ const args = ['send', '--to', 'owner', '--text', text, '--json'];
59
+ // Reuse the one injection-tested remote-command builder every `--device`
60
+ // dispatch uses (posix `bash -lc` / Windows `-EncodedCommand`), rather than a
61
+ // second hand-rolled quoting path on a security-sensitive seam. The env map is
62
+ // the loop guard, exported the same way every remote invocation exports env.
63
+ const remoteCmd = buildRemoteAgentsInvocation(args, undefined, peer.os, { [OWNER_FORWARD_GUARD_ENV]: '1' });
64
+ const capture = await sshCapture(peer.target, remoteCmd, PEER_SEND_TIMEOUT_MS);
65
+ if (capture.code !== 0)
66
+ return undefined;
67
+ try {
68
+ const parsed = JSON.parse(stripClixml(capture.stdout));
69
+ if (parsed && typeof parsed === 'object' && typeof parsed.ok === 'boolean')
70
+ return parsed;
71
+ }
72
+ catch {
73
+ return undefined;
74
+ }
75
+ return undefined;
76
+ }
77
+ /**
78
+ * Try each capable peer in order and return the first successful delivery. A
79
+ * peer that is unreachable or reports its own delivery failure is skipped and
80
+ * the next is tried; the first `ok:true` wins and stops the sweep so the owner's
81
+ * phone rings once. Resolves `undefined` when forwarding does not apply or no
82
+ * peer delivered — the caller then keeps its original local error.
83
+ *
84
+ * The transport (`send`) is injectable so the try-order / first-success / stop
85
+ * orchestration is testable without a live SSH host; the default runs the real
86
+ * `agents send --to owner` over SSH.
87
+ */
88
+ export async function forwardOwnerNotifyToPeer(text, channel, meta, opts = {}) {
89
+ // Cheap, I/O-free gate first: a box that already received a forward, or an
90
+ // owner channel that isn't the macOS-only rush family, can never forward — so
91
+ // a normal local success/failure never pays a device-registry disk read.
92
+ if (process.env[OWNER_FORWARD_GUARD_ENV] === '1')
93
+ return undefined;
94
+ if (!isRushBackedTransport(channel, meta))
95
+ return undefined;
96
+ const self = opts.self ?? machineId();
97
+ let devices = opts.devices;
98
+ if (!devices) {
99
+ try {
100
+ devices = Object.values(await loadDevices());
101
+ }
102
+ catch {
103
+ return undefined; // no registry, nothing to forward to
104
+ }
105
+ }
106
+ const plan = planOwnerForward(channel, meta, devices, self);
107
+ if (plan.candidates.length === 0)
108
+ return undefined;
109
+ const send = opts.send ?? sendOnPeer;
110
+ for (const machine of plan.candidates) {
111
+ const result = await send(machine, text);
112
+ if (result?.ok)
113
+ return result;
114
+ }
115
+ return undefined;
116
+ }
@@ -11,6 +11,13 @@
11
11
  * after-the-fact `owner failed: …` line. `agents doctor` had no signal for it,
12
12
  * which is exactly the gap RUSH-2258 / RUSH-2262 flagged.
13
13
  *
14
+ * At runtime the owner-delivery lane no longer strands this failure: when local
15
+ * delivery fails on a box that structurally cannot reach the owner,
16
+ * `forwardOwnerNotifyToPeer` (owner-forward.ts) hands it to a capable macOS peer
17
+ * over SSH (PHNX-3303). This finding still stands as a diagnostic — the forward
18
+ * is best-effort and only lands when a reachable mac peer exists, so a box that
19
+ * cannot deliver locally is worth surfacing regardless.
20
+ *
14
21
  * This probes the SAME transport the lane uses, from the SAME context doctor runs
15
22
  * in, so `agents doctor` can fail loud when this box cannot reach the owner. It is
16
23
  * deliberately honest about context: `rush whoami` is what tells a real signed-in
@@ -419,6 +419,14 @@ export interface TmuxWrapContext {
419
419
  remoteDispatch: boolean;
420
420
  /** Whether a tmux binary is on PATH. */
421
421
  tmuxAvailable: boolean;
422
+ /**
423
+ * True when this process has a real TTY to attach (`stdout.isTTY`).
424
+ * A piped `agents run --interactive` (session-tracker tests, CI) has none:
425
+ * wrapping then treating the failed attach as Ctrl-b d leaked live panes
426
+ * for a week on yosemite-s0 (PHNX-3293). Remote dispatch still wraps
427
+ * without a TTY — `--device --no-follow` *wants* a detached pane.
428
+ */
429
+ hasTty: boolean;
422
430
  }
423
431
  /**
424
432
  * What to do with an interactive spawn. Three outcomes, not two: a run that
package/dist/lib/exec.js CHANGED
@@ -1210,6 +1210,12 @@ export function resolveTmuxWrap(ctx) {
1210
1210
  return { kind: 'bare' };
1211
1211
  if (ctx.noTmuxEnv)
1212
1212
  return { kind: 'bare' };
1213
+ // Local interactive with no TTY cannot attach. Wrapping anyway creates a
1214
+ // detached pane, attach returns immediately, and resolveAfterAttach treats
1215
+ // the still-alive pane as Ctrl-b d — the session-tracker test leak.
1216
+ // Remote dispatch is the exception: --no-follow *intends* a detached pane.
1217
+ if (!ctx.hasTty && !ctx.remoteDispatch)
1218
+ return { kind: 'bare' };
1213
1219
  if (!ctx.configEnabled && !ctx.remoteDispatch)
1214
1220
  return { kind: 'bare' };
1215
1221
  // Fail loud rather than launch a remote agent that a blink would kill: the
@@ -1668,6 +1674,7 @@ async function spawnAgent(options) {
1668
1674
  configEnabled: isTmuxEnabled(),
1669
1675
  remoteDispatch: process.env[REMOTE_INTERACTIVE_ENV] === '1',
1670
1676
  tmuxAvailable: isTmuxInstalled(),
1677
+ hasTty: !!process.stdout.isTTY,
1671
1678
  });
1672
1679
  if (tmuxWrap.kind === 'undurable') {
1673
1680
  // Refuse rather than start work a blink would destroy. This is the ONLY
@@ -36,6 +36,7 @@
36
36
  import { spawnSync } from 'child_process';
37
37
  import { isOwnerAlias, readOwnerDest, resolveSendEnvelope, deliverEnvelope } from './channels/send.js';
38
38
  import { lookupTransport } from './channels/resolve.js';
39
+ import { forwardOwnerNotifyToPeer } from './channels/owner-forward.js';
39
40
  import { registerBuiltinProviders } from './channels/providers/index.js';
40
41
  const LEVEL_RANK = { milestone: 0, important: 1 };
41
42
  /** Parse a `--level` value; anything unrecognized is a usage error, not a default. */
@@ -473,7 +474,20 @@ async function runChannelSink(sink, meta) {
473
474
  if (!provider)
474
475
  return { name, ok: false, error };
475
476
  const result = await deliverEnvelope(resolved.envelope, meta);
476
- return result.ok ? { name, ok: true } : { name, ok: false, error: result.error };
477
+ if (result.ok)
478
+ return { name, ok: true };
479
+ // The owner sink failed locally. When this box structurally cannot reach the
480
+ // owner (the rush-backed channel is macOS-only, so a headless Linux worker can
481
+ // never ring the phone — PHNX-3303), hand the delivery to a capable fleet peer
482
+ // over SSH rather than stranding the important post. Only the `owner` alias
483
+ // forwards: it resolves the peer's own fleet-synced owner destination, so a
484
+ // non-owner channel sink with an explicit recipient stays local.
485
+ if (owner) {
486
+ const forwarded = await forwardOwnerNotifyToPeer(sink.text ?? '', resolved.envelope.channel, meta);
487
+ if (forwarded?.ok)
488
+ return { name, ok: true };
489
+ }
490
+ return { name, ok: false, error: result.error };
477
491
  }
478
492
  /**
479
493
  * Run the planned sinks. A `command:` sink is a direct spawn with a bounded