@linxiraos/pi-utils 1.4.3 → 1.4.6
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 +12 -0
- package/THIRD-PARTY-NOTICES.txt +14 -0
- package/dist/types/acp/protocol.d.ts +1 -0
- package/dist/types/ar/tar.d.ts +1 -1
- package/dist/types/ar/types.d.ts +1 -1
- package/dist/types/format.d.ts +2 -1
- package/dist/types/json.d.ts +7 -0
- package/dist/types/marked/inline-closers.d.ts +18 -5
- package/dist/types/mime.d.ts +7 -0
- package/dist/types/postmortem.d.ts +19 -2
- package/dist/types/process-name.d.ts +2 -1
- package/dist/types/which.d.ts +13 -0
- package/package.json +2 -2
- package/src/acp/protocol.ts +1 -0
- package/src/ar/tar.ts +66 -10
- package/src/ar/types.ts +1 -1
- package/src/dirs.ts +15 -1
- package/src/format.ts +6 -2
- package/src/json.ts +25 -0
- package/src/marked/core.ts +22 -17
- package/src/marked/inline-closers.ts +37 -42
- package/src/mime.ts +35 -0
- package/src/postmortem.ts +174 -25
- package/src/process-name.ts +10 -5
- package/src/stream.ts +16 -9
- package/src/which.ts +27 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [1.4.6] - 2026-10-28
|
|
4
|
+
|
|
5
|
+
- Released as part of Zeta 1.4.6; no package-specific user-visible changes.
|
|
6
|
+
|
|
7
|
+
## [1.4.5] - 2026-10-28
|
|
8
|
+
|
|
9
|
+
- Synced with upstream OMP v18.13.2 (shared utilities and runtime fixes); user-visible changes are in UPDATE-LOG.md under 1.4.5.
|
|
10
|
+
|
|
11
|
+
## [1.4.4] - 2026-10-26
|
|
12
|
+
|
|
13
|
+
- Released as part of Zeta 1.4.4; no package-specific user-visible changes.
|
|
14
|
+
|
|
3
15
|
## [1.4.3] - 2026-10-25
|
|
4
16
|
|
|
5
17
|
- Released as part of Zeta 1.4.3; no package-specific user-visible changes.
|
package/THIRD-PARTY-NOTICES.txt
CHANGED
|
@@ -335,6 +335,20 @@ This license allows the work and adaptations of it to be shared and used
|
|
|
335
335
|
commercially, as long as it is attributed to Poppy Works. The font is bundled
|
|
336
336
|
here (crates/pi-natives/src/fonts/Silver.ttf) as a CJK/Unicode bitmap fallback.
|
|
337
337
|
|
|
338
|
+
-------------------------------------------------------------------------------
|
|
339
|
+
main/crates/zetawork-gui/assets/fonts/MapleMono-CN-Regular.ttf
|
|
340
|
+
|
|
341
|
+
Maple Mono CN
|
|
342
|
+
Source: https://github.com/subframe7536/maple-font
|
|
343
|
+
Version: 7.9
|
|
344
|
+
Author: The Maple Mono Project Authors
|
|
345
|
+
Copyright 2022 The Maple Mono Project Authors
|
|
346
|
+
|
|
347
|
+
License: SIL Open Font License, Version 1.1
|
|
348
|
+
The full license text is bundled at
|
|
349
|
+
main/crates/zetawork-gui/assets/fonts/OFL.txt and is installed with the
|
|
350
|
+
desktop package. The font file is embedded unmodified in the GUI binary.
|
|
351
|
+
|
|
338
352
|
-------------------------------------------------------------------------------
|
|
339
353
|
packages/coding-agent/src/markit/NOTICE
|
|
340
354
|
|
|
@@ -93,6 +93,7 @@ export interface ToolCallLocation extends Meta {
|
|
|
93
93
|
export interface ToolCall extends Meta {
|
|
94
94
|
toolCallId: ToolCallId;
|
|
95
95
|
title: string;
|
|
96
|
+
name?: string | null;
|
|
96
97
|
kind?: ToolKind | null;
|
|
97
98
|
status?: ToolCallStatus | null;
|
|
98
99
|
content?: ToolCallContent[] | null;
|
package/dist/types/ar/tar.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ export declare function readTarEntriesFromBuffer(buffer: Uint8Array, options: Fo
|
|
|
6
6
|
* pass over `open()` walks the headers, and member reads open it again.
|
|
7
7
|
*/
|
|
8
8
|
export declare function readTarEntriesFromStream(open: () => AsyncIterable<Uint8Array>, options: FormatReadOptions): Promise<ArchiveIndexEntry[]>;
|
|
9
|
-
/**
|
|
9
|
+
/** Index a plain tar by ranging over headers and retaining lazy member offsets. */
|
|
10
10
|
export declare const readTar: FormatReader;
|
|
11
11
|
/** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
|
|
12
12
|
export declare function sniffTar(bytes: Uint8Array): boolean;
|
package/dist/types/ar/types.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
|
|
|
8
8
|
* Where to read an archive from: an extension-inferred filesystem path, a
|
|
9
9
|
* format-tagged filesystem path, in-memory bytes, or any caller-provided
|
|
10
10
|
* {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
|
|
11
|
-
* File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
|
|
11
|
+
* File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
|
|
12
12
|
*/
|
|
13
13
|
export type ArchiveSource = string | {
|
|
14
14
|
bytes: Uint8Array;
|
package/dist/types/format.d.ts
CHANGED
|
@@ -6,9 +6,10 @@ export declare function formatDuration(ms: number): string;
|
|
|
6
6
|
/**
|
|
7
7
|
* Format a number with K/M/B suffix for compact display.
|
|
8
8
|
* Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
|
|
9
|
+
* Values below 1,000 print as-is unless `maxFractionDigits` caps their decimals.
|
|
9
10
|
* Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
|
|
10
11
|
*/
|
|
11
|
-
export declare function formatNumber(n: number): string;
|
|
12
|
+
export declare function formatNumber(n: number, maxFractionDigits?: number): string;
|
|
12
13
|
/**
|
|
13
14
|
* Format a byte count to a human-readable string.
|
|
14
15
|
* Examples: "512B", "1.5KB", "2.3MB", "1.2GB"
|
package/dist/types/json.d.ts
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
* Try to parse JSON, returning null on failure.
|
|
3
3
|
*/
|
|
4
4
|
export declare function tryParseJson<T = unknown>(content: string): T | null;
|
|
5
|
+
/**
|
|
6
|
+
* Index just past the JSON object or array that opens at `text[start]`, or -1
|
|
7
|
+
* when it does not close within `text`. String literals are skipped, honoring
|
|
8
|
+
* escapes, so brackets inside values do not count. Malformed input yields some
|
|
9
|
+
* end that a parser then rejects.
|
|
10
|
+
*/
|
|
11
|
+
export declare function jsonContainerEnd(text: string, start: number): number;
|
|
5
12
|
/**
|
|
6
13
|
* Serialize JSON while preserving bigint precision as decimal strings.
|
|
7
14
|
*
|
|
@@ -45,15 +45,23 @@ declare class EmphasisDelimiters {
|
|
|
45
45
|
}
|
|
46
46
|
/**
|
|
47
47
|
* The runs of backticks in the root source, for the code span rule. A span opened by `width` backticks closes at the
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
48
|
+
* next run of exactly `width` backticks: a run of any other length is no closer, and the one that closes is the first
|
|
49
|
+
* run of that width at or after the opener. Backslash escapes do not apply inside a code span, so a backslash just
|
|
50
|
+
* before that closing backtick is content and still closes the span.
|
|
51
51
|
*/
|
|
52
52
|
declare class CodeRuns {
|
|
53
53
|
#private;
|
|
54
54
|
constructor(src: string);
|
|
55
55
|
/** Where the run of backticks holding the backtick at `at` ends. */
|
|
56
56
|
endOf(at: number): number;
|
|
57
|
+
/**
|
|
58
|
+
* Whether a backtick string starts at `at` for a scan that walked to `at`: a string is a whole run, so it starts at
|
|
59
|
+
* the run's first backtick and nowhere else. A retry deeper inside a run is not a string, which keeps a run whose
|
|
60
|
+
* opener found no closer from opening a narrower span one backtick later. A position a tokenizer's raw resumed at,
|
|
61
|
+
* or the suffix an escape token left behind, is not a walked position — `codeOpenerWidth` admits those on its own,
|
|
62
|
+
* because it is told the scan resumed there rather than inferring it from the source text.
|
|
63
|
+
*/
|
|
64
|
+
startsStringAt(at: number): boolean;
|
|
57
65
|
/** Where the closer of a code span opened by `width` backticks that end at `from` starts, or -1. */
|
|
58
66
|
closerAfter(from: number, width: number): number;
|
|
59
67
|
}
|
|
@@ -102,8 +110,13 @@ export declare class InlineClosers {
|
|
|
102
110
|
escapeWithin(rest: string, from: number, to: number): boolean;
|
|
103
111
|
/** Where the emphasis opened by the `width` markers at the start of `rest` closes, or -1. */
|
|
104
112
|
closeEmphasis(rest: string, marker: string, width: number): number;
|
|
105
|
-
/**
|
|
106
|
-
|
|
113
|
+
/**
|
|
114
|
+
* How many backticks start `rest`, which starts with one, or 0 where no code span opens because no backtick string
|
|
115
|
+
* starts there: the opener is a whole string, so the rest of a run whose opener found no closer is text, while the
|
|
116
|
+
* backticks left after a backslash escaped the run's first one do open a span. `resumed` says the scan resumed at
|
|
117
|
+
* `rest` after a tokenizer consumed a raw that ended here, where the backticks are a string wherever they sit.
|
|
118
|
+
*/
|
|
119
|
+
codeOpenerWidth(rest: string, resumed?: boolean): number;
|
|
107
120
|
/** Where the code span opened by the `width` backticks at the start of `rest` closes, or -1. */
|
|
108
121
|
closeCode(rest: string, width: number): number;
|
|
109
122
|
/**
|
package/dist/types/mime.d.ts
CHANGED
|
@@ -26,6 +26,13 @@ export type ImageMetadata = {
|
|
|
26
26
|
hasAlpha?: boolean;
|
|
27
27
|
};
|
|
28
28
|
export declare function parseImageMetadata(header: Uint8Array): ImageMetadata | null;
|
|
29
|
+
/** Still images in a HEIF (ISO BMFF) container, which the image pipeline cannot decode itself. */
|
|
30
|
+
export type HeifImageMimeType = "image/heic" | "image/heif" | "image/avif";
|
|
31
|
+
/**
|
|
32
|
+
* MIME type of a HEIC, AVIF or other HEIF image from the brands of its leading `ftyp` box:
|
|
33
|
+
* the major brand, then the compatible brands, with the generic `mif1`/`msf1` brands last.
|
|
34
|
+
*/
|
|
35
|
+
export declare function parseHeifImageMimeType(header: Uint8Array): HeifImageMimeType | null;
|
|
29
36
|
/** Why base64 image data is unusable; `undefined` when it is canonical and non-empty. */
|
|
30
37
|
export type Base64ImageDataFailure = "invalid base64 image data" | "empty image data";
|
|
31
38
|
/**
|
|
@@ -52,7 +52,7 @@ export declare const NATIVE_PROCESS_EXIT: unique symbol;
|
|
|
52
52
|
* signal and fatal exit.
|
|
53
53
|
*/
|
|
54
54
|
export declare function exitProcess(code: number): never;
|
|
55
|
-
/** User-facing command printed
|
|
55
|
+
/** User-facing command printed after fatal or SIGTERM cleanup so interrupted work can be resumed. */
|
|
56
56
|
export interface FatalRecoveryHint {
|
|
57
57
|
/** Stable label identifying the recoverable session or process. */
|
|
58
58
|
label: string;
|
|
@@ -168,7 +168,10 @@ export declare function isExpectedCleanupError(reason: unknown): boolean;
|
|
|
168
168
|
export declare function interceptUnhandledRejections(interceptor: (reason: unknown) => boolean): () => void;
|
|
169
169
|
/**
|
|
170
170
|
* Register a synchronous recovery command to print when the process exits
|
|
171
|
-
* through an uncaught exception
|
|
171
|
+
* through an uncaught exception, an unhandled rejection, or SIGTERM.
|
|
172
|
+
*
|
|
173
|
+
* Providers are read once cleanup finishes (or hits its deadline), so they
|
|
174
|
+
* report what teardown left behind: a session it saved or one it dropped.
|
|
172
175
|
*/
|
|
173
176
|
export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
|
|
174
177
|
/**
|
|
@@ -182,6 +185,20 @@ export interface CleanupRegistrationOptions {
|
|
|
182
185
|
* The registration remains armed when a keep-alive pass skips it.
|
|
183
186
|
*/
|
|
184
187
|
exitOnly?: boolean;
|
|
188
|
+
/**
|
|
189
|
+
* How long a pass waits for this callback, when that is longer than the
|
|
190
|
+
* default 10 s cleanup deadline. It extends the pass only while the callback
|
|
191
|
+
* is still pending: one that settles early ends its hold at once.
|
|
192
|
+
*/
|
|
193
|
+
deadlineMs?: number;
|
|
194
|
+
/**
|
|
195
|
+
* With `deadlineMs`: a line printed to stderr, with the remaining wait
|
|
196
|
+
* appended, if exit is still waiting on this callback once every other
|
|
197
|
+
* callback has finished (a terminal owner has then released the screen), or
|
|
198
|
+
* at the default 10 s deadline when another callback is still running.
|
|
199
|
+
* Nothing is printed when the callback finishes first.
|
|
200
|
+
*/
|
|
201
|
+
waitMessage?: string;
|
|
185
202
|
}
|
|
186
203
|
/**
|
|
187
204
|
* Registers a cleanup callback for shutdown, signals, fatal errors, and
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Set
|
|
2
|
+
* Set the JS `process.title` (except on Windows, see above) and — on Linux —
|
|
3
|
+
* the kernel `comm` name.
|
|
3
4
|
*
|
|
4
5
|
* Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
|
|
5
6
|
* to the `process.title`-only behavior, so it is safe to call at startup.
|
package/dist/types/which.d.ts
CHANGED
|
@@ -42,4 +42,17 @@ export declare const whichFresh: typeof darwinWhich;
|
|
|
42
42
|
* @returns Filesystem path if found, else null
|
|
43
43
|
*/
|
|
44
44
|
export declare function $which(command: string, options?: WhichOptions): string | null;
|
|
45
|
+
/**
|
|
46
|
+
* Locate every match for `command` on PATH, in PATH order, uncached.
|
|
47
|
+
*
|
|
48
|
+
* Callers that probe candidates (e.g. the Python eval backend) use this so a broken
|
|
49
|
+
* first hit — a version-manager shim with no version selected, a Windows Store alias —
|
|
50
|
+
* does not hide a working binary further down PATH. On macOS the Xcode/CLT toolchain
|
|
51
|
+
* match is appended last, mirroring {@link $which}'s fallback.
|
|
52
|
+
*
|
|
53
|
+
* @param command - Binary name to resolve
|
|
54
|
+
* @param options - `PATH` (defaults to `process.env.PATH`) and `cwd` for relative entries
|
|
55
|
+
* @returns Distinct filesystem paths; empty when nothing matches
|
|
56
|
+
*/
|
|
57
|
+
export declare function $whichAll(command: string, options?: Bun.WhichOptions): string[];
|
|
45
58
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxiraos/pi-utils",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
4
4
|
"description": "Shared utilities for pi packages",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"utilities",
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
-
"@linxiraos/pi-natives": "1.4.
|
|
57
|
+
"@linxiraos/pi-natives": "1.4.6"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "1.4.3"
|
package/src/acp/protocol.ts
CHANGED
|
@@ -102,6 +102,7 @@ export interface ToolCallLocation extends Meta {
|
|
|
102
102
|
export interface ToolCall extends Meta {
|
|
103
103
|
toolCallId: ToolCallId;
|
|
104
104
|
title: string;
|
|
105
|
+
name?: string | null;
|
|
105
106
|
kind?: ToolKind | null;
|
|
106
107
|
status?: ToolCallStatus | null;
|
|
107
108
|
content?: ToolCallContent[] | null;
|
package/src/ar/tar.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
normalizeArchiveEntryPath,
|
|
17
17
|
normalizeArchiveLookupPath,
|
|
18
18
|
} from "./paths";
|
|
19
|
-
import {
|
|
19
|
+
import type { ByteSource } from "./source";
|
|
20
20
|
import type { ArchiveIndexEntry, FormatReader, FormatReadOptions, MemberSource } from "./types";
|
|
21
21
|
|
|
22
22
|
const BLOCK_SIZE = 512;
|
|
@@ -53,8 +53,44 @@ interface PendingTarLink {
|
|
|
53
53
|
targetPath: string;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
interface TarCursor {
|
|
57
|
+
readonly position: number;
|
|
58
|
+
read(length: number): Promise<Uint8Array>;
|
|
59
|
+
skip(length: number): Promise<boolean>;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Seek over plain tar payloads without fetching their bytes. */
|
|
63
|
+
class TarSourceCursor implements TarCursor {
|
|
64
|
+
readonly #source: ByteSource;
|
|
65
|
+
#position = 0;
|
|
66
|
+
|
|
67
|
+
constructor(source: ByteSource) {
|
|
68
|
+
this.#source = source;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
get position(): number {
|
|
72
|
+
return this.#position;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async read(length: number): Promise<Uint8Array> {
|
|
76
|
+
const size = Math.min(length, this.#source.size - this.#position);
|
|
77
|
+
if (size === 0) return new Uint8Array(0);
|
|
78
|
+
const end = this.#position + size;
|
|
79
|
+
const bytes = await this.#source.read(this.#position, end);
|
|
80
|
+
if (bytes.byteLength !== size) throw new ArchiveError("Invalid archive: truncated data");
|
|
81
|
+
this.#position = end;
|
|
82
|
+
return bytes;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async skip(length: number): Promise<boolean> {
|
|
86
|
+
if (length > this.#source.size - this.#position) return false;
|
|
87
|
+
this.#position += length;
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
56
92
|
/** Forward-only reader over tar bytes that arrive in chunks: one buffer, or a decompressing stream. */
|
|
57
|
-
class TarByteStream {
|
|
93
|
+
class TarByteStream implements TarCursor {
|
|
58
94
|
readonly #chunks: AsyncIterator<Uint8Array> | Iterator<Uint8Array>;
|
|
59
95
|
#chunk: Uint8Array = new Uint8Array(0);
|
|
60
96
|
#position = 0;
|
|
@@ -119,6 +155,27 @@ interface TarPayload {
|
|
|
119
155
|
read(offset: number, size: number, memberPath: string): Promise<Uint8Array>;
|
|
120
156
|
}
|
|
121
157
|
|
|
158
|
+
class TarSourcePayload implements TarPayload {
|
|
159
|
+
readonly #source: ByteSource;
|
|
160
|
+
|
|
161
|
+
constructor(source: ByteSource) {
|
|
162
|
+
this.#source = source;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async read(offset: number, size: number, memberPath: string): Promise<Uint8Array> {
|
|
166
|
+
try {
|
|
167
|
+
const bytes = await this.#source.read(offset, offset + size);
|
|
168
|
+
if (bytes.byteLength !== size) {
|
|
169
|
+
throw new ArchiveError(`Archive member '${formatArchivePathForError(memberPath)}' is truncated`);
|
|
170
|
+
}
|
|
171
|
+
return bytes;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error instanceof ArchiveError) throw error;
|
|
174
|
+
throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar member");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
122
179
|
class TarBufferPayload implements TarPayload {
|
|
123
180
|
readonly #buffer: Uint8Array;
|
|
124
181
|
|
|
@@ -533,7 +590,7 @@ function resolvePendingLinks(
|
|
|
533
590
|
|
|
534
591
|
/** Read one metadata record's payload, bounded by `maxIndexSize`, and step over its padding. */
|
|
535
592
|
async function readTarMetadata(
|
|
536
|
-
stream:
|
|
593
|
+
stream: TarCursor,
|
|
537
594
|
size: number,
|
|
538
595
|
limits: ArchiveLimits,
|
|
539
596
|
what: string,
|
|
@@ -551,7 +608,7 @@ async function readTarMetadata(
|
|
|
551
608
|
* stepped over, never held: entries point into `payload` by stream offset.
|
|
552
609
|
*/
|
|
553
610
|
async function readTarEntries(
|
|
554
|
-
stream:
|
|
611
|
+
stream: TarCursor,
|
|
555
612
|
payload: TarPayload,
|
|
556
613
|
options: FormatReadOptions,
|
|
557
614
|
): Promise<ArchiveIndexEntry[]> {
|
|
@@ -737,18 +794,17 @@ export async function readTarEntriesFromStream(
|
|
|
737
794
|
}
|
|
738
795
|
}
|
|
739
796
|
|
|
740
|
-
/**
|
|
797
|
+
/** Index a plain tar by ranging over headers and retaining lazy member offsets. */
|
|
741
798
|
export const readTar: FormatReader = async (source, options) => {
|
|
742
|
-
|
|
743
|
-
|
|
799
|
+
if (!Number.isSafeInteger(source.size) || source.size < 0) {
|
|
800
|
+
throw new ArchiveError("Archive is too large to read safely");
|
|
801
|
+
}
|
|
744
802
|
try {
|
|
745
|
-
|
|
803
|
+
return await readTarEntries(new TarSourceCursor(source), new TarSourcePayload(source), options);
|
|
746
804
|
} catch (error) {
|
|
747
805
|
if (error instanceof ArchiveError) throw error;
|
|
748
806
|
throw new ArchiveError(error instanceof Error ? error.message : "Failed to read tar archive");
|
|
749
807
|
}
|
|
750
|
-
if (bytes.byteLength !== source.size) throw new ArchiveError("Invalid archive: truncated data");
|
|
751
|
-
return readTarEntriesFromBuffer(bytes, options);
|
|
752
808
|
};
|
|
753
809
|
|
|
754
810
|
/** Detect a tar header, including legacy pre-ustar archives, by its checksum. */
|
package/src/ar/types.ts
CHANGED
|
@@ -37,7 +37,7 @@ export type WritableArchiveFormat = "zip" | "tar" | "tar.gz" | "tar.zst" | "asar
|
|
|
37
37
|
* Where to read an archive from: an extension-inferred filesystem path, a
|
|
38
38
|
* format-tagged filesystem path, in-memory bytes, or any caller-provided
|
|
39
39
|
* {@link ByteSource} (e.g. `httpByteSource` for ranged remote reads).
|
|
40
|
-
* File- and source-backed ZIP/ASAR/RAR/7z/ISO are read lazily.
|
|
40
|
+
* File- and source-backed TAR/ZIP/ASAR/RAR/7z/ISO are read lazily.
|
|
41
41
|
*/
|
|
42
42
|
export type ArchiveSource =
|
|
43
43
|
| string
|
package/src/dirs.ts
CHANGED
|
@@ -333,7 +333,21 @@ export function getSafeProjectCwd(): string {
|
|
|
333
333
|
|
|
334
334
|
/** Get the config directory name relative to home (e.g. ".zeta" or ZETA_CONFIG_DIR override). */
|
|
335
335
|
export function getConfigDirName(): string {
|
|
336
|
-
|
|
336
|
+
const configured = process.env.ZETA_CONFIG_DIR;
|
|
337
|
+
if (!configured) return CONFIG_DIR_NAME;
|
|
338
|
+
// This value is a directory *name* under the user's home, never a path to
|
|
339
|
+
// adopt directly. Absolute paths would be re-joined to home by every
|
|
340
|
+
// consumer (`getBaseConfigRoot`, project discovery, plugin roots) and
|
|
341
|
+
// produce inconsistent trees; `..` would escape home entirely. Reject
|
|
342
|
+
// both at the boundary instead of scattering files.
|
|
343
|
+
if (path.isAbsolute(configured) || path.parse(configured).root !== "") {
|
|
344
|
+
throw new Error("ZETA_CONFIG_DIR must be a relative directory name under the home directory");
|
|
345
|
+
}
|
|
346
|
+
const normalized = path.normalize(configured);
|
|
347
|
+
if (normalized === "." || normalized === ".." || normalized.startsWith(`..${path.sep}`)) {
|
|
348
|
+
throw new Error("ZETA_CONFIG_DIR must stay within the home directory");
|
|
349
|
+
}
|
|
350
|
+
return normalized;
|
|
337
351
|
}
|
|
338
352
|
|
|
339
353
|
/** Get the config agent directory name relative to home (e.g. ".zeta/agent" or PI_CONFIG_DIR + "/agent"). */
|
package/src/format.ts
CHANGED
|
@@ -33,10 +33,14 @@ export function formatDuration(ms: number): string {
|
|
|
33
33
|
/**
|
|
34
34
|
* Format a number with K/M/B suffix for compact display.
|
|
35
35
|
* Uses 1 decimal for small leading digits when non-zero, rounded otherwise.
|
|
36
|
+
* Values below 1,000 print as-is unless `maxFractionDigits` caps their decimals.
|
|
36
37
|
* Examples: "999", "1K", "1.5K", "25K", "1M", "1.5M", "25M", "1.5B"
|
|
37
38
|
*/
|
|
38
|
-
export function formatNumber(n: number): string {
|
|
39
|
-
if (n < 1_000)
|
|
39
|
+
export function formatNumber(n: number, maxFractionDigits?: number): string {
|
|
40
|
+
if (n < 1_000) {
|
|
41
|
+
if (maxFractionDigits === undefined || Number.isInteger(n)) return n.toString();
|
|
42
|
+
return Number(n.toFixed(maxFractionDigits)).toString();
|
|
43
|
+
}
|
|
40
44
|
if (n < 10_000) return `${trim1(n / 1_000)}K`;
|
|
41
45
|
if (n < 1_000_000) return `${Math.round(n / 1_000)}K`;
|
|
42
46
|
if (n < 10_000_000) return `${trim1(n / 1_000_000)}M`;
|
package/src/json.ts
CHANGED
|
@@ -9,6 +9,31 @@ export function tryParseJson<T = unknown>(content: string): T | null {
|
|
|
9
9
|
}
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Index just past the JSON object or array that opens at `text[start]`, or -1
|
|
14
|
+
* when it does not close within `text`. String literals are skipped, honoring
|
|
15
|
+
* escapes, so brackets inside values do not count. Malformed input yields some
|
|
16
|
+
* end that a parser then rejects.
|
|
17
|
+
*/
|
|
18
|
+
export function jsonContainerEnd(text: string, start: number): number {
|
|
19
|
+
let depth = 0;
|
|
20
|
+
for (let index = start; index < text.length; index++) {
|
|
21
|
+
const code = text.charCodeAt(index);
|
|
22
|
+
if (code === 0x22) {
|
|
23
|
+
for (index++; index < text.length; index++) {
|
|
24
|
+
const inner = text.charCodeAt(index);
|
|
25
|
+
if (inner === 0x5c) index++;
|
|
26
|
+
else if (inner === 0x22) break;
|
|
27
|
+
}
|
|
28
|
+
} else if (code === 0x7b || code === 0x5b) {
|
|
29
|
+
depth++;
|
|
30
|
+
} else if ((code === 0x7d || code === 0x5d) && --depth === 0) {
|
|
31
|
+
return index + 1;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return -1;
|
|
35
|
+
}
|
|
36
|
+
|
|
12
37
|
/**
|
|
13
38
|
* Serialize JSON while preserving bigint precision as decimal strings.
|
|
14
39
|
*
|
package/src/marked/core.ts
CHANGED
|
@@ -655,39 +655,48 @@ function lexToNested(lex: InlineLex): NestedMatch | undefined {
|
|
|
655
655
|
// The context is shared with the sources nested in this one, which move its end while they are lexed.
|
|
656
656
|
context.end = closers.end;
|
|
657
657
|
let rest = lex.rest;
|
|
658
|
+
// A token the lexer consumed ends the walk, so the backticks left where the next token starts are a string of their
|
|
659
|
+
// own even when the run they belong to began earlier: an extension, an override or an escape token may leave the
|
|
660
|
+
// scan inside a run, and a code span opens there. Only the text step's own advance leaves the scan inside a run
|
|
661
|
+
// without consuming a token, and no span opens at such a position.
|
|
662
|
+
let resumed = false;
|
|
663
|
+
/** Pushes `token` and moves past its raw, which is where the next token starts. */
|
|
664
|
+
const consumeToken = (token: Token): void => {
|
|
665
|
+
output.push(token);
|
|
666
|
+
rest = rest.slice(token.raw.length);
|
|
667
|
+
resumed = true;
|
|
668
|
+
};
|
|
658
669
|
while (rest !== "") {
|
|
670
|
+
const resumedHere = resumed;
|
|
671
|
+
resumed = false;
|
|
659
672
|
let custom: Tokens.Generic | undefined;
|
|
660
673
|
for (const extension of lexer.extensions.inline) {
|
|
661
674
|
custom = extension.tokenizer.call(context, rest, output);
|
|
662
675
|
if (custom?.raw) break;
|
|
663
676
|
}
|
|
664
677
|
if (custom?.raw) {
|
|
665
|
-
|
|
666
|
-
rest = rest.slice(custom.raw.length);
|
|
678
|
+
consumeToken(custom);
|
|
667
679
|
continue;
|
|
668
680
|
}
|
|
669
681
|
|
|
670
682
|
const escaped = /^\\([!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~])/.exec(rest);
|
|
671
683
|
if (escaped) {
|
|
672
|
-
|
|
673
|
-
rest = rest.slice(2);
|
|
684
|
+
consumeToken({ type: "escape", raw: escaped[0], text: escaped[1]! });
|
|
674
685
|
continue;
|
|
675
686
|
}
|
|
676
687
|
const br = lexer.options.breaks ? /^(?: {2,}|\\)?\n/.exec(rest) : /^(?: {2,}|\\)\n/.exec(rest);
|
|
677
688
|
if (br) {
|
|
678
|
-
|
|
679
|
-
rest = rest.slice(br[0].length);
|
|
689
|
+
consumeToken({ type: "br", raw: br[0] });
|
|
680
690
|
continue;
|
|
681
691
|
}
|
|
682
692
|
if (rest[0] === "`") {
|
|
683
|
-
const width = closers.codeOpenerWidth(rest);
|
|
693
|
+
const width = closers.codeOpenerWidth(rest, resumedHere);
|
|
684
694
|
const end = closers.closeCode(rest, width);
|
|
685
695
|
if (end !== -1) {
|
|
686
696
|
const raw = rest.slice(0, end + width);
|
|
687
697
|
let text = rest.slice(width, end).replace(/\n/g, " ");
|
|
688
698
|
if (/^ .* $/.test(text) && text.trim() !== "") text = text.slice(1, -1);
|
|
689
|
-
|
|
690
|
-
rest = rest.slice(raw.length);
|
|
699
|
+
consumeToken({ type: "codespan", raw, text });
|
|
691
700
|
continue;
|
|
692
701
|
}
|
|
693
702
|
}
|
|
@@ -695,14 +704,12 @@ function lexToNested(lex: InlineLex): NestedMatch | undefined {
|
|
|
695
704
|
if (auto) {
|
|
696
705
|
const text = auto.slice(1, -1);
|
|
697
706
|
const href = text.includes("@") && !/^[a-z][a-z+.-]*:\/\//i.test(text) ? `mailto:${text}` : text;
|
|
698
|
-
|
|
699
|
-
rest = rest.slice(auto.length);
|
|
707
|
+
consumeToken({ type: "link", raw: auto, text, href, tokens: [{ type: "text", raw: text, text }] });
|
|
700
708
|
continue;
|
|
701
709
|
}
|
|
702
710
|
const html = inlineHtmlPrefix(rest, closers);
|
|
703
711
|
if (html) {
|
|
704
|
-
|
|
705
|
-
rest = rest.slice(html.length);
|
|
712
|
+
consumeToken({ type: "html", raw: html, inLink: false, inRawBlock: false, block: false, text: html });
|
|
706
713
|
continue;
|
|
707
714
|
}
|
|
708
715
|
const nested = matchLink(rest, lexer, closers) ?? matchEmphasis(rest, output, closers);
|
|
@@ -716,8 +723,7 @@ function lexToNested(lex: InlineLex): NestedMatch | undefined {
|
|
|
716
723
|
if (override) del = override.call(lexer.tokenizer, rest, rest);
|
|
717
724
|
if (del === false || !override) del = lexer.tokenizer.del(rest);
|
|
718
725
|
if (del) {
|
|
719
|
-
|
|
720
|
-
rest = rest.slice(del.raw.length);
|
|
726
|
+
consumeToken(del);
|
|
721
727
|
continue;
|
|
722
728
|
}
|
|
723
729
|
}
|
|
@@ -744,8 +750,7 @@ function lexToNested(lex: InlineLex): NestedMatch | undefined {
|
|
|
744
750
|
}
|
|
745
751
|
}
|
|
746
752
|
if (url) {
|
|
747
|
-
|
|
748
|
-
rest = rest.slice(url.raw.length);
|
|
753
|
+
consumeToken(url);
|
|
749
754
|
continue;
|
|
750
755
|
}
|
|
751
756
|
|
|
@@ -170,38 +170,30 @@ class EmphasisDelimiters {
|
|
|
170
170
|
|
|
171
171
|
/**
|
|
172
172
|
* The runs of backticks in the root source, for the code span rule. A span opened by `width` backticks closes at the
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
173
|
+
* next run of exactly `width` backticks: a run of any other length is no closer, and the one that closes is the first
|
|
174
|
+
* run of that width at or after the opener. Backslash escapes do not apply inside a code span, so a backslash just
|
|
175
|
+
* before that closing backtick is content and still closes the span.
|
|
176
176
|
*/
|
|
177
177
|
class CodeRuns {
|
|
178
|
-
// Per run, in order: where it starts and ends
|
|
178
|
+
// Per run, in order: where it starts and ends; the starts are the binary search `endOf` and `startsStringAt` need.
|
|
179
179
|
readonly #starts: number[] = [];
|
|
180
180
|
readonly #ends: number[] = [];
|
|
181
|
-
|
|
182
|
-
//
|
|
183
|
-
readonly #
|
|
184
|
-
readonly #leaves: number;
|
|
181
|
+
// Run starts by width, each list ascending: a closer must have the opener's exact width, so finding one is a
|
|
182
|
+
// binary search in the list for that width and no scan of the runs between.
|
|
183
|
+
readonly #byWidth = new Map<number, number[]>();
|
|
185
184
|
|
|
186
185
|
constructor(src: string) {
|
|
187
|
-
const widths: number[] = [];
|
|
188
186
|
for (let at = src.indexOf("`"); at !== -1;) {
|
|
189
187
|
let end = at + 1;
|
|
190
188
|
while (src.charCodeAt(end) === 0x60 /* ` */) end++;
|
|
191
|
-
const escaped = escapedAt(src, at);
|
|
192
189
|
this.#starts.push(at);
|
|
193
190
|
this.#ends.push(end);
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
const width = end - at;
|
|
192
|
+
const starts = this.#byWidth.get(width);
|
|
193
|
+
if (starts === undefined) this.#byWidth.set(width, [at]);
|
|
194
|
+
else starts.push(at);
|
|
196
195
|
at = src.indexOf("`", end);
|
|
197
196
|
}
|
|
198
|
-
let leaves = 1;
|
|
199
|
-
while (leaves < widths.length) leaves <<= 1;
|
|
200
|
-
const widest = new Int32Array(2 * leaves);
|
|
201
|
-
for (let run = 0; run < widths.length; run++) widest[leaves + run] = widths[run]!;
|
|
202
|
-
for (let node = leaves - 1; node >= 1; node--) widest[node] = Math.max(widest[2 * node]!, widest[2 * node + 1]!);
|
|
203
|
-
this.#widest = widest;
|
|
204
|
-
this.#leaves = leaves;
|
|
205
197
|
}
|
|
206
198
|
|
|
207
199
|
/** Where the run of backticks holding the backtick at `at` ends. */
|
|
@@ -209,28 +201,24 @@ class CodeRuns {
|
|
|
209
201
|
return this.#ends[lowerBound(this.#starts, at + 1) - 1]!;
|
|
210
202
|
}
|
|
211
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Whether a backtick string starts at `at` for a scan that walked to `at`: a string is a whole run, so it starts at
|
|
206
|
+
* the run's first backtick and nowhere else. A retry deeper inside a run is not a string, which keeps a run whose
|
|
207
|
+
* opener found no closer from opening a narrower span one backtick later. A position a tokenizer's raw resumed at,
|
|
208
|
+
* or the suffix an escape token left behind, is not a walked position — `codeOpenerWidth` admits those on its own,
|
|
209
|
+
* because it is told the scan resumed there rather than inferring it from the source text.
|
|
210
|
+
*/
|
|
211
|
+
startsStringAt(at: number): boolean {
|
|
212
|
+
const run = lowerBound(this.#starts, at + 1) - 1;
|
|
213
|
+
return this.#starts[run] === at;
|
|
214
|
+
}
|
|
215
|
+
|
|
212
216
|
/** Where the closer of a code span opened by `width` backticks that end at `from` starts, or -1. */
|
|
213
217
|
closerAfter(from: number, width: number): number {
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
/** The first run at or after `first` that holds a closer of `width` backticks, or -1. */
|
|
219
|
-
#firstHolding(first: number, width: number): number {
|
|
220
|
-
if (first >= this.#starts.length) return -1;
|
|
221
|
-
const widest = this.#widest;
|
|
222
|
-
const leaves = this.#leaves;
|
|
223
|
-
let node = leaves + first;
|
|
224
|
-
if (widest[node]! >= width) return first;
|
|
225
|
-
// Up to the nearest subtree on the right that holds one, then down to its first run that does.
|
|
226
|
-
for (;;) {
|
|
227
|
-
if (node === 1) return -1;
|
|
228
|
-
if ((node & 1) === 0 && widest[node + 1]! >= width) break;
|
|
229
|
-
node >>= 1;
|
|
230
|
-
}
|
|
231
|
-
node++;
|
|
232
|
-
while (node < leaves) node = widest[2 * node]! >= width ? 2 * node : 2 * node + 1;
|
|
233
|
-
return node - leaves;
|
|
218
|
+
const starts = this.#byWidth.get(width);
|
|
219
|
+
if (starts === undefined) return -1;
|
|
220
|
+
const run = lowerBound(starts, from);
|
|
221
|
+
return run < starts.length ? starts[run]! : -1;
|
|
234
222
|
}
|
|
235
223
|
}
|
|
236
224
|
|
|
@@ -388,10 +376,17 @@ export class InlineClosers {
|
|
|
388
376
|
return close === -1 ? -1 : close - pos;
|
|
389
377
|
}
|
|
390
378
|
|
|
391
|
-
/**
|
|
392
|
-
|
|
379
|
+
/**
|
|
380
|
+
* How many backticks start `rest`, which starts with one, or 0 where no code span opens because no backtick string
|
|
381
|
+
* starts there: the opener is a whole string, so the rest of a run whose opener found no closer is text, while the
|
|
382
|
+
* backticks left after a backslash escaped the run's first one do open a span. `resumed` says the scan resumed at
|
|
383
|
+
* `rest` after a tokenizer consumed a raw that ended here, where the backticks are a string wherever they sit.
|
|
384
|
+
*/
|
|
385
|
+
codeOpenerWidth(rest: string, resumed = false): number {
|
|
393
386
|
const pos = this.end - rest.length;
|
|
394
|
-
|
|
387
|
+
const runs = this.#indexes.codeRuns;
|
|
388
|
+
if (!resumed && !runs.startsStringAt(pos)) return 0;
|
|
389
|
+
return Math.min(runs.endOf(pos), this.end) - pos;
|
|
395
390
|
}
|
|
396
391
|
|
|
397
392
|
/** Where the code span opened by the `width` backticks at the start of `rest` closes, or -1. */
|
package/src/mime.ts
CHANGED
|
@@ -11,6 +11,7 @@ const GIF89A = Buffer.from("GIF89a");
|
|
|
11
11
|
const WEBP_VP8X = Buffer.from("VP8X");
|
|
12
12
|
const WEBP_VP8L = Buffer.from("VP8L");
|
|
13
13
|
const WEBP_VP8 = Buffer.from("VP8 ");
|
|
14
|
+
const FTYP = Buffer.from("ftyp");
|
|
14
15
|
|
|
15
16
|
export const SUPPORTED_IMAGE_MIME_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
|
|
16
17
|
|
|
@@ -143,6 +144,40 @@ export function parseImageMetadata(header: Uint8Array): ImageMetadata | null {
|
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
|
|
147
|
+
/** Still images in a HEIF (ISO BMFF) container, which the image pipeline cannot decode itself. */
|
|
148
|
+
export type HeifImageMimeType = "image/heic" | "image/heif" | "image/avif";
|
|
149
|
+
|
|
150
|
+
const HEIF_BRAND_MIME_TYPES: Readonly<Record<string, HeifImageMimeType>> = {
|
|
151
|
+
heic: "image/heic",
|
|
152
|
+
heix: "image/heic",
|
|
153
|
+
heim: "image/heic",
|
|
154
|
+
heis: "image/heic",
|
|
155
|
+
hevc: "image/heic",
|
|
156
|
+
hevx: "image/heic",
|
|
157
|
+
avif: "image/avif",
|
|
158
|
+
avis: "image/avif",
|
|
159
|
+
mif1: "image/heif",
|
|
160
|
+
msf1: "image/heif",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* MIME type of a HEIC, AVIF or other HEIF image from the brands of its leading `ftyp` box:
|
|
165
|
+
* the major brand, then the compatible brands, with the generic `mif1`/`msf1` brands last.
|
|
166
|
+
*/
|
|
167
|
+
export function parseHeifImageMimeType(header: Uint8Array): HeifImageMimeType | null {
|
|
168
|
+
if (!magicEquals(header, 4, FTYP)) return null;
|
|
169
|
+
const view = new DataView(header.buffer, header.byteOffset, header.byteLength);
|
|
170
|
+
const boxEnd = Math.min(view.getUint32(0, false), header.length);
|
|
171
|
+
let generic: HeifImageMimeType | null = null;
|
|
172
|
+
// The major brand sits at 8 and the compatible brands start at 16, after the minor version.
|
|
173
|
+
for (let offset = 8; offset + 4 <= boxEnd; offset += offset === 8 ? 8 : 4) {
|
|
174
|
+
const mimeType = HEIF_BRAND_MIME_TYPES[String.fromCharCode(...header.subarray(offset, offset + 4))];
|
|
175
|
+
if (mimeType === "image/heif") generic ??= mimeType;
|
|
176
|
+
else if (mimeType) return mimeType;
|
|
177
|
+
}
|
|
178
|
+
return generic;
|
|
179
|
+
}
|
|
180
|
+
|
|
146
181
|
/** Why base64 image data is unusable; `undefined` when it is canonical and non-empty. */
|
|
147
182
|
export type Base64ImageDataFailure = "invalid base64 image data" | "empty image data";
|
|
148
183
|
|
package/src/postmortem.ts
CHANGED
|
@@ -27,10 +27,26 @@ interface CleanupRegistration {
|
|
|
27
27
|
id: string;
|
|
28
28
|
callback: (reason: Reason) => Promise<void> | void;
|
|
29
29
|
exitOnly: boolean;
|
|
30
|
+
/** How long a pass waits for this callback, when longer than {@link CLEANUP_DEADLINE_MS}. */
|
|
31
|
+
deadlineMs: number | undefined;
|
|
32
|
+
/** Printed to stderr when exit waits on this callback alone; see {@link CleanupRegistrationOptions.waitMessage}. */
|
|
33
|
+
waitMessage: string | undefined;
|
|
30
34
|
cancelled: boolean;
|
|
31
35
|
lastPass: number;
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/** The deadline of one cleanup pass, which callbacks with their own `deadlineMs` hold open. */
|
|
39
|
+
interface CleanupPassDeadline {
|
|
40
|
+
/**
|
|
41
|
+
* Keep the pass open until `pending` settles, for at most `deadlineMs`. A
|
|
42
|
+
* `waitMessage` is printed to stderr if the pass is still waiting on it once
|
|
43
|
+
* every other callback has finished.
|
|
44
|
+
*/
|
|
45
|
+
extend(pending: Promise<unknown>, deadlineMs: number, waitMessage?: string): void;
|
|
46
|
+
/** Whether a callback with its own deadline still holds the pass open. */
|
|
47
|
+
extended(): boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
34
50
|
// Active cleanup callbacks in registration order. Registrations survive
|
|
35
51
|
// keep-alive passes; `lastPass` enforces at-most-once invocation per pass.
|
|
36
52
|
const callbackList: CleanupRegistration[] = [];
|
|
@@ -42,6 +58,8 @@ let activeCleanupKeepAlive = false;
|
|
|
42
58
|
// Promises of callbacks invoked late (registered while a pass runs), joined by
|
|
43
59
|
// the active pass before it settles so `cleanup()`/signal exits await them.
|
|
44
60
|
let activeLatePromises: Promise<void>[] | undefined;
|
|
61
|
+
// Deadline of the running pass, so callbacks invoked late can extend it too.
|
|
62
|
+
let activePassDeadline: CleanupPassDeadline | undefined;
|
|
45
63
|
const CLEANUP_DEADLINE_MS = 10_000;
|
|
46
64
|
/**
|
|
47
65
|
* Symbol stamped by the extension-load guard onto the throwing replacement it
|
|
@@ -122,7 +140,7 @@ export function exitProcess(code: number): never {
|
|
|
122
140
|
let cleanupPromise: Promise<void> | undefined;
|
|
123
141
|
let stdioDisconnectRegistrations = 0;
|
|
124
142
|
|
|
125
|
-
/** User-facing command printed
|
|
143
|
+
/** User-facing command printed after fatal or SIGTERM cleanup so interrupted work can be resumed. */
|
|
126
144
|
export interface FatalRecoveryHint {
|
|
127
145
|
/** Stable label identifying the recoverable session or process. */
|
|
128
146
|
label: string;
|
|
@@ -184,10 +202,75 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
|
184
202
|
}
|
|
185
203
|
};
|
|
186
204
|
|
|
205
|
+
// The pass ends once every callback settles, or at the deadline: CLEANUP_DEADLINE_MS,
|
|
206
|
+
// held open while a callback with a longer `deadlineMs` is still pending.
|
|
207
|
+
const deadline = Promise.withResolvers<void>();
|
|
208
|
+
const deadlineTimers: NodeJS.Timeout[] = [];
|
|
209
|
+
let baseDeadlineElapsed = false;
|
|
210
|
+
let openExtensions = 0;
|
|
211
|
+
let passDone = false;
|
|
212
|
+
const expireIfDue = (): void => {
|
|
213
|
+
if (passDone || !baseDeadlineElapsed || openExtensions > 0) return;
|
|
214
|
+
passDone = true;
|
|
215
|
+
logger.error("Cleanup deadline exceeded; proceeding with exit", { reason });
|
|
216
|
+
settle();
|
|
217
|
+
deadline.resolve();
|
|
218
|
+
};
|
|
219
|
+
// Holds that still keep the pass open and have a message for the user. The
|
|
220
|
+
// message is printed once the pass waits on nothing else, so a terminal owner
|
|
221
|
+
// has already released the screen and the user sees why exit is slow.
|
|
222
|
+
const announced: { message: string; until: number; open: () => boolean }[] = [];
|
|
223
|
+
let othersSettled = false;
|
|
224
|
+
const announce = (): void => {
|
|
225
|
+
if (passDone || !othersSettled) return;
|
|
226
|
+
for (const hold of announced.splice(0)) {
|
|
227
|
+
if (!hold.open()) continue;
|
|
228
|
+
const seconds = Math.max(1, Math.ceil((hold.until - Date.now()) / 1000));
|
|
229
|
+
try {
|
|
230
|
+
fs.writeSync(2, `${hold.message} (up to ${seconds} s)…\n`);
|
|
231
|
+
} catch {}
|
|
232
|
+
}
|
|
233
|
+
};
|
|
234
|
+
const passDeadline: CleanupPassDeadline = {
|
|
235
|
+
extend(pending, deadlineMs, waitMessage) {
|
|
236
|
+
if (passDone || deadlineMs <= 0) return;
|
|
237
|
+
openExtensions++;
|
|
238
|
+
let open = true;
|
|
239
|
+
const close = (): void => {
|
|
240
|
+
if (!open) return;
|
|
241
|
+
open = false;
|
|
242
|
+
openExtensions--;
|
|
243
|
+
// Check on a later macrotask: when this was the last pending callback, the
|
|
244
|
+
// pass completes first and no deadline is reported.
|
|
245
|
+
deadlineTimers.push(setTimeout(expireIfDue, 0));
|
|
246
|
+
};
|
|
247
|
+
deadlineTimers.push(setTimeout(close, deadlineMs));
|
|
248
|
+
pending.then(close, close);
|
|
249
|
+
if (waitMessage !== undefined) {
|
|
250
|
+
announced.push({ message: waitMessage, until: Date.now() + deadlineMs, open: () => open });
|
|
251
|
+
// On a later macrotask, so a callback that is already done closes first.
|
|
252
|
+
if (othersSettled) deadlineTimers.push(setTimeout(announce, 0));
|
|
253
|
+
}
|
|
254
|
+
},
|
|
255
|
+
extended: () => !passDone && openExtensions > 0,
|
|
256
|
+
};
|
|
257
|
+
activePassDeadline = passDeadline;
|
|
258
|
+
|
|
187
259
|
// Snapshot the pass. Registrations added while a keep-alive cleanup runs are
|
|
188
260
|
// invoked by register() when appropriate and remain active for later passes.
|
|
189
|
-
const
|
|
190
|
-
|
|
261
|
+
const snapshot = callbackList.toReversed();
|
|
262
|
+
const promises = snapshot.map(registration => {
|
|
263
|
+
return Promise.try(() => {
|
|
264
|
+
const pending = invokeCleanup(registration, reason, keepAlive, pass);
|
|
265
|
+
if (pending && registration.deadlineMs !== undefined) {
|
|
266
|
+
passDeadline.extend(pending, registration.deadlineMs, registration.waitMessage);
|
|
267
|
+
}
|
|
268
|
+
return pending;
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
void Promise.allSettled(promises.filter((_, index) => snapshot[index]?.deadlineMs === undefined)).then(() => {
|
|
272
|
+
othersSettled = true;
|
|
273
|
+
deadlineTimers.push(setTimeout(announce, 0));
|
|
191
274
|
});
|
|
192
275
|
|
|
193
276
|
const cleanupSettled = Promise.allSettled(promises).then(async results => {
|
|
@@ -202,14 +285,20 @@ function runCleanup(reason: Reason, keepAlive = false): Promise<void> {
|
|
|
202
285
|
while (late.length > 0) await Promise.allSettled(late.splice(0));
|
|
203
286
|
settle();
|
|
204
287
|
});
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
288
|
+
deadlineTimers.push(
|
|
289
|
+
setTimeout(() => {
|
|
290
|
+
baseDeadlineElapsed = true;
|
|
291
|
+
// The pass no longer waits on the other callbacks, so a hold still open is
|
|
292
|
+
// what exit waits on; terminal owners released the screen synchronously.
|
|
293
|
+
othersSettled = true;
|
|
294
|
+
announce();
|
|
295
|
+
expireIfDue();
|
|
296
|
+
}, CLEANUP_DEADLINE_MS),
|
|
297
|
+
);
|
|
211
298
|
const passPromise = Promise.race([cleanupSettled, deadline.promise]).finally(() => {
|
|
212
|
-
|
|
299
|
+
passDone = true;
|
|
300
|
+
for (const timer of deadlineTimers) clearTimeout(timer);
|
|
301
|
+
if (activePassDeadline === passDeadline) activePassDeadline = undefined;
|
|
213
302
|
// A re-armed pass must drop only its own settled promise; an older
|
|
214
303
|
// deadline-limited pass may finish after a newer one has already started.
|
|
215
304
|
if (keepAlive && cleanupPass === pass && cleanupPromise === passPromise) cleanupPromise = undefined;
|
|
@@ -480,7 +569,10 @@ export function interceptUnhandledRejections(interceptor: (reason: unknown) => b
|
|
|
480
569
|
|
|
481
570
|
/**
|
|
482
571
|
* Register a synchronous recovery command to print when the process exits
|
|
483
|
-
* through an uncaught exception
|
|
572
|
+
* through an uncaught exception, an unhandled rejection, or SIGTERM.
|
|
573
|
+
*
|
|
574
|
+
* Providers are read once cleanup finishes (or hits its deadline), so they
|
|
575
|
+
* report what teardown left behind: a session it saved or one it dropped.
|
|
484
576
|
*/
|
|
485
577
|
export function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void {
|
|
486
578
|
fatalRecoveryHintProviders.add(provider);
|
|
@@ -494,10 +586,10 @@ function escapeFatalHintText(value: string): string {
|
|
|
494
586
|
});
|
|
495
587
|
}
|
|
496
588
|
|
|
497
|
-
function formatFatalRecoveryHints(): string {
|
|
589
|
+
function formatFatalRecoveryHints(providers: Iterable<FatalRecoveryHintProvider>): string {
|
|
498
590
|
const lines: string[] = [];
|
|
499
591
|
const seenCommands = new Set<string>();
|
|
500
|
-
for (const provider of
|
|
592
|
+
for (const provider of providers) {
|
|
501
593
|
try {
|
|
502
594
|
const hint = provider();
|
|
503
595
|
if (!hint?.command || seenCommands.has(hint.command)) continue;
|
|
@@ -519,21 +611,54 @@ function formatFatalError(label: string, err: Error): string {
|
|
|
519
611
|
return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
|
|
520
612
|
}
|
|
521
613
|
|
|
614
|
+
/** Write a final report to stderr once terminal owners have handed the terminal back. */
|
|
615
|
+
function writeTerminalReport(output: string): void {
|
|
616
|
+
restoreTerminalStderr();
|
|
617
|
+
// A revoked terminal can make stream writes raise another fatal error. Use
|
|
618
|
+
// the descriptor directly so failure stays synchronous and contained.
|
|
619
|
+
try {
|
|
620
|
+
fs.writeSync(2, output);
|
|
621
|
+
} catch {}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
let recoveryHintsWritten = false;
|
|
625
|
+
|
|
626
|
+
/**
|
|
627
|
+
* Print the recovery commands at most once per process. Callers snapshot the
|
|
628
|
+
* providers before cleanup, because a session's own cleanup unregisters its
|
|
629
|
+
* provider, and call this after cleanup, because teardown can still save the
|
|
630
|
+
* session (an unsent draft, an aborted first reply) or drop an empty one.
|
|
631
|
+
*/
|
|
632
|
+
function writeRecoveryHints(providers: readonly FatalRecoveryHintProvider[], heading = ""): void {
|
|
633
|
+
if (recoveryHintsWritten) return;
|
|
634
|
+
const hints = formatFatalRecoveryHints(providers);
|
|
635
|
+
if (!hints) return;
|
|
636
|
+
recoveryHintsWritten = true;
|
|
637
|
+
writeTerminalReport(`${heading}${hints}`);
|
|
638
|
+
}
|
|
639
|
+
|
|
522
640
|
async function exitAfterFatal(output: string, logMessage: string, err: Error, reason: Reason): Promise<never> {
|
|
523
|
-
const
|
|
641
|
+
const hintProviders = [...fatalRecoveryHintProviders];
|
|
642
|
+
// The forced exit backs up the pass deadline, so it waits while a callback with
|
|
643
|
+
// its own `deadlineMs` holds the pass open; that pass still ends at its deadline.
|
|
644
|
+
const forceExit = (): void => {
|
|
645
|
+
if (activePassDeadline?.extended()) {
|
|
646
|
+
forcedExit = setTimeout(forceExit, CLEANUP_DEADLINE_MS);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
writeRecoveryHints(hintProviders);
|
|
650
|
+
exitProcess(1);
|
|
651
|
+
};
|
|
652
|
+
let forcedExit = setTimeout(forceExit, CLEANUP_DEADLINE_MS);
|
|
524
653
|
try {
|
|
525
654
|
// Cleanup callbacks are invoked synchronously before runCleanup returns its
|
|
526
655
|
// completion promise. TUI owners therefore hand the cursor back before the
|
|
527
656
|
// fatal report is written, while slower resource cleanup continues afterward.
|
|
528
657
|
const cleanup = runCleanup(reason);
|
|
529
|
-
|
|
530
|
-
// A revoked terminal can make stream writes raise another fatal error. Use
|
|
531
|
-
// the descriptor directly so failure stays synchronous and contained.
|
|
532
|
-
try {
|
|
533
|
-
fs.writeSync(2, output);
|
|
534
|
-
} catch {}
|
|
658
|
+
writeTerminalReport(output);
|
|
535
659
|
logger.error(logMessage, { err });
|
|
536
660
|
await cleanup;
|
|
661
|
+
writeRecoveryHints(hintProviders);
|
|
537
662
|
} finally {
|
|
538
663
|
clearTimeout(forcedExit);
|
|
539
664
|
exitProcess(1);
|
|
@@ -552,12 +677,12 @@ function handleWorkerSendEpipe(err: Error): boolean {
|
|
|
552
677
|
*/
|
|
553
678
|
export async function fatal(error: unknown): Promise<never> {
|
|
554
679
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
555
|
-
const
|
|
680
|
+
const report = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n`;
|
|
556
681
|
if (!Bun.isMainThread) {
|
|
557
|
-
process.stderr.write(
|
|
682
|
+
process.stderr.write(`${report}${formatFatalRecoveryHints(fatalRecoveryHintProviders)}`);
|
|
558
683
|
process.exit(1);
|
|
559
684
|
}
|
|
560
|
-
return exitAfterFatal(
|
|
685
|
+
return exitAfterFatal(report, "Fatal error", err, Reason.UNHANDLED_REJECTION);
|
|
561
686
|
}
|
|
562
687
|
|
|
563
688
|
if (Bun.isMainThread) {
|
|
@@ -607,7 +732,7 @@ if (Bun.isMainThread) {
|
|
|
607
732
|
return;
|
|
608
733
|
}
|
|
609
734
|
await exitAfterFatal(
|
|
610
|
-
|
|
735
|
+
formatFatalError("Uncaught Exception", err),
|
|
611
736
|
"Uncaught exception",
|
|
612
737
|
err,
|
|
613
738
|
Reason.UNCAUGHT_EXCEPTION,
|
|
@@ -630,7 +755,7 @@ if (Bun.isMainThread) {
|
|
|
630
755
|
}
|
|
631
756
|
}
|
|
632
757
|
await exitAfterFatal(
|
|
633
|
-
|
|
758
|
+
formatFatalError("Unhandled Rejection", err),
|
|
634
759
|
"Unhandled rejection",
|
|
635
760
|
err,
|
|
636
761
|
Reason.UNHANDLED_REJECTION,
|
|
@@ -640,7 +765,12 @@ if (Bun.isMainThread) {
|
|
|
640
765
|
void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
|
|
641
766
|
})
|
|
642
767
|
.on("SIGTERM", async () => {
|
|
768
|
+
// A stopped session otherwise leaves its last frame above the shell prompt,
|
|
769
|
+
// which reads as a crash. A repeated SIGTERM joins the same cleanup and the
|
|
770
|
+
// hints still print once.
|
|
771
|
+
const hintProviders = [...fatalRecoveryHintProviders];
|
|
643
772
|
await runCleanup(Reason.SIGTERM);
|
|
773
|
+
writeRecoveryHints(hintProviders, "\n[Stopped by SIGTERM]");
|
|
644
774
|
exitProcess(143); // 128 + SIGTERM (15)
|
|
645
775
|
})
|
|
646
776
|
.on("SIGHUP", async () => {
|
|
@@ -664,6 +794,20 @@ export interface CleanupRegistrationOptions {
|
|
|
664
794
|
* The registration remains armed when a keep-alive pass skips it.
|
|
665
795
|
*/
|
|
666
796
|
exitOnly?: boolean;
|
|
797
|
+
/**
|
|
798
|
+
* How long a pass waits for this callback, when that is longer than the
|
|
799
|
+
* default 10 s cleanup deadline. It extends the pass only while the callback
|
|
800
|
+
* is still pending: one that settles early ends its hold at once.
|
|
801
|
+
*/
|
|
802
|
+
deadlineMs?: number;
|
|
803
|
+
/**
|
|
804
|
+
* With `deadlineMs`: a line printed to stderr, with the remaining wait
|
|
805
|
+
* appended, if exit is still waiting on this callback once every other
|
|
806
|
+
* callback has finished (a terminal owner has then released the screen), or
|
|
807
|
+
* at the default 10 s deadline when another callback is still running.
|
|
808
|
+
* Nothing is printed when the callback finishes first.
|
|
809
|
+
*/
|
|
810
|
+
waitMessage?: string;
|
|
667
811
|
}
|
|
668
812
|
|
|
669
813
|
/**
|
|
@@ -690,6 +834,8 @@ export function register(
|
|
|
690
834
|
id,
|
|
691
835
|
callback,
|
|
692
836
|
exitOnly: options.exitOnly ?? false,
|
|
837
|
+
deadlineMs: options.deadlineMs,
|
|
838
|
+
waitMessage: options.waitMessage,
|
|
693
839
|
cancelled: false,
|
|
694
840
|
lastPass: 0,
|
|
695
841
|
};
|
|
@@ -709,6 +855,9 @@ export function register(
|
|
|
709
855
|
// Join the active pass so cleanup()/signal exits await it; after a
|
|
710
856
|
// completed exit pass there is nothing left to join.
|
|
711
857
|
activeLatePromises?.push(tracked);
|
|
858
|
+
if (registration.deadlineMs !== undefined) {
|
|
859
|
+
activePassDeadline?.extend(tracked, registration.deadlineMs, registration.waitMessage);
|
|
860
|
+
}
|
|
712
861
|
} catch (error) {
|
|
713
862
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
714
863
|
logger.error("Cleanup callback failed", { err, id, stack: err.stack });
|
package/src/process-name.ts
CHANGED
|
@@ -10,9 +10,11 @@
|
|
|
10
10
|
* setter) and additionally drive `prctl` via `bun:ffi` on Linux, mirroring the
|
|
11
11
|
* libc-FFI pattern in `ttyid.ts` / `stderr-guard.ts`.
|
|
12
12
|
*
|
|
13
|
-
* macOS has no clean userspace equivalent for the shebang-run path,
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* macOS has no clean userspace equivalent for the shebang-run path, so there
|
|
14
|
+
* we only set `process.title`. Windows derives the name from the exec'd file,
|
|
15
|
+
* and there Bun's `process.title` setter is libuv's `SetConsoleTitleW`: every
|
|
16
|
+
* omp child attached to a pane's console (workers, `browser-relay`, servers)
|
|
17
|
+
* would overwrite that pane's tab title, so Windows skips it entirely.
|
|
16
18
|
*/
|
|
17
19
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
18
20
|
import * as os from "node:os";
|
|
@@ -21,17 +23,20 @@ import * as os from "node:os";
|
|
|
21
23
|
const PR_SET_NAME = 15;
|
|
22
24
|
|
|
23
25
|
/**
|
|
24
|
-
* Set
|
|
26
|
+
* Set the JS `process.title` (except on Windows, see above) and — on Linux —
|
|
27
|
+
* the kernel `comm` name.
|
|
25
28
|
*
|
|
26
29
|
* Never throws: `bun:ffi` unavailability or a failed syscall degrades silently
|
|
27
30
|
* to the `process.title`-only behavior, so it is safe to call at startup.
|
|
28
31
|
*/
|
|
29
32
|
export function setProcessName(name: string): void {
|
|
33
|
+
const platform = os.platform();
|
|
34
|
+
if (platform === "win32") return;
|
|
30
35
|
try {
|
|
31
36
|
process.title = name;
|
|
32
37
|
} catch {}
|
|
33
38
|
|
|
34
|
-
if (
|
|
39
|
+
if (platform !== "linux") return;
|
|
35
40
|
|
|
36
41
|
// glibc first, then the generic soname for musl-style layouts (see stderr-guard.ts).
|
|
37
42
|
for (const soname of ["libc.so.6", "libc.so"]) {
|
package/src/stream.ts
CHANGED
|
@@ -2,6 +2,7 @@ const trailingEvents = new WeakSet<ServerSentEvent>();
|
|
|
2
2
|
|
|
3
3
|
import { abortableSource } from "./abortable";
|
|
4
4
|
import { parseStreamingJson } from "./json-parse";
|
|
5
|
+
import { isWhitespace } from "./json-lexer";
|
|
5
6
|
|
|
6
7
|
const LF = 0x0a;
|
|
7
8
|
const CR = 0x0d;
|
|
@@ -613,9 +614,11 @@ export async function* readSseEvents(
|
|
|
613
614
|
*/
|
|
614
615
|
export function parseJsonlLenient<T>(buffer: string, options: { onMalformedRecord?: () => void } = {}): T[] {
|
|
615
616
|
let entries: T[] | undefined;
|
|
617
|
+
let cursor = 0;
|
|
618
|
+
const len = buffer.length;
|
|
616
619
|
|
|
617
|
-
while (
|
|
618
|
-
const { values, error, read, done } = Bun.JSONL.parseChunk(buffer);
|
|
620
|
+
while (cursor < len) {
|
|
621
|
+
const { values, error, read, done } = Bun.JSONL.parseChunk(buffer, cursor);
|
|
619
622
|
if (values.length > 0) {
|
|
620
623
|
const ext = values as T[];
|
|
621
624
|
if (!entries) {
|
|
@@ -625,18 +628,22 @@ export function parseJsonlLenient<T>(buffer: string, options: { onMalformedRecor
|
|
|
625
628
|
}
|
|
626
629
|
}
|
|
627
630
|
if (error) {
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
+
let malformedStart = read;
|
|
632
|
+
while (malformedStart < len && isWhitespace(buffer.charCodeAt(malformedStart))) {
|
|
633
|
+
malformedStart++;
|
|
634
|
+
}
|
|
635
|
+
const nextNewline = buffer.indexOf("\n", malformedStart);
|
|
636
|
+
const malformedEnd = nextNewline === -1 ? len : nextNewline;
|
|
637
|
+
if (buffer.substring(malformedStart, malformedEnd).trim().length > 0) options.onMalformedRecord?.();
|
|
631
638
|
if (nextNewline === -1) break;
|
|
632
|
-
|
|
639
|
+
cursor = nextNewline + 1;
|
|
633
640
|
continue;
|
|
634
641
|
}
|
|
635
|
-
if (read ===
|
|
636
|
-
if (buffer.trim().length > 0) options.onMalformedRecord?.();
|
|
642
|
+
if (read === cursor) {
|
|
643
|
+
if (buffer.substring(cursor).trim().length > 0) options.onMalformedRecord?.();
|
|
637
644
|
break;
|
|
638
645
|
}
|
|
639
|
-
|
|
646
|
+
cursor = read;
|
|
640
647
|
if (done) break;
|
|
641
648
|
}
|
|
642
649
|
return entries ?? [];
|
package/src/which.ts
CHANGED
|
@@ -261,3 +261,30 @@ export function $which(command: string, options?: WhichOptions): string | null {
|
|
|
261
261
|
}
|
|
262
262
|
return result;
|
|
263
263
|
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Locate every match for `command` on PATH, in PATH order, uncached.
|
|
267
|
+
*
|
|
268
|
+
* Callers that probe candidates (e.g. the Python eval backend) use this so a broken
|
|
269
|
+
* first hit — a version-manager shim with no version selected, a Windows Store alias —
|
|
270
|
+
* does not hide a working binary further down PATH. On macOS the Xcode/CLT toolchain
|
|
271
|
+
* match is appended last, mirroring {@link $which}'s fallback.
|
|
272
|
+
*
|
|
273
|
+
* @param command - Binary name to resolve
|
|
274
|
+
* @param options - `PATH` (defaults to `process.env.PATH`) and `cwd` for relative entries
|
|
275
|
+
* @returns Distinct filesystem paths; empty when nothing matches
|
|
276
|
+
*/
|
|
277
|
+
export function $whichAll(command: string, options?: Bun.WhichOptions): string[] {
|
|
278
|
+
const searchPath = options?.PATH ?? process.env.PATH ?? "";
|
|
279
|
+
const matches: string[] = [];
|
|
280
|
+
for (const dir of searchPath.split(path.delimiter)) {
|
|
281
|
+
if (!dir) continue;
|
|
282
|
+
const match = Bun.which(command, { cwd: options?.cwd, PATH: dir });
|
|
283
|
+
if (match && !matches.includes(match)) matches.push(match);
|
|
284
|
+
}
|
|
285
|
+
if (os.platform() === "darwin" && isXcodeBin(command)) {
|
|
286
|
+
const toolchainMatch = getMacosToolPaths().get(command);
|
|
287
|
+
if (toolchainMatch && !matches.includes(toolchainMatch)) matches.push(toolchainMatch);
|
|
288
|
+
}
|
|
289
|
+
return matches;
|
|
290
|
+
}
|