@phnx-labs/agents-cli 1.22.115 → 1.22.116

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 (88) hide show
  1. package/CHANGELOG.md +149 -14
  2. package/README.md +1 -1
  3. package/dist/commands/browser.js +11 -0
  4. package/dist/commands/exec.js +212 -141
  5. package/dist/commands/feed.js +65 -14
  6. package/dist/commands/sessions-picker.d.ts +11 -0
  7. package/dist/commands/sessions-picker.js +88 -7
  8. package/dist/commands/sessions.d.ts +21 -2
  9. package/dist/commands/sessions.js +157 -3
  10. package/dist/commands/setup-secrets.d.ts +2 -2
  11. package/dist/commands/setup-term.d.ts +24 -0
  12. package/dist/commands/setup-term.js +70 -0
  13. package/dist/commands/setup.d.ts +1 -1
  14. package/dist/commands/setup.js +12 -4
  15. package/dist/commands/ssh.js +1 -69
  16. package/dist/lib/accounting/rotate.d.ts +63 -1
  17. package/dist/lib/accounting/rotate.js +56 -0
  18. package/dist/lib/accounts/add.js +2 -2
  19. package/dist/lib/accounts/slots.js +32 -2
  20. package/dist/lib/answer-router.d.ts +11 -2
  21. package/dist/lib/answer-router.js +26 -2
  22. package/dist/lib/auth-mint.d.ts +5 -4
  23. package/dist/lib/auth-mint.js +4 -3
  24. package/dist/lib/browser/drivers/arc.d.ts +1 -1
  25. package/dist/lib/browser/service.d.ts +10 -0
  26. package/dist/lib/browser/service.js +208 -36
  27. package/dist/lib/browser/types.d.ts +18 -0
  28. package/dist/lib/config-keys.d.ts +1 -1
  29. package/dist/lib/config-keys.js +5 -0
  30. package/dist/lib/device-config.js +61 -0
  31. package/dist/lib/devices/doctor-findings.js +2 -6
  32. package/dist/lib/feed/answer.d.ts +153 -4
  33. package/dist/lib/feed/answer.js +716 -105
  34. package/dist/lib/feed/feed.d.ts +61 -1
  35. package/dist/lib/feed/feed.js +226 -14
  36. package/dist/lib/feed/hub-server.d.ts +58 -3
  37. package/dist/lib/feed/hub-server.js +306 -54
  38. package/dist/lib/feed/pr-status.d.ts +8 -0
  39. package/dist/lib/feed/pr-status.js +9 -1
  40. package/dist/lib/feed-outcome.d.ts +1 -1
  41. package/dist/lib/feed-outcome.js +9 -2
  42. package/dist/lib/feed-policy.js +9 -3
  43. package/dist/lib/fleet/auth-sync.d.ts +2 -55
  44. package/dist/lib/fleet/auth-sync.js +2 -89
  45. package/dist/lib/harness-auth-capabilities.js +7 -2
  46. package/dist/lib/hosts/dispatch.d.ts +20 -1
  47. package/dist/lib/hosts/dispatch.js +52 -30
  48. package/dist/lib/hosts/remote-cmd.d.ts +21 -0
  49. package/dist/lib/hosts/remote-cmd.js +26 -2
  50. package/dist/lib/mailbox.d.ts +12 -0
  51. package/dist/lib/mailbox.js +16 -2
  52. package/dist/lib/menubar/snapshot.d.ts +51 -0
  53. package/dist/lib/menubar/snapshot.js +42 -3
  54. package/dist/lib/open-url.js +2 -2
  55. package/dist/lib/projects.d.ts +23 -0
  56. package/dist/lib/projects.js +78 -0
  57. package/dist/lib/secrets-cli.d.ts +3 -3
  58. package/dist/lib/secrets-cli.js +1 -1
  59. package/dist/lib/session/active.d.ts +1 -0
  60. package/dist/lib/session/active.js +8 -0
  61. package/dist/lib/session/db.d.ts +67 -3
  62. package/dist/lib/session/db.js +381 -126
  63. package/dist/lib/session/prompt.d.ts +23 -7
  64. package/dist/lib/session/prompt.js +46 -8
  65. package/dist/lib/session/remote/remote-list.d.ts +20 -0
  66. package/dist/lib/session/remote/remote-list.js +22 -6
  67. package/dist/lib/session/remote/watch.d.ts +12 -0
  68. package/dist/lib/session/remote/watch.js +9 -0
  69. package/dist/lib/session/remote-preview-cache.d.ts +29 -0
  70. package/dist/lib/session/remote-preview-cache.js +373 -0
  71. package/dist/lib/session/tail.d.ts +50 -0
  72. package/dist/lib/session/tail.js +219 -0
  73. package/dist/lib/setup-tool-install.js +2 -1
  74. package/dist/lib/setup-tool-status.d.ts +1 -1
  75. package/dist/lib/setup-tool-status.js +6 -1
  76. package/dist/lib/signin-badge.d.ts +19 -4
  77. package/dist/lib/signin-badge.js +29 -11
  78. package/dist/lib/term-driver.d.ts +24 -0
  79. package/dist/lib/term-driver.js +36 -0
  80. package/dist/lib/terminal/index.d.ts +1 -1
  81. package/dist/lib/terminal/index.js +1 -1
  82. package/dist/lib/terminal/inject.d.ts +38 -0
  83. package/dist/lib/terminal/inject.js +55 -9
  84. package/dist/lib/terminal/transport.d.ts +15 -5
  85. package/dist/lib/terminal/transport.js +61 -11
  86. package/package.json +1 -1
  87. package/dist/lib/fleet/remote-login.d.ts +0 -170
  88. package/dist/lib/fleet/remote-login.js +0 -568
