@phnx-labs/agents-cli 1.22.50 → 1.22.52
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +219 -0
- package/dist/bootstrap.js +1 -1
- package/dist/commands/attach.js +7 -0
- package/dist/commands/browser.d.ts +10 -0
- package/dist/commands/browser.js +191 -54
- package/dist/commands/config.js +20 -0
- package/dist/commands/daemon.d.ts +2 -0
- package/dist/commands/daemon.js +8 -4
- package/dist/commands/detach.js +1 -1
- package/dist/commands/feedback.js +1 -1
- package/dist/commands/focus.d.ts +1 -10
- package/dist/commands/focus.js +14 -79
- package/dist/commands/go.d.ts +26 -0
- package/dist/commands/go.js +63 -5
- package/dist/commands/menubar.js +6 -4
- package/dist/commands/monitors.js +1 -1
- package/dist/commands/repo.js +31 -3
- package/dist/commands/sessions-resume.d.ts +1 -0
- package/dist/commands/sessions-resume.js +13 -2
- package/dist/commands/sessions-stop.js +1 -1
- package/dist/commands/sessions.d.ts +23 -13
- package/dist/commands/sessions.js +40 -20
- package/dist/commands/setup-browser.d.ts +5 -2
- package/dist/commands/setup-browser.js +14 -29
- package/dist/commands/setup-preferences.d.ts +22 -3
- package/dist/commands/setup-preferences.js +25 -8
- package/dist/commands/share.js +12 -8
- package/dist/commands/status.js +5 -0
- package/dist/commands/sync.js +58 -2
- package/dist/commands/tmux.d.ts +8 -1
- package/dist/commands/tmux.js +167 -17
- package/dist/commands/traces.js +32 -4
- package/dist/lib/browser/ipc.d.ts +44 -0
- package/dist/lib/browser/ipc.js +120 -8
- package/dist/lib/browser/profiles.d.ts +39 -17
- package/dist/lib/browser/profiles.js +51 -52
- package/dist/lib/browser/runtime-state.d.ts +4 -2
- package/dist/lib/browser/runtime-state.js +4 -2
- package/dist/lib/browser/service.js +4 -3
- package/dist/lib/channels/owner-forward.d.ts +88 -0
- package/dist/lib/channels/owner-forward.js +116 -0
- package/dist/lib/channels/owner-sink.js +7 -0
- package/dist/lib/claude-statusline.d.ts +9 -0
- package/dist/lib/claude-statusline.js +45 -4
- package/dist/lib/computer/ssh-tunnel.d.ts +7 -6
- package/dist/lib/computer/ssh-tunnel.js +13 -8
- package/dist/lib/config-keys.d.ts +4 -3
- package/dist/lib/config-keys.js +9 -2
- package/dist/lib/device-config.js +23 -0
- package/dist/lib/exec.d.ts +8 -0
- package/dist/lib/exec.js +7 -0
- package/dist/lib/factory/snapshot.d.ts +1 -1
- package/dist/lib/factory/snapshot.js +1 -1
- package/dist/lib/feed-broadcast.js +15 -1
- package/dist/lib/git.d.ts +93 -0
- package/dist/lib/git.js +232 -0
- package/dist/lib/helper-download.d.ts +12 -2
- package/dist/lib/helper-download.js +12 -2
- package/dist/lib/installations/migrate.js +4 -4
- package/dist/lib/menubar/install-menubar.d.ts +71 -8
- package/dist/lib/menubar/install-menubar.js +183 -24
- package/dist/lib/monitors/remote.d.ts +18 -1
- package/dist/lib/monitors/remote.js +15 -2
- package/dist/lib/notify.d.ts +7 -0
- package/dist/lib/notify.js +15 -1
- package/dist/lib/session/local-tmux-attach.d.ts +69 -0
- package/dist/lib/session/local-tmux-attach.js +164 -0
- package/dist/lib/session/remote-active.d.ts +8 -0
- package/dist/lib/session/remote-active.js +1 -0
- package/dist/lib/share/publish.d.ts +8 -11
- package/dist/lib/share/publish.js +16 -20
- package/dist/lib/share/worker-template.js +99 -12
- package/dist/lib/star-nudge.d.ts +2 -2
- package/dist/lib/star-nudge.js +2 -2
- package/dist/lib/state.d.ts +1 -1
- package/dist/lib/state.js +2 -2
- package/dist/lib/sync-status.d.ts +17 -0
- package/dist/lib/sync-status.js +21 -2
- package/dist/lib/tmux/index.d.ts +1 -1
- package/dist/lib/tmux/index.js +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +29 -0
- package/dist/lib/traces/sync.d.ts +30 -0
- package/dist/lib/traces/sync.js +91 -10
- package/package.json +3 -3
|
@@ -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
|
|
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/
|
|
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
|
|
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
|
|
79
|
-
* hard error naming that tag, never a silent fallback to another
|
|
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/
|
|
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
|
|
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
|
|
154
|
-
* hard error naming that tag, never a silent fallback to another
|
|
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
|
-
|
|
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
|
|
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
|
|
32
|
-
*
|
|
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 {
|
package/dist/lib/config-keys.js
CHANGED
|
@@ -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.
|
|
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',
|
package/dist/lib/exec.d.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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
|
-
|
|
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
|
*/
|