@linxiraos/pi-utils 1.1.6 → 1.1.8

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.1.8] - 2026-09-04
6
+
7
+ - Added `symlinkDirectorySync` — Windows-correct directory links (junction) for tests and tooling that must link temp trees without elevation.
8
+ - Extended the temp-removal retry window to 7.5s: measured Windows handle-release latency after a child process dies reaches ~5s, and the previous 2s window flaked suites cleaning up trees containing SQLite databases.
9
+
5
10
  ## [1.1.6] - 2026-08-30
6
11
 
7
12
  - 同步上游 OMP v18.0.10(`33cc6b9a043a`)。
@@ -11,12 +16,6 @@
11
16
 
12
17
  - 同步上游 OMP v18.0.5 / v18.0.6:新增 browsers / json 工具,SHA-2/SHA-3 在 ARM64 上加速。
13
18
 
14
- ## [1.1.3] - 2026-08-25
15
-
16
- ### Fixed
17
-
18
- - Republished as 1.1.3 to reset the latest tag after the broken 1.1.2 (no functional change over 1.1.1).
19
-
20
19
  ## [1.1.2] - 2026-08-25
21
20
 
22
21
  ### Fixed
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Wrap a promise with a timeout and optional abort signal.
3
- * Rejects with the given message if the timeout fires first.
4
- * Cleans up all listeners on settlement.
3
+ * Rejects with the given error or a new error containing the given message if
4
+ * the timeout fires first. Cleans up all listeners on settlement.
5
5
  */
6
- export declare function withTimeout<T>(promise: Promise<T>, ms: number, message: string, signal?: AbortSignal): Promise<T>;
6
+ export declare function withTimeout<T>(promise: Promise<T>, ms: number, timeout: string | Error, signal?: AbortSignal): Promise<T>;
7
7
  /**
8
8
  * Coalesces rapid-fire writes into one deferred batch. `push` queues a value
9
9
  * and returns a promise for the batch flush; the first push of a batch arms a
@@ -16,6 +16,15 @@ export declare function isValidEnvName(name: string): boolean;
16
16
  export declare function isSafeEnvName(name: string): boolean;
17
17
  export declare function isSafeEnvValue(value: string): boolean;
18
18
  export declare function isMacosMallocStackLoggingEnvName(name: string): boolean;
19
+ /**
20
+ * True when running inside a WSL (Windows Subsystem for Linux) distribution.
21
+ *
22
+ * WSL reports `linux` for `process.platform`, so the only reliable signal is
23
+ * the `WSL_DISTRO_NAME`/`WSL_INTEROP` variables the interop layer injects.
24
+ * Callers use this to translate Windows drive paths to their `/mnt/<drive>`
25
+ * mounts and to route clipboard access through `powershell.exe`.
26
+ */
27
+ export declare function isWsl(platform?: NodeJS.Platform, env?: NodeJS.ProcessEnv): boolean;
19
28
  export declare function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string>;
20
29
  /** Filters process env for child shells without launch-cwd dotenv values. */
21
30
  export declare function filterChildShellEnv(env: Record<string, string | undefined>, cwd?: string): Record<string, string>;
@@ -14,6 +14,8 @@
14
14
  * - `Please retry in 250ms` / `Please retry in 12s`
15
15
  * - `"retryDelay": "34.074824224s"` (JSON error detail field)
16
16
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
17
+ * - `retry-after-ms=98497000`
18
+ * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
17
19
  *
18
20
  * Returns `undefined` if no signal is found.
19
21
  */
@@ -14,6 +14,7 @@ export * from "./json.js";
14
14
  export * from "./json-parse.js";
15
15
  export * as logger from "./logger.js";
16
16
  export * from "./loop-phase.js";
17
+ export * from "./materialize-string.js";
17
18
  export * from "./math-delimiters.js";
18
19
  export * from "./mermaid-ascii.js";
19
20
  export * from "./mime.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Copy text into independent UTF-16 backing storage before a bounded substring outlives its source.
3
+ *
4
+ * The UTF-16 round trip preserves every JavaScript code unit, including lone
5
+ * surrogates that a UTF-8 round trip would replace.
6
+ */
7
+ export declare function materializeString(text: string): string;
@@ -1,2 +1,4 @@
1
+ /** Maps an absolute Windows drive path to the drive's default WSL mount. */
2
+ export declare function windowsPathToWslMount(filePath: string): string | undefined;
1
3
  /** Removes Win32 extended-length prefixes before passing paths to Bun APIs. */
2
4
  export declare function stripWindowsExtendedLengthPathPrefix(filePath: string, platform?: NodeJS.Platform): string;
