@linxiraos/pi-utils 1.1.25 → 1.1.27
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 +8 -0
- package/dist/types/dirs.d.ts +6 -0
- package/dist/types/math-delimiters.d.ts +38 -0
- package/dist/types/procmgr.d.ts +14 -20
- package/dist/types/vterm/query-responder.d.ts +15 -0
- package/package.json +2 -2
- package/src/dirs.ts +9 -0
- package/src/marked/core.ts +3 -1
- package/src/math-delimiters.ts +109 -5
- package/src/procmgr.ts +58 -4
- package/src/sqlite.ts +58 -1
- package/src/temp.ts +6 -0
- package/src/vterm/query-responder.ts +31 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.1.27] - 2026-10-07
|
|
6
|
+
|
|
7
|
+
- 版本线推进至 1.1.27;本版无独立用户可见变化。
|
|
8
|
+
|
|
9
|
+
### Breaking Changes
|
|
10
|
+
|
|
11
|
+
- Replaced the `cursorPosition` option of `TerminalQueryResponder` with `hostCursorHandshake`, which leaves only the PTY host's own session-start cursor query unanswered instead of every cursor-position query
|
|
12
|
+
|
|
5
13
|
## [1.1.25] - 2026-10-03
|
|
6
14
|
|
|
7
15
|
- 版本线推进至 1.1.25;本版无独立用户可见变化。
|
package/dist/types/dirs.d.ts
CHANGED
|
@@ -357,6 +357,12 @@ export declare function getDaemonRuntimeDir(projectDir: string): string;
|
|
|
357
357
|
export declare function getGlobalDaemonRuntimeRoot(): string;
|
|
358
358
|
/** Get a profile-independent runtime directory for a machine-global daemon service. */
|
|
359
359
|
export declare function getGlobalDaemonRuntimeDir(service: string): string;
|
|
360
|
+
/**
|
|
361
|
+
* Directory naming session ownership leases (~/.zeta/run/session-owners; XDG
|
|
362
|
+
* default: $XDG_STATE_HOME/zeta/run/session-owners). Shared across profiles:
|
|
363
|
+
* every omp process that opens a session must meet the same lease.
|
|
364
|
+
*/
|
|
365
|
+
export declare function getSessionOwnersDir(): string;
|
|
360
366
|
/** Get the provider in-flight root directory (~/.zeta/run/provider-inflight; XDG default: $XDG_STATE_HOME/zeta/run/provider-inflight). */
|
|
361
367
|
export declare function getProviderInFlightRoot(): string;
|
|
362
368
|
/** Get the marketplaces registry path (~/.zeta/marketplaces.json; XDG default: $XDG_DATA_HOME/zeta/marketplaces.json). Adopts a legacy registry on first XDG resolution. */
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
*/
|
|
7
7
|
/** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
|
|
8
8
|
export type MathOpener = "$" | "$$" | "\\(" | "\\[";
|
|
9
|
+
/** Opener of an own-line display block. */
|
|
10
|
+
export type MathBlockOpener = "$$" | "\\[";
|
|
9
11
|
/** A closed math span found in the source. */
|
|
10
12
|
export interface MathSpan {
|
|
11
13
|
opener: MathOpener;
|
|
@@ -65,3 +67,39 @@ export declare function mathSpanInContext(context: {
|
|
|
65
67
|
}, src: string): MathSpan | undefined;
|
|
66
68
|
/** The own-line display block starting at offset 0, or `undefined`. */
|
|
67
69
|
export declare function mathBlockAt(source: string): MathBlock | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* {@link mathBlockAt} for many offsets of one `source`. An own-line opener
|
|
72
|
+
* whose closer line is missing means every later opener of the same kind
|
|
73
|
+
* misses one too, since its search covers a suffix of that search. The scan
|
|
74
|
+
* keeps that answer, so asking at every block start takes linear time instead
|
|
75
|
+
* of a scan to the end of `source` per unclosed opener.
|
|
76
|
+
*/
|
|
77
|
+
export declare class MathBlockScan {
|
|
78
|
+
#private;
|
|
79
|
+
constructor(source: string);
|
|
80
|
+
/** The own-line display block starting at `from`, or `undefined`. */
|
|
81
|
+
at(from: number): MathBlock | undefined;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The display opener alone on the line at `from` (up to 3 leading spaces,
|
|
85
|
+
* trailing spaces or tabs; the source's last line may be unterminated), or
|
|
86
|
+
* `undefined`.
|
|
87
|
+
*/
|
|
88
|
+
export declare function mathBlockOpenerAt(source: string, from?: number): MathBlockOpener | undefined;
|
|
89
|
+
/**
|
|
90
|
+
* Whether the own-line display block opened at `from` in `source` is closed,
|
|
91
|
+
* or could still close once more text is appended: the text from `from`
|
|
92
|
+
* onward is a streaming prefix whose real closer may not have arrived. A
|
|
93
|
+
* closer on the unterminated last line has not arrived either, since the next
|
|
94
|
+
* append may extend it into text (`$$ E = mc^2 $$`). With no opener line at
|
|
95
|
+
* `from` it returns false before scanning, so a caller can ask at every block
|
|
96
|
+
* start.
|
|
97
|
+
*/
|
|
98
|
+
export declare function mathBlockMayCloseAt(source: string, from?: number): boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Offset of the first line of `source`, from the line start `from` on, that
|
|
101
|
+
* holds the closer of `opener` alone (up to 3 leading spaces, trailing spaces
|
|
102
|
+
* or tabs), the unterminated last line included, or `undefined`: the first
|
|
103
|
+
* line at which a block `opener` opened before `from` could close.
|
|
104
|
+
*/
|
|
105
|
+
export declare function mathBlockCloserIndex(source: string, from: number, opener: MathBlockOpener): number | undefined;
|
package/dist/types/procmgr.d.ts
CHANGED
|
@@ -36,26 +36,6 @@ export declare function isPosixShell(shell: string): boolean;
|
|
|
36
36
|
* Resolve a basic shell (bash or sh) as fallback.
|
|
37
37
|
*/
|
|
38
38
|
export declare function resolveBasicShell(): string | undefined;
|
|
39
|
-
/**
|
|
40
|
-
* Resolve the external shell to advertise on Windows.
|
|
41
|
-
*
|
|
42
|
-
* A host bash is OPTIONAL: bash tool commands always execute in the embedded
|
|
43
|
-
* brush-core shell. The resolved binary only serves the spawn-a-shell paths
|
|
44
|
-
* (interactive PTY sessions, ACP client terminals, SHELL env), so this
|
|
45
|
-
* prefers a real Git Bash when one exists and otherwise falls back to
|
|
46
|
-
* cmd.exe — it never fails.
|
|
47
|
-
*
|
|
48
|
-
* Search order:
|
|
49
|
-
* 1. Git for Windows install roots (machine + per-user installers)
|
|
50
|
-
* 2. scoop installs — scoop's git manifest sets GIT_INSTALL_ROOT and shims
|
|
51
|
-
* sh.exe/git.exe but never bash.exe, so PATH lookup alone misses it
|
|
52
|
-
* 3. bash.exe on PATH (Cygwin, MSYS2, ...)
|
|
53
|
-
* 4. sh.exe on PATH (Git for Windows' sh.exe is bash; prefer a sibling
|
|
54
|
-
* bash.exe when present)
|
|
55
|
-
* 5. cmd.exe from ComSpec
|
|
56
|
-
*
|
|
57
|
-
* Exported for tests; `env` overrides Bun.env-based discovery.
|
|
58
|
-
*/
|
|
59
39
|
export declare function resolveWindowsShell(env?: Record<string, string | undefined>): string;
|
|
60
40
|
/**
|
|
61
41
|
* Get shell configuration based on platform.
|
|
@@ -70,6 +50,20 @@ export declare function resolveWindowsShell(env?: Record<string, string | undefi
|
|
|
70
50
|
* `shellPath` mid-process immediately falls back to the platform default.
|
|
71
51
|
*/
|
|
72
52
|
export declare function getShellConfig(customShellPath?: string, options?: ShellConfigOptions): ShellConfig;
|
|
53
|
+
/**
|
|
54
|
+
* Capture the current process environment for child shells and drop both cached
|
|
55
|
+
* shell configs, so the next {@link getShellConfig} builds the spawn environment
|
|
56
|
+
* from that capture. Call right after code that changed `process.env` in a way
|
|
57
|
+
* child shells must see, such as extension session lifecycle handlers exporting
|
|
58
|
+
* session-scoped variables; later writes reach child shells only at the next call.
|
|
59
|
+
*/
|
|
60
|
+
export declare function refreshShellConfigCache(): void;
|
|
61
|
+
/**
|
|
62
|
+
* Test-only: forget the capture and both cached shell configs, so the next
|
|
63
|
+
* {@link getShellConfig} builds from the live environment again. Production code
|
|
64
|
+
* MUST NOT call this; the capture's lifecycle is owned by {@link refreshShellConfigCache}.
|
|
65
|
+
*/
|
|
66
|
+
export declare function __resetShellConfigCacheForTests(): void;
|
|
73
67
|
/**
|
|
74
68
|
* Check if a process is running.
|
|
75
69
|
*/
|
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link TerminalQueryResponder}.
|
|
3
|
+
*/
|
|
4
|
+
export interface TerminalQueryResponderOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The PTY host opens the session with its own cursor-position query
|
|
7
|
+
* (`CSI 6 n`), which the PTY layer has already answered — ConPTY's
|
|
8
|
+
* `PSEUDOCONSOLE_INHERIT_CURSOR` handshake. Leave that first cursor query
|
|
9
|
+
* unanswered: the host consumes exactly one report, so a second one would
|
|
10
|
+
* reach the program as input it never asked for. Every later cursor query
|
|
11
|
+
* is answered. Defaults to `false`.
|
|
12
|
+
*/
|
|
13
|
+
hostCursorHandshake?: boolean;
|
|
14
|
+
}
|
|
1
15
|
/**
|
|
2
16
|
* Answers terminal capability queries emitted by programs on a headless PTY.
|
|
3
17
|
*
|
|
@@ -16,6 +30,7 @@
|
|
|
16
30
|
*/
|
|
17
31
|
export declare class TerminalQueryResponder {
|
|
18
32
|
#private;
|
|
33
|
+
constructor(options?: TerminalQueryResponderOptions);
|
|
19
34
|
/**
|
|
20
35
|
* Feed one raw PTY output chunk. Returns the reply bytes to write back into
|
|
21
36
|
* the PTY, or an empty string when the chunk held no answerable query.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxiraos/pi-utils",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.27",
|
|
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.1.
|
|
57
|
+
"@linxiraos/pi-natives": "1.1.27"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/bun": "^1.3.14"
|
package/src/dirs.ts
CHANGED
|
@@ -1159,6 +1159,15 @@ export function getGlobalDaemonRuntimeDir(service: string): string {
|
|
|
1159
1159
|
return path.join(getGlobalDaemonRuntimeRoot(), service);
|
|
1160
1160
|
}
|
|
1161
1161
|
|
|
1162
|
+
/**
|
|
1163
|
+
* Directory naming session ownership leases (~/.zeta/run/session-owners; XDG
|
|
1164
|
+
* default: $XDG_STATE_HOME/zeta/run/session-owners). Shared across profiles:
|
|
1165
|
+
* every omp process that opens a session must meet the same lease.
|
|
1166
|
+
*/
|
|
1167
|
+
export function getSessionOwnersDir(): string {
|
|
1168
|
+
return dirs.baseRootSubdir(path.join("run", "session-owners"), "state");
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1162
1171
|
/** Get the provider in-flight root directory (~/.zeta/run/provider-inflight; XDG default: $XDG_STATE_HOME/zeta/run/provider-inflight). */
|
|
1163
1172
|
export function getProviderInFlightRoot(): string {
|
|
1164
1173
|
return dirs.rootSubdir(path.join("run", "provider-inflight"), "state");
|
package/src/marked/core.ts
CHANGED
|
@@ -820,8 +820,10 @@ function isBlockStart(lines: string[], index: number): boolean {
|
|
|
820
820
|
return false;
|
|
821
821
|
}
|
|
822
822
|
|
|
823
|
+
// `[^\n]`, not `.`: `.` stops at U+2028 and U+2029, which end no Markdown line,
|
|
824
|
+
// and the match then skips the text in front of them.
|
|
823
825
|
function lineArray(src: string): string[] {
|
|
824
|
-
return src.match(
|
|
826
|
+
return src.match(/[^\n]*(?:\n|$)/g)?.filter((line, index, all) => line !== "" || index < all.length - 1) ?? [];
|
|
825
827
|
}
|
|
826
828
|
function stripFinalNewline(value: string): string {
|
|
827
829
|
return value.endsWith("\n") ? value.slice(0, -1) : value;
|
package/src/math-delimiters.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
/** Opening delimiter. Each closer (`$`, `$$`, `\)`, `\]`) is as wide as its opener. */
|
|
9
9
|
export type MathOpener = "$" | "$$" | "\\(" | "\\[";
|
|
10
10
|
|
|
11
|
+
/** Opener of an own-line display block. */
|
|
12
|
+
export type MathBlockOpener = "$$" | "\\[";
|
|
13
|
+
|
|
11
14
|
/** A closed math span found in the source. */
|
|
12
15
|
export interface MathSpan {
|
|
13
16
|
opener: MathOpener;
|
|
@@ -33,8 +36,8 @@ export interface MathBlock {
|
|
|
33
36
|
// it. The own-line requirement leaves inline `$$…$$` inside prose to the span
|
|
34
37
|
// grammar below. `\r?\n` at each line boundary keeps the grammar CRLF-safe for
|
|
35
38
|
// direct callers; marked-fed renderers already normalize line endings first.
|
|
36
|
-
const MATH_BLOCK_DOLLAR =
|
|
37
|
-
const MATH_BLOCK_BRACKET =
|
|
39
|
+
const MATH_BLOCK_DOLLAR = / {0,3}\$\$[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\$\$[ \t]*(?:\r?\n|$)/y;
|
|
40
|
+
const MATH_BLOCK_BRACKET = / {0,3}\\\[[ \t]*\r?\n([\s\S]+?)\r?\n {0,3}\\\][ \t]*(?:\r?\n|$)/y;
|
|
38
41
|
|
|
39
42
|
/**
|
|
40
43
|
* Leftmost offset at or after `from` where an opener could begin. A scan hint,
|
|
@@ -182,9 +185,110 @@ function spanOf(source: string, opener: MathOpener, bodyStart: number, closeAt:
|
|
|
182
185
|
|
|
183
186
|
/** The own-line display block starting at offset 0, or `undefined`. */
|
|
184
187
|
export function mathBlockAt(source: string): MathBlock | undefined {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
return
|
|
188
|
+
MATH_BLOCK_DOLLAR.lastIndex = 0;
|
|
189
|
+
MATH_BLOCK_BRACKET.lastIndex = 0;
|
|
190
|
+
return blockOf(MATH_BLOCK_DOLLAR.exec(source) ?? MATH_BLOCK_BRACKET.exec(source));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The block a match of the block grammar describes; a whitespace-only body is no block. */
|
|
194
|
+
function blockOf(match: RegExpExecArray | null): MathBlock | undefined {
|
|
195
|
+
return match === null || match[1].trim() === "" ? undefined : { raw: match[0], body: match[1] };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// An own-line display opener line (`$$` or `\[` after up to 3 spaces), capturing
|
|
199
|
+
// the opener and the line's end: "" when it is the source's unterminated last line.
|
|
200
|
+
const MATH_BLOCK_OPENER_LINE_RE = / {0,3}(\$\$|\\\[)[ \t]*(\r?\n|$)/y;
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* {@link mathBlockAt} for many offsets of one `source`. An own-line opener
|
|
204
|
+
* whose closer line is missing means every later opener of the same kind
|
|
205
|
+
* misses one too, since its search covers a suffix of that search. The scan
|
|
206
|
+
* keeps that answer, so asking at every block start takes linear time instead
|
|
207
|
+
* of a scan to the end of `source` per unclosed opener.
|
|
208
|
+
*/
|
|
209
|
+
export class MathBlockScan {
|
|
210
|
+
readonly #source: string;
|
|
211
|
+
// Offset of the first `$$` / `\[` opener line found with no closer line after it.
|
|
212
|
+
#unclosedDollarFrom = Number.POSITIVE_INFINITY;
|
|
213
|
+
#unclosedBracketFrom = Number.POSITIVE_INFINITY;
|
|
214
|
+
|
|
215
|
+
constructor(source: string) {
|
|
216
|
+
this.#source = source;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The own-line display block starting at `from`, or `undefined`. */
|
|
220
|
+
at(from: number): MathBlock | undefined {
|
|
221
|
+
MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
|
|
222
|
+
const line = MATH_BLOCK_OPENER_LINE_RE.exec(this.#source);
|
|
223
|
+
// An unterminated opener line opens no block.
|
|
224
|
+
if (line === null || line[2] === "") return undefined;
|
|
225
|
+
const dollar = line[1] === "$$";
|
|
226
|
+
if (from >= (dollar ? this.#unclosedDollarFrom : this.#unclosedBracketFrom)) return undefined;
|
|
227
|
+
const grammar = dollar ? MATH_BLOCK_DOLLAR : MATH_BLOCK_BRACKET;
|
|
228
|
+
grammar.lastIndex = from;
|
|
229
|
+
const match = grammar.exec(this.#source);
|
|
230
|
+
if (match === null) {
|
|
231
|
+
if (dollar) this.#unclosedDollarFrom = from;
|
|
232
|
+
else this.#unclosedBracketFrom = from;
|
|
233
|
+
}
|
|
234
|
+
return blockOf(match);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The display opener alone on the line at `from` (up to 3 leading spaces,
|
|
240
|
+
* trailing spaces or tabs; the source's last line may be unterminated), or
|
|
241
|
+
* `undefined`.
|
|
242
|
+
*/
|
|
243
|
+
export function mathBlockOpenerAt(source: string, from = 0): MathBlockOpener | undefined {
|
|
244
|
+
MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
|
|
245
|
+
const line = MATH_BLOCK_OPENER_LINE_RE.exec(source);
|
|
246
|
+
return line === null ? undefined : line[1] === "$$" ? "$$" : "\\[";
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Whether the own-line display block opened at `from` in `source` is closed,
|
|
251
|
+
* or could still close once more text is appended: the text from `from`
|
|
252
|
+
* onward is a streaming prefix whose real closer may not have arrived. A
|
|
253
|
+
* closer on the unterminated last line has not arrived either, since the next
|
|
254
|
+
* append may extend it into text (`$$ E = mc^2 $$`). With no opener line at
|
|
255
|
+
* `from` it returns false before scanning, so a caller can ask at every block
|
|
256
|
+
* start.
|
|
257
|
+
*/
|
|
258
|
+
export function mathBlockMayCloseAt(source: string, from = 0): boolean {
|
|
259
|
+
MATH_BLOCK_OPENER_LINE_RE.lastIndex = from;
|
|
260
|
+
const line = MATH_BLOCK_OPENER_LINE_RE.exec(source);
|
|
261
|
+
if (line === null) return false;
|
|
262
|
+
// The opener's own line is still being written.
|
|
263
|
+
if (line[2] === "") return true;
|
|
264
|
+
const grammar = line[1] === "$$" ? MATH_BLOCK_DOLLAR : MATH_BLOCK_BRACKET;
|
|
265
|
+
grammar.lastIndex = from;
|
|
266
|
+
const match = grammar.exec(source);
|
|
267
|
+
// No closer line yet, or only on the last line, which is still being written.
|
|
268
|
+
if (match === null || (from + match[0].length === source.length && !match[0].endsWith("\n"))) return true;
|
|
269
|
+
return blockOf(match) !== undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// A display closer alone on its line, captured; the last line counts unterminated.
|
|
273
|
+
const MATH_BLOCK_CLOSER_LINE_RE = /(?<=^|\n) {0,3}(\$\$|\\\])[ \t]*(?=\r?\n|$)/g;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Offset of the first line of `source`, from the line start `from` on, that
|
|
277
|
+
* holds the closer of `opener` alone (up to 3 leading spaces, trailing spaces
|
|
278
|
+
* or tabs), the unterminated last line included, or `undefined`: the first
|
|
279
|
+
* line at which a block `opener` opened before `from` could close.
|
|
280
|
+
*/
|
|
281
|
+
export function mathBlockCloserIndex(source: string, from: number, opener: MathBlockOpener): number | undefined {
|
|
282
|
+
const closer = opener === "$$" ? "$$" : "\\]";
|
|
283
|
+
MATH_BLOCK_CLOSER_LINE_RE.lastIndex = from;
|
|
284
|
+
for (
|
|
285
|
+
let line = MATH_BLOCK_CLOSER_LINE_RE.exec(source);
|
|
286
|
+
line !== null;
|
|
287
|
+
line = MATH_BLOCK_CLOSER_LINE_RE.exec(source)
|
|
288
|
+
) {
|
|
289
|
+
if (line[1] === closer) return line.index;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
188
292
|
}
|
|
189
293
|
|
|
190
294
|
/**
|
package/src/procmgr.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { Process, ProcessStatus } from "@linxiraos/pi-natives";
|
|
4
4
|
import type { Subprocess } from "bun";
|
|
5
|
-
import { getAgentDir, MAIN_CONFIG_FILENAMES } from "./dirs";
|
|
5
|
+
import { getAgentDir, getProjectDir, MAIN_CONFIG_FILENAMES } from "./dirs";
|
|
6
6
|
import { $env, filterChildShellEnv } from "./env";
|
|
7
7
|
import { isExecutable } from "./executable";
|
|
8
8
|
import { $which } from "./which";
|
|
@@ -20,18 +20,31 @@ export interface ShellConfigOptions {
|
|
|
20
20
|
/** File path or runtime layer that supplied the active shell setting. */
|
|
21
21
|
configSource?: string;
|
|
22
22
|
}
|
|
23
|
-
/** Auto-resolved shell (no custom path);
|
|
23
|
+
/** Auto-resolved shell (no custom path); kept until {@link refreshShellConfigCache}. */
|
|
24
24
|
let cachedDefaultShellConfig: ShellConfig | null = null;
|
|
25
25
|
/** Config for the most recent custom `shellPath`; never served once the path is cleared. */
|
|
26
26
|
let cachedCustomShellConfig: ShellConfig | null = null;
|
|
27
|
+
/**
|
|
28
|
+
* The child-shell environment {@link refreshShellConfigCache} last captured. Unset
|
|
29
|
+
* until the first refresh, so spawn environments are built from the live environment.
|
|
30
|
+
*/
|
|
31
|
+
let capturedChildShellEnv: Record<string, string> | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Project whose dotenv values the spawn environment filters out: the one current
|
|
34
|
+
* when it was first built or captured.
|
|
35
|
+
* That is normally the launch project, whose dotenv files Bun and omp load into
|
|
36
|
+
* `process.env`; a later session in another project must not receive them.
|
|
37
|
+
*/
|
|
38
|
+
let spawnEnvProjectDir: string | undefined;
|
|
27
39
|
|
|
28
40
|
/**
|
|
29
41
|
* Build the spawn environment (cached).
|
|
30
42
|
*/
|
|
31
43
|
function buildSpawnEnv(shell: string): Record<string, string> {
|
|
32
44
|
const noCI = $env.PI_BASH_NO_CI || $env.CLAUDE_BASH_NO_CI;
|
|
45
|
+
spawnEnvProjectDir ??= getProjectDir();
|
|
33
46
|
return {
|
|
34
|
-
...filterChildShellEnv(Bun.env),
|
|
47
|
+
...(capturedChildShellEnv ?? filterChildShellEnv(Bun.env, spawnEnvProjectDir)),
|
|
35
48
|
SHELL: shell,
|
|
36
49
|
GIT_EDITOR: "true",
|
|
37
50
|
GPG_TTY: "not a tty",
|
|
@@ -145,6 +158,17 @@ export function resolveBasicShell(): string | undefined {
|
|
|
145
158
|
*
|
|
146
159
|
* Exported for tests; `env` overrides Bun.env-based discovery.
|
|
147
160
|
*/
|
|
161
|
+
|
|
162
|
+
/** True for the WSL launcher bash.exe variants, which are not Windows shells. */
|
|
163
|
+
function isWslLauncher(bashPath: string): boolean {
|
|
164
|
+
const normalized = bashPath.toLowerCase().replaceAll("/", "\\");
|
|
165
|
+
return (
|
|
166
|
+
normalized.endsWith("\\system32\\bash.exe") ||
|
|
167
|
+
normalized.endsWith("\\windowsapps\\bash.exe") ||
|
|
168
|
+
normalized.endsWith("\\system32\\wsl.exe")
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
148
172
|
export function resolveWindowsShell(env: Record<string, string | undefined> = Bun.env): string {
|
|
149
173
|
const gitRoots = [
|
|
150
174
|
env.ProgramFiles && path.join(env.ProgramFiles, "Git"),
|
|
@@ -160,8 +184,12 @@ export function resolveWindowsShell(env: Record<string, string | undefined> = Bu
|
|
|
160
184
|
if (fs.existsSync(candidate)) return candidate;
|
|
161
185
|
}
|
|
162
186
|
|
|
187
|
+
// The WSL launcher (`System32\bash.exe` / `WindowsApps\bash.exe`) is not a
|
|
188
|
+
// Windows shell: it runs inside Linux without the spawn environment, so
|
|
189
|
+
// scripted `-c` snapshots break. Skip it and let the sh.exe sibling (Git
|
|
190
|
+
// Bash) or cmd.exe win instead.
|
|
163
191
|
const bashOnPath = $which("bash.exe");
|
|
164
|
-
if (bashOnPath) return bashOnPath;
|
|
192
|
+
if (bashOnPath && !isWslLauncher(bashOnPath)) return bashOnPath;
|
|
165
193
|
|
|
166
194
|
const shOnPath = $which("sh.exe");
|
|
167
195
|
if (shOnPath) {
|
|
@@ -202,6 +230,32 @@ export function getShellConfig(customShellPath?: string, options: ShellConfigOpt
|
|
|
202
230
|
return cachedDefaultShellConfig;
|
|
203
231
|
}
|
|
204
232
|
|
|
233
|
+
/**
|
|
234
|
+
* Capture the current process environment for child shells and drop both cached
|
|
235
|
+
* shell configs, so the next {@link getShellConfig} builds the spawn environment
|
|
236
|
+
* from that capture. Call right after code that changed `process.env` in a way
|
|
237
|
+
* child shells must see, such as extension session lifecycle handlers exporting
|
|
238
|
+
* session-scoped variables; later writes reach child shells only at the next call.
|
|
239
|
+
*/
|
|
240
|
+
export function refreshShellConfigCache(): void {
|
|
241
|
+
spawnEnvProjectDir ??= getProjectDir();
|
|
242
|
+
capturedChildShellEnv = filterChildShellEnv(Bun.env, spawnEnvProjectDir);
|
|
243
|
+
cachedDefaultShellConfig = null;
|
|
244
|
+
cachedCustomShellConfig = null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Test-only: forget the capture and both cached shell configs, so the next
|
|
249
|
+
* {@link getShellConfig} builds from the live environment again. Production code
|
|
250
|
+
* MUST NOT call this; the capture's lifecycle is owned by {@link refreshShellConfigCache}.
|
|
251
|
+
*/
|
|
252
|
+
export function __resetShellConfigCacheForTests(): void {
|
|
253
|
+
capturedChildShellEnv = undefined;
|
|
254
|
+
spawnEnvProjectDir = undefined;
|
|
255
|
+
cachedDefaultShellConfig = null;
|
|
256
|
+
cachedCustomShellConfig = null;
|
|
257
|
+
}
|
|
258
|
+
|
|
205
259
|
/** Platform shell discovery used when no custom `shellPath` is configured. */
|
|
206
260
|
function resolveDefaultShell(): string {
|
|
207
261
|
if (process.platform === "win32") {
|
package/src/sqlite.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/** Shared SQLite opening, error attribution, and result-code classification for persistent stores. */
|
|
2
2
|
import { Database } from "bun:sqlite";
|
|
3
3
|
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
4
5
|
import { getDbBusyTimeoutMs } from "./env";
|
|
5
6
|
import { withFileLockSync } from "./file-lock";
|
|
6
7
|
import { isEnoent } from "./fs-error";
|
|
@@ -12,6 +13,55 @@ const SQLITE_STORE_SUFFIXES = ["-wal", "-shm", "-journal", ""];
|
|
|
12
13
|
|
|
13
14
|
type SqliteFileIdentity = string | null | undefined;
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Corrupt handles held open by in-process openers awaiting recovery, keyed by
|
|
18
|
+
* resolved store path. Windows refuses to unlink a file any handle still holds,
|
|
19
|
+
* so the opener that quarantines the store closes its peers' dead handles too.
|
|
20
|
+
*/
|
|
21
|
+
const pendingCorruptHandles = new Map<string, Set<Database>>();
|
|
22
|
+
|
|
23
|
+
function corruptHandleKey(dbPath: string): string {
|
|
24
|
+
const resolved = path.resolve(dbPath);
|
|
25
|
+
return process.platform === "win32" ? resolved.toLowerCase() : resolved;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function registerCorruptHandle(dbPath: string, db: Database | undefined): void {
|
|
29
|
+
if (!db) return;
|
|
30
|
+
const key = corruptHandleKey(dbPath);
|
|
31
|
+
let handles = pendingCorruptHandles.get(key);
|
|
32
|
+
if (!handles) {
|
|
33
|
+
handles = new Set();
|
|
34
|
+
pendingCorruptHandles.set(key, handles);
|
|
35
|
+
}
|
|
36
|
+
handles.add(db);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unregisterCorruptHandle(dbPath: string, db: Database | undefined): void {
|
|
40
|
+
if (!db) return;
|
|
41
|
+
const key = corruptHandleKey(dbPath);
|
|
42
|
+
const handles = pendingCorruptHandles.get(key);
|
|
43
|
+
if (!handles) return;
|
|
44
|
+
handles.delete(db);
|
|
45
|
+
if (handles.size === 0) pendingCorruptHandles.delete(key);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Closes peers' failed handles on the store; each peer still closes (idempotently) and adopts on its own path. */
|
|
49
|
+
function closePeerCorruptHandles(dbPath: string, own: Database | undefined): void {
|
|
50
|
+
const handles = pendingCorruptHandles.get(corruptHandleKey(dbPath));
|
|
51
|
+
if (!handles) return;
|
|
52
|
+
for (const peer of handles) {
|
|
53
|
+
if (peer === own) continue;
|
|
54
|
+
try {
|
|
55
|
+
peer.close();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
logger.warn("Failed to close a peer's corrupt SQLite handle before quarantine", {
|
|
58
|
+
path: dbPath,
|
|
59
|
+
error: String(error),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
15
65
|
class SqliteAttemptFailure extends Error {
|
|
16
66
|
readonly original: unknown;
|
|
17
67
|
readonly identity: SqliteFileIdentity;
|
|
@@ -99,6 +149,7 @@ async function openWithBusyRetries<T>(
|
|
|
99
149
|
} catch (caught) {
|
|
100
150
|
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
101
151
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
152
|
+
registerCorruptHandle(dbPath, db);
|
|
102
153
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
103
154
|
}
|
|
104
155
|
closeFailedDatabase(db, error, identity);
|
|
@@ -120,6 +171,7 @@ function openOnce<T>(dbPath: string, initialize: (db: Database) => T, options: S
|
|
|
120
171
|
} catch (caught) {
|
|
121
172
|
const error = options.recoverCorruption ? revealHiddenCorruption(db, caught) : caught;
|
|
122
173
|
if (options.recoverCorruption && isSqliteCorruptionError(error)) {
|
|
174
|
+
registerCorruptHandle(dbPath, db);
|
|
123
175
|
throw new SqliteAttemptFailure(error, identity, { db });
|
|
124
176
|
}
|
|
125
177
|
closeFailedDatabase(db, error, identity);
|
|
@@ -143,6 +195,10 @@ function quarantineCorruptSqliteStore(dbPath: string, db: Database | undefined):
|
|
|
143
195
|
}
|
|
144
196
|
}
|
|
145
197
|
db?.close();
|
|
198
|
+
// Only Windows refuses to unlink a file another handle still holds. Elsewhere
|
|
199
|
+
// each peer must keep its own handle until it closes it after adopting the
|
|
200
|
+
// replacement; closing it here races the peer's recovery on POSIX.
|
|
201
|
+
if (process.platform === "win32") closePeerCorruptHandles(dbPath, db);
|
|
146
202
|
|
|
147
203
|
const removed: string[] = [];
|
|
148
204
|
try {
|
|
@@ -199,6 +255,7 @@ function recoverCorruptDatabase(dbPath: string, error: unknown, options: SqliteO
|
|
|
199
255
|
return quarantineCorruptSqliteStore(dbPath, failure.db);
|
|
200
256
|
});
|
|
201
257
|
} finally {
|
|
258
|
+
unregisterCorruptHandle(dbPath, failure.db);
|
|
202
259
|
closeFailedDatabase(failure.db, failure.original, failure.identity);
|
|
203
260
|
}
|
|
204
261
|
} catch (preservationError) {
|
|
@@ -267,7 +324,7 @@ export function openSqliteDatabaseSync<T>(
|
|
|
267
324
|
/** Adds the failing store's path to an error without losing SQLite result codes or its original stack. */
|
|
268
325
|
export function annotateSqliteError(error: unknown, dbPath: string): Error {
|
|
269
326
|
const annotated = error instanceof Error ? error : new Error(String(error));
|
|
270
|
-
annotated.message = `Database ${
|
|
327
|
+
annotated.message = `Database "${dbPath}": ${annotated.message}`;
|
|
271
328
|
return annotated;
|
|
272
329
|
}
|
|
273
330
|
|
package/src/temp.ts
CHANGED
|
@@ -96,6 +96,11 @@ export async function removeWithRetries(target: string): Promise<void> {
|
|
|
96
96
|
return;
|
|
97
97
|
} catch (err) {
|
|
98
98
|
if (!shouldRetryRemove(err, attempt)) throw err;
|
|
99
|
+
// bun on Windows finalizes SQLite's db/-wal/-shm file and directory
|
|
100
|
+
// handles on GC, so a closed database can still block deletion for
|
|
101
|
+
// seconds. One forced major collection on the first retry releases
|
|
102
|
+
// them immediately instead of burning the retry window.
|
|
103
|
+
if (attempt === 0) Bun.gc(true);
|
|
99
104
|
await Bun.sleep(kRemoveRetryDelayMs);
|
|
100
105
|
}
|
|
101
106
|
}
|
|
@@ -108,6 +113,7 @@ export function removeSyncWithRetries(target: string): void {
|
|
|
108
113
|
return;
|
|
109
114
|
} catch (err) {
|
|
110
115
|
if (!shouldRetryRemove(err, attempt)) throw err;
|
|
116
|
+
if (attempt === 0) Bun.gc(true);
|
|
111
117
|
sleepSync(kRemoveRetryDelayMs);
|
|
112
118
|
}
|
|
113
119
|
}
|
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Options for {@link TerminalQueryResponder}.
|
|
3
|
+
*/
|
|
4
|
+
export interface TerminalQueryResponderOptions {
|
|
5
|
+
/**
|
|
6
|
+
* The PTY host opens the session with its own cursor-position query
|
|
7
|
+
* (`CSI 6 n`), which the PTY layer has already answered — ConPTY's
|
|
8
|
+
* `PSEUDOCONSOLE_INHERIT_CURSOR` handshake. Leave that first cursor query
|
|
9
|
+
* unanswered: the host consumes exactly one report, so a second one would
|
|
10
|
+
* reach the program as input it never asked for. Every later cursor query
|
|
11
|
+
* is answered. Defaults to `false`.
|
|
12
|
+
*/
|
|
13
|
+
hostCursorHandshake?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
1
16
|
/**
|
|
2
17
|
* Answers terminal capability queries emitted by programs on a headless PTY.
|
|
3
18
|
*
|
|
@@ -17,6 +32,12 @@
|
|
|
17
32
|
export class TerminalQueryResponder {
|
|
18
33
|
/** Trailing bytes that may be the start of an unfinished query escape. */
|
|
19
34
|
#residual = "";
|
|
35
|
+
/** Whether the next cursor query is the host's already-answered session-start handshake. */
|
|
36
|
+
#hostCursorHandshakePending: boolean;
|
|
37
|
+
|
|
38
|
+
constructor(options: TerminalQueryResponderOptions = {}) {
|
|
39
|
+
this.#hostCursorHandshakePending = options.hostCursorHandshake ?? false;
|
|
40
|
+
}
|
|
20
41
|
|
|
21
42
|
/**
|
|
22
43
|
* Feed one raw PTY output chunk. Returns the reply bytes to write back into
|
|
@@ -29,7 +50,12 @@ export class TerminalQueryResponder {
|
|
|
29
50
|
QUERY.lastIndex = 0;
|
|
30
51
|
for (let match = QUERY.exec(buffer); match !== null; match = QUERY.exec(buffer)) {
|
|
31
52
|
lastEnd = match.index + match[0].length;
|
|
32
|
-
|
|
53
|
+
const reply = replyFor(match);
|
|
54
|
+
if (reply === CURSOR_POSITION_REPORT && this.#hostCursorHandshakePending) {
|
|
55
|
+
this.#hostCursorHandshakePending = false;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
replies += reply;
|
|
33
59
|
}
|
|
34
60
|
// Keep only a short unmatched trailing escape: a query split across
|
|
35
61
|
// chunks completes on the next feed, while a long tail is ordinary output
|
|
@@ -41,6 +67,9 @@ export class TerminalQueryResponder {
|
|
|
41
67
|
}
|
|
42
68
|
}
|
|
43
69
|
|
|
70
|
+
/** Cursor position report for the home position: there is no screen to locate the cursor on. */
|
|
71
|
+
const CURSOR_POSITION_REPORT = "\x1b[1;1R";
|
|
72
|
+
|
|
44
73
|
/** Longest query escape we answer, bounding the cross-chunk residual. */
|
|
45
74
|
const MAX_PARTIAL_QUERY = 32;
|
|
46
75
|
|
|
@@ -63,7 +92,7 @@ function replyFor(match: RegExpExecArray): string {
|
|
|
63
92
|
}
|
|
64
93
|
if (intermediate !== "") return ""; // private DSR forms (DECXCPR, appearance) stay unanswered
|
|
65
94
|
const selector = params.split(";", 1)[0];
|
|
66
|
-
if (selector === "6") return
|
|
95
|
+
if (selector === "6") return CURSOR_POSITION_REPORT;
|
|
67
96
|
if (selector === "5") return "\x1b[0n"; // device status: OK
|
|
68
97
|
return "";
|
|
69
98
|
}
|