@gajae-code/utils 0.13.3 → 0.14.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.
@@ -0,0 +1,73 @@
1
+ /** Algorithm version recorded beside every emitted fingerprint. */
2
+ export declare const CRASH_FINGERPRINT_VERSION = 1;
3
+ /** Hex length of a published fingerprint (128 bits). */
4
+ export declare const CRASH_FINGERPRINT_HEX_LENGTH = 32;
5
+ /** Matches exactly a published fingerprint. */
6
+ export declare const CRASH_FINGERPRINT_PATTERN: RegExp;
7
+ /** Marker used for the machine-readable identity line of each crash record. */
8
+ export declare const CRASH_RECORD_MARKER = "gjc-crash-record.v1";
9
+ /** Marker embedded in an external issue body, outside crash-derived blocks. */
10
+ export declare const CRASH_ISSUE_MARKER_PREFIX = "gjc-crash-fp.v1:";
11
+ /** Literal frame used when a stack carries no in-app frame at all. */
12
+ export declare const NO_APP_FRAME = "<no-app-frame>";
13
+ export interface CrashFingerprintInput {
14
+ readonly name: string;
15
+ readonly message: string;
16
+ readonly stack: string;
17
+ }
18
+ export interface CrashFingerprint {
19
+ /** 32 lowercase hex characters. */
20
+ readonly fingerprint: string;
21
+ /** Algorithm version (`fpv`). */
22
+ readonly version: number;
23
+ readonly errorName: string;
24
+ /** Normalized, placeholder-substituted message class (safe to display). */
25
+ readonly messageClass: string;
26
+ /** Normalized in-app frames that participated in the digest. */
27
+ readonly frames: readonly string[];
28
+ }
29
+ export interface CrashFingerprintOptions {
30
+ /** Install root used to relativize in-app frames. Defaults to the GJC install root. */
31
+ readonly installRoot?: string;
32
+ /** Home directory used for `<home>` substitution. Defaults to `os.homedir()`. */
33
+ readonly homeDir?: string;
34
+ }
35
+ /**
36
+ * Replace absolute path-like tokens with `<home>` / `<path>`.
37
+ *
38
+ * Semantically meaningful codes are untouched: this rule only fires on tokens
39
+ * that are recognizably absolute filesystem paths.
40
+ */
41
+ export declare function replaceAbsolutePaths(text: string, homeDir?: string): string;
42
+ /**
43
+ * Typed message normalization.
44
+ *
45
+ * Deliberately *not* "strip all digits": HTTP statuses, exit codes and errno
46
+ * names are the difference between distinct crash classes, so runs of three or
47
+ * fewer digits and alphabetic error codes survive verbatim (`404` stays
48
+ * distinct from `500`). Only high-entropy identifiers are collapsed.
49
+ */
50
+ export declare function normalizeCrashMessage(message: string, options?: CrashFingerprintOptions): string;
51
+ /**
52
+ * Normalized in-app frames, newest first, capped at three.
53
+ *
54
+ * A frame is `<install-root-relative path>#<function>` with no line or column
55
+ * numbers. Stacks with no in-app frame yield the single literal
56
+ * `<no-app-frame>`; distinct roots can merge under that literal, which is an
57
+ * accepted and documented v1 property.
58
+ */
59
+ export declare function normalizeCrashFrames(stack: string, options?: CrashFingerprintOptions): string[];
60
+ /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
61
+ export declare function computeCrashFingerprint(input: CrashFingerprintInput, options?: CrashFingerprintOptions): CrashFingerprint;
62
+ /** The machine-readable identity line appended to every new crash record. */
63
+ export declare function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string;
64
+ export interface CrashRecordMarker {
65
+ readonly fingerprint: string;
66
+ readonly version: number;
67
+ readonly recordId: string;
68
+ }
69
+ /**
70
+ * Parse an identity line. Records written before this feature carry no marker
71
+ * and are therefore `unmatchable`: this parser never guesses at them.
72
+ */
73
+ export declare function parseCrashRecordMarker(line: string): CrashRecordMarker | undefined;
@@ -0,0 +1,57 @@
1
+ /** Hard cap for one journal line, including its newline. */
2
+ export declare const CRASH_EVENT_MAX_BYTES = 512;
3
+ /** Journal line format tag. */
4
+ export declare const CRASH_EVENT_KIND = "gjc-crash-event.v1";
5
+ /** Preview cap for the message class carried by an event. */
6
+ export declare const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
7
+ export type CrashEvent = CrashOccurrenceEvent | CrashReportedEvent | CrashAcknowledgedEvent | CrashNudgedEvent;
8
+ export interface CrashOccurrenceEvent {
9
+ readonly kind: "occurrence";
10
+ readonly fingerprint: string;
11
+ readonly fpv: number;
12
+ readonly recordId: string;
13
+ readonly at: number;
14
+ readonly errorName: string;
15
+ readonly messageClass: string;
16
+ }
17
+ export interface CrashReportedEvent {
18
+ readonly kind: "reported";
19
+ readonly fingerprint: string;
20
+ readonly at: number;
21
+ readonly issueUrl: string;
22
+ /** Set when the submission was a "+1" comment rather than a new issue. */
23
+ readonly commented?: boolean;
24
+ }
25
+ export interface CrashAcknowledgedEvent {
26
+ readonly kind: "acknowledged";
27
+ readonly fingerprint: string;
28
+ readonly at: number;
29
+ }
30
+ export interface CrashNudgedEvent {
31
+ readonly kind: "nudged";
32
+ readonly at: number;
33
+ }
34
+ /**
35
+ * Serialize one event to a single journal line (newline included), bounded to
36
+ * `CRASH_EVENT_MAX_BYTES`. Oversized message previews are shortened, and if the
37
+ * line still does not fit the preview is dropped entirely rather than the event.
38
+ */
39
+ export declare function formatCrashEventLine(event: CrashEvent): string;
40
+ /** Parse one journal line. Anything unexpected yields `undefined`, never a throw. */
41
+ export declare function parseCrashEventLine(line: string): CrashEvent | undefined;
42
+ /**
43
+ * Append one event synchronously with `O_APPEND`. Never throws.
44
+ *
45
+ * No parse, no lock, no rename, no read: the only I/O is one bounded write to
46
+ * an append-only file, so concurrent writers interleave whole lines instead of
47
+ * corrupting each other's records.
48
+ */
49
+ export declare function appendCrashEvent(event: CrashEvent, journalPath: string): boolean;
50
+ /**
51
+ * Fatal-path entry point: at most one journal append per process lifetime.
52
+ *
53
+ * A crash raised while handling a crash must not spend the process's remaining
54
+ * moments on journal bookkeeping, so the latch is never cleared — the process
55
+ * is exiting anyway. Returns whether this call wrote a line.
56
+ */
57
+ export declare function appendFatalCrashEvent(event: CrashEvent, journalPath: string): boolean;
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Best-effort credential scrubbing for durable crash records.
3
+ *
4
+ * This is a *persistence-time* scrub: it keeps obvious credential shapes out of
5
+ * a file GJC keeps indefinitely. It is explicitly NOT a privacy guarantee and
6
+ * must never be treated as one for data that leaves the machine — outbound
7
+ * text goes through `sanitizeExternalCrashV1` instead.
8
+ */
9
+ /**
10
+ * Scrub credential material from crash text before it is persisted.
11
+ * Covers bearer/basic-style headers, key=value or JSON key forms of common
12
+ * credential names, and well-known vendor token shapes. Normal messages and
13
+ * stack frames are untouched; matches are replaced in place so surrounding
14
+ * diagnostic context survives.
15
+ */
16
+ export declare function redactCrashSecrets(text: string): string;
17
+ /** Every placeholder `redactCrashSecrets` can emit. Used by downstream normalizers. */
18
+ export declare const CRASH_REDACTION_MARKERS: readonly string[];
@@ -35,6 +35,11 @@ export declare function formatBunRuntimeError(opts: {
35
35
  execPath?: string;
36
36
  platform?: NodeJS.Platform;
37
37
  }): string;
