@phnx-labs/agents-cli 1.22.25 → 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 +427 -0
- package/README.md +35 -3
- package/dist/bin/agents +0 -0
- package/dist/browser.js +14 -4
- package/dist/commands/apply.js +52 -8
- package/dist/commands/bench.d.ts +2 -0
- package/dist/commands/bench.js +101 -0
- package/dist/commands/browser.js +35 -0
- package/dist/commands/doctor.js +32 -10
- package/dist/commands/exec.js +17 -16
- package/dist/commands/insights.d.ts +25 -19
- package/dist/commands/insights.js +146 -38
- package/dist/commands/output.js +100 -25
- package/dist/commands/reconnect.d.ts +46 -0
- package/dist/commands/reconnect.js +109 -0
- package/dist/commands/resume.d.ts +11 -0
- package/dist/commands/resume.js +51 -0
- package/dist/commands/routines.js +2 -2
- package/dist/commands/secrets.d.ts +2 -8
- package/dist/commands/secrets.js +29 -105
- package/dist/commands/sessions-picker.js +12 -7
- package/dist/commands/sessions.d.ts +47 -1
- package/dist/commands/sessions.js +349 -37
- package/dist/commands/setup-secrets.d.ts +1 -0
- package/dist/commands/setup-secrets.js +1 -1
- package/dist/commands/setup.d.ts +26 -3
- package/dist/commands/setup.js +105 -46
- package/dist/commands/ssh.js +184 -11
- package/dist/commands/teams.d.ts +6 -0
- package/dist/commands/teams.js +46 -3
- package/dist/commands/trends.d.ts +8 -0
- package/dist/commands/trends.js +10 -156
- package/dist/index.js +17 -21
- package/dist/lib/agents.d.ts +11 -0
- package/dist/lib/agents.js +29 -2
- package/dist/lib/analytics/dashboard.d.ts +10 -6
- package/dist/lib/analytics/dashboard.js +6 -4
- package/dist/lib/analytics/mix-commands.d.ts +53 -0
- package/dist/lib/analytics/mix-commands.js +229 -0
- package/dist/lib/analytics/recipes.d.ts +19 -14
- package/dist/lib/analytics/recipes.js +4 -2
- 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/browser/ipc.d.ts +26 -0
- package/dist/lib/browser/ipc.js +139 -24
- package/dist/lib/browser/profiles.d.ts +11 -0
- package/dist/lib/browser/profiles.js +1 -1
- package/dist/lib/browser/stream.d.ts +14 -0
- package/dist/lib/browser/stream.js +71 -0
- package/dist/lib/channels/owner-sink.d.ts +27 -0
- package/dist/lib/channels/owner-sink.js +93 -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 +10 -1
- package/dist/lib/devices/doctor-findings.js +47 -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.d.ts +59 -3
- package/dist/lib/fleet/apply.js +191 -12
- package/dist/lib/fleet/remote-login.d.ts +4 -3
- package/dist/lib/fleet/remote-login.js +11 -9
- package/dist/lib/fleet/types.d.ts +21 -2
- package/dist/lib/gemini-settings.d.ts +0 -1
- package/dist/lib/gemini-settings.js +12 -7
- package/dist/lib/hooks/cache.js +15 -0
- 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 +45 -0
- package/dist/lib/hosts/passthrough.js +59 -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 +6 -2
- package/dist/lib/hosts/ready.js +33 -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 +56 -21
- package/dist/lib/hosts/reconnect.js +128 -71
- 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/routines.js +14 -2
- package/dist/lib/runner.d.ts +9 -4
- package/dist/lib/runner.js +182 -30
- 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/secrets/push.d.ts +94 -0
- package/dist/lib/secrets/push.js +145 -0
- package/dist/lib/secrets/reaper.d.ts +15 -1
- package/dist/lib/secrets/reaper.js +30 -3
- package/dist/lib/self-update.d.ts +20 -5
- package/dist/lib/self-update.js +93 -16
- package/dist/lib/session/db.d.ts +38 -3
- package/dist/lib/session/db.js +273 -16
- package/dist/lib/session/discover.d.ts +22 -1
- package/dist/lib/session/discover.js +239 -51
- package/dist/lib/session/insights.d.ts +37 -0
- package/dist/lib/session/insights.js +219 -9
- 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/tool-index.js +133 -22
- package/dist/lib/session/tool-store.d.ts +26 -2
- package/dist/lib/session/tool-store.js +36 -17
- 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 +42 -3
- 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 +2 -0
- package/dist/lib/startup/command-registry.js +6 -0
- package/dist/lib/teams/agents.d.ts +13 -0
- package/dist/lib/teams/agents.js +75 -7
- package/dist/lib/teams/placement-probe.d.ts +21 -0
- package/dist/lib/teams/placement-probe.js +135 -0
- package/dist/lib/teams/scheduler.d.ts +74 -1
- package/dist/lib/teams/scheduler.js +187 -10
- 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
|
@@ -11,19 +11,28 @@
|
|
|
11
11
|
* SSH automatically, with bounded backoff, until the user detaches cleanly (the
|
|
12
12
|
* remote returns 0) or the agent exits (the tmux session is gone; a non-255 code).
|
|
13
13
|
*
|
|
14
|
-
* The re-attach reuses the peer's OWN
|
|
15
|
-
* --local
|
|
16
|
-
*
|
|
14
|
+
* The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
|
|
15
|
+
* --local` — which JOINS the live local tmux pane there (a second client, no fork)
|
|
16
|
+
* when it still exists, and RESUMES the session in place when the pane is already
|
|
17
|
+
* gone. Dropping `--attach-only` is deliberate: a reattach that lands after the
|
|
18
|
+
* remote pane died must not dead-end at a bare shell (the RUSH-2085 bug), it must
|
|
19
|
+
* fall through to resume so the user is put back into the agent. There is one
|
|
20
|
+
* re-attach implementation (the peer's focus) to keep in sync.
|
|
17
21
|
*
|
|
18
|
-
* **
|
|
19
|
-
* for BOTH "couldn't connect at all" and "connected, then the link
|
|
20
|
-
* failed connect still takes up to `ConnectTimeout` (10s) to return, so
|
|
21
|
-
*
|
|
22
|
-
* would classify every hung connect as a live session and retry
|
|
23
|
-
* sustained outage (the exact failure this feature exists to
|
|
24
|
-
* attempt runs a fast preflight probe
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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}.
|
|
27
36
|
*
|
|
28
37
|
* The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
|
|
29
38
|
* without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
|
|
@@ -31,37 +40,37 @@
|
|
|
31
40
|
*
|
|
32
41
|
* **255 from the REMOTE side should never be trusted as "the link dropped."**
|
|
33
42
|
* `reattachRemoteSession`'s `connected` flag is set as soon as the fast preflight
|
|
34
|
-
* probe succeeds — it says nothing about whether the interactive attach
|
|
35
|
-
* follows actually
|
|
36
|
-
* sessions focus <id> --local
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
43
|
+
* probe succeeds — it says nothing about whether the interactive attach/resume
|
|
44
|
+
* that follows actually put the user back into the agent. If the REMOTE command
|
|
45
|
+
* (`agents sessions focus <id> --local`) itself ever happened to exit 255 for a
|
|
46
|
+
* reason that has nothing to do with the ssh transport, `sshStream` would return
|
|
47
|
+
* that same 255, `reconnectStep` couldn't tell it apart from a genuine drop, and
|
|
48
|
+
* `connected: true` would refill the retry budget forever — "attempt 1/N" printed
|
|
49
|
+
* on every single cycle, the terminal filling with aborted-TTY escape-code
|
|
50
|
+
* garbage, `MAX_ATTEMPTS` never actually bounding anything.
|
|
42
51
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
* or not — indistinguishable from a real drop. {@link wrapRemoteExitCode} wraps
|
|
53
|
-
* the entire remote command so that whatever exit code it decides on, a 255 is
|
|
54
|
-
* remapped to {@link REMOTE_EXIT_255_REMAPPED} before `sshStream` ever sees it,
|
|
55
|
-
* regardless of which internal branch produced it and regardless of the peer's
|
|
56
|
-
* `agents` version (the remap happens in the shell wrapper THIS process sends).
|
|
52
|
+
* The resume fall-through only widens that surface — the peer's focus now runs a
|
|
53
|
+
* full recovery path (`resumeSessionInPlace` / `runOnPeer`) on a dead pane, any
|
|
54
|
+
* step of which could in principle exit 255 for its own reasons. So the channel-
|
|
55
|
+
* level defense is what matters, not an audit of which branch can fire:
|
|
56
|
+
* {@link wrapRemoteExitCode} wraps the entire remote command so that whatever exit
|
|
57
|
+
* code it decides on, a 255 is remapped to {@link REMOTE_EXIT_255_REMAPPED} before
|
|
58
|
+
* `sshStream` ever sees it, regardless of which internal branch produced it and
|
|
59
|
+
* regardless of the peer's `agents` version (the remap happens in the shell
|
|
60
|
+
* wrapper THIS process sends).
|
|
57
61
|
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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.
|
|
65
74
|
*/
|
|
66
75
|
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
67
76
|
import { sshTargetFor } from './types.js';
|
|
@@ -72,13 +81,22 @@ export const SSH_CONN_FAILURE = 255;
|
|
|
72
81
|
* {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
|
|
73
82
|
* transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
|
|
74
83
|
export const REMOTE_EXIT_255_REMAPPED = 254;
|
|
75
|
-
/** Consecutive
|
|
76
|
-
* {@link MAX_BACKOFF_MS}. A reattach that
|
|
77
|
-
* refills the budget, so a long session that blinks all day reconnects every time
|
|
78
|
-
*
|
|
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. */
|
|
79
89
|
export const MAX_ATTEMPTS = 6;
|
|
80
90
|
const BASE_BACKOFF_MS = 2_000;
|
|
81
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;
|
|
82
100
|
export function initialReconnectState() {
|
|
83
101
|
return { attempt: 0 };
|
|
84
102
|
}
|
|
@@ -86,6 +104,16 @@ export function initialReconnectState() {
|
|
|
86
104
|
export function backoffMs(attempt) {
|
|
87
105
|
return Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS);
|
|
88
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
|
+
}
|
|
89
117
|
/**
|
|
90
118
|
* Decide what to do after a run/re-attach returned `outcome`. Pure — the only
|
|
91
119
|
* input is the prior state and the outcome, the only output is the next action.
|
|
@@ -93,14 +121,15 @@ export function backoffMs(attempt) {
|
|
|
93
121
|
* - a non-255 code means the remote command spoke for itself (clean detach = 0,
|
|
94
122
|
* agent exit / no live session = non-zero) → stop and surface that code.
|
|
95
123
|
* - a 255 means the link dropped → retry, unless the budget is spent.
|
|
96
|
-
* - a 255 from an attempt that
|
|
97
|
-
*
|
|
98
|
-
*
|
|
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.
|
|
99
128
|
*/
|
|
100
129
|
export function reconnectStep(state, outcome) {
|
|
101
130
|
if (outcome.code !== SSH_CONN_FAILURE)
|
|
102
131
|
return { action: 'stop', code: outcome.code };
|
|
103
|
-
const attempts = outcome
|
|
132
|
+
const attempts = refillsBudget(outcome) ? 0 : state.attempt;
|
|
104
133
|
if (attempts >= MAX_ATTEMPTS)
|
|
105
134
|
return { action: 'stop', code: SSH_CONN_FAILURE };
|
|
106
135
|
return { action: 'retry', waitMs: backoffMs(attempts), state: { attempt: attempts + 1 } };
|
|
@@ -111,9 +140,21 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
|
|
|
111
140
|
const when = secs <= 1 ? 'now' : `in ${secs} seconds`;
|
|
112
141
|
return `\nConnection to ${host} dropped — the agent is still running there. Reconnecting to ${sessionId.slice(0, 8)} ${when} (attempt ${attempt}/${MAX_ATTEMPTS})…\n`;
|
|
113
142
|
}
|
|
114
|
-
/** Notice shown once the retry budget is spent.
|
|
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
|
-
return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running —
|
|
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`;
|
|
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`;
|
|
117
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
|
|
@@ -121,7 +162,7 @@ export function exhaustedNotice(sessionId, host) {
|
|
|
121
162
|
* {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
|
|
122
163
|
* only for a genuinely spent retry budget. */
|
|
123
164
|
export function remoteExitNotice(sessionId, host) {
|
|
124
|
-
return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — check whether it's still live:\n agents
|
|
165
|
+
return `\nReattach to ${sessionId.slice(0, 8)} on ${host} ended (not a network drop) — get back in, or check whether it's still live:\n agents reconnect ${sessionId.slice(0, 8)}\n`;
|
|
125
166
|
}
|
|
126
167
|
/**
|
|
127
168
|
* Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
|
|
@@ -145,16 +186,17 @@ export function wrapRemoteExitCode(cmd) {
|
|
|
145
186
|
return `bash -lc ${shellQuote(guarded)}`;
|
|
146
187
|
}
|
|
147
188
|
/**
|
|
148
|
-
* The remote command a reattach runs — the peer's own
|
|
149
|
-
* (`agents sessions focus <id> --local
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
189
|
+
* The remote command a reattach runs — the peer's own recovery verb
|
|
190
|
+
* (`agents sessions focus <id> --local`), wrapped by {@link wrapRemoteExitCode}
|
|
191
|
+
* so a stray remote-origin 255 (from this command, whatever produces it — see the
|
|
192
|
+
* file header) can never masquerade as a network drop. No `--attach-only`: focus
|
|
193
|
+
* joins the live pane when it survived, else RESUMES the session in place, so a
|
|
194
|
+
* reattach landing after the pane died recovers the agent instead of dead-ending
|
|
195
|
+
* (RUSH-2085). Split out from {@link reattachRemoteSession} so it is unit-tested
|
|
196
|
+
* without SSH — mirrors `remoteAgentsJsonCommand` in lib/remote-agents-json.ts.
|
|
155
197
|
*/
|
|
156
198
|
export function reattachRemoteCommand(sessionId) {
|
|
157
|
-
const inner = ['agents', 'sessions', 'focus', sessionId, '--local'
|
|
199
|
+
const inner = ['agents', 'sessions', 'focus', sessionId, '--local']
|
|
158
200
|
.map(shellQuote)
|
|
159
201
|
.join(' ');
|
|
160
202
|
return wrapRemoteExitCode(inner);
|
|
@@ -164,10 +206,11 @@ export function reattachRemoteCommand(sessionId) {
|
|
|
164
206
|
* `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
|
|
165
207
|
* first establishes whether the host is actually reachable this attempt — that
|
|
166
208
|
* `connected` bit, not the call duration, is what the retry policy keys on. Only on
|
|
167
|
-
* a reachable host do we run the interactive attach (which carries no
|
|
168
|
-
* the agent already runs on the peer — so it rides the normal
|
|
169
|
-
* the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
170
|
-
* session ended)
|
|
209
|
+
* a reachable host do we run the interactive attach-or-resume (which carries no
|
|
210
|
+
* credentials — the agent already runs on the peer — so it rides the normal
|
|
211
|
+
* transport). Returns the ssh exit code (255 = dropped again / unreachable; 0 =
|
|
212
|
+
* clean detach; other = session ended), whether this attempt connected, and how
|
|
213
|
+
* long the attach held — the two inputs {@link refillsBudget} decides on.
|
|
171
214
|
*/
|
|
172
215
|
export function reattachRemoteSession(host, sessionId) {
|
|
173
216
|
const target = sshTargetFor(host);
|
|
@@ -175,8 +218,12 @@ export function reattachRemoteSession(host, sessionId) {
|
|
|
175
218
|
// completed, so a hung/failed connect is never mistaken for a live reconnection.
|
|
176
219
|
const probe = sshExec(target, 'true', { multiplex: false });
|
|
177
220
|
if (probe.code !== 0)
|
|
178
|
-
return { code: SSH_CONN_FAILURE, connected: false };
|
|
179
|
-
|
|
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 };
|
|
180
227
|
}
|
|
181
228
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
182
229
|
/**
|
|
@@ -189,15 +236,25 @@ export async function reconnectInteractiveSession(opts) {
|
|
|
189
236
|
const wait = opts.wait ?? sleep;
|
|
190
237
|
const reattach = opts.reattach ?? reattachRemoteSession;
|
|
191
238
|
let state = initialReconnectState();
|
|
192
|
-
// The initial run reached the point of running the agent, so it connected.
|
|
193
|
-
|
|
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 };
|
|
194
244
|
for (;;) {
|
|
195
245
|
const decision = reconnectStep(state, outcome);
|
|
196
246
|
if (decision.action === 'stop') {
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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) {
|
|
200
256
|
write(remoteExitNotice(opts.sessionId, opts.host.name));
|
|
257
|
+
}
|
|
201
258
|
return decision.code;
|
|
202
259
|
}
|
|
203
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
|
*
|