@newrelic/preflight 1.17.0 → 1.18.0
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/dist/dashboard/routes/api-handler.d.ts +3 -0
- package/dist/dashboard/routes/api-handler.d.ts.map +1 -1
- package/dist/dashboard/routes/api-handler.js +45 -4
- package/dist/dashboard/routes/api-handler.js.map +1 -1
- package/dist/data/pricing-overlay/README.md +58 -0
- package/dist/data/pricing-overlay/pricing.json +1 -0
- package/dist/deploy/data-paths.d.ts +4 -4
- package/dist/deploy/data-paths.js +3 -3
- package/dist/hooks/session-resolver.d.ts +134 -0
- package/dist/hooks/session-resolver.d.ts.map +1 -1
- package/dist/hooks/session-resolver.js +283 -16
- package/dist/hooks/session-resolver.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +91 -5
- package/dist/index.js.map +1 -1
- package/dist/metrics/live-session-registry.d.ts +24 -0
- package/dist/metrics/live-session-registry.d.ts.map +1 -1
- package/dist/metrics/live-session-registry.js +57 -2
- package/dist/metrics/live-session-registry.js.map +1 -1
- package/dist/metrics/{copilot-pricing-overlay.d.ts → pricing-overlay.d.ts} +9 -9
- package/dist/metrics/pricing-overlay.d.ts.map +1 -0
- package/dist/metrics/{copilot-pricing-overlay.js → pricing-overlay.js} +15 -15
- package/dist/metrics/pricing-overlay.js.map +1 -0
- package/dist/metrics/session-tracker.d.ts +49 -0
- package/dist/metrics/session-tracker.d.ts.map +1 -1
- package/dist/metrics/session-tracker.js +63 -4
- package/dist/metrics/session-tracker.js.map +1 -1
- package/dist/shared/pricing-data.d.ts.map +1 -1
- package/dist/shared/pricing-data.js +243 -12
- package/dist/shared/pricing-data.js.map +1 -1
- package/dist/storage/session-store.d.ts +11 -0
- package/dist/storage/session-store.d.ts.map +1 -1
- package/dist/storage/session-store.js +26 -0
- package/dist/storage/session-store.js.map +1 -1
- package/dist/tools/cross-session-tools.d.ts.map +1 -1
- package/dist/tools/cross-session-tools.js +4 -0
- package/dist/tools/cross-session-tools.js.map +1 -1
- package/dist/tools/session-stats.d.ts +2 -0
- package/dist/tools/session-stats.d.ts.map +1 -1
- package/dist/tools/session-stats.js +8 -1
- package/dist/tools/session-stats.js.map +1 -1
- package/dist/web/assets/index-BSqTG7nX.js +64 -0
- package/dist/web/assets/{index-DwdSz9gz.css → index-DZ9_lGC1.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +16 -15
- package/dist/data/copilot-pricing/README.md +0 -42
- package/dist/data/copilot-pricing/pricing.json +0 -54
- package/dist/metrics/copilot-pricing-overlay.d.ts.map +0 -1
- package/dist/metrics/copilot-pricing-overlay.js.map +0 -1
- package/dist/web/assets/index-Bwc4qaqj.js +0 -64
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# Pricing overlay
|
|
2
|
+
|
|
3
|
+
`pricing.json` fills gaps in the vendored `src/shared/pricing-data.ts` table
|
|
4
|
+
for model IDs a platform reports that the vendored table doesn't resolve —
|
|
5
|
+
confirmed live via `PricingTable.resolve()`'s four resolution paths (exact /
|
|
6
|
+
alias / forward-prefix / reverse-prefix). It's applied automatically at
|
|
7
|
+
startup (`applyPricingOverlay()` in `src/metrics/pricing-overlay.ts`) unless
|
|
8
|
+
a user has configured their own custom pricing file, and is gap-fill-only:
|
|
9
|
+
`applyGapFilledOverlay()` drops any entry that already resolves against the
|
|
10
|
+
vendored table instead of overriding it, so it can never silently mask a
|
|
11
|
+
correction made upstream.
|
|
12
|
+
|
|
13
|
+
It's currently empty (`{}`). This isn't tied to any one platform — add an
|
|
14
|
+
entry here whenever you hit a real model ID that Preflight resolves to `$0`
|
|
15
|
+
cost because the vendored table doesn't have it yet, regardless of which
|
|
16
|
+
platform surfaced it.
|
|
17
|
+
|
|
18
|
+
## Adding an entry
|
|
19
|
+
|
|
20
|
+
1. Find the model's real per-token rate from a source you can cite — the
|
|
21
|
+
vendor's own pricing page where one exists, or (for models only listed
|
|
22
|
+
through a specific platform, e.g. one a coding assistant surfaces that
|
|
23
|
+
the underlying model vendor doesn't itself document standalone) that
|
|
24
|
+
platform's own published pricing page. Note your source and the date you
|
|
25
|
+
checked it in your commit or PR description — pricing pages change
|
|
26
|
+
without much notice.
|
|
27
|
+
2. Add the entry to `pricing.json`, matching the `ModelPricing` shape
|
|
28
|
+
(`src/shared/pricing.ts`). Only add IDs confirmed missing from the
|
|
29
|
+
vendored table — never duplicate or "correct" an existing vendored entry
|
|
30
|
+
here; that needs a fix upstream, in whatever repo maintains
|
|
31
|
+
`src/shared/pricing-data.ts` for you.
|
|
32
|
+
3. `contextWindow` is a non-billing-critical best-effort default (not used
|
|
33
|
+
in `calculateCost()`'s pricing math, only validated as a positive
|
|
34
|
+
integer) — it's fine to estimate it if you can't verify it precisely.
|
|
35
|
+
|
|
36
|
+
This file is exactly the place to add an entry if you can't push directly to
|
|
37
|
+
wherever the vendored table's source lives (e.g. an external contributor) —
|
|
38
|
+
a maintainer with access can upstream it later, and the collision guard
|
|
39
|
+
above makes that safe even if the sync lags.
|
|
40
|
+
|
|
41
|
+
## Known, unavoidable limitation
|
|
42
|
+
|
|
43
|
+
Inherited from `ModelPricing`'s shape, which this overlay reuses as-is — not
|
|
44
|
+
fixable here without a schema change upstream: `ModelPricing` has no tiered
|
|
45
|
+
cache-read field, so a model whose cached-input rate is _higher_ at its
|
|
46
|
+
long-context tier than at its default tier can't have that modeled — the
|
|
47
|
+
base (lower) cache rate would apply at every tier. If you hit this, note it
|
|
48
|
+
explicitly as a comment on the entry.
|
|
49
|
+
|
|
50
|
+
## Interaction with a user's own custom pricing file
|
|
51
|
+
|
|
52
|
+
If a user sets `NEW_RELIC_AI_CUSTOM_PRICING_FILE` (or `customPricingFile` in
|
|
53
|
+
`~/.newrelic-preflight/config.json`) — for _any_ reason, even one unrelated
|
|
54
|
+
to this overlay — it's not applied at all. `initPricing()` always replaces
|
|
55
|
+
the full table from `DEFAULT_PRICING_TABLE`; it never merges a user's file
|
|
56
|
+
with this overlay. A user relying on both a custom pricing entry and one of
|
|
57
|
+
the gap-fill models here should add the model they need from `pricing.json`
|
|
58
|
+
into their own custom pricing file instead.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Resolve the directory containing bundled data files (alerts/, dashboards/,
|
|
3
|
-
* or
|
|
3
|
+
* or pricing-overlay/ JSON definitions).
|
|
4
4
|
*
|
|
5
5
|
* The deploy modules ship as `dist/deploy/*.js` after TypeScript build, with
|
|
6
6
|
* data files copied into `dist/data/<name>/` by the postbuild step (see
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
*
|
|
18
18
|
* Throws if no candidate exists — the deploy commands cannot run without the
|
|
19
19
|
* underlying data files. Callers for whom the data is optional (e.g. the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* pricing overlay, unlike alerts/dashboards which are required for their own
|
|
21
|
+
* commands) must wrap this in a try/catch and degrade gracefully.
|
|
22
22
|
*/
|
|
23
|
-
export declare function resolveDataDir(name: 'alerts' | 'dashboards' | '
|
|
23
|
+
export declare function resolveDataDir(name: 'alerts' | 'dashboards' | 'pricing-overlay'): string;
|
|
24
24
|
//# sourceMappingURL=data-paths.d.ts.map
|
|
@@ -2,7 +2,7 @@ import { existsSync, realpathSync } from 'node:fs';
|
|
|
2
2
|
import { dirname, resolve } from 'node:path';
|
|
3
3
|
/**
|
|
4
4
|
* Resolve the directory containing bundled data files (alerts/, dashboards/,
|
|
5
|
-
* or
|
|
5
|
+
* or pricing-overlay/ JSON definitions).
|
|
6
6
|
*
|
|
7
7
|
* The deploy modules ship as `dist/deploy/*.js` after TypeScript build, with
|
|
8
8
|
* data files copied into `dist/data/<name>/` by the postbuild step (see
|
|
@@ -19,8 +19,8 @@ import { dirname, resolve } from 'node:path';
|
|
|
19
19
|
*
|
|
20
20
|
* Throws if no candidate exists — the deploy commands cannot run without the
|
|
21
21
|
* underlying data files. Callers for whom the data is optional (e.g. the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
22
|
+
* pricing overlay, unlike alerts/dashboards which are required for their own
|
|
23
|
+
* commands) must wrap this in a try/catch and degrade gracefully.
|
|
24
24
|
*/
|
|
25
25
|
export function resolveDataDir(name) {
|
|
26
26
|
const rawPath = process.argv[1] ?? process.cwd();
|
|
@@ -68,6 +68,17 @@ export declare function resolveFromJobDir(claudeJobDir: string | null | undefine
|
|
|
68
68
|
* only checks whether the PID is alive, and a recycled PID always is.
|
|
69
69
|
*/
|
|
70
70
|
export declare function resolveFromBreadcrumb(storagePath: string, ppid: number | undefined, processStartMs?: number): string | null;
|
|
71
|
+
/**
|
|
72
|
+
* Sanitize a cwd into the single filename segment used both by the collector's
|
|
73
|
+
* `writeCwdBreadcrumb()` (session-by-cwd breadcrumb) and by Claude Code's own
|
|
74
|
+
* transcript-directory naming under `~/.claude/projects/`: backslash, forward
|
|
75
|
+
* slash, and colon each become `-`. Must stay identical to
|
|
76
|
+
* `collector-script.ts` so both sides derive the same filename independently.
|
|
77
|
+
* Colons are stripped so a Windows drive letter (e.g. `C:`) never survives into
|
|
78
|
+
* a filename where `path.resolve()` on win32 would reinterpret it as a
|
|
79
|
+
* drive-relative component and read/write outside the intended directory.
|
|
80
|
+
*/
|
|
81
|
+
export declare function sanitizeCwdForFilename(cwd: string): string;
|
|
71
82
|
/**
|
|
72
83
|
* Try to resolve the session_id synchronously from the cwd breadcrumb file.
|
|
73
84
|
* Fallback for platforms where the PPID breadcrumb never matches (see the
|
|
@@ -131,4 +142,127 @@ export declare function isSyntheticSessionId(id: string | null | undefined): boo
|
|
|
131
142
|
* double-counting) should check this, not `isSyntheticSessionId`.
|
|
132
143
|
*/
|
|
133
144
|
export declare function isUnscopedAggregatorSessionId(id: string | null | undefined): boolean;
|
|
145
|
+
/** Where a resolved session name came from, most trusted first. */
|
|
146
|
+
export type SessionNameSource = 'user' | 'ai-title' | 'auto' | 'cwd';
|
|
147
|
+
/**
|
|
148
|
+
* The naming-relevant fields read out of `CLAUDE_JOB_DIR/state.json`.
|
|
149
|
+
* `sessionId`/`name`/`nameSource` are labels; `intent` is CONTENT.
|
|
150
|
+
*/
|
|
151
|
+
export interface JobState {
|
|
152
|
+
/** Full session UUID, from the explicit `sessionId` field or `linkScanPath`. */
|
|
153
|
+
readonly sessionId: string | null;
|
|
154
|
+
/** Human-readable title Claude Code wrote for the job. */
|
|
155
|
+
readonly name: string | null;
|
|
156
|
+
/** Whether a human named it (`user`) or Claude auto-generated it (`auto`). */
|
|
157
|
+
readonly nameSource: 'user' | 'auto' | null;
|
|
158
|
+
/**
|
|
159
|
+
* SENSITIVE (CONTENT): the job's first user prompt. Populated ONLY when
|
|
160
|
+
* `readJobState` is called with `{ recordContent: true }`; otherwise null,
|
|
161
|
+
* so the sensitive field is off by construction rather than by convention.
|
|
162
|
+
* Because `recordContent` is force-disabled under highSecurity upstream
|
|
163
|
+
* (never bypass), passing `config.recordContent` keeps intent null in high
|
|
164
|
+
* security mode automatically. Even when populated it is returned verbatim,
|
|
165
|
+
* so any consumer MUST still pass it through `redactSensitive()` before it
|
|
166
|
+
* reaches a log, NR event, tool response, or persisted file. Phase-1 wiring
|
|
167
|
+
* reads `name`/`nameSource` only and never requests or forwards `intent`.
|
|
168
|
+
*/
|
|
169
|
+
readonly intent: string | null;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Read the naming fields from `CLAUDE_JOB_DIR/state.json`. Sibling to
|
|
173
|
+
* `resolveFromJobDir` (whose `string | null` contract is unchanged) — returns
|
|
174
|
+
* null when there is no readable state file. `sessionId` prefers an explicit
|
|
175
|
+
* `sessionId` field, falling back to the UUID embedded in `linkScanPath`.
|
|
176
|
+
*
|
|
177
|
+
* The sensitive `intent` (first-prompt CONTENT) is populated ONLY when
|
|
178
|
+
* `options.recordContent === true`; it is null otherwise. This gates the
|
|
179
|
+
* content field by construction (mirroring `DecisionTracker`'s `recordContent`
|
|
180
|
+
* gate) so a future consumer that copies the `name`/`nameSource` wiring cannot
|
|
181
|
+
* leak prompt content by simply reading `.intent`. Pass `config.recordContent`
|
|
182
|
+
* (already force-disabled under highSecurity) to stay compliant; the `name`/
|
|
183
|
+
* `nameSource` display labels are never gated.
|
|
184
|
+
*/
|
|
185
|
+
export declare function readJobState(claudeJobDir: string | null | undefined, options?: {
|
|
186
|
+
readonly recordContent?: boolean;
|
|
187
|
+
}): JobState | null;
|
|
188
|
+
/**
|
|
189
|
+
* Scan `text` (a suffix of a transcript JSONL file) for the LAST `ai-title`
|
|
190
|
+
* record and return its `aiTitle` (raw, unredacted — the caller redacts).
|
|
191
|
+
* `firstFragmentComplete` must be true only when `text` begins exactly at a
|
|
192
|
+
* file/line boundary; otherwise the substring before the first newline is a
|
|
193
|
+
* partial line whose start lies in an earlier, unread region and is skipped.
|
|
194
|
+
* Scanning newest-first means the first match is the file's last `ai-title`.
|
|
195
|
+
*/
|
|
196
|
+
export declare function findLastAiTitleInText(text: string, firstFragmentComplete: boolean): string | null;
|
|
197
|
+
/**
|
|
198
|
+
* Read the last `ai-title` from a session's transcript JSONL, redacted.
|
|
199
|
+
* Locates the transcript by matching `<sessionId>.jsonl` under any project slug
|
|
200
|
+
* (see `findTranscriptPath` — robust to Claude Code's cwd-slug scheme). Reads
|
|
201
|
+
* BOUNDED — reverse-scans the file's tail one chunk at a time up to
|
|
202
|
+
* `TRANSCRIPT_TITLE_MAX_SCAN_BYTES`, never slurping the whole file. Returns null
|
|
203
|
+
* on any miss (no file, no title, bad sessionId) and never throws.
|
|
204
|
+
*
|
|
205
|
+
* @param projectsDir Override for `~/.claude/projects` (test seam).
|
|
206
|
+
*/
|
|
207
|
+
export declare function readTranscriptTitle(input: {
|
|
208
|
+
readonly projectsDir?: string;
|
|
209
|
+
readonly sessionId: string;
|
|
210
|
+
}): string | null;
|
|
211
|
+
/**
|
|
212
|
+
* Directory basenames too generic to serve as a session label. Single source
|
|
213
|
+
* of truth for the guard `SessionTracker`'s streaming cwd fallback also uses. A
|
|
214
|
+
* degenerate startup cwd yields no authoritative `cwd` name (step 4 falls
|
|
215
|
+
* through to null), leaving the tracker's streaming fallback free to upgrade to
|
|
216
|
+
* a better name from a later tool call's cwd.
|
|
217
|
+
*/
|
|
218
|
+
export declare const DEGENERATE_NAMES: ReadonlySet<string>;
|
|
219
|
+
/** Inputs to the pure `resolveSessionName` precedence decision. */
|
|
220
|
+
export interface ResolveSessionNameInputs {
|
|
221
|
+
/** Parsed background-job state, from `readJobState` (null if none). */
|
|
222
|
+
readonly jobState?: JobState | null;
|
|
223
|
+
/** Last transcript `ai-title`, from `readTranscriptTitle` (null if none). */
|
|
224
|
+
readonly transcriptTitle?: string | null;
|
|
225
|
+
/** Working directory, for the basename fallback. */
|
|
226
|
+
readonly cwd?: string | null;
|
|
227
|
+
}
|
|
228
|
+
/** A resolved session name plus which source produced it. */
|
|
229
|
+
export interface SessionNameResult {
|
|
230
|
+
readonly name: string;
|
|
231
|
+
readonly source: SessionNameSource;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Pure precedence decision for a session's display name (first hit wins):
|
|
235
|
+
* 1. state.json `name` where `nameSource === 'user'` → `user`
|
|
236
|
+
* 2. transcript last `ai-title` → `ai-title`
|
|
237
|
+
* 3. state.json `name` where `nameSource === 'auto'` → `auto`
|
|
238
|
+
* 4. basename(cwd), unless degenerate → `cwd`
|
|
239
|
+
* 5. null
|
|
240
|
+
* Does no IO and gates on nothing: callers pass already-read values and redact
|
|
241
|
+
* the returned name at each sink.
|
|
242
|
+
*/
|
|
243
|
+
export declare function resolveSessionName(inputs: ResolveSessionNameInputs): SessionNameResult | null;
|
|
244
|
+
/**
|
|
245
|
+
* Trust rank of a session-name source; LOWER is more trusted. Mirrors
|
|
246
|
+
* `resolveSessionName`'s precedence exactly: `user`(0) > `ai-title`(1) >
|
|
247
|
+
* `auto`(2) > `cwd`(3). Used to decide whether a freshly re-resolved name may
|
|
248
|
+
* replace the one already in place (see `shouldReplaceSessionName`).
|
|
249
|
+
*/
|
|
250
|
+
export declare function sessionNameSourceRank(source: SessionNameSource): number;
|
|
251
|
+
/**
|
|
252
|
+
* Whether a re-resolved name from `next` should replace a name currently
|
|
253
|
+
* sourced from `current`. Freshness re-resolution (Phase 2) re-reads Claude
|
|
254
|
+
* Code's name sources at persist/shutdown time so a refined `ai-title` — or a
|
|
255
|
+
* name a human later assigns — supersedes the first-prompt guess.
|
|
256
|
+
*
|
|
257
|
+
* Replaces only when `next` is at least as trusted as `current`
|
|
258
|
+
* (`rank(next) <= rank(current)`): an equal-or-better source wins (which also
|
|
259
|
+
* picks up refined text for the SAME source, e.g. an updated `ai-title`),
|
|
260
|
+
* while a strictly-less-trusted source is refused. That refusal is the
|
|
261
|
+
* load-bearing invariant: a `user`-sourced name is NEVER downgraded to
|
|
262
|
+
* `auto`/`cwd`, even if the job-state file that supplied it later disappears
|
|
263
|
+
* and re-resolution would otherwise fall through to the transcript title or
|
|
264
|
+
* the cwd basename. A `null` current source (session not yet named) always
|
|
265
|
+
* accepts.
|
|
266
|
+
*/
|
|
267
|
+
export declare function shouldReplaceSessionName(current: SessionNameSource | null, next: SessionNameSource): boolean;
|
|
134
268
|
//# sourceMappingURL=session-resolver.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-resolver.d.ts","sourceRoot":"","sources":["../../src/hooks/session-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;
|
|
1
|
+
{"version":3,"file":"session-resolver.d.ts","sourceRoot":"","sources":["../../src/hooks/session-resolver.ts"],"names":[],"mappings":"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"}
|
|
@@ -26,10 +26,11 @@
|
|
|
26
26
|
* tool handlers should report "session_id not yet resolved" rather than make
|
|
27
27
|
* one up.
|
|
28
28
|
*/
|
|
29
|
-
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, statSync, openSync, readSync, closeSync, constants as fsConstants, } from 'node:fs';
|
|
30
30
|
import { resolve, basename } from 'node:path';
|
|
31
31
|
import { homedir } from 'node:os';
|
|
32
32
|
import { createLogger } from '../shared/index.js';
|
|
33
|
+
import { redactSensitive } from '../config.js';
|
|
33
34
|
const logger = createLogger('session-resolver');
|
|
34
35
|
const SESSION_ID_RE = /^[a-zA-Z0-9_-]{1,128}$/;
|
|
35
36
|
const DEFAULT_STORAGE_DIR = resolve(homedir(), '.newrelic-preflight');
|
|
@@ -37,10 +38,12 @@ const POLL_SCHEDULE_MS = [100, 200, 500, 1000, 2000];
|
|
|
37
38
|
const STEADY_POLL_MS = 2000;
|
|
38
39
|
const WARN_AFTER_MS = 60_000;
|
|
39
40
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
41
|
+
* Read and JSON-parse `<claudeJobDir>/state.json`, returning the parsed object
|
|
42
|
+
* or null on any failure (missing dir, unreadable, invalid JSON, non-object).
|
|
43
|
+
* Shared by `resolveFromJobDir` and `readJobState` so both derive from one
|
|
44
|
+
* read; neither's public contract changes.
|
|
42
45
|
*/
|
|
43
|
-
|
|
46
|
+
function readAndParseJobState(claudeJobDir) {
|
|
44
47
|
if (!claudeJobDir || typeof claudeJobDir !== 'string')
|
|
45
48
|
return null;
|
|
46
49
|
const statePath = resolve(claudeJobDir, 'state.json');
|
|
@@ -64,18 +67,30 @@ export function resolveFromJobDir(claudeJobDir) {
|
|
|
64
67
|
}
|
|
65
68
|
if (parsed === null || typeof parsed !== 'object')
|
|
66
69
|
return null;
|
|
67
|
-
|
|
70
|
+
return parsed;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Extract the session UUID from a `linkScanPath` value: the basename minus its
|
|
74
|
+
* extension, validated against `SESSION_ID_RE` so a path-traversing value is
|
|
75
|
+
* never accepted. Returns null when absent or invalid.
|
|
76
|
+
*/
|
|
77
|
+
function sessionIdFromLinkScanPath(linkScanPath) {
|
|
68
78
|
if (typeof linkScanPath !== 'string' || linkScanPath.length === 0)
|
|
69
79
|
return null;
|
|
70
|
-
// The session UUID is the basename minus its extension. Validate against
|
|
71
|
-
// the same character class used everywhere else so we never accept a
|
|
72
|
-
// path-traversing value.
|
|
73
80
|
const file = basename(linkScanPath);
|
|
74
81
|
const dot = file.lastIndexOf('.');
|
|
75
82
|
const sid = dot > 0 ? file.slice(0, dot) : file;
|
|
76
|
-
|
|
83
|
+
return SESSION_ID_RE.test(sid) ? sid : null;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Try to resolve the session_id synchronously from `CLAUDE_JOB_DIR/state.json`.
|
|
87
|
+
* Returns the validated session_id or null.
|
|
88
|
+
*/
|
|
89
|
+
export function resolveFromJobDir(claudeJobDir) {
|
|
90
|
+
const parsed = readAndParseJobState(claudeJobDir);
|
|
91
|
+
if (!parsed)
|
|
77
92
|
return null;
|
|
78
|
-
return
|
|
93
|
+
return sessionIdFromLinkScanPath(parsed.linkScanPath);
|
|
79
94
|
}
|
|
80
95
|
/**
|
|
81
96
|
* Try to resolve the session_id synchronously from the PPID breadcrumb file.
|
|
@@ -123,6 +138,19 @@ export function resolveFromBreadcrumb(storagePath, ppid, processStartMs = Date.n
|
|
|
123
138
|
return null;
|
|
124
139
|
return sid;
|
|
125
140
|
}
|
|
141
|
+
/**
|
|
142
|
+
* Sanitize a cwd into the single filename segment used both by the collector's
|
|
143
|
+
* `writeCwdBreadcrumb()` (session-by-cwd breadcrumb) and by Claude Code's own
|
|
144
|
+
* transcript-directory naming under `~/.claude/projects/`: backslash, forward
|
|
145
|
+
* slash, and colon each become `-`. Must stay identical to
|
|
146
|
+
* `collector-script.ts` so both sides derive the same filename independently.
|
|
147
|
+
* Colons are stripped so a Windows drive letter (e.g. `C:`) never survives into
|
|
148
|
+
* a filename where `path.resolve()` on win32 would reinterpret it as a
|
|
149
|
+
* drive-relative component and read/write outside the intended directory.
|
|
150
|
+
*/
|
|
151
|
+
export function sanitizeCwdForFilename(cwd) {
|
|
152
|
+
return cwd.replace(/[\\/:]/g, '-');
|
|
153
|
+
}
|
|
126
154
|
/**
|
|
127
155
|
* Try to resolve the session_id synchronously from the cwd breadcrumb file.
|
|
128
156
|
* Fallback for platforms where the PPID breadcrumb never matches (see the
|
|
@@ -131,12 +159,7 @@ export function resolveFromBreadcrumb(storagePath, ppid, processStartMs = Date.n
|
|
|
131
159
|
export function resolveFromCwd(storagePath, cwd) {
|
|
132
160
|
if (typeof cwd !== 'string' || cwd.length === 0)
|
|
133
161
|
return null;
|
|
134
|
-
|
|
135
|
-
// getTranscriptPath() — must stay identical so both sides derive the same
|
|
136
|
-
// filename independently. Colons are stripped too (unlike
|
|
137
|
-
// getTranscriptPath) so a Windows drive letter never survives into the
|
|
138
|
-
// filename — see the matching comment in writeCwdBreadcrumb().
|
|
139
|
-
const sanitizedCwd = cwd.replace(/[\\/:]/g, '-');
|
|
162
|
+
const sanitizedCwd = sanitizeCwdForFilename(cwd);
|
|
140
163
|
const breadcrumbPath = resolve(storagePath, 'session-by-cwd', `${sanitizedCwd}.txt`);
|
|
141
164
|
if (!existsSync(breadcrumbPath))
|
|
142
165
|
return null;
|
|
@@ -354,4 +377,248 @@ export function isUnscopedAggregatorSessionId(id) {
|
|
|
354
377
|
return false;
|
|
355
378
|
return id.startsWith('local-') || id.startsWith('proxy-');
|
|
356
379
|
}
|
|
380
|
+
/**
|
|
381
|
+
* Read the naming fields from `CLAUDE_JOB_DIR/state.json`. Sibling to
|
|
382
|
+
* `resolveFromJobDir` (whose `string | null` contract is unchanged) — returns
|
|
383
|
+
* null when there is no readable state file. `sessionId` prefers an explicit
|
|
384
|
+
* `sessionId` field, falling back to the UUID embedded in `linkScanPath`.
|
|
385
|
+
*
|
|
386
|
+
* The sensitive `intent` (first-prompt CONTENT) is populated ONLY when
|
|
387
|
+
* `options.recordContent === true`; it is null otherwise. This gates the
|
|
388
|
+
* content field by construction (mirroring `DecisionTracker`'s `recordContent`
|
|
389
|
+
* gate) so a future consumer that copies the `name`/`nameSource` wiring cannot
|
|
390
|
+
* leak prompt content by simply reading `.intent`. Pass `config.recordContent`
|
|
391
|
+
* (already force-disabled under highSecurity) to stay compliant; the `name`/
|
|
392
|
+
* `nameSource` display labels are never gated.
|
|
393
|
+
*/
|
|
394
|
+
export function readJobState(claudeJobDir, options) {
|
|
395
|
+
const parsed = readAndParseJobState(claudeJobDir);
|
|
396
|
+
if (!parsed)
|
|
397
|
+
return null;
|
|
398
|
+
const sessionId = typeof parsed.sessionId === 'string' && SESSION_ID_RE.test(parsed.sessionId)
|
|
399
|
+
? parsed.sessionId
|
|
400
|
+
: sessionIdFromLinkScanPath(parsed.linkScanPath);
|
|
401
|
+
const name = typeof parsed.name === 'string' && parsed.name.length > 0 ? parsed.name : null;
|
|
402
|
+
const nameSource = parsed.nameSource === 'user' || parsed.nameSource === 'auto' ? parsed.nameSource : null;
|
|
403
|
+
const intent = options?.recordContent === true && typeof parsed.intent === 'string' && parsed.intent.length > 0
|
|
404
|
+
? parsed.intent
|
|
405
|
+
: null;
|
|
406
|
+
return { sessionId, name, nameSource, intent };
|
|
407
|
+
}
|
|
408
|
+
// Bounded transcript reads: scan at most this many bytes back from EOF, one
|
|
409
|
+
// chunk at a time, to find the last `ai-title`. A degenerate huge transcript
|
|
410
|
+
// with no title in its tail costs at most MAX_SCAN_BYTES of IO, never the
|
|
411
|
+
// whole file.
|
|
412
|
+
const TRANSCRIPT_TITLE_CHUNK_BYTES = 64 * 1024;
|
|
413
|
+
const TRANSCRIPT_TITLE_MAX_SCAN_BYTES = 1024 * 1024;
|
|
414
|
+
/**
|
|
415
|
+
* Scan `text` (a suffix of a transcript JSONL file) for the LAST `ai-title`
|
|
416
|
+
* record and return its `aiTitle` (raw, unredacted — the caller redacts).
|
|
417
|
+
* `firstFragmentComplete` must be true only when `text` begins exactly at a
|
|
418
|
+
* file/line boundary; otherwise the substring before the first newline is a
|
|
419
|
+
* partial line whose start lies in an earlier, unread region and is skipped.
|
|
420
|
+
* Scanning newest-first means the first match is the file's last `ai-title`.
|
|
421
|
+
*/
|
|
422
|
+
export function findLastAiTitleInText(text, firstFragmentComplete) {
|
|
423
|
+
const parts = text.split('\n');
|
|
424
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
425
|
+
if (i === 0 && !firstFragmentComplete)
|
|
426
|
+
break; // partial leading line — its start is unread
|
|
427
|
+
const line = parts[i];
|
|
428
|
+
if (line.length === 0)
|
|
429
|
+
continue;
|
|
430
|
+
// Cheap pre-filter: `ai-title` records are rare, so skip JSON.parse unless
|
|
431
|
+
// the marker is present at all.
|
|
432
|
+
if (!line.includes('"ai-title"'))
|
|
433
|
+
continue;
|
|
434
|
+
let parsed;
|
|
435
|
+
try {
|
|
436
|
+
parsed = JSON.parse(line);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (parsed.type === 'ai-title' &&
|
|
442
|
+
typeof parsed.aiTitle === 'string' &&
|
|
443
|
+
parsed.aiTitle.length > 0) {
|
|
444
|
+
return parsed.aiTitle;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Locate a session's transcript file under `<projectsDir>/<slug>/<sessionId>.jsonl`.
|
|
451
|
+
*
|
|
452
|
+
* Claude Code names each project subdirectory with a slug derived from the cwd,
|
|
453
|
+
* but that scheme is NOT the same as our breadcrumb `sanitizeCwdForFilename`
|
|
454
|
+
* (Claude Code also replaces `.` — so `/repo/.claude/wt` becomes
|
|
455
|
+
* `-repo--claude-wt`). Rather than mirror an undocumented slug scheme that can
|
|
456
|
+
* drift, we exploit the fact that a session id is a UUID — globally unique
|
|
457
|
+
* across every project slug — and find the one subdirectory that contains
|
|
458
|
+
* `<sessionId>.jsonl`. Returns the first match, or null.
|
|
459
|
+
*/
|
|
460
|
+
function findTranscriptPath(projectsDir, sessionId) {
|
|
461
|
+
const fileName = `${sessionId}.jsonl`;
|
|
462
|
+
let entries;
|
|
463
|
+
try {
|
|
464
|
+
entries = readdirSync(projectsDir);
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
logger.debug('projects dir unreadable', { error: String(err) });
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
for (const entry of entries) {
|
|
471
|
+
const candidate = resolve(projectsDir, entry, fileName);
|
|
472
|
+
if (existsSync(candidate))
|
|
473
|
+
return candidate;
|
|
474
|
+
}
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* Read the last `ai-title` from a session's transcript JSONL, redacted.
|
|
479
|
+
* Locates the transcript by matching `<sessionId>.jsonl` under any project slug
|
|
480
|
+
* (see `findTranscriptPath` — robust to Claude Code's cwd-slug scheme). Reads
|
|
481
|
+
* BOUNDED — reverse-scans the file's tail one chunk at a time up to
|
|
482
|
+
* `TRANSCRIPT_TITLE_MAX_SCAN_BYTES`, never slurping the whole file. Returns null
|
|
483
|
+
* on any miss (no file, no title, bad sessionId) and never throws.
|
|
484
|
+
*
|
|
485
|
+
* @param projectsDir Override for `~/.claude/projects` (test seam).
|
|
486
|
+
*/
|
|
487
|
+
export function readTranscriptTitle(input) {
|
|
488
|
+
const { sessionId } = input;
|
|
489
|
+
if (!SESSION_ID_RE.test(sessionId))
|
|
490
|
+
return null;
|
|
491
|
+
const projectsDir = input.projectsDir ?? resolve(homedir(), '.claude', 'projects');
|
|
492
|
+
const transcriptPath = findTranscriptPath(projectsDir, sessionId);
|
|
493
|
+
if (transcriptPath === null)
|
|
494
|
+
return null;
|
|
495
|
+
let fd;
|
|
496
|
+
let size;
|
|
497
|
+
try {
|
|
498
|
+
size = statSync(transcriptPath).size;
|
|
499
|
+
if (size === 0)
|
|
500
|
+
return null;
|
|
501
|
+
fd = openSync(transcriptPath, fsConstants.O_RDONLY);
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
logger.debug('transcript unreadable', { error: String(err) });
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
try {
|
|
508
|
+
let pos = size;
|
|
509
|
+
// Accumulate raw chunks and decode the contiguous tail as ONE buffer each
|
|
510
|
+
// iteration, so a multi-byte UTF-8 sequence straddling a 64KB chunk
|
|
511
|
+
// boundary is decoded intact — decoding each chunk in isolation would
|
|
512
|
+
// corrupt a straddling character to U+FFFD on both sides.
|
|
513
|
+
const chunks = [];
|
|
514
|
+
while (pos > 0 && size - pos < TRANSCRIPT_TITLE_MAX_SCAN_BYTES) {
|
|
515
|
+
const chunkSize = Math.min(TRANSCRIPT_TITLE_CHUNK_BYTES, pos);
|
|
516
|
+
pos -= chunkSize;
|
|
517
|
+
const buffer = Buffer.alloc(chunkSize);
|
|
518
|
+
const bytesRead = readSync(fd, buffer, 0, chunkSize, pos);
|
|
519
|
+
chunks.unshift(bytesRead === chunkSize ? buffer : buffer.subarray(0, bytesRead));
|
|
520
|
+
const acc = Buffer.concat(chunks).toString('utf-8');
|
|
521
|
+
// `pos === 0` means `acc` now starts at the file head, so its first line
|
|
522
|
+
// is complete; otherwise the leading fragment is partial and skipped.
|
|
523
|
+
const title = findLastAiTitleInText(acc, pos === 0);
|
|
524
|
+
if (title !== null)
|
|
525
|
+
return redactSensitive(title);
|
|
526
|
+
}
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
catch (err) {
|
|
530
|
+
logger.debug('transcript read failed', { error: String(err) });
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
finally {
|
|
534
|
+
closeSync(fd);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Directory basenames too generic to serve as a session label. Single source
|
|
539
|
+
* of truth for the guard `SessionTracker`'s streaming cwd fallback also uses. A
|
|
540
|
+
* degenerate startup cwd yields no authoritative `cwd` name (step 4 falls
|
|
541
|
+
* through to null), leaving the tracker's streaming fallback free to upgrade to
|
|
542
|
+
* a better name from a later tool call's cwd.
|
|
543
|
+
*/
|
|
544
|
+
export const DEGENERATE_NAMES = new Set([
|
|
545
|
+
'tmp',
|
|
546
|
+
'temp',
|
|
547
|
+
'var',
|
|
548
|
+
'usr',
|
|
549
|
+
'opt',
|
|
550
|
+
'home',
|
|
551
|
+
'.',
|
|
552
|
+
'..',
|
|
553
|
+
'',
|
|
554
|
+
]);
|
|
555
|
+
/**
|
|
556
|
+
* Pure precedence decision for a session's display name (first hit wins):
|
|
557
|
+
* 1. state.json `name` where `nameSource === 'user'` → `user`
|
|
558
|
+
* 2. transcript last `ai-title` → `ai-title`
|
|
559
|
+
* 3. state.json `name` where `nameSource === 'auto'` → `auto`
|
|
560
|
+
* 4. basename(cwd), unless degenerate → `cwd`
|
|
561
|
+
* 5. null
|
|
562
|
+
* Does no IO and gates on nothing: callers pass already-read values and redact
|
|
563
|
+
* the returned name at each sink.
|
|
564
|
+
*/
|
|
565
|
+
export function resolveSessionName(inputs) {
|
|
566
|
+
const jobState = inputs.jobState ?? null;
|
|
567
|
+
if (jobState && jobState.nameSource === 'user' && jobState.name) {
|
|
568
|
+
return { name: jobState.name, source: 'user' };
|
|
569
|
+
}
|
|
570
|
+
if (inputs.transcriptTitle && inputs.transcriptTitle.length > 0) {
|
|
571
|
+
return { name: inputs.transcriptTitle, source: 'ai-title' };
|
|
572
|
+
}
|
|
573
|
+
if (jobState && jobState.nameSource === 'auto' && jobState.name) {
|
|
574
|
+
return { name: jobState.name, source: 'auto' };
|
|
575
|
+
}
|
|
576
|
+
const cwd = inputs.cwd;
|
|
577
|
+
if (typeof cwd === 'string' && cwd.length > 0) {
|
|
578
|
+
const base = basename(cwd);
|
|
579
|
+
if (base.length > 0 && !DEGENERATE_NAMES.has(base.toLowerCase())) {
|
|
580
|
+
return { name: base, source: 'cwd' };
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
return null;
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Trust rank of a session-name source; LOWER is more trusted. Mirrors
|
|
587
|
+
* `resolveSessionName`'s precedence exactly: `user`(0) > `ai-title`(1) >
|
|
588
|
+
* `auto`(2) > `cwd`(3). Used to decide whether a freshly re-resolved name may
|
|
589
|
+
* replace the one already in place (see `shouldReplaceSessionName`).
|
|
590
|
+
*/
|
|
591
|
+
export function sessionNameSourceRank(source) {
|
|
592
|
+
switch (source) {
|
|
593
|
+
case 'user':
|
|
594
|
+
return 0;
|
|
595
|
+
case 'ai-title':
|
|
596
|
+
return 1;
|
|
597
|
+
case 'auto':
|
|
598
|
+
return 2;
|
|
599
|
+
case 'cwd':
|
|
600
|
+
return 3;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* Whether a re-resolved name from `next` should replace a name currently
|
|
605
|
+
* sourced from `current`. Freshness re-resolution (Phase 2) re-reads Claude
|
|
606
|
+
* Code's name sources at persist/shutdown time so a refined `ai-title` — or a
|
|
607
|
+
* name a human later assigns — supersedes the first-prompt guess.
|
|
608
|
+
*
|
|
609
|
+
* Replaces only when `next` is at least as trusted as `current`
|
|
610
|
+
* (`rank(next) <= rank(current)`): an equal-or-better source wins (which also
|
|
611
|
+
* picks up refined text for the SAME source, e.g. an updated `ai-title`),
|
|
612
|
+
* while a strictly-less-trusted source is refused. That refusal is the
|
|
613
|
+
* load-bearing invariant: a `user`-sourced name is NEVER downgraded to
|
|
614
|
+
* `auto`/`cwd`, even if the job-state file that supplied it later disappears
|
|
615
|
+
* and re-resolution would otherwise fall through to the transcript title or
|
|
616
|
+
* the cwd basename. A `null` current source (session not yet named) always
|
|
617
|
+
* accepts.
|
|
618
|
+
*/
|
|
619
|
+
export function shouldReplaceSessionName(current, next) {
|
|
620
|
+
if (current === null)
|
|
621
|
+
return true;
|
|
622
|
+
return sessionNameSourceRank(next) <= sessionNameSourceRank(current);
|
|
623
|
+
}
|
|
357
624
|
//# sourceMappingURL=session-resolver.js.map
|