@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.
- package/CHANGELOG.md +149 -14
- package/README.md +1 -1
- package/dist/commands/browser.js +11 -0
- package/dist/commands/exec.js +212 -141
- package/dist/commands/feed.js +65 -14
- package/dist/commands/sessions-picker.d.ts +11 -0
- package/dist/commands/sessions-picker.js +88 -7
- package/dist/commands/sessions.d.ts +21 -2
- package/dist/commands/sessions.js +157 -3
- package/dist/commands/setup-secrets.d.ts +2 -2
- package/dist/commands/setup-term.d.ts +24 -0
- package/dist/commands/setup-term.js +70 -0
- package/dist/commands/setup.d.ts +1 -1
- package/dist/commands/setup.js +12 -4
- package/dist/commands/ssh.js +1 -69
- package/dist/lib/accounting/rotate.d.ts +63 -1
- package/dist/lib/accounting/rotate.js +56 -0
- package/dist/lib/accounts/add.js +2 -2
- package/dist/lib/accounts/slots.js +32 -2
- package/dist/lib/answer-router.d.ts +11 -2
- package/dist/lib/answer-router.js +26 -2
- package/dist/lib/auth-mint.d.ts +5 -4
- package/dist/lib/auth-mint.js +4 -3
- package/dist/lib/browser/drivers/arc.d.ts +1 -1
- package/dist/lib/browser/service.d.ts +10 -0
- package/dist/lib/browser/service.js +208 -36
- package/dist/lib/browser/types.d.ts +18 -0
- package/dist/lib/config-keys.d.ts +1 -1
- package/dist/lib/config-keys.js +5 -0
- package/dist/lib/device-config.js +61 -0
- package/dist/lib/devices/doctor-findings.js +2 -6
- package/dist/lib/feed/answer.d.ts +153 -4
- package/dist/lib/feed/answer.js +716 -105
- package/dist/lib/feed/feed.d.ts +61 -1
- package/dist/lib/feed/feed.js +226 -14
- package/dist/lib/feed/hub-server.d.ts +58 -3
- package/dist/lib/feed/hub-server.js +306 -54
- package/dist/lib/feed/pr-status.d.ts +8 -0
- package/dist/lib/feed/pr-status.js +9 -1
- package/dist/lib/feed-outcome.d.ts +1 -1
- package/dist/lib/feed-outcome.js +9 -2
- package/dist/lib/feed-policy.js +9 -3
- package/dist/lib/fleet/auth-sync.d.ts +2 -55
- package/dist/lib/fleet/auth-sync.js +2 -89
- package/dist/lib/harness-auth-capabilities.js +7 -2
- package/dist/lib/hosts/dispatch.d.ts +20 -1
- package/dist/lib/hosts/dispatch.js +52 -30
- package/dist/lib/hosts/remote-cmd.d.ts +21 -0
- package/dist/lib/hosts/remote-cmd.js +26 -2
- package/dist/lib/mailbox.d.ts +12 -0
- package/dist/lib/mailbox.js +16 -2
- package/dist/lib/menubar/snapshot.d.ts +51 -0
- package/dist/lib/menubar/snapshot.js +42 -3
- package/dist/lib/open-url.js +2 -2
- package/dist/lib/projects.d.ts +23 -0
- package/dist/lib/projects.js +78 -0
- package/dist/lib/secrets-cli.d.ts +3 -3
- package/dist/lib/secrets-cli.js +1 -1
- package/dist/lib/session/active.d.ts +1 -0
- package/dist/lib/session/active.js +8 -0
- package/dist/lib/session/db.d.ts +67 -3
- package/dist/lib/session/db.js +381 -126
- package/dist/lib/session/prompt.d.ts +23 -7
- package/dist/lib/session/prompt.js +46 -8
- package/dist/lib/session/remote/remote-list.d.ts +20 -0
- package/dist/lib/session/remote/remote-list.js +22 -6
- package/dist/lib/session/remote/watch.d.ts +12 -0
- package/dist/lib/session/remote/watch.js +9 -0
- package/dist/lib/session/remote-preview-cache.d.ts +29 -0
- package/dist/lib/session/remote-preview-cache.js +373 -0
- package/dist/lib/session/tail.d.ts +50 -0
- package/dist/lib/session/tail.js +219 -0
- package/dist/lib/setup-tool-install.js +2 -1
- package/dist/lib/setup-tool-status.d.ts +1 -1
- package/dist/lib/setup-tool-status.js +6 -1
- package/dist/lib/signin-badge.d.ts +19 -4
- package/dist/lib/signin-badge.js +29 -11
- package/dist/lib/term-driver.d.ts +24 -0
- package/dist/lib/term-driver.js +36 -0
- package/dist/lib/terminal/index.d.ts +1 -1
- package/dist/lib/terminal/index.js +1 -1
- package/dist/lib/terminal/inject.d.ts +38 -0
- package/dist/lib/terminal/inject.js +55 -9
- package/dist/lib/terminal/transport.d.ts +15 -5
- package/dist/lib/terminal/transport.js +61 -11
- package/package.json +1 -1
- package/dist/lib/fleet/remote-login.d.ts +0 -170
- 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
|
-
*
|
|
299
|
-
*
|
|
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
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
306
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
550
|
-
let parsed;
|
|
567
|
+
return { ok: false, reason: 'unreachable' };
|
|
551
568
|
try {
|
|
552
|
-
|
|
569
|
+
return { ok: true, envelope: JSON.parse(stripClixml(capture.stdout)) };
|
|
553
570
|
}
|
|
554
571
|
catch {
|
|
555
|
-
return
|
|
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 {};
|