@phnx-labs/agents-cli 1.21.2 → 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 (102) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/README.md +32 -3
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/computer-actions.d.ts +4 -0
  5. package/dist/commands/computer-actions.js +35 -0
  6. package/dist/commands/computer.js +4 -2
  7. package/dist/commands/exec.d.ts +27 -0
  8. package/dist/commands/exec.js +123 -6
  9. package/dist/commands/models.js +36 -1
  10. package/dist/commands/perf.d.ts +16 -0
  11. package/dist/commands/perf.js +11 -1
  12. package/dist/commands/projects.d.ts +11 -1
  13. package/dist/commands/projects.js +38 -4
  14. package/dist/commands/sessions-backfill.d.ts +32 -0
  15. package/dist/commands/sessions-backfill.js +186 -0
  16. package/dist/commands/sessions-picker.js +17 -2
  17. package/dist/commands/sessions.d.ts +22 -1
  18. package/dist/commands/sessions.js +331 -18
  19. package/dist/commands/teams.js +1 -1
  20. package/dist/commands/worktree.d.ts +3 -3
  21. package/dist/commands/worktree.js +35 -4
  22. package/dist/index.js +8 -0
  23. package/dist/lib/browser/service.js +13 -0
  24. package/dist/lib/computer/dispatch.d.ts +3 -1
  25. package/dist/lib/computer/dispatch.js +10 -2
  26. package/dist/lib/daemon.d.ts +5 -1
  27. package/dist/lib/daemon.js +63 -14
  28. package/dist/lib/devices/resolve-target.d.ts +6 -0
  29. package/dist/lib/devices/resolve-target.js +9 -3
  30. package/dist/lib/event-stream.d.ts +2 -0
  31. package/dist/lib/event-stream.js +3 -0
  32. package/dist/lib/events.d.ts +3 -1
  33. package/dist/lib/events.js +4 -2
  34. package/dist/lib/exec.js +39 -8
  35. package/dist/lib/git.d.ts +14 -0
  36. package/dist/lib/git.js +36 -0
  37. package/dist/lib/hooks/profile.js +1 -14
  38. package/dist/lib/hosts/dispatch.d.ts +12 -0
  39. package/dist/lib/hosts/dispatch.js +23 -6
  40. package/dist/lib/hosts/reconnect.d.ts +38 -0
  41. package/dist/lib/hosts/reconnect.js +85 -4
  42. package/dist/lib/hosts/run-target.js +14 -2
  43. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  44. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  45. package/dist/lib/model-tiers.d.ts +54 -0
  46. package/dist/lib/model-tiers.js +229 -0
  47. package/dist/lib/models.d.ts +3 -0
  48. package/dist/lib/models.js +44 -7
  49. package/dist/lib/percentile.d.ts +12 -0
  50. package/dist/lib/percentile.js +24 -0
  51. package/dist/lib/perf/db.d.ts +1 -2
  52. package/dist/lib/perf/db.js +2 -14
  53. package/dist/lib/plugins.js +12 -1
  54. package/dist/lib/pricing/prices.json +16 -1
  55. package/dist/lib/project-focus.d.ts +42 -0
  56. package/dist/lib/project-focus.js +80 -0
  57. package/dist/lib/project-schedule.d.ts +75 -0
  58. package/dist/lib/project-schedule.js +110 -0
  59. package/dist/lib/redact.d.ts +2 -0
  60. package/dist/lib/redact.js +22 -0
  61. package/dist/lib/remote-agents-json.d.ts +2 -0
  62. package/dist/lib/remote-agents-json.js +3 -3
  63. package/dist/lib/resources.d.ts +16 -0
  64. package/dist/lib/resources.js +25 -14
  65. package/dist/lib/rotate.d.ts +84 -1
  66. package/dist/lib/rotate.js +155 -5
  67. package/dist/lib/routines.js +1 -14
  68. package/dist/lib/runner.d.ts +4 -2
  69. package/dist/lib/runner.js +21 -5
  70. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  71. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  72. package/dist/lib/session/bash-command.js +60 -9
  73. package/dist/lib/session/db.d.ts +22 -1
  74. package/dist/lib/session/db.js +516 -24
  75. package/dist/lib/session/discover.d.ts +68 -7
  76. package/dist/lib/session/discover.js +186 -84
  77. package/dist/lib/session/highlights.d.ts +24 -4
  78. package/dist/lib/session/highlights.js +52 -7
  79. package/dist/lib/session/parse.d.ts +8 -1
  80. package/dist/lib/session/parse.js +102 -35
  81. package/dist/lib/session/prompt.d.ts +19 -0
  82. package/dist/lib/session/prompt.js +43 -0
  83. package/dist/lib/session/remote-list.d.ts +71 -0
  84. package/dist/lib/session/remote-list.js +410 -2
  85. package/dist/lib/session/shell-programs.d.ts +15 -0
  86. package/dist/lib/session/shell-programs.js +359 -0
  87. package/dist/lib/session/tool-calls.d.ts +88 -0
  88. package/dist/lib/session/tool-calls.js +612 -0
  89. package/dist/lib/session/tool-index.d.ts +100 -0
  90. package/dist/lib/session/tool-index.js +773 -0
  91. package/dist/lib/session/tool-store.d.ts +15 -0
  92. package/dist/lib/session/tool-store.js +198 -0
  93. package/dist/lib/session/types.d.ts +49 -0
  94. package/dist/lib/state.d.ts +10 -1
  95. package/dist/lib/state.js +11 -2
  96. package/dist/lib/teams/remoteWorktree.d.ts +3 -4
  97. package/dist/lib/teams/remoteWorktree.js +3 -4
  98. package/dist/lib/teams/worktree.d.ts +11 -1
  99. package/dist/lib/teams/worktree.js +42 -4
  100. package/dist/lib/types.d.ts +31 -0
  101. package/dist/lib/types.js +17 -0
  102. package/package.json +3 -1
