@phnx-labs/agents-cli 1.22.7 → 1.22.8

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 (43) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dist/bin/agents +0 -0
  3. package/dist/commands/browser.js +61 -0
  4. package/dist/commands/exec.js +49 -13
  5. package/dist/commands/harness.d.ts +0 -1
  6. package/dist/commands/harness.js +60 -4
  7. package/dist/commands/monitors.js +2 -2
  8. package/dist/commands/routines.js +2 -2
  9. package/dist/commands/run-account-picker.js +2 -0
  10. package/dist/commands/snapshot.d.ts +11 -0
  11. package/dist/commands/snapshot.js +107 -0
  12. package/dist/commands/teams.js +2 -1
  13. package/dist/commands/view.d.ts +7 -0
  14. package/dist/commands/view.js +1 -1
  15. package/dist/index.js +4 -1
  16. package/dist/lib/browser/ipc.js +2 -0
  17. package/dist/lib/browser/remote-control.d.ts +35 -0
  18. package/dist/lib/browser/remote-control.js +48 -0
  19. package/dist/lib/browser/service.d.ts +19 -0
  20. package/dist/lib/browser/service.js +19 -1
  21. package/dist/lib/browser/types.d.ts +14 -2
  22. package/dist/lib/device-config.js +8 -0
  23. package/dist/lib/hosts/passthrough.d.ts +10 -1
  24. package/dist/lib/hosts/passthrough.js +23 -2
  25. package/dist/lib/hosts/remote-cmd.js +1 -0
  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/placement.d.ts +82 -0
  29. package/dist/lib/placement.js +188 -0
  30. package/dist/lib/profiles.d.ts +31 -9
  31. package/dist/lib/profiles.js +83 -13
  32. package/dist/lib/rotate.d.ts +19 -4
  33. package/dist/lib/rotate.js +24 -1
  34. package/dist/lib/routines.d.ts +2 -0
  35. package/dist/lib/runner.d.ts +3 -0
  36. package/dist/lib/runner.js +90 -7
  37. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  38. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  39. package/dist/lib/snapshot.d.ts +103 -0
  40. package/dist/lib/snapshot.js +99 -0
  41. package/dist/lib/startup/command-registry.d.ts +1 -0
  42. package/dist/lib/startup/command-registry.js +2 -0
  43. package/package.json +1 -1
@@ -199,11 +199,46 @@ export function profileAuthLabel(profile) {
199
199
  return provider;
200
200
  }
201
201
  /**
202
- * Header label for the harness — the slot `AGENTS[id].name` fills for a native
203
- * harness, so `agents view` can print custom and native harnesses the same way.
202
+ * Curated vendor/brand display names, matched case-insensitively per token.
203
+ * Entries with a space (e.g. 'Moonshot AI') are single-token → multi-word expansions.
204
+ */
205
+ const VENDOR_TABLE = [
206
+ ['deepseek', 'DeepSeek'],
207
+ ['openai', 'OpenAI'],
208
+ ['anthropic', 'Anthropic'],
209
+ ['claude', 'Claude'],
210
+ ['grok', 'Grok'],
211
+ ['xai', 'xAI'],
212
+ ['gpt', 'GPT'],
213
+ ['meta', 'Meta'],
214
+ ['mistral', 'Mistral'],
215
+ ['mistralai', 'Mistral'],
216
+ ['qwen', 'Qwen'],
217
+ ['gemini', 'Gemini'],
218
+ ['moonshot', 'Moonshot AI'],
219
+ ['moonshotai', 'Moonshot AI'],
220
+ ['kimi', 'Kimi'],
221
+ ['cohere', 'Cohere'],
222
+ ['perplexity', 'Perplexity'],
223
+ ];
224
+ function tokenToDisplayName(token) {
225
+ const lower = token.toLowerCase();
226
+ for (const [key, display] of VENDOR_TABLE) {
227
+ if (lower === key)
228
+ return display;
229
+ }
230
+ return token.charAt(0).toUpperCase() + token.slice(1);
231
+ }
232
+ /**
233
+ * Header label for the harness — derived from `profile.name` by splitting on
234
+ * `[-_]` and mapping each token through the vendor/brand table. Never reads
235
+ * the stored `label` field; old YAML files with a `label:` key are unaffected.
236
+ *
237
+ * Examples: `deepseek-flash` → `'DeepSeek Flash'`, `spark` → `'Spark'`,
238
+ * `deepseek_chat_v3` → `'DeepSeek Chat V3'`.
204
239
  */
205
240
  export function profileLabel(profile) {
206
- return profile.label || profile.name;
241
+ return profile.name.split(/[-_]/).map(tokenToDisplayName).join(' ');
207
242
  }
208
243
  /** Build a stable, machine-readable summary for list and view surfaces. */