@@ -123,6 +123,10 @@ export declare function interceptUnhandledRejections(interceptor: (reason: unkno
123
123
  * through an uncaught exception or unhandled rejection.
124
124
  */
125
125
  export declare function registerFatalRecoveryHint(provider: FatalRecoveryHintProvider): () => void;
126
+ /**
127
+ * Reports a caught top-level failure after terminal owners restore their display, then exits.
128
+ */
129
+ export declare function fatal(error: unknown): Promise<never>;
126
130
  /** Controls when a registered cleanup callback participates in cleanup passes. */
127
131
  export interface CleanupRegistrationOptions {
128
132
  /**
@@ -73,6 +73,13 @@ export interface EnsureRuntimeInstalledOptions {
73
73
  export declare function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void>;
74
74
  /**
75
75
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
76
- * cross-process safe via a lock directory). Returns `runtimeDir`.
76
+ * cross-process safe). Returns `runtimeDir`.
77
+ *
78
+ * Serialization uses the OS-backed {@link withFileLock} at
79
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
80
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
81
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
82
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
83
+ * new install so older processes cannot cross the migration boundary.
77
84
  */
78
85
  export declare function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string>;
@@ -15,3 +15,10 @@ export declare class TempDir {
15
15
  /** Removes a path recursively, retrying transient Windows deletion failures. */
16
16
  export declare function removeWithRetries(target: string): Promise<void>;
17
17
  export declare function removeSyncWithRetries(target: string): void;
18
+ /**
19
+ * Create a directory symlink in a platform-correct way. Windows denies
20
+ * unprivileged file/dir symlinks (EPERM on `fs.symlink` without a type), but
21
+ * allows directory *junctions*, which every test that links temp directories
22
+ * should use instead. POSIX ignores the type argument.
23
+ */
24
+ export declare function symlinkDirectorySync(target: string, linkPath: string): void;
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Answers terminal capability queries emitted by programs on a headless PTY.
3
+ *
4
+ * A PTY that advertises `TERM=xterm-256color` but has no terminal behind it
5
+ * leaves capability probes unanswered: a program writes a query escape to
6
+ * stdout and blocks on stdin until the reply arrives (or its timeout expires,
7
+ * seconds later). This scanner watches raw PTY output for the standard queries
8
+ * and returns the bytes a real xterm-class terminal would send back, so the
9
+ * caller can write them into the PTY. It keeps no screen state — cursor
10
+ * position reports are answered with the home position — which makes it cheap
11
+ * enough to run on every byte of a long-lived supervised process. Use the full
12
+ * {@link Terminal} when the caller also renders the output.
13
+ *
14
+ * Queries can straddle chunk boundaries, so an unfinished trailing escape is
15
+ * carried into the next {@link feed}.
16
+ */
17
+ export declare class TerminalQueryResponder {
18
+ #private;
19
+ /**
20
+ * Feed one raw PTY output chunk. Returns the reply bytes to write back into
21
+ * the PTY, or an empty string when the chunk held no answerable query.
22
+ */
23
+ feed(chunk: string): string;
24
+ }
@@ -1,5 +1,6 @@
1
1
  /** Behavior-compatible reimplementation of @xterm/headless's used surface. */
2
2
  export * from "./vterm/buffer.js";
3
+ export * from "./vterm/query-responder.js";
3
4
  export * from "./vterm/terminal.js";
4
5
  import { Terminal } from "./vterm/terminal.js";
5
6
  declare const vterm: {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@linxiraos/pi-utils",
4
- "version": "1.1.6",
4
+ "version": "1.1.8",
5
5
  "description": "Shared utilities for pi packages",
6
6
  "homepage": "https://linxira-os.github.io/zeta/",
7
7
  "author": "Can Boluk",
@@ -31,7 +31,7 @@
31
31
  "fmt": "biome format --write ."
32
32
  },
33
33
  "dependencies": {
34
- "@linxiraos/pi-natives": "1.1.6"
34
+ "@linxiraos/pi-natives": "1.1.8"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/bun": "^1.3.14"
@@ -67,13 +67,11 @@ export class AgentSideConnection {
67
67
  #connection: RpcConnection;
68
68
 
69
69
  constructor(toAgent: (connection: AgentSideConnection) => Agent, stream: Stream) {
70
- let agent: Agent | undefined;
71
70
  this.#connection = new RpcConnection(stream, async (method, params, notification) => {
72
- const target = agent;
73
- if (!target) throw RequestError.internalError(undefined, "Agent is not initialized");
74
- return dispatchAgent(target, method, params, notification);
71
+ if (!agent) throw RequestError.internalError(undefined, "Agent is not initialized");
72
+ return dispatchAgent(agent, method, params, notification);
75
73
  });
76
- agent = toAgent(this);
74
+ const agent = toAgent(this);
77
75
  }
78
76
 
79
77
  /** Signal aborted when the transport closes. */
@@ -170,13 +168,11 @@ export class ClientSideConnection {
170
168
  #connection: RpcConnection;
171
169
 
172
170
  constructor(toClient: (connection: ClientSideConnection) => Client, stream: Stream) {
173
- let client: Client | undefined;
174
171
  this.#connection = new RpcConnection(stream, async (method, params, notification) => {
175
- const target = client;
176
- if (!target) throw RequestError.internalError(undefined, "Client is not initialized");
177
- return dispatchClient(target, method, params, notification);
172
+ if (!client) throw RequestError.internalError(undefined, "Client is not initialized");
173
+ return dispatchClient(client, method, params, notification);
178
174
  });
179
- client = toClient(this);
175
+ const client = toClient(this);
180
176
  }
181
177
  /** Signal aborted when the transport closes. */
182
178
  get signal(): AbortSignal {
package/src/ar/entries.ts CHANGED
@@ -45,7 +45,7 @@ export function upsertArchiveEntry(
45
45
  */
46
46
  export function ensureParentDirectories(map: Map<string, ArchiveIndexEntry>, limits: ArchiveLimits): void {
47
47
  assertEntryCount(map.size, limits);
48
- for (const entry of [...map.values()]) {
48
+ for (const entry of Array.from(map.values())) {
49
49
  const parts = entry.path.split("/");
50
50
  const stop = parts.length - 1;
51
51
  for (let index = 1; index <= stop; index++) {
@@ -107,6 +107,7 @@ interface FileMetadata {
107
107
  }
108
108
 
109
109
  function readBoolVector(reader: HeaderReader, count: number): boolean[] {
110
+ // [suppressed] length preallocation
110
111
  const result = new Array<boolean>(count);
111
112
  let byte = 0;
112
113
  let mask = 0;
@@ -122,12 +123,16 @@ function readBoolVector(reader: HeaderReader, count: number): boolean[] {
122
123
  }
123
124
 
124
125
  function readDefinedVector(reader: HeaderReader, count: number): boolean[] {
125
- if (reader.readByte() !== 0) return new Array<boolean>(count).fill(true);
126
+ if (reader.readByte() !== 0) {
127
+ // [suppressed] length preallocation
128
+ return new Array<boolean>(count).fill(true);
129
+ }
126
130
  return readBoolVector(reader, count);
127
131
  }
128
132
 
129
133
  function readDigests(reader: HeaderReader, count: number): Digests {
130
134
  const defined = readDefinedVector(reader, count);
135
+ // [suppressed] length preallocation
131
136
  const values = new Array<number | undefined>(count);
132
137
  for (let index = 0; index < count; index++) if (defined[index]) values[index] = reader.readUInt32();
133
138
  return { defined, values };
@@ -229,7 +234,10 @@ function parsePackInfo(reader: HeaderReader, streams: SevenZipStreams, options:
229
234
  } else reader.skipSizedProperty();
230
235
  }
231
236
  if (streams.packSizes.length !== count) throw new ArchiveError("Invalid 7z PackInfo without complete sizes");
232
- if (streams.packCrcs.length === 0) streams.packCrcs = new Array(count).fill(undefined);
237
+ if (streams.packCrcs.length === 0) {
238
+ // [suppressed] length preallocation
239
+ streams.packCrcs = new Array(count).fill(undefined);
240
+ }
233
241
  }
234
242
 
235
243
  function parseUnpackInfo(reader: HeaderReader, streams: SevenZipStreams, options: FormatReadOptions): void {
@@ -254,6 +262,7 @@ function parseUnpackInfo(reader: HeaderReader, streams: SevenZipStreams, options
254
262
  }
255
263
 
256
264
  function parseSubStreamsInfo(reader: HeaderReader, streams: SevenZipStreams): void {
265
+ // [suppressed] length preallocation
257
266
  const counts = new Array<number>(streams.folders.length).fill(1);
258
267
  let sizes: number[] | undefined;
259
268
  let rawDigests: Digests | undefined;
@@ -398,6 +407,7 @@ function parseNames(reader: HeaderReader, count: number, external: Uint8Array[],
398
407
  function parseTimes(reader: HeaderReader, count: number, external: Uint8Array[]): Array<number | undefined> {
399
408
  const defined = readDefinedVector(reader, count);
400
409
  const values = selectPropertyStream(reader, external);
410
+ // [suppressed] length preallocation
401
411
  const result = new Array<number | undefined>(count);
402
412
  for (let index = 0; index < count; index++)
403
413
  if (defined[index]) {
@@ -411,6 +421,7 @@ function parseTimes(reader: HeaderReader, count: number, external: Uint8Array[])
411
421
  function parseAttributes(reader: HeaderReader, count: number, external: Uint8Array[]): Array<number | undefined> {
412
422
  const defined = readDefinedVector(reader, count);
413
423
  const values = selectPropertyStream(reader, external);
424
+ // [suppressed] length preallocation
414
425
  const result = new Array<number | undefined>(count);
415
426
  for (let index = 0; index < count; index++) if (defined[index]) result[index] = values.readUInt32();
416
427
  return result;
@@ -420,11 +431,15 @@ function parseFilesInfo(reader: HeaderReader, external: Uint8Array[], options: F
420
431
  const count = reader.readNumber("file count");
421
432
  assertEntryCount(count, options.limits);
422
433
  const metadata: FileMetadata = {
434
+ // [suppressed] length preallocation
423
435
  names: new Array<string>(count).fill(""),
436
+ // [suppressed] length preallocation
424
437
  emptyStreams: new Array<boolean>(count).fill(false),
425
438
  emptyFiles: [],
426
439
  antiFiles: [],
440
+ // [suppressed] length preallocation
427
441
  mtimes: new Array<number | undefined>(count),
442
+ // [suppressed] length preallocation
428
443
  attributes: new Array<number | undefined>(count),
429
444
  };
430
445
  for (;;) {
package/src/async.ts CHANGED
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Wrap a promise with a timeout and optional abort signal.
3
- * Rejects with the given message if the timeout fires first.
4
- * Cleans up all listeners on settlement.
3
+ * Rejects with the given error or a new error containing the given message if
4
+ * the timeout fires first. Cleans up all listeners on settlement.
5
5
  */
6
- export function withTimeout<T>(promise: Promise<T>, ms: number, message: string, signal?: AbortSignal): Promise<T> {
6
+ export function withTimeout<T>(
7
+ promise: Promise<T>,
8
+ ms: number,
9
+ timeout: string | Error,
10
+ signal?: AbortSignal,
11
+ ): Promise<T> {
7
12
  if (signal?.aborted) {
8
13
  const reason = signal.reason instanceof Error ? signal.reason : new Error("Aborted");
9
14
  return Promise.reject(reason);
@@ -15,7 +20,7 @@ export function withTimeout<T>(promise: Promise<T>, ms: number, message: string,
15
20
  if (settled) return;
16
21
  settled = true;
17
22
  if (signal) signal.removeEventListener("abort", onAbort);
18
- reject(new Error(message));
23
+ reject(typeof timeout === "string" ? new Error(timeout) : timeout);
19
24
  }, ms);
20
25
 
21
26
  const onAbort = () => {
package/src/browsers.ts CHANGED
@@ -39,6 +39,13 @@ export enum BrowserPlatform {
39
39
  WIN32 = "win32",
40
40
  WIN64 = "win64",
41
41
  }
42
+ type ChromeForTestingPlatform = Exclude<BrowserPlatform, BrowserPlatform.LINUX_ARM>;
43
+
44
+ function requireChromeForTestingPlatform(platform: BrowserPlatform): ChromeForTestingPlatform {
45
+ if (platform === BrowserPlatform.LINUX_ARM)
46
+ throw new Error("Chrome for Testing does not provide linux/arm64 builds");
47
+ return platform;
48
+ }
42
49
 
43
50
  const BROWSERS = [
44
51
  Browser.CHROME,
@@ -163,13 +170,13 @@ export function getDownloadUrl(
163
170
 
164
171
  /** Compute the executable path in Puppeteer's cache layout. */
165
172
  export function computeExecutablePath(options: ComputeExecutablePathOptions): string {
166
- const platform = options.platform ?? detectBrowserPlatform();
167
- if (!platform) throw new Error("Cannot determine a browser platform for this host");
173
+ const detectedPlatform = options.platform ?? detectBrowserPlatform();
174
+ if (!detectedPlatform) throw new Error("Cannot determine a browser platform for this host");
168
175
  if (options.browser !== Browser.CHROME) throw new Error(`Unsupported browser executable: ${options.browser}`);
176
+ const platform = requireChromeForTestingPlatform(detectedPlatform);
169
177
  const installDir = installationDir(options.cacheDir, options.browser, platform, options.buildId);
170
178
  switch (platform) {
171
179
  case BrowserPlatform.LINUX:
172
- case BrowserPlatform.LINUX_ARM:
173
180
  return path.join(installDir, "chrome-linux64", "chrome");
174
181
  case BrowserPlatform.MAC:
175
182
  return path.join(
@@ -292,9 +299,8 @@ async function fetchMetadata<T>(filename: string): Promise<T> {
292
299
  }
293
300
 
294
301
  function chromeArchivePlatform(platform: BrowserPlatform): string {
295
- switch (platform) {
302
+ switch (requireChromeForTestingPlatform(platform)) {
296
303
  case BrowserPlatform.LINUX:
297
- case BrowserPlatform.LINUX_ARM:
298
304
  return "linux64";
299
305
  case BrowserPlatform.MAC:
300
306
  return "mac-x64";
package/src/dom/core.ts CHANGED
@@ -105,7 +105,7 @@ export class EventTarget {
105
105
  dispatchEvent(event: Event): boolean {
106
106
  if (!event.target) event.target = this;
107
107
  event.currentTarget = this;
108
- for (const listener of [...(this.#listeners.get(event.type) ?? [])]) {
108
+ for (const listener of Array.from(this.#listeners.get(event.type) ?? [])) {
109
109
  if (typeof listener === "function") listener.call(this, event);
110
110
  else listener.handleEvent(event);
111
111
  if (event.propagationStopped) break;
@@ -196,7 +196,7 @@ export class Node extends EventTarget {
196
196
  const node: Node = child;
197
197
  if (node === this || node.contains(this)) throw new Error("The new child is an ancestor of this node");
198
198
  if (child instanceof DocumentFragment) {
199
- for (const nested of [...child.childNodes]) this.appendChild(nested);
199
+ for (const nested of Array.from(child.childNodes)) this.appendChild(nested);
200
200
  return child;
201
201
  }
202
202
  child.parentNode?.removeChild(child);
@@ -212,7 +212,7 @@ export class Node extends EventTarget {
212
212
  const index = this.childNodes.indexOf(reference);
213
213
  if (index < 0) throw new Error("The reference node is not a child of this node");
214
214
  if (child instanceof DocumentFragment) {
215
- for (const nested of [...child.childNodes]) this.insertBefore(nested, reference);
215
+ for (const nested of Array.from(child.childNodes)) this.insertBefore(nested, reference);
216
216
  return child;
217
217
  }
218
218
  child.parentNode?.removeChild(child);
@@ -681,7 +681,7 @@ export class Element extends Node {
681
681
  const parent = this.parentNode;
682
682
  if (!parent) return;
683
683
  const fragment = parseFragment(value, this.documentForCreation(), this.parentElement?.localName);
684
- for (const child of [...fragment.childNodes]) parent.insertBefore(child, this);
684
+ for (const child of Array.from(fragment.childNodes)) parent.insertBefore(child, this);
685
685
  parent.removeChild(this);
686
686
  }
687
687
 
package/src/env.ts CHANGED
@@ -36,6 +36,18 @@ export function isMacosMallocStackLoggingEnvName(name: string): boolean {
36
36
  return name === "MallocStackLogging" || name === "MallocStackLoggingNoCompact";
37
37
  }
38
38
 
39
+ /**
40
+ * True when running inside a WSL (Windows Subsystem for Linux) distribution.
41
+ *
42
+ * WSL reports `linux` for `process.platform`, so the only reliable signal is
43
+ * the `WSL_DISTRO_NAME`/`WSL_INTEROP` variables the interop layer injects.
44
+ * Callers use this to translate Windows drive paths to their `/mnt/<drive>`
45
+ * mounts and to route clipboard access through `powershell.exe`.
46
+ */
47
+ export function isWsl(platform: NodeJS.Platform = process.platform, env: NodeJS.ProcessEnv = process.env): boolean {
48
+ return platform === "linux" && Boolean(env.WSL_DISTRO_NAME || env.WSL_INTEROP);
49
+ }
50
+
39
51
  export function filterProcessEnv(env: Record<string, string | undefined>): Record<string, string> {
40
52
  const result: Record<string, string> = {};
41
53
  for (const key in env) {
@@ -12,6 +12,12 @@ const RETRY_DELAY_FIELD_PATTERN = /"retryDelay":\s*"([0-9.]+)(ms|s)"/i;
12
12
  const TRY_AGAIN_PATTERN = /try again in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
13
13
  // "Your limit will reset in 13 minutes" / "reset in 13 minutes" / "will reset in 2h"
14
14
  const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s|minutes?|mins?|m|hours?|hrs?|h)\b/i;
15
+ // "Your limit will reset at 2026-09-01 09:44:51" / "reset at 2026-09-01T09:44:51Z"
16
+ const WILL_RESET_AT_PATTERN =
17
+ /(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
18
+ const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
19
+ // "retry-after-ms=98497000"
20
+ const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms=([0-9]+)\b/i;
15
21
 
16
22
  /**
17
23
  * Server-suggested retry delay extraction. Merges the patterns historically used
@@ -29,6 +35,8 @@ const WILL_RESET_IN_PATTERN = /(?:will\s+)?reset in\s+~?\s*([0-9.]+)\s*(ms|sec|s
29
35
  * - `Please retry in 250ms` / `Please retry in 12s`
30
36
  * - `"retryDelay": "34.074824224s"` (JSON error detail field)
31
37
  * - `try again in 250ms` / `try again in 12s` / `try again in 5 min` / `try again in ~158 min`
38
+ * - `retry-after-ms=98497000`
39
+ * - `Your limit will reset at 2026-09-01 09:44:51` / `将在 2026-09-01 09:44:51 重置`
32
40
  *
33
41
  * Returns `undefined` if no signal is found.
34
42
  */
@@ -85,11 +93,38 @@ export function extractRetryHint(source: Response | Headers | null | undefined,
85
93
  if (totalMs > 0) return totalMs;
86
94
  }
87
95
  }
96
+ for (const pattern of [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
97
+ const match = pattern.exec(body);
98
+ if (match?.[1]) {
99
+ // Provider timestamps without an explicit offset are interpreted as UTC.
100
+ const normalized = match[1].replace(" ", "T");
101
+ const hasOffset = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized);
102
+ const parsed = Date.parse(hasOffset ? normalized : `${normalized}Z`);
103
+ if (!Number.isNaN(parsed) && parsed > Date.now()) {
104
+ return parsed - Date.now();
105
+ }
106
+ }
107
+ }
88
108
  // Account-reset hints ("will reset in …") take precedence over short
89
109
  // retry hints ("please retry in 5s"): a body carrying both must honour the
90
110
  // longer account window, not the shorter generic one. QUOTA_RESET_PATTERN
91
111
  // ("reset after …") above already runs first and stays first.
92
- for (const pattern of [WILL_RESET_IN_PATTERN, PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
112
+ const accountResetMatch = WILL_RESET_IN_PATTERN.exec(body);
113
+ if (accountResetMatch?.[1]) {
114
+ const value = Number.parseFloat(accountResetMatch[1]);
115
+ if (Number.isFinite(value) && value > 0) {
116
+ const unitMs = unitToMs(accountResetMatch[2]!);
117
+ if (unitMs !== undefined) return value * unitMs;
118
+ }
119
+ }
120
+
121
+ const retryAfterMsMatch = RETRY_AFTER_MS_BODY_PATTERN.exec(body);
122
+ if (retryAfterMsMatch?.[1]) {
123
+ const ms = Number(retryAfterMsMatch[1]);
124
+ if (Number.isFinite(ms) && ms > 0) return ms;
125
+ }
126
+
127
+ for (const pattern of [PLEASE_RETRY_PATTERN, RETRY_DELAY_FIELD_PATTERN, TRY_AGAIN_PATTERN]) {
93
128
  const match = pattern.exec(body);
94
129
  if (match?.[1]) {
95
130
  const value = Number.parseFloat(match[1]);
package/src/format.ts CHANGED
@@ -9,7 +9,7 @@ const DAY = 24 * HOUR;
9
9
  */
10
10
  export function formatDuration(ms: number): string {
11
11
  if (!Number.isFinite(ms) || ms <= 0) return "0ms";
12
- if (ms < SEC) return `${ms}ms`;
12
+ if (ms < SEC) return `${Math.floor(ms)}ms`;
13
13
  if (ms < MIN) return `${(ms / SEC).toFixed(1)}s`;
14
14
  if (ms < HOUR) {
15
15
  const mins = Math.floor(ms / MIN);
@@ -22,6 +22,7 @@ export function normalizeFrontmatterKeys<T>(obj: T): T {
22
22
  if (obj === null || typeof obj !== "object") return obj;
23
23
  if (Array.isArray(obj)) {
24
24
  let changed = false;
25
+ // [suppressed] length preallocation
25
26
  const out: unknown[] = new Array(obj.length);
26
27
  for (let i = 0; i < obj.length; i++) {
27
28
  const v = obj[i];
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./json";
14
14
  export * from "./json-parse";
15
15
  export * as logger from "./logger";
16
16
  export * from "./loop-phase";
17
+ export * from "./materialize-string";
17
18
  export * from "./math-delimiters";
18
19
  export * from "./mermaid-ascii";
19
20
  export * from "./mime";
@@ -343,8 +343,15 @@ const DEFAULTS: MarkedOptions = {
343
343
  };
344
344
  const PUNCTUATION = /[!"#$%&'()*+,\-./:;<=>?@[\\\]^_`{|}~]/;
345
345
 
346
- function tokenList(links: Links = {}): TokensList {
346
+ function tokenList(links: Links = Object.create(null)): TokensList {
347
347
  const list = [] as unknown as TokensList;
348
+ // The reference-definition map is keyed by user-controlled labels. A plain
349
+ // `{}` inherits `Object.prototype`, so a reference-style link whose label is
350
+ // an inherited member (`[x][constructor]`, `[x][__proto__]`) resolves to a
351
+ // truthy non-definition and yields a link token with `href: undefined`
352
+ // (issue #10283). A null-prototype map makes such lookups miss, so the link
353
+ // correctly falls back to literal text, and label writes cannot pollute the
354
+ // prototype.
348
355
  list.links = links;
349
356
  return list;
350
357
  }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Copy text into independent UTF-16 backing storage before a bounded substring outlives its source.
3
+ *
4
+ * The UTF-16 round trip preserves every JavaScript code unit, including lone
5
+ * surrogates that a UTF-8 round trip would replace.
6
+ */
7
+ export function materializeString(text: string): string {
8
+ if (text.length === 0) return "";
9
+ return Buffer.from(text, "utf16le").toString("utf16le");
10
+ }
package/src/path.ts CHANGED
@@ -1,3 +1,17 @@
1
+ import * as path from "node:path";
2
+
3
+ const WINDOWS_DRIVE_PATH = /^([A-Za-z]):[\\/](.*)$/;
4
+
5
+ /** Maps an absolute Windows drive path to the drive's default WSL mount. */
6
+ export function windowsPathToWslMount(filePath: string): string | undefined {
7
+ const normalized = path.win32.normalize(filePath.trim());
8
+ const match = WINDOWS_DRIVE_PATH.exec(normalized);
9
+ if (!match) return undefined;
10
+ const [, drive, rest] = match;
11
+ const segments = rest.split("\\").filter(Boolean);
12
+ return path.posix.join("/mnt", drive!.toLowerCase(), ...segments);
13
+ }
14
+
1
15
  const WINDOWS_DRIVE_EXTENDED_PREFIX = /^\\\\[?]\\([A-Za-z]:[\\/].*)$/;
2
16
  const WINDOWS_UNC_EXTENDED_PREFIX = /^\\\\[?]\\UNC[\\/]([^\\/]+)[\\/](.+)$/i;
3
17
  const WINDOWS_DRIVE_EXTENDED_FORWARD_PREFIX = /^\/\/[?]\/([A-Za-z]:\/.*)$/;
package/src/postmortem.ts CHANGED
@@ -222,7 +222,7 @@ export function isInternalSocketClosedError(err: unknown): boolean {
222
222
  const internal = frames.every(frame => {
223
223
  const trimmed = frame.trim();
224
224
  if (trimmed === "" || trimmed === "at unknown" || trimmed === "at native") return true;
225
- if (!/\(node:[^)]*\)$/.test(trimmed) && !/^at node:/.test(trimmed)) return false;
225
+ if (!/\(node:[^)]*\)$/.test(trimmed) && !trimmed.startsWith("at node:")) return false;
226
226
  hasNetFrame ||= trimmed.includes("node:net:");
227
227
  return true;
228
228
  });
@@ -395,23 +395,40 @@ function formatFatalError(label: string, err: Error): string {
395
395
  return `\n[${label}] ${name}: ${message}${formattedStack}\n`;
396
396
  }
397
397
 
398
- async function exitAfterFatal(label: string, logMessage: string, err: Error, reason: Reason): Promise<void> {
398
+ async function exitAfterFatal(output: string, logMessage: string, err: Error, reason: Reason): Promise<never> {
399
399
  const forcedExit = setTimeout(() => exitProcess(1), CLEANUP_DEADLINE_MS);
400
400
  try {
401
+ // Cleanup callbacks are invoked synchronously before runCleanup returns its
402
+ // completion promise. TUI owners therefore hand the cursor back before the
403
+ // fatal report is written, while slower resource cleanup continues afterward.
404
+ const cleanup = runCleanup(reason);
401
405
  restoreTerminalStderr();
402
406
  // A revoked terminal can make stream writes raise another fatal error. Use
403
407
  // the descriptor directly so failure stays synchronous and contained.
404
408
  try {
405
- fs.writeSync(2, `${formatFatalError(label, err)}${formatFatalRecoveryHints()}`);
409
+ fs.writeSync(2, output);
406
410
  } catch {}
407
411
  logger.error(logMessage, { err });
408
- await runCleanup(reason);
412
+ await cleanup;
409
413
  } finally {
410
414
  clearTimeout(forcedExit);
411
415
  exitProcess(1);
412
416
  }
413
417
  }
414
418
 
419
+ /**
420
+ * Reports a caught top-level failure after terminal owners restore their display, then exits.
421
+ */
422
+ export async function fatal(error: unknown): Promise<never> {
423
+ const err = error instanceof Error ? error : new Error(String(error));
424
+ const output = `${Bun.inspect(error, { colors: process.stderr.isTTY === true })}\n${formatFatalRecoveryHints()}`;
425
+ if (!isMainThread) {
426
+ process.stderr.write(output);
427
+ process.exit(1);
428
+ }
429
+ return exitAfterFatal(output, "Fatal error", err, Reason.UNHANDLED_REJECTION);
430
+ }
431
+
415
432
  if (isMainThread) {
416
433
  process
417
434
  .on("SIGINT", async () => {
@@ -461,7 +478,12 @@ if (isMainThread) {
461
478
  });
462
479
  return;
463
480
  }
464
- await exitAfterFatal("Uncaught Exception", "Uncaught exception", err, Reason.UNCAUGHT_EXCEPTION);
481
+ await exitAfterFatal(
482
+ `${formatFatalError("Uncaught Exception", err)}${formatFatalRecoveryHints()}`,
483
+ "Uncaught exception",
484
+ err,
485
+ Reason.UNCAUGHT_EXCEPTION,
486
+ );
465
487
  })
466
488
  .on("unhandledRejection", async reason => {
467
489
  const err = reason instanceof Error ? reason : new Error(String(reason));
@@ -497,7 +519,12 @@ if (isMainThread) {
497
519
  });
498
520
  }
499
521
  }
500
- await exitAfterFatal("Unhandled Rejection", "Unhandled rejection", err, Reason.UNHANDLED_REJECTION);
522
+ await exitAfterFatal(
523
+ `${formatFatalError("Unhandled Rejection", err)}${formatFatalRecoveryHints()}`,
524
+ "Unhandled rejection",
525
+ err,
526
+ Reason.UNHANDLED_REJECTION,
527
+ );
501
528
  })
502
529
  .on("exit", async () => {
503
530
  void runCleanup(Reason.EXIT); // fire and forget (exit imminent)
package/src/prompt.ts CHANGED
@@ -196,6 +196,7 @@ export function format(content: string, options: PromptFormatOptions = {}): stri
196
196
  } = options;
197
197
  const isPreRender = renderPhase === "pre-render";
198
198
  const lines = content.split("\n");
199
+ // [suppressed] length preallocation
199
200
  const result: string[] = new Array(lines.length);
200
201
  let n = 0; // logical length of `result` (pops are n--)
201
202
  let inCodeBlock = false;
package/src/ring.ts CHANGED
@@ -15,6 +15,7 @@ export class RingBuffer<T> {
15
15
  * @param capacity - The maximum number of elements the buffer can hold. Must be positive.
16
16
  */
17
17
  constructor(public readonly capacity: number) {
18
+ // [suppressed] length preallocation
18
19
  this.#buf = new Array(capacity);
19
20
  }
20
21
 
@@ -2,6 +2,8 @@ import * as fs from "node:fs";
2
2
  import * as fsp from "node:fs/promises";
3
3
  import * as Module from "node:module";
4
4
  import * as path from "node:path";
5
+ import { withFileLock } from "./file-lock";
6
+ import { isEexist, isEnoent } from "./fs-error";
5
7
 
6
8
  /**
7
9
  * On-demand runtime dependency support for native-heavy optional packages
@@ -303,25 +305,62 @@ export interface EnsureRuntimeInstalledOptions {
303
305
  lockSleepMs?: number;
304
306
  }
305
307
 
306
- function isErrnoCode(error: unknown, code: string): boolean {
307
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
308
- }
308
+ /** No runtime install plausibly runs this long, so older legacy lock directories are crash orphans. */
309
+ const STALE_LEGACY_LOCK_MS = 10 * 60_000;
309
310
 
310
- async function acquireInstallLock(runtimeDir: string, attempts: number, sleepMs: number): Promise<() => Promise<void>> {
311
- const lockDir = `${runtimeDir}.lock`;
312
- await fsp.mkdir(path.dirname(lockDir), { recursive: true });
313
- for (let attempt = 0; attempt < attempts; attempt++) {
311
+ /**
312
+ * Run `fn` while reserving the pre-crash-safe `${runtimeDir}.lock` namespace.
313
+ *
314
+ * Versions through 18.0.10 serialized installs with a bare lock *directory*
315
+ * that only its creator removed; an installer killed outside that window
316
+ * (SIGKILL/OOM/Ctrl-C) left it unreleasable, wedging every later install for
317
+ * the full wait envelope (issue #10120). During an in-flight upgrade a legacy
318
+ * process may still legitimately own this directory, so poll until it is
319
+ * released and only force-reclaim once the directory is older than any
320
+ * plausible install ({@link STALE_LEGACY_LOCK_MS}) — never merely because a
321
+ * retry budget elapsed, which would delete a still-active legacy lock and let
322
+ * two installers race the same tree. Once the namespace is free, atomically
323
+ * create and retain a regular file through `fn`: an older process cannot
324
+ * acquire it between the handoff check and the new install. A file left by a
325
+ * crashed new installer can be reused immediately because the outer OS lock
326
+ * proves its owner is gone, unlike a legacy directory whose owner is unknown.
327
+ */
328
+ async function withLegacyInstallLock<T>(runtimeDir: string, sleepMs: number, fn: () => Promise<T>): Promise<T> {
329
+ const legacy = `${runtimeDir}.lock`;
330
+ for (;;) {
314
331
  try {
315
- await fsp.mkdir(lockDir);
316
- return async () => {
317
- await fsp.rm(lockDir, { recursive: true, force: true });
318
- };
332
+ const reservation = await fsp.open(legacy, "wx");
333
+ await reservation.close();
319
334
  } catch (error) {
320
- if (!isErrnoCode(error, "EEXIST")) throw error;
321
- await Bun.sleep(sleepMs);
335
+ if (!isEexist(error)) throw error;
336
+ let stat: fs.Stats;
337
+ try {
338
+ stat = await fsp.stat(legacy);
339
+ } catch (statError) {
340
+ if (isEnoent(statError)) continue; // released between open and stat; retry
341
+ throw statError;
342
+ }
343
+ // A non-directory is a reservation left by a newer installer. The
344
+ // outer OS lock proves that installer is gone, so reuse it at once.
345
+ if (!stat.isDirectory()) break;
346
+ // A fresh directory may still belong to a live pre-18.x installer, so
347
+ // wait for it to finish; only a crash orphan (older than any plausible
348
+ // install) is force-reclaimed.
349
+ if (Date.now() - stat.mtimeMs > STALE_LEGACY_LOCK_MS) {
350
+ await fsp.rm(legacy, { recursive: true, force: true });
351
+ } else {
352
+ await Bun.sleep(sleepMs);
353
+ }
354
+ continue;
322
355
  }
356
+ break;
357
+ }
358
+ // Retain the regular-file reservation across the install.
359
+ try {
360
+ return await fn();
361
+ } finally {
362
+ await fsp.rm(legacy, { force: true });
323
363
  }
324
- throw new Error(`Timed out waiting for runtime install lock: ${lockDir}`);
325
364
  }
326
365
 
327
366
  export async function writeRuntimeManifest(runtimeDir: string, install: RuntimeInstallSpec): Promise<void> {
@@ -363,7 +402,14 @@ async function runRuntimeInstall(runtimeDir: string): Promise<void> {
363
402
 
364
403
  /**
365
404
  * Materialize a pinned dependency set into `runtimeDir` (idempotent,
366
- * cross-process safe via a lock directory). Returns `runtimeDir`.
405
+ * cross-process safe). Returns `runtimeDir`.
406
+ *
407
+ * Serialization uses the OS-backed {@link withFileLock} at
408
+ * `${runtimeDir}.install.lock`, which the kernel releases on process death, so
409
+ * a crashed installer cannot wedge later attempts (issue #10120). The path is
410
+ * deliberately distinct from the legacy `${runtimeDir}.lock` mkdir directory;
411
+ * {@link withLegacyInstallLock} atomically reserves that namespace during the
412
+ * new install so older processes cannot cross the migration boundary.
367
413
  */
368
414
  export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOptions): Promise<string> {
369
415
  const { runtimeDir, install, onPhase, lockAttempts = 240, lockSleepMs = 250 } = options;
@@ -379,15 +425,20 @@ export async function ensureRuntimeInstalled(options: EnsureRuntimeInstalledOpti
379
425
  if (await probeManifest.exists()) return runtimeDir;
380
426
 
381
427
  onPhase?.("initiate");
382
- const releaseLock = await acquireInstallLock(runtimeDir, lockAttempts, lockSleepMs);
383
- try {
384
- if (await probeManifest.exists()) return runtimeDir;
385
- await writeRuntimeManifest(runtimeDir, install);
386
- onPhase?.("download");
387
- await runRuntimeInstall(runtimeDir);
388
- onPhase?.("done");
389
- return runtimeDir;
390
- } finally {
391
- await releaseLock();
392
- }
428
+ // withFileLock does not create parent directories; the runtime cache dir may
429
+ // not exist yet on the very first install.
430
+ await fsp.mkdir(path.dirname(runtimeDir), { recursive: true });
431
+ return withFileLock(
432
+ `${runtimeDir}.install`,
433
+ () =>
434
+ withLegacyInstallLock(runtimeDir, lockSleepMs, async () => {
435
+ if (await probeManifest.exists()) return runtimeDir;
436
+ await writeRuntimeManifest(runtimeDir, install);
437
+ onPhase?.("download");
438
+ await runRuntimeInstall(runtimeDir);
439
+ onPhase?.("done");
440
+ return runtimeDir;
441
+ }),
442
+ { retries: lockAttempts, retryDelayMs: lockSleepMs },
443
+ );
393
444
  }
package/src/temp.ts CHANGED
@@ -78,10 +78,12 @@ function normalizePrefix(prefix?: string): string {
78
78
  }
79
79
 
80
80
  const kRemoveOptions = { recursive: true, force: true } as const;
81
- const kRemoveRetries = 40;
82
- // 50ms × 40 retries = 2s total retry window. Windows holds file locks on
83
- // SQLite DBs for up to ~1.5s after close(); the previous 25ms (1s total)
84
- // was too short for some test cleanup scenarios.
81
+ const kRemoveRetries = 150;
82
+ // 50ms × 150 retries = 7.5s total retry window. Windows releases file locks on
83
+ // SQLite DBs (and spawned-process CWDs) asynchronously after close(); the
84
+ // measured release latency reaches ~730ms–5s when a child process just died
85
+ // holding the directory, and the previous 2s window flaked suites that clean
86
+ // up temp trees containing agent.db.
85
87
  const kRemoveRetryDelayMs = 50;
86
88
  const kRetryableRemoveErrorCodes = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]);
87
89
  const kSleepBuffer = new Int32Array(new SharedArrayBuffer(4));
@@ -132,3 +134,13 @@ function sleepSync(ms: number): void {
132
134
  }
133
135
  Atomics.wait(kSleepBuffer, 0, 0, ms);
134
136
  }
137
+
138
+ /**
139
+ * Create a directory symlink in a platform-correct way. Windows denies
140
+ * unprivileged file/dir symlinks (EPERM on `fs.symlink` without a type), but
141
+ * allows directory *junctions*, which every test that links temp directories
142
+ * should use instead. POSIX ignores the type argument.
143
+ */
144
+ export function symlinkDirectorySync(target: string, linkPath: string): void {
145
+ fs.symlinkSync(target, linkPath, process.platform === "win32" ? "junction" : "dir");
146
+ }
package/src/template.ts CHANGED
@@ -549,7 +549,7 @@ export function compile<T = unknown>(source: string, options: CompileOptions = {
549
549
  const nodes = parseTemplate(stripStandalone(source));
550
550
  return (context, runtime = {}) => {
551
551
  const root = context ?? {};
552
- const frame: Frame = { context: root, parents: [], root, data: { root, ...(runtime.data ?? {}) } };
552
+ const frame: Frame = { context: root, parents: [], root, data: { root, ...runtime.data } };
553
553
  return renderNodes(nodes, frame, { helpers, partials, options, runtime });
554
554
  };
555
555
  }
@@ -579,7 +579,7 @@ export class TemplateEngine {
579
579
  const nodes = parseTemplate(stripStandalone(source));
580
580
  return (context, runtime = {}) => {
581
581
  const root = context ?? {};
582
- const frame: Frame = { context: root, parents: [], root, data: { root, ...(runtime.data ?? {}) } };
582
+ const frame: Frame = { context: root, parents: [], root, data: { root, ...runtime.data } };
583
583
  return renderNodes(nodes, frame, { helpers: this.#helpers, partials: this.#partials, options, runtime });
584
584
  };
585
585
  }
package/src/tls-fetch.ts CHANGED
@@ -174,5 +174,5 @@ export function withExtraCaFetch<T extends { fetch?: FetchImpl } | undefined>(op
174
174
  const fetchImpl = options?.fetch ?? (globalThis.fetch as FetchImpl);
175
175
  const wrapped = wrapFetchForExtraCa(fetchImpl);
176
176
  if (wrapped === fetchImpl && options?.fetch !== undefined) return options;
177
- return { ...(options ?? {}), fetch: wrapped } as T;
177
+ return { ...options, fetch: wrapped } as T;
178
178
  }
@@ -222,7 +222,7 @@ export function parseHtmlFragment(html: string): TurndownNode {
222
222
  const stack: HtmlNode[] = [root];
223
223
  for (const token of htmlTokens(html)) {
224
224
  const parent = stack[stack.length - 1] ?? root;
225
- if (token.startsWith("<!--") || /^<!/i.test(token)) continue;
225
+ if (token.startsWith("<!--") || token.startsWith("<!")) continue;
226
226
  if (!token.startsWith("<")) {
227
227
  parent.append(new HtmlText(decodeEntities(token)));
228
228
  continue;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Answers terminal capability queries emitted by programs on a headless PTY.
3
+ *
4
+ * A PTY that advertises `TERM=xterm-256color` but has no terminal behind it
5
+ * leaves capability probes unanswered: a program writes a query escape to
6
+ * stdout and blocks on stdin until the reply arrives (or its timeout expires,
7
+ * seconds later). This scanner watches raw PTY output for the standard queries
8
+ * and returns the bytes a real xterm-class terminal would send back, so the
9
+ * caller can write them into the PTY. It keeps no screen state — cursor
10
+ * position reports are answered with the home position — which makes it cheap
11
+ * enough to run on every byte of a long-lived supervised process. Use the full
12
+ * {@link Terminal} when the caller also renders the output.
13
+ *
14
+ * Queries can straddle chunk boundaries, so an unfinished trailing escape is
15
+ * carried into the next {@link feed}.
16
+ */
17
+ export class TerminalQueryResponder {
18
+ /** Trailing bytes that may be the start of an unfinished query escape. */
19
+ #residual = "";
20
+
21
+ /**
22
+ * Feed one raw PTY output chunk. Returns the reply bytes to write back into
23
+ * the PTY, or an empty string when the chunk held no answerable query.
24
+ */
25
+ feed(chunk: string): string {
26
+ const buffer = this.#residual + chunk;
27
+ let replies = "";
28
+ let lastEnd = 0;
29
+ QUERY.lastIndex = 0;
30
+ for (let match = QUERY.exec(buffer); match !== null; match = QUERY.exec(buffer)) {
31
+ lastEnd = match.index + match[0].length;
32
+ replies += replyFor(match);
33
+ }
34
+ // Keep only a short unmatched trailing escape: a query split across
35
+ // chunks completes on the next feed, while a long tail is ordinary output
36
+ // that can never become a query.
37
+ const tailEscape = buffer.lastIndexOf("\x1b");
38
+ this.#residual =
39
+ tailEscape >= lastEnd && buffer.length - tailEscape <= MAX_PARTIAL_QUERY ? buffer.slice(tailEscape) : "";
40
+ return replies;
41
+ }
42
+ }
43
+
44
+ /** Longest query escape we answer, bounding the cross-chunk residual. */
45
+ const MAX_PARTIAL_QUERY = 32;
46
+
47
+ /**
48
+ * CSI DSR/DA queries (final byte `n` or `c`) and OSC 10/11 color queries. Only
49
+ * forms with canned answers are matched; everything else stays plain output.
50
+ */
51
+ const QUERY = /\x1b\[([?>=]?)([0-9;]*)([nc])|\x1b\](10|11);\?(\x07|\x1b\\)/gu;
52
+
53
+ /** Reply a real xterm-class terminal would send for one matched query. */
54
+ function replyFor(match: RegExpExecArray): string {
55
+ const final = match[3];
56
+ if (final !== undefined) {
57
+ const intermediate = match[1];
58
+ const params = match[2] ?? "";
59
+ if (final === "c") {
60
+ if (intermediate === ">") return "\x1b[>0;10;1c"; // secondary DA: VT100-class, firmware 10
61
+ if (intermediate === "" || intermediate === "0") return "\x1b[?1;2c"; // primary DA: VT100 with AVO
62
+ return ""; // tertiary (`=`) DA has no widely expected reply
63
+ }
64
+ if (intermediate !== "") return ""; // private DSR forms (DECXCPR, appearance) stay unanswered
65
+ const selector = params.split(";", 1)[0];
66
+ if (selector === "6") return "\x1b[1;1R"; // cursor position: home, there is no screen
67
+ if (selector === "5") return "\x1b[0n"; // device status: OK
68
+ return "";
69
+ }
70
+ // OSC color queries: neutral colors, terminated the way the request was.
71
+ const selector = match[4];
72
+ const terminator = match[5] ?? "\x07";
73
+ if (selector === "10") return `\x1b]10;rgb:ffff/ffff/ffff${terminator}`; // foreground
74
+ if (selector === "11") return `\x1b]11;rgb:0000/0000/0000${terminator}`; // background
75
+ return "";
76
+ }
package/src/vterm.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Behavior-compatible reimplementation of @xterm/headless's used surface. */
2
2
  export * from "./vterm/buffer";
3
+ export * from "./vterm/query-responder";
3
4
  export * from "./vterm/terminal";
4
5
 
5
6
  import { Terminal } from "./vterm/terminal";