@@ -3,6 +3,8 @@ import { type AppInfo } from '../../commands/computer-actions.js';
3
3
  import type { VerbDispatcher } from './loop.js';
4
4
  declare function rpcMethodFor(verb: string): string;
5
5
  declare function toRpcParams(verb: string, input: Record<string, unknown>, pid?: number): Record<string, unknown>;
6
- export declare function makeVerbDispatcher(client: ComputerClient): VerbDispatcher;
6
+ export declare function makeVerbDispatcher(client: ComputerClient, context?: {
7
+ host?: string;
8
+ }): VerbDispatcher;
7
9
  export { rpcMethodFor, toRpcParams };
8
10
  export type { AppInfo };
@@ -5,7 +5,7 @@
5
5
  // it names the same RPC methods the explicit `agents computer <verb>` commands
6
6
  // call, so the external-agent verb surface is untouched. The daemon stays the
7
7
  // single authority on permissions and targeting.
8
- import { resolveTargetPidDecision } from '../../commands/computer-actions.js';
8
+ import { resolveTargetPidDecision, emitComputerAction } from '../../commands/computer-actions.js';
9
9
  // CLI verb -> daemon RPC method. Only the rename cases need listing; the rest
10
10
  // pass through by name.
11
11
  const RPC_METHOD = {
@@ -102,7 +102,14 @@ function toRpcParams(verb, input, pid) {
102
102
  // Build a dispatcher bound to a live client. Every call is a single daemon
103
103
  // round-trip; errors are returned (never thrown) so the loop can feed them
104
104
  // back to the model as a tool_result.
105
- export function makeVerbDispatcher(client) {
105
+ //
106
+ // `computer run`'s embedded model loop drives every verb (click, type,
107
+ // screenshot, ...) through THIS single seam rather than the explicit
108
+ // `agents computer <verb>` commands in computer-actions.ts, so it must emit
109
+ // the same `computer.action` event those commands emit — otherwise a session
110
+ // that only used the loop reads back usedComputer=false even though it drove
111
+ // real actions (reviewer-flagged regression on #1864).
112
+ export function makeVerbDispatcher(client, context = {}) {
106
113
  return async (call) => {
107
114
  const verb = call.name;
108
115
  const input = call.input ?? {};
@@ -118,6 +125,7 @@ export function makeVerbDispatcher(client) {
118
125
  const res = await client.call(method, params);
119
126
  if (res.error)
120
127
  return { ok: false, error: `${res.error.code}: ${res.error.message}` };
128
+ emitComputerAction(verb, pid, { bundle: input.bundle, host: context.host });
121
129
  return { ok: true, result: res.result ?? {} };
122
130
  };
123
131
  }
@@ -43,7 +43,11 @@ export declare function writeHeartbeat(pid?: number): void;
43
43
  export declare function readHeartbeat(): DaemonHeartbeat | null;
44
44
  export declare function removeHeartbeat(): void;
45
45
  export declare function isDaemonWedged(): boolean;
46
- /** Check if the daemon process is alive by sending signal 0 to the stored PID. */
46
+ /**
47
+ * Check whether a daemon is alive — via the pid file, or a fresh heartbeat when
48
+ * the pid file has been lost (see resolveLiveDaemonPid). Heals the pid file as a
49
+ * side effect so a subsequent read is consistent.
50
+ */
47
51
  export declare function isDaemonRunning(): boolean;
48
52
  /**
49
53
  * Single-instance claim for the daemon foreground entrypoint.
@@ -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
  }
@@ -18,6 +18,8 @@ export interface UnifiedQuery {
18
18
  eventTypes?: EventType[];
19
19
  level?: EventLevel;
20
20
  agent?: string;
21
+ /** Only events stamped with this session id (payload `sessionId`, the provenance floor). */
22
+ sessionId?: string;
21
23
  caller?: string;
22
24
  command?: string;
23
25
  module?: string;
@@ -29,6 +29,8 @@ function matches(r, q) {
29
29
  return false;
30
30
  if (q.agent && r.agent !== q.agent)
31
31
  return false;
32
+ if (q.sessionId && r.sessionId !== q.sessionId)
33
+ return false;
32
34
  if (q.caller && r.caller !== q.caller)
33
35
  return false;
34
36
  if (q.command && r.command !== q.command &&
@@ -51,6 +53,7 @@ export function readUnifiedEvents(q = {}) {
51
53
  eventTypes: q.eventTypes,
52
54
  level: q.level,
53
55
  agent: q.agent,
56
+ sessionId: q.sessionId,
54
57
  caller: q.caller,
55
58
  command: q.command,
56
59
  module: q.module,
@@ -13,7 +13,7 @@
13
13
  */
14
14
  import type { ActorKind } from './actor.js';
15
15
  export type EventLevel = 'audit' | 'warn' | 'info' | 'debug';
16
- export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
16
+ export type EventType = 'agent.run.start' | 'agent.run.end' | 'agent.spawn.start' | 'agent.spawn.end' | 'version.install' | 'version.switch' | 'version.remove' | 'skill.install' | 'skill.remove' | 'browser.launch' | 'browser.close' | 'browser.navigate' | 'browser.screenshot' | 'computer.action' | 'secrets.get' | 'secrets.unlocked' | 'secrets.create' | 'secrets.import' | 'secrets.export' | 'secrets.view' | 'secrets.set' | 'secrets.delete' | 'secrets.rename' | 'cloud.dispatch' | 'cloud.complete' | 'cloud.cancel' | 'cloud.message' | 'teams.create' | 'teams.add' | 'teams.start' | 'teams.complete' | 'teams.disband' | 'hook.fire' | 'hook.complete' | 'hook.error' | 'mcp.add' | 'mcp.remove' | 'mcp.register' | 'resource.sync' | 'rotation.resolved' | 'command.start' | 'command.end' | 'perf.timing' | 'session.start' | 'session.end' | 'webhook.received' | 'webhook.authorized' | 'webhook.rejected' | 'webhook.matched' | 'webhook.fired' | 'webhook.handler.start' | 'webhook.handler.end' | 'plan.created' | 'pr.opened' | 'pr.merged' | 'worktree.created' | 'worktree.removed' | 'commit.created' | 'pushed' | 'subagent.spawned' | 'artifact.created' | 'task.completed' | 'checklist.created' | 'status.posted' | 'file.edited' | 'factory.command' | 'factory.action' | 'factory.uri' | 'factory.launch' | 'friction' | 'error' | 'warn' | 'info' | 'debug';
17
17
  /** Every known event kind. Derived from {@link EVENT_TYPE_TABLE}, never hand-listed. */
18
18
  export declare const EVENT_TYPES: readonly EventType[];
19
19
  /** Runtime guard for an event kind arriving from outside this process. */
@@ -208,6 +208,8 @@ export declare function query(options: {
208
208
  eventTypes?: EventType[];
209
209
  level?: EventLevel;
210
210
  agent?: string;
211
+ /** Only events stamped with this session id (payload `sessionId`, the provenance floor). */
212
+ sessionId?: string;
211
213
  caller?: string;
212
214
  command?: string;
213
215
  module?: string;
@@ -84,6 +84,7 @@ const EVENT_TYPE_TABLE = {
84
84
  'version.install': true, 'version.switch': true, 'version.remove': true,
85
85
  'skill.install': true, 'skill.remove': true,
86
86
  'browser.launch': true, 'browser.close': true, 'browser.navigate': true, 'browser.screenshot': true,
87
+ 'computer.action': true,
87
88
  'secrets.get': true, 'secrets.unlocked': true, 'secrets.create': true, 'secrets.import': true, 'secrets.export': true, 'secrets.view': true, 'secrets.set': true, 'secrets.delete': true, 'secrets.rename': true,
88
89
  'cloud.dispatch': true, 'cloud.complete': true, 'cloud.cancel': true, 'cloud.message': true,
89
90
  'teams.create': true, 'teams.add': true, 'teams.start': true, 'teams.complete': true, 'teams.disband': true,
@@ -327,7 +328,6 @@ export function detectCaller(env = process.env, stdoutIsTTY = Boolean(process.st
327
328
  }
328
329
  return { kind: stdoutIsTTY ? 'terminal' : 'script' };
329
330
  }
330
- // ─── Audit attribution ────────────────────────────────────────────────────────
331
331
  // ─── Core API ─────────────────────────────────────────────────────────────────
332
332
  /**
333
333
  * Emit a structured event to the append-only audit log.
@@ -729,7 +729,7 @@ export function maybeRotate() {
729
729
  * @returns Array of event records
730
730
  */
731
731
  export function query(options) {
732
- const { startDate, endDate = new Date(), eventTypes, level, agent, caller, command, module, limit } = options;
732
+ const { startDate, endDate = new Date(), eventTypes, level, agent, sessionId, caller, command, module, limit } = options;
733
733
  const results = [];
734
734
  if (!fs.existsSync(eventsDir()))
735
735
  return results;
@@ -774,6 +774,8 @@ export function query(options) {
774
774
  continue;
775
775
  if (agent && record.agent !== agent)
776
776
  continue;
777
+ if (sessionId && record.sessionId !== sessionId)
778
+ continue;
777
779
  if (caller && record.caller !== caller)
778
780
  continue;
779
781
  if (command && record.command !== command &&
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) {
package/dist/lib/git.d.ts CHANGED
@@ -103,6 +103,20 @@ export interface RepoStateSnapshot {
103
103
  * result is stable across git versions and never mutates the tree. Returns null
104
104
  * when the path is not a git worktree. */
105
105
  export declare function readRepoState(repoPath: string): RepoStateSnapshot | null;
106
+ /**
107
+ * The short HEAD sha of the git repo at `repoRoot` (`git -C <repoRoot>
108
+ * rev-parse --short HEAD`), for provenance — "which commit of this DotAgents
109
+ * repo was this resource/plugin resolved from". `undefined` when `repoRoot`
110
+ * isn't a git repo (or has no commits yet), never a throw.
111
+ *
112
+ * Deliberately synchronous + resolved once and cached: callers (resources.ts,
113
+ * plugins.ts) attach this as a lazy getter on the resolved object, so a
114
+ * consumer that never inspects provenance never pays for the git shell-out —
115
+ * see {@link ResolvedResource.snapshotSha} / {@link DiscoveredPlugin.snapshotSha}.
116
+ */
117
+ export declare function resolveSnapshotSha(repoRoot: string): string | undefined;
118
+ /** Test seam: clear the memoized snapshot-sha cache between test cases. */
119
+ export declare function _resetSnapshotShaCacheForTest(): void;
106
120
  /**
107
121
  * Get the current GitHub username using gh CLI.
108
122
  * Returns null if gh is not installed or user is not authenticated.
package/dist/lib/git.js CHANGED
@@ -371,6 +371,42 @@ export function readRepoState(repoPath) {
371
371
  const dirty = porcelain != null && porcelain.length > 0;
372
372
  return { branch, head, dirty };
373
373
  }
374
+ /** Memoized per repoRoot — a resolveResource()/listResources()/plugin-discovery
375
+ * call that touches many resources from the SAME DotAgents repo must not shell
376
+ * out to git once per resource. */
377
+ const _snapshotShaCache = new Map();
378
+ /**
379
+ * The short HEAD sha of the git repo at `repoRoot` (`git -C <repoRoot>
380
+ * rev-parse --short HEAD`), for provenance — "which commit of this DotAgents
381
+ * repo was this resource/plugin resolved from". `undefined` when `repoRoot`
382
+ * isn't a git repo (or has no commits yet), never a throw.
383
+ *
384
+ * Deliberately synchronous + resolved once and cached: callers (resources.ts,
385
+ * plugins.ts) attach this as a lazy getter on the resolved object, so a
386
+ * consumer that never inspects provenance never pays for the git shell-out —
387
+ * see {@link ResolvedResource.snapshotSha} / {@link DiscoveredPlugin.snapshotSha}.
388
+ */
389
+ export function resolveSnapshotSha(repoRoot) {
390
+ const cached = _snapshotShaCache.get(repoRoot);
391
+ if (cached !== undefined || _snapshotShaCache.has(repoRoot))
392
+ return cached;
393
+ let sha;
394
+ try {
395
+ const raw = execFileSync('git', ['-C', repoRoot, 'rev-parse', '--short', 'HEAD'], {
396
+ stdio: ['ignore', 'pipe', 'ignore'],
397
+ }).toString().trim();
398
+ sha = raw || undefined;
399
+ }
400
+ catch {
401
+ sha = undefined;
402
+ }
403
+ _snapshotShaCache.set(repoRoot, sha);
404
+ return sha;
405
+ }
406
+ /** Test seam: clear the memoized snapshot-sha cache between test cases. */
407
+ export function _resetSnapshotShaCacheForTest() {
408
+ _snapshotShaCache.clear();
409
+ }
374
410
  /**
375
411
  * Get the current GitHub username using gh CLI.
376
412
  * Returns null if gh is not installed or user is not authenticated.
@@ -12,6 +12,7 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import { getLogsDir } from '../state.js';
15
+ import { percentile } from '../percentile.js';
15
16
  /**
16
17
  * Load every `hook.fire` event from the last `days` daily log files.
17
18
  * Lines that aren't JSON or aren't `hook.fire` events are silently skipped —
@@ -53,20 +54,6 @@ export function loadHookFireEvents(days = 7, logsDir = getLogsDir()) {
53
54
  }
54
55
  return events;
55
56
  }
56
- /** Percentile of a sorted-ascending array. p in [0,100]. Linear interpolation. */
57
- function percentile(sorted, p) {
58
- if (sorted.length === 0)
59
- return 0;
60
- if (sorted.length === 1)
61
- return sorted[0];
62
- const rank = (p / 100) * (sorted.length - 1);
63
- const lo = Math.floor(rank);
64
- const hi = Math.ceil(rank);
65
- if (lo === hi)
66
- return sorted[lo];
67
- const frac = rank - lo;
68
- return sorted[lo] * (1 - frac) + sorted[hi] * frac;
69
- }
70
57
  /** Aggregate fire events into a per-hook profile, sorted by p99 desc. */
71
58
  export function aggregateHookProfile(events) {
72
59
  const byHook = new Map();
@@ -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`)