@gajae-code/coding-agent 0.5.2 → 0.5.3

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.
Files changed (78) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/types/async/job-manager.d.ts +6 -0
  3. package/dist/types/dap/client.d.ts +2 -1
  4. package/dist/types/edit/read-file.d.ts +6 -0
  5. package/dist/types/eval/js/context-manager.d.ts +3 -0
  6. package/dist/types/eval/js/executor.d.ts +1 -0
  7. package/dist/types/exec/bash-executor.d.ts +2 -0
  8. package/dist/types/gjc-runtime/tmux-sessions.d.ts +7 -1
  9. package/dist/types/lsp/types.d.ts +2 -0
  10. package/dist/types/modes/bridge/bridge-mode.d.ts +1 -0
  11. package/dist/types/modes/components/model-selector.d.ts +2 -0
  12. package/dist/types/modes/components/oauth-selector.d.ts +1 -0
  13. package/dist/types/modes/components/runtime-mcp-add-wizard.d.ts +1 -0
  14. package/dist/types/modes/components/tool-execution.d.ts +1 -0
  15. package/dist/types/runtime/process-lifecycle.d.ts +108 -0
  16. package/dist/types/runtime-mcp/transports/stdio.d.ts +1 -0
  17. package/dist/types/runtime-mcp/types.d.ts +2 -0
  18. package/dist/types/session/agent-session.d.ts +17 -1
  19. package/dist/types/session/artifacts.d.ts +4 -1
  20. package/dist/types/session/streaming-output.d.ts +5 -0
  21. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +76 -0
  22. package/dist/types/tools/bash.d.ts +1 -0
  23. package/dist/types/tools/browser/tab-supervisor.d.ts +9 -0
  24. package/dist/types/tools/sqlite-reader.d.ts +2 -1
  25. package/package.json +7 -7
  26. package/src/async/job-manager.ts +153 -39
  27. package/src/config/file-lock.ts +9 -1
  28. package/src/dap/client.ts +105 -64
  29. package/src/dap/session.ts +44 -7
  30. package/src/edit/read-file.ts +19 -1
  31. package/src/eval/js/context-manager.ts +228 -65
  32. package/src/eval/js/executor.ts +2 -0
  33. package/src/eval/js/index.ts +1 -0
  34. package/src/eval/js/worker-core.ts +10 -6
  35. package/src/eval/py/executor.ts +68 -19
  36. package/src/eval/py/kernel.ts +46 -22
  37. package/src/eval/py/runner.py +68 -14
  38. package/src/exec/bash-executor.ts +49 -13
  39. package/src/gjc-runtime/tmux-gc.ts +86 -37
  40. package/src/gjc-runtime/tmux-sessions.ts +44 -6
  41. package/src/internal-urls/artifact-protocol.ts +10 -1
  42. package/src/internal-urls/docs-index.generated.ts +2 -2
  43. package/src/lsp/client.ts +64 -26
  44. package/src/lsp/index.ts +2 -1
  45. package/src/lsp/lspmux.ts +33 -9
  46. package/src/lsp/types.ts +2 -0
  47. package/src/modes/bridge/bridge-mode.ts +21 -0
  48. package/src/modes/components/assistant-message.ts +10 -2
  49. package/src/modes/components/bash-execution.ts +5 -1
  50. package/src/modes/components/eval-execution.ts +5 -1
  51. package/src/modes/components/model-selector.ts +34 -2
  52. package/src/modes/components/oauth-selector.ts +5 -0
  53. package/src/modes/components/runtime-mcp-add-wizard.ts +58 -7
  54. package/src/modes/components/skill-message.ts +24 -16
  55. package/src/modes/components/tool-execution.ts +6 -0
  56. package/src/modes/controllers/extension-ui-controller.ts +33 -6
  57. package/src/modes/controllers/input-controller.ts +5 -0
  58. package/src/modes/controllers/selector-controller.ts +6 -1
  59. package/src/modes/utils/ui-helpers.ts +5 -2
  60. package/src/runtime/process-lifecycle.ts +400 -0
  61. package/src/runtime-mcp/manager.ts +164 -50
  62. package/src/runtime-mcp/transports/http.ts +12 -11
  63. package/src/runtime-mcp/transports/stdio.ts +64 -38
  64. package/src/runtime-mcp/types.ts +3 -0
  65. package/src/sdk.ts +27 -0
  66. package/src/session/agent-session.ts +168 -22
  67. package/src/session/artifacts.ts +17 -2
  68. package/src/session/blob-store.ts +36 -2
  69. package/src/session/session-manager.ts +29 -13
  70. package/src/session/streaming-output.ts +54 -3
  71. package/src/slash-commands/builtin-registry.ts +30 -3
  72. package/src/slash-commands/helpers/fast-status-report.ts +111 -0
  73. package/src/tools/archive-reader.ts +10 -1
  74. package/src/tools/bash.ts +11 -4
  75. package/src/tools/browser/tab-supervisor.ts +22 -0
  76. package/src/tools/browser.ts +38 -4
  77. package/src/tools/read.ts +11 -12
  78. package/src/tools/sqlite-reader.ts +19 -5
