@phnx-labs/agents-cli 1.22.7 → 1.22.9
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 +97 -0
- package/README.md +5 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +61 -0
- package/dist/commands/exec.js +89 -21
- package/dist/commands/feed.js +38 -1
- package/dist/commands/harness.d.ts +40 -2
- package/dist/commands/harness.js +316 -20
- package/dist/commands/monitors.js +2 -2
- package/dist/commands/profiles.d.ts +42 -0
- package/dist/commands/profiles.js +91 -3
- package/dist/commands/routines.js +2 -2
- package/dist/commands/run-account-picker.js +2 -0
- package/dist/commands/sessions-picker.js +3 -3
- package/dist/commands/sessions-render.d.ts +12 -0
- package/dist/commands/sessions-render.js +124 -0
- package/dist/commands/sessions.js +28 -1
- package/dist/commands/snapshot.d.ts +11 -0
- package/dist/commands/snapshot.js +107 -0
- package/dist/commands/ssh.js +26 -3
- package/dist/commands/teams.js +14 -2
- package/dist/commands/view.d.ts +7 -0
- package/dist/commands/view.js +1 -1
- package/dist/index.js +11 -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/crabbox/cli.d.ts +35 -0
- package/dist/lib/crabbox/cli.js +46 -0
- package/dist/lib/crabbox/config.d.ts +21 -0
- package/dist/lib/crabbox/config.js +43 -0
- package/dist/lib/crabbox/lease.d.ts +15 -5
- package/dist/lib/crabbox/lease.js +57 -17
- package/dist/lib/daemon.js +12 -11
- package/dist/lib/device-config.js +8 -0
- package/dist/lib/devices/resolve-target.d.ts +4 -3
- package/dist/lib/devices/resolve-target.js +4 -3
- package/dist/lib/hosts/passthrough.d.ts +10 -1
- package/dist/lib/hosts/passthrough.js +41 -3
- package/dist/lib/hosts/registry.d.ts +4 -0
- package/dist/lib/hosts/registry.js +16 -0
- package/dist/lib/hosts/remote-cmd.js +2 -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/observe-aliases.d.ts +30 -0
- package/dist/lib/observe-aliases.js +56 -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/redact.js +4 -0
- 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/session/parse.d.ts +5 -1
- package/dist/lib/session/parse.js +23 -13
- package/dist/lib/session/prompt.js +5 -0
- package/dist/lib/session/render.d.ts +3 -0
- package/dist/lib/session/render.js +33 -10
- 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 +17 -1
- package/dist/lib/usage-refresh.d.ts +19 -11
- package/dist/lib/usage-refresh.js +75 -43
- package/dist/lib/usage.d.ts +12 -0
- package/dist/lib/usage.js +37 -6
- package/package.json +1 -1
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
|
|
@@ -31,7 +31,11 @@ export declare function safeReadSessionFile(filePath: string, maxBytes?: number)
|
|
|
31
31
|
/**
|
|
32
32
|
* Auto-detect agent type from file path and parse the session.
|
|
33
33
|
*/
|
|
34
|
-
export
|
|
34
|
+
export interface ParseSessionOptions {
|
|
35
|
+
/** Keep normalized tool results compact by default; renderers can request full output. */
|
|
36
|
+
maxToolOutputChars?: number;
|
|
37
|
+
}
|
|
38
|
+
export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
|
|
35
39
|
/** Infer the agent type from a session file path using known directory conventions. */
|
|
36
40
|
export declare function detectAgent(filePath: string): SessionAgentId | null;
|
|
37
41
|
/**
|
|
@@ -112,10 +112,12 @@ export function safeReadSessionFile(filePath, maxBytes = SESSION_FILE_MAX_BYTES)
|
|
|
112
112
|
}
|
|
113
113
|
return fs.readFileSync(filePath, 'utf-8');
|
|
114
114
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
115
|
+
function truncateNormalizedToolOutput(output, maxChars) {
|
|
116
|
+
if (!Number.isFinite(maxChars) || output.length <= maxChars)
|
|
117
|
+
return output;
|
|
118
|
+
return `${output.slice(0, maxChars)}\n\n[Output truncated: ${output.length - maxChars} characters omitted.]`;
|
|
119
|
+
}
|
|
120
|
+
export function parseSession(filePath, agent, opts = {}) {
|
|
119
121
|
const detected = agent || detectAgent(filePath);
|
|
120
122
|
if (!detected) {
|
|
121
123
|
throw new Error(`Cannot detect agent type from path: ${filePath}`);
|
|
@@ -166,7 +168,11 @@ export function parseSession(filePath, agent) {
|
|
|
166
168
|
// from `!`-prefix runs, `<system-reminder>`, etc.) as `_synthetic` so turn
|
|
167
169
|
// slicing and `--include user` count only genuine user intent — one place,
|
|
168
170
|
// every harness, instead of per-consumer regex.
|
|
171
|
+
const maxToolOutputChars = opts.maxToolOutputChars ?? 500;
|
|
169
172
|
for (const e of events) {
|
|
173
|
+
if (e.type === 'tool_result' && e.output) {
|
|
174
|
+
e.output = truncateNormalizedToolOutput(e.output, maxToolOutputChars);
|
|
175
|
+
}
|
|
170
176
|
sanitizeEvent(e);
|
|
171
177
|
if (e.type === 'message' && e.role === 'user' && isSyntheticUserMessage(e.content)) {
|
|
172
178
|
e._synthetic = true;
|
|
@@ -496,7 +502,11 @@ export function parseClaudeContent(content) {
|
|
|
496
502
|
exitCode: structured.exitCode,
|
|
497
503
|
statusCode: structured.statusCode,
|
|
498
504
|
errorCode: structured.errorCode,
|
|
499
|
-
|
|
505
|
+
// Not truncated here: `parseSession` caps every event's `output`
|
|
506
|
+
// centrally via `maxToolOutputChars` (default 500, the same
|
|
507
|
+
// bound this site used to hardcode), so the render path can ask
|
|
508
|
+
// for the full text with `Infinity`.
|
|
509
|
+
output,
|
|
500
510
|
});
|
|
501
511
|
}
|
|
502
512
|
if (toolId)
|
|
@@ -686,7 +696,7 @@ export function parseCodexContent(content) {
|
|
|
686
696
|
exitCode: result.exitCode,
|
|
687
697
|
statusCode: result.statusCode,
|
|
688
698
|
errorCode: result.errorCode,
|
|
689
|
-
output: result.text
|
|
699
|
+
output: result.text,
|
|
690
700
|
});
|
|
691
701
|
if (callId)
|
|
692
702
|
callMap.delete(callId);
|
|
@@ -741,7 +751,7 @@ export function parseCodexContent(content) {
|
|
|
741
751
|
exitCode: result.exitCode,
|
|
742
752
|
statusCode: result.statusCode,
|
|
743
753
|
errorCode: result.errorCode,
|
|
744
|
-
output: result.text
|
|
754
|
+
output: result.text,
|
|
745
755
|
});
|
|
746
756
|
if (callId)
|
|
747
757
|
callMap.delete(callId);
|
|
@@ -865,7 +875,7 @@ export function parseGemini(filePath) {
|
|
|
865
875
|
tool: toolName,
|
|
866
876
|
callId: typeof tc.id === 'string' ? tc.id : undefined,
|
|
867
877
|
success: tc.status === 'success',
|
|
868
|
-
output
|
|
878
|
+
output,
|
|
869
879
|
});
|
|
870
880
|
}
|
|
871
881
|
}
|
|
@@ -1242,7 +1252,7 @@ export function parseGrok(filePath) {
|
|
|
1242
1252
|
callId,
|
|
1243
1253
|
success: structuredError ? false : structuredSuccess ? true : undefined,
|
|
1244
1254
|
outcome: structuredError ? 'error' : structuredSuccess ? 'ok' : 'unknown',
|
|
1245
|
-
output: output
|
|
1255
|
+
output: output,
|
|
1246
1256
|
});
|
|
1247
1257
|
if (callId)
|
|
1248
1258
|
toolCallMap.delete(callId);
|
|
@@ -1360,7 +1370,7 @@ export function parseOpenCode(filePath) {
|
|
|
1360
1370
|
tool: toolName,
|
|
1361
1371
|
callId,
|
|
1362
1372
|
success: state.status === 'completed',
|
|
1363
|
-
output: outputStr
|
|
1373
|
+
output: outputStr,
|
|
1364
1374
|
});
|
|
1365
1375
|
}
|
|
1366
1376
|
break;
|
|
@@ -1470,7 +1480,7 @@ export function parseRush(filePath) {
|
|
|
1470
1480
|
tool: info?.tool ?? (typeof raw.name === 'string' ? raw.name : 'unknown'),
|
|
1471
1481
|
callId,
|
|
1472
1482
|
success,
|
|
1473
|
-
output: outputStr
|
|
1483
|
+
output: outputStr,
|
|
1474
1484
|
});
|
|
1475
1485
|
if (callId)
|
|
1476
1486
|
toolCallMap.delete(callId);
|
|
@@ -1680,7 +1690,7 @@ export function parseKimi(filePath) {
|
|
|
1680
1690
|
callId,
|
|
1681
1691
|
success: structuredError ? false : structuredSuccess ? true : undefined,
|
|
1682
1692
|
outcome: structuredError ? 'error' : structuredSuccess ? 'ok' : 'unknown',
|
|
1683
|
-
output: output
|
|
1693
|
+
output: output,
|
|
1684
1694
|
});
|
|
1685
1695
|
if (callId) {
|
|
1686
1696
|
toolCallMap.delete(callId);
|
|
@@ -1825,7 +1835,7 @@ function parseAnthropicMessageJsonl(filePath, agent) {
|
|
|
1825
1835
|
else {
|
|
1826
1836
|
events.push({
|
|
1827
1837
|
type: 'tool_result', agent, timestamp, tool: toolInfo?.tool, callId: toolId, success: true,
|
|
1828
|
-
output: output
|
|
1838
|
+
output: output,
|
|
1829
1839
|
});
|
|
1830
1840
|
}
|
|
1831
1841
|
if (toolId)
|
|
@@ -46,6 +46,11 @@ const SYNTHETIC_USER_MESSAGE_PATTERNS = [
|
|
|
46
46
|
/^\s*<\/?user-prompt-submit-hook>/i,
|
|
47
47
|
/^\s*<\/?persisted-output>/i,
|
|
48
48
|
/^\s*<permissions instructions>/i,
|
|
49
|
+
/^\s*<(?:apps|plugins|skills)_instructions>/i,
|
|
50
|
+
/^\s*<(?:multi_agent_mode|environment_context)>/i,
|
|
51
|
+
/^\s*## (?:In-flight in this repo|Host & Fleet)/i,
|
|
52
|
+
/^\s*Your current session id is\b/i,
|
|
53
|
+
/^\s*Linear context skipped:/i,
|
|
49
54
|
/^\s*<collaboration_mode>/i,
|
|
50
55
|
/^\s*Caveat: The messages below were generated by the user while running/i,
|
|
51
56
|
/^\s*\[Request interrupted/i,
|
|
@@ -108,6 +108,9 @@ export declare function filterEvents(events: SessionEvent[], opts: FilterOptions
|
|
|
108
108
|
*/
|
|
109
109
|
export interface RenderConversationMarkdownOptions {
|
|
110
110
|
redact?: boolean;
|
|
111
|
+
knownSecrets?: readonly string[];
|
|
112
|
+
reasoning?: 'omit' | 'fold' | 'include';
|
|
113
|
+
maxToolOutputChars?: number;
|
|
111
114
|
}
|
|
112
115
|
export declare function renderConversationMarkdown(events: SessionEvent[], opts?: RenderConversationMarkdownOptions): string;
|
|
113
116
|
/**
|
|
@@ -923,10 +923,23 @@ export function filterEvents(events, opts) {
|
|
|
923
923
|
const sliced = applyTurnSlice(events, opts);
|
|
924
924
|
return applyRoleFilter(sliced, opts);
|
|
925
925
|
}
|
|
926
|
+
function markdownFence(content, language = '') {
|
|
927
|
+
const fence = content.includes('```') ? '````' : '```';
|
|
928
|
+
return `${fence}${language}\n${content}\n${fence}`;
|
|
929
|
+
}
|
|
930
|
+
function truncateToolOutput(content, maxChars) {
|
|
931
|
+
if (content.length <= maxChars)
|
|
932
|
+
return content;
|
|
933
|
+
return `${content.slice(0, maxChars)}\n\n[Output truncated: ${content.length - maxChars} characters omitted.]`;
|
|
934
|
+
}
|
|
926
935
|
export function renderConversationMarkdown(events, opts = {}) {
|
|
927
936
|
const parts = [];
|
|
928
937
|
const shouldRedact = opts.redact !== false;
|
|
929
|
-
const sanitize = (text) => shouldRedact ? redactSecrets(text) : text;
|
|
938
|
+
const sanitize = (text) => shouldRedact ? redactSecrets(text, opts.knownSecrets) : text;
|
|
939
|
+
// Preserve the long-standing `sessions <id> --markdown` full-fidelity default.
|
|
940
|
+
// The shareable `sessions render` surface passes `omit` explicitly.
|
|
941
|
+
const reasoning = opts.reasoning ?? 'include';
|
|
942
|
+
const maxToolOutputChars = opts.maxToolOutputChars ?? 4000;
|
|
930
943
|
for (const event of events) {
|
|
931
944
|
if (event.type === 'message') {
|
|
932
945
|
if (event.role === 'user') {
|
|
@@ -937,27 +950,37 @@ export function renderConversationMarkdown(events, opts = {}) {
|
|
|
937
950
|
}
|
|
938
951
|
}
|
|
939
952
|
else if (event.type === 'thinking') {
|
|
940
|
-
if (event.content)
|
|
941
|
-
parts.push(`###
|
|
953
|
+
if (event.content && reasoning === 'include') {
|
|
954
|
+
parts.push(`### Reasoning\n\n${sanitize(event.content)}`);
|
|
955
|
+
}
|
|
956
|
+
else if (event.content && reasoning === 'fold') {
|
|
957
|
+
parts.push(`<details>\n<summary>Reasoning</summary>\n\n${sanitize(event.content)}\n\n</details>`);
|
|
958
|
+
}
|
|
942
959
|
}
|
|
943
960
|
else if (event.type === 'tool_use') {
|
|
944
961
|
const tool = event.tool || 'unknown';
|
|
945
962
|
if (event.command) {
|
|
946
|
-
|
|
963
|
+
const args = event.args && Object.keys(event.args).length > 0
|
|
964
|
+
? `\n\nArguments:\n\n${markdownFence(sanitize(JSON.stringify(event.args, null, 2)), 'json')}`
|
|
965
|
+
: '';
|
|
966
|
+
parts.push(`### Tool: ${tool}\n\n${markdownFence(sanitize(event.command), 'bash')}${args}`);
|
|
967
|
+
}
|
|
968
|
+
else if (event.args && Object.keys(event.args).length > 0) {
|
|
969
|
+
parts.push(`### Tool: ${tool}\n\nArguments:\n\n${markdownFence(sanitize(JSON.stringify(event.args, null, 2)), 'json')}`);
|
|
947
970
|
}
|
|
948
971
|
else if (event.path) {
|
|
949
|
-
parts.push(`### Tool: ${tool}\n\n\`${shortenPathTrace(event.path)}\``);
|
|
972
|
+
parts.push(`### Tool: ${tool}\n\n\`${sanitize(shortenPathTrace(event.path))}\``);
|
|
950
973
|
}
|
|
951
974
|
else {
|
|
952
975
|
const summary = summarizeToolUse(tool, event.args);
|
|
953
|
-
parts.push(`### Tool: ${tool}\n\n${summary}`);
|
|
976
|
+
parts.push(`### Tool: ${tool}\n\n${sanitize(summary)}`);
|
|
954
977
|
}
|
|
955
978
|
}
|
|
956
979
|
else if (event.type === 'tool_result') {
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
const body = sanitize(
|
|
960
|
-
parts.push(`### Tool Result\n\n
|
|
980
|
+
const output = event.content || event.output;
|
|
981
|
+
if (output) {
|
|
982
|
+
const body = sanitize(truncateToolOutput(output, maxToolOutputChars));
|
|
983
|
+
parts.push(`### Tool Result${event.tool ? `: ${event.tool}` : ''}\n\n${markdownFence(body)}`);
|
|
961
984
|
}
|
|
962
985
|
}
|
|
963
986
|
else if (event.type === 'error') {
|
|
@@ -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>;
|