@phnx-labs/agents-cli 1.21.3 → 1.22.0
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 +67 -0
- package/README.md +32 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/computer-actions.js +1 -0
- 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 +22 -2
- 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/daemon.d.ts +5 -1
- package/dist/lib/daemon.js +63 -14
- package/dist/lib/devices/resolve-target.d.ts +6 -0
- package/dist/lib/devices/resolve-target.js +9 -3
- 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/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/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/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/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/package.json +3 -1
package/dist/lib/daemon.js
CHANGED
|
@@ -172,6 +172,16 @@ export function removeHeartbeat() {
|
|
|
172
172
|
}
|
|
173
173
|
catch { /* already removed */ }
|
|
174
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* A heartbeat is "fresh" when its last tick falls inside the wedge window — the
|
|
177
|
+
* same threshold isDaemonWedged() uses to decide a still-present daemon has gone
|
|
178
|
+
* unresponsive. A fresh heartbeat whose pid is alive is proof of a live, ticking
|
|
179
|
+
* daemon even when the pid file has been lost.
|
|
180
|
+
*/
|
|
181
|
+
function isHeartbeatFresh(hb) {
|
|
182
|
+
const elapsed = Date.now() - Date.parse(hb.lastTick);
|
|
183
|
+
return elapsed <= WEDGE_THRESHOLD_TICKS * MONITOR_TICK_MS;
|
|
184
|
+
}
|
|
175
185
|
export function isDaemonWedged() {
|
|
176
186
|
const pid = readDaemonPid();
|
|
177
187
|
if (!pid)
|
|
@@ -183,22 +193,49 @@ export function isDaemonWedged() {
|
|
|
183
193
|
return false;
|
|
184
194
|
if (hb.pid !== pid)
|
|
185
195
|
return false;
|
|
186
|
-
|
|
187
|
-
return elapsed > WEDGE_THRESHOLD_TICKS * MONITOR_TICK_MS;
|
|
196
|
+
return !isHeartbeatFresh(hb);
|
|
188
197
|
}
|
|
189
198
|
/** How long stopDaemon waits for a SIGTERMed daemon to exit before escalating. */
|
|
190
199
|
const STOP_GRACE_MS = 5000;
|
|
191
200
|
/** How long it waits after the hard tree-kill before giving up. */
|
|
192
201
|
const STOP_KILL_GRACE_MS = 2000;
|
|
193
|
-
/**
|
|
194
|
-
|
|
202
|
+
/**
|
|
203
|
+
* Resolve the PID of the live daemon, tolerant of a pid-file/heartbeat desync.
|
|
204
|
+
*
|
|
205
|
+
* The daemon writes the pid file once (on claim/start) but rewrites the
|
|
206
|
+
* heartbeat every tick. If the pid file is lost while the daemon keeps ticking
|
|
207
|
+
* — e.g. an earlier isDaemonRunning() found a stale/reused/dead pid and cleared
|
|
208
|
+
* the file, or it was removed out from under a live daemon — the pid file reads
|
|
209
|
+
* empty even though a daemon is genuinely alive and firing jobs. Reading only
|
|
210
|
+
* the pid file then reports "stopped" for a running scheduler, and (worse) lets
|
|
211
|
+
* claimDaemonInstance() start a SECOND daemon that double-fires every routine.
|
|
212
|
+
*
|
|
213
|
+
* So: trust the pid file when its pid is alive; otherwise trust a FRESH
|
|
214
|
+
* heartbeat whose pid is alive, and re-adopt the pid file so the desync heals.
|
|
215
|
+
* Returns null only when neither points at a live process (clearing a stale pid
|
|
216
|
+
* file on the way out).
|
|
217
|
+
*/
|
|
218
|
+
function resolveLiveDaemonPid() {
|
|
195
219
|
const pid = readDaemonPid();
|
|
196
|
-
if (
|
|
197
|
-
return
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
220
|
+
if (pid !== null && isAlive(pid))
|
|
221
|
+
return pid;
|
|
222
|
+
const hb = readHeartbeat();
|
|
223
|
+
if (hb && isAlive(hb.pid) && isHeartbeatFresh(hb)) {
|
|
224
|
+
if (pid !== hb.pid)
|
|
225
|
+
writeDaemonPid(hb.pid); // heal the pid-file/heartbeat desync
|
|
226
|
+
return hb.pid;
|
|
227
|
+
}
|
|
228
|
+
if (pid !== null)
|
|
229
|
+
removeDaemonPid();
|
|
230
|
+
return null;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Check whether a daemon is alive — via the pid file, or a fresh heartbeat when
|
|
234
|
+
* the pid file has been lost (see resolveLiveDaemonPid). Heals the pid file as a
|
|
235
|
+
* side effect so a subsequent read is consistent.
|
|
236
|
+
*/
|
|
237
|
+
export function isDaemonRunning() {
|
|
238
|
+
return resolveLiveDaemonPid() !== null;
|
|
202
239
|
}
|
|
203
240
|
/**
|
|
204
241
|
* Single-instance claim for the daemon foreground entrypoint.
|
|
@@ -217,16 +254,28 @@ export function isDaemonRunning() {
|
|
|
217
254
|
*/
|
|
218
255
|
export function claimDaemonInstance() {
|
|
219
256
|
const release = acquireStartLock();
|
|
257
|
+
// acquireStartLock() returns null only when another __daemon-run currently
|
|
258
|
+
// holds the O_EXCL lock — a dead holder's lock is reclaimed and retried inside
|
|
259
|
+
// acquireStartLock, so null means a *live* claimer is mid-claim. Bail rather
|
|
260
|
+
// than run the read-decide-write unlocked: otherwise two first-start processes
|
|
261
|
+
// could each see no pid file (before either writes one) and both claim,
|
|
262
|
+
// running the concurrent JobScheduler this guard exists to prevent.
|
|
263
|
+
if (!release)
|
|
264
|
+
return false;
|
|
220
265
|
try {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
266
|
+
// resolveLiveDaemonPid() also consults a fresh heartbeat, so a live daemon
|
|
267
|
+
// whose pid file was lost still blocks a second claim — otherwise a missing
|
|
268
|
+
// pid file would let this instance start a concurrent JobScheduler and
|
|
269
|
+
// double-fire every routine.
|
|
270
|
+
const existing = resolveLiveDaemonPid();
|
|
271
|
+
if (existing !== null && existing !== process.pid) {
|
|
272
|
+
return false; // another live daemon already owns the instance
|
|
224
273
|
}
|
|
225
274
|
writeDaemonPid(process.pid);
|
|
226
275
|
return true;
|
|
227
276
|
}
|
|
228
277
|
finally {
|
|
229
|
-
release
|
|
278
|
+
release();
|
|
230
279
|
}
|
|
231
280
|
}
|
|
232
281
|
/**
|
|
@@ -9,6 +9,10 @@ export interface ResolvedSshTarget {
|
|
|
9
9
|
name: string;
|
|
10
10
|
os?: string;
|
|
11
11
|
}
|
|
12
|
+
export interface ResolvedExplicitTargetSet {
|
|
13
|
+
targets: ResolvedSshTarget[];
|
|
14
|
+
unresolved: string[];
|
|
15
|
+
}
|
|
12
16
|
/**
|
|
13
17
|
* Resolve a target token to a full {@link DeviceProfile} for `agents ssh`. Same
|
|
14
18
|
* grammar as the fan-out, but returns the whole profile (auth, shell, tailscale
|
|
@@ -27,3 +31,5 @@ export declare function resolveDeviceTarget(token: string): Promise<DeviceProfil
|
|
|
27
31
|
* cross-machine fan-out so they can never diverge onto two routes.
|
|
28
32
|
*/
|
|
29
33
|
export declare function resolveExplicitTargets(hosts: string[]): Promise<ResolvedSshTarget[]>;
|
|
34
|
+
/** Resolve explicit tokens while retaining failures for coverage-sensitive callers. */
|
|
35
|
+
export declare function resolveExplicitTargetSet(hosts: string[]): Promise<ResolvedExplicitTargetSet>;
|
|
@@ -96,14 +96,20 @@ export async function resolveDeviceTarget(token) {
|
|
|
96
96
|
* cross-machine fan-out so they can never diverge onto two routes.
|
|
97
97
|
*/
|
|
98
98
|
export async function resolveExplicitTargets(hosts) {
|
|
99
|
-
|
|
99
|
+
return (await resolveExplicitTargetSet(hosts)).targets;
|
|
100
|
+
}
|
|
101
|
+
/** Resolve explicit tokens while retaining failures for coverage-sensitive callers. */
|
|
102
|
+
export async function resolveExplicitTargetSet(hosts) {
|
|
103
|
+
const targets = [];
|
|
104
|
+
const unresolved = [];
|
|
100
105
|
for (const h of hosts) {
|
|
101
106
|
const resolved = await toResolvedTarget(h);
|
|
102
107
|
if (!resolved) {
|
|
103
108
|
process.stderr.write(chalk.gray(` ${h}: not a resolvable ssh target — skipped\n`));
|
|
109
|
+
unresolved.push(h);
|
|
104
110
|
continue;
|
|
105
111
|
}
|
|
106
|
-
|
|
112
|
+
targets.push(resolved);
|
|
107
113
|
}
|
|
108
|
-
return
|
|
114
|
+
return { targets, unresolved };
|
|
109
115
|
}
|
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);
|
|
@@ -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
|
|
@@ -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;
|