@@ -1,7 +1,8 @@
1
1
  import * as path from "node:path";
2
2
  import * as timers from "node:timers/promises";
3
- import { logger, ptree, untilAborted } from "@gajae-code/utils";
3
+ import { logger, untilAborted } from "@gajae-code/utils";
4
4
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
5
+ import { type OwnedProcess, spawnOwnedProcess } from "../runtime/process-lifecycle";
5
6
  import { DapClient } from "./client";
6
7
  import type {
7
8
  DapAttachArguments,
@@ -63,6 +64,24 @@ import type {
63
64
  DapWriteMemoryResponse,
64
65
  } from "./types";
65
66
 
67
+ function drainStream(stream: ReadableStream<Uint8Array> | null | undefined): void {
68
+ if (!stream) return;
69
+ void (async () => {
70
+ try {
71
+ const reader = stream.getReader();
72
+ try {
73
+ while (!(await reader.read()).done) {
74
+ // drain only
75
+ }
76
+ } finally {
77
+ reader.releaseLock();
78
+ }
79
+ } catch {
80
+ // Process stream closed or was already consumed.
81
+ }
82
+ })();
83
+ }
84
+
66
85
  interface DapSession {
67
86
  id: string;
68
87
  adapter: DapResolvedAdapter;
@@ -87,6 +106,7 @@ interface DapSession {
87
106
  initializedSeen: boolean;
88
107
  needsConfigurationDone: boolean;
89
108
  configurationDoneSent: boolean;
109
+ runInTerminalProcesses: Set<OwnedProcess>;
90
110
  }
91
111
 
92
112
  export interface DapOutputSnapshot {
@@ -948,6 +968,7 @@ export class DapSessionManager {
948
968
  initializedSeen: false,
949
969
  needsConfigurationDone: false,
950
970
  configurationDoneSent: false,
971
+ runInTerminalProcesses: new Set(),
951
972
  };
952
973
  client.onReverseRequest("runInTerminal", async rawArgs => {
953
974
  const args = (rawArgs ?? {}) as DapRunInTerminalArguments;
@@ -957,17 +978,21 @@ export class DapSessionManager {
957
978
  const env = Object.fromEntries(
958
979
  Object.entries(args.env ?? {}).filter((entry): entry is [string, string] => entry[1] !== null),
959
980
  );
960
- const proc = ptree.spawn(args.args, {
981
+ const owner = spawnOwnedProcess(args.args, {
961
982
  cwd: args.cwd ?? session.cwd,
962
- stdin: "pipe",
983
+ stdin: "ignore",
963
984
  env: {
964
985
  ...Bun.env,
965
986
  ...NON_INTERACTIVE_ENV,
966
987
  ...env,
967
988
  },
968
- detached: true,
989
+ name: `dap:${session.id}:runInTerminal`,
969
990
  });
970
- return { processId: proc.pid } satisfies DapRunInTerminalResponse;
991
+ drainStream(owner.child.stdout);
992
+ drainStream(owner.child.stderr);
993
+ session.runInTerminalProcesses.add(owner);
994
+ owner.exited.finally(() => session.runInTerminalProcesses.delete(owner));
995
+ return { processId: owner.pid } satisfies DapRunInTerminalResponse;
971
996
  });
972
997
  client.onReverseRequest("startDebugging", async rawArgs => {
973
998
  const startArgs = (rawArgs ?? {}) as Partial<DapStartDebuggingArguments>;
@@ -1294,12 +1319,24 @@ export class DapSessionManager {
1294
1319
  return session;
1295
1320
  }
1296
1321
 
1297
- #disposeSession(session: DapSession) {
1322
+ async #disposeSession(session: DapSession) {
1298
1323
  if (this.#activeSessionId === session.id) {
1299
1324
  this.#activeSessionId = null;
1300
1325
  }
1301
1326
  this.#sessions.delete(session.id);
1302
- void session.client.dispose().catch(() => {});
1327
+ await this.#disposeRunInTerminalProcesses(session);
1328
+ await session.client.dispose().catch(() => {});
1329
+ }
1330
+
1331
+ async #disposeRunInTerminalProcesses(session: DapSession): Promise<void> {
1332
+ const owners = [...session.runInTerminalProcesses];
1333
+ session.runInTerminalProcesses.clear();
1334
+ await Promise.allSettled(
1335
+ owners.map(async owner => {
1336
+ await owner.dispose();
1337
+ await owner.awaitExit({ timeoutMs: 1_000 });
1338
+ }),
1339
+ );
1303
1340
  }
1304
1341
  }
1305
1342
 
@@ -7,10 +7,28 @@
7
7
  import { isEnoent } from "@gajae-code/utils";
8
8
  import { isNotebookPath, readEditableNotebookText, serializeEditedNotebookText } from "./notebook";
9
9
 
10
+ /**
11
+ * Max byte size of a file the edit modes will load whole. Editing loads + normalizes +
12
+ * fuzzy-matches + diffs the entire file on the main thread, so a multi-MB/generated file
13
+ * would block the event loop (F19). Above this, fail fast with an actionable error.
14
+ */
15
+ export const MAX_EDIT_FILE_BYTES = 8 * 1024 * 1024;
16
+
10
17
  export async function readEditFileText(absolutePath: string, path: string): Promise<string> {
11
18
  try {
19
+ const file = Bun.file(absolutePath);
20
+ const size = file.size; // 0 for a missing file; the read below then throws ENOENT.
21
+ if (size > MAX_EDIT_FILE_BYTES) {
22
+ throw new Error(
23
+ `File too large to edit safely: ${path} is ${size} bytes (limit ${MAX_EDIT_FILE_BYTES}). ` +
24
+ `Editing loads and diffs the whole file on the main thread; make a more targeted change, ` +
25
+ `split the file, or use a specialized tool.`,
26
+ );
27
+ }
28
+ // Guard BEFORE the notebook fast-path: a >8 MiB .ipynb would otherwise load + JSON-parse
29
+ // + convert the whole file via readEditableNotebookText, bypassing the F19 freeze guard.
12
30
  if (isNotebookPath(absolutePath)) return await readEditableNotebookText(absolutePath, path);
13
- return await Bun.file(absolutePath).text();
31
+ return await file.text();
14
32
  } catch (error) {
15
33
  if (isEnoent(error)) {
16
34
  throw new Error(`File not found: ${path}`);
@@ -1,4 +1,5 @@
1
1
  import { isCompiledBinary, logger, Snowflake } from "@gajae-code/utils";
2
+ import { registerResourceOwner } from "../../runtime/process-lifecycle";
2
3
  import type { ToolSession } from "../../tools";
3
4
  import { ToolAbortError, ToolError } from "../../tools/tool-errors";
4
5
  import { callSessionTool, type JsStatusEvent } from "./tool-bridge";
@@ -43,22 +44,66 @@ interface PendingRun {
43
44
  settled: boolean;
44
45
  }
45
46
 
47
+ interface ReadyDeferred {
48
+ promise: Promise<JsSession>;
49
+ resolve(session: JsSession): void;
50
+ reject(error: Error): void;
51
+ }
52
+
53
+ interface QueueDeferred {
54
+ promise: Promise<void>;
55
+ resolve(): void;
56
+ reject(error: Error): void;
57
+ }
58
+
46
59
  interface JsSession {
47
60
  sessionKey: string;
48
- worker: WorkerHandle;
49
- state: "alive" | "dead";
61
+ worker?: WorkerHandle;
62
+ state: "starting" | "alive" | "dead";
63
+ ownerId?: string;
50
64
  pending: Map<string, PendingRun>;
51
65
  queue: Promise<void>;
66
+ queuedWaiters: Set<(error: Error) => void>;
67
+ queueTail: QueueDeferred;
68
+ controllers: Set<AbortController>;
69
+ runSignal?: AbortSignal;
70
+ ready: ReadyDeferred;
71
+ unsubscribe?: () => void;
72
+ unregistered?: () => void;
52
73
  }
53
74
 
54
75
  const sessions = new Map<string, JsSession>();
76
+ const sessionWaiters = new Map<string, Set<(error: Error) => void>>();
77
+ let vmResourceCleanupRegistered = false;
78
+
79
+ function ensureVmResourceCleanup(): void {
80
+ if (vmResourceCleanupRegistered) return;
81
+ vmResourceCleanupRegistered = true;
82
+ registerResourceOwner("js-vm-contexts", async () => {
83
+ try {
84
+ await disposeAllVmContexts();
85
+ } finally {
86
+ vmResourceCleanupRegistered = false;
87
+ }
88
+ });
89
+ }
55
90
  const READY_TIMEOUT_MS_DEFAULT = 5_000;
56
91
 
92
+ function getSessionWaiters(sessionKey: string): Set<(error: Error) => void> {
93
+ let waiters = sessionWaiters.get(sessionKey);
94
+ if (!waiters) {
95
+ waiters = new Set();
96
+ sessionWaiters.set(sessionKey, waiters);
97
+ }
98
+ return waiters;
99
+ }
100
+
57
101
  export async function executeInVmContext(options: {
58
102
  sessionKey: string;
59
103
  sessionId: string;
60
104
  cwd: string;
61
105
  session: ToolSession;
106
+ ownerId?: string;
62
107
  reset?: boolean;
63
108
  code: string;
64
109
  filename: string;
@@ -68,40 +113,84 @@ export async function executeInVmContext(options: {
68
113
  if (options.reset) {
69
114
  await resetVmContext(options.sessionKey);
70
115
  }
71
- const session = await acquireSession(
72
- options.sessionKey,
73
- { cwd: options.cwd, sessionId: options.sessionId },
74
- options.timeoutMs,
75
- );
76
- return await runQueued(session, () => runOnce(session, options));
116
+ const waiters = getSessionWaiters(options.sessionKey);
117
+ const { promise: contextResetPromise, reject: rejectContextReset } = Promise.withResolvers<never>();
118
+ contextResetPromise.catch(() => undefined);
119
+ waiters.add(rejectContextReset);
120
+ const runPromise = (async (): Promise<{ value: unknown }> => {
121
+ const session = await acquireSession(
122
+ options.sessionKey,
123
+ { cwd: options.cwd, sessionId: options.sessionId },
124
+ options.ownerId,
125
+ options.timeoutMs,
126
+ );
127
+ return await runQueued(session, () => runOnce(session, options));
128
+ })();
129
+ try {
130
+ return await Promise.race([runPromise, contextResetPromise]);
131
+ } finally {
132
+ waiters.delete(rejectContextReset);
133
+ }
77
134
  }
78
135
 
79
136
  export async function resetVmContext(sessionKey: string): Promise<void> {
80
137
  const session = sessions.get(sessionKey);
81
138
  if (!session) return;
82
139
  sessions.delete(sessionKey);
140
+ const waiters = sessionWaiters.get(sessionKey);
141
+ if (waiters) for (const reject of [...waiters]) reject(new ToolError("JS context reset"));
83
142
  await killSession(session, new ToolError("JS context reset"));
84
143
  }
85
144
 
145
+ export async function disposeVmContextsByOwner(ownerId: string): Promise<void> {
146
+ const owned = [...sessions.entries()].filter(
147
+ ([sessionKey, session]) =>
148
+ session.ownerId === ownerId || sessionKey === ownerId || sessionKey === `js:${ownerId}`,
149
+ );
150
+ for (const [sessionKey, session] of owned) {
151
+ if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
152
+ }
153
+ await Promise.all(owned.map(([, session]) => killSession(session, new ToolError("JS context disposed"))));
154
+ }
155
+
86
156
  export async function disposeAllVmContexts(): Promise<void> {
87
157
  const all = [...sessions.values()];
88
158
  sessions.clear();
89
159
  await Promise.all(all.map(session => killSession(session, new ToolError("JS context disposed"))));
90
160
  }
91
161
 
162
+ export function liveVmContextCount(): number {
163
+ return [...sessions.values()].filter(session => session.state !== "dead").length;
164
+ }
165
+
92
166
  async function runQueued<T>(session: JsSession, work: () => Promise<T>): Promise<T> {
167
+ if (session.state !== "alive") throw new ToolError("JS worker is not alive");
93
168
  const previous = session.queue;
94
- const { promise, resolve } = Promise.withResolvers<void>();
95
- session.queue = promise;
96
- try {
97
- await previous;
98
- } catch {
99
- // Previous run's failure must not poison this one.
100
- }
169
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
170
+ const queueController = new AbortController();
171
+ const queueItem: QueueDeferred = { promise, resolve, reject };
172
+ const rejectWaiter = (error: Error): void => queueItem.reject(error);
173
+ session.queuedWaiters.add(rejectWaiter);
174
+ session.controllers.add(queueController);
175
+ session.queueTail = queueItem;
176
+ session.queue = (async () => {
177
+ await previous.catch(() => undefined);
178
+ await queueItem.promise;
179
+ })().catch(() => undefined);
101
180
  try {
102
- return await work();
181
+ await Promise.race([previous.catch(() => undefined), queueItem.promise, abortPromise(queueController.signal)]);
182
+ if (session.runSignal?.aborted) throw reasonToError(session.runSignal.reason, "JS worker is not alive");
183
+ if (session.state !== "alive") throw new ToolError("JS worker is not alive");
184
+ session.queuedWaiters.delete(rejectWaiter);
185
+ return await Promise.race([
186
+ work(),
187
+ queueItem.promise.then(() => new Promise<never>(() => {})),
188
+ abortPromise(queueController.signal),
189
+ ]);
103
190
  } finally {
104
- resolve();
191
+ session.queuedWaiters.delete(rejectWaiter);
192
+ session.controllers.delete(queueController);
193
+ queueItem.resolve();
105
194
  }
106
195
  }
107
196
 
@@ -118,6 +207,7 @@ async function runOnce(
118
207
  ): Promise<{ value: unknown }> {
119
208
  const runId = `r-${Snowflake.next()}`;
120
209
  const { promise, resolve, reject } = Promise.withResolvers<{ value: unknown }>();
210
+ const sessionSignal = session.runSignal;
121
211
  const pending: PendingRun = {
122
212
  runId,
123
213
  runState: options.runState,
@@ -145,13 +235,19 @@ async function runOnce(
145
235
  }
146
236
 
147
237
  try {
148
- session.worker.send({
149
- type: "run",
150
- runId,
151
- code: options.code,
152
- filename: options.filename,
153
- snapshot: { cwd: options.cwd, sessionId: options.sessionId },
154
- });
238
+ if (sessionSignal?.aborted) throw reasonToError(sessionSignal.reason, "JS worker is not alive");
239
+ if (
240
+ !safeSend(session, {
241
+ type: "run",
242
+ runId,
243
+ code: options.code,
244
+ filename: options.filename,
245
+ snapshot: { cwd: options.cwd, sessionId: options.sessionId },
246
+ })
247
+ ) {
248
+ settleRunWithError(session, pending, new ToolError("JS worker send failed"));
249
+ return await promise;
250
+ }
155
251
  return await promise;
156
252
  } finally {
157
253
  options.runState.signal?.removeEventListener("abort", onAbort);
@@ -159,46 +255,76 @@ async function runOnce(
159
255
  }
160
256
  }
161
257
 
162
- async function acquireSession(sessionKey: string, snapshot: SessionSnapshot, timeoutMs?: number): Promise<JsSession> {
258
+ async function acquireSession(
259
+ sessionKey: string,
260
+ snapshot: SessionSnapshot,
261
+ ownerId: string | undefined,
262
+ timeoutMs?: number,
263
+ ): Promise<JsSession> {
264
+ ensureVmResourceCleanup();
163
265
  const existing = sessions.get(sessionKey);
164
- if (existing && existing.state === "alive") return existing;
266
+ if (existing && existing.state !== "dead") return await existing.ready.promise;
165
267
 
166
- const worker = await spawnJsWorker();
268
+ const { promise: ready, resolve: resolveSession, reject: rejectSession } = Promise.withResolvers<JsSession>();
269
+ ready.catch(() => undefined);
167
270
  const session: JsSession = {
168
271
  sessionKey,
169
- worker,
170
- state: "alive",
272
+ state: "starting",
273
+ ownerId,
171
274
  pending: new Map(),
172
275
  queue: Promise.resolve(),
276
+ queuedWaiters: new Set(),
277
+ queueTail: settledQueueDeferred(),
278
+ controllers: new Set(),
279
+ ready: { promise: ready, resolve: resolveSession, reject: rejectSession },
173
280
  };
174
- const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
175
- let resolved = false;
176
- const unsubscribe = worker.onMessage(msg => {
177
- if (!resolved && msg.type === "ready") {
178
- resolved = true;
179
- resolveReady();
180
- return;
181
- }
182
- if (!resolved && msg.type === "init-failed") {
183
- resolved = true;
184
- rejectReady(errorFromPayload(msg.error));
185
- return;
186
- }
187
- handleSessionMessage(session, msg);
188
- });
281
+ sessions.set(sessionKey, session);
282
+
283
+ let worker: WorkerHandle | undefined;
189
284
  try {
190
- // Cold-start can exceed 5s on slow hosts. Let the caller's per-cell timeout dominate so
191
- // users can grant more headroom when they raise `timeout` on a cell.
285
+ worker = await spawnJsWorker();
286
+ const current = sessions.get(sessionKey);
287
+ if (current !== session) {
288
+ await worker.terminate().catch(() => undefined);
289
+ return (
290
+ (await current?.ready.promise) ?? Promise.reject(new ToolError("JS context replaced during initialization"))
291
+ );
292
+ }
293
+ session.worker = worker;
294
+ const { promise: readyPromise, resolve: resolveReady, reject: rejectReady } = Promise.withResolvers<void>();
295
+ let resolved = false;
296
+ session.unsubscribe = worker.onMessage(msg => {
297
+ if (!resolved && msg.type === "ready") {
298
+ resolved = true;
299
+ resolveReady();
300
+ return;
301
+ }
302
+ if (!resolved && msg.type === "init-failed") {
303
+ resolved = true;
304
+ rejectReady(errorFromPayload(msg.error));
305
+ return;
306
+ }
307
+ handleSessionMessage(session, msg);
308
+ });
192
309
  const readyTimeoutMs = Math.max(READY_TIMEOUT_MS_DEFAULT, timeoutMs ?? 0);
193
310
  await raceWithTimeout(readyPromise, readyTimeoutMs, "Timed out initializing JS eval worker");
311
+ if (sessions.get(sessionKey) !== session) {
312
+ await killSession(session, new ToolError("JS context replaced during initialization"));
313
+ return (
314
+ (await sessions.get(sessionKey)?.ready.promise) ??
315
+ Promise.reject(new ToolError("JS context replaced during initialization"))
316
+ );
317
+ }
318
+ worker.send({ type: "init", snapshot });
319
+ session.state = "alive";
320
+ session.ready.resolve(session);
321
+ return session;
194
322
  } catch (error) {
195
- unsubscribe();
196
- await worker.terminate().catch(() => undefined);
323
+ if (sessions.get(sessionKey) === session) sessions.delete(sessionKey);
324
+ await killSession(session, error instanceof Error ? error : new Error(String(error)));
325
+ session.ready.reject(error instanceof Error ? error : new Error(String(error)));
197
326
  throw error;
198
327
  }
199
- worker.send({ type: "init", snapshot });
200
- sessions.set(sessionKey, session);
201
- return session;
202
328
  }
203
329
 
204
330
  function handleSessionMessage(session: JsSession, msg: WorkerOutbound): void {
@@ -276,22 +402,54 @@ async function killSessionFor(session: JsSession, error: Error): Promise<void> {
276
402
  async function killSession(session: JsSession, error: Error): Promise<void> {
277
403
  if (session.state === "dead") return;
278
404
  session.state = "dead";
279
- for (const pending of session.pending.values()) {
280
- if (pending.settled) continue;
281
- pending.settled = true;
282
- for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
283
- pending.reject(error);
284
- }
405
+ const unsubscribe = session.unsubscribe;
406
+ session.unsubscribe = undefined;
407
+ unsubscribe?.();
408
+ session.ready.reject(error);
409
+ session.queueTail.reject(error);
410
+ for (const controller of [...session.controllers]) controller.abort(error);
411
+ session.controllers.clear();
412
+ session.runSignal = AbortSignal.abort(error);
413
+ for (const reject of [...session.queuedWaiters]) reject(error);
414
+ session.queuedWaiters.clear();
415
+ for (const pending of [...session.pending.values()]) settleRunWithError(session, pending, error);
285
416
  session.pending.clear();
286
- await session.worker.terminate().catch(() => undefined);
417
+ void session.worker?.terminate().catch(() => undefined);
418
+ }
419
+
420
+ function settleRunWithError(session: JsSession, pending: PendingRun, error: Error): void {
421
+ if (pending.settled) return;
422
+ pending.settled = true;
423
+ for (const ctrl of pending.toolCalls.values()) ctrl.abort(error);
424
+ pending.toolCalls.clear();
425
+ session.pending.delete(pending.runId);
426
+ pending.reject(error);
287
427
  }
288
428
 
289
- function safeSend(session: JsSession, msg: WorkerInbound): void {
290
- if (session.state !== "alive") return;
429
+ function settledQueueDeferred(): QueueDeferred {
430
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
431
+ resolve();
432
+ return { promise, resolve, reject };
433
+ }
434
+
435
+ function abortPromise(signal: AbortSignal): Promise<never> {
436
+ if (signal.aborted) return Promise.reject(reasonToError(signal.reason, "JS worker is not alive"));
437
+ const { promise, reject } = Promise.withResolvers<never>();
438
+ const onAbort = (): void => reject(reasonToError(signal.reason, "JS worker is not alive"));
439
+ signal.addEventListener("abort", onAbort, { once: true });
440
+ promise.finally(() => signal.removeEventListener("abort", onAbort)).catch(() => undefined);
441
+ return promise;
442
+ }
443
+
444
+ function safeSend(session: JsSession, msg: WorkerInbound): boolean {
445
+ if (session.state !== "alive") return false;
291
446
  try {
292
- session.worker.send(msg);
447
+ session.worker?.send(msg);
448
+ return true;
293
449
  } catch (err) {
294
450
  logger.debug("js worker send failed", { error: err instanceof Error ? err.message : String(err) });
451
+ void killSessionFor(session, err instanceof Error ? err : new Error(String(err)));
452
+ return false;
295
453
  }
296
454
  }
297
455
 
@@ -352,10 +510,15 @@ async function spawnJsWorker(): Promise<WorkerHandle> {
352
510
  : new Worker(new URL("./worker-entry.ts", import.meta.url).href, { type: "module" });
353
511
  return wrapBunWorker(worker);
354
512
  } catch (err) {
355
- logger.warn("Bun Worker spawn failed; using inline JS eval worker (no sync-loop guard)", {
356
- error: err instanceof Error ? err.message : String(err),
357
- });
358
- return spawnInlineWorker();
513
+ if (process.env.GAJAE_CODE_JS_EVAL_INLINE_WORKER === "1") {
514
+ logger.warn("Bun Worker spawn failed; using test-only inline JS eval worker", {
515
+ error: err instanceof Error ? err.message : String(err),
516
+ });
517
+ return spawnInlineWorker();
518
+ }
519
+ throw new ToolError(
520
+ `JS eval worker is unavailable and inline fallback is disabled because it cannot interrupt synchronous user code: ${err instanceof Error ? err.message : String(err)}`,
521
+ );
359
522
  }
360
523
  }
361
524
 
@@ -10,6 +10,7 @@ export interface JsExecutorOptions {
10
10
  onChunk?: (chunk: string) => Promise<void> | void;
11
11
  signal?: AbortSignal;
12
12
  sessionId: string;
13
+ ownerId?: string;
13
14
  reset?: boolean;
14
15
  sessionFile?: string;
15
16
  artifactPath?: string;
@@ -68,6 +69,7 @@ export async function executeJs(code: string, options: JsExecutorOptions): Promi
68
69
  await executeInVmContext({
69
70
  sessionKey: options.sessionId,
70
71
  sessionId: options.sessionId,
72
+ ownerId: options.ownerId,
71
73
  cwd: options.cwd ?? options.session.cwd,
72
74
  session: options.session,
73
75
  reset: options.reset,
@@ -23,6 +23,7 @@ export default {
23
23
  deadlineMs: opts.deadlineMs,
24
24
  signal: opts.signal,
25
25
  sessionId: namespaceSessionId(opts.sessionId),
26
+ ownerId: opts.kernelOwnerId,
26
27
  sessionFile: opts.sessionFile,
27
28
  reset: opts.reset,
28
29
  artifactPath: opts.artifactPath,
@@ -12,6 +12,11 @@ interface ActiveRun {
12
12
  pendingTools: Map<string, PendingTool>;
13
13
  }
14
14
 
15
+ function rejectPendingTools(active: ActiveRun, error: Error): void {
16
+ for (const pending of active.pendingTools.values()) pending.reject(error);
17
+ active.pendingTools.clear();
18
+ }
19
+
15
20
  function errorPayload(error: unknown): RunErrorPayload {
16
21
  if (error instanceof Error) {
17
22
  return {
@@ -99,7 +104,8 @@ export class WorkerCore {
99
104
  async #runOne(runId: string, code: string, filename: string, snapshot: SessionSnapshot): Promise<void> {
100
105
  const runtime = this.#ensureRuntime(snapshot);
101
106
  runtime.setCwd(snapshot.cwd);
102
- this.#active = { runId, pendingTools: new Map() };
107
+ const active: ActiveRun = { runId, pendingTools: new Map() };
108
+ this.#active = active;
103
109
  try {
104
110
  const value = await runtime.run(code, filename);
105
111
  runtime.displayValue(value);
@@ -107,7 +113,8 @@ export class WorkerCore {
107
113
  } catch (error) {
108
114
  this.#transport.send({ type: "result", runId, ok: false, error: errorPayload(error) });
109
115
  } finally {
110
- this.#active = null;
116
+ rejectPendingTools(active, new ToolError("JS run ended"));
117
+ if (this.#active === active) this.#active = null;
111
118
  }
112
119
  }
113
120
 
@@ -132,10 +139,7 @@ export class WorkerCore {
132
139
  #close(): void {
133
140
  const active = this.#active;
134
141
  if (active) {
135
- for (const pending of active.pendingTools.values()) {
136
- pending.reject(new ToolError("JS worker closed"));
137
- }
138
- active.pendingTools.clear();
142
+ rejectPendingTools(active, new ToolError("JS worker closed"));
139
143
  }
140
144
  this.#active = null;
141
145
  this.#runtime = null;