@@ -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,24 @@
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 OAuth device-code driver (`agents fleet
6
+ * login`, `agents auth mint`) spawns `term` on demand and fails loud when it is
7
+ * absent, so onboarding installs it here like every other standalone tool.
8
+ *
9
+ * Unlike browser/computer/secrets there is nothing to configure — no profile,
10
+ * no OS permission, no migrate step. A missing binary is a routine install
11
+ * (`agents clis install term` via the system `clis/term.yaml`, then a pinned
12
+ * `npm i -g`, both handled by installSetupTool), and once it is on PATH the
13
+ * tool is ready.
14
+ */
15
+ import type { Command } from 'commander';
16
+ /** True when the standalone `term` executable resolves ($TERM_BIN or PATH). */
17
+ export declare function isTermCliInstalled(): boolean;
18
+ /**
19
+ * Install the standalone `term` CLI if missing. Returns whether `term` is on
20
+ * PATH afterwards. There is no further onboarding — presence is readiness.
21
+ */
22
+ export declare function runTermWizard(): Promise<boolean>;
23
+ /** Register `agents setup term` under the parent `setup` command. */
24
+ export declare function registerSetupTermCommand(setupCmd: Command): void;
@@ -0,0 +1,70 @@
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 OAuth device-code driver (`agents fleet
6
+ * login`, `agents auth mint`) spawns `term` on demand and fails loud when it is
7
+ * absent, so onboarding installs it here like every other standalone tool.
8
+ *
9
+ * Unlike browser/computer/secrets there is nothing to configure — no profile,
10
+ * no OS permission, no migrate step. A missing binary is a routine install
11
+ * (`agents clis install term` via the system `clis/term.yaml`, then a pinned
12
+ * `npm i -g`, both handled by installSetupTool), and once it is on PATH the
13
+ * tool is ready.
14
+ */
15
+ import chalk from 'chalk';
16
+ import { isPromptCancelled } from './utils.js';
17
+ import { openSetupTerminal } from './setup-terminal.js';
18
+ import { resolveTermBin } from '../lib/term-client.js';
19
+ import { installSetupTool } from '../lib/setup-tool-install.js';
20
+ import { refreshToolSetup } from '../lib/setup-tool-status.js';
21
+ const INSTALL_HINT = 'agents clis install term # or: npm i -g @phnx-labs/term-cli';
22
+ /** True when the standalone `term` executable resolves ($TERM_BIN or PATH). */
23
+ export function isTermCliInstalled() {
24
+ return resolveTermBin() !== null;
25
+ }
26
+ /**
27
+ * Install the standalone `term` CLI if missing. Returns whether `term` is on
28
+ * PATH afterwards. There is no further onboarding — presence is readiness.
29
+ */
30
+ export async function runTermWizard() {
31
+ if (isTermCliInstalled()) {
32
+ console.log(chalk.green('The standalone `term` CLI is installed.'));
33
+ return true;
34
+ }
35
+ if (await installSetupTool('term')) {
36
+ console.log(chalk.green('Installed the standalone `term` CLI.'));
37
+ return true;
38
+ }
39
+ console.log(chalk.yellow('The standalone `term` CLI is not installed.'));
40
+ console.log(chalk.gray('Install it, then re-run `agents setup term`:'));
41
+ console.log(chalk.cyan(` ${INSTALL_HINT}`));
42
+ return false;
43
+ }
44
+ /** Register `agents setup term` under the parent `setup` command. */
45
+ export function registerSetupTermCommand(setupCmd) {
46
+ setupCmd
47
+ .command('term')
48
+ .description('Install the standalone `term` CLI (the PTY engine fleet login and auth mint spawn) if missing.')
49
+ .option('--install-only', 'Install the standalone term CLI (identical to the wizard; term needs no further setup)')
50
+ .option('--terminal [backend]', 'Open interactive setup in a detected or selected terminal')
51
+ .action(async (options) => {
52
+ try {
53
+ if (options.terminal !== undefined) {
54
+ await openSetupTerminal('term', options.terminal, options.installOnly);
55
+ return;
56
+ }
57
+ if (!(await runTermWizard()))
58
+ process.exitCode = 1;
59
+ await refreshToolSetup('term');
60
+ }
61
+ catch (err) {
62
+ if (isPromptCancelled(err)) {
63
+ console.log(chalk.yellow('\nCancelled'));
64
+ return;
65
+ }
66
+ console.error(chalk.red(err.message));
67
+ process.exitCode = 1;
68
+ }
69
+ });
70
+ }
@@ -31,7 +31,7 @@ export declare function ensureInitialized(program: Command): Promise<void>;
31
31
  * wizard. Never throws — a cancel or an optional wizard's error just skips the