38
+ /**
39
+ * On macOS, strip /private prefix only when both paths resolve to the same location.
40
+ * This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
41
+ */
42
+ export declare function standardizeMacOSPath(p: string): string;
38
43
  export declare function resolveEquivalentPath(inputPath: string): string;
39
44
  export declare function normalizePathForComparison(inputPath: string, platform?: NodeJS.Platform): string;
40
45
  /** Return whether a relative path crosses above its root or is unexpectedly absolute. */
@@ -113,6 +118,8 @@ export declare function getGpuCachePath(): string;
113
118
  * cache file without touching the rest of the config root.
114
119
  */
115
120
  export declare function getGithubCacheDbPath(): string;
121
+ /** Get the durable tool-choice capability cache path. */
122
+ export declare function getToolChoiceCapabilityCachePath(): string;
116
123
  /** Get the natives directory (~/.gjc/natives). */
117
124
  export declare function getNativesDir(): string;
118
125
  /** Get the stats database path (~/.gjc/stats.db). */
@@ -155,6 +162,10 @@ export declare function getMemoriesDir(agentDir?: string): string;
155
162
  export declare function getTerminalSessionsDir(agentDir?: string): string;
156
163
  /** Get the crash log path (~/.gjc/agent/gjc-crash.log). */
157
164
  export declare function getCrashLogPath(agentDir?: string): string;
165
+ /** Get the crash event journal path (~/.gjc/agent/gjc-crash-events.jsonl). */
166
+ export declare function getCrashEventsPath(agentDir?: string): string;
167
+ /** Get the compacted crash signature index path (~/.gjc/agent/gjc-crash-index.json). */
168
+ export declare function getCrashIndexPath(agentDir?: string): string;
158
169
  /** Get the debug log path (~/.gjc/agent/gjc-debug.log). */
159
170
  export declare function getDebugLogPath(agentDir?: string): string;
160
171
  /** Get the project-level Python modules directory (.gjc/modules). */
@@ -2,6 +2,9 @@ export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./broken-pipe";
4
4
  export * from "./color";
5
+ export * from "./crash-fingerprint";
6
+ export * from "./crash-journal";
7
+ export * from "./crash-redaction";
5
8
  export * from "./dirs";
6
9
  export * from "./env";
7
10
  export * from "./fetch-retry";
@@ -1,3 +1,11 @@
1
+ /**
2
+ * Cleanup and postmortem handler utilities.
3
+ *
4
+ * This module provides a system for registering and running cleanup callbacks
5
+ * in response to process exit, signals, or fatal exceptions. It is intended to
6
+ * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
+ */
8
+ import { redactCrashSecrets } from "./crash-redaction";
1
9
  export declare enum Reason {
2
10
  PRE_EXIT = "pre_exit",// Pre-exit phase (not used by default)
3
11
  EXIT = "exit",// Normal process exit
@@ -16,6 +24,7 @@ export declare const CRASH_LOG_MAX_BYTES: number;
16
24
  * with a marker) before the append/reset decision.
17
25
  */
18
26
  export declare const CRASH_RECORD_MAX_BYTES: number;
27
+ export { redactCrashSecrets };
19
28
  /**
20
29
  * Append a fatal-crash record to the dedicated, rotation-immune crash log
21
30
  * (`~/.gjc/agent/gjc-crash.log`).
@@ -29,10 +38,11 @@ export declare const CRASH_RECORD_MAX_BYTES: number;
29
38
  * original fatal) and uses synchronous IO so the record lands before
30
39
  * `process.exit`. Returns the path written, or `undefined` on failure.
31
40
  */
