@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
@@ -295,15 +295,45 @@ export function classifyUserPrompt(raw, opts = {}) {
295
295
  return { clean: display, kind: 'text' };
296
296
  }
297
297
  /**
298
- * Collapse a harness-generated session title when it is injected skill
299
- * scaffolding; otherwise leave the title as the harness wrote it.
298
+ * A label that is nothing but a control command — `/clear`, `/compact`,
299
+ * `/model`, `/exit` — with no words of its own after it.
300
+ *
301
+ * One rule instead of a per-harness list of control verbs: a title that is a
302
+ * bare slash token describes an action taken ON the session, never what the
303
+ * session is about, so whatever it names it is not a headline. A command WITH an
304
+ * argument (`/continue fix the parser`, `/code:commit the retry fix`) carries
305
+ * real intent and is kept.
306
+ *
307
+ * Skill collapse runs first in {@link cleanGeneratedSessionLabel} and returns
308
+ * its own `/<skill>` form, so this never swallows that deliberate shape.
309
+ */
310
+ const BARE_CONTROL_COMMAND_RE = /^\/[\w:-]+$/;
311
+ /**
312
+ * Reduce a harness-generated session title to something worth showing as a
313
+ * headline, or `undefined` when it is not (PHNX-3999 F26/F27).
300
314
  *
301
315
  * Claude's `ai-title` and Cursor's `chatMeta.title` are both derived from the
302
- * first turn, so a session opened with a skill gets named after the injected
303
- * "Base directory for this skill: …" line. {@link classifyUserPrompt} reports
304
- * `kind: 'skill'` only for that line, so collapse it to `/<skill>`. Empty or
305
- * whitespace-only input yields `undefined` so the caller falls through to the
306
- * first-prompt topic.
316
+ * FIRST turn of the transcript, so whatever the harness injected there becomes
317
+ * the session's name: a skill's install-directory preamble, a `!`-prefixed shell
318
+ * echo wrapped in `<bash-input>`, a `/clear`. The owner's recording shows both
319
+ * failures — one row titled with shell-command XML wrappers, others reading
320
+ * `/clear`. Three rungs, in order:
321
+ *
322
+ * 1. A skill invocation collapses to `/<skill>` ({@link classifyUserPrompt}
323
+ * reports `kind: 'skill'` only for that injected line).
324
+ * 2. Harness scaffolding is REJECTED, not cleaned: `<bash-input>`,
325
+ * `<command-name>`, `<local-command-stdout>`, `<system-reminder>` and the
326
+ * rest of {@link isSyntheticUserMessage}'s cross-harness list, plus a title
327
+ * that is only tags. Stripping the tags would leave the shell command
328
+ * itself as the headline, which is the same wrong answer with tidier
329
+ * punctuation.
330
+ * 3. A bare control command is rejected ({@link BARE_CONTROL_COMMAND_RE}).
331
+ *
332
+ * A rejected label returns `undefined`, so the row falls to the next rung of the
333
+ * canonical ladder — the daemon-generated title, then the user's own first
334
+ * prompt (`sessionHeadline`, `session/title.ts`). Nothing is deleted: the
335
+ * original turn stays on the row as `firstUserMessage` / `request`, so the
336
+ * details view still shows exactly what the agent was told.
307
337
  *
308
338
  * A user-authored title (Claude `/rename` / `custom-title`) is never passed
309
339
  * here — the caller keeps it verbatim.
@@ -313,7 +343,15 @@ export function cleanGeneratedSessionLabel(title) {
313
343
  if (!trimmed)
314
344
  return undefined;
315
345
  const classified = classifyUserPrompt(trimmed);
316
- return classified.kind === 'skill' ? classified.clean : trimmed;
346
+ if (classified.kind === 'skill')
347
+ return classified.clean;
348
+ if (isSyntheticUserMessage(trimmed))
349
+ return undefined;
350
+ if (!stripXmlLikeTags(trimmed).trim())
351
+ return undefined;
352
+ if (BARE_CONTROL_COMMAND_RE.test(trimmed))
353
+ return undefined;
354
+ return trimmed;
317
355
  }
318
356
  /**
319
357
  * Extract a one-line topic from a raw user message, or undefined if the message
@@ -124,6 +124,26 @@ export declare function resolvePeerTarget(machine: string): Promise<{
124
124
  * error, timeout, version-skewed peer with no `--json` preview envelope.
125
125
  */
126
126
  export declare function fetchPeerPreviewDigest(sessionId: string, machine: string, timeoutMs?: number): Promise<unknown | undefined>;