209
244
  export function profileSummary(profile) {
@@ -300,8 +335,6 @@ export function profileFromHostModel(name, host, model, opts = {}) {
300
335
  provider: opts.provider ?? host,
301
336
  forkedFrom: host,
302
337
  };
303
- if (opts.label)
304
- profile.label = opts.label;
305
338
  if (opts.provider && opts.authEnvVar) {
306
339
  profile.auth = { envVar: opts.authEnvVar, keychainItem: keychainItemName(opts.provider) };
307
340
  profile.authOptional = false;
@@ -337,14 +370,6 @@ export function forkProfile(source, name, opts = {}) {
337
370
  description: opts.description ?? (opts.model ? `Forked from ${source.name}: ${opts.model}` : source.description),
338
371
  forkedFrom: source.name,
339
372
  };
340
- // `label` is the header `agents view` prints, so an inherited one would make
341
- // the fork and its source visually identical — the ambiguity a per-harness
342
- // block exists to remove. A fork carries a label only when it is given one;
343
- // otherwise `profileLabel` falls back to the fork's own name.
344
- if (opts.label)
345
- forked.label = opts.label;
346
- else
347
- delete forked.label;
348
373
  // A fork that repoints the model or endpoint is no longer that preset — keep
349
374
  // the preset link only while the fork still matches what the preset defines.
350
375
  if (opts.model || opts.baseUrl)
@@ -360,6 +385,51 @@ export function forkProfile(source, name, opts = {}) {
360
385
  }
361
386
  return forked;
362
387
  }
388
+ /**
389
+ * Edit an existing profile in-place, applying overrides without changing its
390
+ * name or lineage. Reuses {@link forkProfile}'s validation and override logic
391
+ * (model swap, base-URL validation, auth repoint), then restores the original
392
+ * `forkedFrom` so an edit never self-references the profile.
393
+ *
394
+ * Note: this returns the updated `Profile` object but does NOT write it to
395
+ * disk — callers should follow up with `writeProfile(result)` if persistence
396
+ * is needed.
397
+ */
398
+ export function editProfile(source, opts = {}) {
399
+ const edited = forkProfile(source, source.name, opts);
400
+ // forkProfile sets forkedFrom = source.name; for an in-place edit that would
401
+ // be a self-reference. Restore the original lineage instead.
402
+ edited.forkedFrom = source.forkedFrom;
403
+ return edited;
404
+ }
405
+ /**
406
+ * Rename a profile on disk, then rewrite `forkedFrom` in every other profile
407
+ * that pointed at the old name so lineage display never goes stale.
408
+ *
409
+ * Throws if `oldName` does not exist or `newName` already exists. There is no
410
+ * `--force` / overwrite path — a collision is a hard error directing the user
411
+ * to remove the target first.
412
+ */
413
+ export function renameProfile(oldName, newName) {
414
+ validateProfileName(newName);
415
+ if (!profileExists(oldName)) {
416
+ throw new Error(`Profile '${oldName}' not found.`);
417
+ }
418
+ if (profileExists(newName)) {
419
+ throw new Error(`Profile '${newName}' already exists; remove it first.`);
420
+ }
421
+ const profile = readProfile(oldName);
422
+ profile.name = newName;
423
+ writeProfile(profile);
424
+ deleteProfile(oldName);
425
+ // Rewrite forkedFrom in every other profile that referenced the old name.
426
+ for (const other of listProfiles()) {
427
+ if (other.name !== newName && other.forkedFrom === oldName) {
428
+ other.forkedFrom = newName;
429
+ writeProfile(other);
430
+ }
431
+ }
432
+ }
363
433
  /**
364
434
  * Resolve a profile into the env block that should be injected into the
365
435
  * spawned agent process. Reads the token from keychain at exec time so the
@@ -8,6 +8,7 @@ import type { AgentId, RunStrategy } from './types.js';
8
8
  import type { FallbackEntry } from './exec.js';
9
9
  import { type AccountInfo, type CredentialPresence } from './agents.js';
10
10
  import { type UsageSnapshot } from './usage.js';
11
+ import { type AuthVerdict } from './auth-health.js';
11
12
  export interface RotateCandidate {
12
13
  agent: AgentId;
13
14
  version: string;
@@ -35,6 +36,18 @@ export interface RotateCandidate {
35
36
  usageMinutesToLimit: number | null;
36
37
  plan: string | null;
37
38
  signedIn: boolean;
39
+ /**
40
+ * Live auth-health verdict for this (agent, version) from the daemon's probe
41
+ * cache (`auth-health.ts`), or null when no probe row exists (cold cache, or a
42
+ * harness with no live-probe endpoint). `signedIn` only means "a credential
43
+ * file is present and its email decodes" — it cannot tell a good token from a
44
+ * revoked-but-unexpired one, so a server-rejected account reads
45
+ * `signedIn: true` but `authVerdict: 'revoked'`. Eligibility excludes a
46
+ * revoked account so rotation never launches into a doomed auth (see
47
+ * {@link readinessFromCandidate}). Fail-open: any non-revoked or null verdict
48
+ * does not gate — a stale/absent probe never blocks a launch.
49
+ */
50
+ authVerdict: AuthVerdict | null;
38
51
  lastActive: Date | null;
39
52
  }