32
- export declare function recordFatalCrash(label: string, reason: unknown, options?: {
41
+ export declare function recordFatalCrash(label: string, reason: unknown, options?: CrashRecordOptions): string | undefined;
42
+ interface CrashRecordOptions {
33
43
  path?: string;
34
44
  now?: Date;
35
- }): string | undefined;
45
+ }
36
46
  /**
37
47
  * Register a process cleanup callback, to be run on shutdown, signal, or fatal error.
38
48
  *
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/utils",
4
- "version": "0.13.3",
4
+ "version": "0.14.0",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@gajae-code/natives": "0.13.3",
34
+ "@gajae-code/natives": "0.14.0",
35
35
  "beautiful-mermaid": "^1.1.3",
36
36
  "handlebars": "^4.7.9",
37
37
  "winston": "^3.19.0",
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Crash fingerprinting, algorithm v1.
3
+ *
4
+ * A fingerprint is a stable, versioned identity for a crash *class*, computed
5
+ * at `recordFatalCrash` time from the already-captured diagnostic text (error
6
+ * name, message, stack) and nothing else. It exists so that N crash-loop
7
+ * records collapse into one countable signature.
8
+ *
9
+ * ## Privacy posture
10
+ *
11
+ * The fingerprint is a **public, pseudonymous correlation token**. It is
12
+ * deterministic over low-entropy inputs, therefore dictionary-testable, and it
13
+ * links the same crash class across installs and accounts. It is NOT a
14
+ * confidentiality control. Only *normalized* text is hashed — absolute paths,
15
+ * home directories, uuids/hex/long digit runs and everything
16
+ * `redactCrashSecrets` rewrites are replaced with placeholders before hashing —
17
+ * so a fingerprint never encodes a secret or a raw filesystem path.
18
+ *
19
+ * ## Canonical serialization (v1)
20
+ *
21
+ * Fields are length-prefixed UTF-8 (`<byteLength>:<bytes>`) so no delimiter
22
+ * ambiguity exists between an empty field and a missing one:
23
+ *
24
+ * "gjc-crash-fp.v1" | errorName | messageClass | frame0 | frame1 | frame2
25
+ *
26
+ * Up to three normalized in-app frames participate; absent frames are omitted
27
+ * (the length prefix of the preceding fields keeps the encoding unambiguous).
28
+ * The digest is sha256 truncated to its first 16 bytes, published as 32
29
+ * lowercase hex characters (128 bits).
30
+ */
31
+ import { createHash } from "node:crypto";
32
+ import * as os from "node:os";
33
+ import * as path from "node:path";
34
+ import { redactCrashSecrets } from "./crash-redaction";
35
+
36
+ /** Algorithm version recorded beside every emitted fingerprint. */
37
+ export const CRASH_FINGERPRINT_VERSION = 1;
38
+ /** Hex length of a published fingerprint (128 bits). */
39
+ export const CRASH_FINGERPRINT_HEX_LENGTH = 32;
40
+ /** Matches exactly a published fingerprint. */
41
+ export const CRASH_FINGERPRINT_PATTERN = /^[0-9a-f]{32}$/;
42
+ /** Marker used for the machine-readable identity line of each crash record. */
43
+ export const CRASH_RECORD_MARKER = "gjc-crash-record.v1";
44
+ /** Marker embedded in an external issue body, outside crash-derived blocks. */
45
+ export const CRASH_ISSUE_MARKER_PREFIX = "gjc-crash-fp.v1:";
46
+ /** Literal frame used when a stack carries no in-app frame at all. */
47
+ export const NO_APP_FRAME = "<no-app-frame>";
48
+
49
+ /** Byte caps applied before hashing, so a huge throwable cannot dominate cost. */
50
+ const MESSAGE_CLASS_MAX_BYTES = 512;
51
+ const FRAME_MAX_BYTES = 256;
52
+ const MAX_FRAMES = 3;
53
+
54
+ export interface CrashFingerprintInput {
55
+ readonly name: string;
56
+ readonly message: string;
57
+ readonly stack: string;
58
+ }
59
+
60
+ export interface CrashFingerprint {
61
+ /** 32 lowercase hex characters. */
62
+ readonly fingerprint: string;
63
+ /** Algorithm version (`fpv`). */
64
+ readonly version: number;
65
+ readonly errorName: string;
66
+ /** Normalized, placeholder-substituted message class (safe to display). */
67
+ readonly messageClass: string;
68
+ /** Normalized in-app frames that participated in the digest. */
69
+ readonly frames: readonly string[];
70
+ }
71
+
72
+ export interface CrashFingerprintOptions {
73
+ /** Install root used to relativize in-app frames. Defaults to the GJC install root. */
74
+ readonly installRoot?: string;
75
+ /** Home directory used for `<home>` substitution. Defaults to `os.homedir()`. */
76
+ readonly homeDir?: string;
77
+ }
78
+
79
+ /**
80
+ * Root of this installation, used to relativize frames.
81
+ *
82
+ * Source checkouts resolve to the workspace root (this file lives at
83
+ * `packages/utils/src/`); an npm install resolves to the `node_modules` root.
84
+ * Both are stable per install *shape*, which is what frame identity needs;
85
+ * compiled binaries never reach here because their frames are BunFS paths.
86
+ */
87
+ function defaultInstallRoot(): string {
88
+ return path.resolve(import.meta.dir, "..", "..", "..");
89
+ }
90
+
91
+ function truncateUtf8(text: string, maxBytes: number): string {
92
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
93
+ const bytes = Buffer.from(text, "utf8");
94
+ let end = maxBytes;
95
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
96
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
97
+ return bytes.subarray(0, end).toString("utf8");
98
+ }
99
+
100
+ const UUID_PATTERN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g;
101
+ const HEX_RUN_PATTERN = /\b[0-9a-fA-F]{8,}\b/g;
102
+ const DIGIT_RUN_PATTERN = /\d{4,}/g;
103
+ // UNC, Windows drive-letter, BunFS (posix + windows compiled forms), POSIX.
104
+ const PATH_PATTERN =
105
+ /\\\\[^\s"'`,;)\]]+|\b[A-Za-z]:\\[^\s"'`,;)\]]*|\/\$bunfs\/[^\s"'`,;)\]]*|(?<![\w.~])\/[^\s"'`,;)\]]*/g;
106
+
107
+ function normalizeSeparators(value: string): string {
108
+ return value.replace(/\\/g, "/");
109
+ }
110
+
111
+ /**
112
+ * Replace absolute path-like tokens with `<home>` / `<path>`.
113
+ *
114
+ * Semantically meaningful codes are untouched: this rule only fires on tokens
115
+ * that are recognizably absolute filesystem paths.
116
+ */
117
+ export function replaceAbsolutePaths(text: string, homeDir: string = os.homedir()): string {
118
+ const normalizedHome = normalizeSeparators(homeDir).replace(/\/+$/, "");
119
+ return text.replace(PATH_PATTERN, match => {
120
+ if (match === "/") return match;
121
+ const normalized = normalizeSeparators(match);
122
+ if (normalizedHome.length > 0 && (normalized === normalizedHome || normalized.startsWith(`${normalizedHome}/`)))
123
+ return "<home>";
124
+ return "<path>";
125
+ });
126
+ }
127
+
128
+ /**
129
+ * Typed message normalization.
130
+ *
131
+ * Deliberately *not* "strip all digits": HTTP statuses, exit codes and errno
132
+ * names are the difference between distinct crash classes, so runs of three or
133
+ * fewer digits and alphabetic error codes survive verbatim (`404` stays
134
+ * distinct from `500`). Only high-entropy identifiers are collapsed.
135
+ */
136
+ export function normalizeCrashMessage(message: string, options: CrashFingerprintOptions = {}): string {
137
+ const homeDir = options.homeDir ?? os.homedir();
138
+ let normalized = redactCrashSecrets(message);
139
+ normalized = replaceAbsolutePaths(normalized, homeDir);
140
+ normalized = normalized.replace(UUID_PATTERN, "<uuid>");
141
+ normalized = normalized.replace(HEX_RUN_PATTERN, match => (/[a-fA-F]/.test(match) ? "<hex>" : "<num>"));
142
+ normalized = normalized.replace(DIGIT_RUN_PATTERN, "<num>");
143
+ normalized = normalized.replace(/\s+/g, " ").trim();
144
+ return truncateUtf8(normalized, MESSAGE_CLASS_MAX_BYTES);
145
+ }
146
+
147
+ interface ParsedFrame {
148
+ readonly functionName: string;
149
+ readonly location: string;
150
+ }
151
+
152
+ function parseStackFrame(line: string): ParsedFrame | undefined {
153
+ const trimmed = line.trim();
154
+ const atMatch = /^at\s+(.*)$/.exec(trimmed);
155
+ if (!atMatch) return undefined;
156
+ const rest = atMatch[1] ?? "";
157
+ const parenIndex = rest.lastIndexOf(" (");
158
+ let functionName = "<anonymous>";
159
+ let location = rest;
160
+ if (parenIndex >= 0 && rest.endsWith(")")) {
161
+ functionName = rest.slice(0, parenIndex).trim();
162
+ location = rest.slice(parenIndex + 2, -1).trim();
163
+ }
164
+ if (!location) return undefined;
165
+ return { functionName, location };
166
+ }
167
+
168
+ function stripLocation(location: string): string {
169
+ let value = location;
170
+ if (value.startsWith("file://")) value = Bun.fileURLToPath(value);
171
+ // Drop `:line:col` / `:line` suffixes: they churn on every release.
172
+ value = value.replace(/:\d+(?::\d+)?$/, "");
173
+ return value;
174
+ }
175
+
176
+ function normalizeFunctionName(name: string): string {
177
+ let value = name.replace(/^(?:async|new)\s+/, "").trim();
178
+ value = value.replace(/\s+\[as\s+[^\]]+\]$/, "");
179
+ if (!value || value === "<anonymous>") return "<anonymous>";
180
+ return value;
181
+ }
182
+
183
+ function relativizeInApp(location: string, installRoot: string): string | undefined {
184
+ const normalized = normalizeSeparators(location);
185
+ if (!normalized || normalized === "native" || normalized === "<anonymous>") return undefined;
186
+ if (/^(?:node|bun):/.test(normalized)) return undefined;
187
+ if (/(?:^|\/)node_modules\//.test(normalized)) return undefined;
188
+ // Compiled-binary frames: posix `/$bunfs/root/...` and windows `B:\~BUN\root\...`.
189
+ const bunfs = /^(?:[A-Za-z]:\/~BUN|\/\$bunfs)\/root\/(.*)$/.exec(normalized);
190
+ if (bunfs) return bunfs[1] ?? undefined;
191
+ const root = normalizeSeparators(installRoot).replace(/\/+$/, "");
192
+ if (root.length > 0 && normalized.startsWith(`${root}/`)) return normalized.slice(root.length + 1);
193
+ return undefined;
194
+ }
195
+
196
+ /**
197
+ * Normalized in-app frames, newest first, capped at three.
198
+ *
199
+ * A frame is `<install-root-relative path>#<function>` with no line or column
200
+ * numbers. Stacks with no in-app frame yield the single literal
201
+ * `<no-app-frame>`; distinct roots can merge under that literal, which is an
202
+ * accepted and documented v1 property.
203
+ */
204
+ export function normalizeCrashFrames(stack: string, options: CrashFingerprintOptions = {}): string[] {
205
+ const installRoot = options.installRoot ?? defaultInstallRoot();
206
+ const frames: string[] = [];
207
+ for (const line of stack.split("\n")) {
208
+ if (frames.length >= MAX_FRAMES) break;
209
+ const parsed = parseStackFrame(line);
210
+ if (!parsed) continue;
211
+ const relative = relativizeInApp(stripLocation(parsed.location), installRoot);
212
+ if (relative === undefined) continue;
213
+ frames.push(truncateUtf8(`${relative}#${normalizeFunctionName(parsed.functionName)}`, FRAME_MAX_BYTES));
214
+ }
215
+ return frames.length > 0 ? frames : [NO_APP_FRAME];
216
+ }
217
+
218
+ function canonicalSerialization(fields: readonly string[]): Buffer {
219
+ const parts: Buffer[] = [];
220
+ for (const field of fields) {
221
+ const bytes = Buffer.from(field, "utf8");
222
+ parts.push(Buffer.from(`${bytes.byteLength}:`, "utf8"), bytes);
223
+ }
224
+ return Buffer.concat(parts);
225
+ }
226
+
227
+ /** Compute the v1 fingerprint of an already-captured fatal diagnostic. */
228
+ export function computeCrashFingerprint(
229
+ input: CrashFingerprintInput,
230
+ options: CrashFingerprintOptions = {},
231
+ ): CrashFingerprint {
232
+ const errorName = truncateUtf8(normalizeCrashMessage(input.name, options) || "Error", 128);
233
+ const messageClass = normalizeCrashMessage(input.message, options);
234
+ const frames = normalizeCrashFrames(input.stack, options);
235
+ const digest = createHash("sha256")
236
+ .update(canonicalSerialization(["gjc-crash-fp.v1", errorName, messageClass, ...frames]))
237
+ .digest();
238
+ return {
239
+ fingerprint: digest.subarray(0, CRASH_FINGERPRINT_HEX_LENGTH / 2).toString("hex"),
240
+ version: CRASH_FINGERPRINT_VERSION,
241
+ errorName,
242
+ messageClass,
243
+ frames,
244
+ };
245
+ }
246
+
247
+ /** The machine-readable identity line appended to every new crash record. */
248
+ export function formatCrashRecordMarker(fingerprint: string, version: number, recordId: string): string {
249
+ return `${CRASH_RECORD_MARKER} fp:${fingerprint} fpv:${version} id:${recordId}`;
250
+ }
251
+
252
+ export interface CrashRecordMarker {
253
+ readonly fingerprint: string;
254
+ readonly version: number;
255
+ readonly recordId: string;
256
+ }
257
+
258
+ /**
259
+ * Parse an identity line. Records written before this feature carry no marker
260
+ * and are therefore `unmatchable`: this parser never guesses at them.
261
+ */
262
+ export function parseCrashRecordMarker(line: string): CrashRecordMarker | undefined {
263
+ const match = new RegExp(`^${CRASH_RECORD_MARKER} fp:([0-9a-f]{32}) fpv:(\\d{1,3}) id:([0-9a-f]{8,32})$`).exec(
264
+ line.trim(),
265
+ );
266
+ if (!match) return undefined;
267
+ const version = Number(match[2]);
268
+ if (!Number.isSafeInteger(version) || version < 1) return undefined;
269
+ return { fingerprint: match[1] as string, version, recordId: match[3] as string };
270
+ }
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Append-only crash event journal.
3
+ *
4
+ * The fatal path is the most hostile place in the process: it runs while the
5
+ * program is already broken, possibly out of disk, possibly re-entered by a
6
+ * crash inside the crash handler. So the fatal path does exactly one thing
7
+ * here — append a single bounded line with `O_APPEND` — and never parses,
8
+ * locks, renames or reads. Aggregation into `gjc-crash-index.json` happens at
9
+ * the next startup, under a cross-process lock, far away from the fatal path.
10
+ *
11
+ * The journal, not the index, is the source of increments: a lost index can be
12
+ * rebuilt from journal events, and concurrent compactors cannot drop counts.
13
+ */
14
+ import * as fs from "node:fs";
15
+ import * as path from "node:path";
16
+ import { CRASH_FINGERPRINT_PATTERN } from "./crash-fingerprint";
17
+
18
+ /** Hard cap for one journal line, including its newline. */
19
+ export const CRASH_EVENT_MAX_BYTES = 512;
20
+ /** Journal line format tag. */
21
+ export const CRASH_EVENT_KIND = "gjc-crash-event.v1";
22
+ /** Preview cap for the message class carried by an event. */
23
+ export const CRASH_EVENT_MESSAGE_MAX_BYTES = 256;
24
+
25
+ export type CrashEvent = CrashOccurrenceEvent | CrashReportedEvent | CrashAcknowledgedEvent | CrashNudgedEvent;
26
+
27
+ export interface CrashOccurrenceEvent {
28
+ readonly kind: "occurrence";
29
+ readonly fingerprint: string;
30
+ readonly fpv: number;
31
+ readonly recordId: string;
32
+ readonly at: number;
33
+ readonly errorName: string;
34
+ readonly messageClass: string;
35
+ }
36
+
37
+ export interface CrashReportedEvent {
38
+ readonly kind: "reported";
39
+ readonly fingerprint: string;
40
+ readonly at: number;
41
+ readonly issueUrl: string;
42
+ /** Set when the submission was a "+1" comment rather than a new issue. */
43
+ readonly commented?: boolean;
44
+ }
45
+
46
+ export interface CrashAcknowledgedEvent {
47
+ readonly kind: "acknowledged";
48
+ readonly fingerprint: string;
49
+ readonly at: number;
50
+ }
51
+
52
+ export interface CrashNudgedEvent {
53
+ readonly kind: "nudged";
54
+ readonly at: number;
55
+ }
56
+
57
+ function truncateUtf8(text: string, maxBytes: number): string {
58
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
59
+ const bytes = Buffer.from(text, "utf8");
60
+ let end = maxBytes;
61
+ while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
62
+ if (end > 0 && bytes[end - 1] >= 0xc0) end--;
63
+ return bytes.subarray(0, end).toString("utf8");
64
+ }
65
+
66
+ /** Strip control characters so one event line can never contain a newline. */
67
+ function sanitizeEventText(text: string): string {
68
+ return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ");
69
+ }
70
+
71
+ /**
72
+ * Serialize one event to a single journal line (newline included), bounded to
73
+ * `CRASH_EVENT_MAX_BYTES`. Oversized message previews are shortened, and if the
74
+ * line still does not fit the preview is dropped entirely rather than the event.
75
+ */
76
+ export function formatCrashEventLine(event: CrashEvent): string {
77
+ const build = (messageClass?: string): string => {
78
+ const body: Record<string, unknown> =
79
+ event.kind === "occurrence"
80
+ ? {
81
+ k: "occurrence",
82
+ fp: event.fingerprint,
83
+ fpv: event.fpv,
84
+ id: event.recordId,
85
+ at: event.at,
86
+ n: sanitizeEventText(truncateUtf8(event.errorName, 64)),
87
+ ...(messageClass === undefined ? {} : { m: messageClass }),
88
+ }
89
+ : event.kind === "reported"
90
+ ? {
91
+ k: "reported",
92
+ fp: event.fingerprint,
93
+ at: event.at,
94
+ u: sanitizeEventText(truncateUtf8(event.issueUrl, 256)),
95
+ ...(event.commented ? { c: 1 } : {}),
96
+ }
97
+ : event.kind === "acknowledged"
98
+ ? { k: "acknowledged", fp: event.fingerprint, at: event.at }
99
+ : { k: "nudged", at: event.at };
100
+ return `${CRASH_EVENT_KIND} ${JSON.stringify(body)}\n`;
101
+ };
102
+
103
+ if (event.kind !== "occurrence") return truncateLine(build());
104
+ let preview = sanitizeEventText(truncateUtf8(event.messageClass, CRASH_EVENT_MESSAGE_MAX_BYTES));
105
+ let line = build(preview);
106
+ while (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES && preview.length > 0) {
107
+ preview = preview.slice(0, Math.floor(preview.length / 2));
108
+ line = build(preview);
109
+ }
110
+ if (Buffer.byteLength(line, "utf8") > CRASH_EVENT_MAX_BYTES) line = build();
111
+ return truncateLine(line);
112
+ }
113
+
114
+ /** Final safety net: an event line never exceeds the cap, even if malformed. */
115
+ function truncateLine(line: string): string {
116
+ if (Buffer.byteLength(line, "utf8") <= CRASH_EVENT_MAX_BYTES) return line;
117
+ return `${truncateUtf8(line.trimEnd(), CRASH_EVENT_MAX_BYTES - 1)}\n`;
118
+ }
119
+
120
+ function isSafeTimestamp(value: unknown): value is number {
121
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
122
+ }
123
+
124
+ /** Parse one journal line. Anything unexpected yields `undefined`, never a throw. */
125
+ export function parseCrashEventLine(line: string): CrashEvent | undefined {
126
+ const trimmed = line.trim();
127
+ if (!trimmed.startsWith(`${CRASH_EVENT_KIND} `)) return undefined;
128
+ if (Buffer.byteLength(trimmed, "utf8") > CRASH_EVENT_MAX_BYTES) return undefined;
129
+ let parsed: unknown;
130
+ try {
131
+ parsed = JSON.parse(trimmed.slice(CRASH_EVENT_KIND.length + 1)) as unknown;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
136
+ const body = parsed as Record<string, unknown>;
137
+ if (!isSafeTimestamp(body.at)) return undefined;
138
+ const at = body.at;
139
+ const fingerprint = typeof body.fp === "string" && CRASH_FINGERPRINT_PATTERN.test(body.fp) ? body.fp : undefined;
140
+
141
+ switch (body.k) {
142
+ case "occurrence": {
143
+ if (!fingerprint) return undefined;
144
+ if (typeof body.id !== "string" || !/^[0-9a-f]{8,32}$/.test(body.id)) return undefined;
145
+ if (typeof body.fpv !== "number" || !Number.isSafeInteger(body.fpv) || body.fpv < 1) return undefined;
146
+ const errorName = typeof body.n === "string" ? sanitizeEventText(body.n) : "Error";
147
+ const messageClass = typeof body.m === "string" ? sanitizeEventText(body.m) : "";
148
+ return { kind: "occurrence", fingerprint, fpv: body.fpv, recordId: body.id, at, errorName, messageClass };
149
+ }
150
+ case "reported": {
151
+ if (!fingerprint) return undefined;
152
+ if (typeof body.u !== "string" || body.u.length === 0) return undefined;
153
+ return { kind: "reported", fingerprint, at, issueUrl: sanitizeEventText(body.u), commented: body.c === 1 };
154
+ }
155
+ case "acknowledged": {
156
+ if (!fingerprint) return undefined;
157
+ return { kind: "acknowledged", fingerprint, at };
158
+ }
159
+ case "nudged":
160
+ return { kind: "nudged", at };
161
+ default:
162
+ return undefined;
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Append one event synchronously with `O_APPEND`. Never throws.
168
+ *
169
+ * No parse, no lock, no rename, no read: the only I/O is one bounded write to
170
+ * an append-only file, so concurrent writers interleave whole lines instead of
171
+ * corrupting each other's records.
172
+ */
173
+ export function appendCrashEvent(event: CrashEvent, journalPath: string): boolean {
174
+ try {
175
+ const line = formatCrashEventLine(event);
176
+ fs.mkdirSync(path.dirname(journalPath), { recursive: true });
177
+ const fd = fs.openSync(journalPath, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND, 0o600);
178
+ try {
179
+ fs.writeSync(fd, line);
180
+ } finally {
181
+ fs.closeSync(fd);
182
+ }
183
+ return true;
184
+ } catch {
185
+ // A failing journal write must never mask or delay the original fatal.
186
+ return false;
187
+ }
188
+ }
189
+
190
+ let fatalJournalLatched = false;
191
+
192
+ /**
193
+ * Fatal-path entry point: at most one journal append per process lifetime.
194
+ *
195
+ * A crash raised while handling a crash must not spend the process's remaining
196
+ * moments on journal bookkeeping, so the latch is never cleared — the process
197
+ * is exiting anyway. Returns whether this call wrote a line.
198
+ */
199
+ export function appendFatalCrashEvent(event: CrashEvent, journalPath: string): boolean {
200
+ if (fatalJournalLatched) return false;
201
+ fatalJournalLatched = true;
202
+ return appendCrashEvent(event, journalPath);
203
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Best-effort credential scrubbing for durable crash records.
3
+ *
4
+ * This is a *persistence-time* scrub: it keeps obvious credential shapes out of
5
+ * a file GJC keeps indefinitely. It is explicitly NOT a privacy guarantee and
6
+ * must never be treated as one for data that leaves the machine — outbound
7
+ * text goes through `sanitizeExternalCrashV1` instead.
8
+ */
9
+
10
+ /**
11
+ * Scrub credential material from crash text before it is persisted.
12
+ * Covers bearer/basic-style headers, key=value or JSON key forms of common
13
+ * credential names, and well-known vendor token shapes. Normal messages and
14
+ * stack frames are untouched; matches are replaced in place so surrounding
15
+ * diagnostic context survives.
16
+ */
17
+ export function redactCrashSecrets(text: string): string {
18
+ let redacted = text;
19
+ redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
20
+ redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
21
+ redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
22
+ // `gh[opsur]_` covers the classic PAT/OAuth/server/user/refresh prefixes;
23
+ // fine-grained PATs use an entirely different `github_pat_` prefix and would
24
+ // otherwise survive into a log the module keeps indefinitely.
25
+ redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
26
+ redacted = redacted.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "«redacted-github-token»");
27
+ redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
28
+ // AKIA is the long-term access key id; ASIA is the temporary/STS one, which is
29
+ // the shape that actually shows up in a crashed request. The id alone is not
30
+ // the credential: an STS payload carries `SecretAccessKey` and `SessionToken`
31
+ // alongside it, so the labeled-value rule below must name both. `secret_key`
32
+ // does not match `SecretAccessKey` (the canonical field has `Access` in the
33
+ // middle), and `access_token` does not match `SessionToken`.
34
+ redacted = redacted.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
35
+ redacted = redacted.replace(
36
+ /(?<![A-Za-z0-9_])(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|secret[_-]?key|secret[_-]?access[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
37
+ "$1«redacted»",
38
+ );
39
+ return redacted;
40
+ }
41
+
42
+ /** Every placeholder `redactCrashSecrets` can emit. Used by downstream normalizers. */
43
+ export const CRASH_REDACTION_MARKERS: readonly string[] = [
44
+ "«redacted-auth»",
45
+ "«redacted-jwt»",
46
+ "«redacted-api-key»",
47
+ "«redacted-github-token»",
48
+ "«redacted-slack-token»",
49
+ "«redacted-aws-key»",
50
+ "«redacted»",
51
+ ];
package/src/dirs.ts CHANGED
@@ -90,7 +90,7 @@ export function formatBunRuntimeError(opts: {
90
90
  * On macOS, strip /private prefix only when both paths resolve to the same location.
91
91
  * This preserves aliases like /private/tmp -> /tmp without rewriting unrelated paths.
92
92
  */
93
- function standardizeMacOSPath(p: string): string {
93
+ export function standardizeMacOSPath(p: string): string {
94
94
  if (process.platform !== "darwin" || !p.startsWith("/private/")) return p;
95
95
  const stripped = p.slice("/private".length);
96
96
  try {
@@ -487,6 +487,11 @@ export function getGithubCacheDbPath(): string {
487
487
  return dirs.rootSubdir(path.join("cache", "github-cache.db"), "cache");
488
488
  }
489
489
 
490
+ /** Get the durable tool-choice capability cache path. */
491
+ export function getToolChoiceCapabilityCachePath(): string {
492
+ return dirs.rootSubdir(path.join("cache", "tool-choice-capabilities.db"), "cache");
493
+ }
494
+
490
495
  /** Get the natives directory (~/.gjc/natives). */
491
496
  export function getNativesDir(): string {
492
497
  return dirs.rootSubdir("natives", "cache");
@@ -596,6 +601,16 @@ export function getCrashLogPath(agentDir?: string): string {
596
601
  return dirs.agentSubdir(agentDir, "gjc-crash.log", "state");
597
602
  }
598
603
 
604
+ /** Get the crash event journal path (~/.gjc/agent/gjc-crash-events.jsonl). */
605
+ export function getCrashEventsPath(agentDir?: string): string {
606
+ return dirs.agentSubdir(agentDir, "gjc-crash-events.jsonl", "state");
607
+ }
608
+
609
+ /** Get the compacted crash signature index path (~/.gjc/agent/gjc-crash-index.json). */
610
+ export function getCrashIndexPath(agentDir?: string): string {
611
+ return dirs.agentSubdir(agentDir, "gjc-crash-index.json", "state");
612
+ }
613
+
599
614
  /** Get the debug log path (~/.gjc/agent/gjc-debug.log). */
600
615
  export function getDebugLogPath(agentDir?: string): string {
601
616
  return dirs.agentSubdir(agentDir, `${APP_NAME}-debug.log`, "state");
package/src/index.ts CHANGED
@@ -2,6 +2,9 @@ export { createAbortableStream, once, untilAborted } from "./abortable";
2
2
  export * from "./async";
3
3
  export * from "./broken-pipe";
4
4
  export * from "./color";
5
+ export * from "./crash-fingerprint";
6
+ export * from "./crash-journal";
7
+ export * from "./crash-redaction";
5
8
  export * from "./dirs";
6
9
  export * from "./env";
7
10
  export * from "./fetch-retry";
package/src/postmortem.ts CHANGED
@@ -5,12 +5,17 @@
5
5
  * in response to process exit, signals, or fatal exceptions. It is intended to
6
6
  * allow reliably releasing resources or shutting down subprocesses, files, sockets, etc.
7
7
  */
8
+
9
+ import { randomBytes } from "node:crypto";
8
10
  import * as fs from "node:fs";
9
11
  import inspector from "node:inspector";
10
12
  import * as path from "node:path";
11
13
  import { isMainThread } from "node:worker_threads";
12
14
  import { BROKEN_PIPE_EXIT_CODE, createProcessStdoutEpipeClassifier } from "./broken-pipe";
13
- import { getCrashLogPath } from "./dirs";
15
+ import { computeCrashFingerprint, formatCrashRecordMarker } from "./crash-fingerprint";
16
+ import { appendFatalCrashEvent } from "./crash-journal";
17
+ import { redactCrashSecrets } from "./crash-redaction";
18
+ import { getCrashEventsPath, getCrashLogPath } from "./dirs";
14
19
  import * as logger from "./logger";
15
20
  import { safeStderrWrite } from "./safe-stderr";
16
21
 
@@ -406,47 +411,17 @@ export const CRASH_LOG_MAX_BYTES = 512 * 1024;
406
411
  export const CRASH_RECORD_MAX_BYTES = 64 * 1024;
407
412
  const CRASH_RECORD_TRUNCATION_MARKER = "\n… [crash record truncated]\n\n";
408
413
 
409
- /**
410
- * Best-effort scrub of credential material from a crash record before it is
411
- * persisted indefinitely. Covers bearer/basic-style headers, key=value or
412
- * JSON key forms of common credential names, and well-known vendor token
413
- * shapes. Normal messages and stack frames are untouched; matches are
414
- * replaced in place so surrounding diagnostic context survives.
415
- */
416
- function redactCrashSecrets(text: string): string {
417
- let redacted = text;
418
- redacted = redacted.replace(/\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}/gi, "«redacted-auth»");
419
- redacted = redacted.replace(/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, "«redacted-jwt»");
420
- redacted = redacted.replace(/\bsk-[A-Za-z0-9_-]{8,}\b/g, "«redacted-api-key»");
421
- // `gh[opsur]_` covers the classic PAT/OAuth/server/user/refresh prefixes;
422
- // fine-grained PATs use an entirely different `github_pat_` prefix and would
423
- // otherwise survive into a log the module keeps indefinitely.
424
- redacted = redacted.replace(/\bgh[opsur]_[A-Za-z0-9]{16,}\b/g, "«redacted-github-token»");
425
- redacted = redacted.replace(/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, "«redacted-github-token»");
426
- redacted = redacted.replace(/\bxox[baprs]-[A-Za-z0-9-]{8,}\b/g, "«redacted-slack-token»");
427
- // AKIA is the long-term access key id; ASIA is the temporary/STS one, which is
428
- // the shape that actually shows up in a crashed request. The id alone is not
429
- // the credential: an STS payload carries `SecretAccessKey` and `SessionToken`
430
- // alongside it, so the labeled-value rule below must name both. `secret_key`
431
- // does not match `SecretAccessKey` (the canonical field has `Access` in the
432
- // middle), and `access_token` does not match `SessionToken`.
433
- redacted = redacted.replace(/\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g, "«redacted-aws-key»");
434
- redacted = redacted.replace(
435
- /(?<![A-Za-z0-9_])(["']?(?:api[_-]?key|apikey|access[_-]?token|refresh[_-]?token|id[_-]?token|session[_-]?token|client[_-]?secret|secret[_-]?key|secret[_-]?access[_-]?key|password|passwd|authorization)["']?\s*[=:]\s*["']?)[^\s"',;}\]]{8,}/gi,
436
- "$1«redacted»",
437
- );
438
- return redacted;
439
- }
414
+ export { redactCrashSecrets };
440
415
 
441
416
  /**
442
- * Bound one record to CRASH_RECORD_MAX_BYTES without splitting a UTF-8
443
- * sequence. Keeps the header (timestamp/label/message) at the front, where
444
- * the diagnostic value is highest.
417
+ * Bound one record to `maxBytes` without splitting a UTF-8 sequence. Keeps the
418
+ * header (timestamp/label/message) at the front, where the diagnostic value is
419
+ * highest.
445
420
  */
446
- function boundCrashRecord(report: string): string {
447
- if (Buffer.byteLength(report, "utf8") <= CRASH_RECORD_MAX_BYTES) return report;
421
+ function boundCrashRecord(report: string, maxBytes: number = CRASH_RECORD_MAX_BYTES): string {
422
+ if (Buffer.byteLength(report, "utf8") <= maxBytes) return report;
448
423
  const bytes = Buffer.from(report, "utf8");
449
- const budget = CRASH_RECORD_MAX_BYTES - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
424
+ const budget = maxBytes - Buffer.byteLength(CRASH_RECORD_TRUNCATION_MARKER, "utf8");
450
425
  let end = budget;
451
426
  // Drop trailing continuation bytes of a truncated multi-byte sequence.
452
427
  while (end > 0 && (bytes[end - 1] & 0xc0) === 0x80) end--;
@@ -468,29 +443,35 @@ function boundCrashRecord(report: string): string {
468
443
  * original fatal) and uses synchronous IO so the record lands before
469
444
  * `process.exit`. Returns the path written, or `undefined` on failure.
470
445
  */
471
- export function recordFatalCrash(
472
- label: string,
473
- reason: unknown,
474
- options: { path?: string; now?: Date } = {},
475
- ): string | undefined {
446
+ export function recordFatalCrash(label: string, reason: unknown, options: CrashRecordOptions = {}): string | undefined {
476
447
  return writeCrashRecord(label, describeFatal(reason), options);
477
448
  }
478
449
 
479
- function writeCrashRecord(
480
- label: string,
481
- fatal: FatalDiagnostic,
482
- options: { path?: string; now?: Date } = {},
483
- ): string | undefined {
450
+ interface CrashRecordOptions {
451
+ path?: string;
452
+ now?: Date;
453
+ }
454
+
455
+ function writeCrashRecord(label: string, fatal: FatalDiagnostic, options: CrashRecordOptions = {}): string | undefined {
484
456
  try {
485
457
  const target = options.path ?? getCrashLogPath();
486
458
  const now = options.now ?? new Date();
487
459
  const stack = fatal.stack ? `${redactCrashSecrets(fatal.stack)}\n` : "";
488
460
  const payload = fatal.payload ? `${redactCrashSecrets(fatal.payload)}\n` : "";
489
- const report = boundCrashRecord(
461
+ // Identity is computed from the already-captured diagnostic text only; the
462
+ // throwable is never read again here.
463
+ const fingerprint = computeCrashFingerprint(fatal);
464
+ const recordId = randomBytes(8).toString("hex");
465
+ const markerLine = `${formatCrashRecordMarker(fingerprint.fingerprint, fingerprint.version, recordId)}\n`;
466
+ // The marker is the record's identity, so it is budgeted first and appended
467
+ // after truncation: an oversized body can never evict it.
468
+ const body = boundCrashRecord(
490
469
  `${now.toISOString()} pid=${process.pid} [${label}] ` +
491
470
  `${redactCrashSecrets(fatal.name)}: ${redactCrashSecrets(fatal.message)}\n` +
492
- `${stack}${payload}\n`,
471
+ `${stack}${payload}`,
472
+ CRASH_RECORD_MAX_BYTES - Buffer.byteLength(markerLine, "utf8") - 1,
493
473
  );
474
+ const report = `${body}${markerLine}\n`;
494
475
  fs.mkdirSync(path.dirname(target), { recursive: true });
495
476
  let existingSize = 0;
496
477
  try {
@@ -508,6 +489,25 @@ function writeCrashRecord(
508
489
  try {
509
490
  fs.chmodSync(target, 0o600);
510
491
  } catch {}
492
+ // The journal always lives beside the crash log it describes, so an
493
+ // overridden crash-log path (tests, alternate agent dirs) keeps its events
494
+ // in the same scope instead of leaking into the user's agent dir.
495
+ const eventsPath =
496
+ options.path === undefined
497
+ ? getCrashEventsPath()
498
+ : path.join(path.dirname(target), path.basename(getCrashEventsPath()));
499
+ appendFatalCrashEvent(
500
+ {
501
+ kind: "occurrence",
502
+ fingerprint: fingerprint.fingerprint,
503
+ fpv: fingerprint.version,
504
+ recordId,
505
+ at: now.getTime(),
506
+ errorName: fingerprint.errorName,
507
+ messageClass: fingerprint.messageClass,
508
+ },
509
+ eventsPath,
510
+ );
511
511
  return target;
512
512
  } catch {
513
513
  return undefined;