@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.
Files changed (75) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/README.md +5 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +61 -0
  5. package/dist/commands/exec.js +89 -21
  6. package/dist/commands/feed.js +38 -1
  7. package/dist/commands/harness.d.ts +40 -2
  8. package/dist/commands/harness.js +316 -20
  9. package/dist/commands/monitors.js +2 -2
  10. package/dist/commands/profiles.d.ts +42 -0
  11. package/dist/commands/profiles.js +91 -3
  12. package/dist/commands/routines.js +2 -2
  13. package/dist/commands/run-account-picker.js +2 -0
  14. package/dist/commands/sessions-picker.js +3 -3
  15. package/dist/commands/sessions-render.d.ts +12 -0
  16. package/dist/commands/sessions-render.js +124 -0
  17. package/dist/commands/sessions.js +28 -1
  18. package/dist/commands/snapshot.d.ts +11 -0
  19. package/dist/commands/snapshot.js +107 -0
  20. package/dist/commands/ssh.js +26 -3
  21. package/dist/commands/teams.js +14 -2
  22. package/dist/commands/view.d.ts +7 -0
  23. package/dist/commands/view.js +1 -1
  24. package/dist/index.js +11 -1
  25. package/dist/lib/browser/ipc.js +2 -0
  26. package/dist/lib/browser/remote-control.d.ts +35 -0
  27. package/dist/lib/browser/remote-control.js +48 -0
  28. package/dist/lib/browser/service.d.ts +19 -0
  29. package/dist/lib/browser/service.js +19 -1
  30. package/dist/lib/browser/types.d.ts +14 -2
  31. package/dist/lib/crabbox/cli.d.ts +35 -0
  32. package/dist/lib/crabbox/cli.js +46 -0
  33. package/dist/lib/crabbox/config.d.ts +21 -0
  34. package/dist/lib/crabbox/config.js +43 -0
  35. package/dist/lib/crabbox/lease.d.ts +15 -5
  36. package/dist/lib/crabbox/lease.js +57 -17
  37. package/dist/lib/daemon.js +12 -11
  38. package/dist/lib/device-config.js +8 -0
  39. package/dist/lib/devices/resolve-target.d.ts +4 -3
  40. package/dist/lib/devices/resolve-target.js +4 -3
  41. package/dist/lib/hosts/passthrough.d.ts +10 -1
  42. package/dist/lib/hosts/passthrough.js +41 -3
  43. package/dist/lib/hosts/registry.d.ts +4 -0
  44. package/dist/lib/hosts/registry.js +16 -0
  45. package/dist/lib/hosts/remote-cmd.js +2 -0
  46. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  47. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  48. package/dist/lib/observe-aliases.d.ts +30 -0
  49. package/dist/lib/observe-aliases.js +56 -0
  50. package/dist/lib/placement.d.ts +82 -0
  51. package/dist/lib/placement.js +188 -0
  52. package/dist/lib/profiles.d.ts +31 -9
  53. package/dist/lib/profiles.js +83 -13
  54. package/dist/lib/redact.js +4 -0
  55. package/dist/lib/rotate.d.ts +19 -4
  56. package/dist/lib/rotate.js +24 -1
  57. package/dist/lib/routines.d.ts +2 -0
  58. package/dist/lib/runner.d.ts +3 -0
  59. package/dist/lib/runner.js +90 -7
  60. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  61. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  62. package/dist/lib/session/parse.d.ts +5 -1
  63. package/dist/lib/session/parse.js +23 -13
  64. package/dist/lib/session/prompt.js +5 -0
  65. package/dist/lib/session/render.d.ts +3 -0
  66. package/dist/lib/session/render.js +33 -10
  67. package/dist/lib/snapshot.d.ts +103 -0
  68. package/dist/lib/snapshot.js +99 -0
  69. package/dist/lib/startup/command-registry.d.ts +1 -0
  70. package/dist/lib/startup/command-registry.js +17 -1
  71. package/dist/lib/usage-refresh.d.ts +19 -11
  72. package/dist/lib/usage-refresh.js +75 -43
  73. package/dist/lib/usage.d.ts +12 -0
  74. package/dist/lib/usage.js +37 -6
  75. package/package.json +1 -1
@@ -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);
@@ -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 declare function parseSession(filePath: string, agent?: SessionAgentId): SessionEvent[];
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
- * Auto-detect agent type from file path and parse the session.
117
- */
118
- export function parseSession(filePath, agent) {
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
- output: output.length > 500 ? output.slice(0, 497) + '...' : output,
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.length > 500 ? result.text.slice(0, 497) + '...' : 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.length > 500 ? result.text.slice(0, 497) + '...' : 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: output.length > 500 ? output.slice(0, 497) + '...' : 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.length > 500 ? output.slice(0, 497) + '...' : 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.length > 500 ? outputStr.slice(0, 497) + '...' : 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.length > 500 ? outputStr.slice(0, 497) + '...' : 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.length > 500 ? output.slice(0, 497) + '...' : 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.length > 500 ? output.slice(0, 497) + '...' : 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(`### Thinking\n\n${sanitize(event.content)}`);
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
- parts.push(`### Tool: ${tool}\n\n\`\`\`bash\n${sanitize(event.command)}\n\`\`\``);
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
- if (event.content) {
958
- const truncated = event.content.length > 2000 ? event.content.slice(0, 2000) + '\n…' : event.content;
959
- const body = sanitize(truncated);
960
- parts.push(`### Tool Result\n\n\`\`\`\n${body}\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>;