40
53
  export interface RotateResult {
@@ -108,15 +121,17 @@ export declare const USAGE_DECISION_MAX_AGE_MS: number;
108
121
  export declare function isUsageVerified(candidate: RotateCandidate, nowMs?: number): boolean;
109
122
  /**
110
123
  * Whether a specific account can serve a run right now, and — when it can't —
111
- * why. `signed_out` covers a missing usable credential; `rate_limited` and
112
- * `out_of_credits` name the throttle. Used to pre-warn on a version-pinned
113
- * teammate whose account rotation won't route around (a pin IS the target).
124
+ * why. `signed_out` covers a missing usable credential; `revoked` is a token the
125
+ * server has actually rejected (401/403, from the live auth-health probe);
126
+ * `rate_limited` and `out_of_credits` name the throttle. Used to pre-warn on a
127
+ * version-pinned teammate whose account rotation won't route around (a pin IS
128
+ * the target).
114
129
  */
115
130
  export type AccountReadiness = {
116
131
  ready: true;
117
132
  } | {
118
133
  ready: false;
119
- reason: 'rate_limited' | 'out_of_credits' | 'signed_out';
134
+ reason: 'rate_limited' | 'out_of_credits' | 'signed_out' | 'revoked';
120
135
  email: string | null;
121
136
  };
122
137
  /**
@@ -13,6 +13,8 @@ import { getProjectRunConfigs } from './run-config.js';
13
13
  import { emit } from './events.js';
14
14
  import { getUsageInfoByIdentity, getUsageLookupKey, deriveUsageStatusFromSnapshot, } from './usage.js';
15
15
  import { readAccountHeadroom } from './fleet-cache.js';
16
+ import { machineId } from './machine-id.js';
17
+ import { readAuthHealthCache, authCacheKey, isDeadVerdict } from './auth-health.js';
16
18
  function getRotateDir() {
17
19
  const dir = path.join(getHelpersDir(), 'rotate');
18
20
  fs.mkdirSync(dir, { recursive: true });
@@ -65,8 +67,14 @@ export function setGlobalRunStrategy(agent, strategy) {
65
67
  meta.run[agent] = { ...(meta.run[agent] ?? {}), strategy };
66
68
  writeMeta(meta);
67
69
  }
70
+ /**
71
+ * Whether an account may be rotated INTO right now. Defined in terms of
72
+ * {@link readinessFromCandidate} so the router's pick gate and the pre-flight
73
+ * warning can never disagree: an account is eligible iff its readiness is
74
+ * `ready` — signed in, not server-revoked, and not out of usage.
75
+ */
68
76
  function isRotationEligible(candidate) {
69
- return candidate.signedIn && hasUsageAvailable(candidate);
77
+ return readinessFromCandidate(candidate).ready;
70
78
  }
71
79
  /**
72
80
  * Whether a version home can actually authenticate a launch.
@@ -146,6 +154,13 @@ export function readinessFromCandidate(candidate) {
146
154
  if (!candidate.signedIn) {
147
155
  return { ready: false, reason: 'signed_out', email: candidate.email };
148
156
  }
157
+ // A token the daemon's live probe saw rejected (401/403 -> `revoked`) will fail
158
+ // auth at spawn no matter how much usage headroom it has. Exclude it BEFORE the
159
+ // usage gate so rotation never routes into a doomed login. Fail-open: any other
160
+ // (or null) verdict does not gate — see `RotateCandidate.authVerdict`.
161
+ if (candidate.authVerdict !== null && isDeadVerdict(candidate.authVerdict)) {
162
+ return { ready: false, reason: 'revoked', email: candidate.email };
163
+ }
149
164
  if (hasUsageAvailable(candidate)) {
150
165
  return { ready: true };
151
166
  }
@@ -495,6 +510,12 @@ export function formatNoHealthyHarnessError(summaries, nowMs = Date.now()) {
495
510
  }
496
511
  export async function collectRunCandidates(agent) {
497
512
  const versions = listInstalledVersions(agent);
513
+ // Read the local auth-health probe cache once (cache-only, no network — the
514
+ // daemon is the sole writer). A `revoked` verdict for a (host, agent, version)
515
+ // excludes that account from the pick; a missing row is fail-open. Keyed by the
516
+ // LOCAL host — routing decides which local version to launch.
517
+ const authCache = readAuthHealthCache();
518
+ const localHost = machineId();
498
519
  const rows = await Promise.all(versions.map(async (version) => {
499
520
  const home = getVersionHomePath(agent, version);
500
521
  const info = await getAccountInfo(agent, home);
@@ -510,6 +531,7 @@ export async function collectRunCandidates(agent) {
510
531
  // lives — see isLaunchableSignedIn. Do not reuse the active-home fallback
511
532
  // identity for routing, or empty version homes look healthy and die at spawn.
512
533
  const launchable = isLaunchableSignedIn(info.signedIn, credentialPresence(agent, home));
534
+ const authVerdict = authCache[authCacheKey(localHost, agent, version)]?.verdict ?? null;
513
535
  return {
514
536
  agent,
515
537
  version,
@@ -521,6 +543,7 @@ export async function collectRunCandidates(agent) {
521
543
  usageStatus: launchable ? info.usageStatus : null,
522
544
  plan: launchable ? info.plan : null,
523
545
  signedIn: launchable,
546
+ authVerdict,
524
547
  lastActive: info.lastActive,
525
548
  };
526
549
  }));
@@ -239,6 +239,8 @@ export interface RunMeta {
239
239
  pid: number | null;
240
240
  /** Process birth time (epoch ms) recorded at spawn for pid-reuse detection. */
241
241
  spawnedAt?: number;
242
+ /** Configured execution deadline persisted for daemon-restart recovery. */
243
+ timeoutMs?: number;
242
244
  /**
243
245
  * `missed` is not an execution outcome — it is the record that a scheduled
244
246
  * fire never happened (the daemon was down, asleep, or wedged when it came
@@ -23,6 +23,9 @@ export interface RunResult {
23
23
  meta: RunMeta;
24
24
  reportPath: string | null;
25
25
  }
26
+ export declare class RoutineAlreadyRunningError extends Error {
27
+ constructor(jobName: string, runId: string);
28
+ }
26
29
  /** Agents the daemon can actually run, derived from the command table above
27
30
  * so the `--agent` help and any validation can never drift from it. */
28
31
  export declare const ROUTINE_AGENT_IDS: readonly string[];
@@ -17,7 +17,7 @@ import { spawn, execFileSync } from 'child_process';
17
17
  import * as fs from 'fs';
18
18
  import * as path from 'path';
19
19
  import * as os from 'os';
20
- import { resolveJobPrompt, parseTimeout, writeRunMeta, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, } from './routines.js';
20
+ import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, } from './routines.js';
21
21
  import { getRunsDir } from './state.js';