127
+ export type PeerPreviewEnvelopeResult = {
128
+ ok: true;
129
+ envelope: unknown;
130
+ } | {
131
+ ok: false;
132
+ reason: 'no-target' | 'unreachable' | 'invalid-json';
133
+ };
134
+ /**
135
+ * Fetch the FULL `agents sessions preview <id> --local --json` envelope
136
+ * (session/active/preview/error, not just the `.preview` slice
137
+ * {@link fetchPeerPreviewDigest} narrows to) from a session's owning peer in
138
+ * exactly ONE bounded {@link sshCapture} hop. This is the canonical exact
139
+ * ID+owner preview loader (PHNX-3999): the peer's own command already runs the
140
+ * owner-side bounded parsers/fold (`loadSessionPreviewDigest`) and existing
141
+ * redaction, so a caller that already knows the owning device needs no
142
+ * separate metadata round trip before this one — unlike the general fleet
143
+ * resolver, which fans out a `sessions <id> --json --all` metadata query first
144
+ * because it does NOT yet know which peer (if any) holds the id.
145
+ */
146
+ export declare function fetchPeerPreviewEnvelope(sessionId: string, machine: string, timeoutMs?: number): Promise<PeerPreviewEnvelopeResult>;
127
147
  /** Parse the JSON envelope returned by `sessions preview --json`. */
128
148
  export declare function parsePeerPreviewDigest(parsed: unknown): unknown | undefined;
129
149
  /**
@@ -540,21 +540,37 @@ const PEER_PREVIEW_TIMEOUT_MS = 15_000;
540
540
  * error, timeout, version-skewed peer with no `--json` preview envelope.
541
541
  */
542
542
  export async function fetchPeerPreviewDigest(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
543
+ const envelope = await fetchPeerPreviewEnvelope(sessionId, machine, timeoutMs);
544
+ if (!envelope.ok)
545
+ return undefined;
546
+ return parsePeerPreviewDigest(envelope.envelope);
547
+ }
548
+ /**
549
+ * Fetch the FULL `agents sessions preview <id> --local --json` envelope
550
+ * (session/active/preview/error, not just the `.preview` slice
551
+ * {@link fetchPeerPreviewDigest} narrows to) from a session's owning peer in
552
+ * exactly ONE bounded {@link sshCapture} hop. This is the canonical exact
553
+ * ID+owner preview loader (PHNX-3999): the peer's own command already runs the
554
+ * owner-side bounded parsers/fold (`loadSessionPreviewDigest`) and existing
555
+ * redaction, so a caller that already knows the owning device needs no
556
+ * separate metadata round trip before this one — unlike the general fleet
557
+ * resolver, which fans out a `sessions <id> --json --all` metadata query first
558
+ * because it does NOT yet know which peer (if any) holds the id.
559
+ */
560
+ export async function fetchPeerPreviewEnvelope(sessionId, machine, timeoutMs = PEER_PREVIEW_TIMEOUT_MS) {
543
561
  const peer = await resolvePeerTarget(machine);
544
562
  if (!peer)
545
- return undefined;
563
+ return { ok: false, reason: 'no-target' };
546
564
  const cmd = remoteListCommand(['sessions', 'preview', sessionId, '--local', '--json'], peer.os);
547
565
  const capture = await sshCapture(peer.target, cmd, timeoutMs);
548
566
  if (capture.code !== 0)
549
- return undefined;
550
- let parsed;
567
+ return { ok: false, reason: 'unreachable' };
551
568
  try {
552
- parsed = JSON.parse(stripClixml(capture.stdout));
569
+ return { ok: true, envelope: JSON.parse(stripClixml(capture.stdout)) };
553
570
  }
554
571
  catch {
555
- return undefined;
572
+ return { ok: false, reason: 'invalid-json' };
556
573
  }
557
- return parsePeerPreviewDigest(parsed);
558
574
  }
559
575
  /** Parse the JSON envelope returned by `sessions preview --json`. */
