@phnx-labs/agents-cli 1.22.115 → 1.22.117

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 (91) hide show
  1. package/CHANGELOG.md +161 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.d.ts +18 -0
  5. package/dist/commands/exec.js +263 -152
  6. package/dist/commands/feed.js +65 -14
  7. package/dist/commands/sessions-picker.d.ts +11 -0
  8. package/dist/commands/sessions-picker.js +88 -7
  9. package/dist/commands/sessions.d.ts +21 -2
  10. package/dist/commands/sessions.js +157 -3
  11. package/dist/commands/setup-secrets.d.ts +2 -2
  12. package/dist/commands/setup-term.d.ts +25 -0
  13. package/dist/commands/setup-term.js +71 -0
  14. package/dist/commands/setup.d.ts +1 -1
  15. package/dist/commands/setup.js +12 -4
  16. package/dist/commands/ssh.js +1 -69
  17. package/dist/lib/accounting/rotate.d.ts +63 -1
  18. package/dist/lib/accounting/rotate.js +56 -0
  19. package/dist/lib/accounts/add.js +2 -2
  20. package/dist/lib/accounts/slots.js +32 -2
  21. package/dist/lib/answer-router.d.ts +11 -2
  22. package/dist/lib/answer-router.js +26 -2
  23. package/dist/lib/auth-mint.d.ts +5 -4
  24. package/dist/lib/auth-mint.js +4 -3
  25. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  26. package/dist/lib/browser/service.d.ts +10 -0
  27. package/dist/lib/browser/service.js +208 -36
  28. package/dist/lib/browser/types.d.ts +18 -0
  29. package/dist/lib/config-keys.d.ts +1 -1
  30. package/dist/lib/config-keys.js +5 -0
  31. package/dist/lib/device-config.js +61 -0
  32. package/dist/lib/devices/doctor-findings.js +2 -6
  33. package/dist/lib/feed/answer.d.ts +153 -4
  34. package/dist/lib/feed/answer.js +716 -105
  35. package/dist/lib/feed/feed.d.ts +61 -1
  36. package/dist/lib/feed/feed.js +226 -14
  37. package/dist/lib/feed/hub-server.d.ts +58 -3
  38. package/dist/lib/feed/hub-server.js +306 -54
  39. package/dist/lib/feed/pr-status.d.ts +8 -0
  40. package/dist/lib/feed/pr-status.js +9 -1
  41. package/dist/lib/feed-outcome.d.ts +1 -1
  42. package/dist/lib/feed-outcome.js +9 -2
  43. package/dist/lib/feed-policy.js +9 -3
  44. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  45. package/dist/lib/fleet/auth-sync.js +2 -89
  46. package/dist/lib/harness-auth-capabilities.js +7 -2
  47. package/dist/lib/hosts/dispatch.d.ts +20 -1
  48. package/dist/lib/hosts/dispatch.js +52 -30
  49. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  50. package/dist/lib/hosts/remote-cmd.js +27 -2
  51. package/dist/lib/mailbox.d.ts +12 -0
  52. package/dist/lib/mailbox.js +16 -2
  53. package/dist/lib/menubar/snapshot.d.ts +51 -0
  54. package/dist/lib/menubar/snapshot.js +42 -3
  55. package/dist/lib/open-url.js +2 -2
  56. package/dist/lib/placement.d.ts +2 -0
  57. package/dist/lib/placement.js +5 -0
  58. package/dist/lib/projects.d.ts +23 -0
  59. package/dist/lib/projects.js +78 -0
  60. package/dist/lib/secrets-cli.d.ts +3 -3
  61. package/dist/lib/secrets-cli.js +1 -1
  62. package/dist/lib/session/active.d.ts +1 -0
  63. package/dist/lib/session/active.js +8 -0
  64. package/dist/lib/session/db.d.ts +67 -3
  65. package/dist/lib/session/db.js +381 -126
  66. package/dist/lib/session/prompt.d.ts +23 -7
  67. package/dist/lib/session/prompt.js +46 -8
  68. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  69. package/dist/lib/session/remote/remote-list.js +22 -6
  70. package/dist/lib/session/remote/watch.d.ts +12 -0
  71. package/dist/lib/session/remote/watch.js +9 -0
  72. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  73. package/dist/lib/session/remote-preview-cache.js +373 -0
  74. package/dist/lib/session/tail.d.ts +50 -0
  75. package/dist/lib/session/tail.js +219 -0
  76. package/dist/lib/setup-tool-install.js +2 -1
  77. package/dist/lib/setup-tool-status.d.ts +1 -1
  78. package/dist/lib/setup-tool-status.js +7 -1
  79. package/dist/lib/signin-badge.d.ts +19 -4
  80. package/dist/lib/signin-badge.js +29 -11
  81. package/dist/lib/term-driver.d.ts +24 -0
  82. package/dist/lib/term-driver.js +36 -0
  83. package/dist/lib/terminal/index.d.ts +1 -1
  84. package/dist/lib/terminal/index.js +1 -1
  85. package/dist/lib/terminal/inject.d.ts +38 -0
  86. package/dist/lib/terminal/inject.js +55 -9
  87. package/dist/lib/terminal/transport.d.ts +15 -5
  88. package/dist/lib/terminal/transport.js +61 -11
  89. package/package.json +1 -1
  90. package/dist/lib/fleet/remote-login.d.ts +0 -170
  91. package/dist/lib/fleet/remote-login.js +0 -568
