@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
@@ -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
@@ -1,4 +1,12 @@
1
1
  export declare const CLAUDE_STATUSLINE_COMMAND = "agents __claude-statusline";
2
+ /**
3
+ * True when `command` re-invokes THIS status-line producer (our private
4
+ * `__claude-statusline` subcommand) under any binary name or path. Delegating to
5
+ * such a command is the fork bomb, so both the read side (renderDelegate) and the
6
+ * write side (installClaudeStatusLine) treat it as "not a real external
7
+ * producer" and never chain to it.
8
+ */
9
+ export declare function isStatusLineSelfReference(command: string): boolean;
2
10
  interface ClaudeStatusLinePayload {
3
11
  cwd?: string;
4
12
  workspace?: {
@@ -20,6 +28,7 @@ interface ClaudeStatusLinePayload {
20
28
  };
21
29
  }
22
30
  export declare function ingestClaudeStatusLineUsage(payload: ClaudeStatusLinePayload, versionHome: string): boolean;
31
+ export declare function renderDelegate(payload: string, versionHome: string): string;
23
32
  export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string): string;
24
33
  export declare function runClaudeStatusLine(): Promise<number>;
25
34
  export declare function installClaudeStatusLine(versionHome: string): {
@@ -7,6 +7,39 @@ import { atomicWriteFileSync } from './fs-atomic.js';
7
7
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
8
8
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
9
9
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
10
+ // The private subcommand this feature runs. It is only ever invoked internally,
11
+ // so ANY command that contains it — under any binary name or path (`agents`,
12
+ // `agents-dev`, `ag`, an absolute path, a wrapper) — IS this status-line
13
+ // producer, and delegating to it recurses without bound. Match the subcommand,
14
+ // not the exact `agents __claude-statusline` string, or a delegate seeded with a
15
+ // differently-named binary (e.g. `agents-dev __claude-statusline`) fork-bombs the
16
+ // machine: each render spawns a copy that reads the same delegate and spawns
17
+ // another, forever.
18
+ const STATUSLINE_SUBCOMMAND = '__claude-statusline';
19
+ // Set on the child env before spawning a delegate. If it is already present we
20
+ // are ourselves running as someone's delegate, so we refuse to delegate again —
21
+ // a hard depth-1 backstop that bounds the blast radius even if a self-reference
22
+ // somehow slips past isStatusLineSelfReference(). One hop is the contract:
23
+ // installClaudeStatusLine only ever preserves a single prior command.
24
+ const DELEGATE_GUARD_ENV = 'AGENTS_CLAUDE_STATUSLINE_DELEGATED';
25
+ // A hung or slow delegate must never pin a status-line render open — the render
26
+ // is re-invoked on every refresh, so an unbounded delegate accumulates processes.
27
+ const DELEGATE_TIMEOUT_MS = 5_000;
28
+ /**
29
+ * True when `command` re-invokes THIS status-line producer (our private
30
+ * `__claude-statusline` subcommand) under any binary name or path. Delegating to
31
+ * such a command is the fork bomb, so both the read side (renderDelegate) and the
32
+ * write side (installClaudeStatusLine) treat it as "not a real external
33
+ * producer" and never chain to it.
34
+ */
35
+ export function isStatusLineSelfReference(command) {
36
+ const trimmed = command.trim();
37
+ if (!trimmed)
38
+ return false;
39
+ if (trimmed === CLAUDE_STATUSLINE_COMMAND)
40
+ return true;
41
+ return new RegExp(`(^|\\s)${STATUSLINE_SUBCOMMAND}(\\s|$)`).test(trimmed);
42
+ }
10
43
  function isRecord(value) {
11
44
  return typeof value === 'object' && value !== null && !Array.isArray(value);
12
45
  }
@@ -48,7 +81,10 @@ export function ingestClaudeStatusLineUsage(payload, versionHome) {
48
81
  function delegatePath(versionHome) {
49
82
  return path.join(versionHome, DELEGATE_FILE);
50
83
  }
51
- function renderDelegate(payload, versionHome) {
84
+ export function renderDelegate(payload, versionHome) {
85
+ // Already running as a delegate hop — never spawn another. Hard recursion stop.
86
+ if (process.env[DELEGATE_GUARD_ENV])
87
+ return '';
52
88
  let command = '';
53
89
  try {
54
90
  command = fs.readFileSync(delegatePath(versionHome), 'utf8').trim();
@@ -56,13 +92,14 @@ function renderDelegate(payload, versionHome) {
56
92
  catch {
57
93
  return '';
58
94
  }
59
- if (!command || command === CLAUDE_STATUSLINE_COMMAND)
95
+ if (!command || isStatusLineSelfReference(command))
60
96
  return '';
61
97
  const result = spawnSync(command, {
62
98
  shell: true,
63
99
  input: payload,
64
100
  encoding: 'utf8',
65
- env: process.env,
101
+ env: { ...process.env, [DELEGATE_GUARD_ENV]: '1' },
102
+ timeout: DELEGATE_TIMEOUT_MS,
66
103
  });
67
104
  return result.status === 0 ? result.stdout.trim() : '';
68
105
  }
@@ -117,11 +154,15 @@ export function installClaudeStatusLine(versionHome) {
117
154
  : '';
118
155
  if (existing === CLAUDE_STATUSLINE_COMMAND)
119
156
  return { changed: false };
120
- if (existing) {
157
+ if (existing && !isStatusLineSelfReference(existing)) {
158
+ // A genuine third-party status-line command → preserve it as a delegate.
121
159
  fs.mkdirSync(path.dirname(delegatePath(versionHome)), { recursive: true });
122
160
  atomicWriteFileSync(delegatePath(versionHome), `${existing}\n`);
123
161
  }
124
162
  else {
163
+ // Empty, or our own subcommand under a different binary name
164
+ // (`agents-dev __claude-statusline`, an absolute path, …). Saving that as a
165
+ // delegate is the fork bomb — never persist it. Drop any prior delegate.
125
166
  fs.rmSync(delegatePath(versionHome), { force: true });
126
167
  }
127
168
  settings.statusLine = {
@@ -59,11 +59,11 @@ export declare const WIN_HELPER_TOKEN_FILE = "helper-token";
59
59
  * the release-asset download (`ensureWinHelperExe`).
60
60
  */
61
61
  export declare function resolveWinHelperExe(): string | null;
62
- /** GitHub repo whose `v<version>` releases carry the exe as an asset. */
63
- export declare const WIN_HELPER_RELEASE_REPO = "phnx-labs/agents-cli";
62
+ /** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
63
+ export declare const WIN_HELPER_RELEASE_REPO = "phnx-labs/agi-cli";
64
64
  /** Cache dir for downloaded helper exes, one subdir per release tag. */
65
65
  export declare function winHelperCacheDir(version: string): string;
66
- /** Release-asset URLs for the exe + its checksum at one exact `v<version>` tag. */
66
+ /** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
67
67
  export declare function winHelperAssetUrls(version: string): {
68
68
  exe: string;
69
69
  sha256: string;
@@ -73,10 +73,11 @@ export declare function winHelperAssetUrls(version: string): {
73
73
  * a bare hex digest. Throws on anything that does not lead with 64 hex chars.
74
74
  */
75
75
  /**
76
- * Download the exe release asset for this CLI version, verify its sha256
76
+ * Download the exe release asset for this HELPER version, verify its sha256
77
77
  * against the published `.sha256` asset, and cache it under the agents cache
78
- * dir. Only the exact `v<version>` tag is consulted — a missing asset is a
79
- * hard error naming that tag, never a silent fallback to another release.
78
+ * dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
79
+ * asset is a hard error naming that tag, never a silent fallback to another
80
+ * release. The version is the HELPER's own, not the CLI's.
80
81
  */
81
82
  export declare function downloadWinHelperExe(version: string): Promise<string>;
82
83
  /**
@@ -127,13 +127,13 @@ export function resolveWinHelperExe() {
127
127
  }
128
128
  return null;
129
129
  }
130
- /** GitHub repo whose `v<version>` releases carry the exe as an asset. */
131
- export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agents-cli';
130
+ /** GitHub repo whose `computer-win/v<version>` releases carry the exe as an asset. */
131
+ export const WIN_HELPER_RELEASE_REPO = 'phnx-labs/agi-cli';
132
132
  /** Cache dir for downloaded helper exes, one subdir per release tag. */
133
133
  export function winHelperCacheDir(version) {
134
134
  return path.join(getCacheDir(), 'computer', 'win-helper', `v${version}`);
135
135
  }
136
- /** Release-asset URLs for the exe + its checksum at one exact `v<version>` tag. */
136
+ /** Release-asset URLs for the exe + its checksum at one exact `computer-win/v<version>` tag. */
137
137
  export function winHelperAssetUrls(version) {
138
138
  // The helper's OWN tag, not the CLI's. This exe is not an .app bundle so it
139
139
  // cannot share helper-download.ts's zip/codesign machinery, but it had the
@@ -148,19 +148,24 @@ export function winHelperAssetUrls(version) {
148
148
  * a bare hex digest. Throws on anything that does not lead with 64 hex chars.
149
149
  */
150
150
  /**
151
- * Download the exe release asset for this CLI version, verify its sha256
151
+ * Download the exe release asset for this HELPER version, verify its sha256
152
152
  * against the published `.sha256` asset, and cache it under the agents cache
153
- * dir. Only the exact `v<version>` tag is consulted — a missing asset is a
154
- * hard error naming that tag, never a silent fallback to another release.
153
+ * dir. Only the exact `computer-win/v<version>` tag is consulted — a missing
154
+ * asset is a hard error naming that tag, never a silent fallback to another
155
+ * release. The version is the HELPER's own, not the CLI's.
155
156
  */
156
157
  export async function downloadWinHelperExe(version) {
157
158
  const cached = path.join(winHelperCacheDir(version), WIN_HELPER_EXE);
158
159
  if (fs.existsSync(cached))
159
160
  return cached;
160
- const tag = `v${version}`;
161
+ // The SAME tag the URL is built from. This said `v${version}` — the CLI's tag
162
+ // shape — while winHelperAssetUrls uses helperTag('computer-win', ...), so a
163
+ // missing asset sent you looking for a tag that does not exist. The mac path
164
+ // had this fixed already; the Windows path was left behind by the tag split.
165
+ const tag = helperTag('computer-win', version);
161
166
  const { exe: exeUrl, sha256: shaUrl } = winHelperAssetUrls(version);
162
167
  const missing = (status, url) => new Error(`no ${WIN_HELPER_EXE} release asset for tag ${tag} (HTTP ${status} on ${url}). ` +
163
- `The Windows helper ships as a GitHub release asset per tagged CLI version; ` +
168
+ `The Windows helper ships as a GitHub release asset on its own helper tag; ` +
164
169
  `from a repo checkout you can build it locally instead: bash scripts/build-win.sh`);
165
170
  // Checksum first: it is tiny and 404s fast when the tag has no assets.
166
171
  const shaRes = await fetch(shaUrl, { signal: AbortSignal.timeout(30_000) });
@@ -28,11 +28,12 @@ export interface ParsedAutoConfigKey {
28
28
  scope: 'auto';
29
29
  property: 'pool';
30
30
  }
31
- /** A device-scope browser key (self or peer): the profile agents drive, or the
32
- * browser that shows the user a page. */
31
+ /** A browser key: the profile agents drive or the browser that shows the user a
32
+ * page (both device-scope, self or peer), or `device` — the user-scope fleet
33
+ * hub every box drives by default, which is central and never peer-targeted. */
33
34
  export interface ParsedBrowserConfigKey {
34
35
  scope: 'browser';
35
- property: 'profile' | 'viewer';
36
+ property: 'profile' | 'viewer' | 'device';
36
37
  device?: string;
37
38
  }
38
39
  export interface ParsedProjectConfigKey {
@@ -88,6 +88,9 @@ export function parseConfigKey(key) {
88
88
  if (raw === 'browser.viewer') {
89
89
  return { scope: 'browser', property: 'viewer' };
90
90
  }
91
+ if (raw === 'browser.device') {
92
+ return { scope: 'browser', property: 'device' };
93
+ }
91
94
  if (raw === 'project.root') {
92
95
  return { scope: 'project', property: 'root' };
93
96
  }
@@ -110,7 +113,7 @@ export function parseConfigKey(key) {
110
113
  throw new Error(`Invalid auto config key '${key}'. Use auto.pool.`);
111
114
  }
112
115
  if (raw.startsWith('browser.')) {
113
- throw new Error(`Invalid browser config key '${key}'. Use browser.profile or browser.viewer.`);
116
+ throw new Error(`Invalid browser config key '${key}'. Use browser.profile, browser.viewer, or browser.device.`);
114
117
  }
115
118
  if (raw.startsWith('project.')) {
116
119
  throw new Error(`Invalid project config key '${key}'. Use project.root.`);
@@ -149,7 +152,7 @@ export function listKnownConfigKeys() {
149
152
  for (const tier of MODEL_TIERS) {
150
153
  keys.push(`run.<agent@version>.tier.${tier}`);
151
154
  }
152
- keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'project.root');
155
+ keys.push('interactive.host', 'auto.pool', 'browser.profile', 'browser.viewer', 'browser.device', 'project.root');
153
156
  for (const prop of DEVICE_CONFIG_PROPERTIES) {
154
157
  keys.push(`devices.<name>.${prop}`);
155
158
  }
@@ -202,6 +205,10 @@ export function configKeyStorageHint(parsed) {
202
205
  case 'auto':
203
206
  return 'config.autoPool';
204
207
  case 'browser': {
208
+ if (parsed.property === 'device') {
209
+ // User scope: one value in the central agents.yaml that syncs fleet-wide.
210
+ return 'config.defaultBrowserDevice (central agents.yaml; syncs fleet-wide)';
211
+ }
205
212
  const yamlKey = parsed.property === 'viewer' ? 'browserViewer' : 'defaultBrowserProfile';
206
213
  return parsed.device
207
214
  ? `devices/${parsed.device}/agents.yaml config.${yamlKey}`
@@ -94,6 +94,29 @@ export const CONFIG_KEYS = [
94
94
  type: 'string',
95
95
  description: 'Browser profile `agents browser start` resolves to without --profile (set via `agents browser use`).',
96
96
  },
97
+ {
98
+ name: 'browser.device',
99
+ yamlKey: 'defaultBrowserDevice',
100
+ // user scope, so a SINGLE value in the central agents.yaml syncs to every box:
101
+ // the fleet's browser hub. A worker with this set forwards its browser drives
102
+ // to the hub (as if `--device <hub>` was passed) with no per-command flag, so
103
+ // every agent shares the hub's one logged-in browser. The hub names itself, so
104
+ // there it resolves to a self-host and runs locally — which is why one synced
105
+ // value is safe. Unset = drive this box's own browser (today's behavior).
106
+ scope: 'user',
107
+ type: 'string',
108
+ description: 'Fleet browser hub: the device whose browser `agents browser` drive verbs target by default, with no --device. ' +
109
+ 'The hub itself runs locally; every other box forwards to it. Unset = each box drives its own browser.',
110
+ validate: (v) => {
111
+ try {
112
+ assertValidDeviceName(v);
113
+ return null;
114
+ }
115
+ catch (err) {
116
+ return err?.message ?? String(err);
117
+ }
118
+ },
119
+ },
97
120
  {
98
121
  name: 'agents.max-concurrent',
99
122
  yamlKey: 'maxAgents',
@@ -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
@@ -13,7 +13,7 @@ export declare const FACTORY_PROJECTS: readonly [{
13
13
  readonly repo: "phnx-labs/rush-cli";
14
14
  }, {
15
15
  readonly name: "Agents CLI";
16
- readonly repo: "phnx-labs/agents-cli";
16
+ readonly repo: "phnx-labs/agi-cli";
17
17
  }, {
18
18
  readonly name: "Linear CLI";
19
19
  readonly repo: "phnx-labs/linear-cli";
@@ -25,7 +25,7 @@ export const FACTORY_PROJECTS = [
25
25
  { name: 'Prix', repo: 'phnx-labs/prix' },
26
26
  { name: 'Rush App', repo: 'phnx-labs/rush' },
27
27
  { name: 'Rush CLI', repo: 'phnx-labs/rush-cli' },
28
- { name: 'Agents CLI', repo: 'phnx-labs/agents-cli' },
28
+ { name: 'Agents CLI', repo: 'phnx-labs/agi-cli' },
29
29
  { name: 'Linear CLI', repo: 'phnx-labs/linear-cli' },
30
30
  ];
31
31
  const defaults = () => ({
@@ -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
package/dist/lib/git.d.ts CHANGED
@@ -260,6 +260,99 @@ export declare function adoptRepo(source: string, targetDir: string): Promise<{
260
260
  backedUp: string[];
261
261
  error?: string;
262
262
  }>;
263
+ /** Read an origin remote URL from a git dir, or null when there is none. */
264
+ export declare function readOriginUrl(dir: string): string | null;
265
+ /**
266
+ * Persist the user repo's remote URL to device-local runtime state so a future
267
+ * adopt-in-place can recover it after a `.git` loss. Best-effort — a write
268
+ * failure never blocks a sync.
269
+ */
270
+ export declare function recordUserRepoRemote(dir: string, url: string): void;
271
+ /**
272
+ * Resolve the user config repo's remote URL WITHOUT hardcoding it, for the
273
+ * adopt-in-place self-heal. In priority order:
274
+ * 1. an existing `origin` remote on the dir (a partial repo that kept its
275
+ * `.git` but drifted) — the same source `agents repo sync` already reads;
276
+ * 2. the `AGENTS_USER_REPO_URL` env override (a fresh/never-cloned box);
277
+ * 3. the device-local record written by a prior healthy sync (a box that lost
278
+ * its `.git` but kept `.history/` runtime state).
279
+ * Returns null when none is known — the caller then guides the operator to
280
+ * `agents repo pull user <git-url>` instead of crashing.
281
+ */
282
+ export declare function resolveUserRepoRemoteUrl(dir: string): string | null;
283
+ /**
284
+ * Decide whether a local top-level `agents.yaml` is a stale install stub that
285
+ * should be restored from the committed copy, vs. a legitimately customized file
286
+ * that must be preserved.
287
+ *
288
+ * The stub a partial install leaves behind (createDefaultMeta + a few config
289
+ * writes) is strictly SHORTER than the committed config AND missing whole
290
+ * top-level blocks the committed one carries (`config:` / `hooks:` — the fleet
291
+ * browser hub and hook registrations). Device-specific settings live in
292
+ * `devices/<host>/agents.yaml`, never here, so restoring the top-level file is
293
+ * safe. A file that already carries those blocks (or is longer) is treated as a
294
+ * real local edit and left alone — it surfaces as a modified path instead.
295
+ */
296
+ export declare function isStaleAgentsYamlStub(local: string, committed: string): boolean;
297
+ export interface AdoptInPlaceResult {
298
+ success: boolean;
299
+ commit: string;
300
+ /** Tracked files that were absent locally and materialized from origin/main. */
301
+ materialized: number;
302
+ /** True when the stale-stub top-level agents.yaml was restored from origin. */
303
+ reconciledAgentsYaml: boolean;
304
+ /**
305
+ * When agents.yaml was reconciled, the path the PRE-reconcile local copy was
306
+ * saved to first — so even a false-positive stub match (e.g. a user who
307
+ * deliberately removed a whole `hooks:`/`config:` block) is recoverable, never
308
+ * silently lost.
309
+ */
310
+ agentsYamlBackup?: string;
311
+ /**
312
+ * Tracked paths whose local copy differs from origin/main and was NOT touched
313
+ * — un-gitignored local edits surfaced rather than silently overwritten.
314
+ */
315
+ localEdits: string[];
316
+ error?: string;
317
+ }
318
+ /**
319
+ * Adopt an EXISTING, non-git (or origin-less) `~/.agents` directory in place —
320
+ * git-back it against its remote WITHOUT re-cloning and WITHOUT destroying the
321
+ * runtime state it carries (`.cache` / `.history` / `scratch` / `.system`, all
322
+ * gitignored). The self-heal for a partial install (PHNX-3301): the current code
323
+ * hard-fails with "Not a git repo", and the only manual fix is a destructive
324
+ * re-clone that wipes that runtime state.
325
+ *
326
+ * Plumbing-only, so it never trips the fleet git-guard (no `reset` / `checkout
327
+ * <branch>` / `stash` / `git config`):
328
+ * 1. `git init` + point HEAD at `main`.
329
+ * 2. `git remote add origin <url>`.
330
+ * 3. `git fetch origin main`.
331
+ * 4. `git update-ref refs/heads/main origin/main`; set upstream to origin/main.
332
+ * 5. `git read-tree origin/main` — index = origin/main, working tree untouched.
333
+ * 6. Materialize only the tracked files MISSING from the working tree
334
+ * (`checkout-index` on that set) — existing local files are never overwritten.
335
+ * 7. Reconcile the top-level `agents.yaml`: restore it from origin/main only
336
+ * when the local copy is a stale stub ({@link isStaleAgentsYamlStub}).
337
+ *
338
+ * Idempotent: a second run finds the remote/refs already present and simply
339
+ * re-materializes nothing. Any tracked path with real local edits is returned in
340
+ * `localEdits` (surfaced, never clobbered).
341
+ */
342
+ export declare function adoptRepoInPlace(dir: string, remoteUrl: string): Promise<AdoptInPlaceResult>;
343
+ /**
344
+ * Self-heal entry point for the USER config repo: when `dir` is not a git repo
345
+ * (or is a repo with no `origin`), resolve its remote URL and adopt it in place;
346
+ * otherwise return null (nothing to adopt — the normal sync path runs). Returns a
347
+ * failed result carrying `needsUrl` when the URL cannot be resolved, so the
348
+ * caller can print the `agents repo pull user <git-url>` remediation instead of
349
+ * the old "Not a git repo" crash.
350
+ */
351
+ export declare function adoptUserRepoIfNeeded(dir: string, opts?: {
352
+ explicitUrl?: string;
353
+ }): Promise<(AdoptInPlaceResult & {
354
+ needsUrl?: boolean;
355
+ }) | null>;
263
356
  /**
264
357
  * Check if the repo's origin points to the system repo.
265
358
  */