@oh-my-pi/pi-coding-agent 15.13.1 → 15.13.2
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 +25 -0
- package/dist/cli.js +957 -214
- package/dist/types/config/model-registry.d.ts +1 -0
- package/dist/types/config/models-config-schema.d.ts +3 -0
- package/dist/types/config/models-config.d.ts +3 -0
- package/dist/types/config/settings-schema.d.ts +66 -0
- package/dist/types/edit/hashline/block-resolver.d.ts +1 -1
- package/dist/types/edit/index.d.ts +2 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/controllers/input-controller.d.ts +4 -4
- package/dist/types/modes/rpc/rpc-types.d.ts +2 -1
- package/dist/types/sdk.d.ts +3 -0
- package/dist/types/session/session-dump-format.d.ts +2 -1
- package/dist/types/system-prompt.d.ts +11 -0
- package/dist/types/tools/ask.d.ts +2 -0
- package/dist/types/tools/ast-edit.d.ts +2 -0
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/debug.d.ts +2 -0
- package/dist/types/tools/eval.d.ts +2 -0
- package/dist/types/tools/find.d.ts +2 -0
- package/dist/types/tools/inspect-image.d.ts +2 -1
- package/dist/types/tools/irc.d.ts +2 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/tools/todo.d.ts +2 -0
- package/dist/types/tui/tree-list.d.ts +1 -0
- package/package.json +12 -12
- package/src/config/model-registry.ts +10 -0
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/models-config.ts +1 -0
- package/src/config/settings-schema.ts +53 -0
- package/src/edit/hashline/block-resolver.ts +1 -1
- package/src/edit/hashline/execute.ts +1 -6
- package/src/edit/index.ts +48 -0
- package/src/eval/__tests__/js-context-manager.test.ts +41 -1
- package/src/eval/js/context-manager.ts +92 -26
- package/src/eval/js/worker-core.ts +1 -1
- package/src/internal-urls/docs-index.generated.ts +9 -2
- package/src/modes/components/welcome.ts +14 -4
- package/src/modes/controllers/input-controller.ts +21 -38
- package/src/modes/rpc/rpc-mode.ts +1 -0
- package/src/modes/rpc/rpc-types.ts +2 -2
- package/src/prompts/system/system-prompt.md +17 -21
- package/src/prompts/tools/ask.md +0 -8
- package/src/prompts/tools/ast-edit.md +0 -15
- package/src/prompts/tools/ast-grep.md +0 -13
- package/src/prompts/tools/browser.md +0 -21
- package/src/prompts/tools/debug.md +0 -13
- package/src/prompts/tools/eval.md +0 -9
- package/src/prompts/tools/find.md +0 -13
- package/src/prompts/tools/inspect-image.md +0 -9
- package/src/prompts/tools/irc.md +0 -15
- package/src/prompts/tools/patch.md +0 -13
- package/src/prompts/tools/ssh.md +0 -9
- package/src/prompts/tools/todo.md +1 -19
- package/src/sdk.ts +19 -0
- package/src/session/agent-session.ts +125 -19
- package/src/session/session-dump-format.ts +10 -31
- package/src/system-prompt.ts +31 -0
- package/src/tools/ask.ts +41 -0
- package/src/tools/ast-edit.ts +46 -0
- package/src/tools/ast-grep.ts +24 -0
- package/src/tools/browser.ts +52 -0
- package/src/tools/debug.ts +17 -0
- package/src/tools/eval.ts +20 -1
- package/src/tools/find.ts +24 -0
- package/src/tools/inspect-image.ts +27 -1
- package/src/tools/irc.ts +41 -0
- package/src/tools/ssh.ts +16 -0
- package/src/tools/todo.ts +82 -3
- package/src/tui/tree-list.ts +68 -19
|
@@ -1719,6 +1719,48 @@ export const SETTINGS_SCHEMA = {
|
|
|
1719
1719
|
},
|
|
1720
1720
|
},
|
|
1721
1721
|
|
|
1722
|
+
"tools.format": {
|
|
1723
|
+
type: "enum",
|
|
1724
|
+
values: [
|
|
1725
|
+
"auto",
|
|
1726
|
+
"native",
|
|
1727
|
+
"glm",
|
|
1728
|
+
"hermes",
|
|
1729
|
+
"kimi",
|
|
1730
|
+
"xml",
|
|
1731
|
+
"anthropic",
|
|
1732
|
+
"deepseek",
|
|
1733
|
+
"harmony",
|
|
1734
|
+
"pi",
|
|
1735
|
+
"qwen3",
|
|
1736
|
+
] as const,
|
|
1737
|
+
default: "auto",
|
|
1738
|
+
ui: {
|
|
1739
|
+
tab: "context",
|
|
1740
|
+
group: "Experimental",
|
|
1741
|
+
label: "Tool Call Format",
|
|
1742
|
+
description:
|
|
1743
|
+
"Controls how tools are exposed to the model. Auto uses native tool calls unless the selected model is marked as not supporting tools, then falls back to GLM-style in-band tool calls. Native forces provider-native tools; the other values force the named in-band syntax. Applies on session start.",
|
|
1744
|
+
options: [
|
|
1745
|
+
{
|
|
1746
|
+
value: "auto",
|
|
1747
|
+
label: "Auto",
|
|
1748
|
+
description: "Use native tool calls unless the model is known not to support them.",
|
|
1749
|
+
},
|
|
1750
|
+
{ value: "native", label: "Native", description: "Use provider-native tool calls." },
|
|
1751
|
+
{ value: "glm", label: "GLM", description: "Use GLM-style in-band tool calls." },
|
|
1752
|
+
{ value: "hermes", label: "Hermes", description: "Use Hermes-style in-band tool calls." },
|
|
1753
|
+
{ value: "kimi", label: "Kimi", description: "Use Kimi-style in-band tool calls." },
|
|
1754
|
+
{ value: "xml", label: "XML", description: "Use generic XML in-band tool calls." },
|
|
1755
|
+
{ value: "anthropic", label: "Anthropic", description: "Use Anthropic-style in-band tool calls." },
|
|
1756
|
+
{ value: "deepseek", label: "DeepSeek", description: "Use DeepSeek-style in-band tool calls." },
|
|
1757
|
+
{ value: "harmony", label: "Harmony", description: "Use Harmony-style in-band tool calls." },
|
|
1758
|
+
{ value: "pi", label: "Pi", description: "Use Pi-style in-band tool calls." },
|
|
1759
|
+
{ value: "qwen3", label: "Qwen3", description: "Use Qwen3-style in-band tool calls." },
|
|
1760
|
+
],
|
|
1761
|
+
},
|
|
1762
|
+
},
|
|
1763
|
+
|
|
1722
1764
|
"snapcompact.shape": {
|
|
1723
1765
|
type: "enum",
|
|
1724
1766
|
values: ["auto", ...SHAPE_VARIANT_NAMES] as const,
|
|
@@ -3183,6 +3225,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
3183
3225
|
description: "Ask the agent to describe the intent of each tool call before executing it",
|
|
3184
3226
|
},
|
|
3185
3227
|
},
|
|
3228
|
+
"tools.abortOnFabricatedResult": {
|
|
3229
|
+
type: "boolean",
|
|
3230
|
+
default: true,
|
|
3231
|
+
ui: {
|
|
3232
|
+
tab: "tools",
|
|
3233
|
+
group: "Execution",
|
|
3234
|
+
label: "Abort On Fabricated Tool Result",
|
|
3235
|
+
description:
|
|
3236
|
+
"With in-band tool calls, stop the model immediately when it starts hallucinating a tool result mid-turn. Disable to let the model finish generating and discard the fabricated continuation instead.",
|
|
3237
|
+
},
|
|
3238
|
+
},
|
|
3186
3239
|
|
|
3187
3240
|
"tools.maxTimeout": {
|
|
3188
3241
|
type: "number",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tree-sitter-backed {@link BlockResolver} for the hashline
|
|
2
|
+
* Tree-sitter-backed {@link BlockResolver} for the hashline block replace
|
|
3
3
|
* operator. Bridges the pure hashline seam to the native `blockRangeAt`
|
|
4
4
|
* primitive in `@oh-my-pi/pi-natives`, which infers the language from the file
|
|
5
5
|
* path and returns the 1-indexed line span of the syntactic block beginning on
|
|
@@ -98,12 +98,7 @@ interface RenderedSection {
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
function formatBlockResolution(resolution: BlockResolution): string {
|
|
101
|
-
const op =
|
|
102
|
-
resolution.op === "delete"
|
|
103
|
-
? "delete block"
|
|
104
|
-
: resolution.op === "insert_after"
|
|
105
|
-
? "insert after block"
|
|
106
|
-
: "replace block";
|
|
101
|
+
const op = resolution.op === "delete" ? "DEL.BLK" : resolution.op === "insert_after" ? "INS.BLK.POST" : "SWAP.BLK";
|
|
107
102
|
const lines = resolution.end - resolution.start + 1;
|
|
108
103
|
const span =
|
|
109
104
|
resolution.start === resolution.end ? `line ${resolution.start}` : `lines ${resolution.start}-${resolution.end}`;
|
package/src/edit/index.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { MismatchError as HashlineMismatchError } from "@oh-my-pi/hashline";
|
|
|
2
2
|
import hashlineGrammar from "@oh-my-pi/hashline/grammar.lark" with { type: "text" };
|
|
3
3
|
import hashlineDescription from "@oh-my-pi/hashline/prompt.md" with { type: "text" };
|
|
4
4
|
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
|
|
5
|
+
import type { ToolExample } from "@oh-my-pi/pi-ai";
|
|
5
6
|
import { prompt } from "@oh-my-pi/pi-utils";
|
|
7
|
+
import type { z } from "zod/v4";
|
|
6
8
|
import {
|
|
7
9
|
createLspWritethrough,
|
|
8
10
|
type FileDiagnosticsResult,
|
|
@@ -50,6 +52,7 @@ type EditParams = ReplaceParams | PatchParams | HashlineParams | ApplyPatchParam
|
|
|
50
52
|
type EditModeDefinition = {
|
|
51
53
|
description: (session: ToolSession) => string;
|
|
52
54
|
parameters: TInput;
|
|
55
|
+
examples?: readonly ToolExample[];
|
|
53
56
|
execute: (
|
|
54
57
|
tool: EditTool,
|
|
55
58
|
params: EditParams,
|
|
@@ -356,6 +359,10 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
356
359
|
return this.#getModeDefinition().parameters;
|
|
357
360
|
}
|
|
358
361
|
|
|
362
|
+
get examples(): readonly ToolExample[] | undefined {
|
|
363
|
+
return this.#getModeDefinition().examples;
|
|
364
|
+
}
|
|
365
|
+
|
|
359
366
|
/**
|
|
360
367
|
* When in `apply_patch` mode, expose the Codex Lark grammar so providers
|
|
361
368
|
* that support OpenAI-style custom tools can emit a grammar-constrained
|
|
@@ -404,6 +411,39 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
404
411
|
patch: {
|
|
405
412
|
description: () => prompt.render(patchDescription),
|
|
406
413
|
parameters: patchEditSchema,
|
|
414
|
+
examples: [
|
|
415
|
+
{
|
|
416
|
+
caption: "Create",
|
|
417
|
+
call: { path: "hello.txt", edits: [{ op: "create", diff: "Hello\n" }] },
|
|
418
|
+
},
|
|
419
|
+
{
|
|
420
|
+
caption: "Update",
|
|
421
|
+
call: {
|
|
422
|
+
path: "src/app.py",
|
|
423
|
+
edits: [
|
|
424
|
+
{
|
|
425
|
+
op: "update",
|
|
426
|
+
diff: "@@ def greet():\n def greet():\n-print('Hi')\n+print('Hello')\n",
|
|
427
|
+
},
|
|
428
|
+
],
|
|
429
|
+
},
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
caption: "Rename",
|
|
433
|
+
call: {
|
|
434
|
+
path: "src/app.py",
|
|
435
|
+
edits: [{ op: "update", rename: "src/main.py", diff: "@@\n …\n" }],
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
caption: "Delete",
|
|
440
|
+
call: { path: "obsolete.txt", edits: [{ op: "delete" }] },
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
caption: "Multiple entries",
|
|
444
|
+
note: "All entries in one call apply to the top-level `path`; use separate calls for different files.",
|
|
445
|
+
},
|
|
446
|
+
] satisfies readonly ToolExample<z.input<typeof patchEditSchema>>[],
|
|
407
447
|
execute: (
|
|
408
448
|
tool: EditTool,
|
|
409
449
|
params: EditParams,
|
|
@@ -432,6 +472,14 @@ export class EditTool implements AgentTool<TInput> {
|
|
|
432
472
|
apply_patch: {
|
|
433
473
|
description: () => prompt.render(applyPatchDescription),
|
|
434
474
|
parameters: applyPatchSchema,
|
|
475
|
+
examples: [
|
|
476
|
+
{
|
|
477
|
+
caption: "Apply a combined patch file",
|
|
478
|
+
call: {
|
|
479
|
+
input: '*** Begin Patch\n*** Add File: hello.txt\n+Hello world\n*** Update File: src/app.py\n*** Move to: src/main.py\n@@ def greet():\n-print("Hi")\n+print("Hello, world!")\n*** Delete File: obsolete.txt\n*** End Patch\n',
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
] satisfies readonly ToolExample<z.input<typeof applyPatchSchema>>[],
|
|
435
483
|
execute: (
|
|
436
484
|
tool: EditTool,
|
|
437
485
|
params: EditParams,
|
|
@@ -15,6 +15,7 @@ interface FakeWorkerStats {
|
|
|
15
15
|
interface FakeWorkerBehavior {
|
|
16
16
|
exitOnClose: boolean;
|
|
17
17
|
settleRuns: boolean;
|
|
18
|
+
errorOnStart?: boolean;
|
|
18
19
|
}
|
|
19
20
|
|
|
20
21
|
function makeSession(cwd: string): ToolSession {
|
|
@@ -70,6 +71,7 @@ async function waitForRealWorkerExitAfterClose(cwd: string): Promise<void> {
|
|
|
70
71
|
worker.addEventListener("close", () => workerClosed.resolve());
|
|
71
72
|
|
|
72
73
|
try {
|
|
74
|
+
worker.postMessage({ type: "init", snapshot });
|
|
73
75
|
await withTimeout(ready.promise, 1_000, "worker ready");
|
|
74
76
|
worker.postMessage({
|
|
75
77
|
type: "run",
|
|
@@ -91,6 +93,7 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
|
|
|
91
93
|
class FakeWorker {
|
|
92
94
|
#messageListeners = new Set<(event: MessageEvent) => void>();
|
|
93
95
|
#closeListeners = new Set<(event: Event) => void>();
|
|
96
|
+
#errorListeners = new Set<(event: Event) => void>();
|
|
94
97
|
#readyQueued = false;
|
|
95
98
|
#exited = false;
|
|
96
99
|
|
|
@@ -115,11 +118,18 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
|
|
|
115
118
|
this.#closeListeners.add(listener as (event: Event) => void);
|
|
116
119
|
return;
|
|
117
120
|
}
|
|
121
|
+
if (type === "error") {
|
|
122
|
+
this.#errorListeners.add(listener as (event: Event) => void);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
118
125
|
if (type !== "message") return;
|
|
119
126
|
this.#messageListeners.add(listener as (event: MessageEvent) => void);
|
|
120
127
|
if (!this.#readyQueued) {
|
|
121
128
|
this.#readyQueued = true;
|
|
122
|
-
queueMicrotask(() =>
|
|
129
|
+
queueMicrotask(() => {
|
|
130
|
+
if (behavior.errorOnStart) this.#emitError();
|
|
131
|
+
else this.#emitMessage({ type: "ready" });
|
|
132
|
+
});
|
|
123
133
|
}
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -128,6 +138,10 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
|
|
|
128
138
|
this.#closeListeners.delete(listener as (event: Event) => void);
|
|
129
139
|
return;
|
|
130
140
|
}
|
|
141
|
+
if (type === "error") {
|
|
142
|
+
this.#errorListeners.delete(listener as (event: Event) => void);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
131
145
|
if (type !== "message") return;
|
|
132
146
|
this.#messageListeners.delete(listener as (event: MessageEvent) => void);
|
|
133
147
|
}
|
|
@@ -148,6 +162,14 @@ function installFakeWorker(stats: FakeWorkerStats, behavior: FakeWorkerBehavior)
|
|
|
148
162
|
const event = new Event("close");
|
|
149
163
|
for (const listener of this.#closeListeners) listener(event);
|
|
150
164
|
}
|
|
165
|
+
|
|
166
|
+
#emitError(): void {
|
|
167
|
+
const event = new ErrorEvent("error", {
|
|
168
|
+
message: "fake worker failed to start",
|
|
169
|
+
error: new Error("fake worker failed to start"),
|
|
170
|
+
});
|
|
171
|
+
for (const listener of this.#errorListeners) listener(event);
|
|
172
|
+
}
|
|
151
173
|
}
|
|
152
174
|
|
|
153
175
|
Object.defineProperty(globalThis, "Worker", {
|
|
@@ -238,4 +260,22 @@ describe("JavaScript eval worker lifecycle", () => {
|
|
|
238
260
|
expect(stats.closeRequests).toBe(0);
|
|
239
261
|
expect(stats.terminateCalls).toBe(1);
|
|
240
262
|
});
|
|
263
|
+
|
|
264
|
+
it("falls back to the inline worker when the spawned worker errors during startup", async () => {
|
|
265
|
+
using tempDir = TempDir.createSync("@omp-js-worker-error-");
|
|
266
|
+
const stats: FakeWorkerStats = { closeRequests: 0, terminateCalls: 0 };
|
|
267
|
+
installFakeWorker(stats, { exitOnClose: true, settleRuns: true, errorOnStart: true });
|
|
268
|
+
|
|
269
|
+
const session = makeSession(tempDir.path());
|
|
270
|
+
const sessionId = `js-worker-error:${crypto.randomUUID()}`;
|
|
271
|
+
|
|
272
|
+
// The spawned worker emits an `error` event instead of `ready`. Without fail-fast
|
|
273
|
+
// error handling the handshake would stall until WORKER_INIT_TIMEOUT_MS (15s); with
|
|
274
|
+
// it, the handshake rejects at once and the inline worker runs the cell.
|
|
275
|
+
const result = await executeJs("return String(6 * 7);", { cwd: tempDir.path(), sessionId, session });
|
|
276
|
+
expect(result.exitCode).toBe(0);
|
|
277
|
+
expect(result.output.trim()).toBe("42");
|
|
278
|
+
// The errored primary worker is torn down before the inline retry takes over.
|
|
279
|
+
expect(stats.terminateCalls).toBe(1);
|
|
280
|
+
});
|
|
241
281
|
});
|
|
@@ -27,6 +27,7 @@ interface WorkerHandle {
|
|
|
27
27
|
mode: "worker" | "inline";
|
|
28
28
|
send(msg: WorkerInbound): void;
|
|
29
29
|
onMessage(handler: (msg: WorkerOutbound) => void): () => void;
|
|
30
|
+
onError(handler: (error: Error) => void): () => void;
|
|
30
31
|
close(): Promise<boolean>;
|
|
31
32
|
terminate(): Promise<void>;
|
|
32
33
|
}
|
|
@@ -186,41 +187,45 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
|
|
|
186
187
|
if (starting) return await starting;
|
|
187
188
|
|
|
188
189
|
const startup = (async (): Promise<JsSession> => {
|
|
189
|
-
|
|
190
|
+
// The message listener must be attached synchronously after `new Worker`:
|
|
191
|
+
// Bun drops messages posted before a listener exists, and WorkerCore emits
|
|
192
|
+
// `ready` from its constructor on load. `spawnJsWorker` + `initWorker` run with
|
|
193
|
+
// no intervening await, so `ready` can never race the attach.
|
|
194
|
+
const worker = spawnJsWorker();
|
|
190
195
|
const session: JsSession = {
|
|
191
196
|
sessionKey,
|
|
192
197
|
worker,
|
|
193
198
|
state: "alive",
|
|
194
199
|
pending: new Map(),
|
|
195
200
|
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
if (!resolved && msg.type === "ready") {
|
|
200
|
-
resolved = true;
|
|
201
|
-
resolveReady();
|
|
202
|
-
return;
|
|
203
|
-
}
|
|
204
|
-
if (!resolved && msg.type === "init-failed") {
|
|
205
|
-
resolved = true;
|
|
206
|
-
rejectReady(errorFromPayload(msg.error));
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
209
|
-
handleSessionMessage(session, msg);
|
|
210
|
-
});
|
|
201
|
+
// Init headroom is the fixed infrastructure floor; the caller's per-cell timeout
|
|
202
|
+
// dominates when larger so users can grant more by raising `timeout` on a cell.
|
|
203
|
+
const readyTimeoutMs = Math.max(WORKER_INIT_TIMEOUT_MS, timeoutMs ?? 0);
|
|
211
204
|
try {
|
|
212
|
-
|
|
213
|
-
// dominates when larger so users can grant more by raising `timeout` on a cell.
|
|
214
|
-
const readyTimeoutMs = Math.max(WORKER_INIT_TIMEOUT_MS, timeoutMs ?? 0);
|
|
215
|
-
await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
|
|
216
|
-
worker.send({ type: "init", snapshot });
|
|
217
|
-
sessions.set(sessionKey, session);
|
|
218
|
-
return session;
|
|
205
|
+
await initWorker(session, snapshot, readyTimeoutMs);
|
|
219
206
|
} catch (error) {
|
|
220
|
-
|
|
207
|
+
// Worker-thread crash/load failures surface asynchronously via the worker
|
|
208
|
+
// `error` event — after `spawnJsWorker`'s synchronous try/catch already
|
|
209
|
+
// returned — so the only signal is the rejected handshake. Retry on the
|
|
210
|
+
// inline worker so a broken module graph fails fast instead of stalling
|
|
211
|
+
// every cell on the init timeout and then dying with exitCode 1.
|
|
221
212
|
await worker.terminate().catch(() => undefined);
|
|
222
|
-
throw error;
|
|
213
|
+
if (worker.mode === "inline") throw error;
|
|
214
|
+
logger.warn("JS eval worker init failed; retrying with inline worker (no sync-loop guard)", {
|
|
215
|
+
error: error instanceof Error ? error.message : String(error),
|
|
216
|
+
});
|
|
217
|
+
const inline = spawnInlineWorker();
|
|
218
|
+
session.worker = inline;
|
|
219
|
+
session.state = "alive";
|
|
220
|
+
try {
|
|
221
|
+
await initWorker(session, snapshot, readyTimeoutMs);
|
|
222
|
+
} catch (inlineError) {
|
|
223
|
+
await inline.terminate().catch(() => undefined);
|
|
224
|
+
throw inlineError;
|
|
225
|
+
}
|
|
223
226
|
}
|
|
227
|
+
sessions.set(sessionKey, session);
|
|
228
|
+
return session;
|
|
224
229
|
})();
|
|
225
230
|
startingSessions.set(sessionKey, startup);
|
|
226
231
|
try {
|
|
@@ -230,6 +235,49 @@ async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, tim
|
|
|
230
235
|
}
|
|
231
236
|
}
|
|
232
237
|
|
|
238
|
+
async function initWorker(session: JsSession, snapshot: SessionSnapshot, timeoutMs: number): Promise<void> {
|
|
239
|
+
const worker = session.worker;
|
|
240
|
+
const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
|
|
241
|
+
let resolved = false;
|
|
242
|
+
const unsubscribeMessage = worker.onMessage(msg => {
|
|
243
|
+
if (!resolved && msg.type === "ready") {
|
|
244
|
+
resolved = true;
|
|
245
|
+
resolveReady();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!resolved && msg.type === "init-failed") {
|
|
249
|
+
resolved = true;
|
|
250
|
+
rejectReady(errorFromPayload(msg.error));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
handleSessionMessage(session, msg);
|
|
254
|
+
});
|
|
255
|
+
const unsubscribeError = worker.onError(error => {
|
|
256
|
+
if (!resolved) {
|
|
257
|
+
resolved = true;
|
|
258
|
+
rejectReady(error);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// Worker died after a successful handshake: tear the session down so the
|
|
262
|
+
// in-flight run (and the next acquire) fail fast instead of hanging on a
|
|
263
|
+
// worker that will never reply.
|
|
264
|
+
void killSessionFor(session, error, { force: true });
|
|
265
|
+
});
|
|
266
|
+
try {
|
|
267
|
+
// Attach listeners and send init before awaiting ready. The worker now
|
|
268
|
+
// emits ready only in response to init, so this ordering is race-free.
|
|
269
|
+
worker.send({ type: "init", snapshot });
|
|
270
|
+
await raceWithTimeout(readyPromise, timeoutMs, "Timed out initializing JS eval worker");
|
|
271
|
+
} catch (error) {
|
|
272
|
+
// Handshake failed (timeout, init-failed, or worker error): drop both listeners
|
|
273
|
+
// so the abandoned worker can't keep routing messages into a session the caller
|
|
274
|
+
// is about to discard or retry on the inline fallback.
|
|
275
|
+
unsubscribeMessage();
|
|
276
|
+
unsubscribeError();
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
233
281
|
function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
|
|
234
282
|
switch (msg.type) {
|
|
235
283
|
case "text": {
|
|
@@ -379,7 +427,7 @@ async function raceWithTimeout<T>(promise: Promise<T>, timeoutMs: number, reason
|
|
|
379
427
|
}
|
|
380
428
|
}
|
|
381
429
|
|
|
382
|
-
|
|
430
|
+
function spawnJsWorker(): WorkerHandle {
|
|
383
431
|
try {
|
|
384
432
|
const hostEntry = workerHostEntry();
|
|
385
433
|
const worker = hostEntry
|
|
@@ -405,6 +453,17 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
|
|
|
405
453
|
worker.addEventListener("message", wrap);
|
|
406
454
|
return () => worker.removeEventListener("message", wrap);
|
|
407
455
|
},
|
|
456
|
+
onError(handler) {
|
|
457
|
+
const onError = (event: ErrorEvent): void => handler(errorFromWorkerEvent(event));
|
|
458
|
+
const onMessageError = (event: MessageEvent): void =>
|
|
459
|
+
handler(new ToolError(`JS eval worker message error: ${String(event.data)}`));
|
|
460
|
+
worker.addEventListener("error", onError);
|
|
461
|
+
worker.addEventListener("messageerror", onMessageError);
|
|
462
|
+
return () => {
|
|
463
|
+
worker.removeEventListener("error", onError);
|
|
464
|
+
worker.removeEventListener("messageerror", onMessageError);
|
|
465
|
+
};
|
|
466
|
+
},
|
|
408
467
|
async close() {
|
|
409
468
|
const { promise: closed, resolve } = Promise.withResolvers<boolean>();
|
|
410
469
|
let settled = false;
|
|
@@ -443,6 +502,12 @@ function wrapBunWorker(worker: Worker): WorkerHandle {
|
|
|
443
502
|
};
|
|
444
503
|
}
|
|
445
504
|
|
|
505
|
+
function errorFromWorkerEvent(event: ErrorEvent): Error {
|
|
506
|
+
if (event.error instanceof Error) return event.error;
|
|
507
|
+
if (event.message) return new Error(event.message);
|
|
508
|
+
return new Error("Unknown JS eval worker error");
|
|
509
|
+
}
|
|
510
|
+
|
|
446
511
|
/**
|
|
447
512
|
* Inline fallback for environments where Bun cannot spawn the worker entry
|
|
448
513
|
* (e.g. some test runners). Preserves behavior but cannot interrupt synchronous
|
|
@@ -473,6 +538,7 @@ function spawnInlineWorker(): WorkerHandle {
|
|
|
473
538
|
hostListeners.add(handler);
|
|
474
539
|
return () => hostListeners.delete(handler);
|
|
475
540
|
},
|
|
541
|
+
onError: () => () => {},
|
|
476
542
|
async close() {
|
|
477
543
|
const { promise: closed, resolve } = Promise.withResolvers<boolean>();
|
|
478
544
|
let settled = false;
|
|
@@ -43,13 +43,13 @@ export class WorkerCore {
|
|
|
43
43
|
constructor(transport: Transport) {
|
|
44
44
|
this.#transport = transport;
|
|
45
45
|
this.#unsubscribe = transport.onMessage(msg => this.#handle(msg));
|
|
46
|
-
transport.send({ type: "ready" });
|
|
47
46
|
}
|
|
48
47
|
|
|
49
48
|
#handle(msg: WorkerInbound): void {
|
|
50
49
|
switch (msg.type) {
|
|
51
50
|
case "init":
|
|
52
51
|
this.#ensureRuntime(msg.snapshot);
|
|
52
|
+
this.#transport.send({ type: "ready" });
|
|
53
53
|
return;
|
|
54
54
|
case "run":
|
|
55
55
|
void this.#runOne(msg.runId, msg.code, msg.filename, msg.snapshot);
|