@@ -1,5 +1,5 @@
1
1
  import chalk from 'chalk';
2
- import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildDeclaredBlock, publishBlock, } from '../lib/feed/feed.js';
2
+ import { ensureFeedPublishHook, listAskStats, listBlocks, recordNotified, buildDeclaredBlock, deriveBlockState, publishBlock, } from '../lib/feed/feed.js';
3
3
  import { ensureActivityLogHook, readRecentActivity, formatActivityLine, formatProgressUpdate, mergeActivityEvents, parseActivityPayload, } from '../lib/feed/activity.js';
4
4
  import { projectKeyFromCwd } from '../lib/project-key.js';
5
5
  import { postFeedStatus } from '../lib/feed-post.js';
@@ -16,7 +16,7 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
16
16
  import { loadPolicy, applyPolicyToBlock, isPhoneUrgent } from '../lib/feed-policy.js';
17
17
  import { notifyUrgentBlock } from '../lib/notify.js';
18
18
  import { registerFeedWatchCommand } from './feed-watch.js';
19
- import { claimAndRouteAttentionAnswer, forwardFeedAnswer } from '../lib/feed/answer.js';
19
+ import { AnswerError, answerOwnerIsLocal, checkAnswerDelivery, claimAndRouteAttentionAnswer, forwardFeedAnswer, parseAttentionKey, } from '../lib/feed/answer.js';
20
20
  import { gcMailbox } from '../lib/mailbox-gc.js';
21
21
  import { isValidMailboxId } from '../lib/mailbox.js';
22
22
  import { getActiveSessions } from '../lib/session/active.js';