32
32
  * rest and lets core setup complete.
33
33
  */
34
- type SetupPhase = 'browser' | 'computer' | 'secrets' | 'accounts' | 'fleet' | 'watchdog' | 'preferences';
34
+ type SetupPhase = 'browser' | 'computer' | 'secrets' | 'term' | 'accounts' | 'fleet' | 'watchdog' | 'preferences';
35
35
  type SetupStatusState = 'ready' | 'missing' | 'n/a';
36
36
  interface SetupStatusRow {
37
37
  phase: 'core' | SetupPhase;
@@ -22,6 +22,7 @@ import { registerSetupBrowserCommand, runBrowserWizard } from './setup-browser.j
22
22
  import { registerSetupComputerCommand, runComputerWizard } from './setup-computer.js';
23
23
  import { registerSetupMineCommand } from './setup-mine.js';
24
24
  import { registerSetupSecretsCommand } from './setup-secrets.js';
25
+ import { registerSetupTermCommand, runTermWizard, isTermCliInstalled } from './setup-term.js';
25
26
  import { registerSetupFleetCommand } from './setup-fleet.js';
26
27
  import { registerSetupAccountsCommand, runAccountsSetupWizard } from './setup-accounts.js';
27
28
  import { registerSetupWatchdogCommand, runWatchdogSetupWizard } from './setup-watchdog.js';
@@ -278,6 +279,7 @@ export async function getSetupStatus() {
278
279
  const devices = await loadDevices();
279
280
  const coreReady = isGitRepo(getAgentsDir());
280
281
  const secretsReady = fs.existsSync(setupSecretsPrefsPath());
282
+ const termReady = isTermCliInstalled();
281
283
  const minted = hasMintedSetupToken();
282
284
  const watchdogEnabled = getConfigValue('watchdog.enabled').value === true;
283
285
  const interactiveHost = getConfigValue('interactive.host').value;
@@ -287,6 +289,7 @@ export async function getSetupStatus() {
287
289
  { phase: 'browser', state: browserReady ? 'ready' : 'missing', detail: browserReady ? `profile ${browserProfile.name}` : browserProfile ? `profile ${browserProfile.name} cannot launch here` : installedBrowsers.length ? 'no default profile' : 'no supported browser found' },
288
290
  { phase: 'computer', state: computerState, detail: computerState === 'ready' ? 'helper trusted' : computerState === 'n/a' ? 'macOS local setup only' : 'helper not running or not trusted' },
289
291
  { phase: 'secrets', state: secretsReady ? 'ready' : 'missing', detail: secretsReady ? 'defaults chosen' : 'defaults not chosen' },
292
+ { phase: 'term', state: termReady ? 'ready' : 'missing', detail: termReady ? 'installed' : 'not installed (fleet login / auth mint spawn it)' },
290
293
  { phase: 'accounts', state: minted.ready ? 'ready' : 'missing', detail: minted.detail },
291
294
  { phase: 'fleet', state: Object.keys(devices).length ? 'ready' : 'missing', detail: Object.keys(devices).length ? `${Object.keys(devices).length} device${Object.keys(devices).length === 1 ? '' : 's'} registered` : 'no devices registered' },
292
295
  { phase: 'watchdog', state: watchdogEnabled ? 'ready' : 'missing', detail: watchdogEnabled ? 'enabled on this device' : 'disabled on this device' },
@@ -308,6 +311,8 @@ async function runSetupPhase(phase) {
308
311
  await runComputerWizard();
309
312
  else if (phase === 'secrets')
310
313
  await import('./setup-secrets.js').then((m) => m.runSecretsSetupWizard());
314
+ else if (phase === 'term')
315
+ await runTermWizard();
311
316
  else if (phase === 'accounts')
312
317
  await runAccountsSetupWizard();
313
318
  else if (phase === 'fleet')
@@ -366,11 +371,12 @@ export function registerSetupCommand(program) {
366
371
  .description('Set up agents-cli, or re-open the capability onboarding hub.')
367
372
  .option('-f, --force', 'Re-run setup even if ~/.agents/.system/ already exists (use with caution)')
368
373
  .option('--no-system-repo', 'Skip cloning the system repo (you must populate ~/.agents/.system/ yourself)');
369
- // Capability subcommands: `agents setup browser|computer|mine|secrets|accounts|fleet|alias|beta`.
374
+ // Capability subcommands: `agents setup browser|computer|term|mine|secrets|accounts|fleet|alias|beta`.
370
375
  // Artifact publishing is no longer set up here — it lives in the standalone
371
376
  // `artifacts` CLI (`artifacts share setup`/`join`, PHNX-3992).
372
377
  registerSetupBrowserCommand(setupCmd);
373
378
  registerSetupComputerCommand(setupCmd);
379
+ registerSetupTermCommand(setupCmd);
374
380
  registerSetupMineCommand(setupCmd);
375
381
  registerSetupSecretsCommand(setupCmd);
376
382
  registerSetupAccountsCommand(setupCmd);
@@ -399,15 +405,15 @@ export function registerSetupCommand(program) {
399
405
  `,
400
406
  });
401
407
  setupCmd.command('status')
402
- .description('Show setup readiness for core, browser, computer, secrets, accounts, fleet, watchdog, and preferences.')
408
+ .description('Show setup readiness for core, browser, computer, secrets, term, accounts, fleet, watchdog, and preferences.')
403
409
  .option('--json', 'print machine-readable JSON')
404
- .option('--tool <name>', 'Read cached standalone tool status: browser, computer, secrets, or all')
410
+ .option('--tool <name>', 'Read cached standalone tool status: browser, computer, secrets, term, or all')
405
411
  .option('--refresh', 'Explicitly refresh only the selected standalone tool health checks')
406
412
  .action(async (options) => {
407
413
  if (options.tool || options.refresh) {
408
414
  const selected = options.tool ?? 'all';
409
415
  if (selected !== 'all' && !SETUP_TOOLS.includes(selected))
410
- throw new Error('Unknown tool. Choose browser, computer, secrets, or all.');
416
+ throw new Error('Unknown tool. Choose browser, computer, secrets, term, or all.');
411
417
  const rows = options.refresh
412
418
  ? await refreshToolSetup(selected)
413
419
  : getCachedToolSetup().filter((row) => selected === 'all' || row.tool === selected);
@@ -438,6 +444,7 @@ export function registerSetupCommand(program) {
438
444
  agents setup browser
439
445
  agents setup computer
440
446
  agents setup secrets
447
+ agents setup term
441
448
  agents setup accounts
442
449
  agents setup fleet
443
450
  agents setup watchdog
@@ -455,6 +462,7 @@ export function registerSetupCommand(program) {
455
462
  agents setup browser # detect a browser + create the default profile
456
463
  agents setup computer # install the signed macOS helper + grant permissions
457
464
  agents setup secrets # choose secrets backend/policy defaults + import
465
+ agents setup term # install the standalone term CLI (PTY engine for fleet login / auth mint)
458
466
  agents setup accounts # mint a Claude setup-token for unattended usage/probe
459
467
  agents setup fleet # discover Tailscale devices + configure SSH access
460
468
  agents setup watchdog # choose which devices run the daemon watchdog pass