560
576
  export function parsePeerPreviewDigest(parsed) {
@@ -59,6 +59,18 @@ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn' | 'cont
59
59
  branch?: string;
60
60
  rowKey: string;
61
61
  sourceDevice: string;
62
+ /**
63
+ * The registered project this session's working directory belongs to, or
64
+ * `null` when the association is not confirmed (PHNX-3999 F08/F09).
65
+ *
66
+ * This is what a UI groups by. It is deliberately not `project`: that field is
67
+ * a bucket KEY derived from the path (the basename of the cwd, so it always
68
+ * answers something), which is how a loose or unrelated directory became its
69
+ * own project group. `null` means Uncategorized — the row is still listed, it
70
+ * just is not filed under a project nobody bound it to. Resolved on the device
71
+ * that owns the path, since a definition's root is a local path.
72
+ */
73
+ confirmedProject: string | null;
62
74
  /** Durable index rows are kept on the stream under a distinct identity so a
63
75
  * live row can replace/disappear without erasing its recoverable history. */
64
76
  previous: boolean;
@@ -1,4 +1,5 @@
1
1
  import { SessionProjection } from '../projection.js';
2
+ import { confirmedProjectForCwd } from '../../projects.js';
2
3
  import { createHash, randomUUID } from 'node:crypto';
3
4
  import * as fs from 'node:fs';
4
5
  import * as path from 'node:path';
@@ -40,6 +41,11 @@ export function toSessionWatchRow(scope, row) {
40
41
  // no computed summary reads `pending` when the summarizer is on, `skipped`
41
42
  // when it is off (PHNX-3939). goal/checkpoints/summaryChecklist rode `...row`.
42
43
  summaryState: resolveStreamSummaryState(row.summaryState),
44
+ // The CONFIRMED project this work belongs to, or null for Uncategorized
45
+ // (PHNX-3999 F08/F09). Only a registered project definition counts — a
46
+ // consumer must never group by a directory basename, which is how unrelated
47
+ // and unbound directories became their own "projects".
48
+ confirmedProject: confirmedProjectForCwd(row.cwd) ?? null,
43
49
  rowKey,
44
50
  sourceDevice: scope,
45
51
  previous,
@@ -104,7 +110,10 @@ export function toPreviousSessionWatchRow(scope, session) {
104
110
  ...(session.harness ? { harness: session.harness } : {}),
105
111
  sessionId: session.id,
106
112
  ...(session.cwd ? { cwd: session.cwd } : {}),
113
+ // `project` stays the historical bucket KEY rows are joined on; the grouping a
114
+ // person sees comes from the confirmed association (PHNX-3999 F08/F09).
107
115
  ...(session.project ? { project: session.project } : {}),
116
+ confirmedProject: confirmedProjectForCwd(session.cwd) ?? null,
108
117
  // Same headline ladder as a live row (`deriveSessionRecap`, PHNX-3797):
109
118
  // `/rename` label → the daemon-generated title → `request.headline` (the
110
119
  // user's own sentence with attachment noise pulled out, PHNX-3939) →
@@ -0,0 +1,29 @@
1
+ /** Bounded, single-owner preview fetch with a durable requester cache and cross-process coalescing. */
2
+ import { fetchPeerPreviewEnvelope } from './remote/remote-list.js';
3
+ export type RemotePreviewCacheState = 'fresh' | 'stale-offline' | 'stale-error' | 'no-cache-offline' | 'no-cache-error' | 'invalid-id';
4
+ export interface RemotePreviewOutcome {
5
+ envelope?: unknown;
6
+ cache: {
7
+ source: 'live' | 'cache';
8
+ fetchedAt: number | null;
9
+ stale: boolean;
10
+ state: RemotePreviewCacheState;
11
+ device: string;
12
+ reason: string | null;
13
+ };
14
+ }
15
+ /** A changed caller cursor invalidates a good copy; failed attempts preserve both its content and cursor. */
16
+ export interface RemotePreviewDeps {
17
+ /** Defaults to the real bounded SSH transport ({@link fetchPeerPreviewEnvelope}).
18
+ * Overridable so the cache/backoff/revision/lease STATE MACHINE can be tested
19
+ * against a real SQLite DB without a live peer — this repo has no fleet in
20
+ * CI, and the actual bounded-transport behavior (timeout, byte cap) is
21
+ * already covered where `fetchPeerPreviewEnvelope`/`sshCapture` are defined. */
22
+ fetchEnvelope: typeof fetchPeerPreviewEnvelope;
23
+ }
24
+ export declare function getRemoteSessionPreview(sessionId: string, device: string, opts?: {
25
+ refresh?: boolean;
26
+ revision?: string;
27
+ now?: number;
28
+ timeoutMs?: number;
29
+ }, deps?: RemotePreviewDeps): Promise<RemotePreviewOutcome>;
@@ -0,0 +1,373 @@
1
+ /** Bounded, single-owner preview fetch with a durable requester cache and cross-process coalescing. */
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import lockfile from 'proper-lockfile';
6
+ import { normalizeHost } from '../machine-id.js';
7
+ import { getCacheDir } from '../state.js';
8
+ import { isCompleteSessionId } from './discover.js';
9
+ import { fetchPeerPreviewEnvelope, } from './remote/remote-list.js';
10
+ import { readRemotePreviewCache, writeRemotePreviewCacheFailure, writeRemotePreviewCacheSuccess, withSessionDBTimeout, REMOTE_PREVIEW_ENVELOPE_MAX_BYTES, } from './db.js';
11
+ /** How long a successful fetch is served with zero SSH on an ordinary call. */
12
+ const REMOTE_PREVIEW_FRESH_MS = 45_000;
13
+ /** Exponential backoff after a failed fetch, capped, so a persistently
14
+ * unreachable/erroring peer is not re-dialed on every call. */
15
+ function nextAttemptBackoffMs(consecutiveFailures) {
16
+ const capped = Math.min(consecutiveFailures, 6);
17
+ return Math.min(30_000 * 2 ** (capped - 1), 30 * 60_000);
18
+ }
19
+ /**
20
+ * Overall interactive budget for a call that must actually dial the peer:
21
+ * lease-wait + SSH attempt. A caller that provides its own `timeoutMs` still
22
+ * has this outer ceiling applied, since the lease-wait time is additive to it.
23
+ */
24
+ const REMOTE_PREVIEW_TOTAL_DEADLINE_MS = 10_000;
25
+ const CACHE_BUSY_TIMEOUT_MS = 250;
26
+ /** Default SSH attempt bound for this fast path specifically — tighter than
27
+ * the general-purpose picker's `PEER_PREVIEW_TIMEOUT_MS` (15s), which is
28
+ * tuned for an interactive "peek" the user is already waiting on, not a
29
+ * budget-conscious automated caller. */
30
+ const REMOTE_PREVIEW_SSH_TIMEOUT_MS = 8_000;
31
+ function cacheKey(device, sessionId) {
32
+ return `${device}::${sessionId}`;
33
+ }
34
+ const defaultDeps = { fetchEnvelope: fetchPeerPreviewEnvelope };
35
+ export async function getRemoteSessionPreview(sessionId, device, opts = {}, deps = defaultDeps) {
36
+ if (!isCompleteSessionId(sessionId)) {
37
+ return {
38
+ cache: {
39
+ source: 'cache', fetchedAt: null, stale: false, state: 'invalid-id',
40
+ device: normalizeHost(device),
41
+ reason: 'sessions preview --device requires a full session id (bare UUID, session_<uuid>, or ses_<ulid>) for the durable-cache fast path',
42
+ },
43
+ };
44
+ }
45
+ const normalizedDevice = normalizeHost(device);
46
+ try {
47
+ return await fetchOrServe(sessionId, normalizedDevice, opts, deps);
48
+ }
49
+ catch {
50
+ return noCacheOutcome(normalizedDevice, 'The session preview cache is unavailable. Try again.');
51
+ }
52
+ }
53
+ /** Cap on a caller-supplied `--revision` value: it's an opaque cursor, never
54
+ * free text, so this is a sanity bound, not a format check. */
55
+ const REVISION_MAX_CHARS = 128;
56
+ function isValidRevision(revision) {
57
+ return revision.length > 0 && revision.length <= REVISION_MAX_CHARS;
58
+ }
59
+ async function fetchOrServe(sessionId, device, opts, deps) {
60
+ const deadlineAt = Date.now() + REMOTE_PREVIEW_TOTAL_DEADLINE_MS;
61
+ const accessCache = (operation) => withSessionDBTimeout(Math.max(0, Math.min(CACHE_BUSY_TIMEOUT_MS, deadlineAt - Date.now())), operation);
62
+ const now = opts.now ?? Date.now();
63
+ const readCache = () => {
64
+ const stored = accessCache(() => readRemotePreviewCache(device, sessionId));
65
+ return stored?.ok && !validateEnvelope(stored.envelope, sessionId, device).ok
66
+ ? { ...stored, ok: false, envelope: undefined } : stored;
67
+ };
68
+ const cached = readCache();
69
+ const recordFailure = (reason) => {
70
+ try {
71
+ accessCache(() => writeRemotePreviewCacheFailure(device, sessionId, reason, nextAttemptBackoffMs, opts.now ?? Date.now()));
72
+ }
73
+ catch { /* The response still carries the failure and any previously read content. */ }
74
+ };
75
+ const revision = opts.revision !== undefined && isValidRevision(opts.revision) ? opts.revision : undefined;
76
+ if (!opts.refresh && cached?.ok && cached.consecutiveFailures === 0) {
77
+ const revisionConfirmedUnchanged = revision !== undefined
78
+ && cached.lastCallerRevision !== undefined
79
+ && revision === cached.lastCallerRevision;
80
+ // A revision was supplied and there is no prior recorded caller revision,
81
+ // or it DIFFERS: skip the TTL bypass below (activity-driven invalidation)
82
+ // and fall through to a real attempt, still subject to backoff. No
83
+ // revision supplied at all: fall back to the plain TTL freshness window,
84
+ // unchanged from before `--revision` existed.
85
+ const age = now - cached.fetchedAt;
86
+ const withinFreshWindow = revision === undefined && age >= 0 && age < REMOTE_PREVIEW_FRESH_MS;
87
+ if (revisionConfirmedUnchanged || withinFreshWindow) {
88
+ if (validateEnvelope(cached.envelope, sessionId, device).ok)
89
+ return freshFromCache(cached, device);
90
+ }
91
+ }
92
+ if (!opts.refresh && cached && now < cached.nextAttemptAt) {
93
+ // Still inside the negative-backoff window from a recent failure: honor
94
+ // it rather than dialing again, and serve whatever the cache holds.
95
+ const reason = cached.failureReason ?? 'peer unreachable';
96
+ return cached.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
97
+ }
98
+ // From here on we are actually about to dial the peer. This is the
99
+ // realistic duplication case (one CLI process per user action, e.g. the
100
+ // Menu worker shelling out a fresh `agents sessions preview` per click), so
101
+ // this takes a bounded, OS-visible lease keyed on (device, sessionId) —
102
+ // never an in-process-only guard, which cannot help two separate processes.
103
+ const beforeFetchedAt = cached?.fetchedAt ?? 0;
104
+ const timeoutMs = opts.timeoutMs ?? REMOTE_PREVIEW_SSH_TIMEOUT_MS;
105
+ const priorConsecutiveFailures = cached?.consecutiveFailures ?? 0;
106
+ const attempt = async (remainingMs) => {
107
+ const afterLease = readCache();
108
+ if (afterLease?.ok && afterLease.consecutiveFailures === 0 && afterLease.fetchedAt > beforeFetchedAt) {
109
+ // Another process completed a SUCCESSFUL fetch for this exact
110
+ // (device, id) while we waited for the lease — reuse it. True under
111
+ // --refresh too: refresh means "current data now", and data fetched a
112
+ // moment ago while we queued already IS current.
113
+ return freshFromCache(afterLease, device);
114
+ }
115
+ if (!opts.refresh && afterLease && now < afterLease.nextAttemptAt) {
116
+ // Another process just recorded a FAILURE (and its backoff) while we
117
+ // waited: honor it rather than firing a second attempt right behind it.
118
+ return afterLease.ok
119
+ ? staleFromCache(afterLease, device, afterLease.failureReason ?? 'peer unreachable')
120
+ : noCacheOutcome(device, afterLease.failureReason ?? 'peer unreachable');
121
+ }
122
+ if (opts.refresh && afterLease
123
+ && afterLease.consecutiveFailures > priorConsecutiveFailures) {
124
+ // Even under --refresh (which otherwise ignores backoff): another
125
+ // process's OWN refresh attempt just failed while we waited for the
126
+ // lease. Reuse that failure rather than immediately trying a third
127
+ // time — "explicit retry" still means at most one attempt per genuinely
128
+ // concurrent request, not one per caller.
129
+ const reason = afterLease.failureReason ?? 'peer unreachable';
130
+ return afterLease.ok ? staleFromCache(afterLease, device, reason) : noCacheOutcome(device, reason);
131
+ }
132
+ const fetchBudget = Math.min(timeoutMs, remainingMs, deadlineAt - Date.now() - CACHE_BUSY_TIMEOUT_MS - 100);
133
+ if (fetchBudget <= 0)
134
+ return cached?.ok
135
+ ? staleFromCache(cached, device, 'another request for this session was already in flight')
136
+ : noCacheOutcome(device, 'another request for this session was already in flight');
137
+ const result = await deps.fetchEnvelope(sessionId, device, fetchBudget);
138
+ if (!result.ok) {
139
+ const reason = describeFailure(result);
140
+ recordFailure(reason);
141
+ if (cached?.ok)
142
+ return staleFromCache(cached, device, reason);
143
+ return noCacheOutcome(device, reason);
144
+ }
145
+ const validation = validateEnvelope(result.envelope, sessionId, device);
146
+ if (!validation.ok) {
147
+ // A peer answered, but the payload doesn't check out (wrong session id,
148
+ // wrong schema version, malformed shape — a version-skewed or
149
+ // misbehaving peer). Never cache it under this id/device key, and never
150
+ // pass it through as if it were a genuine success.
151
+ recordFailure(validation.reason);
152
+ if (cached?.ok)
153
+ return staleFromCache(cached, device, validation.reason);
154
+ return noCacheOutcome(device, validation.reason);
155
+ }
156
+ const envelopeBytes = Buffer.byteLength(JSON.stringify(result.envelope), 'utf8');
157
+ if (envelopeBytes > REMOTE_PREVIEW_ENVELOPE_MAX_BYTES) {
158
+ // The live fetch genuinely succeeded, but the payload is too large to
159
+ // persist AND too large to hand back unbounded. Never silently pass an
160
+ // oversized blob through as "fresh" — degrade to the last-good stale
161
+ // copy (explicitly labeled) when one exists, else an explicit bounded
162
+ // error, and do not cache the oversized response either way.
163
+ const reason = `peer response was ${envelopeBytes} bytes, over the ${REMOTE_PREVIEW_ENVELOPE_MAX_BYTES}-byte bounded-cache limit`;
164
+ recordFailure(reason);
165
+ if (cached?.ok)
166
+ return staleFromCache(cached, device, reason);
167
+ return noCacheOutcome(device, reason);
168
+ }
169
+ const fetchedAt = Math.max(opts.now ?? Date.now(), (afterLease?.fetchedAt ?? 0) + 1);
170
+ let persistenceReason = null;
171
+ try {
172
+ accessCache(() => writeRemotePreviewCacheSuccess(device, sessionId, result.envelope, fetchedAt, revision));
173
+ }
174
+ catch {
175
+ persistenceReason = 'Session details loaded, but could not be saved for offline use.';
176
+ }
177
+ return {
178
+ envelope: result.envelope,
179
+ cache: { source: 'live', fetchedAt, stale: false, state: 'fresh', device, reason: persistenceReason },
180
+ };
181
+ };
182
+ try {
183
+ return await withBoundedRemoteLease(device, sessionId, timeoutMs, deadlineAt, attempt, () => {
184
+ // Could not acquire the lease within the interactive budget (another
185
+ // process is holding it, presumably mid-fetch, longer than our deadline).
186
+ // Degrade to whatever the cache holds rather than piling on a second SSH
187
+ // attempt — bounded wait, no serial redial, no unbounded queueing.
188
+ const current = cached;
189
+ if (current?.ok)
190
+ return staleFromCache(current, device, 'another request for this session was already in flight');
191
+ return noCacheOutcome(device, 'another request for this session was already in flight and did not finish in time');
192
+ });
193
+ }
194
+ catch {
195
+ const reason = 'The session preview cache is unavailable. Try again.';
196
+ return cached?.ok ? staleFromCache(cached, device, reason) : noCacheOutcome(device, reason);
197
+ }
198
+ }
199
+ /**
200
+ * Validate a peer's response before it is trusted at all: schema-version 1,
201
+ * a `session` object present, its `id` an EXACT non-empty string match for
202
+ * the id we asked for (never missing/null/numeric/mismatched), and — when the
203
+ * peer names its own machine — that name must agree with the device we
204
+ * actually dialed. Without these checks a version-skewed, misbehaving, or
205
+ * misconfigured peer's response could be cached under the WRONG key (this
206
+ * caller's requested sessionId) or attributed to the wrong owning device,
207
+ * poisoning a later read for a completely different session/device pair.
208
+ */
209
+ function validateEnvelope(envelope, sessionId, device) {
210
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
211
+ const text = (value) => typeof value === 'string' && value.trim().length > 0;
212
+ const string = (value) => typeof value === 'string';
213
+ const number = (value) => typeof value === 'number' && Number.isFinite(value);
214
+ const integer = (value) => Number.isSafeInteger(value);
215
+ const boolean = (value) => typeof value === 'boolean';
216
+ const shape = (fields) => value => object(value)
217
+ && Object.entries(fields).every(([key, check]) => value[key] == null || check(value[key]));
218
+ const array = (check) => value => Array.isArray(value) && value.every(check);
219
+ const artifact = shape({ path: string, basename: string, bucket: string });
220
+ const request = shape({ kind: string, text: string, headline: string, turns: integer });
221
+ const step = shape({ text: string, at: string, source: string, live: boolean, now: string,
222
+ mix: value => object(value) && Object.values(value).every(integer) });
223
+ const timeline = shape({ tools: integer, failed: integer, blocked: integer, spanMs: number,
224
+ state: string, reason: string, steps: array(step), earlier: shape({ steps: integer, tools: integer, failed: integer }) });
225
+ const files = shape({ total: integer, source: string,
226
+ changes: array(shape({ path: string, op: string, edits: integer, at: string })) });
227
+ const preview = shape({ firstUser: string, lastAssistant: string, artifacts: array(artifact) });
228
+ const details = shape({ sourceRevision: string, reason: string, partial: boolean, request, timeline, files,
229
+ messages: array(value => object(value) && typeof value.role === 'string' && ['user', 'assistant'].includes(value.role)
230
+ && string(value.text) && (value.at == null || string(value.at))) });
231
+ if (!object(envelope))
232
+ return { ok: false, reason: 'The device returned a preview that was not a JSON object.' };
233
+ if (envelope.schemaVersion !== 1)
234
+ return { ok: false, reason: 'The device returned an unsupported preview schema.' };
235
+ if (!object(envelope.session) || envelope.session.id !== sessionId)
236
+ return { ok: false, reason: 'The device returned a preview for a different or missing session ID.' };
237
+ if (!shape({ agent: string, machine: string, project: string, cwd: string, title: string,
238
+ lastActivity: string, lastActivityMs: number })(envelope.session))
239
+ return { ok: false, reason: 'The device returned malformed session metadata.' };
240
+ if (envelope.cache != null && !object(envelope.cache))
241
+ return { ok: false, reason: 'The device returned malformed cache metadata.' };
242
+ const owners = [envelope.session.machine, object(envelope.cache) ? envelope.cache.device : undefined]
243
+ .filter(value => value !== undefined);
244
+ if (owners.length === 0 || owners.some(owner => typeof owner !== 'string' || !owner.trim() || normalizeHost(owner) !== device))
245
+ return { ok: false, reason: 'The device returned a preview without a matching owner.' };
246
+ if ((envelope.preview != null && !preview(envelope.preview))
247
+ || (envelope.details != null && !details(envelope.details))
248
+ || (envelope.active != null && !shape({ status: string, lastActivityMs: number })(envelope.active))
249
+ || (envelope.error != null && !string(envelope.error)))
250
+ return { ok: false, reason: 'The device returned malformed session details.' };
251
+ const digest = object(envelope.preview) ? envelope.preview : {};
252
+ const detail = object(envelope.details) ? envelope.details : {};
253
+ const hasContent = text(digest.firstUser) || text(digest.lastAssistant)
254
+ || (Array.isArray(detail.messages) && detail.messages.length > 0)
255
+ || (object(detail.request) && text(detail.request.text))
256
+ || (object(detail.timeline) && Array.isArray(detail.timeline.steps) && detail.timeline.steps.length > 0)
257
+ || (object(detail.files) && Array.isArray(detail.files.changes) && detail.files.changes.length > 0)
258
+ || (Array.isArray(digest.artifacts) && digest.artifacts.length > 0);
259
+ if (!hasContent)
260
+ return { ok: false, reason: 'The device has no recorded session details available yet.' };
261
+ return { ok: true };
262
+ }
263
+ /** Cross-process lock target for one (device, sessionId) pair. Deliberately a
264
+ * separate lock namespace from `refresh-coordinator.ts` — different SLA,
265
+ * different consumers, no reason to share contention. */
266
+ function leaseTarget(device, sessionId) {
267
+ const digest = createHash('sha256').update(`${device}::${sessionId}`).digest('hex');
268
+ return path.join(getCacheDir(), 'remote-preview-locks', `${digest}.lock`);
269
+ }
270
+ /** Lock wait, transport, and cache writes share one deadline; no pending retry outlives the call. */
271
+ async function withBoundedRemoteLease(device, sessionId, fetchTimeoutMs, deadlineAt, fn, onDeadline) {
272
+ const target = leaseTarget(device, sessionId);
273
+ await fs.mkdir(path.dirname(target), { recursive: true });
274
+ try {
275
+ await fs.writeFile(target, '', { flag: 'wx', mode: 0o600 });
276
+ }
277
+ catch (error) {
278
+ if (error?.code !== 'EEXIST')
279
+ throw error;
280
+ }
281
+ // Stale slightly above the fetch's own bound: a genuinely dead holder
282
+ // (crashed mid-fetch) is reclaimed soon after its own attempt would have
283
+ // timed out anyway; a live holder's lock never goes stale mid-fetch.
284
+ const staleMs = fetchTimeoutMs + 2_000;
285
+ let release;
286
+ while (!release && Date.now() < deadlineAt) {
287
+ try {
288
+ release = await lockfile.lock(target, {
289
+ realpath: false, stale: staleMs, update: staleMs / 4, retries: 0,
290
+ });
291
+ }
292
+ catch (error) {
293
+ if (error?.code !== 'ELOCKED')
294
+ throw error;
295
+ const remaining = deadlineAt - Date.now();
296
+ if (remaining > 0)
297
+ await new Promise(resolve => setTimeout(resolve, Math.min(100, remaining)));
298
+ }
299
+ }
300
+ if (!release)
301
+ return onDeadline();
302
+ const remainingMs = deadlineAt - Date.now();
303
+ if (remainingMs <= 0) {
304
+ // Acquired right at the wire: no budget left to do any real work with it.
305
+ if (release) {
306
+ await release();
307
+ await cleanupLeaseFile(target);
308
+ }
309
+ return onDeadline();
310
+ }
311
+ try {
312
+ return await fn(remainingMs);
313
+ }
314
+ finally {
315
+ if (release) {
316
+ await release();
317
+ await cleanupLeaseFile(target);
318
+ }
319
+ }
320
+ }
321
+ /** Best-effort removal of the lease marker file after release, so the
322
+ * lock directory doesn't grow one file per (device, sessionId) pair this box
323
+ * has ever previewed. Self-healing either way: `withBoundedRemoteLease`
324
+ * recreates it on demand (`{ flag: 'wx' }` — a no-op if it's still there). */
325
+ async function cleanupLeaseFile(target) {
326
+ try {
327
+ await fs.unlink(target);
328
+ }
329
+ catch {
330
+ // Another concurrent caller may already be using it, or it's already
331
+ // gone — either way, nothing to do.
332
+ }
333
+ }
334
+ function describeFailure(result) {
335
+ switch (result.reason) {
336
+ case 'no-target': return 'device is not a registered, dialable peer';
337
+ case 'unreachable': return 'device did not answer within the bounded window';
338
+ case 'invalid-json': return 'device returned an unparsable response (version skew?)';
339
+ default: return 'unknown failure';
340
+ }
341
+ }
342
+ function freshFromCache(cached, device) {
343
+ return {
344
+ envelope: cached.envelope,
345
+ cache: { source: 'cache', fetchedAt: cached.fetchedAt, stale: false, state: 'fresh', device, reason: null },
346
+ };
347
+ }
348
+ function staleFromCache(cached, device, reason) {
349
+ const offline = reason === 'device did not answer within the bounded window' || reason === 'backoff'
350
+ || reason === 'another request for this session was already in flight';
351
+ return {
352
+ envelope: cached.envelope,
353
+ cache: {
354
+ source: 'cache',
355
+ fetchedAt: cached.fetchedAt,
356
+ stale: true,
357
+ state: offline ? 'stale-offline' : 'stale-error',
358
+ device,
359
+ reason,
360
+ },
361
+ };
362
+ }
363
+ function noCacheOutcome(device, reason) {
364
+ const offline = reason.includes('unreachable') || reason.includes('not answer') || reason === 'backoff'
365
+ || reason.includes('already in flight');
366
+ return {
367
+ cache: {
368
+ source: 'cache', fetchedAt: null, stale: false,
369
+ state: offline ? 'no-cache-offline' : 'no-cache-error',
370
+ device, reason,
371
+ },
372
+ };
373
+ }
@@ -35,4 +35,54 @@ export declare function readSessionTailWithRaw(filePath: string, agent: SessionA
35
35
  * wrapper over {@link readSessionTailWithRaw} for callers that only need events.
36
36
  */
37
37
  export declare function readSessionTail(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
38
+ /**
39
+ * Read the FIRST `maxBytes` of a JSONL transcript as cleaned text — the mirror
40
+ * of {@link readSessionTailContent}, for recovering the session's ACTUAL
41
+ * original request boundedly when it isn't already indexed
42
+ * (`SessionMeta.firstUserMessage`). Returns '' on any error, an empty file, or
43
+ * a first record too large to fit `maxBytes` (bare — no elision fallback; see
44
+ * {@link readSessionHeadContentBounded} for that).
45
+ */
46
+ export declare function readSessionHeadContent(filePath: string, maxBytes?: number): string;
47
+ /**
48
+ * Recover the transcript's opening JSONL record even when it doesn't fit the
49
+ * plain {@link DEFAULT_HEAD_MAX_BYTES} chunk — the case a first turn carrying
50
+ * a multi-megabyte inline image produces, where the record's own closing
51
+ * newline sits past whatever small chunk was read, so the old bare head
52
+ * reader dropped the WHOLE record (and with it, the real original request)
53
+ * rather than the oversized value inside it.
54
+ *
55
+ * Only invoked when the cheap path already failed to find a complete line
56
+ * (bounded — see {@link readSessionHeadChunk}'s `sawEof`), so an ordinary
57
+ * transcript never pays this cost. Reads up to {@link HEAD_ELISION_MAX_READ_BYTES}
58
+ * from byte 0, runs it through {@link elideOversizedJsonStrings} (bounded by
59
+ * {@link HEAD_ELISION_MAX_MS}), and returns the first complete elided line —
60
+ * text before and after the oversized value is preserved verbatim (only the
61
+ * oversized value itself is shortened), UTF-8 decoding and escape handling
62
+ * both go through the exact same path an unelided record would. Returns ''
63
+ * (a partial result, not a guess) when the scan is cut off by either budget
64
+ * before a complete line was ever produced — never a followup/tail turn
65
+ * substituted for it.
66
+ */
67
+ export declare function readSessionHeadContentBounded(filePath: string, maxReadBytes?: number): string;
68
+ /**
69
+ * Read the FIRST `maxEvents` normalized events from a JSONL transcript's
70
+ * head — the session's actual opening turns, bounded and cheap (one small
71
+ * read from byte 0, the same shared Claude/Codex content parsers `readSessionTail`
72
+ * uses, zero duplicated parse logic). Only Claude and Codex are supported,
73
+ * matching {@link readSessionTailWithRaw}; other agents return no events.
74
+ *
75
+ * When the plain bounded chunk holds no complete first line at all — an
76
+ * opening user turn embedding a multi-megabyte inline image is the real case
77
+ * this covers — falls back to {@link readSessionHeadContentBounded}'s
78
+ * JSON-aware elision scan rather than returning nothing: the same content
79
+ * parsers then see a syntactically valid record with the oversized value
80
+ * shortened, so a text block before or after an elided image block is
81
+ * recovered exactly as if the image had never been oversized (the parser's
82
+ * own image-handling path, e.g. Claude's `normalizedAttachmentEvent`, still
83
+ * runs — it just sees a short placeholder `source.data` instead of the real
84
+ * payload, so an attachment's derived byte size is not meaningful when this
85
+ * fallback fired, only the surrounding TEXT is trustworthy).
86
+ */
87
+ export declare function readSessionHead(filePath: string, agent: SessionAgentId, maxBytes?: number, maxEvents?: number): SessionEvent[];
38
88
  export {};