@phnx-labs/agents-cli 1.22.26 → 1.22.27
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 +244 -0
- package/README.md +18 -1
- package/dist/bin/agents +0 -0
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/doctor.js +24 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.js +39 -5
- package/dist/commands/output.js +100 -25
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +345 -37
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.js +3 -3
- package/dist/index.js +16 -20
- package/dist/lib/auth-health.d.ts +47 -3
- package/dist/lib/auth-health.js +74 -15
- package/dist/lib/bench/index.d.ts +4 -0
- package/dist/lib/bench/index.js +4 -0
- package/dist/lib/bench/runner.d.ts +16 -0
- package/dist/lib/bench/runner.js +111 -0
- package/dist/lib/bench/schema.d.ts +5 -0
- package/dist/lib/bench/schema.js +91 -0
- package/dist/lib/bench/storage.d.ts +5 -0
- package/dist/lib/bench/storage.js +32 -0
- package/dist/lib/bench/types.d.ts +40 -0
- package/dist/lib/bench/types.js +1 -0
- package/dist/lib/claude-account-token.d.ts +2 -0
- package/dist/lib/claude-account-token.js +27 -1
- package/dist/lib/crabbox/cli.d.ts +2 -0
- package/dist/lib/crabbox/cli.js +2 -0
- package/dist/lib/crabbox/lease.js +7 -1
- package/dist/lib/daemon.js +20 -0
- package/dist/lib/devices/connect.d.ts +2 -0
- package/dist/lib/devices/connect.js +7 -0
- package/dist/lib/devices/doctor-findings.d.ts +4 -1
- package/dist/lib/devices/doctor-findings.js +15 -1
- package/dist/lib/devices/harness-inventory.d.ts +97 -0
- package/dist/lib/devices/harness-inventory.js +0 -0
- package/dist/lib/devices/registry.d.ts +2 -0
- package/dist/lib/devices/ssh-config.js +3 -0
- package/dist/lib/devices/windows-ssh-enrollment.d.ts +20 -0
- package/dist/lib/devices/windows-ssh-enrollment.js +98 -0
- package/dist/lib/exec.bench.d.ts +1 -0
- package/dist/lib/exec.bench.js +186 -0
- package/dist/lib/exec.js +18 -8
- package/dist/lib/fleet/apply.js +9 -7
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks.d.ts +8 -5
- package/dist/lib/hooks.js +10 -6
- package/dist/lib/hosts/dispatch.js +7 -3
- package/dist/lib/hosts/passthrough.d.ts +22 -0
- package/dist/lib/hosts/passthrough.js +14 -8
- package/dist/lib/hosts/progress.d.ts +2 -1
- package/dist/lib/hosts/progress.js +4 -3
- package/dist/lib/hosts/providers/devices.js +1 -0
- package/dist/lib/hosts/ready.d.ts +4 -2
- package/dist/lib/hosts/ready.js +24 -8
- package/dist/lib/hosts/reconcile.d.ts +1 -1
- package/dist/lib/hosts/reconcile.js +16 -7
- package/dist/lib/hosts/reconnect.d.ts +45 -12
- package/dist/lib/hosts/reconnect.js +90 -34
- package/dist/lib/hosts/registry.d.ts +2 -2
- package/dist/lib/hosts/registry.js +3 -5
- package/dist/lib/hosts/remote-cmd.d.ts +17 -0
- package/dist/lib/hosts/remote-cmd.js +29 -0
- package/dist/lib/hosts/tasks.d.ts +2 -0
- package/dist/lib/hosts/types.d.ts +1 -0
- package/dist/lib/hosts/types.js +3 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/install-menubar.d.ts +9 -6
- package/dist/lib/menubar/install-menubar.js +20 -9
- package/dist/lib/models.d.ts +30 -1
- package/dist/lib/models.js +42 -144
- package/dist/lib/pricing/cost.d.ts +9 -0
- package/dist/lib/pricing/cost.js +24 -0
- package/dist/lib/pricing/index.d.ts +1 -1
- package/dist/lib/pricing/index.js +1 -1
- package/dist/lib/redact.js +8 -3
- package/dist/lib/remote-agents-json.d.ts +32 -0
- package/dist/lib/remote-agents-json.js +47 -16
- package/dist/lib/resource-profiles.js +1 -2
- package/dist/lib/routine-notify-owner.d.ts +102 -0
- package/dist/lib/routine-notify-owner.js +232 -0
- package/dist/lib/routines.d.ts +10 -0
- package/dist/lib/runner.d.ts +9 -1
- package/dist/lib/runner.js +182 -17
- package/dist/lib/sandbox.d.ts +0 -2
- package/dist/lib/sandbox.js +2 -19
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/agent.js +3 -2
- package/dist/lib/secrets/lease.d.ts +25 -0
- package/dist/lib/secrets/lease.js +44 -0
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +19 -2
- package/dist/lib/session/db.js +54 -5
- package/dist/lib/session/discover.d.ts +21 -1
- package/dist/lib/session/discover.js +124 -32
- package/dist/lib/session/insights.d.ts +19 -0
- package/dist/lib/session/insights.js +78 -10
- package/dist/lib/session/recovery.d.ts +17 -1
- package/dist/lib/session/recovery.js +111 -4
- package/dist/lib/session/remote-list.d.ts +10 -9
- package/dist/lib/session/remote-list.js +25 -23
- package/dist/lib/session/resume-owner.d.ts +55 -0
- package/dist/lib/session/resume-owner.js +69 -0
- package/dist/lib/session/team-filter.d.ts +65 -0
- package/dist/lib/session/team-filter.js +98 -3
- package/dist/lib/session/types.d.ts +33 -0
- package/dist/lib/smart-launch.d.ts +31 -5
- package/dist/lib/smart-launch.js +43 -7
- package/dist/lib/ssh-exec.d.ts +27 -0
- package/dist/lib/ssh-exec.js +34 -1
- package/dist/lib/ssh-tunnel.d.ts +3 -2
- package/dist/lib/ssh-tunnel.js +25 -16
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/dist/lib/teams/placement-probe.d.ts +1 -1
- package/dist/lib/teams/placement-probe.js +24 -24
- package/dist/lib/tmux/session.d.ts +8 -0
- package/dist/lib/tmux/session.js +22 -0
- package/dist/lib/types.d.ts +2 -0
- package/package.json +1 -1
|
@@ -19,15 +19,20 @@
|
|
|
19
19
|
* fall through to resume so the user is put back into the agent. There is one
|
|
20
20
|
* re-attach implementation (the peer's focus) to keep in sync.
|
|
21
21
|
*
|
|
22
|
-
* **
|
|
23
|
-
* for BOTH "couldn't connect at all" and "connected, then the link
|
|
24
|
-
* failed connect still takes up to `ConnectTimeout` (10s) to return, so
|
|
25
|
-
*
|
|
26
|
-
* would classify every hung connect as a live session and retry
|
|
27
|
-
* sustained outage (the exact failure this feature exists to
|
|
28
|
-
* attempt runs a fast preflight probe
|
|
29
|
-
*
|
|
30
|
-
*
|
|
22
|
+
* **What it takes to refill the budget: reached the host AND held the pane.** ssh
|
|
23
|
+
* returns 255 for BOTH "couldn't connect at all" and "connected, then the link
|
|
24
|
+
* dropped." A failed connect still takes up to `ConnectTimeout` (10s) to return, so
|
|
25
|
+
* the duration of the whole call can't tell the two apart — a threshold below the
|
|
26
|
+
* connect timeout would classify every hung connect as a live session and retry
|
|
27
|
+
* forever under a sustained outage (the exact failure this feature exists to
|
|
28
|
+
* survive). So each attempt runs a fast preflight probe FIRST, and only once that
|
|
29
|
+
* probe proves the host reachable does the interactive attach run — which means the
|
|
30
|
+
* ATTACH's own duration is a clean signal, measured with the connect phase already
|
|
31
|
+
* behind it. A reattach that reached the host and then held for at least
|
|
32
|
+
* {@link MIN_HOLD_MS} refills the retry budget, so a long session that blinks all
|
|
33
|
+
* day keeps reconnecting. Everything else — never reached the host, or reached it
|
|
34
|
+
* and died right back — counts against the budget, so a sustained outage and a
|
|
35
|
+
* fast-flapping link both give up after {@link MAX_ATTEMPTS}.
|
|
31
36
|
*
|
|
32
37
|
* The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
|
|
33
38
|
* without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
|
|
@@ -54,13 +59,18 @@
|
|
|
54
59
|
* regardless of the peer's `agents` version (the remap happens in the shell
|
|
55
60
|
* wrapper THIS process sends).
|
|
56
61
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
62
|
+
* A recurring LOCAL ssh failure used to defeat the budget the same way, and the
|
|
63
|
+
* remap alone did not close it (agents-cli#1884): `connected` was set by the
|
|
64
|
+
* preflight probe and said nothing about whether the attach that followed held, so
|
|
65
|
+
* a fast-flapping link — or an attach that died at the TTY-negotiation stage every
|
|
66
|
+
* single time — refilled the budget on every cycle, printed "attempt 1/N" forever,
|
|
67
|
+
* and left `MAX_ATTEMPTS` bounding nothing. The {@link MIN_HOLD_MS} floor above is
|
|
68
|
+
* what closes it: an attach that dies immediately is not a reconnection, so the
|
|
69
|
+
* budget drains and the loop gives up with {@link unstableNotice}. A flat total-
|
|
70
|
+
* attempt or wall-clock ceiling that ignored `connected` was the alternative and is
|
|
71
|
+
* deliberately NOT taken — any fixed total eventually strands the all-day-blinking
|
|
72
|
+
* session this feature exists for, while the hold floor only ever stops a loop that
|
|
73
|
+
* is failing to put the user back into the agent.
|
|
64
74
|
*/
|
|
65
75
|
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
66
76
|
import { sshTargetFor } from './types.js';
|
|
@@ -71,13 +81,22 @@ export const SSH_CONN_FAILURE = 255;
|
|
|
71
81
|
* {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
|
|
72
82
|
* transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
|
|
73
83
|
export const REMOTE_EXIT_255_REMAPPED = 254;
|
|
74
|
-
/** Consecutive
|
|
75
|
-
* {@link MAX_BACKOFF_MS}. A reattach that
|
|
76
|
-
* refills the budget, so a long session that blinks all day reconnects every time
|
|
77
|
-
*
|
|
84
|
+
/** Consecutive unproductive reattaches before giving up. Backoff is capped at
|
|
85
|
+
* {@link MAX_BACKOFF_MS}. A reattach that reconnected and HELD (then dropped again)
|
|
86
|
+
* refills the budget, so a long session that blinks all day reconnects every time —
|
|
87
|
+
* an attempt that never reached the host, or reached it and died back inside
|
|
88
|
+
* {@link MIN_HOLD_MS}, counts against it. */
|
|
78
89
|
export const MAX_ATTEMPTS = 6;
|
|
79
90
|
const BASE_BACKOFF_MS = 2_000;
|
|
80
91
|
const MAX_BACKOFF_MS = 30_000;
|
|
92
|
+
/** How long a reattach must hold the remote pane before it counts as a genuine
|
|
93
|
+
* reconnection that refills the budget. Measured on the interactive attach ALONE
|
|
94
|
+
* — the preflight probe has already returned by then, so this is not the connect
|
|
95
|
+
* timing the file header rules out. 10s is comfortably longer than any attach that
|
|
96
|
+
* dies during TTY negotiation and far shorter than a session the user is working
|
|
97
|
+
* in; a link that drops the user out inside 10s on every attempt is one this loop
|
|
98
|
+
* should stop retrying, not one it should keep re-entering. */
|
|
99
|
+
export const MIN_HOLD_MS = 10_000;
|
|
81
100
|
export function initialReconnectState() {
|
|
82
101
|
return { attempt: 0 };
|
|
83
102
|
}
|
|
@@ -85,6 +104,16 @@ export function initialReconnectState() {
|
|
|
85
104
|
export function backoffMs(attempt) {
|
|
86
105
|
return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
87
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* Did this attempt genuinely put the user back into the agent? Only such an attempt
|
|
109
|
+
* refills the retry budget — it must have reached the host AND held the pane for at
|
|
110
|
+
* least {@link MIN_HOLD_MS}. An attach that reached the host and died right back is
|
|
111
|
+
* a flapping link, not a reconnection, and counts against the budget like an
|
|
112
|
+
* unreachable host (agents-cli#1884; see the file header).
|
|
113
|
+
*/
|
|
114
|
+
export function refillsBudget(outcome) {
|
|
115
|
+
return outcome.connected && outcome.heldMs >= MIN_HOLD_MS;
|
|
116
|
+
}
|
|
88
117
|
/**
|
|
89
118
|
* Decide what to do after a run/re-attach returned `outcome`. Pure — the only
|
|
90
119
|
* input is the prior state and the outcome, the only output is the next action.
|
|
@@ -92,14 +121,15 @@ export function backoffMs(attempt) {
|
|
|
92
121
|
* - a non-255 code means the remote command spoke for itself (clean detach = 0,
|
|
93
122
|
* agent exit / no live session = non-zero) → stop and surface that code.
|
|
94
123
|
* - a 255 means the link dropped → retry, unless the budget is spent.
|
|
95
|
-
* - a 255 from an attempt that
|
|
96
|
-
*
|
|
97
|
-
*
|
|
124
|
+
* - a 255 from an attempt that reconnected AND held ({@link refillsBudget})
|
|
125
|
+
* refills the budget first; every other 255 counts against it, so a host that
|
|
126
|
+
* stays unreachable — and a link that keeps dropping the attach immediately —
|
|
127
|
+
* both give up after MAX_ATTEMPTS.
|
|
98
128
|
*/
|
|
99
129
|
export function reconnectStep(state, outcome) {
|
|
100
130
|
if (outcome.code !== SSH_CONN_FAILURE)
|
|
101
131
|
return { action: 'stop', code: outcome.code };
|
|
102
|
-
const attempts = outcome
|
|
132
|
+
const attempts = refillsBudget(outcome) ? 0 : state.attempt;
|
|
103
133
|
if (attempts >= MAX_ATTEMPTS)
|
|
104
134
|
return { action: 'stop', code: SSH_CONN_FAILURE };
|
|
105
135
|
return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
|
|
@@ -110,11 +140,22 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
|
|
|
110
140
|
const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
|
|
111
141
|
return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
|
|
112
142
|
}
|
|
113
|
-
/** Notice shown once the retry budget is spent
|
|
114
|
-
* re-enters the terminal — attach the live pane if it
|
|
143
|
+
/** Notice shown once the retry budget is spent on a host that stayed UNREACHABLE.
|
|
144
|
+
* Hands back the one verb that re-enters the terminal — attach the live pane if it
|
|
145
|
+
* survived, else resume. */
|
|
115
146
|
export function exhaustedNotice(sessionId, host) {
|
|
116
147
|
return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — get back in when the network is back:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
|
|
117
148
|
}
|
|
149
|
+
/** Notice shown when the budget is spent the OTHER way: the last reattach reached
|
|
150
|
+
* the host and the connection dropped again within {@link MIN_HOLD_MS}. Saying
|
|
151
|
+
* "couldn't reconnect" there would be false — it did reconnect and could not stay
|
|
152
|
+
* — and the user needs to know the link, not the host, is the problem. It claims
|
|
153
|
+
* no count of successful reconnections: the budget can also be spent by a run of
|
|
154
|
+
* unreachable attempts followed by one that reconnected and dropped straight out. */
|
|
155
|
+
export function unstableNotice(sessionId, host) {
|
|
156
|
+
const secs = Math.round(MIN_HOLD_MS / 1000);
|
|
157
|
+
return `\nGave up reconnecting to ${host} after ${MAX_ATTEMPTS} attempts — it kept dropping again within ${secs} seconds of getting back in. The agent may still be running there; reconnect once the link is stable:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
|
|
158
|
+
}
|
|
118
159
|
/** Notice shown when a reattach stops on a remapped remote-side exit
|
|
119
160
|
* ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
|
|
120
161
|
* on for its own reasons, not the ssh transport dropping; see
|
|
@@ -168,7 +209,8 @@ export function reattachRemoteCommand(sessionId) {
|
|
|
168
209
|
* a reachable host do we run the interactive attach-or-resume (which carries no
|
|
169
210
|
* credentials — the agent already runs on the peer — so it rides the normal
|
|
170
211
|
* transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
171
|
-
* clean detach; other = session ended)
|
|
212
|
+
* clean detach; other = session ended), whether this attempt connected, and how
|
|
213
|
+
* long the attach held — the two inputs {@link refillsBudget} decides on.
|
|
172
214
|
*/
|
|
173
215
|
export function reattachRemoteSession(host, sessionId) {
|
|
174
216
|
const target = sshTargetFor(host);
|
|
@@ -176,8 +218,12 @@ export function reattachRemoteSession(host, sessionId) {
|
|
|
176
218
|
// completed, so a hung/failed connect is never mistaken for a live reconnection.
|
|
177
219
|
const probe = sshExec(target, 'true', { multiplex: false });
|
|
178
220
|
if (probe.code !== 0)
|
|
179
|
-
return { code: SSH_CONN_FAILURE, connected: false };
|
|
180
|
-
|
|
221
|
+
return { code: SSH_CONN_FAILURE, connected: false, heldMs: 0 };
|
|
222
|
+
// Timed from AFTER the probe returned, so this is the attach's own duration and
|
|
223
|
+
// carries none of the connect phase the file header rules out as a signal.
|
|
224
|
+
const startedAt = Date.now();
|
|
225
|
+
const code = sshStream(target, reattachRemoteCommand(sessionId), { tty: true });
|
|
226
|
+
return { code, connected: true, heldMs: Date.now() - startedAt };
|
|
181
227
|
}
|
|
182
228
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
183
229
|
/**
|
|
@@ -190,15 +236,25 @@ export async function reconnectInteractiveSession(opts) {
|
|
|
190
236
|
const wait = opts.wait ?? sleep;
|
|
191
237
|
const reattach = opts.reattach ?? reattachRemoteSession;
|
|
192
238
|
let state = initialReconnectState();
|
|
193
|
-
// The initial run reached the point of running the agent, so it connected.
|
|
194
|
-
|
|
239
|
+
// The initial run reached the point of running the agent, so it connected. Its
|
|
240
|
+
// hold duration is never measured — exec.ts owns that call — and never needs to
|
|
241
|
+
// be: refilling is a no-op at attempt 0, which is where the loop starts, so this
|
|
242
|
+
// outcome can only ever produce the first retry either way.
|
|
243
|
+
let outcome = { code: opts.initialExit, connected: true, heldMs: 0 };
|
|
195
244
|
for (;;) {
|
|
196
245
|
const decision = reconnectStep(state, outcome);
|
|
197
246
|
if (decision.action === 'stop') {
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
247
|
+
// A spent budget has two shapes and one wrong message: `outcome` is the
|
|
248
|
+
// reattach that spent it, so an unreachable host reads "couldn't reconnect"
|
|
249
|
+
// and a link that reconnected but kept dropping reads as exactly that.
|
|
250
|
+
if (decision.code === SSH_CONN_FAILURE) {
|
|
251
|
+
write(outcome.connected
|
|
252
|
+
? unstableNotice(opts.sessionId, opts.host.name)
|
|
253
|
+
: exhaustedNotice(opts.sessionId, opts.host.name));
|
|
254
|
+
}
|
|
255
|
+
else if (decision.code === REMOTE_EXIT_255_REMAPPED) {
|
|
201
256
|
write(remoteExitNotice(opts.sessionId, opts.host.name));
|
|
257
|
+
}
|
|
202
258
|
return decision.code;
|
|
203
259
|
}
|
|
204
260
|
write(reconnectNotice(opts.sessionId, opts.host.name, decision.state.attempt, decision.waitMs));
|
|
@@ -53,8 +53,8 @@ export interface MatchHostOptions {
|
|
|
53
53
|
* "Unknown device" verdict reachable).
|
|
54
54
|
*/
|
|
55
55
|
allowBareLiteral?: boolean;
|
|
56
|
-
/** Override the affinity pick for
|
|
57
|
-
*
|
|
56
|
+
/** Override the affinity pick for generic `auto` host resolution in tests.
|
|
57
|
+
* Harness-aware run/team placement resolves `auto` before reaching this core. */
|
|
58
58
|
resolveAuto?: () => DeviceAffinityPlan;
|
|
59
59
|
}
|
|
60
60
|
/**
|
|
@@ -148,11 +148,9 @@ function literalHost(token, host, user) {
|
|
|
148
148
|
* into a dispatch verdict.
|
|
149
149
|
*/
|
|
150
150
|
export async function matchHost(name, opts = {}) {
|
|
151
|
-
// `auto`
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
// device the SAME way `run` does instead of rejecting it as "Unknown device
|
|
155
|
-
// 'auto'" (RUSH-2185). A `null` plan.host means the affinity engine picked this
|
|
151
|
+
// Generic host-only callers resolve `auto` through the affinity engine here;
|
|
152
|
+
// harness-aware run/team placement resolves it earlier with live probes. A
|
|
153
|
+
// `null` plan.host means the affinity engine picked this
|
|
156
154
|
// very machine; resolve that as the local device/host entry (if any) rather than
|
|
157
155
|
// returning nothing — callers that already special-case "target is this
|
|
158
156
|
// machine" (teams add/create, the passthrough self-host check) then treat it as
|
|
@@ -140,6 +140,23 @@ export interface WindowsAgentsCommand {
|
|
|
140
140
|
* not the progress stream.)
|
|
141
141
|
*/
|
|
142
142
|
export declare const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
|
|
143
|
+
/**
|
|
144
|
+
* Strip a PowerShell CLIXML wrapper from stdout relayed off a Windows host.
|
|
145
|
+
*
|
|
146
|
+
* PowerShell 5.1 serializes progress / error / verbose records to CLIXML when a
|
|
147
|
+
* stream is a redirected pipe (an ssh capture, not a console): a `#< CLIXML`
|
|
148
|
+
* banner followed by one or more `<Objs …>…</Objs>` elements. {@link
|
|
149
|
+
* POWERSHELL_PROGRESS_SILENCE} suppresses the common "Preparing modules for
|
|
150
|
+
* first use." progress record at the source, but a Windows peer reached WITHOUT
|
|
151
|
+
* that prelude — a raw `agents ssh <win> 'agents … --json'`, an older peer, or a
|
|
152
|
+
* record on a stream we did not silence — can still emit the banner ahead of the
|
|
153
|
+
* real payload, which breaks a naive `JSON.parse` of the relayed `--json`
|
|
154
|
+
* (RUSH-2286). Remove the banner and every self-contained `<Objs …>…</Objs>`
|
|
155
|
+
* block, leaving the genuine payload untouched. A no-op (returns the input
|
|
156
|
+
* unchanged) when no `#< CLIXML` marker is present, so it is safe to apply on
|
|
157
|
+
* every remote-JSON boundary regardless of the peer's OS.
|
|
158
|
+
*/
|
|
159
|
+
export declare function stripClixml(stdout: string): string;
|
|
143
160
|
export declare function windowsAgentsScript(cmd: WindowsAgentsCommand): string;
|
|
144
161
|
/**
|
|
145
162
|
* Build the `ssh <target> <cmd>` string for one `agents …` invocation on a
|
|
@@ -252,6 +252,35 @@ export function decodePowershell(encoded) {
|
|
|
252
252
|
* not the progress stream.)
|
|
253
253
|
*/
|
|
254
254
|
export const POWERSHELL_PROGRESS_SILENCE = "$ProgressPreference = 'SilentlyContinue'";
|
|
255
|
+
/**
|
|
256
|
+
* Strip a PowerShell CLIXML wrapper from stdout relayed off a Windows host.
|
|
257
|
+
*
|
|
258
|
+
* PowerShell 5.1 serializes progress / error / verbose records to CLIXML when a
|
|
259
|
+
* stream is a redirected pipe (an ssh capture, not a console): a `#< CLIXML`
|
|
260
|
+
* banner followed by one or more `<Objs …>…</Objs>` elements. {@link
|
|
261
|
+
* POWERSHELL_PROGRESS_SILENCE} suppresses the common "Preparing modules for
|
|
262
|
+
* first use." progress record at the source, but a Windows peer reached WITHOUT
|
|
263
|
+
* that prelude — a raw `agents ssh <win> 'agents … --json'`, an older peer, or a
|
|
264
|
+
* record on a stream we did not silence — can still emit the banner ahead of the
|
|
265
|
+
* real payload, which breaks a naive `JSON.parse` of the relayed `--json`
|
|
266
|
+
* (RUSH-2286). Remove the banner and every self-contained `<Objs …>…</Objs>`
|
|
267
|
+
* block, leaving the genuine payload untouched. A no-op (returns the input
|
|
268
|
+
* unchanged) when no `#< CLIXML` marker is present, so it is safe to apply on
|
|
269
|
+
* every remote-JSON boundary regardless of the peer's OS.
|
|
270
|
+
*/
|
|
271
|
+
export function stripClixml(stdout) {
|
|
272
|
+
if (!stdout.includes('#< CLIXML'))
|
|
273
|
+
return stdout;
|
|
274
|
+
// A CLIXML flush is the `#< CLIXML` banner immediately followed by one or more
|
|
275
|
+
// <Objs …>…</Objs> elements, as one contiguous block. Remove that whole unit,
|
|
276
|
+
// ANCHORED to the banner: the <Objs> removal is scoped to blocks that follow a
|
|
277
|
+
// banner, so a stray `<Objs>` appearing inside a legitimate JSON string value
|
|
278
|
+
// (a session title/prompt that quotes CLIXML text) is never touched — stripping
|
|
279
|
+
// <Objs> globally would silently delete JSON between two such substrings.
|
|
280
|
+
return stdout
|
|
281
|
+
.replace(/#< CLIXML[^\n]*\r?\n?(?:\s*<Objs\b[\s\S]*?<\/Objs>)*/g, '')
|
|
282
|
+
.trim();
|
|
283
|
+
}
|
|
255
284
|
export function windowsAgentsScript(cmd) {
|
|
256
285
|
const { args, env, cwd, propagateExit = true } = cmd;
|
|
257
286
|
const parts = [POWERSHELL_PROGRESS_SILENCE];
|
package/dist/lib/hosts/types.js
CHANGED
|
Binary file
|
|
Binary file
|
|
@@ -134,12 +134,11 @@ export declare function disableMenubarService(): void;
|
|
|
134
134
|
* different CDHashes. Any digest gate therefore reports "changed" for exactly
|
|
135
135
|
* the skew case it was meant to exempt.
|
|
136
136
|
*
|
|
137
|
-
* So
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* the new helper normally. Pure so the truth table is unit-testable.
|
|
137
|
+
* So the installed version decides release skew: a newer signed release takes
|
|
138
|
+
* ownership immediately, an older release cannot downgrade it, and equal foreign
|
|
139
|
+
* releases retain the recorded owner. The plist's `AGENTS_ENTRY` remains the
|
|
140
|
+
* ownership signal for legacy state with no version marker, where the cooldown
|
|
141
|
+
* bounds takeover churn. Pure so the truth table is unit-testable.
|
|
143
142
|
*/
|
|
144
143
|
export declare function mayInstallMenubarHelper(opts: {
|
|
145
144
|
/** `AGENTS_ENTRY` baked into the installed plist — the recorded owner. */
|
|
@@ -152,6 +151,10 @@ export declare function mayInstallMenubarHelper(opts: {
|
|
|
152
151
|
helperExecMissing: boolean;
|
|
153
152
|
/** Installed copy is ad-hoc while the shipped source is Developer ID. */
|
|
154
153
|
needsDevIdHeal: boolean;
|
|
154
|
+
/** Version stamped beside the installed helper, or null for legacy state. */
|
|
155
|
+
installedVersion: string | null;
|
|
156
|
+
/** Version of the agents-cli install now attempting the heal. */
|
|
157
|
+
currentVersion: string | null;
|
|
155
158
|
/** ms since the last self-heal reinstall, or null if none is recorded. */
|
|
156
159
|
msSinceLastHeal: number | null;
|
|
157
160
|
/** How long a non-owner waits before it may take over. */
|
|
@@ -23,6 +23,7 @@ import { sleepSync } from '../fs-atomic.js';
|
|
|
23
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
24
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
25
25
|
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
26
|
+
import { compareVersions } from '../agent-spec/primitives.js';
|
|
26
27
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
27
28
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
28
29
|
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
@@ -491,12 +492,11 @@ export function disableMenubarService() {
|
|
|
491
492
|
* different CDHashes. Any digest gate therefore reports "changed" for exactly
|
|
492
493
|
* the skew case it was meant to exempt.
|
|
493
494
|
*
|
|
494
|
-
* So
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
*
|
|
498
|
-
*
|
|
499
|
-
* the new helper normally. Pure so the truth table is unit-testable.
|
|
495
|
+
* So the installed version decides release skew: a newer signed release takes
|
|
496
|
+
* ownership immediately, an older release cannot downgrade it, and equal foreign
|
|
497
|
+
* releases retain the recorded owner. The plist's `AGENTS_ENTRY` remains the
|
|
498
|
+
* ownership signal for legacy state with no version marker, where the cooldown
|
|
499
|
+
* bounds takeover churn. Pure so the truth table is unit-testable.
|
|
500
500
|
*/
|
|
501
501
|
export function mayInstallMenubarHelper(opts) {
|
|
502
502
|
// Repairs are never gated: a missing binary or a broken signing identity leaves
|
|
@@ -511,10 +511,18 @@ export function mayInstallMenubarHelper(opts) {
|
|
|
511
511
|
// No owner recorded yet (fresh or pre-`AGENTS_ENTRY` plist) — adopt it.
|
|
512
512
|
if (!opts.plistEntry)
|
|
513
513
|
return true;
|
|
514
|
-
if (opts.plistEntry === opts.activeEntry)
|
|
515
|
-
return true; // we are the owner
|
|
516
514
|
if (!opts.ownerEntryExists)
|
|
517
515
|
return true; // the recorded owner is gone
|
|
516
|
+
if (opts.installedVersion && opts.currentVersion) {
|
|
517
|
+
const versionOrder = compareVersions(opts.currentVersion, opts.installedVersion);
|
|
518
|
+
if (versionOrder > 0)
|
|
519
|
+
return opts.sourceIsDeveloperId;
|
|
520
|
+
if (versionOrder < 0)
|
|
521
|
+
return false;
|
|
522
|
+
return opts.plistEntry === opts.activeEntry;
|
|
523
|
+
}
|
|
524
|
+
if (opts.plistEntry === opts.activeEntry)
|
|
525
|
+
return true; // we are the owner
|
|
518
526
|
// A foreign install while the owner still exists. Refusing outright bounds the
|
|
519
527
|
// loop but strands the user when the recorded owner is a stale copy that simply
|
|
520
528
|
// still sits on disk (an old nvm node dir) while their daily driver upgrades:
|
|
@@ -534,7 +542,8 @@ export function mayInstallMenubarHelper(opts) {
|
|
|
534
542
|
return opts.msSinceLastHeal === null || opts.msSinceLastHeal >= opts.cooldownMs;
|
|
535
543
|
}
|
|
536
544
|
/**
|
|
537
|
-
* How long a non-owner install waits before it may take
|
|
545
|
+
* How long a non-owner install waits before it may take an unversioned legacy
|
|
546
|
+
* helper over. Long
|
|
538
547
|
* enough that a multi-install box restarts the helper at most once an hour
|
|
539
548
|
* instead of every few seconds; short enough that a user who switched installs
|
|
540
549
|
* gets their upgrade without hunting for `agents menubar setup`.
|
|
@@ -572,6 +581,8 @@ function mayHealMenubar(needsDevIdHeal) {
|
|
|
572
581
|
ownerEntryExists: Boolean(plistEntry) && fs.existsSync(plistEntry),
|
|
573
582
|
helperExecMissing: !fs.existsSync(installedExecutablePath()),
|
|
574
583
|
needsDevIdHeal,
|
|
584
|
+
installedVersion: readInstalledMenubarVersion(),
|
|
585
|
+
currentVersion: getCliVersion(),
|
|
575
586
|
msSinceLastHeal: msSinceLastMenubarHeal(),
|
|
576
587
|
cooldownMs: MENUBAR_TAKEOVER_COOLDOWN_MS,
|
|
577
588
|
sourceIsDeveloperId: Boolean(src) && hasDeveloperIdSignature(src),
|
package/dist/lib/models.d.ts
CHANGED
|
@@ -43,7 +43,7 @@ export interface ModelCatalog {
|
|
|
43
43
|
aliases: Record<string, string>;
|
|
44
44
|
}
|
|
45
45
|
/** How the model catalog source was obtained. */
|
|
46
|
-
export type ModelSourceKind = 'bundle' | 'binary' | '
|
|
46
|
+
export type ModelSourceKind = 'bundle' | 'binary' | 'cli';
|
|
47
47
|
/** Describes the location and extraction strategy for a model catalog source. */
|
|
48
48
|
export interface ModelSource {
|
|
49
49
|
path: string;
|
|
@@ -76,6 +76,35 @@ export declare function locateModelSource(agent: AgentId, version: string): Mode
|
|
|
76
76
|
* current model and is kept.
|
|
77
77
|
*/
|
|
78
78
|
export declare function dropBareLegacyIds(ids: string[]): string[];
|
|
79
|
+
/**
|
|
80
|
+
* Scan raw binary/bundle text for canonical Claude model ids, then drop bare
|
|
81
|
+
* legacy prefixes (issue #1892). Two independent guards keep non-model strings
|
|
82
|
+
* out of the catalog:
|
|
83
|
+
*
|
|
84
|
+
* - **Word-boundary anchors on the id regex.** The id must not be glued to a
|
|
85
|
+
* surrounding identifier character, and must not be the truncated prefix of a
|
|
86
|
+
* longer *version* token. `(?<![A-Za-z0-9_])` rejects a glued prefix;
|
|
87
|
+
* `(?![A-Za-z0-9])` rejects a glued alnum suffix; `(?!\.\d)` rejects a
|
|
88
|
+
* dotted-version continuation — so the bare-major prefix of the binary's own
|
|
89
|
+
* "Typo in model ID" troubleshooting string `claude-sonnet-4.6` is not
|
|
90
|
+
* scraped as `claude-sonnet-4`, while a real id followed by an unrelated `.`
|
|
91
|
+
* suffix (`claude-fable-5.md`) still matches. Dash-separated segments only:
|
|
92
|
+
* the dotted form never appears in a genuine id.
|
|
93
|
+
*
|
|
94
|
+
* The id body is captured inside a lookahead (`(?=(...))\1`) so the greedy
|
|
95
|
+
* `-\d+` run matches **atomically**: without it, a suffix-glued token like
|
|
96
|
+
* `claude-opus-4-1x` would fail the trailing anchor on the full match, then
|
|
97
|
+
* backtrack a segment and re-emit the bare `claude-opus-4` — the exact 404-able
|
|
98
|
+
* id this scan exists to suppress (two packed strings can end up glued with no
|
|
99
|
+
* separator in the extracted binary text). The atomic match fails outright
|
|
100
|
+
* instead of degrading to the bare form.
|
|
101
|
+
* - **`dropBareLegacyIds`.** A standalone `.includes("claude-opus-4")` prefix
|
|
102
|
+
* check is a fully delimited string the anchors cannot tell apart from a real
|
|
103
|
+
* bare id, so a bare `claude-<family>-<major>` is dropped only when a
|
|
104
|
+
* more-specific sibling (`claude-opus-4-8`) is also present; a genuinely bare
|
|
105
|
+
* current id with no sibling (`claude-sonnet-5`) is kept.
|
|
106
|
+
*/
|
|
107
|
+
export declare function scanClaudeCatalogIds(text: string): string[];
|
|
79
108
|
/**
|
|
80
109
|
* Parse `grok models` stdout into a catalog. Exported for unit tests.
|
|
81
110
|
*
|