@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.
- package/CHANGELOG.md +44 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +61 -0
- package/dist/commands/exec.js +49 -13
- package/dist/commands/harness.d.ts +0 -1
- package/dist/commands/harness.js +60 -4
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/routines.js +2 -2
- package/dist/commands/run-account-picker.js +2 -0
- package/dist/commands/snapshot.d.ts +11 -0
- package/dist/commands/snapshot.js +107 -0
- package/dist/commands/teams.js +2 -1
- package/dist/commands/view.d.ts +7 -0
- package/dist/commands/view.js +1 -1
- package/dist/index.js +4 -1
- package/dist/lib/browser/ipc.js +2 -0
- package/dist/lib/browser/remote-control.d.ts +35 -0
- package/dist/lib/browser/remote-control.js +48 -0
- package/dist/lib/browser/service.d.ts +19 -0
- package/dist/lib/browser/service.js +19 -1
- package/dist/lib/browser/types.d.ts +14 -2
- package/dist/lib/device-config.js +8 -0
- package/dist/lib/hosts/passthrough.d.ts +10 -1
- package/dist/lib/hosts/passthrough.js +23 -2
- package/dist/lib/hosts/remote-cmd.js +1 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/placement.d.ts +82 -0
- package/dist/lib/placement.js +188 -0
- package/dist/lib/profiles.d.ts +31 -9
- package/dist/lib/profiles.js +83 -13
- package/dist/lib/rotate.d.ts +19 -4
- package/dist/lib/rotate.js +24 -1
- package/dist/lib/routines.d.ts +2 -0
- package/dist/lib/runner.d.ts +3 -0
- package/dist/lib/runner.js +90 -7
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/snapshot.d.ts +103 -0
- package/dist/lib/snapshot.js +99 -0
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +2 -0
- package/package.json +1 -1
package/dist/lib/profiles.js
CHANGED
|
@@ -199,11 +199,46 @@ export function profileAuthLabel(profile) {
|
|
|
199
199
|
return provider;
|
|
200
200
|
}
|
|
201
201
|
/**
|
|
202
|
-
*
|
|
203
|
-
*
|
|
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.
|
|
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
|
package/dist/lib/rotate.d.ts
CHANGED
|
@@ -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; `
|
|
112
|
-
*
|
|
113
|
-
*
|
|
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
|
/**
|
package/dist/lib/rotate.js
CHANGED
|
@@ -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
|
|
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
|
}));
|
package/dist/lib/routines.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/runner.d.ts
CHANGED
|
@@ -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[];
|
package/dist/lib/runner.js
CHANGED
|
@@ -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
|
-
|
|
1512
|
-
|
|
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);
|
|
Binary file
|
|
Binary file
|
|
@@ -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;
|