@@ -171,7 +171,10 @@ function renderBlock(b, localHost, indent = '') {
171
171
  const cost = b.costOfDelay ? chalk.gray(`cost:${b.costOfDelay}`) : '';
172
172
  const rank = b.delayRank ? chalk.gray(`rank:${Math.round(b.delayRank.score)}`) : '';
173
173
  // Shared fleet-comms glyphs: ▲ open ask, ✓ answered (see comms-render GLYPH).
174
- const marker = b.answer
174
+ // A PENDING claim carries an answer record while the block is still open, so
175
+ // the delivered glyph is driven by the canonical state, not by `b.answer`
176
+ // being set — a claimed-but-undelivered ask must keep reading as needs-you.
177
+ const marker = deriveBlockState(b) !== 'open'
175
178
  ? chalk.green(GLYPH.delivered)
176
179
  : b.kind === 'control'
177
180
  ? chalk.red('!')
@@ -197,7 +200,9 @@ function renderBlock(b, localHost, indent = '') {
197
200
  if (b.answer) {
198
201
  const verified = b.answer.verified ? chalk.green(GLYPH.delivered) : chalk.yellow('?');
199
202
  const who = b.answer.answeredFrom + (b.answer.answeredBy ? ` (${b.answer.answeredBy})` : '');
200
- console.log(`${indent} ${chalk.green('answered')} by ${who} ${verified}`);
203
+ const pending = deriveBlockState(b) === 'open';
204
+ const label = pending ? chalk.yellow('claimed (delivery unconfirmed)') : chalk.green('answered');
205
+ console.log(`${indent} ${label} by ${who} ${verified}`);
201
206
  }
202
207
  if (b.parkedAt) {
203
208
  console.log(`${indent} ${chalk.red('hard-parked')} ${relTime(b.parkedAt)}`);
@@ -314,6 +319,23 @@ export async function loadSessionMetasForFeedEnrichment(load) {
314
319
  throw err;
315
320
  }
316
321
  }
322
+ /**
323
+ * One human line per answer outcome. The three states the operator has to tell
324
+ * apart are a real receipt, a hand-off no rail can confirm, and a refusal —
325
+ * never collapsed into a single "Delivered" (PHNX-3999).
326
+ */
327
+ function renderAnswerResult(result) {
328
+ switch (result.status) {
329
+ case 'failed':
330
+ return `Not delivered: ${result.reason ?? 'unknown failure'} — answering again is safe.`;
331
+ case 'unknown':
332
+ return `Delivery unconfirmed: ${result.reason ?? 'no rail reported a receipt.'} Run \`agents feed answer ${result.attentionKey} --check\` rather than resending.`;
333
+ case 'already_answered':
334
+ return `Already answered — ${result.receipt?.status ?? 'claimed'}${result.receipt ? ` as ${result.receipt.msgId}` : ''} at ${result.receipt?.at ?? result.attempt}.`;
335
+ default:
336
+ return `Delivered ${result.receipt?.msgId} (${result.receipt?.status}).`;
337
+ }
338
+ }
317
339
  export function registerFeedCommand(program) {
318
340
  const feed = program
319
341
  .command('feed')
@@ -334,23 +356,52 @@ export function registerFeedCommand(program) {
334
356
  .option('--choice <choice-id>', 'Stable choice id from the attention item')
335
357
  .option('--text <answer>', 'Free-text answer')
336
358
  .option('--as <operator>', 'Verified operator id for high-consequence answers')
359
+ .option('--check', 'Read-only: report this item\'s delivery state without claiming, routing or resending')
360
+ .option('--attempt <at>', 'With --check: the attempt timestamp being checked, so a newer one is reported as such')
337
361
  .option('--json', 'Emit the delivery result as JSON')
338
362
  .action(async (attentionKey, opts, invoked) => {
363
+ const wantsJson = Boolean(opts.json || invoked.parent?.opts()?.json);
339
364
  try {
340
- const ownerHost = attentionKey.slice(0, attentionKey.indexOf('/'));
341
- const result = ownerHost && ownerHost !== machineId()
342
- ? await forwardFeedAnswer({ host: ownerHost, attentionKey, choiceId: opts.choice, text: opts.text, operatorId: opts.as })
343
- : await claimAndRouteAttentionAnswer({
344
- attentionKey, choiceId: opts.choice, text: opts.text,
345
- operator: { id: opts.as, verified: Boolean(opts.as), label: opts.as },
346
- });
347
- if (opts.json || invoked.parent?.opts()?.json)
365
+ // The key's own host decides the rail; `answerOwnerIsLocal` normalizes
366
+ // both sides so a `.local` suffix is not read as a different machine.
367
+ const { host: ownerHost } = parseAttentionKey(attentionKey);
368
+ const local = answerOwnerIsLocal(ownerHost);
369
+ if (opts.check && (opts.choice != null || opts.text != null)) {
370
+ throw new AnswerError('--check is read-only; it takes no --choice or --text.', 'empty_answer');
371
+ }
372
+ if (opts.attempt != null && !opts.check) {
373
+ throw new AnswerError('--attempt only applies to --check.', 'empty_answer');
374
+ }
375
+ const result = opts.check
376
+ ? (local
377
+ ? checkAnswerDelivery(attentionKey, undefined, opts.attempt)
378
+ : await forwardFeedAnswer({ host: ownerHost, attentionKey, check: true, attempt: opts.attempt, operatorId: opts.as }))
379
+ : local
380
+ ? await claimAndRouteAttentionAnswer({
381
+ attentionKey, choiceId: opts.choice, text: opts.text,
382
+ operator: { id: opts.as, verified: Boolean(opts.as), label: opts.as },
383
+ })
384
+ : await forwardFeedAnswer({ host: ownerHost, attentionKey, choiceId: opts.choice, text: opts.text, operatorId: opts.as });
385
+ if (wantsJson)
348
386
  console.log(JSON.stringify(result));
349
387
  else
350
- console.log(result.status === 'delivered' ? `Delivered ${result.receipt.msgId}.` : `Already answered (${result.receipt.at}).`);
388
+ console.log(renderAnswerResult(result));
351
389
  }
352
390
  catch (error) {
353
- invoked.error(`error: ${error instanceof Error ? error.message : String(error)}`);
391
+ // A failure is a first-class outcome for the operator UI, not a parse
392
+ // error: `--json` still gets a result object it can branch on, with a
393
+ // non-zero exit so a scripted caller sees the failure too.
394
+ const message = error instanceof Error ? error.message : String(error);
395
+ if (wantsJson) {
396
+ const failure = {
397
+ status: 'failed', delivery: 'failed', resolved: false, reason: message,
398
+ code: error instanceof AnswerError ? error.code : 'rail_failed', attentionKey,
399
+ };
400
+ console.log(JSON.stringify(failure));
401
+ process.exitCode = 1;
402
+ return;
403
+ }
404
+ invoked.error(`error: ${message}`);
354
405
  }
355
406
  });
356
407
  feed
@@ -153,6 +153,17 @@ export interface SessionPreviewDigest {
153
153
  firstError?: string;
154
154
  toolHistogram: ReturnType<typeof toolHistogram>;
155
155
  test: ReturnType<typeof detectTestResult>;
156
+ /**
157
+ * True when this digest was built WITHOUT parsing the transcript (PHNX-3999):
158
+ * the file exceeded {@link PREVIEW_DIGEST_MAX_PARSE_BYTES} and no cached
159
+ * digest existed yet. `firstUser`/`lastAssistant` fall back to the already-
160
+ * indexed `SessionMeta.firstUserMessage`/`lastUserMessage` (cheap, no parse)
161
+ * rather than being silently empty; every event-derived field (toolCalls,
162
+ * changedFiles, artifacts, etc.) stays at its zero-value default because it
163
+ * genuinely was not computed, not because nothing happened.
164
+ */
165
+ partial?: boolean;
166
+ partialReason?: string;
156
167
  }
157
168
  /** Fold a harness-normalized event stream into the stable preview data model. */
158
169
  export declare function buildSessionPreviewDigest(events: SessionEvent[], session: SessionMeta): SessionPreviewDigest;
@@ -8,12 +8,13 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
- import { truncate, humanDuration } from '../lib/format.js';
11
+ import { truncate, humanDuration, formatBytes } from '../lib/format.js';
12
12
  import { sessionDisplayAgent } from '../lib/session/types.js';
13
13
  import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
14
14
  import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
15
+ import { readSessionTail, readSessionHead } from '../lib/session/tail.js';
15
16
  import { safeTeamText } from '../lib/session/team-filter.js';
16
- import { cleanSessionPrompt, extractSessionTopic, isSyntheticUserMessage } from '../lib/session/prompt.js';
17
+ import { cleanSessionPrompt, extractSessionTopic, isSyntheticUserMessage, firstUserMessageFromEvents } from '../lib/session/prompt.js';
17
18
  import { linkPath, linkUrl, relativeToCwd, shortenModel } from '../lib/session/render.js';
18
19
  import { linearIssueUrl } from '../lib/session/linear.js';
19
20
  import { extractTodoProgress, WORKTREE_RE } from '../lib/session/state.js';
@@ -324,7 +325,14 @@ export function loadSessionPreviewDigest(session) {
324
325
  archived.plugins = getSessionPlugins(session.id);
325
326
  return { digest: archived, events: [] };
326
327
  }
327
- return { events: [] };
328
+ // No file on disk AND no archived digest: a metadata-only row (a Rush
329
+ // dispatch/audit row, a synthesized attach-only entry, a live session not
330
+ // yet indexed) with nothing to read (PHNX-3999). Callers that only
331
+ // destructure `digest` (the picker's "not indexed here" note) are
332
+ // unaffected; a JSON caller reading `error` gets a truthful reason instead
333
+ // of a `preview: null` that looks identical to "this session genuinely has
334
+ // no content yet".
335
+ return { events: [], error: 'no local transcript for this session (metadata-only entry, no archived digest)' };
328
336
  }
329
337
  const safe = sanitizeMeta(session);
330
338
  let events = [];
@@ -340,9 +348,53 @@ export function loadSessionPreviewDigest(session) {
340
348
  fileSize: sourceStamp.size,
341
349
  });
342
350
  if (!digest) {
343
- try {
344
- events = parseSession(session.filePath, session.agent);
351
+ if (sourceStamp.size > PREVIEW_DIGEST_MAX_PARSE_BYTES) {
352
+ // A full `parseSession` on a cache miss is a synchronous, unbounded
353
+ // whole-file parse with no time/byte cap of its own (PHNX-3999) — real
354
+ // 6.3 MiB and 35.7 MiB screenshot-heavy transcripts on this fleet both
355
+ // lacked a computed digest/timeline, consistent with this path not
356
+ // finishing in a reasonable request budget for either. `PREVIEW_DIGEST_MAX_PARSE_BYTES`
357
+ // is deliberately smaller than the daemon's own background-work ceiling
358
+ // (see that constant's own doc) so this catches both real cases.
359
+ //
360
+ // This IS genuinely partial, not empty: rather than parsing nothing,
361
+ // `readSessionTail` (`tail.ts`) reads only the LAST 128 KiB of the file
362
+ // (already the live-view's own bounded reader, reused verbatim — no new
363
+ // parse logic) for a real recent-events window on the two harnesses it
364
+ // supports (Claude/Codex); event-derived fields below (toolCalls,
365
+ // toolTags, etc.) reflect that tail window, not the whole session, which
366
+ // `partialReason` states explicitly.
367
+ //
368
+ // `firstUser` is NEVER set from the tail fold: a tail window's "first
369
+ // user message IN THAT WINDOW" is a mid-session follow-up on any
370
+ // multi-turn session, not the session's actual original request, and
371
+ // there is no honest way to tell the two apart from the tail alone.
372
+ // Falling back to it would silently mislabel a follow-up as the
373
+ // original ask. Preference order for the CANONICAL original request:
374
+ // (1) the already-indexed `SessionMeta.firstUserMessage` (zero
375
+ // extra I/O); (2) failing that, a bounded HEAD read (`readSessionHead`,
376
+ // `tail.ts` — the mirror of the tail reader, first ~32 KiB from byte 0,
377
+ // where a session's opening turn always lives) so a row with no indexed
378
+ // value yet still gets the REAL original request rather than nothing.
379
+ // Only when neither is available does `firstUser` stay empty —
380
+ // `partial`/`partialReason` already say why detail is missing, which is
381
+ // the truthful signal, never a guessed value. The digest is cached
382
+ // against this stamp so the bound is paid once per transcript version,
383
+ // not once per call.
384
+ events = readSessionTail(session.filePath, session.agent);
345
385
  digest = buildSessionPreviewDigest(events, safe);
386
+ if (session.firstUserMessage) {
387
+ digest.firstUser = session.firstUserMessage;
388
+ }
389
+ else {
390
+ // Reuse the canonical extractor (rejects synthetic/system-injected
391
+ // turns, unwraps a Grok/Cursor <user_query> wrapper) rather than a
392
+ // bespoke inline find — the same rules SessionMeta.firstUserMessage
393
+ // itself was built with.
394
+ digest.firstUser = firstUserMessageFromEvents(readSessionHead(session.filePath, session.agent)) ?? '';
395
+ }
396
+ digest.partial = true;
397
+ digest.partialReason = `transcript is ${formatBytes(sourceStamp.size)}, over the ${formatBytes(PREVIEW_DIGEST_MAX_PARSE_BYTES)} bounded-parse limit for an uncached preview; digest reflects only the last ~128 KiB (tail) of the transcript, not the whole session`;
346
398
  writeSessionPreviewCache({
347
399
  id: session.id,
348
400
  fileMtimeMs: sourceStamp.mtimeMs,
@@ -350,8 +402,20 @@ export function loadSessionPreviewDigest(session) {
350
402
  preview: digest,
351
403
  });
352
404
  }
353
- catch (err) {
354
- return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
405
+ else {
406
+ try {
407
+ events = parseSession(session.filePath, session.agent);
408
+ digest = buildSessionPreviewDigest(events, safe);
409
+ writeSessionPreviewCache({
410
+ id: session.id,
411
+ fileMtimeMs: sourceStamp.mtimeMs,
412
+ fileSize: sourceStamp.size,
413
+ preview: digest,
414
+ });
415
+ }
416
+ catch (err) {
417
+ return { events, error: sanitizeForTerminal(err?.message ?? String(err)) };
418
+ }
355
419
  }
356
420
  }
357
421
  digest.plugins = getSessionPlugins(session.id);
@@ -711,6 +775,23 @@ const DIRS_TOUCHED_MAX = 5;
711
775
  // enough to cover any real session's edits, low enough that a runaway rewrite
712
776
  // can't bloat the cached JSON. The `changes` counts stay the true totals.
713
777
  const CHANGED_FILES_MAX = 200;
778
+ /**
779
+ * Bound for an uncached `loadSessionPreviewDigest` parse (PHNX-3999).
780
+ *
781
+ * Deliberately SMALLER than the daemon's own
782
+ * `TIMELINE_PASS_MAX_WHOLE_FILE_BYTES` (16 MiB, `timeline-pass.ts`) — that
783
+ * number bounds BACKGROUND work the daemon tick can afford to spend; this one
784
+ * bounds a SYNCHRONOUS request an interactive caller (the Menu, with its own
785
+ * end-to-end latency budget) is actively waiting on. Two real screenshot-heavy
786
+ * transcripts on this fleet (6.3 MiB and 35.7 MiB) both lacked any computed
787
+ * digest/timeline — a 16 MiB threshold here would still miss the smaller one.
788
+ * The exact per-byte cost of `parseSession` is not measured in this change;
789
+ * 4 MiB is a conservative invariant (well under the 6.3 MiB failure case,
790
+ * comfortably above ordinary non-screenshot transcript sizes), not a timing
791
+ * guarantee — verify against real transcripts before relying on a specific
792
+ * elapsed-time bound.
793
+ */
794
+ const PREVIEW_DIGEST_MAX_PARSE_BYTES = 4 * 1024 * 1024;
714
795
  /** Fold a harness-normalized event stream into the stable preview data model. */
715
796
  export function buildSessionPreviewDigest(events, session) {
716
797
  let firstUser = '';
@@ -1,13 +1,13 @@
1
1
  import { type Command } from 'commander';
2
2
  import { type ProjectDef } from '../lib/projects.js';
3
- import type { SessionAgentId, SessionMeta, ViewMode } from '../lib/session/types.js';
3
+ import type { SessionAgentId, SessionEvent, SessionMeta, ViewMode } from '../lib/session/types.js';
4
4
  import { type ActiveSession } from '../lib/session/active.js';
5
5
  export { activeSessionProjectKey, backfillActiveRowsFromIndex, backfillActiveRowsFromMeta, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson, type BackfillMeta } from '../lib/session/active.js';
6
6
  import { loadLocalActiveSessions } from '../lib/session/session-cache.js';
7
7
  import { gatherRemoteList, runOnPeer } from '../lib/session/remote-list.js';
8
8
  import { type RemoteAgentsJsonParseResult } from '../lib/remote-agents-json.js';
9
9
  import { type RunMeta } from '../lib/scheduling/routines.js';
10
- import { type PickedSession } from './sessions-picker.js';
10
+ import { type PickedSession, type SessionPreviewDigest } from './sessions-picker.js';
11
11
  import { type ComputerRunRow } from '../lib/computer/sessions-list.js';
12
12
  import { type ToolSearchEnvelope, type ToolProgramCountEnvelope } from '../lib/session/tool-index.js';
13
13
  interface SessionFilterOptions {
@@ -466,6 +466,23 @@ export declare function printRoutineDrilldown(drill: RoutineDrilldown, liveIndex
466
466
  hiddenCount?: number;
467
467
  hiddenUnmanaged?: number;
468
468
  }): void;
469
+ export interface SessionDetailMessage {
470
+ role: 'user' | 'assistant';
471
+ text: string;
472
+ at: string | null;
473
+ }
474
+ export declare function buildSessionDetailBlock(session: SessionMeta, digest: SessionPreviewDigest | undefined, events: SessionEvent[], sourceStamp?: {
475
+ fileMtimeMs: number;
476
+ fileSize: number;
477
+ }): {
478
+ request: unknown;
479
+ timeline: unknown;
480
+ files: unknown;
481
+ messages: SessionDetailMessage[];
482
+ sourceRevision: string | null;
483
+ partial: boolean;
484
+ reason: string | null;
485
+ };
469
486
  /** Resolve a session by id/query globally and print its compact preview (no pager).
470
487
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
471
488
  export declare function renderSessionPreview(query: string, scope: {
@@ -474,6 +491,8 @@ export declare function renderSessionPreview(query: string, scope: {
474
491
  local?: boolean;
475
492
  hosts?: string[];
476
493
  json?: boolean;
494
+ refresh?: boolean;
495
+ revision?: string;
477
496
  }): Promise<void>;
478
497
  /**
479
498
  * The one-line live status banner shown above a session preview: the glyph, the
@@ -35,7 +35,9 @@ import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
35
35
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
36
36
  import { inferSessionState } from '../lib/session/state.js';
37
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
38
- import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
38
+ import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview, readSessionTimelineAny } from '../lib/session/db.js';
39
+ import { foldTimeline, emptyTimelineState, projectTimeline, projectSessionFiles } from '../lib/session/timeline.js';
40
+ import { readSessionTail } from '../lib/session/tail.js';
39
41
  import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
40
42
  import { sessionHeadline } from '../lib/session/title.js';
41
43
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
@@ -1982,9 +1984,151 @@ function canonicalSessionsCommand(query, options) {
1982
1984
  a.push(JSON.stringify(q));
1983
1985
  return 'ag ' + a.join(' ');
1984
1986
  }
1987
+ /** Bound on the `details.messages` array and on each message's `text` — this
1988
+ * rides a cached JSON payload (and, for the remote fast path, an SSH capture
1989
+ * with its own byte cap), so it is capped independently of either. */
1990
+ const SESSION_DETAIL_MAX_MESSAGES = 8;
1991
+ const SESSION_DETAIL_MESSAGE_MAX_CHARS = 4_000;
1992
+ /**
1993
+ * The additive `details` block on `sessions preview --json` (PHNX-3999):
1994
+ * `request`/`timeline`/`files` are read FIRST from the existing
1995
+ * `session_timelines` projection (`readSessionTimelineAny` — the daemon's own
1996
+ * bounded, incrementally-folded cache, `SessionTimelineProjection` in
1997
+ * `lib/session/db.ts`), so a session the daemon has already reached costs
1998
+ * nothing extra here. When that projection is missing (daemon hasn't reached
1999
+ * this session yet), this does an ON-DEMAND fold using the EXACT same pure,
2000
+ * already-redacting pipeline the daemon uses (`foldTimeline` /
2001
+ * `projectTimeline` / `projectSessionFiles`, `timeline.ts`) over whatever
2002
+ * events are cheaply available: the fresh-parse `events` from
2003
+ * `loadSessionPreviewDigest` when there is one, else a bounded 128 KiB tail
2004
+ * read (`readSessionTail`, Claude/Codex only — the same bounded reader the
2005
+ * local preview's over-cap fallback uses) so a WARM cache hit (no fresh parse)
2006
+ * still gets real, on-demand content instead of falling back to a 2-message
2007
+ * digest summary. This is genuinely bounded either way: it folds only events
2008
+ * already in hand or a fixed 128 KiB window, never a fresh whole-file parse.
2009
+ * `partial`/`reason` say when this happened (`daemon-pending: on-demand
2010
+ * bounded fold`) versus a session with no transcript at all.
2011
+ */
2012
+ function sessionTranscriptStamp(session) {
2013
+ try {
2014
+ if (!session.filePath)
2015
+ return undefined;
2016
+ const stat = fs.statSync(session.filePath);
2017
+ return { fileMtimeMs: stat.mtimeMs, fileSize: stat.size };
2018
+ }
2019
+ catch {
2020
+ return undefined;
2021
+ }
2022
+ }
2023
+ export function buildSessionDetailBlock(session, digest, events, sourceStamp) {
2024
+ const bound = (text) => redactSecrets(sanitizeForTerminal(text)).slice(0, SESSION_DETAIL_MESSAGE_MAX_CHARS);
2025
+ const stamp = sourceStamp ?? sessionTranscriptStamp(session);
2026
+ const canDateEvents = sourceStamp !== undefined || events.length === 0;
2027
+ const daemonProjection = stamp ? readSessionTimelineAny(session.id, stamp) : undefined;
2028
+ // A bounded tail read backs `messages` on EVERY warm cache hit (no fresh
2029
+ // parse this call), REGARDLESS of whether the daemon projection is already
2030
+ // present — this is what fixes a real bug review caught: a warm digest hit
2031
+ // whose daemon timeline HAD landed still fell through to the 2-message
2032
+ // digest summary below, because the tail-read used to be gated on the
2033
+ // on-demand-fold branch only. `messages` and the on-demand fold are two
2034
+ // independent uses of the same cheap, bounded events window.
2035
+ let foldEvents = events;
2036
+ if (foldEvents.length === 0 && session.filePath) {
2037
+ foldEvents = readSessionTail(session.filePath, session.agent);
2038
+ }
2039
+ let projection = daemonProjection;
2040
+ let onDemand = false;
2041
+ if (!projection && foldEvents.length > 0) {
2042
+ const state = foldTimeline(foldEvents, emptyTimelineState());
2043
+ projection = {
2044
+ request: state.request,
2045
+ timeline: projectTimeline(state, undefined),
2046
+ files: projectSessionFiles(state),
2047
+ };
2048
+ onDemand = true;
2049
+ }
2050
+ let messages;
2051
+ if (foldEvents.length > 0) {
2052
+ messages = foldEvents
2053
+ .filter((e) => e.type === 'message' && !e._synthetic && Boolean(e.content) && (e.role === 'user' || e.role === 'assistant'))
2054
+ .slice(-SESSION_DETAIL_MAX_MESSAGES)
2055
+ .map(e => ({ role: e.role, text: bound(e.content), at: e.timestamp ?? null }));
2056
+ }
2057
+ else {
2058
+ messages = [];
2059
+ if (digest?.firstUser)
2060
+ messages.push({ role: 'user', text: bound(digest.firstUser), at: session.timestamp ?? null });
2061
+ if (digest?.lastAssistant)
2062
+ messages.push({ role: 'assistant', text: bound(digest.lastAssistant), at: session.lastActivity ?? null });
2063
+ }
2064
+ const endStamp = sessionTranscriptStamp(session);
2065
+ const unchanged = canDateEvents && stamp !== undefined && endStamp !== undefined
2066
+ && stamp.fileMtimeMs === endStamp.fileMtimeMs && stamp.fileSize === endStamp.fileSize;
2067
+ const partial = Boolean(digest?.partial) || !daemonProjection || !unchanged;
2068
+ const reason = digest?.partialReason
2069
+ ?? (onDemand
2070
+ ? 'background timeline pass has not reached this session yet; request/timeline/files below are an on-demand bounded fold, not the full-history daemon projection'
2071
+ : (!projection ? 'no transcript available to fold request/timeline/files for this session' : null));
2072
+ return {
2073
+ request: projection?.request ?? null,
2074
+ timeline: projection?.timeline ?? null,
2075
+ files: projection?.files ?? null,
2076
+ messages,
2077
+ sourceRevision: unchanged ? new Date(stamp.fileMtimeMs).toISOString() : null,
2078
+ partial,
2079
+ reason,
2080
+ };
2081
+ }
1985
2082
  /** Resolve a session by id/query globally and print its compact preview (no pager).
1986
2083
  * Backs `--preview` — the fast path for the "peek before resume" hot loop. */
1987
2084
  export async function renderSessionPreview(query, scope) {
2085
+ // Exact ID + exactly one named, non-local device: the canonical bounded
2086
+ // preview loader (PHNX-3999) answers in ONE ssh hop plus a durable local
2087
+ // cache, instead of the general resolver's metadata fan-out followed by a
2088
+ // second render hop. Scoped tightly on purpose — a short id/label is not a
2089
+ // stable cache key across devices, and this never touches local discovery.
2090
+ //
2091
+ // Gated on `isCompleteSessionId`, NOT a bare-UUID regex: measured over this
2092
+ // fleet's own index, a "full session id" is a bare UUID for most harnesses
2093
+ // but `session_<uuid>` for kimi/rush and `ses_<ulid>` for opencode
2094
+ // (discover.ts). A bare-UUID-only check would silently skip this fast path
2095
+ // (falling through to the slower general resolver, never wrong, but wrong
2096
+ // to claim as "the fast path" for those harnesses) for a real, exact,
2097
+ // caller-supplied full id on those three.
2098
+ if (scope.json && !scope.local && scope.hosts?.length === 1
2099
+ && scope.hosts[0] !== machineId() && isCompleteSessionId(query.trim())) {
2100
+ const { getRemoteSessionPreview } = await import('../lib/session/remote-preview-cache.js');
2101
+ const result = await getRemoteSessionPreview(query.trim(), scope.hosts[0], {
2102
+ refresh: scope.refresh,
2103
+ revision: scope.revision,
2104
+ });
2105
+ const envelope = result.envelope;
2106
+ console.log(JSON.stringify({
2107
+ schemaVersion: 1,
2108
+ session: envelope?.session ?? null,
2109
+ // `active` is a live snapshot the PEER computed at the moment it ran
2110
+ // `sessions preview --local --json` — meaningful only for that instant.
2111
+ // When this response is served from the durable cache (`cache.source ===
2112
+ // 'cache'`, which can be up to 45s old, or indefinitely old under a
2113
+ // matched --revision), presenting that stale snapshot under the same
2114
+ // `active` key would read as the session's CURRENT working/idle status
2115
+ // when it may no longer be. Null it out rather than relabel it with an
2116
+ // age stamp: the caller already has `cache.fetchedAt`/`cache.stale` to
2117
+ // reason about staleness, and getting a fresh `active` read costs
2118
+ // another SSH round trip this fast path exists specifically to avoid.
2119
+ active: result.cache.source === 'live' ? (envelope?.active ?? null) : null,
2120
+ preview: envelope?.preview ?? null,
2121
+ error: envelope?.error ?? (envelope ? null : result.cache.reason),
2122
+ // The peer's own `sessions preview --local --json` already computed
2123
+ // `details` (request/timeline/files/messages) with its own bounded reader
2124
+ // (see buildSessionDetailBlock) — this hop just forwards it verbatim,
2125
+ // never re-derives it, so a metadata-only/no-transcript peer row's
2126
+ // explicit unavailable reason survives the hop unchanged.
2127
+ details: envelope?.details ?? null,
2128
+ cache: result.cache,
2129
+ }));
2130
+ return;
2131
+ }
1988
2132
  let outcome = await resolveSessionMetadataValue(query, scope);
1989
2133
  // resolveSessionMetadataValue already consults the live registry for a running
1990
2134
  // session with no transcript row (RUSH-2682), so a live session resolves above.
@@ -2059,7 +2203,8 @@ export async function renderSessionPreview(query, scope) {
2059
2203
  }
2060
2204
  catch { /* plain preview on any probe failure */ }
2061
2205
  if (scope.json) {
2062
- const { digest, error } = loadSessionPreviewDigest(session);
2206
+ const sourceStamp = sessionTranscriptStamp(session);
2207
+ const { digest, error, events } = loadSessionPreviewDigest(session);
2063
2208
  console.log(JSON.stringify({
2064
2209
  schemaVersion: 1,
2065
2210
  session: {
@@ -2095,6 +2240,7 @@ export async function renderSessionPreview(query, scope) {
2095
2240
  } : null,
2096
2241
  preview: digest ?? null,
2097
2242
  error: error ?? null,
2243
+ details: buildSessionDetailBlock(session, digest, events, sourceStamp),
2098
2244
  }));
2099
2245
  return;
2100
2246
  }
@@ -5370,7 +5516,9 @@ export function registerSessionsCommands(program) {
5370
5516
  .option('-p, --project <name>', 'Narrow the ID to one project')
5371
5517
  .option('--local', 'Only this machine; do not resolve the ID across the fleet')
5372
5518
  .option('-D, --device <target...>', 'Resolve only on the named device(s)')
5373
- .option('--json', 'Output the session preview as JSON');
5519
+ .option('--json', 'Output the session preview as JSON')
5520
+ .option('--refresh', 'Bypass the durable remote-preview cache and negative backoff for one bounded fetch (full ID + single --device only)')
5521
+ .option('--revision <cursor>', 'Opaque caller-owned activity cursor (any stable value YOU track, e.g. your own feed\'s lastActivityMs) -- passing the SAME value as your last call confirms nothing changed and serves the cache with zero SSH indefinitely; a different value fetches once, still subject to backoff unless --refresh is also set (full ID + single --device only)');
5374
5522
  setHelpSections(previewCmd, {
5375
5523
  examples: `
5376
5524
  # Preview by the 8-character ID shown in agents sessions
@@ -5382,11 +5530,15 @@ export function registerSessionsCommands(program) {
5382
5530
  # Stay on this machine or restrict the authoritative lookup to one peer
5383
5531
  agents sessions preview 407b8dd5 --local
5384
5532
  agents sessions preview 407b8dd5 --device zion
5533
+
5534
+ # Full ID + one --device: durable-cached fast path (PHNX-3999); force a fresh fetch
5535
+ agents sessions preview c70ecdea-6210-4039-9845-246a3a7a9942 --device zion --json --refresh
5385
5536
  `,
5386
5537
  notes: `
5387
5538
  - Full UUIDs are globally unique and may stop the fleet lookup at the first exact hit.
5388
5539
  - Short IDs wait for every selected device so ambiguity is never hidden.
5389
5540
  - Active status is refreshed through the bounded live-state TTL; transcript-derived details use the durable session index.
5541
+ - A full UUID with exactly one --device and --json is served from a local durable cache (~45s fresh window); the JSON envelope's "cache" field reports fresh/stale/offline state. --refresh forces one bounded re-fetch.
5390
5542
  `,
5391
5543
  });
5392
5544
  previewCmd.action(async (id) => {
@@ -5398,6 +5550,8 @@ export function registerSessionsCommands(program) {
5398
5550
  local: options.local,
5399
5551
  hosts: hosts.length > 0 ? hosts : undefined,
5400
5552
  json: options.json,
5553
+ refresh: options.refresh,
5554
+ revision: options.revision,
5401
5555
  });
5402
5556
  });
5403
5557
  registerSessionsTailCommand(sessionsCmd);
@@ -8,8 +8,8 @@
8
8
  * `npm i -g`. Users do not set extra env vars.
9
9
  */
10
10
  import type { Command } from 'commander';
11
- export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.4";
12
- export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.4";
11
+ export declare const SECRETS_CLI_PACKAGE = "@phnx-labs/secrets-cli@0.1.5";
12
+ export declare const INSTALL_HINT = "agents clis install secrets # or: npm i -g @phnx-labs/secrets-cli@0.1.5";
13
13
  export declare function setupSecretsPrefsPath(): string;
14
14
  /** True when the standalone `secrets` executable resolves ($SECRETS_BIN or PATH). */
15
15
  export declare function isSecretsCliInstalled(): boolean;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * `agents setup term` — install the standalone `term` CLI if missing (PHNX-4092).
3
+ *
4
+ * The PTY engine lives in `@phnx-labs/term-cli` (extracted PHNX-4091);
5
+ * agents-cli never rebundles it. The setup-token mint behind `agents accounts
6
+ * add` / `agents accounts login` (auth-mint.ts → term-driver.ts) spawns `term`
7
+ * on demand and fails loud when it is absent, so onboarding installs it here
8
+ * like every other standalone tool.
9
+ *
10
+ * Unlike browser/computer/secrets there is nothing to configure — no profile,
11
+ * no OS permission, no migrate step. A missing binary is a routine install
12
+ * (`agents clis install term` via the system `clis/term.yaml`, then a pinned
13
+ * `npm i -g`, both handled by installSetupTool), and once it is on PATH the
14
+ * tool is ready.
15
+ */
16
+ import type { Command } from 'commander';
17
+ /** True when the standalone `term` executable resolves ($TERM_BIN or PATH). */
18
+ export declare function isTermCliInstalled(): boolean;
19
+ /**
20
+ * Install the standalone `term` CLI if missing. Returns whether `term` is on
21
+ * PATH afterwards. There is no further onboarding — presence is readiness.
22
+ */
23
+ export declare function runTermWizard(): Promise<boolean>;
24
+ /** Register `agents setup term` under the parent `setup` command. */
25
+ export declare function registerSetupTermCommand(setupCmd: Command): void;