@phnx-labs/agents-cli 1.21.3 → 1.22.1
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 +136 -0
- package/README.md +32 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.js +1 -0
- package/dist/commands/doctor.js +34 -2
- package/dist/commands/exec.d.ts +27 -0
- package/dist/commands/exec.js +123 -6
- package/dist/commands/models.js +36 -1
- package/dist/commands/projects.js +120 -62
- package/dist/commands/sessions-backfill.d.ts +32 -0
- package/dist/commands/sessions-backfill.js +186 -0
- package/dist/commands/sessions.d.ts +17 -1
- package/dist/commands/sessions.js +317 -18
- package/dist/commands/teams.js +1 -1
- package/dist/commands/worktree.d.ts +3 -3
- package/dist/commands/worktree.js +35 -4
- package/dist/lib/app-bundle-install.d.ts +17 -0
- package/dist/lib/app-bundle-install.js +94 -0
- package/dist/lib/daemon.d.ts +5 -1
- package/dist/lib/daemon.js +63 -14
- package/dist/lib/devices/doctor-findings.js +12 -4
- package/dist/lib/devices/doctor-overview-cache.d.ts +45 -0
- package/dist/lib/devices/doctor-overview-cache.js +168 -0
- package/dist/lib/devices/fleet.js +7 -2
- package/dist/lib/devices/resolve-target.d.ts +6 -0
- package/dist/lib/devices/resolve-target.js +9 -3
- package/dist/lib/devices/self-host.d.ts +9 -0
- package/dist/lib/devices/self-host.js +61 -0
- package/dist/lib/exec.js +39 -8
- package/dist/lib/hosts/dispatch.d.ts +12 -0
- package/dist/lib/hosts/dispatch.js +23 -6
- package/dist/lib/hosts/passthrough.js +8 -6
- package/dist/lib/hosts/reconnect.d.ts +38 -0
- package/dist/lib/hosts/reconnect.js +85 -4
- package/dist/lib/hosts/run-target.js +14 -2
- 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.js +27 -23
- package/dist/lib/model-tiers.d.ts +54 -0
- package/dist/lib/model-tiers.js +229 -0
- package/dist/lib/models.d.ts +3 -0
- package/dist/lib/models.js +44 -7
- package/dist/lib/pricing/prices.json +16 -1
- package/dist/lib/project-focus.d.ts +42 -0
- package/dist/lib/project-focus.js +80 -0
- package/dist/lib/project-schedule.d.ts +75 -0
- package/dist/lib/project-schedule.js +110 -0
- package/dist/lib/project-status.d.ts +7 -0
- package/dist/lib/project-status.js +9 -0
- package/dist/lib/projects.d.ts +11 -2
- package/dist/lib/projects.js +57 -9
- package/dist/lib/redact.d.ts +2 -0
- package/dist/lib/redact.js +22 -0
- package/dist/lib/remote-agents-json.d.ts +2 -0
- package/dist/lib/remote-agents-json.js +3 -3
- package/dist/lib/rotate.d.ts +84 -1
- package/dist/lib/rotate.js +155 -5
- package/dist/lib/runner.d.ts +4 -2
- package/dist/lib/runner.js +13 -4
- 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/install-helper.js +28 -31
- package/dist/lib/session/bash-command.js +60 -9
- package/dist/lib/session/db.d.ts +7 -1
- package/dist/lib/session/db.js +301 -32
- package/dist/lib/session/discover.d.ts +40 -7
- package/dist/lib/session/discover.js +144 -83
- package/dist/lib/session/parse.d.ts +8 -1
- package/dist/lib/session/parse.js +83 -32
- package/dist/lib/session/remote-list.d.ts +71 -0
- package/dist/lib/session/remote-list.js +410 -2
- package/dist/lib/session/shell-programs.d.ts +15 -0
- package/dist/lib/session/shell-programs.js +359 -0
- package/dist/lib/session/tool-calls.d.ts +88 -0
- package/dist/lib/session/tool-calls.js +612 -0
- package/dist/lib/session/tool-index.d.ts +100 -0
- package/dist/lib/session/tool-index.js +773 -0
- package/dist/lib/session/tool-store.d.ts +15 -0
- package/dist/lib/session/tool-store.js +198 -0
- package/dist/lib/session/types.d.ts +7 -0
- package/dist/lib/state.d.ts +10 -1
- package/dist/lib/state.js +11 -2
- package/dist/lib/teams/remoteWorktree.d.ts +3 -4
- package/dist/lib/teams/remoteWorktree.js +3 -4
- package/dist/lib/teams/worktree.d.ts +11 -1
- package/dist/lib/teams/worktree.js +42 -4
- package/dist/lib/types.d.ts +17 -0
- package/dist/lib/types.js +17 -0
- package/dist/lib/versions.js +69 -22
- package/package.json +3 -1
package/dist/lib/exec.js
CHANGED
|
@@ -13,6 +13,7 @@ import { AGENTS } from './agents.js';
|
|
|
13
13
|
import { parseTimeout } from './routines.js';
|
|
14
14
|
import { getBinaryPath, getVersionHomePath, isVersionInstalled, resolveVersion } from './versions.js';
|
|
15
15
|
import { resolveModel, buildReasoningFlags } from './models.js';
|
|
16
|
+
import { isTierToken, resolveTier } from './model-tiers.js';
|
|
16
17
|
import { maybeRotate, createTimer, redactPrompt, redactArgs } from './events.js';
|
|
17
18
|
import { sanitizeProcessEnv } from './secrets/bundles.js';
|
|
18
19
|
import { resolveActor, actorEnv } from './actor.js';
|
|
@@ -681,10 +682,24 @@ export function buildExecCommand(options) {
|
|
|
681
682
|
cmd[0] = realBinary && fs.existsSync(realBinary) ? realBinary : versionedName;
|
|
682
683
|
}
|
|
683
684
|
}
|
|
685
|
+
// Resolve the model up front so the reasoning-flag block can honor a cost tier
|
|
686
|
+
// that maps to reasoning effort on a single-model harness (e.g. Grok, where the
|
|
687
|
+
// tier IS the effort dial). `modelVersion` is null when no version resolves;
|
|
688
|
+
// `tierModel` is the concrete model a tier resolved to (null => drop the flag).
|
|
689
|
+
const effectiveModel = options.model
|
|
690
|
+
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined);
|
|
691
|
+
const modelVersion = effectiveModel && template.modelFlag
|
|
692
|
+
? (options.version || resolveVersion(options.agent, options.cwd || process.cwd()))
|
|
693
|
+
: null;
|
|
694
|
+
const tierResolved = effectiveModel && modelVersion && isTierToken(effectiveModel)
|
|
695
|
+
? resolveTier(options.agent, modelVersion, effectiveModel)
|
|
696
|
+
: null;
|
|
697
|
+
// An explicit --effort wins; otherwise a single-model tier's effort applies.
|
|
698
|
+
const effortLevel = options.effort !== 'auto' ? options.effort : (tierResolved?.effort ?? options.effort);
|
|
684
699
|
// Add reasoning effort flags (before mode flags for codex -c positioning)
|
|
685
700
|
// For codex, -c must come before 'exec' subcommand, so we insert at position 1
|
|
686
|
-
if (
|
|
687
|
-
const reasoningFlags = buildReasoningFlags(options.agent,
|
|
701
|
+
if (effortLevel !== 'auto') {
|
|
702
|
+
const reasoningFlags = buildReasoningFlags(options.agent, effortLevel);
|
|
688
703
|
if (reasoningFlags.length > 0) {
|
|
689
704
|
if (options.agent === 'codex') {
|
|
690
705
|
// Insert after 'codex' (or 'codex@version') but before 'exec'
|
|
@@ -803,20 +818,36 @@ export function buildExecCommand(options) {
|
|
|
803
818
|
// carry that setting, so without this it silently defaults to gpt-5.3-codex,
|
|
804
819
|
// which a ChatGPT-tier account can't use (HTTP 400). Forwarding keeps the
|
|
805
820
|
// user's default model setup for both `agents run` and `agents teams`.
|
|
806
|
-
const effectiveModel = options.model
|
|
807
|
-
?? (options.agent === 'codex' ? readCodexConfiguredModel() : undefined);
|
|
808
821
|
if (effectiveModel && template.modelFlag) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
822
|
+
if (tierResolved) {
|
|
823
|
+
// Cost tier (cheap|default|best|ultra) -> a concrete model this harness+
|
|
824
|
+
// version actually ships. Covers `agents run` and `agents teams` (both
|
|
825
|
+
// funnel here). A null model means nothing resolved -> drop the flag and
|
|
826
|
+
// let the harness pick its default.
|
|
827
|
+
if (tierResolved.model) {
|
|
828
|
+
cmd.push(template.modelFlag, tierResolved.model);
|
|
829
|
+
if (tierResolved.note)
|
|
830
|
+
process.stderr.write(`[agents] --model ${effectiveModel} -> ${tierResolved.model} (${tierResolved.note})\n`);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
process.stderr.write(`[agents] no model for tier "${effectiveModel}" on ${options.agent}@${modelVersion}; using harness default\n`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
else if (modelVersion) {
|
|
837
|
+
const resolved = resolveModel(options.agent, modelVersion, effectiveModel);
|
|
812
838
|
if (resolved.warning) {
|
|
813
839
|
process.stderr.write(`[agents] ${resolved.warning}\n`);
|
|
814
840
|
}
|
|
815
841
|
cmd.push(template.modelFlag, resolved.forwarded);
|
|
816
842
|
}
|
|
817
|
-
else {
|
|
843
|
+
else if (!isTierToken(effectiveModel)) {
|
|
818
844
|
cmd.push(template.modelFlag, effectiveModel);
|
|
819
845
|
}
|
|
846
|
+
else {
|
|
847
|
+
// Tier token but no version resolved -> forwarding the literal "best"/etc.
|
|
848
|
+
// would be rejected by the CLI, so drop the flag (harness default).
|
|
849
|
+
process.stderr.write(`[agents] cannot resolve tier "${effectiveModel}" without a version; using harness default\n`);
|
|
850
|
+
}
|
|
820
851
|
}
|
|
821
852
|
// Add JSON output flags if requested
|
|
822
853
|
if (options.json && template.jsonFlags) {
|
|
@@ -52,6 +52,18 @@ export declare function remoteCdPrefix(remoteCwd?: string, opts?: {
|
|
|
52
52
|
* collision, mirroring `buildExecEnv`'s `...options.env` precedence (exec.ts).
|
|
53
53
|
*/
|
|
54
54
|
export declare function withActorEnv(env?: Record<string, string>): Record<string, string>;
|
|
55
|
+
/**
|
|
56
|
+
* The shell-export prelude prepended to EVERY remote `agents run` dispatch —
|
|
57
|
+
* actor provenance plus, for a `run auto` dispatch, the chain-hop guard
|
|
58
|
+
* (RUN_AUTO_HOST_RESOLVED_ENV): this dispatch already IS the affinity pick, so
|
|
59
|
+
* the remote CLI must not re-run host affinity and hop to a third host. The
|
|
60
|
+
* guard MUST be a shell export (landing in the remote CLI's own process.env,
|
|
61
|
+
* which `runAutoDefaultsToAffinity` reads) — a forwarded `--env` flag would
|
|
62
|
+
* only reach the spawned agent's env and the remote `run auto` would re-pick.
|
|
63
|
+
* Shared by the interactive (runInteractiveOnHost) and detached
|
|
64
|
+
* (launchDetached) paths so both behave identically.
|
|
65
|
+
*/
|
|
66
|
+
export declare function remoteRunShellPrelude(agent: string): string;
|
|
55
67
|
/**
|
|
56
68
|
* Launch a detached login-shell command in its own Unix session/process group.
|
|
57
69
|
*
|
|
@@ -20,6 +20,7 @@ import { followHostTask } from './progress.js';
|
|
|
20
20
|
import { wrapHostCommandWithCredentials } from './credentials.js';
|
|
21
21
|
import { hostKeyCheckingOpts } from '../devices/known-hosts.js';
|
|
22
22
|
import { toRemotePortable } from '../project-root.js';
|
|
23
|
+
import { RUN_AUTO_KEYWORD, RUN_AUTO_HOST_RESOLVED_ENV } from '../types.js';
|
|
23
24
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
24
25
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
25
26
|
// injection-safe to interpolate unquoted into remote commands.
|
|
@@ -95,6 +96,22 @@ export function remoteCdPrefix(remoteCwd, opts = {}) {
|
|
|
95
96
|
export function withActorEnv(env) {
|
|
96
97
|
return { ...actorEnv(resolveActor()), ...terminalIdEnv(), ...(env ?? {}) };
|
|
97
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* The shell-export prelude prepended to EVERY remote `agents run` dispatch —
|
|
101
|
+
* actor provenance plus, for a `run auto` dispatch, the chain-hop guard
|
|
102
|
+
* (RUN_AUTO_HOST_RESOLVED_ENV): this dispatch already IS the affinity pick, so
|
|
103
|
+
* the remote CLI must not re-run host affinity and hop to a third host. The
|
|
104
|
+
* guard MUST be a shell export (landing in the remote CLI's own process.env,
|
|
105
|
+
* which `runAutoDefaultsToAffinity` reads) — a forwarded `--env` flag would
|
|
106
|
+
* only reach the spawned agent's env and the remote `run auto` would re-pick.
|
|
107
|
+
* Shared by the interactive (runInteractiveOnHost) and detached
|
|
108
|
+
* (launchDetached) paths so both behave identically.
|
|
109
|
+
*/
|
|
110
|
+
export function remoteRunShellPrelude(agent) {
|
|
111
|
+
const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
|
|
112
|
+
const exports = posixEnvExports(withActorEnv(guard));
|
|
113
|
+
return exports ? `${exports}; ` : '';
|
|
114
|
+
}
|
|
98
115
|
/**
|
|
99
116
|
* Forward the launching editor tab's `AGENT_TERMINAL_ID` across the SSH hop.
|
|
100
117
|
*
|
|
@@ -233,11 +250,11 @@ async function launchDetached(host, target, opts) {
|
|
|
233
250
|
const remoteExit = `${REMOTE_DIR}/${id}.exit`;
|
|
234
251
|
// Inner command run under a login shell so PATH resolves `agents`. Export the
|
|
235
252
|
// resolved actor provenance first so the detached remote run inherits it
|
|
236
|
-
// instead of re-resolving from this box's SSH_CONNECTION (RUSH-2028)
|
|
253
|
+
// instead of re-resolving from this box's SSH_CONNECTION (RUSH-2028); a
|
|
254
|
+
// `run auto` dispatch also gets the chain-hop guard (remoteRunShellPrelude).
|
|
237
255
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
238
256
|
const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
|
|
239
|
-
const
|
|
240
|
-
const prelude = actorExports ? `${actorExports}; ` : '';
|
|
257
|
+
const prelude = remoteRunShellPrelude(opts.agentLabel);
|
|
241
258
|
let inner = `${prelude}${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
242
259
|
if (opts.copyCreds) {
|
|
243
260
|
inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
|
|
@@ -432,9 +449,9 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
432
449
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
433
450
|
const cwd = remoteCdPrefix(opts.remoteCwd, { mirror: opts.mirrorCwd });
|
|
434
451
|
// Forward actor provenance so the interactive remote run inherits it rather
|
|
435
|
-
// than re-resolving from this box's SSH_CONNECTION (RUSH-2028)
|
|
436
|
-
|
|
437
|
-
const prelude =
|
|
452
|
+
// than re-resolving from this box's SSH_CONNECTION (RUSH-2028); a `run auto`
|
|
453
|
+
// dispatch also gets the chain-hop guard (remoteRunShellPrelude).
|
|
454
|
+
const prelude = remoteRunShellPrelude(opts.agent);
|
|
438
455
|
let remoteCmd = `${prelude}${cwd}${invocation}`;
|
|
439
456
|
if (opts.copyCreds) {
|
|
440
457
|
remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
|
|
@@ -25,6 +25,7 @@ import { stripRoutingFlags, buildRemoteAgentsInvocation, HOST_ROUTING_SPECS, } f
|
|
|
25
25
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
26
26
|
import { machineId } from '../session/sync/config.js';
|
|
27
27
|
import { loadDevices } from '../devices/registry.js';
|
|
28
|
+
import { isSelfHost } from '../devices/self-host.js';
|
|
28
29
|
import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
|
|
29
30
|
import { platformGroupLabel } from '../devices/health-report.js';
|
|
30
31
|
/**
|
|
@@ -334,7 +335,7 @@ export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
|
|
|
334
335
|
const localRunner = opts.localRunner ?? runLocalCommand;
|
|
335
336
|
const results = await fanOutDevices(targets, async (target) => {
|
|
336
337
|
const cmd = ['agents', ...forwarded];
|
|
337
|
-
const isSelf = target.device.name.toLowerCase() === self.toLowerCase();
|
|
338
|
+
const isSelf = target.device.name.toLowerCase() === self.toLowerCase() || isSelfHost(target.device.name);
|
|
338
339
|
const res = isSelf ? localRunner(cmd) : runner(target.device, cmd);
|
|
339
340
|
if (res.code !== 0) {
|
|
340
341
|
const detail = (res.stderr || res.stdout || 'unreachable').trim().slice(0, 200);
|
|
@@ -443,11 +444,12 @@ export async function maybeRunOnHost(command, allArgs, opts) {
|
|
|
443
444
|
if (!hostName)
|
|
444
445
|
return false;
|
|
445
446
|
// Running against your own machine is just a local run — skip the SSH round-trip.
|
|
446
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
449
|
-
//
|
|
450
|
-
|
|
447
|
+
// Match EVERY identity the box answers to (short id, loopback, tailscale
|
|
448
|
+
// dnsName), not just machineId() — a `--host <self-dnsName>` used to slip past a
|
|
449
|
+
// short-hostname-only check and self-SSH (RUSH-2114). Strip the routing flags
|
|
450
|
+
// from process.argv so the local command never sees an unregistered
|
|
451
|
+
// `--host`/`--device` and dies with "unknown option".
|
|
452
|
+
if (isSelfHost(hostName)) {
|
|
451
453
|
const stripped = stripRoutingFlags(allArgs, STRIP_SPECS);
|
|
452
454
|
process.argv = [process.argv[0], process.argv[1], ...stripped];
|
|
453
455
|
return false;
|
|
@@ -2,6 +2,10 @@ import { type Host } from './types.js';
|
|
|
2
2
|
/** ssh's connection-layer failure code — the signal that the link dropped rather
|
|
3
3
|
* than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
|
|
4
4
|
export declare const SSH_CONN_FAILURE = 255;
|
|
5
|
+
/** What a would-be-255 remote-origin exit code is remapped to by
|
|
6
|
+
* {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
|
|
7
|
+
* transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
|
|
8
|
+
export declare const REMOTE_EXIT_255_REMAPPED = 254;
|
|
5
9
|
/** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
|
|
6
10
|
* {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
|
|
7
11
|
* refills the budget, so a long session that blinks all day reconnects every time
|
|
@@ -46,6 +50,40 @@ export declare function reconnectStep(state: ReconnectState, outcome: ReconnectO
|
|
|
46
50
|
export declare function reconnectNotice(sessionId: string, host: string, attempt: number, waitMs: number): string;
|
|
47
51
|
/** Notice shown once the retry budget is spent. */
|
|
48
52
|
export declare function exhaustedNotice(sessionId: string, host: string): string;
|
|
53
|
+
/** Notice shown when a reattach stops on a remapped remote-side exit
|
|
54
|
+
* ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
|
|
55
|
+
* on for its own reasons, not the ssh transport dropping; see
|
|
56
|
+
* {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
|
|
57
|
+
* only for a genuinely spent retry budget. */
|
|
58
|
+
export declare function remoteExitNotice(sessionId: string, host: string): string;
|
|
59
|
+
/**
|
|
60
|
+
* Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
|
|
61
|
+
* in remote-cmd.ts uses for its own POSIX callers — see its doc comment for why
|
|
62
|
+
* a login shell at all; the sibling interactive dispatch in dispatch.ts sends a
|
|
63
|
+
* bare `agents …` with no shell wrapper, so this is a NEW login-shell hop on the
|
|
64
|
+
* reattach path specifically, not something already universal here) with a
|
|
65
|
+
* trailing exit-code remap: whatever `cmd` itself exits with, a 255 becomes
|
|
66
|
+
* {@link REMOTE_EXIT_255_REMAPPED} before the wrapper exits — see the file
|
|
67
|
+
* header for why. Every other code (0, 1, …) passes through unchanged. This
|
|
68
|
+
* carries no PATH bootstrap of its own — `ensureHostReady`/`readyProbe` already
|
|
69
|
+
* gates every `--host` dispatch on `bash -lc 'agents --version'` succeeding
|
|
70
|
+
* before a run is attempted at all, so the peer's login shell resolving `agents`
|
|
71
|
+
* is an established precondition here too. Pure string-building, so it is
|
|
72
|
+
* unit-tested without SSH (and, since the constructed script is ordinary POSIX,
|
|
73
|
+
* also exercised by actually running it through a real shell in the test — no
|
|
74
|
+
* mock needed).
|
|
75
|
+
*/
|
|
76
|
+
export declare function wrapRemoteExitCode(cmd: string): string;
|
|
77
|
+
/**
|
|
78
|
+
* The remote command a reattach runs — the peer's own reconnect verb
|
|
79
|
+
* (`agents sessions focus <id> --local --attach-only`), wrapped by
|
|
80
|
+
* {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
|
|
81
|
+
* whatever produces it — see the file header) can never masquerade as a
|
|
82
|
+
* network drop. Split out from {@link reattachRemoteSession} so it is
|
|
83
|
+
* unit-tested without SSH — mirrors `remoteAgentsJsonCommand` in
|
|
84
|
+
* lib/remote-agents-json.ts.
|
|
85
|
+
*/
|
|
86
|
+
export declare function reattachRemoteCommand(sessionId: string): string;
|
|
49
87
|
/**
|
|
50
88
|
* Re-attach the live remote tmux pane for `sessionId` by driving the peer's own
|
|
51
89
|
* `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
|
|
@@ -28,12 +28,50 @@
|
|
|
28
28
|
* The retry policy is a pure state machine (`reconnectStep`) so it is unit-tested
|
|
29
29
|
* without touching SSH; the loop (`reconnectInteractiveSession`) only adds the real
|
|
30
30
|
* preflight + `sshStream` re-attach and the wait.
|
|
31
|
+
*
|
|
32
|
+
* **255 from the REMOTE side should never be trusted as "the link dropped."**
|
|
33
|
+
* `reattachRemoteSession`'s `connected` flag is set as soon as the fast preflight
|
|
34
|
+
* probe succeeds — it says nothing about whether the interactive attach that
|
|
35
|
+
* follows actually reattached a live pane. If the REMOTE command (`agents
|
|
36
|
+
* sessions focus <id> --local --attach-only`) itself ever happened to exit 255
|
|
37
|
+
* for a reason that has nothing to do with the ssh transport, `sshStream` would
|
|
38
|
+
* return that same 255, `reconnectStep` couldn't tell it apart from a genuine
|
|
39
|
+
* drop, and `connected: true` would refill the retry budget forever — "attempt
|
|
40
|
+
* 1/N" printed on every single cycle, the terminal filling with aborted-TTY
|
|
41
|
+
* escape-code garbage, `MAX_ATTEMPTS` never actually bounding anything.
|
|
42
|
+
*
|
|
43
|
+
* Two candidate producers of that scenario were investigated —
|
|
44
|
+
* `refuseFallback`'s login-shell fallback (commands/go.ts) and `jumpTo`'s
|
|
45
|
+
* nested remote-tmux hop — and both turned out to be UNREACHABLE through this
|
|
46
|
+
* exact remote command: under `--local`, `gatherLiveTargets` never sets a
|
|
47
|
+
* foreign `.machine` (go.ts:61), so `remote` is always `undefined` in both,
|
|
48
|
+
* and neither branch can fire (the same reasoning that made an earlier,
|
|
49
|
+
* narrower fix here dead code — see git history). So this fix does not close
|
|
50
|
+
* a confirmed incident cause; what it closes is the underlying channel-level
|
|
51
|
+
* flaw that would make *any* future remote-side 255 producer — reachable today
|
|
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).
|
|
57
|
+
*
|
|
58
|
+
* This does NOT close every way `reconnectStep` can loop on a real transport
|
|
59
|
+
* 255: a genuinely recurring LOCAL ssh failure (a fast-flapping link, an
|
|
60
|
+
* attach that dies at the TTY stage on every reconnect) still refills the
|
|
61
|
+
* budget every time by design (see "Why the budget refills on `connected`"
|
|
62
|
+
* above) and can still print "attempt 1/N" indefinitely. That's an accepted,
|
|
63
|
+
* pre-existing tradeoff of the original feature, not something this fix
|
|
64
|
+
* changes either way — tracked separately (agents-cli#1884), not fixed here.
|
|
31
65
|
*/
|
|
32
66
|
import { sshExec, sshStream, shellQuote } from '../ssh-exec.js';
|
|
33
67
|
import { sshTargetFor } from './types.js';
|
|
34
68
|
/** ssh's connection-layer failure code — the signal that the link dropped rather
|
|
35
69
|
* than the remote command exiting on its own. Mirrors ssh-exec.ts `sshStream`. */
|
|
36
70
|
export const SSH_CONN_FAILURE = 255;
|
|
71
|
+
/** What a would-be-255 remote-origin exit code is remapped to by
|
|
72
|
+
* {@link wrapRemoteExitCode} — see the file header. Never produced by the ssh
|
|
73
|
+
* transport itself, so it can never be confused with {@link SSH_CONN_FAILURE}. */
|
|
74
|
+
export const REMOTE_EXIT_255_REMAPPED = 254;
|
|
37
75
|
/** Consecutive failed-to-connect reattaches before giving up. Backoff is capped at
|
|
38
76
|
* {@link MAX_BACKOFF_MS}. A reattach that actually reconnected (then dropped again)
|
|
39
77
|
* refills the budget, so a long session that blinks all day reconnects every time
|
|
@@ -77,6 +115,50 @@ export function reconnectNotice(sessionId, host, attempt, waitMs) {
|
|
|
77
115
|
export function exhaustedNotice(sessionId, host) {
|
|
78
116
|
return `\nCouldn't reconnect to ${host} after ${MAX_ATTEMPTS} attempts. The agent may still be running — reattach when the network is back:\n agents sessions focus ${sessionId.slice(0, 8)}\n`;
|
|
79
117
|
}
|
|
118
|
+
/** Notice shown when a reattach stops on a remapped remote-side exit
|
|
119
|
+
* ({@link REMOTE_EXIT_255_REMAPPED} — a would-be-255 the remote command decided
|
|
120
|
+
* on for its own reasons, not the ssh transport dropping; see
|
|
121
|
+
* {@link wrapRemoteExitCode}). Distinct from {@link exhaustedNotice}, which is
|
|
122
|
+
* only for a genuinely spent retry budget. */
|
|
123
|
+
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 sessions ${sessionId.slice(0, 8)}\n`;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Wrap `cmd` in `bash -lc` (the login-shell pattern `buildRemoteAgentsInvocation`
|
|
128
|
+
* in remote-cmd.ts uses for its own POSIX callers — see its doc comment for why
|
|
129
|
+
* a login shell at all; the sibling interactive dispatch in dispatch.ts sends a
|
|
130
|
+
* bare `agents …` with no shell wrapper, so this is a NEW login-shell hop on the
|
|
131
|
+
* reattach path specifically, not something already universal here) with a
|
|
132
|
+
* trailing exit-code remap: whatever `cmd` itself exits with, a 255 becomes
|
|
133
|
+
* {@link REMOTE_EXIT_255_REMAPPED} before the wrapper exits — see the file
|
|
134
|
+
* header for why. Every other code (0, 1, …) passes through unchanged. This
|
|
135
|
+
* carries no PATH bootstrap of its own — `ensureHostReady`/`readyProbe` already
|
|
136
|
+
* gates every `--host` dispatch on `bash -lc 'agents --version'` succeeding
|
|
137
|
+
* before a run is attempted at all, so the peer's login shell resolving `agents`
|
|
138
|
+
* is an established precondition here too. Pure string-building, so it is
|
|
139
|
+
* unit-tested without SSH (and, since the constructed script is ordinary POSIX,
|
|
140
|
+
* also exercised by actually running it through a real shell in the test — no
|
|
141
|
+
* mock needed).
|
|
142
|
+
*/
|
|
143
|
+
export function wrapRemoteExitCode(cmd) {
|
|
144
|
+
const guarded = `${cmd}; rc=$?; [ "$rc" = "${SSH_CONN_FAILURE}" ] && rc=${REMOTE_EXIT_255_REMAPPED}; exit "$rc"`;
|
|
145
|
+
return `bash -lc ${shellQuote(guarded)}`;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The remote command a reattach runs — the peer's own reconnect verb
|
|
149
|
+
* (`agents sessions focus <id> --local --attach-only`), wrapped by
|
|
150
|
+
* {@link wrapRemoteExitCode} so a stray remote-origin 255 (from this command,
|
|
151
|
+
* whatever produces it — see the file header) can never masquerade as a
|
|
152
|
+
* network drop. Split out from {@link reattachRemoteSession} so it is
|
|
153
|
+
* unit-tested without SSH — mirrors `remoteAgentsJsonCommand` in
|
|
154
|
+
* lib/remote-agents-json.ts.
|
|
155
|
+
*/
|
|
156
|
+
export function reattachRemoteCommand(sessionId) {
|
|
157
|
+
const inner = ['agents', 'sessions', 'focus', sessionId, '--local', '--attach-only']
|
|
158
|
+
.map(shellQuote)
|
|
159
|
+
.join(' ');
|
|
160
|
+
return wrapRemoteExitCode(inner);
|
|
161
|
+
}
|
|
80
162
|
/**
|
|
81
163
|
* Re-attach the live remote tmux pane for `sessionId` by driving the peer's own
|
|
82
164
|
* `agents sessions focus`. A fast, un-multiplexed preflight probe (`ssh … true`)
|
|
@@ -94,10 +176,7 @@ export function reattachRemoteSession(host, sessionId) {
|
|
|
94
176
|
const probe = sshExec(target, 'true', { multiplex: false });
|
|
95
177
|
if (probe.code !== 0)
|
|
96
178
|
return { code: SSH_CONN_FAILURE, connected: false };
|
|
97
|
-
|
|
98
|
-
.map(shellQuote)
|
|
99
|
-
.join(' ');
|
|
100
|
-
return { code: sshStream(target, remoteCmd, { tty: true }), connected: true };
|
|
179
|
+
return { code: sshStream(target, reattachRemoteCommand(sessionId), { tty: true }), connected: true };
|
|
101
180
|
}
|
|
102
181
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
103
182
|
/**
|
|
@@ -117,6 +196,8 @@ export async function reconnectInteractiveSession(opts) {
|
|
|
117
196
|
if (decision.action === 'stop') {
|
|
118
197
|
if (decision.code === SSH_CONN_FAILURE)
|
|
119
198
|
write(exhaustedNotice(opts.sessionId, opts.host.name));
|
|
199
|
+
else if (decision.code === REMOTE_EXIT_255_REMAPPED)
|
|
200
|
+
write(remoteExitNotice(opts.sessionId, opts.host.name));
|
|
120
201
|
return decision.code;
|
|
121
202
|
}
|
|
122
203
|
write(reconnectNotice(opts.sessionId, opts.host.name, decision.state.attempt, decision.waitMs));
|
|
@@ -55,7 +55,16 @@ export async function resolveHostRunTarget(name, opts = {}) {
|
|
|
55
55
|
}
|
|
56
56
|
/** Resolve the id the remote host will adopt for a fresh Claude session. */
|
|
57
57
|
export function resolveHostSessionId(agent, resume, sessionId) {
|
|
58
|
-
|
|
58
|
+
if (resume)
|
|
59
|
+
return undefined;
|
|
60
|
+
if (agent === 'claude')
|
|
61
|
+
return sessionId ?? randomUUID();
|
|
62
|
+
// `run auto`: the harness is picked on the REMOTE. Forward an explicit id so
|
|
63
|
+
// a claude pick adopts it — but never mint one: minting would suppress the
|
|
64
|
+
// --emit-session-id marker a non-claude pick needs to register its session.
|
|
65
|
+
if (agent === 'auto')
|
|
66
|
+
return sessionId;
|
|
67
|
+
return undefined;
|
|
59
68
|
}
|
|
60
69
|
/**
|
|
61
70
|
* Dispatch a headless prompt run onto a resolved host, then relate the run's
|
|
@@ -77,7 +86,10 @@ export async function dispatchPromptToHost(host, opts) {
|
|
|
77
86
|
// Ask the remote to print its resolved id whenever we did NOT force one (every
|
|
78
87
|
// non-Claude agent, and Claude-on-resume where the id is already known). No-op
|
|
79
88
|
// when the run isn't followed — nothing tails the log to catch the marker.
|
|
80
|
-
|
|
89
|
+
// `run auto` with an explicit --session-id is the exception: the id is only
|
|
90
|
+
// ADOPTED when the remote picks claude, so a non-claude pick must still emit
|
|
91
|
+
// the marker for its own coined id.
|
|
92
|
+
const emitSessionId = (!forcedSessionId || opts.agent === 'auto') && !opts.resume && opts.follow !== false;
|
|
81
93
|
const result = await dispatchToHost(host, {
|
|
82
94
|
agent: opts.agent,
|
|
83
95
|
prompt: opts.prompt,
|
|
Binary file
|
|
Binary file
|
|
@@ -22,6 +22,7 @@ import * as path from 'path';
|
|
|
22
22
|
import { sleepSync } from '../fs-atomic.js';
|
|
23
23
|
import { getRuntimeStateDir, getHelpersDir } from '../state.js';
|
|
24
24
|
import { getCliVersion, resolveAgentsBin, resolveInstalledLayout } from '../version.js';
|
|
25
|
+
import { copyAppBundle, withInstallLock } from '../app-bundle-install.js';
|
|
25
26
|
const APP_BUNDLE_NAME = 'MenubarHelper.app';
|
|
26
27
|
const INSTALL_DIR_NAME = 'agents-cli';
|
|
27
28
|
const SERVICE_LABEL = 'com.phnx-labs.agents-menubar';
|
|
@@ -154,17 +155,6 @@ function resolveCliEntry() {
|
|
|
154
155
|
}
|
|
155
156
|
return null;
|
|
156
157
|
}
|
|
157
|
-
function copyAppBundle(src, dest) {
|
|
158
|
-
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
159
|
-
if (fs.existsSync(dest))
|
|
160
|
-
fs.rmSync(dest, { recursive: true, force: true });
|
|
161
|
-
// `cp -R` preserves the bundle's signature and resource forks (see install-helper.ts).
|
|
162
|
-
const r = spawnSync('cp', ['-R', src, dest], { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf-8' });
|
|
163
|
-
if (r.status !== 0) {
|
|
164
|
-
const msg = (r.stderr || r.stdout || '').toString().trim();
|
|
165
|
-
throw new Error(`Failed to copy ${src} -> ${dest}: ${msg || 'unknown error'}`);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
158
|
/**
|
|
169
159
|
* Register the freshly-installed bundle with LaunchServices.
|
|
170
160
|
*
|
|
@@ -232,18 +222,32 @@ export function ensureMenubarAppInstalled(opts = {}) {
|
|
|
232
222
|
if (!src)
|
|
233
223
|
return null;
|
|
234
224
|
const dest = installedAppPath();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if (
|
|
239
|
-
return
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
//
|
|
245
|
-
|
|
246
|
-
|
|
225
|
+
// Heal an older install that was ad-hoc re-signed over a Developer ID source:
|
|
226
|
+
// that unstable identity made Accessibility re-prompt on every upgrade.
|
|
227
|
+
const needsInstall = () => {
|
|
228
|
+
if (opts.forceReinstall)
|
|
229
|
+
return true;
|
|
230
|
+
if (!fs.existsSync(dest))
|
|
231
|
+
return true;
|
|
232
|
+
return hasDeveloperIdSignature(src) && !hasDeveloperIdSignature(dest);
|
|
233
|
+
};
|
|
234
|
+
// Fast path: nothing to do.
|
|
235
|
+
if (!needsInstall())
|
|
236
|
+
return installedExecutablePath();
|
|
237
|
+
// Serialize the atomic install so concurrent `agents` invocations (this runs
|
|
238
|
+
// on the darwin startup path) don't race the swap or each re-copy — the
|
|
239
|
+
// stampede that transiently corrupted MenubarHelper.app and tripped the
|
|
240
|
+
// "damaged" dialog.
|
|
241
|
+
withInstallLock(dest, (heartbeat) => {
|
|
242
|
+
if (!needsInstall())
|
|
243
|
+
return;
|
|
244
|
+
copyAppBundle(src, dest);
|
|
245
|
+
heartbeat(); // cp -R done; keep the lock fresh across lsregister
|
|
246
|
+
// A fresh copy is exactly when the bundle's icon can be new (first install) or
|
|
247
|
+
// superseded (upgrade) — register it so LaunchServices knows the bundle and can
|
|
248
|
+
// resolve its AppIcon for the left-hand slot of daemon notifications.
|
|
249
|
+
refreshBundleIconRegistration(dest);
|
|
250
|
+
});
|
|
247
251
|
return installedExecutablePath();
|
|
248
252
|
}
|
|
249
253
|
function xmlEscape(s) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cost tiers for model selection: cheap / default / best / ultra.
|
|
3
|
+
*
|
|
4
|
+
* An orchestrating agent picks a teammate's model by a stable, cost-first tier
|
|
5
|
+
* instead of a concrete id that churns per release and varies per harness. A
|
|
6
|
+
* tier resolves, per (harness, installed version), to a model that version
|
|
7
|
+
* actually ships — so `--model cheap|default|best|ultra` works on `agents run`
|
|
8
|
+
* and `agents teams add` alike, funnelling through `resolveModel()`.
|
|
9
|
+
*
|
|
10
|
+
* Ranking signal, in priority (see apps/cli/docs — model ranking mechanisms):
|
|
11
|
+
* 1. Provider-declared lineup — the catalog's own family names / descriptions
|
|
12
|
+
* (opus/sonnet/haiku/fable; Codex "frontier / balanced / fast"). Most
|
|
13
|
+
* drift-proof: the provider tells us its own ranking.
|
|
14
|
+
* 2. Per-token price (prices.json) — cross-check + the $/Mtok display + budget.
|
|
15
|
+
* 3. Size-token heuristic (nano|mini|lite|flash cheaper; pro|max|opus dearer).
|
|
16
|
+
* 4. Reasoning effort for single-model harnesses (Grok) — tiers steer --effort.
|
|
17
|
+
*
|
|
18
|
+
* The mechanism differs per provider and drifts across versions, so tiers always
|
|
19
|
+
* resolve against the installed version's own catalog.
|
|
20
|
+
*/
|
|
21
|
+
import type { AgentId } from './types.js';
|
|
22
|
+
import { type ModelInfo } from './models.js';
|
|
23
|
+
/** The four cross-harness cost tiers, cheapest -> most capable. */
|
|
24
|
+
export declare const MODEL_TIERS: readonly ["cheap", "default", "best", "ultra"];
|
|
25
|
+
export type ModelTier = (typeof MODEL_TIERS)[number];
|
|
26
|
+
/** True if `s` is one of the four tier tokens (not a concrete model id). */
|
|
27
|
+
export declare function isTierToken(s: string | undefined | null): s is ModelTier;
|
|
28
|
+
/** How a single tier resolved for a given (agent, version). */
|
|
29
|
+
export interface TierResolution {
|
|
30
|
+
tier: ModelTier;
|
|
31
|
+
/** Concrete model id to forward, or null when nothing resolves (fail-safe). */
|
|
32
|
+
model: string | null;
|
|
33
|
+
/** Reasoning effort to forward, for single-model harnesses where the tier is effort, not model. */
|
|
34
|
+
effort?: string;
|
|
35
|
+
/** Set when this tier has no rung of its own: the lower tier whose model it borrowed. */
|
|
36
|
+
clampedFrom?: ModelTier;
|
|
37
|
+
/** Human note (e.g. why it clamped, or that it is a curated/subscription mapping). */
|
|
38
|
+
note?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Resolve all four tiers for an (agent, version). The map is what `agents models`
|
|
42
|
+
* prints and what `resolveTier` indexes into.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resolveTierMap(agent: AgentId, version: string): Record<ModelTier, TierResolution>;
|
|
45
|
+
/**
|
|
46
|
+
* Map a harness's catalog models onto the four tiers. Pure (no catalog lookup)
|
|
47
|
+
* so it is directly testable with synthetic inputs. Ranks the models, collapses
|
|
48
|
+
* variants, buckets onto cheap/default/best/ultra, and clamps absent tiers down
|
|
49
|
+
* to the nearest lower one. A single-model harness maps the tiers to reasoning
|
|
50
|
+
* effort instead of models.
|
|
51
|
+
*/
|
|
52
|
+
export declare function tierizeModels(agent: AgentId, models: ModelInfo[]): Record<ModelTier, TierResolution>;
|
|
53
|
+
/** Resolve one tier for an (agent, version). Null model => caller drops the flag. */
|
|
54
|
+
export declare function resolveTier(agent: AgentId, version: string, tier: ModelTier): TierResolution;
|