22
22
  import { prepareJobHome, buildSpawnEnv, getJobHomePath } from './sandbox.js';
23
23
  import { resolveModel, buildReasoningFlags } from './models.js';
@@ -26,7 +26,9 @@ import { normalizeMode, resolveHeadlessMode, buildExecEnv, detectRateLimit, dete
26
26
  import { resolveActor } from './actor.js';
27
27
  import { loadTask as loadHostTask } from './hosts/tasks.js';
28
28
  import { reconcileTask as reconcileHostTask } from './hosts/reconcile.js';
29
- import { backgroundSpawnOptions } from './platform/process.js';
29
+ import { backgroundSpawnOptions, killTree } from './platform/process.js';
30
+ import lockfile from 'proper-lockfile';
31
+ import { ensureLockTarget } from './fs-atomic.js';
30
32
  import { walkForFiles } from './fs-walk.js';
31
33
  import { getBinaryPath, isVersionInstalled, resolveVersion } from './versions.js';
32
34
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from './rotate.js';
@@ -34,6 +36,66 @@ import { readAuthHealth, isDeadVerdict } from './auth-health.js';
34
36
  import { machineId } from './machine-id.js';
35
37
  import { isSelfUpdatingAgent } from './agents.js';
36
38
  import { expandLocalHome, getProjectRoot } from './project-root.js';
39
+ export class RoutineAlreadyRunningError extends Error {
40
+ constructor(jobName, runId) {
41
+ super(`Routine '${jobName}' already has a running execution (${runId})`);
42
+ this.name = 'RoutineAlreadyRunningError';
43
+ }
44
+ }
45
+ const ROUTINE_LAUNCH_LOCK_STALE_MS = 30_000;
46
+ const ROUTINE_LAUNCH_LOCK_WAIT_MS = 10_000;
47
+ function activeRoutineRun(config) {
48
+ const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
49
+ const now = Date.now();
50
+ const runs = listRuns(config.name);
51
+ for (let i = runs.length - 1; i >= 0; i--) {
52
+ const run = runs[i];
53
+ if (run.status !== 'running')
54
+ continue;
55
+ if (run.pid && isPidOurs(run.pid, run.spawnedAt))
56
+ return run;
57
+ if (!run.pid) {
58
+ const startedAt = Date.parse(run.startedAt);
59
+ if (Number.isFinite(startedAt) && now - startedAt < (run.timeoutMs ?? timeoutMs))
60
+ return run;
61
+ }
62
+ }
63
+ return null;
64
+ }
65
+ async function withRoutineLaunchClaim(config, launch) {
66
+ const target = path.join(getJobRunsDir(config.name), '.launch-claim');
67
+ ensureLockTarget(target, '', 0o700);
68
+ const release = await lockfile.lock(target, {
69
+ stale: ROUTINE_LAUNCH_LOCK_STALE_MS,
70
+ retries: {
71
+ retries: Math.ceil(ROUTINE_LAUNCH_LOCK_WAIT_MS / 100),
72
+ factor: 1,
73
+ minTimeout: 100,
74
+ maxTimeout: 100,
75
+ },
76
+ });
77
+ try {
78
+ const active = activeRoutineRun(config);
79
+ if (active)
80
+ throw new RoutineAlreadyRunningError(config.name, active.runId);
81
+ return await launch();
82
+ }
83
+ finally {
84
+ await release();
85
+ }
86
+ }
87
+ function terminateRoutineTree(pid) {
88
+ if (!pid)
89
+ return;
90
+ if (process.platform === 'win32') {
91
+ killTree(pid);
92
+ return;
93
+ }
94
+ try {
95
+ process.kill(-pid, 'SIGKILL');
96
+ }
97
+ catch { /* already exited */ }
98
+ }
37
99
  /** CLI command templates per agent, with {prompt} as a placeholder. */
38
100
  const AGENT_COMMANDS = {
39
101
  claude: ['claude', '-p', '--verbose', '{prompt}', '--output-format', 'stream-json', '--permission-mode', 'plan'],
@@ -1086,6 +1148,9 @@ export async function executeJobDetached(config, hooks) {
1086
1148
  process.stderr.write(`[agents] daemon: skipping '${config.name}' — ${eligibility.message}\n`);
1087
1149
  throw new Error(eligibility.message);
1088
1150
  }
1151
+ return withRoutineLaunchClaim(config, () => executeJobDetachedClaimed(config, hooks));
1152
+ }
1153
+ async function executeJobDetachedClaimed(config, hooks) {
1089
1154
  // Placement (hostStrategy / bare host:) — dispatch off-box and return; the
1090
1155
  // monitor finalizes host: runs, cloud runs stay terminal when dispatch ends.
1091
1156
  // Either way the in-process onFinish hook does not fire for off-box routines
@@ -1161,6 +1226,7 @@ export async function executeJobDetached(config, hooks) {
1161
1226
  ...(config.workflow ? { workflow: config.workflow } : {}),
1162
1227
  pid: null,
1163
1228
  spawnedAt: Date.now(),
1229
+ timeoutMs: parseTimeout(config.timeout) || 10 * 60 * 1000,
1164
1230
  status: 'running',
1165
1231
  startedAt: new Date().toISOString(),
1166
1232
  completedAt: null,
@@ -1195,10 +1261,13 @@ export async function executeJobDetached(config, hooks) {
1195
1261
  env: spawnEnv,
1196
1262
  });
1197
1263
  let settled = false;
1264
+ let timeoutTimer;
1198
1265
  const settle = (status, exitCode, errorMessage) => {
1199
1266
  if (settled)
1200
1267
  return;
1201
1268
  settled = true;
1269
+ if (timeoutTimer)
1270
+ clearTimeout(timeoutTimer);
1202
1271
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
1203
1272
  writeRunMeta(meta);
1204
1273
  archiveRoutineTranscripts(meta, runDir, overlayHome);
@@ -1212,6 +1281,10 @@ export async function executeJobDetached(config, hooks) {
1212
1281
  // can read report.md (RUSH-2030). Best-effort; never breaks finalization.
1213
1282
  safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
1214
1283
  };
1284
+ timeoutTimer = setTimeout(() => {
1285
+ terminateRoutineTree(child.pid ?? null);
1286
+ settle('timeout', null, 'exceeded configured timeout');
1287
+ }, meta.timeoutMs);
1215
1288
  child.on('exit', (code) => {
1216
1289
  let logText = '';
1217
1290
  try {
@@ -1249,7 +1322,7 @@ export async function executeJobDetached(config, hooks) {
1249
1322
  catch { /* fd already closed */ }
1250
1323
  meta.pid = child.pid || null;
1251
1324
  writeRunMeta(meta);
1252
- return meta;
1325
+ return { ...meta };
1253
1326
  }
1254
1327
  /**
1255
1328
  * Detached (fire-and-forget) execution for a command-mode routine. Mirrors the
@@ -1288,6 +1361,7 @@ function executeCommandJobDetached(config, hooks) {
1288
1361
  command: config.command,
1289
1362
  pid: null,
1290
1363
  spawnedAt: Date.now(),
1364
+ timeoutMs: parseTimeout(config.timeout) || 10 * 60 * 1000,
1291
1365
  status: 'running',
1292
1366
  startedAt: new Date().toISOString(),
1293
1367
  completedAt: null,
@@ -1302,17 +1376,24 @@ function executeCommandJobDetached(config, hooks) {
1302
1376
  // fire-and-forget call, so the exit event fires here. (monitorRunningJobs no
1303
1377
  // longer force-fails command jobs; it reads exit-code only on the restart edge.)
1304
1378
  let settled = false;
1379
+ let timeoutTimer;
1305
1380
  const settle = (status, exitCode, errorMessage) => {
1306
1381
  if (settled)
1307
1382
  return;
1308
1383
  settled = true;
1384
+ if (timeoutTimer)
1385
+ clearTimeout(timeoutTimer);
1309
1386
  finalizeRunMeta(meta, status, exitCode, errorMessage ? { errorMessage } : undefined);
1310
1387
  writeRunMeta(meta);
1311
- timer.end({ status, exitCode, runId, ...(errorMessage ? { error: errorMessage } : {}) });
1388
+ timer.end({ status, exitCode: exitCode ?? undefined, runId, ...(errorMessage ? { error: errorMessage } : {}) });
1312
1389
  // Finish notification (RUSH-2030). For command routines the threshold only
1313
1390
  // surfaces failures, decided in routine-notify.ts. Best-effort.
1314
1391
  safeHook(hooks?.onFinish ? () => hooks.onFinish(meta) : undefined);
1315
1392
  };
1393
+ timeoutTimer = setTimeout(() => {
1394
+ terminateRoutineTree(child.pid ?? null);
1395
+ settle('timeout', null, 'exceeded configured timeout');
1396
+ }, meta.timeoutMs);
1316
1397
  child.on('exit', (code) => settle(code === 0 ? 'completed' : 'failed', code ?? 1));
1317
1398
  child.on('error', (err) => {
1318
1399
  settle('failed', 1, err.message);
@@ -1325,7 +1406,7 @@ function executeCommandJobDetached(config, hooks) {
1325
1406
  catch { /* fd already closed */ }
1326
1407
  meta.pid = child.pid || null;
1327
1408
  writeRunMeta(meta);
1328
- return meta;
1409
+ return { ...meta };
1329
1410
  }
1330
1411
  function extractAndSaveReport(stdoutPath, agentType, runDir) {
1331
1412
  try {
@@ -1508,8 +1589,10 @@ export function monitorRunningJobs() {
1508
1589
  // parse or extract. Reap them on pid liveness alone.
1509
1590
  const isCommandRun = Boolean(meta.command) || !meta.agent;
1510
1591
  const wallClockMs = Date.now() - Date.parse(meta.startedAt);
1511
- if (Number.isFinite(wallClockMs) && wallClockMs > MAX_WALL_CLOCK_MS) {
1512
- finalizeRunMeta(meta, 'timeout', null, { errorMessage: 'exceeded max wall clock' });
1592
+ const timeoutMs = meta.timeoutMs ?? MAX_WALL_CLOCK_MS;
1593
+ if (Number.isFinite(wallClockMs) && wallClockMs > timeoutMs) {
1594
+ terminateRoutineTree(meta.pid);
1595
+ finalizeRunMeta(meta, 'timeout', null, { errorMessage: 'exceeded configured timeout' });
1513
1596
  writeRunMeta(meta);
1514
1597
  if (!isCommandRun) {
1515
1598
  extractAndSaveReport(stdoutPath, meta.agent, runDirPath);
@@ -0,0 +1,103 @@
1
+ /**
2
+ * `agents snapshot` — one-process fleet consumer snapshot.
3
+ *
4
+ * Consumers (Factory watchdog, menubar, fleet scripts) used to fork:
5
+ * agents view <agent> --json × N harnesses
6
+ * agents sessions --active --json
7
+ * agents feed --json (sometimes)
8
+ *
9
+ * That is N+2 process starts per poll tick. This module gathers the same
10
+ * shapes in one invocation so poll count drops to 1 without redefining
11
+ * `agents status` (which stays the UnifiedSyncStatus sync contract).
12
+ *
13
+ * Stores are not merged — inventory still comes from view, active rows from
14
+ * sessions, blocks from feed. Only the reader is consolidated.
15
+ */
16
+ import { type OpenBlock } from './feed.js';
17
+ import { type FleetAgentCounts } from './fleet-status.js';
18
+ import type { AgentId } from './types.js';
19
+ import type { UnifiedSyncStatus } from './sync-status.js';
20
+ import type { ViewJsonAgent } from '../commands/view.js';
21
+ /** One open-block row in the optional feed summary (no full question bodies). */
22
+ export interface SnapshotFeedBlock {
23
+ blockId: string;
24
+ sessionId: string;
25
+ host: string;
26
+ runtime: string;
27
+ kind?: OpenBlock['kind'];
28
+ ticket?: string;
29
+ pr?: string;
30
+ questionCount: number;
31
+ ts: string;
32
+ }
33
+ /** Compact feed slice for needs-you polls. */
34
+ export interface SnapshotFeedSummary {
35
+ openBlocks: number;
36
+ blocks: SnapshotFeedBlock[];
37
+ }
38
+ /** Active-session row as emitted by `sessions --active --json`. */
39
+ export type SnapshotSessionRow = {
40
+ ticketId: string | null;
41
+ project: string | null;
42
+ prLink: string | null;
43
+ viewingIn: string | null;
44
+ [key: string]: unknown;
45
+ };
46
+ /**
47
+ * Stable machine-readable contract for `agents snapshot --json`.
48
+ * Bump `version` only on breaking shape changes.
49
+ */
50
+ export interface FleetSnapshot {
51
+ version: 1;
52
+ /** Host that produced this snapshot (machineId). */
53
+ host: string;
54
+ /** ISO-8601 capture time. */
55
+ capturedAt: string;
56
+ /** Installed agent inventory — same shape as `agents view --json`. */
57
+ inventory: ViewJsonAgent[];
58
+ /** Live sessions — same row shape as `agents sessions --active --json`. */
59
+ sessions: SnapshotSessionRow[];
60
+ /** How many remote devices contributed sessions (0 when --local). */
61
+ remoteDeviceCount: number;
62
+ /** Running/live tallies derived from `sessions` (same as fleet-status). */
63
+ agents: FleetAgentCounts;
64
+ /** Present when --with-feed. */
65
+ feed?: SnapshotFeedSummary;
66
+ /** Present when --with-sync (UnifiedSyncStatus; does not replace `agents status`). */
67
+ sync?: UnifiedSyncStatus;
68
+ }
69
+ export interface ComputeSnapshotOptions {
70
+ /** Restrict inventory to one agent id. */
71
+ agent?: AgentId;
72
+ /** Local sessions only — no cross-machine SSH fan-out. Default true for cheap polls. */
73
+ local?: boolean;
74
+ /** Explicit host filter for the sessions gather (same semantics as sessions --active). */
75
+ hosts?: string[];
76
+ /** Include open feed-block summary. */
77
+ withFeed?: boolean;
78
+ /** Include UnifiedSyncStatus (opt-in; can be slower). */
79
+ withSync?: boolean;
80
+ /** Cap feed.blocks length (default 50). */
81
+ feedLimit?: number;
82
+ }
83
+ /** Summarize open blocks for the snapshot feed slice. Pure / unit-testable. */
84
+ export declare function summarizeFeedBlocks(blocks: ReadonlyArray<OpenBlock>, limit?: number): SnapshotFeedSummary;
85
+ /**
86
+ * Assemble a snapshot payload from already-gathered pieces. Pure so tests do
87
+ * not need live process scans or network.
88
+ */
89
+ export declare function assembleSnapshot(parts: {
90
+ host: string;
91
+ capturedAt: string;
92
+ inventory: ViewJsonAgent[];
93
+ sessions: SnapshotSessionRow[];
94
+ remoteDeviceCount: number;
95
+ feed?: SnapshotFeedSummary;
96
+ sync?: UnifiedSyncStatus;
97
+ }): FleetSnapshot;
98
+ /**
99
+ * Gather inventory + active sessions (+ optional feed/sync) in one process.
100
+ * Default `local: true` keeps the common poll path free of SSH fan-out; pass
101
+ * `local: false` (or hosts) to match full `sessions --active` fleet scope.
102
+ */
103
+ export declare function computeSnapshot(opts?: ComputeSnapshotOptions): Promise<FleetSnapshot>;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `agents snapshot` — one-process fleet consumer snapshot.
3
+ *
4
+ * Consumers (Factory watchdog, menubar, fleet scripts) used to fork:
5
+ * agents view <agent> --json × N harnesses
6
+ * agents sessions --active --json
7
+ * agents feed --json (sometimes)
8
+ *
9
+ * That is N+2 process starts per poll tick. This module gathers the same
10
+ * shapes in one invocation so poll count drops to 1 without redefining
11
+ * `agents status` (which stays the UnifiedSyncStatus sync contract).
12
+ *
13
+ * Stores are not merged — inventory still comes from view, active rows from
14
+ * sessions, blocks from feed. Only the reader is consolidated.
15
+ */
16
+ import { machineId } from './machine-id.js';
17
+ import { listBlocks } from './feed.js';
18
+ import { computeAgentCounts } from './fleet-status.js';
19
+ /** Summarize open blocks for the snapshot feed slice. Pure / unit-testable. */
20
+ export function summarizeFeedBlocks(blocks, limit = 50) {
21
+ const sorted = [...blocks].sort((a, b) => (a.ts < b.ts ? 1 : a.ts > b.ts ? -1 : 0));
22
+ const slice = sorted.slice(0, Math.max(0, limit));
23
+ return {
24
+ openBlocks: blocks.length,
25
+ blocks: slice.map((b) => ({
26
+ blockId: b.blockId,
27
+ sessionId: b.sessionId,
28
+ host: b.host,
29
+ runtime: b.runtime,
30
+ kind: b.kind,
31
+ ticket: b.ticket,
32
+ pr: b.pr,
33
+ questionCount: b.questions?.length ?? 0,
34
+ ts: b.ts,
35
+ })),
36
+ };
37
+ }
38
+ /**
39
+ * Assemble a snapshot payload from already-gathered pieces. Pure so tests do
40
+ * not need live process scans or network.
41
+ */
42
+ export function assembleSnapshot(parts) {
43
+ return {
44
+ version: 1,
45
+ host: parts.host,
46
+ capturedAt: parts.capturedAt,
47
+ inventory: parts.inventory,
48
+ sessions: parts.sessions,
49
+ remoteDeviceCount: parts.remoteDeviceCount,
50
+ agents: computeAgentCounts(parts.sessions.map((s) => ({
51
+ status: typeof s.status === 'string' ? s.status : undefined,
52
+ context: typeof s.context === 'string' ? s.context : undefined,
53
+ kind: typeof s.kind === 'string' ? s.kind : undefined,
54
+ }))),
55
+ ...(parts.feed ? { feed: parts.feed } : {}),
56
+ ...(parts.sync ? { sync: parts.sync } : {}),
57
+ };
58
+ }
59
+ /**
60
+ * Gather inventory + active sessions (+ optional feed/sync) in one process.
61
+ * Default `local: true` keeps the common poll path free of SSH fan-out; pass
62
+ * `local: false` (or hosts) to match full `sessions --active` fleet scope.
63
+ */
64
+ export async function computeSnapshot(opts = {}) {
65
+ // Default local-only sessions (cheap poll). Explicit hosts → scoped fan-out.
66
+ // local: false (from --all-hosts) → full sessions --active fan-out.
67
+ const localOnly = opts.hosts?.length ? false : opts.local !== false;
68
+ const [{ collectAgentsJson }, sessionsMod] = await Promise.all([
69
+ import('../commands/view.js'),
70
+ import('../commands/sessions.js'),
71
+ ]);
72
+ const inventoryP = collectAgentsJson(opts.agent);
73
+ const sessionsP = sessionsMod.gatherActiveSessions({
74
+ local: localOnly,
75
+ hosts: opts.hosts,
76
+ });
77
+ const feedP = opts.withFeed
78
+ ? Promise.resolve(summarizeFeedBlocks(listBlocks(), opts.feedLimit ?? 50))
79
+ : Promise.resolve(undefined);
80
+ const syncP = opts.withSync
81
+ ? import('./sync-status.js').then((m) => m.computeSyncStatus())
82
+ : Promise.resolve(undefined);
83
+ const [inventory, gathered, feed, sync] = await Promise.all([
84
+ inventoryP,
85
+ sessionsP,
86
+ feedP,
87
+ syncP,
88
+ ]);
89
+ const sessions = sessionsMod.serializeActiveSessionsForJson(gathered.sessions);
90
+ return assembleSnapshot({
91
+ host: machineId(),
92
+ capturedAt: new Date().toISOString(),
93
+ inventory,
94
+ sessions,
95
+ remoteDeviceCount: gathered.remoteDeviceCount,
96
+ feed,
97
+ sync,
98
+ });
99
+ }
@@ -56,6 +56,7 @@ export declare const loadRestore: ModuleLoader;
56
56
  export declare const loadDoctor: ModuleLoader;
57
57
  export declare const loadApply: ModuleLoader;
58
58
  export declare const loadStatus: ModuleLoader;
59
+ export declare const loadSnapshot: ModuleLoader;
59
60
  export declare const loadProfiles: ModuleLoader;
60
61
  export declare const loadHarness: ModuleLoader;
61
62
  export declare const loadSecrets: ModuleLoader;