@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.
Files changed (73) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.js +1 -0
  5. package/dist/commands/exec.d.ts +27 -0
  6. package/dist/commands/exec.js +123 -6
  7. package/dist/commands/models.js +36 -1
  8. package/dist/commands/projects.js +22 -2
  9. package/dist/commands/sessions-backfill.d.ts +32 -0
  10. package/dist/commands/sessions-backfill.js +186 -0
  11. package/dist/commands/sessions.d.ts +17 -1
  12. package/dist/commands/sessions.js +317 -18
  13. package/dist/commands/teams.js +1 -1
  14. package/dist/commands/worktree.d.ts +3 -3
  15. package/dist/commands/worktree.js +35 -4
  16. package/dist/lib/daemon.d.ts +5 -1
  17. package/dist/lib/daemon.js +63 -14
  18. package/dist/lib/devices/resolve-target.d.ts +6 -0
  19. package/dist/lib/devices/resolve-target.js +9 -3
  20. package/dist/lib/exec.js +39 -8
  21. package/dist/lib/hosts/dispatch.d.ts +12 -0
  22. package/dist/lib/hosts/dispatch.js +23 -6
  23. package/dist/lib/hosts/reconnect.d.ts +38 -0
  24. package/dist/lib/hosts/reconnect.js +85 -4
  25. package/dist/lib/hosts/run-target.js +14 -2
  26. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  27. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  28. package/dist/lib/model-tiers.d.ts +54 -0
  29. package/dist/lib/model-tiers.js +229 -0
  30. package/dist/lib/models.d.ts +3 -0
  31. package/dist/lib/models.js +44 -7
  32. package/dist/lib/pricing/prices.json +16 -1
  33. package/dist/lib/project-focus.d.ts +42 -0
  34. package/dist/lib/project-focus.js +80 -0
  35. package/dist/lib/project-schedule.d.ts +75 -0
  36. package/dist/lib/project-schedule.js +110 -0
  37. package/dist/lib/redact.d.ts +2 -0
  38. package/dist/lib/redact.js +22 -0
  39. package/dist/lib/remote-agents-json.d.ts +2 -0
  40. package/dist/lib/remote-agents-json.js +3 -3
  41. package/dist/lib/rotate.d.ts +84 -1
  42. package/dist/lib/rotate.js +155 -5
  43. package/dist/lib/runner.d.ts +4 -2
  44. package/dist/lib/runner.js +13 -4
  45. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  46. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  47. package/dist/lib/session/bash-command.js +60 -9
  48. package/dist/lib/session/db.d.ts +7 -1
  49. package/dist/lib/session/db.js +301 -32
  50. package/dist/lib/session/discover.d.ts +40 -7
  51. package/dist/lib/session/discover.js +144 -83
  52. package/dist/lib/session/parse.d.ts +8 -1
  53. package/dist/lib/session/parse.js +83 -32
  54. package/dist/lib/session/remote-list.d.ts +71 -0
  55. package/dist/lib/session/remote-list.js +410 -2
  56. package/dist/lib/session/shell-programs.d.ts +15 -0
  57. package/dist/lib/session/shell-programs.js +359 -0
  58. package/dist/lib/session/tool-calls.d.ts +88 -0
  59. package/dist/lib/session/tool-calls.js +612 -0
  60. package/dist/lib/session/tool-index.d.ts +100 -0
  61. package/dist/lib/session/tool-index.js +773 -0
  62. package/dist/lib/session/tool-store.d.ts +15 -0
  63. package/dist/lib/session/tool-store.js +198 -0
  64. package/dist/lib/session/types.d.ts +7 -0
  65. package/dist/lib/state.d.ts +10 -1
  66. package/dist/lib/state.js +11 -2
  67. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  68. package/dist/lib/teams/remoteWorktree.js +3 -4
  69. package/dist/lib/teams/worktree.d.ts +11 -1
  70. package/dist/lib/teams/worktree.js +42 -4
  71. package/dist/lib/types.d.ts +17 -0
  72. package/dist/lib/types.js +17 -0
  73. package/package.json +3 -1
@@ -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
- const elapsed = Date.now() - Date.parse(hb.lastTick);
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
- /** Check if the daemon process is alive by sending signal 0 to the stored PID. */
194
- export function isDaemonRunning() {
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 (!pid)
197
- return false;
198
- if (isAlive(pid))
199
- return true;
200
- removeDaemonPid();
201
- return false;
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
- const existing = readDaemonPid();
222
- if (existing !== null && existing !== process.pid && isAlive(existing)) {
223
- return false; // another live daemon already owns the pid file
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
- const out = [];
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
- out.push(resolved);
112
+ targets.push(resolved);
107
113
  }
108
- return out;
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 (options.effort !== 'auto') {
687
- const reasoningFlags = buildReasoningFlags(options.agent, options.effort);
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
- const effectiveVersion = options.version || resolveVersion(options.agent, options.cwd || process.cwd());
810
- if (effectiveVersion) {
811
- const resolved = resolveModel(options.agent, effectiveVersion, effectiveModel);
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 actorExports = posixEnvExports(withActorEnv());
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
- const actorExports = posixEnvExports(withActorEnv());
437
- const prelude = actorExports ? `${actorExports}; ` : '';
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
- const remoteCmd = ['agents', 'sessions', 'focus', sessionId, '--local', '--attach-only']
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
- return agent === 'claude' && !resume ? sessionId ?? randomUUID() : undefined;
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
- const emitSessionId = !forcedSessionId && !opts.resume && opts.follow !== false;
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,
@@ -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;