better-dsh 0.3.0 → 0.3.1-a

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 (76) hide show
  1. package/README.md +49 -177
  2. package/cordis.patch.yml +5 -36
  3. package/docs/20_specs/kernel-provisioning/spec.md +2 -0
  4. package/docs/adr/0015-independent-bundles-agent-remote.md +40 -0
  5. package/docs/remote-tools.md +56 -0
  6. package/docs/specs/kernel-provisioning/spec.md +12 -40
  7. package/docs/specs/omp-python-repl-port/runtime-guide.md +35 -0
  8. package/docs/specs/omp-python-repl-port/spec.md +68 -0
  9. package/docs/specs/remote-tool-enhancement/spec.md +203 -0
  10. package/dsh-docs/baseline.md +13 -0
  11. package/dsh-docs/subsystems/invariants.md +3 -0
  12. package/dsh-docs/subsystems/invariants.zh.md +3 -0
  13. package/eval-description.md +7 -3
  14. package/lib/LaunchOptions-0Mr6meJc.js +4 -0
  15. package/lib/{LaunchOptions-kGXcZOe0.js → LaunchOptions-iE9M-STS.js} +1 -1
  16. package/lib/{bidi-BalpKaJc.js → bidi-CaQeBZHY.js} +1 -1
  17. package/lib/client/index.js +30 -6
  18. package/lib/failover/index.js +1 -1
  19. package/lib/fs-aware/sandbox-plugin.js +3 -3
  20. package/lib/{index-browser-CysrjMyM.js → index-browser-CJzxT9Vi.js} +4 -4
  21. package/lib/index.d.ts +16 -50
  22. package/lib/index.js +482 -1720
  23. package/lib/kernel-env-x_WkG6Fr.js +76 -0
  24. package/lib/kernel-env.d.ts +5 -69
  25. package/lib/kernel-env.js +2 -2
  26. package/lib/{lsp-server-registry-BXvUTBY7.js → lsp-server-registry-BcdkHROD.js} +1 -1
  27. package/lib/{lsp-server-registry-CH9WNFFt.js → lsp-server-registry-D4VLs1Sm.js} +1 -1
  28. package/lib/{main-BQeFrBM1.js → main-taSMz1KX.js} +1 -1
  29. package/lib/{main-CupOhMp5.js → main-uqGqYYnA.js} +7 -5
  30. package/lib/mobile/plugin.d.ts +1 -1
  31. package/lib/omp-python/DILL-LICENSE +35 -0
  32. package/lib/omp-python/OMP-LICENSE +23 -0
  33. package/lib/omp-python/better_snapshot.py +391 -0
  34. package/lib/omp-python/better_support.py +425 -0
  35. package/lib/omp-python/dill-provenance.json +55 -0
  36. package/lib/omp-python/provenance.json +13 -0
  37. package/lib/omp-python/runner.py +2499 -0
  38. package/lib/omp-python/vendor/dill/__diff.py +234 -0
  39. package/lib/omp-python/vendor/dill/__info__.py +291 -0
  40. package/lib/omp-python/vendor/dill/__init__.py +119 -0
  41. package/lib/omp-python/vendor/dill/_dill.py +2265 -0
  42. package/lib/omp-python/vendor/dill/_objects.py +545 -0
  43. package/lib/omp-python/vendor/dill/_shims.py +193 -0
  44. package/lib/omp-python/vendor/dill/detect.py +287 -0
  45. package/lib/omp-python/vendor/dill/logger.py +285 -0
  46. package/lib/omp-python/vendor/dill/objtypes.py +24 -0
  47. package/lib/omp-python/vendor/dill/pointers.py +122 -0
  48. package/lib/omp-python/vendor/dill/session.py +612 -0
  49. package/lib/omp-python/vendor/dill/settings.py +25 -0
  50. package/lib/omp-python/vendor/dill/source.py +1025 -0
  51. package/lib/omp-python/vendor/dill/temp.py +252 -0
  52. package/lib/{puppeteer-core-CSk2PtSm.js → puppeteer-core-C-4NyOpY.js} +8 -8
  53. package/lib/remote/plugin.d.ts +459 -5
  54. package/lib/remote/plugin.js +2389 -210
  55. package/lib/url-schemes/index.d.ts +11 -11
  56. package/lib/url-schemes/index.js +11 -11
  57. package/lib/web-password.d.ts +8 -8
  58. package/lib/web-password.js +5 -1
  59. package/lib/{web-trust-Dej_RI2B.d.ts → web-trust-C_Y6o8Z2.d.ts} +11 -11
  60. package/lib/web-trust.d.ts +1 -2
  61. package/lib/welcome-notice.d.ts +5 -7
  62. package/lib/welcome-notice.js +9 -48
  63. package/lib/{wrap-T4I0nfPM.js → wrap-0VA0uFx8.js} +1 -1
  64. package/package.json +7 -24
  65. package/scripts/kernel-check.mjs +5 -0
  66. package/lib/LaunchOptions-B874eBu2.js +0 -4
  67. package/lib/bundle-delivery.d.ts +0 -20
  68. package/lib/bundle-delivery.js +0 -209
  69. package/lib/hmr.d.ts +0 -32
  70. package/lib/hmr.js +0 -49
  71. package/lib/kernel-bridge.py +0 -172
  72. package/lib/kernel-env-D8UOWeji.js +0 -195
  73. package/lib/webserver.d.ts +0 -10
  74. package/lib/webserver.js +0 -19
  75. package/scripts/kernel-provision.mjs +0 -35
  76. package/scripts/register-bundle.mjs +0 -80
package/lib/index.js CHANGED
@@ -1,17 +1,16 @@
1
1
  import { t as Schema } from "./lib-C8_jXvwA.js";
2
2
  import { a as RESERVED_ERROR_MEMBERS, i as RESERVED_BINDING_GLOBALS, n as DUNDER_MEMBER, o as ReplRuntime, r as PORTABLE_RESERVED_WORDS, t as isFlatBindableName } from "./py-sdk-D7klhpWb.js";
3
- import { s as resolveKernelEnv } from "./kernel-env-D8UOWeji.js";
3
+ import { n as resolveKernelEnv } from "./kernel-env-x_WkG6Fr.js";
4
4
  import { t as captureAllTools } from "./native-capture-BEByDXYM.js";
5
5
  import { createRequire } from "node:module";
6
- import { randomBytes, randomUUID } from "node:crypto";
7
- import { spawn } from "node:child_process";
8
- import { join } from "node:path";
9
- import { fileURLToPath } from "node:url";
10
6
  import { Context } from "@deepseek-ai/cordis";
11
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
12
- import { tmpdir } from "node:os";
13
- import { setTimeout as setTimeout$1 } from "node:timers/promises";
7
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
8
+ import { existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { spawn } from "node:child_process";
11
+ import { createServer } from "node:http";
14
12
  import { createInterface } from "node:readline";
13
+ import { fileURLToPath } from "node:url";
15
14
  import { TOOL_RUNTIME_SCHEDULER, defineTool } from "@deepseek-ai/dsh-tools";
16
15
  import * as dshLlm from "@deepseek-ai/dsh-llm";
17
16
  import { BlockAssembler, HarnessError, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -175,870 +174,32 @@ function snapshotJsonValue(value) {
175
174
  return walkJsonValue(value, true);
176
175
  }
177
176
 
178
- //#endregion
179
- //#region src/kernel-transport.ts
180
- /** The bridge script shipped beside the built entry (`lib/kernel-bridge.py`). */
181
- const BRIDGE_SCRIPT = fileURLToPath(new URL("./kernel-bridge.py", import.meta.url));
182
- /**
183
- * One bridge subprocess speaking JSON lines. Lifecycle is owned by the caller
184
- * (the kernel bridge registers it as an `ctx.effect`); this type installs no
185
- * process-level signal handlers.
186
- */
187
- var KernelTransport = class {
188
- child;
189
- reader;
190
- startup = false;
191
- closed = false;
192
- readySettled = false;
193
- readyResolve;
194
- readyReject;
195
- bridgeStderr = "";
196
- handlers = /* @__PURE__ */ new Set();
197
- waiters = /* @__PURE__ */ new Set();
198
- constructor(options) {
199
- this.options = options;
200
- }
201
- /** Spawn the bridge and resolve once it reports the channels are up. */
202
- async start() {
203
- if (this.reader !== void 0) return;
204
- if (this.closed) throw new Error("kernel transport is closed");
205
- const child = spawn(this.options.python, [BRIDGE_SCRIPT, this.options.connectionFile], {
206
- ...this.options.cwd === void 0 ? {} : { cwd: this.options.cwd },
207
- env: {
208
- PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
209
- LANG: "C.UTF-8",
210
- HOME: process.env.HOME ?? tmpdir(),
211
- PYTHONUNBUFFERED: "1"
212
- },
213
- stdio: [
214
- "pipe",
215
- "pipe",
216
- "pipe"
217
- ]
218
- });
219
- this.child = child;
220
- this.startup = true;
221
- const ready = new Promise((resolve$1, reject) => {
222
- this.readyResolve = resolve$1;
223
- this.readyReject = reject;
224
- });
225
- const timer = setTimeout(() => {
226
- this.failReady(/* @__PURE__ */ new Error(`kernel bridge did not report ready within ${this.options.readyTimeoutMs}ms; stderr tail:\n${this.stderrTail()}`));
227
- }, this.options.readyTimeoutMs);
228
- timer.unref?.();
229
- child.stderr?.on("data", (buffer) => {
230
- this.bridgeStderr += buffer.toString();
231
- });
232
- child.stdin?.on("error", (error) => {
233
- this.bridgeStderr += `stdin error: ${error.code ?? error.message}\n`;
234
- this.closed = true;
235
- this.failWaiters();
236
- });
237
- child.stdout?.on("error", (error) => {
238
- this.bridgeStderr += `stdout error: ${error.code ?? error.message}\n`;
239
- });
240
- child.on("error", (error) => {
241
- this.bridgeStderr += `spawn error: ${error.message}\n`;
242
- this.failReady(/* @__PURE__ */ new Error(`kernel bridge failed to spawn: ${error.message}`));
243
- this.failWaiters();
244
- this.failExit();
245
- });
246
- child.on("exit", (code, signal) => {
247
- const detail = `kernel bridge exited (code=${String(code)} signal=${String(signal)})`;
248
- this.bridgeStderr += `${detail}\n`;
249
- this.closed = true;
250
- this.failReady(/* @__PURE__ */ new Error(`${detail}; stderr tail:\n${this.stderrTail()}`));
251
- this.failWaiters();
252
- this.failExit();
253
- });
254
- if (child.stdout) {
255
- this.reader = createInterface({ input: child.stdout });
256
- this.reader.on("line", (line) => {
257
- this.accept(line);
258
- });
259
- }
260
- try {
261
- await ready;
262
- } finally {
263
- clearTimeout(timer);
264
- this.startup = false;
265
- }
266
- }
267
- /** Whether the bridge process is still live. */
268
- get alive() {
269
- return this.child !== void 0 && this.child.exitCode === null && !this.closed;
270
- }
271
- /** The bridge's stderr tail, for startup and crash diagnostics. */
272
- stderrTail() {
273
- return this.bridgeStderr.slice(-1024) || "(empty)";
274
- }
275
- /** Register a message handler; the returned function removes it. */
276
- onMessage(handler) {
277
- this.handlers.add(handler);
278
- return () => {
279
- this.handlers.delete(handler);
280
- };
281
- }
282
- /**
283
- * Register a bridge-exit handler; the returned function removes it. A bridge
284
- * that dies mid-cell must settle the active execution at once rather than at
285
- * the cell budget.
286
- */
287
- onExit(handler) {
288
- this.exitHandlers.add(handler);
289
- return () => {
290
- this.exitHandlers.delete(handler);
291
- };
292
- }
293
- exitHandlers = /* @__PURE__ */ new Set();
294
- /** Notify exit handlers once per bridge process end. */
295
- failExit() {
296
- const handlers = [...this.exitHandlers];
297
- this.exitHandlers.clear();
298
- for (const handler of handlers) handler();
299
- }
300
- /** Send one message on the shell or control channel. */
301
- send(channel, message) {
302
- this.writeLine({
303
- op: "send",
304
- channel,
305
- header: message.header,
306
- content: message.content
307
- });
308
- }
309
- /** Ask the kernel to interrupt the running cell (control channel). */
310
- interrupt() {
311
- this.writeLine({ op: "interrupt" });
312
- }
313
- /** Write one protocol line, refusing instead of throwing when the pipe is gone. */
314
- writeLine(payload) {
315
- const stdin = this.child?.stdin;
316
- if (stdin === null || stdin === void 0 || this.closed || !stdin.writable) throw new Error("kernel bridge is not connected");
317
- stdin.write(`${JSON.stringify(payload)}\n`);
318
- }
319
- /**
320
- * Wait for the next message on `channel` matching `match`.
321
- * @returns The message, or null when the budget expires or the bridge dies.
322
- */
323
- waitFor(channel, match, timeoutMs) {
324
- if (this.closed) return Promise.resolve(null);
325
- return new Promise((resolve$1) => {
326
- const waiter = {
327
- channel,
328
- match,
329
- settle: (message) => {
330
- clearTimeout(waiter.timer);
331
- this.waiters.delete(waiter);
332
- resolve$1(message);
333
- },
334
- timer: setTimeout(() => {
335
- this.waiters.delete(waiter);
336
- resolve$1(null);
337
- }, timeoutMs)
338
- };
339
- waiter.timer.unref?.();
340
- this.waiters.add(waiter);
341
- });
342
- }
343
- /** Stop the bridge (graceful shutdown note, then kill). */
344
- async dispose() {
345
- const child = this.child;
346
- this.closed = true;
347
- this.failWaiters();
348
- this.reader?.close();
349
- this.reader = void 0;
350
- if (child === void 0) return;
351
- try {
352
- if (child.stdin?.writable === true) child.stdin.write(`${JSON.stringify({ op: "shutdown" })}\n`);
353
- } catch {}
354
- try {
355
- child.stdin?.end();
356
- } catch {}
357
- if (child.exitCode !== null) return;
358
- await new Promise((resolve$1) => {
359
- const timer = setTimeout(() => {
360
- try {
361
- child.kill("SIGKILL");
362
- } catch {}
363
- resolve$1();
364
- }, 500);
365
- timer.unref?.();
366
- child.once("exit", () => {
367
- clearTimeout(timer);
368
- resolve$1();
369
- });
370
- });
371
- }
372
- failReady(error) {
373
- if (this.readySettled) return;
374
- this.readySettled = true;
375
- this.readyReject?.(error);
376
- }
377
- failWaiters() {
378
- for (const waiter of [...this.waiters]) waiter.settle(null);
379
- }
380
- /** Route one decoded line; unknown events are ignored. */
381
- accept(line) {
382
- let event;
383
- try {
384
- event = JSON.parse(line);
385
- } catch {
386
- return;
387
- }
388
- if (event.ev === "ready") {
389
- if (!this.readySettled) {
390
- this.readySettled = true;
391
- this.readyResolve?.();
392
- }
393
- return;
394
- }
395
- if (event.ev === "msg") {
396
- const channel = event.channel;
397
- const raw = event.msg;
398
- if (typeof channel !== "string" || raw === void 0 || typeof raw.header !== "object" || raw.header === null) return;
399
- const message = {
400
- header: raw.header,
401
- parent_header: raw.parent_header ?? {},
402
- content: raw.content ?? {}
403
- };
404
- for (const waiter of [...this.waiters]) if (waiter.channel === channel && waiter.match(message)) waiter.settle(message);
405
- for (const handler of this.handlers) handler(channel, message);
406
- return;
407
- }
408
- if (event.ev === "fatal") this.bridgeStderr += `fatal: ${String(event.message ?? "unknown")}\n`;
409
- }
410
- };
411
-
412
177
  //#endregion
413
178
  //#region src/bootstrap.ts
414
- /**
415
- * Python-side bootstrap executed once as an internal cell right after kernel
416
- * startup. Seeds the user namespace with the host-request comm bridge
417
- * (`dashr.host` target, the PA `host_request()` pattern), the binding-proxy
418
- * factories, the typed rejection classes, and the lossless-JSON completion
419
- * encoder. Names are `_dashr`/`__dashr` prefixed (any case) so they are
420
- * excluded from namespace snapshots and never leak into user state.
421
- * @module dashr/bootstrap
422
- */
423
- /** Comm target the kernel-side shim opens for typed host requests. */
424
- const HOST_COMM_TARGET = "dashr.host";
425
- /**
426
- * The bootstrap source. Kept dependency-free apart from `ast`, `asyncio`,
427
- * `json`, and `comm` (present by construction — it IS the kernel).
428
- */
429
- const KERNEL_BOOTSTRAP = `
430
- import ast as _dashr_ast
431
- import json as _dashr_json
432
-
433
-
434
- def _dashr_install_control_comm_handlers():
435
- # Host replies arrive as comm_msg on the CONTROL channel while the shell
436
- # channel is busy executing our cell; teach the kernel to route them.
437
- try:
438
- from IPython import get_ipython
439
- except Exception:
440
- return
441
- shell = get_ipython()
442
- kernel = getattr(shell, 'kernel', None)
443
- comm_manager = getattr(kernel, 'comm_manager', None)
444
- control_handlers = getattr(kernel, 'control_handlers', None)
445
- if comm_manager is None or not isinstance(control_handlers, dict):
446
- return
447
- control_handlers.setdefault('comm_msg', comm_manager.comm_msg)
448
- control_handlers.setdefault('comm_close', comm_manager.comm_close)
449
-
450
-
451
- def _dashr_install_interrupt():
452
- # ipykernel's async-cell SIGINT handling only schedules a callback on the
453
- # kernel event loop, and a non-yielding cell ('while True: pass') blocks
454
- # that very loop, so the control-channel interrupt can never break it.
455
- # SIGALRM is untouched by ipykernel: raising KeyboardInterrupt from its
456
- # handler breaks any cell, and the host sends it (after a confirm window,
457
- # see the bridge) on timeout/abort. Best effort — where SIGALRM is
458
- # unavailable the control-channel interrupt is all there is.
459
- #
460
- # The busy guard is load-bearing (M3-A, blueprint §5 "SIGALRM-at-idle 双重
461
- # 窗口"): a KeyboardInterrupt raised OUTSIDE cell execution — while the
462
- # kernel boots, idles between cells, or unwinds a finished one — escapes
463
- # the shell's containment and terminates the process cleanly, which used
464
- # to kill the kernel deterministically (10/10 same-tick aborts, 40/40
465
- # during cold boot; dev/m2a-report.md §5.1). The handler therefore only
466
- # raises while a dashr cell is actually executing, tracked by the
467
- # module-level _dashr_state dict below and set/cleared by the run/snapshot
468
- # cell scaffolds around their whole body. A stray SIGALRM outside that
469
- # window is swallowed — the host's force-settle already resolves the run,
470
- # and the kernel lives. The dict (not a bare flag) is captured by the
471
- # handler closure, so user code rebinding the NAME cannot disarm the
472
- # guard; mutating the dict itself stays possible and accepted — this shim
473
- # is a capability seam, not a security boundary.
474
- try:
475
- import signal as _dashr_signal
476
-
477
- def _dashr_on_sigalrm(_signum, _frame):
478
- if not _dashr_state['executing']:
479
- return
480
- raise KeyboardInterrupt
481
-
482
- _dashr_signal.signal(_dashr_signal.SIGALRM, _dashr_on_sigalrm)
483
- except (ImportError, AttributeError, OSError, ValueError):
484
- pass
485
-
486
-
487
- # The dashr busy-guard flag (see _dashr_install_interrupt): True for the whole
488
- # duration of a run or snapshot cell — the only window in which a SIGALRM may
489
- # raise KeyboardInterrupt. Lives at module (user-namespace) level so the cell
490
- # scaffolds can flip it; excluded from snapshots by the _dashr prefix rule.
491
- _dashr_state = {'executing': False}
492
-
493
-
494
- _dashr_install_interrupt()
495
-
496
-
497
- class _DashrRejected(Exception):
498
- def __init__(self, message):
499
- Exception.__init__(self, message)
500
-
501
-
502
- def _dashr_host_request(payload):
503
- from comm import create_comm
504
- import asyncio
505
- _dashr_install_control_comm_handlers()
506
- loop = asyncio.get_running_loop()
507
- future = loop.create_future()
508
- # primary=True publishes comm_open so the host learns this comm_id; the
509
- # request itself travels as the first comm_msg.
510
- comm = create_comm(target_name=${JSON.stringify(HOST_COMM_TARGET)})
511
-
512
- def _resolve(action):
513
- def _apply():
514
- if future.done():
515
- return
516
- action()
517
- comm.close()
518
- # Replies arrive on the kernel's control channel, which may run off
519
- # the event-loop thread; only call_soon_threadsafe may touch the
520
- # future from there.
521
- loop.call_soon_threadsafe(_apply)
522
-
523
- def _on_msg(msg):
524
- content = msg.get('content', {})
525
- reply = content.get('data', {}) if isinstance(content, dict) else {}
526
- if not isinstance(reply, dict) or future.done():
527
- return
528
- status = reply.get('status')
529
- if status == 'ok':
530
- _resolve(lambda: future.set_result(reply.get('result')))
531
- elif status == 'error':
532
- _resolve(lambda: future.set_exception(_DashrRejected(reply.get('error') or 'host request failed')))
533
-
534
- comm.on_msg(_on_msg)
535
- comm.send(dict(payload))
536
- return future
537
-
538
-
539
- def _dashr_make_error_class(name, member_property):
540
- def _init(self, message, member=None):
541
- Exception.__init__(self, message)
542
- if member is not None:
543
- setattr(self, member_property, member)
544
- return type(name, (Exception,), {'__init__': _init})
545
-
546
-
547
- def _dashr_make_proxy(global_name, function_name, error_class_name, member_property):
548
- async def _dashr_proxy(*args, **kwargs):
549
- if kwargs:
550
- raise TypeError('dashr binding calls do not accept keyword arguments')
551
- if len(args) == 1:
552
- call_args = args[0]
553
- elif len(args) == 0:
554
- call_args = None
555
- else:
556
- call_args = list(args)
557
- payload = {
558
- 'type': 'binding.call',
559
- 'global': global_name,
560
- 'name': function_name,
561
- 'args': call_args,
562
- }
563
- try:
564
- return await _dashr_host_request(payload)
565
- except _DashrRejected as rejected:
566
- cls = globals().get(error_class_name) if error_class_name else None
567
- if cls is None:
568
- raise
569
- raise cls(str(rejected), function_name) from None
570
- return _dashr_proxy
571
-
572
-
573
- def _dashr_make_holder(global_name, error_class_name, member_property, members):
574
- # Attribute misses travel to the host instead of raising AttributeError
575
- # here: the host owns the namespace, so IT rejects an unknown member as an
576
- # unknown binding rather than the kernel inventing a local error class.
577
- class _DashrBindingHolder:
578
- def __dir__(self):
579
- # F9 (v0.2.0-a): introspection tells the truth — dir(tool) lists
580
- # exactly the bound names. A custom __dir__ fully replaces the
581
- # default object.__dir__, so no dunder noise leaks in.
582
- return list(members)
583
-
584
- def __getattr__(self, function_name):
585
- return _dashr_make_proxy(
586
- global_name,
587
- function_name,
588
- error_class_name,
589
- member_property,
590
- )
591
-
592
- return _DashrBindingHolder()
593
-
594
-
595
- def _dashr_make_callable(global_name, function_name, error_class_name, member_property):
596
- # A BARE callable global (the flat per-tool bindings AND the bridge
597
- # tools: 'callable: true' on the namespace) instead of an object
598
- # holder. The one-object form is the single convention: every callable
599
- # takes exactly one positional arguments object (e.g.
600
- # agent_message({'receiver': 'child', ...})), and keyword / multi-positional forms are
601
- # rejected HERE, at the Python call boundary, so the introspectable
602
- # signature tells the truth and a TypeError names the binding global
603
- # (read(...) got an unexpected keyword argument ...). The wire envelope
604
- # keeps the historical {'args': [...], 'kwargs': {}} shape so the host's
605
- # parseReplCall / flatToolArgs / flatBridgeToolArgs validation layers stay
606
- # authoritative and unchanged. The single functions-entry name is
607
- # transport-only: the program never sees it, and a typed rejection
608
- # carries the GLOBAL name as its member (for a flat tool binding the
609
- # global IS the tool name the program knows). __name__/__qualname__ are
610
- # pinned to the global name so introspection shows the binding's name,
611
- # not the factory-local _dashr_callable.
612
- async def _dashr_callable(args=None, /):
613
- payload = {
614
- 'type': 'binding.call',
615
- 'global': global_name,
616
- 'name': function_name,
617
- 'args': {'args': [] if args is None else [args], 'kwargs': {}},
618
- }
619
- try:
620
- return await _dashr_host_request(payload)
621
- except _DashrRejected as rejected:
622
- cls = globals().get(error_class_name) if error_class_name else None
623
- if cls is None:
624
- raise
625
- raise cls(str(rejected), global_name) from None
626
-
627
- _dashr_callable.__name__ = global_name
628
- _dashr_callable.__qualname__ = global_name
629
- return _dashr_callable
630
-
631
-
632
- class _DashrNoValue:
633
- # Marks 'no completion value': a cell whose last statement is not an
634
- # expression, or whose last expression evaluates to None — the REPL
635
- # displayhook suppresses None (IPython behavior), so no value crosses.
636
- __slots__ = ()
637
-
638
-
639
- _DASHR_NO_VALUE = _DashrNoValue()
640
-
641
-
642
- class _DashrTopLevelReturnChecker(_dashr_ast.NodeVisitor):
643
- # A cell runs at module scope, like any native IPython cell: a top-level
644
- # 'return'/'yield' is a SyntaxError decided by the kernel itself, exactly
645
- # as in a real REPL. Nested scopes (functions, lambdas, classes) keep
646
- # their own return semantics.
647
- def visit_FunctionDef(self, node):
648
- return
649
-
650
- def visit_AsyncFunctionDef(self, node):
651
- return
652
-
653
- def visit_ClassDef(self, node):
654
- return
655
-
656
- def visit_Lambda(self, node):
657
- return
658
-
659
- def visit_Return(self, node):
660
- raise SyntaxError("'return' outside function")
661
-
662
- def visit_Yield(self, node):
663
- raise SyntaxError("'yield' outside function")
664
-
665
-
666
- async def _dashr_run_program(source):
667
- # Run one cell with the user namespace as BOTH globals and locals —
668
- # module-scope REPL semantics: state persists across cells, top-level
669
- # 'await' is legal (PyCF_ALLOW_TOP_LEVEL_AWAIT, the same mechanism IPython
670
- # uses), a top-level 'return'/'yield' is a SyntaxError decided here by the
671
- # kernel — exactly as in a native IPython cell — and the last expression's
672
- # value is the cell's result (the REPL displayhook handoff).
673
- import inspect as _dashr_inspect
674
- user_ns = globals()
675
- if not source.strip():
676
- source = 'pass'
677
- parsed = _dashr_ast.parse(source, '<dashr program>', 'exec')
678
- _DashrTopLevelReturnChecker().visit(parsed)
679
- body = parsed.body
680
- if body and isinstance(body[-1], _dashr_ast.Expr):
681
- # Capture the last expression the way a REPL's displayhook shows it:
682
- # its value feeds the completion envelope instead of being discarded.
683
- # The name is distinct from the scaffold's __dashr_completion__ (the
684
- # envelope holder) so a statement-ending cell can never pop a stale
685
- # envelope left by an earlier run.
686
- last = body[-1]
687
- body[-1] = _dashr_ast.Assign(
688
- targets=[_dashr_ast.Name(id='__dashr_cell_value__', ctx=_dashr_ast.Store())],
689
- value=last.value,
690
- )
691
- _dashr_ast.fix_missing_locations(body[-1])
692
- code = compile(
693
- parsed,
694
- '<dashr program>',
695
- 'exec',
696
- flags=_dashr_ast.PyCF_ALLOW_TOP_LEVEL_AWAIT,
697
- )
698
- # IPython's own run_code pattern: with PyCF_ALLOW_TOP_LEVEL_AWAIT, plain
699
- # exec() DISCARDS the module coroutine silently; eval() under await
700
- # returns it and runs the code (IPython InteractiveShell.run_code does
701
- # exactly 'await eval(code_obj, user_global_ns, user_ns)').
702
- result = eval(code, user_ns, user_ns)
703
- if _dashr_inspect.iscoroutine(result):
704
- await result
705
- value = user_ns.pop('__dashr_cell_value__', _DASHR_NO_VALUE)
706
- if value is None:
707
- return _DASHR_NO_VALUE
708
- return value
709
-
710
-
711
- def _dashr_install_bindings(spec_json):
712
- spec = _dashr_json.loads(spec_json)
713
- user_ns = globals()
714
- injected = user_ns.get('__dashr_injected__', {})
715
- for old_name, old_spec in list(injected.items()):
716
- if old_name not in spec:
717
- user_ns.pop(old_name, None)
718
- if old_spec.get('errorClass'):
719
- user_ns.pop(old_spec['errorClass']['name'], None)
720
- fresh = {}
721
- # The flat per-tool shape declares the SAME error class on many
722
- # namespaces: materialize each DISTINCT name once, so every proxy's
723
- # raise-time globals() lookup finds ONE class and a single
724
- # 'except ToolCallError' catches failures from every binding.
725
- error_classes = {}
726
- for global_name, namespace in spec.items():
727
- error_class = namespace.get('errorClass')
728
- if namespace.get('callable'):
729
- # Exactly one function entry was validated by the host; its key is
730
- # the transport name, the bare global itself is the callable.
731
- function_name = next(iter(namespace['functions']))
732
- user_ns[global_name] = _dashr_make_callable(
733
- global_name,
734
- function_name,
735
- error_class['name'] if error_class else None,
736
- error_class['memberNameProperty'] if error_class else None,
737
- )
738
- else:
739
- user_ns[global_name] = _dashr_make_holder(
740
- global_name,
741
- error_class['name'] if error_class else None,
742
- error_class['memberNameProperty'] if error_class else None,
743
- sorted(namespace['functions']),
744
- )
745
- if error_class and error_class['name'] not in error_classes:
746
- cls = _dashr_make_error_class(
747
- error_class['name'],
748
- error_class['memberNameProperty'],
749
- )
750
- error_classes[error_class['name']] = cls
751
- user_ns[error_class['name']] = cls
752
- fresh[global_name] = namespace
753
- user_ns['__dashr_injected__'] = fresh
754
-
755
-
756
- def _dashr_encode(value):
757
- if value is _DASHR_NO_VALUE:
758
- return {'ok': True}
759
- try:
760
- return {'ok': True, 'json': _dashr_json.dumps(value, allow_nan=False)}
761
- except (TypeError, ValueError, OverflowError):
762
- # Not lossless JSON: pass its repr TEXT through instead of failing
763
- # the cell. The upstream renders the tool result as text either way,
764
- # so a non-JSON value costs the model nothing; the host keeps a
765
- # plain string it forwards untouched. The envelope itself never
766
- # fails, so the completion never gate-rejects a return value.
767
- return {'ok': True, 'json': _dashr_json.dumps(repr(value))}
768
- `;
179
+ /** Module-qualified initialization survives OMP %reset without leaking imports. */
180
+ const KERNEL_BOOTSTRAP = `__import__('better_support').initialize(globals())`;
769
181
 
770
182
  //#endregion
771
183
  //#region src/python.ts
772
- /**
773
- * Build the single cell for one run.
774
- * @param program - the model's cell program, executed at module scope.
775
- * @param specJson - the JSON-encoded {@link NamespaceSpecs} map.
776
- * @param sentinel - per-run nonce prefix; the envelope line starts with it.
777
- * @returns the complete cell source.
778
- */
779
184
  function buildRunCell(program, specJson, sentinel) {
780
- return [
781
- `_dashr_state['executing'] = True`,
782
- `try:`,
783
- ` _dashr_install_bindings(${JSON.stringify(specJson)})`,
784
- ` __dashr_completion__ = _dashr_encode(await _dashr_run_program(${JSON.stringify(program)}))`,
785
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps(__dashr_completion__))`,
786
- `finally:`,
787
- ` _dashr_state['executing'] = False`
788
- ].join("\n");
789
- }
790
- /**
791
- * Build the internal cell that dumps the user namespace to a dill snapshot and
792
- * writes a JSON manifest next to it. Only USER state is captured: IPython's
793
- * own session plumbing (`exit`, `quit`, `get_ipython`, … tracked in the
794
- * shell's `user_ns_hidden` and still bound to the object IPython installed)
795
- * reaches unpicklable kernel machinery — e.g. the history manager's
796
- * sqlite3.Connection — and is never restored as program state anyway. Shim
797
- * names (any `_dashr`/`__dashr` spelling) are excluded by prefix.
798
- *
799
- * Size-cap policy (M3-B, blueprint §8.3): dill cannot report a serialized
800
- * size before dumping, and a GB-sized `DataFrame` dumped EVERY turn is the IO
801
- * storm the cap exists to stop. The cell therefore gates in two tiers:
802
- * first a bounded-depth namespace walk estimates the footprint (counting
803
- * container lengths and reading `nbytes` / `memory_usage(deep=True)` for
804
- * numpy/pandas objects — the stated pathological case — so the dump is
805
- * skipped BEFORE any dill IO), then the actual dump goes to a sibling
806
- * `.part` file whose true byte size is measured before it replaces the
807
- * previous good snapshot. Skipped snapshots leave the previous
808
- * `state.dill` + manifest untouched, so a revive restores the last good turn.
809
- * @param payloadPath - absolute path for the dill payload.
810
- * @param manifestPath - absolute path for the JSON manifest.
811
- * @param spec - turn counter, skills list, and the size cap.
812
- * @param sentinel - per-cell nonce prefix for the outcome envelope.
813
- * @returns the complete cell source.
814
- */
815
- function buildSnapshotCell(payloadPath, manifestPath, spec, sentinel) {
816
- return [
817
- `_dashr_state['executing'] = True`,
818
- `try:`,
819
- ` import dill as _dashr_dill, json as _dashr_json, platform as _dashr_platform`,
820
- ` import os as _dashr_os, sys as _dashr_sys, datetime as _dashr_dt`,
821
- ` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
822
- ` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
823
- ` _dashr_user_ns = {`,
824
- ` name: value`,
825
- ` for name, value in globals().items()`,
826
- ` if not name.lower().startswith(('_dashr', '__dashr'))`,
827
- ` and (name not in _dashr_hidden or _dashr_hidden[name] is not value)`,
828
- ` }`,
829
- ` _dashr_cap = ${JSON.stringify(spec.sizeCapBytes)}`,
830
- ` def _dashr_estimate(value, depth):`,
831
- ` if value is None or isinstance(value, (bool, int, float, complex)):`,
832
- ` return 32`,
833
- ` if isinstance(value, (str, bytes, bytearray)):`,
834
- ` return len(value) + 64`,
835
- ` if isinstance(value, (list, tuple, set, frozenset)):`,
836
- ` if depth <= 0:`,
837
- ` return 4096`,
838
- ` return 64 + sum(_dashr_estimate(item, depth - 1) for item in value)`,
839
- ` if isinstance(value, dict):`,
840
- ` if depth <= 0:`,
841
- ` return 4096`,
842
- ` total = 64`,
843
- ` for key, item in value.items():`,
844
- ` total += _dashr_estimate(key, depth - 1)`,
845
- ` total += _dashr_estimate(item, depth - 1)`,
846
- ` if total > _dashr_cap:`,
847
- ` return total`,
848
- ` return total`,
849
- ` _dashr_nbytes = getattr(value, 'nbytes', None)`,
850
- ` if isinstance(_dashr_nbytes, int) and _dashr_nbytes > 0:`,
851
- ` return _dashr_nbytes + 128`,
852
- ` _dashr_mem = getattr(value, 'memory_usage', None)`,
853
- ` if callable(_dashr_mem):`,
854
- ` try:`,
855
- ` _dashr_usage = _dashr_mem(deep=True)`,
856
- ` _dashr_sum = getattr(_dashr_usage, 'sum', None)`,
857
- ` if callable(_dashr_sum):`,
858
- ` # pandas returns a numpy scalar (np.int64), which is`,
859
- ` # not a Python int subclass; coerce, don't isinstance.`,
860
- ` _dashr_n = int(_dashr_sum())`,
861
- ` if _dashr_n > 0:`,
862
- ` return _dashr_n + 128`,
863
- ` except Exception:`,
864
- ` pass`,
865
- ` return 4096`,
866
- ` _dashr_estimate_bytes = sum(_dashr_estimate(value, 4) for value in _dashr_user_ns.values())`,
867
- ` if _dashr_estimate_bytes > _dashr_cap:`,
868
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
869
- ` 'ok': False, 'skipped': True, 'reason': 'estimate',`,
870
- ` 'estimateBytes': _dashr_estimate_bytes, 'capBytes': _dashr_cap,`,
871
- ` }))`,
872
- ` else:`,
873
- ` _dashr_part = ${JSON.stringify(payloadPath)} + '.part'`,
874
- ` try:`,
875
- ` with open(_dashr_part, 'wb') as _dashr_f:`,
876
- ` _dashr_dill.dump(_dashr_user_ns, _dashr_f)`,
877
- ` _dashr_size = _dashr_os.path.getsize(_dashr_part)`,
878
- ` if _dashr_size > _dashr_cap:`,
879
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
880
- ` 'ok': False, 'skipped': True, 'reason': 'actual',`,
881
- ` 'sizeBytes': _dashr_size, 'capBytes': _dashr_cap,`,
882
- ` }))`,
883
- ` else:`,
884
- ` _dashr_os.replace(_dashr_part, ${JSON.stringify(payloadPath)})`,
885
- ` _dashr_manifest = {`,
886
- ` 'snapshotFormat': 1,`,
887
- ` 'turn': ${JSON.stringify(spec.turn)},`,
888
- ` 'pythonVersion': _dashr_platform.python_version(),`,
889
- ` 'venvPath': _dashr_sys.executable,`,
890
- ` 'skills': ${JSON.stringify(spec.skills)},`,
891
- ` 'names': sorted(_dashr_user_ns),`,
892
- ` 'sizeBytes': _dashr_size,`,
893
- ` 'skipped': False,`,
894
- ` 'createdAt': _dashr_dt.datetime.now(_dashr_dt.timezone.utc).isoformat(),`,
895
- ` }`,
896
- ` with open(${JSON.stringify(manifestPath)}, 'w', encoding='utf-8') as _dashr_f:`,
897
- ` _dashr_json.dump(_dashr_manifest, _dashr_f)`,
898
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({`,
899
- ` 'ok': True, 'sizeBytes': _dashr_size, 'names': len(_dashr_user_ns),`,
900
- ` }))`,
901
- ` finally:`,
902
- ` if _dashr_os.path.exists(_dashr_part):`,
903
- ` try:`,
904
- ` _dashr_os.unlink(_dashr_part)`,
905
- ` except OSError:`,
906
- ` pass`,
907
- `finally:`,
908
- ` _dashr_state['executing'] = False`
909
- ].join("\n");
910
- }
911
- /**
912
- * Build the internal cell that restores a dill snapshot into the user
913
- * namespace. The kernel validates the manifest ITSELF (it is the only party
914
- * that knows its own python version and `sys.executable`): a python-version
915
- * mismatch, a different interpreter (`venvPath`), a non-empty skills list, or
916
- * an unreadable/corrupt payload degrades to an empty namespace WITHOUT
917
- * touching globals, and reports the reason through the envelope so the host
918
- * can tell the model "snapshot not replayable, started from empty". Shim and
919
- * IPython-hidden names are re-filtered on the way in — the same exclusion the
920
- * snapshot applied on the way out, applied again defensively.
921
- * @param payloadPath - absolute path of the dill payload.
922
- * @param manifestPath - absolute path of the JSON manifest.
923
- * @param sentinel - per-cell nonce prefix for the outcome envelope.
924
- * @returns the complete cell source.
925
- */
926
- function buildRestoreCell(payloadPath, manifestPath, sentinel) {
927
- return [
928
- `_dashr_state['executing'] = True`,
929
- `try:`,
930
- ` import dill as _dashr_dill, json as _dashr_json, platform as _dashr_platform`,
931
- ` import os as _dashr_os, sys as _dashr_sys`,
932
- ` _dashr_manifest = None`,
933
- ` _dashr_reason = 'snapshot manifest could not be read'`,
934
- ` try:`,
935
- ` with open(${JSON.stringify(manifestPath)}, 'r', encoding='utf-8') as _dashr_f:`,
936
- ` _dashr_manifest = _dashr_json.load(_dashr_f)`,
937
- ` except Exception as _dashr_err:`,
938
- ` _dashr_reason = 'snapshot manifest could not be read: ' + repr(_dashr_err)`,
939
- ` if _dashr_manifest is None:`,
940
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': _dashr_reason}))`,
941
- ` elif _dashr_manifest.get('snapshotFormat') != 1:`,
942
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot format is not replayable'}))`,
943
- ` elif _dashr_manifest.get('skipped'):`,
944
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot was skipped (namespace exceeded the size cap)'}))`,
945
- ` elif _dashr_manifest.get('pythonVersion') != _dashr_platform.python_version():`,
946
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'python version mismatch: snapshot ' + str(_dashr_manifest.get('pythonVersion')) + ' vs kernel ' + _dashr_platform.python_version()}))`,
947
- ` elif _dashr_os.path.realpath(_dashr_manifest.get('venvPath') or '') != _dashr_os.path.realpath(_dashr_sys.executable):`,
948
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'kernel interpreter differs from the snapshot venv'}))`,
949
- ` elif _dashr_manifest.get('skills'):`,
950
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': 'snapshot skills are not replayable in this provider'}))`,
951
- ` else:`,
952
- ` try:`,
953
- ` with open(${JSON.stringify(payloadPath)}, 'rb') as _dashr_f:`,
954
- ` _dashr_restored = _dashr_dill.load(_dashr_f)`,
955
- ` except Exception as _dashr_err:`,
956
- ` _dashr_restored = None`,
957
- ` _dashr_reason = 'snapshot payload could not be loaded: ' + repr(_dashr_err)`,
958
- ` if not isinstance(_dashr_restored, dict):`,
959
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': False, 'reason': _dashr_reason if _dashr_restored is None else 'snapshot payload is not a namespace dict'}))`,
960
- ` else:`,
961
- ` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
962
- ` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
963
- ` _dashr_user_ns = globals()`,
964
- ` _dashr_restored_count = 0`,
965
- ` for _dashr_name, _dashr_value in _dashr_restored.items():`,
966
- ` if not isinstance(_dashr_name, str):`,
967
- ` continue`,
968
- ` if _dashr_name.lower().startswith(('_dashr', '__dashr')):`,
969
- ` continue`,
970
- ` if _dashr_name in _dashr_hidden and _dashr_hidden[_dashr_name] is _dashr_value:`,
971
- ` continue`,
972
- ` _dashr_user_ns[_dashr_name] = _dashr_value`,
973
- ` _dashr_restored_count += 1`,
974
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'restored': _dashr_restored_count}))`,
975
- `finally:`,
976
- ` _dashr_state['executing'] = False`
977
- ].join("\n");
185
+ return `await __import__('better_support').run(globals(), ${JSON.stringify(program)}, ${JSON.stringify(specJson)}, ${JSON.stringify(sentinel)})`;
978
186
  }
979
- /**
980
- * Build the internal cell that reads ONE user-namespace variable by name, or
981
- * — when `name` is `null` — lists the namespace's user-variable names. The
982
- * same exclusion the snapshot applies on the way out (any `_dashr`/`__dashr`
983
- * spelling plus IPython's `user_ns_hidden`) defines what "user variable"
984
- * means here, so `ctx://` sees exactly the namespace a revive would restore.
985
- *
986
- * Serialization boundary (design.md D4): a value that survives
987
- * `json.dumps(…, allow_nan=False)` crosses as `kind: 'json'` text; anything
988
- * else falls back to `repr` text (`kind: 'repr'`) so a non-JSON variable
989
- * (the reason the snapshot uses dill at all) is still readable as text. The
990
- * host keeps the `kind` so the presentation can annotate a repr as "this is
991
- * repr text, not a JSON value".
992
- * @param name - the exact variable name, or `null` to list namespace names.
993
- * @param sentinel - per-cell nonce prefix for the outcome envelope.
994
- * @returns the complete cell source.
995
- */
996
187
  function buildQueryVarCell(name$1, sentinel) {
997
- const lines = [
998
- `_dashr_state['executing'] = True`,
999
- `try:`,
1000
- ` import json as _dashr_json`,
1001
- ` _dashr_shell = globals().get('get_ipython', lambda: None)()`,
1002
- ` _dashr_hidden = getattr(_dashr_shell, 'user_ns_hidden', None) or {}`,
1003
- ` _dashr_user_ns = {`,
1004
- ` _name: _value`,
1005
- ` for _name, _value in globals().items()`,
1006
- ` if not _name.lower().startswith(('_dashr', '__dashr'))`,
1007
- ` and (_name not in _dashr_hidden or _dashr_hidden[_name] is not _value)`,
1008
- ` }`
1009
- ];
1010
- if (name$1 === null) lines.push(` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'names', 'names': sorted(_dashr_user_ns)}))`);
1011
- else lines.push(` _dashr_name = ${JSON.stringify(name$1)}`, ` if _dashr_name not in _dashr_user_ns:`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'missing'}))`, ` else:`, ` _dashr_value = _dashr_user_ns[_dashr_name]`, ` try:`, ` _dashr_text = _dashr_json.dumps(_dashr_value, allow_nan=False)`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'json', 'text': _dashr_text}))`, ` except (TypeError, ValueError, OverflowError):`, ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True, 'kind': 'repr', 'text': repr(_dashr_value)}))`);
1012
- lines.push(`finally:`, ` _dashr_state['executing'] = False`);
1013
- return lines.join("\n");
188
+ return `__import__('better_support').query(globals(), ${JSON.stringify(JSON.stringify(name$1))}, ${JSON.stringify(sentinel)})`;
1014
189
  }
1015
- /**
1016
- * Build the internal cell that assigns one JSON value into the user
1017
- * namespace under `name`. The value is carried as pre-serialized JSON text
1018
- * (the host validated it as lossless JSON and the name as a usable
1019
- * identifier), so the cell only decodes and binds — no arbitrary object can
1020
- * cross the wire. Bracketed by the same SIGALRM busy guard as a run cell.
1021
- * @param name - the validated identifier to assign under.
1022
- * @param valueJson - the lossless-JSON text to decode and bind.
1023
- * @param sentinel - per-cell nonce prefix for the outcome envelope.
1024
- * @returns the complete cell source.
1025
- */
1026
190
  function buildSetVarCell(name$1, valueJson, sentinel) {
1027
- return [
1028
- `_dashr_state['executing'] = True`,
1029
- `try:`,
1030
- ` import json as _dashr_json`,
1031
- ` _dashr_value = _dashr_json.loads(${JSON.stringify(valueJson)})`,
1032
- ` globals()[${JSON.stringify(name$1)}] = _dashr_value`,
1033
- ` print(${JSON.stringify(sentinel)} + _dashr_json.dumps({'ok': True}))`,
1034
- `finally:`,
1035
- ` _dashr_state['executing'] = False`
1036
- ].join("\n");
191
+ return `__import__('better_support').set_var(globals(), ${JSON.stringify(name$1)}, ${JSON.stringify(valueJson)}, ${JSON.stringify(sentinel)})`;
192
+ }
193
+ function buildSnapshotCell(payloadPath, manifestPath, spec, sentinel) {
194
+ return `__import__('better_snapshot').save(globals(), ${JSON.stringify(payloadPath)}, ${JSON.stringify(manifestPath)}, ${JSON.stringify(JSON.stringify(spec))}, ${JSON.stringify(sentinel)})`;
195
+ }
196
+ function buildRestoreCell(payloadPath, manifestPath, sentinel, bindings = []) {
197
+ return `__import__('better_snapshot').restore(globals(), ${JSON.stringify(payloadPath)}, ${JSON.stringify(manifestPath)}, ${JSON.stringify(sentinel)}, ${JSON.stringify(JSON.stringify(bindings))})`;
1037
198
  }
1038
199
 
1039
200
  //#endregion
1040
201
  //#region src/output.ts
1041
- /** ANSI color escapes, which ipykernel embeds in published tracebacks. */
202
+ /** ANSI color escapes, which Python may embed in tracebacks. */
1042
203
  const ANSI_ESCAPE = /\x1b\[[0-9;]*[A-Za-z]/g;
1043
204
  /** Smallest cap that can represent an empty logs array plus a quoted message. */
1044
205
  const MIN_OUTPUT_BYTES = 4;
@@ -1067,7 +228,7 @@ function extractStream(streamText, sentinel) {
1067
228
  envelope: envelope === "" ? void 0 : envelope
1068
229
  };
1069
230
  }
1070
- /** Strip ANSI escapes from an ipykernel traceback for a model-facing message. */
231
+ /** Strip ANSI escapes from a Python traceback for a model-facing message. */
1071
232
  function plainTraceback(ename, evalue, traceback) {
1072
233
  const body = traceback.map((line) => line.replace(ANSI_ESCAPE, "")).join("\n");
1073
234
  return body.length > 0 ? `${ename}: ${evalue}\n${body}` : `${ename}: ${evalue}`;
@@ -1122,775 +283,437 @@ function finalizeOutput(logs, value, error, maxBytes) {
1122
283
 
1123
284
  //#endregion
1124
285
  //#region src/kernel.ts
1125
- const PROTOCOL_VERSION = "5.3";
1126
- /** Loopback PUB/SUB subscription propagation guard before the first execute. */
1127
- const IOPUB_SUBSCRIBE_DELAY_MS = 50;
1128
- const CONNECT_POLL_MS = 25;
1129
- const SHUTDOWN_DRAIN_MS = 200;
1130
- function isRecord(value) {
1131
- return typeof value === "object" && value !== null && !Array.isArray(value);
286
+ function record(value) {
287
+ return value !== null && typeof value === "object" && !Array.isArray(value);
1132
288
  }
1133
- function buildMessage(msgType, content, session, username) {
1134
- return {
1135
- header: {
1136
- msg_id: randomUUID(),
1137
- session,
1138
- username,
1139
- date: (/* @__PURE__ */ new Date()).toISOString(),
1140
- msg_type: msgType,
1141
- version: PROTOCOL_VERSION
1142
- },
1143
- parent_header: {},
1144
- content
1145
- };
1146
- }
1147
- function makeConnection() {
1148
- const info = {
1149
- ip: "127.0.0.1",
1150
- transport: "tcp",
1151
- shell_port: 0,
1152
- iopub_port: 0,
1153
- stdin_port: 0,
1154
- control_port: 0,
1155
- hb_port: 0,
1156
- signature_scheme: "hmac-sha256",
1157
- key: randomBytes(16).toString("hex"),
1158
- kernel_name: "python3"
1159
- };
1160
- const tempDir = mkdtempSync(join(tmpdir(), "dashr-kernel-"));
1161
- const path$1 = join(tempDir, "connection.json");
1162
- writeFileSync(path$1, JSON.stringify(info, null, 2), { mode: 384 });
1163
- return {
1164
- path: path$1,
1165
- tempDir
1166
- };
1167
- }
1168
- function readResolvedConnection(path$1) {
1169
- try {
1170
- const parsed = JSON.parse(readFileSync(path$1, "utf8"));
1171
- if (!isRecord(parsed) || parsed.ip !== "127.0.0.1" || parsed.transport !== "tcp" || parsed.signature_scheme !== "hmac-sha256" || typeof parsed.key !== "string") return null;
1172
- for (const port of [
1173
- "shell_port",
1174
- "iopub_port",
1175
- "stdin_port",
1176
- "control_port",
1177
- "hb_port"
1178
- ]) {
1179
- const value = parsed[port];
1180
- if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) return null;
1181
- }
1182
- return parsed;
1183
- } catch {
1184
- return null;
1185
- }
1186
- }
1187
- /**
1188
- * One persistent IPython kernel subprocess with its ZeroMQ channels. Cell
1189
- * execution is serialized on an internal queue (the Jupyter shell channel is
1190
- * request/reply).
1191
- */
289
+ /** Historical class name retained for the runtime's private integration seam. */
1192
290
  var IpyKernelBridge = class {
1193
- session = randomUUID();
1194
291
  state = "idle";
292
+ child;
1195
293
  startPromise;
1196
- kernel;
1197
- transport;
1198
- connection;
1199
- tempDir;
1200
- kernelStderr = "";
1201
- executionQueue = Promise.resolve();
1202
- activeExec;
1203
- commTargets = /* @__PURE__ */ new Map();
1204
- servedComms = /* @__PURE__ */ new Set();
294
+ disposePromise;
295
+ queue = Promise.resolve();
296
+ active;
297
+ stderr = "";
298
+ deathFailure;
299
+ callbackServer;
300
+ closePromise;
301
+ ownedReaders = [];
1205
302
  constructor(options) {
1206
303
  this.options = options;
1207
304
  }
1208
- /** The kernel subprocess pid, for lifecycle assertions; absent before spawn. */
1209
305
  get pid() {
1210
- return this.kernel?.pid;
306
+ return this.child?.pid;
1211
307
  }
1212
308
  get isRunning() {
1213
309
  return this.state === "running";
1214
310
  }
1215
- /**
1216
- * Whether the subprocess is gone (crashed or disposed). A dead bridge is
1217
- * never resurrected — the owning provider replaces it with a fresh one
1218
- * (respawn, M3-A), because kernel state is unrecoverable in-process.
1219
- */
1220
311
  get isDead() {
1221
312
  return this.state === "shutdown";
1222
313
  }
1223
- /**
1224
- * Start the kernel (idempotent; concurrent callers share one startup).
1225
- * @throws when the subprocess cannot spawn, ports never resolve, the
1226
- * kernel_info probe times out, or the bootstrap cell fails.
1227
- */
1228
- async start() {
1229
- if (!this.startPromise) this.startPromise = this.doStart().catch((error) => {
1230
- this.startPromise = void 0;
1231
- throw error;
1232
- });
314
+ start() {
315
+ this.startPromise ??= this.doStart();
1233
316
  return this.startPromise;
1234
317
  }
1235
- stderrTail() {
1236
- return this.kernelStderr.slice(-1024) || "(empty)";
318
+ async windowsCallbacks() {
319
+ const token = randomBytes(32).toString("hex");
320
+ const server = createServer((req, response) => {
321
+ if (req.method !== "POST" || req.url !== "/callback" || req.headers.authorization !== `Bearer ${token}`) {
322
+ response.writeHead(403).end();
323
+ return;
324
+ }
325
+ let body = "";
326
+ req.setEncoding("utf8");
327
+ req.on("data", (data) => {
328
+ body += data;
329
+ });
330
+ req.on("end", () => {
331
+ let parsed;
332
+ try {
333
+ parsed = JSON.parse(body);
334
+ } catch {
335
+ response.writeHead(400).end();
336
+ return;
337
+ }
338
+ this.callback(parsed).then((reply) => {
339
+ if (!response.destroyed) response.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(reply));
340
+ });
341
+ });
342
+ });
343
+ this.callbackServer = server;
344
+ await new Promise((resolve$1, reject) => {
345
+ server.once("error", reject);
346
+ server.listen(0, "127.0.0.1", () => {
347
+ server.off("error", reject);
348
+ resolve$1();
349
+ });
350
+ });
351
+ const address = server.address();
352
+ if (!address || typeof address === "string") throw new Error("callback endpoint unavailable");
353
+ return {
354
+ BETTER_CALLBACK_URL: `http://127.0.0.1:${address.port}/callback`,
355
+ BETTER_CALLBACK_TOKEN: token
356
+ };
1237
357
  }
1238
358
  async doStart() {
1239
- if (this.state !== "idle") return;
359
+ if (this.state !== "idle") throw new Error("Python bridge cannot restart after shutdown");
1240
360
  this.state = "starting";
1241
- const { path: path$1, tempDir } = makeConnection();
1242
- this.tempDir = tempDir;
1243
- const kernel = spawn(this.options.python, [
1244
- "-m",
1245
- "ipykernel_launcher",
1246
- "-f",
1247
- path$1
1248
- ], {
1249
- cwd: this.options.cwd,
1250
- env: {
1251
- PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin",
1252
- LANG: "C.UTF-8",
1253
- HOME: process.env.HOME ?? tmpdir()
1254
- },
1255
- stdio: [
1256
- "ignore",
1257
- "pipe",
1258
- "pipe"
1259
- ]
1260
- });
1261
- this.kernel = kernel;
1262
- kernel.stderr?.on("data", (buf) => {
1263
- this.kernelStderr += buf.toString();
1264
- });
1265
- kernel.on("error", (error) => {
1266
- this.kernelStderr += `spawn error: ${error.message}\n`;
1267
- this.onKernelDeath();
1268
- });
1269
- kernel.on("exit", (code, signal) => {
1270
- if (this.state !== "shutdown") this.kernelStderr += `unexpected exit code=${code} signal=${signal}\n`;
1271
- this.onKernelDeath();
1272
- });
1273
- let conn;
1274
361
  try {
1275
- conn = await this.waitForConnection(path$1);
1276
- this.connection = conn;
1277
- } catch (error) {
1278
- this.cleanupResources();
1279
- this.state = "idle";
1280
- throw error;
1281
- }
1282
- const transport = new KernelTransport({
1283
- python: this.options.python,
1284
- connectionFile: path$1,
1285
- ...this.options.cwd === void 0 ? {} : { cwd: this.options.cwd },
1286
- readyTimeoutMs: this.options.startupTimeoutMs
1287
- });
1288
- this.transport = transport;
1289
- transport.onMessage((channel, message) => {
1290
- this.handleTransportMessage(channel, message);
1291
- });
1292
- transport.onExit(() => {
1293
- if (this.state !== "shutdown") this.forceSettleActive({
1294
- kind: "worker-exit",
1295
- message: `kernel bridge died. stderr tail:\n${transport.stderrTail()}`
362
+ const resources = fileURLToPath(new URL("./omp-python/", import.meta.url));
363
+ const callbackEnv = process.platform === "win32" ? await this.windowsCallbacks() : {};
364
+ if (this.isDead) throw new Error("Python startup cancelled by disposal");
365
+ const child = spawn(this.options.python, ["-u", fileURLToPath(new URL("./omp-python/runner.py", import.meta.url))], {
366
+ ...this.options.cwd ? { cwd: this.options.cwd } : {},
367
+ detached: process.platform !== "win32",
368
+ stdio: process.platform === "win32" ? [
369
+ "pipe",
370
+ "pipe",
371
+ "pipe"
372
+ ] : [
373
+ "pipe",
374
+ "pipe",
375
+ "pipe",
376
+ "pipe",
377
+ "pipe"
378
+ ],
379
+ env: {
380
+ ...process.env,
381
+ ...this.options.env,
382
+ PYTHONUNBUFFERED: "1",
383
+ PYTHONIOENCODING: "utf-8",
384
+ PYTHONDONTWRITEBYTECODE: "1",
385
+ PYTHONPATH: [resources, process.env.PYTHONPATH].filter(Boolean).join(process.platform === "win32" ? ";" : ":"),
386
+ ...callbackEnv
387
+ }
1296
388
  });
1297
- });
1298
- try {
1299
- await transport.start();
1300
- } catch (error) {
1301
- this.cleanupResources();
1302
- this.state = "idle";
1303
- throw error;
1304
- }
1305
- await setTimeout$1(IOPUB_SUBSCRIBE_DELAY_MS);
1306
- try {
1307
- await this.probeReady();
389
+ this.child = child;
390
+ this.closePromise = new Promise((resolve$1) => child.once("close", () => resolve$1()));
391
+ child.on("error", (error) => this.died(`Python process error: ${error.message}`));
392
+ child.on("exit", (code, signal) => this.died(`Python process exited (${signal ?? code}); stderr: ${this.stderr.slice(-2048)}`));
393
+ child.stdin?.on("error", (error) => this.died(`Python input pipe failed: ${error.message}`));
394
+ child.stderr?.setEncoding("utf8").on("data", (data) => {
395
+ this.stderr = (this.stderr + data).slice(-8192);
396
+ });
397
+ const frames = createInterface({ input: child.stdout });
398
+ this.ownedReaders.push(frames);
399
+ frames.on("line", (line) => {
400
+ try {
401
+ this.frame(JSON.parse(line));
402
+ } catch {
403
+ this.died("Python emitted invalid NDJSON protocol");
404
+ }
405
+ });
406
+ if (process.platform !== "win32") {
407
+ const requests = createInterface({ input: child.stdio[3] });
408
+ this.ownedReaders.push(requests);
409
+ const replies = child.stdio[4];
410
+ replies.on("error", () => {});
411
+ requests.on("line", (line) => {
412
+ let data;
413
+ try {
414
+ data = JSON.parse(line);
415
+ } catch {
416
+ return;
417
+ }
418
+ this.callback(data).then((reply) => {
419
+ if (!replies.destroyed && !replies.writableEnded) replies.write(JSON.stringify(reply) + "\n");
420
+ });
421
+ });
422
+ }
423
+ const result = await this.perform(KERNEL_BOOTSTRAP + `\n__import__('better_snapshot').configure(${JSON.stringify(this.options.snapshotDirectory ?? "")}, ${this.options.snapshotSizeCapBytes ?? 268435456}, ${this.options.snapshotTimeoutMs})`, {
424
+ timeoutMs: this.options.startupTimeoutMs,
425
+ interruptGraceMs: 1e3,
426
+ interruptConfirmMs: this.options.interruptConfirmMs
427
+ });
428
+ if (result.outcome !== "completed" || result.status !== "ok") throw new Error(`OMP Python initialization failed: ${JSON.stringify(result)}`);
429
+ if (this.isDead) throw new Error("Python exited during initialization");
430
+ this.state = "running";
1308
431
  } catch (error) {
1309
- this.cleanupResources();
1310
- this.state = "idle";
432
+ await this.dispose();
1311
433
  throw error;
1312
434
  }
1313
- const bootstrap = await this.executeCell(KERNEL_BOOTSTRAP, {
1314
- timeoutMs: this.options.startupTimeoutMs,
1315
- interruptGraceMs: 1e3,
1316
- interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
1317
- });
1318
- if (bootstrap.outcome !== "completed" || bootstrap.status !== "ok") {
1319
- const detail = bootstrap.outcome === "completed" && bootstrap.cellError ? `${bootstrap.cellError.ename}: ${bootstrap.cellError.evalue}` : "bootstrap cell failed";
1320
- this.cleanupResources();
1321
- this.state = "idle";
1322
- throw new Error(`dashr kernel bootstrap failed: ${detail}`);
1323
- }
1324
- this.state = "running";
1325
- }
1326
- async waitForConnection(path$1) {
1327
- const deadline = Date.now() + this.options.startupTimeoutMs;
1328
- while (Date.now() < deadline) {
1329
- if (this.state === "shutdown") throw new Error(`kernel exited before resolving ports. stderr:\n${this.stderrTail()}`);
1330
- const info = readResolvedConnection(path$1);
1331
- if (info) return info;
1332
- await setTimeout$1(CONNECT_POLL_MS);
1333
- }
1334
- throw new Error(`kernel did not resolve connection ports within ${this.options.startupTimeoutMs}ms. stderr tail:\n${this.stderrTail()}`);
1335
435
  }
1336
- async probeReady() {
1337
- const transport = this.transport;
1338
- if (!transport) throw new Error("kernel channels are not connected");
1339
- const msg = buildMessage("kernel_info_request", {}, this.session, this.options.username);
1340
- const requestMsgId = msg.header.msg_id;
1341
- transport.send("shell", msg);
1342
- if (await transport.waitFor("shell", (incoming) => incoming.header.msg_type === "kernel_info_reply" && incoming.parent_header.msg_id === requestMsgId, this.options.startupTimeoutMs)) return;
1343
- const bridgeTail = transport.stderrTail();
1344
- if (this.state === "shutdown") throw new Error(`kernel exited during startup. kernel stderr:\n${this.stderrTail()}\nbridge stderr:\n${bridgeTail}`);
1345
- throw new Error(`kernel did not answer kernel_info_request within ${this.options.startupTimeoutMs}ms. kernel stderr:\n${this.stderrTail()}\nbridge stderr:\n${bridgeTail}`);
436
+ async callback(data) {
437
+ const identity = record(data) ? {
438
+ id: data.id,
439
+ run: data.run
440
+ } : {};
441
+ const active = this.active;
442
+ if (!record(data) || !active || active.id !== data.run || active.pendingForce || !active.opts.hostRequestHandler) return {
443
+ ...identity,
444
+ ok: false,
445
+ message: "host callback rejected: its cell is no longer active"
446
+ };
447
+ const revoked = {
448
+ ...identity,
449
+ ok: false,
450
+ message: "host callback cancelled: its cell ended"
451
+ };
452
+ return new Promise((resolve$1) => {
453
+ let settled = false;
454
+ const finish = (reply) => {
455
+ if (settled) return;
456
+ settled = true;
457
+ active.rejectCallbacks.delete(reject);
458
+ resolve$1(reply);
459
+ };
460
+ const reject = () => finish(revoked);
461
+ active.rejectCallbacks.add(reject);
462
+ active.opts.hostRequestHandler(data).then((reply) => {
463
+ finish(this.active === active && !active.pendingForce ? {
464
+ ...identity,
465
+ ...reply
466
+ } : revoked);
467
+ }, (error) => finish({
468
+ ...identity,
469
+ ok: false,
470
+ message: String(error)
471
+ }));
472
+ });
1346
473
  }
1347
- /**
1348
- * Execute one cell on the persistent kernel, serialized against all other
1349
- * executions. Resolves with the cell's outcome — never rejects for
1350
- * cell-level failures (program errors, timeouts, aborts, kernel death).
1351
- * @param code - complete cell source.
1352
- * @param opts - budgets, abort signal, and the host-request dispatcher.
1353
- */
1354
- async executeCell(code, opts) {
1355
- const prev = this.executionQueue;
1356
- let releaseQueue;
1357
- this.executionQueue = new Promise((resolve$1) => {
1358
- releaseQueue = resolve$1;
474
+ frame(frame) {
475
+ if (!record(frame) || frame.id !== this.active?.id) return;
476
+ const active = this.active;
477
+ if (frame.type === "started") {
478
+ active.started = true;
479
+ if (active.pendingForce) this.signal("SIGINT");
480
+ } else if (frame.type === "stdout" || frame.type === "stderr") {
481
+ if (typeof frame.data === "string") active.streamText += frame.data;
482
+ } else if (frame.type === "display" || frame.type === "result") {
483
+ if (record(frame.bundle)) {
484
+ const text = frame.bundle["text/plain"] ?? frame.bundle["text/markdown"] ?? frame.bundle["text/html"];
485
+ if (typeof text === "string") active.streamText += `${text}\n`;
486
+ else if (frame.bundle["application/json"] !== void 0) active.streamText += `${JSON.stringify(frame.bundle["application/json"])}\n`;
487
+ else active.streamText += `[Python display: ${Object.keys(frame.bundle).join(", ")}]\n`;
488
+ }
489
+ } else if (frame.type === "error") active.error = {
490
+ ename: String(frame.ename),
491
+ evalue: String(frame.evalue),
492
+ traceback: Array.isArray(frame.traceback) ? frame.traceback.map(String) : []
493
+ };
494
+ else if (frame.type === "done") this.settle(active.pendingForce ? {
495
+ outcome: "forced",
496
+ failure: active.pendingForce,
497
+ streamText: active.streamText
498
+ } : {
499
+ outcome: "completed",
500
+ status: frame.status === "ok" ? "ok" : "error",
501
+ streamText: active.streamText,
502
+ ...active.error ? { cellError: active.error } : {}
1359
503
  });
1360
- await prev;
1361
- try {
1362
- if (this.state === "shutdown" || this.state === "idle") return {
504
+ }
505
+ executeCell(code, opts) {
506
+ const task = this.queue.then(() => {
507
+ if (!this.isRunning) return {
1363
508
  outcome: "forced",
1364
509
  failure: {
1365
510
  kind: "worker-exit",
1366
- message: "kernel has been shut down"
511
+ message: "Python is not running"
1367
512
  },
1368
513
  streamText: ""
1369
514
  };
1370
- return await this.executeInner(code, opts);
1371
- } finally {
1372
- releaseQueue();
1373
- }
515
+ return this.perform(code, opts);
516
+ });
517
+ this.queue = task.catch(() => void 0);
518
+ return task;
1374
519
  }
1375
- executeInner(code, opts) {
1376
- const transport = this.transport;
1377
- if (!transport) return Promise.resolve({
520
+ perform(code, opts) {
521
+ if (opts.signal?.aborted) return Promise.resolve({
1378
522
  outcome: "forced",
1379
523
  failure: {
1380
- kind: "worker-exit",
1381
- message: "kernel channels are not connected"
524
+ kind: "abort",
525
+ message: String(opts.signal.reason)
1382
526
  },
1383
527
  streamText: ""
1384
528
  });
1385
- const msg = buildMessage("execute_request", {
1386
- code,
1387
- silent: false,
1388
- store_history: false,
1389
- user_expressions: {},
1390
- allow_stdin: false,
1391
- stop_on_error: true
1392
- }, this.session, this.options.username);
1393
529
  return new Promise((resolve$1) => {
1394
- const execution = {
1395
- requestMsgId: msg.header.msg_id,
530
+ const active = {
531
+ id: randomUUID(),
1396
532
  opts,
1397
533
  streamText: "",
1398
- status: "ok",
1399
- settled: false,
1400
- resolve: resolve$1
534
+ started: false,
535
+ resolve: resolve$1,
536
+ rejectCallbacks: /* @__PURE__ */ new Set()
1401
537
  };
1402
- this.activeExec = execution;
1403
- const settle = (outcome) => {
1404
- if (execution.settled) return;
1405
- execution.settled = true;
1406
- if (execution.forceTimer) clearTimeout(execution.forceTimer);
1407
- if (execution.timeoutTimer) clearTimeout(execution.timeoutTimer);
1408
- if (execution.alarmTimer) clearTimeout(execution.alarmTimer);
1409
- execution.detachSignal?.();
1410
- if (this.activeExec === execution) this.activeExec = void 0;
1411
- resolve$1(outcome);
1412
- };
1413
- const interruptAndForce = (failure) => {
1414
- if (this.activeExec !== execution) return;
1415
- if (execution.pendingForce === void 0) execution.pendingForce = failure;
1416
- this.sendInterrupt(execution);
1417
- execution.forceTimer = setTimeout(() => {
1418
- settle({
1419
- outcome: "forced",
1420
- failure,
1421
- streamText: execution.streamText
1422
- });
1423
- }, opts.interruptGraceMs);
1424
- execution.forceTimer.unref?.();
1425
- };
1426
- const onAbort = () => {
1427
- interruptAndForce({
538
+ this.active = active;
539
+ active.timeout = setTimeout(() => this.interrupt(active, {
540
+ kind: "timeout",
541
+ message: `Python cell exceeded ${opts.timeoutMs}ms`
542
+ }), opts.timeoutMs);
543
+ if (opts.signal) {
544
+ const signal = opts.signal;
545
+ const abort = () => this.interrupt(active, {
1428
546
  kind: "abort",
1429
- message: String(opts.signal?.reason)
1430
- });
1431
- };
1432
- execution.timeoutTimer = setTimeout(() => {
1433
- interruptAndForce({
1434
- kind: "timeout",
1435
- message: `cell exceeded ${opts.timeoutMs}ms wall budget`
1436
- });
1437
- }, opts.timeoutMs);
1438
- execution.timeoutTimer.unref?.();
1439
- opts.signal?.addEventListener("abort", onAbort, { once: true });
1440
- execution.detachSignal = () => opts.signal?.removeEventListener("abort", onAbort);
1441
- if (opts.signal?.aborted) onAbort();
1442
- try {
1443
- transport.send("shell", msg);
1444
- } catch {
1445
- settle({
1446
- outcome: "forced",
1447
- failure: {
1448
- kind: "worker-exit",
1449
- message: "shell channel send failed"
1450
- },
1451
- streamText: execution.streamText
547
+ message: String(signal.reason)
1452
548
  });
549
+ signal.addEventListener("abort", abort, { once: true });
550
+ active.detach = () => signal.removeEventListener("abort", abort);
1453
551
  }
552
+ this.child?.stdin?.write(JSON.stringify({
553
+ id: active.id,
554
+ code,
555
+ ...this.options.cwd ? { cwd: this.options.cwd } : {}
556
+ }) + "\n");
1454
557
  });
1455
558
  }
1456
- /**
1457
- * Route one relayed message. Only iopub drives executions and comms; shell
1458
- * replies serve the startup probe (via `waitFor`), and control is inbound
1459
- * for host replies.
1460
- */
1461
- handleTransportMessage(channel, incoming) {
1462
- if (channel !== "iopub") return;
1463
- const msgType = incoming.header.msg_type;
1464
- if (msgType === "comm_open" || msgType === "comm_msg" || msgType === "comm_close") {
1465
- this.handleCommMessage(incoming);
1466
- return;
1467
- }
1468
- this.handleExecutionMessage(incoming);
1469
- }
1470
- forceSettleActive(failure) {
1471
- const execution = this.activeExec;
1472
- if (!execution || execution.settled) return;
1473
- execution.settled = true;
1474
- if (execution.forceTimer) clearTimeout(execution.forceTimer);
1475
- if (execution.timeoutTimer) clearTimeout(execution.timeoutTimer);
1476
- if (execution.alarmTimer) clearTimeout(execution.alarmTimer);
1477
- execution.detachSignal?.();
1478
- this.activeExec = void 0;
1479
- execution.resolve({
1480
- outcome: "forced",
1481
- failure,
1482
- streamText: execution.streamText
1483
- });
1484
- }
1485
- handleExecutionMessage(incoming) {
1486
- const execution = this.activeExec;
1487
- if (!execution || execution.settled) return;
1488
- if (incoming.parent_header.msg_id !== execution.requestMsgId) return;
1489
- const msgType = incoming.header.msg_type;
1490
- if (msgType === "stream") {
1491
- const content = incoming.content;
1492
- if (typeof content.text === "string") execution.streamText += content.text;
1493
- } else if (msgType === "error") {
1494
- const content = incoming.content;
1495
- execution.cellError = {
1496
- ename: typeof content.ename === "string" ? content.ename : "Error",
1497
- evalue: typeof content.evalue === "string" ? content.evalue : "",
1498
- traceback: Array.isArray(content.traceback) ? content.traceback : []
1499
- };
1500
- execution.status = "error";
1501
- } else if (msgType === "status") {
1502
- if (incoming.content.execution_state === "idle") this.settleCompleted(execution);
1503
- }
1504
- }
1505
- settleCompleted(execution) {
1506
- if (execution.settled) return;
1507
- execution.settled = true;
1508
- if (execution.forceTimer) clearTimeout(execution.forceTimer);
1509
- if (execution.timeoutTimer) clearTimeout(execution.timeoutTimer);
1510
- if (execution.alarmTimer) clearTimeout(execution.alarmTimer);
1511
- execution.detachSignal?.();
1512
- if (this.activeExec === execution) this.activeExec = void 0;
1513
- if (execution.pendingForce !== void 0) {
1514
- execution.resolve({
1515
- outcome: "forced",
1516
- failure: execution.pendingForce,
1517
- streamText: execution.streamText
1518
- });
1519
- return;
1520
- }
1521
- execution.resolve({
1522
- outcome: "completed",
1523
- status: execution.status,
1524
- streamText: execution.streamText,
1525
- ...execution.cellError ? { cellError: execution.cellError } : {}
1526
- });
1527
- }
1528
- handleCommMessage(incoming) {
1529
- const content = incoming.content;
1530
- const commId = content.comm_id;
1531
- if (typeof commId !== "string") return;
1532
- const msgType = incoming.header.msg_type;
1533
- if (msgType === "comm_close") {
1534
- this.commTargets.delete(commId);
1535
- this.servedComms.delete(commId);
1536
- return;
1537
- }
1538
- if (msgType === "comm_open") {
1539
- const targetName = content.target_name;
1540
- if (typeof targetName === "string") this.commTargets.set(commId, targetName);
1541
- return;
1542
- }
1543
- if (msgType === "comm_msg" && this.commTargets.get(commId) === HOST_COMM_TARGET) this.serveHostRequest(commId, content.data);
1544
- }
1545
- serveHostRequest(commId, data) {
1546
- if (this.servedComms.has(commId)) return;
1547
- this.servedComms.add(commId);
1548
- const handler = this.activeExec?.opts.hostRequestHandler;
1549
- if (!handler) {
1550
- this.replyComm(commId, {
1551
- status: "error",
1552
- error: "no host request handler is active"
1553
- }).catch(() => void 0);
1554
- return;
1555
- }
1556
- if (!isRecord(data)) {
1557
- this.replyComm(commId, {
1558
- status: "error",
1559
- error: "host request payload must be an object"
1560
- }).catch(() => void 0);
1561
- return;
1562
- }
1563
- (async () => {
1564
- try {
1565
- const outcome = await handler(data);
1566
- if (outcome.ok) await this.replyComm(commId, {
1567
- status: "ok",
1568
- result: outcome.result
1569
- });
1570
- else await this.replyComm(commId, {
1571
- status: "error",
1572
- error: outcome.message
1573
- });
1574
- } catch (error) {
1575
- const message = error instanceof Error ? error.message : String(error);
1576
- await this.replyComm(commId, {
1577
- status: "error",
1578
- error: message
1579
- }).catch(() => void 0);
1580
- }
1581
- })().catch(() => void 0);
1582
- }
1583
- async replyComm(commId, data) {
1584
- const transport = this.transport;
1585
- if (!transport) throw new Error("kernel channels are not connected");
1586
- transport.send("control", buildMessage("comm_msg", {
1587
- comm_id: commId,
1588
- data
1589
- }, this.session, this.options.username));
1590
- }
1591
- /**
1592
- * Phase one NOW, phase two on a timer: send the control-channel interrupt,
1593
- * then — after the confirm window, and only if the execution has still not
1594
- * settled — escalate to SIGALRM. The zmq interrupt only lands when the cell
1595
- * yields to the kernel event loop; a busy sync cell ('while True: pass')
1596
- * never does, which is why the escalation exists at all. Sending both
1597
- * channels in the same tick (the M1 design) made SIGALRM the FIRST signal
1598
- * an idle-or-booting kernel saw on timeout/abort: its handler raised
1599
- * KeyboardInterrupt at the top level and the process exited cleanly — a
1600
- * deterministic kill (10/10 same-tick aborts, 8/10 at +1-2ms, 40/40 during
1601
- * cold boot; dev/m2a-report.md §5.1). Two-phase escalation plus the
1602
- * kernel-side busy guard closes that window while keeping the hard-abort
1603
- * contract: a busy loop still breaks, one confirm window later.
1604
- */
1605
- async sendInterrupt(execution) {
1606
- const transport = this.transport;
1607
- if (!transport) return;
559
+ interrupt(active, failure) {
560
+ if (this.active !== active || active.pendingForce) return;
561
+ active.pendingForce = failure;
562
+ for (const revoke of [...active.rejectCallbacks]) revoke();
563
+ if (active.started) this.signal("SIGINT");
564
+ active.confirm = setTimeout(() => {
565
+ if (this.active === active && active.started && active.pendingForce) this.signal("SIGINT");
566
+ }, active.opts.interruptConfirmMs);
567
+ active.force = setTimeout(() => {
568
+ this.dispose();
569
+ }, active.opts.interruptGraceMs);
570
+ }
571
+ settle(outcome) {
572
+ const active = this.active;
573
+ if (!active) return;
574
+ this.active = void 0;
575
+ clearTimeout(active.timeout);
576
+ clearTimeout(active.force);
577
+ clearTimeout(active.confirm);
578
+ active.detach?.();
579
+ for (const revoke of [...active.rejectCallbacks]) revoke();
580
+ active.resolve(outcome);
581
+ }
582
+ signal(signal) {
1608
583
  try {
1609
- transport.interrupt();
584
+ this.child?.kill(signal);
1610
585
  } catch {}
1611
- execution.alarmTimer = setTimeout(() => {
1612
- if (execution.settled) return;
1613
- if (this.kernel?.exitCode === null) try {
1614
- this.kernel.kill("SIGALRM");
1615
- } catch {}
1616
- }, execution.opts.interruptConfirmMs);
1617
- execution.alarmTimer.unref?.();
1618
586
  }
1619
- /**
1620
- * Snapshot the user namespace via an internal dill cell. Requires `dill` in
1621
- * the kernel environment. The cell reports its outcome through a nonce
1622
- * envelope (skipped-for-size vs written); a failed/skipped dump NEVER
1623
- * replaces a previous good snapshot on disk.
1624
- * @param payloadPath - dill payload destination.
1625
- * @param manifestPath - JSON manifest destination (turn, names, python
1626
- * version, venv path, skills, size).
1627
- * @param spec - turn counter, skills list, and the size cap.
1628
- */
1629
- async snapshotState(payloadPath, manifestPath, spec) {
1630
- if (!this.isRunning) return {
1631
- kind: "failed",
1632
- reason: "kernel is not running"
1633
- };
1634
- const sentinel = `__dashr_snapshot_${randomBytes(9).toString("hex")}__`;
1635
- const outcome = await this.executeCell(buildSnapshotCell(payloadPath, manifestPath, spec, sentinel), {
1636
- timeoutMs: this.options.snapshotTimeoutMs,
1637
- interruptGraceMs: 1e3,
1638
- interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
1639
- });
1640
- if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
1641
- kind: "failed",
1642
- reason: "snapshot cell failed"
1643
- };
1644
- return this.parseSnapshotEnvelope(outcome.streamText, sentinel);
1645
- }
1646
- /** Parse one snapshot cell's nonce envelope into its outcome. */
1647
- parseSnapshotEnvelope(streamText, sentinel) {
1648
- const capture = extractStream(streamText, sentinel);
1649
- if (capture.envelope === void 0) return {
1650
- kind: "failed",
1651
- reason: "snapshot cell produced no envelope"
1652
- };
1653
- let envelope;
587
+ killGroup() {
588
+ const pid = this.child?.pid;
589
+ if (pid === void 0) return;
1654
590
  try {
1655
- envelope = JSON.parse(capture.envelope);
1656
- } catch {
1657
- return {
1658
- kind: "failed",
1659
- reason: "snapshot envelope was not valid JSON"
1660
- };
1661
- }
1662
- if (typeof envelope !== "object" || envelope === null) return {
1663
- kind: "failed",
1664
- reason: "snapshot envelope was not an object"
1665
- };
1666
- const record = envelope;
1667
- if (record.ok === true) return {
1668
- kind: "ok",
1669
- sizeBytes: typeof record.sizeBytes === "number" ? record.sizeBytes : 0,
1670
- names: typeof record.names === "number" ? record.names : 0
1671
- };
1672
- if (record.skipped === true) return {
1673
- kind: "skipped",
1674
- reason: typeof record.reason === "string" ? record.reason : "size cap",
1675
- ...typeof record.estimateBytes === "number" ? { estimateBytes: record.estimateBytes } : {},
1676
- ...typeof record.sizeBytes === "number" ? { sizeBytes: record.sizeBytes } : {},
1677
- capBytes: typeof record.capBytes === "number" ? record.capBytes : 0
1678
- };
1679
- return {
1680
- kind: "failed",
1681
- reason: typeof record.reason === "string" ? record.reason : "snapshot failed"
1682
- };
591
+ if (process.platform !== "win32") process.kill(-pid, "SIGKILL");
592
+ else this.child?.kill("SIGKILL");
593
+ } catch {}
1683
594
  }
1684
- /**
1685
- * Restore a dill snapshot into the user namespace via an internal cell. The
1686
- * kernel validates the manifest (python version, interpreter identity,
1687
- * skills) and reports restored vs degraded through a nonce envelope; a
1688
- * degraded restore leaves the namespace empty.
1689
- * @param payloadPath - dill payload source.
1690
- * @param manifestPath - JSON manifest source.
1691
- */
1692
- async restoreState(payloadPath, manifestPath) {
1693
- if (!this.isRunning) return {
1694
- kind: "degraded",
1695
- reason: "kernel is not running"
595
+ died(message) {
596
+ this.state = "shutdown";
597
+ this.deathFailure ??= {
598
+ kind: "worker-exit",
599
+ message
1696
600
  };
1697
- const sentinel = `__dashr_restore_${randomBytes(9).toString("hex")}__`;
1698
- const outcome = await this.executeCell(buildRestoreCell(payloadPath, manifestPath, sentinel), {
601
+ this.killGroup();
602
+ this.dispose();
603
+ }
604
+ async internal(code, sentinel) {
605
+ const result = await this.executeCell(code, {
1699
606
  timeoutMs: this.options.snapshotTimeoutMs,
1700
607
  interruptGraceMs: 1e3,
1701
- interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
608
+ interruptConfirmMs: this.options.interruptConfirmMs
1702
609
  });
1703
- if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
1704
- kind: "degraded",
1705
- reason: "restore cell failed"
1706
- };
1707
- const capture = extractStream(outcome.streamText, sentinel);
1708
- if (capture.envelope === void 0) return {
1709
- kind: "degraded",
1710
- reason: "restore cell produced no envelope"
1711
- };
1712
- let envelope;
610
+ if (result.outcome !== "completed" || result.status !== "ok") return void 0;
1713
611
  try {
1714
- envelope = JSON.parse(capture.envelope);
612
+ const envelope = JSON.parse(extractStream(result.streamText, sentinel).envelope ?? "");
613
+ return record(envelope) ? envelope : void 0;
1715
614
  } catch {
1716
- return {
1717
- kind: "degraded",
1718
- reason: "restore envelope was not valid JSON"
1719
- };
615
+ return;
1720
616
  }
1721
- if (typeof envelope !== "object" || envelope === null) return {
1722
- kind: "degraded",
1723
- reason: "restore envelope was not an object"
1724
- };
1725
- const record = envelope;
1726
- if (record.ok === true) return {
1727
- kind: "restored",
1728
- count: typeof record.restored === "number" ? record.restored : 0
1729
- };
1730
- return {
1731
- kind: "degraded",
1732
- reason: typeof record.reason === "string" ? record.reason : "snapshot not replayable"
1733
- };
1734
617
  }
1735
- /**
1736
- * Read one user-namespace variable by name (or list the namespace's user
1737
- * names when `name` is `null`) via an internal cell. JSON-serializable
1738
- * values cross as `kind: 'json'` text; anything else falls back to `repr`
1739
- * text (`kind: 'repr'`) so a non-JSON variable is still readable. A missing
1740
- * name reports `kind: 'missing'` rather than failing the cell.
1741
- * @param name - the exact variable name, or `null` to list namespace names.
1742
- */
1743
618
  async queryVar(name$1) {
1744
- if (!this.isRunning) return {
1745
- kind: "failed",
1746
- reason: "kernel is not running"
1747
- };
1748
- const sentinel = `__dashr_query_${randomBytes(9).toString("hex")}__`;
1749
- const outcome = await this.executeCell(buildQueryVarCell(name$1, sentinel), {
1750
- timeoutMs: this.options.snapshotTimeoutMs,
1751
- interruptGraceMs: 1e3,
1752
- interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
1753
- });
1754
- if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
1755
- kind: "failed",
1756
- reason: "query cell failed"
1757
- };
1758
- const capture = extractStream(outcome.streamText, sentinel);
1759
- if (capture.envelope === void 0) return {
1760
- kind: "failed",
1761
- reason: "query cell produced no envelope"
1762
- };
1763
- let envelope;
1764
- try {
1765
- envelope = JSON.parse(capture.envelope);
1766
- } catch {
1767
- return {
1768
- kind: "failed",
1769
- reason: "query envelope was not valid JSON"
1770
- };
1771
- }
1772
- if (!isRecord(envelope)) return {
1773
- kind: "failed",
1774
- reason: "query envelope was not an object"
1775
- };
1776
- if (envelope.ok !== true) return {
1777
- kind: "failed",
1778
- reason: typeof envelope.reason === "string" ? envelope.reason : "query failed"
1779
- };
1780
- const kind = envelope.kind;
1781
- if (kind === "names") return {
619
+ const sentinel = `__better_query_${randomBytes(12).toString("hex")}__`;
620
+ const reply = await this.internal(buildQueryVarCell(name$1, sentinel), sentinel);
621
+ if (reply?.kind === "names" && Array.isArray(reply.names)) return {
1782
622
  kind: "names",
1783
- names: Array.isArray(envelope.names) ? envelope.names.filter((name$2) => typeof name$2 === "string") : []
623
+ names: reply.names.filter((name$2) => typeof name$2 === "string")
1784
624
  };
1785
- if (kind === "missing") return { kind: "missing" };
1786
- if (kind === "json" || kind === "repr") return typeof envelope.text === "string" ? {
1787
- kind,
1788
- text: envelope.text
1789
- } : {
1790
- kind: "failed",
1791
- reason: "query envelope carried no text"
625
+ if (reply?.kind === "missing") return { kind: "missing" };
626
+ if ((reply?.kind === "json" || reply?.kind === "repr") && typeof reply.text === "string") return {
627
+ kind: reply.kind,
628
+ text: reply.text
1792
629
  };
1793
630
  return {
1794
631
  kind: "failed",
1795
- reason: `unknown query kind ${JSON.stringify(kind)}`
632
+ reason: "Python variable query failed"
1796
633
  };
1797
634
  }
1798
- /**
1799
- * Assign one pre-serialized JSON value into the user namespace under
1800
- * `name` via an internal cell. The host already validated the name and the
1801
- * value; the cell only decodes and binds. Uses the same busy guard and
1802
- * internal-cell budget as the snapshot cells.
1803
- * @param name - the validated identifier to assign under.
1804
- * @param valueJson - the lossless-JSON text to decode and bind.
1805
- */
1806
635
  async setVar(name$1, valueJson) {
1807
- if (!this.isRunning) return {
636
+ const sentinel = `__better_set_${randomBytes(12).toString("hex")}__`;
637
+ return (await this.internal(buildSetVarCell(name$1, valueJson, sentinel), sentinel))?.ok === true ? { kind: "ok" } : {
1808
638
  kind: "failed",
1809
- reason: "kernel is not running"
639
+ reason: "Python variable assignment failed"
1810
640
  };
1811
- const sentinel = `__dashr_set_${randomBytes(9).toString("hex")}__`;
1812
- const outcome = await this.executeCell(buildSetVarCell(name$1, valueJson, sentinel), {
1813
- timeoutMs: this.options.snapshotTimeoutMs,
1814
- interruptGraceMs: 1e3,
1815
- interruptConfirmMs: Math.min(this.options.interruptConfirmMs, 500)
1816
- });
1817
- if (outcome.outcome !== "completed" || outcome.status !== "ok") return {
1818
- kind: "failed",
1819
- reason: "set cell failed"
641
+ }
642
+ async snapshotState(payloadPath, manifestPath, spec) {
643
+ const sentinel = `__better_save_${randomBytes(12).toString("hex")}__`;
644
+ const reply = await this.internal(buildSnapshotCell(payloadPath, manifestPath, spec, sentinel), sentinel);
645
+ if (reply?.kind === "ok" && Array.isArray(reply.names) && typeof reply.sizeBytes === "number" && typeof reply.turn === "number" && record(reply.omitted)) return {
646
+ kind: "ok",
647
+ sizeBytes: reply.sizeBytes,
648
+ names: reply.names,
649
+ omitted: reply.omitted,
650
+ turn: reply.turn,
651
+ ...typeof reply.warning === "string" ? { warning: reply.warning } : {}
1820
652
  };
1821
- const capture = extractStream(outcome.streamText, sentinel);
1822
- if (capture.envelope === void 0) return {
1823
- kind: "failed",
1824
- reason: "set cell produced no envelope"
653
+ if (reply?.kind === "skipped") return {
654
+ kind: "skipped",
655
+ reason: String(reply.reason),
656
+ capBytes: spec.sizeCapBytes
1825
657
  };
1826
- let envelope;
1827
- try {
1828
- envelope = JSON.parse(capture.envelope);
1829
- } catch {
1830
- return {
1831
- kind: "failed",
1832
- reason: "set envelope was not valid JSON"
1833
- };
1834
- }
1835
- if (!isRecord(envelope) || envelope.ok !== true) return {
658
+ return {
1836
659
  kind: "failed",
1837
- reason: isRecord(envelope) && typeof envelope.reason === "string" ? envelope.reason : "set failed"
660
+ reason: typeof reply?.reason === "string" ? reply.reason : "checkpoint helper timed out, failed, or exited; commit status could not be confirmed"
1838
661
  };
1839
- return { kind: "ok" };
1840
662
  }
1841
- onKernelDeath() {
1842
- this.state = "shutdown";
1843
- this.forceSettleActive({
1844
- kind: "worker-exit",
1845
- message: `kernel process died. stderr tail:\n${this.stderrTail()}`
1846
- });
1847
- this.cleanupResources();
663
+ async restoreState(payloadPath, manifestPath, bindings = []) {
664
+ const sentinel = `__better_restore_${randomBytes(12).toString("hex")}__`;
665
+ const reply = await this.internal(buildRestoreCell(payloadPath, manifestPath, sentinel, bindings), sentinel);
666
+ if (reply?.kind === "restored" && typeof reply.count === "number" && typeof reply.turn === "number" && record(reply.omitted)) return {
667
+ kind: "restored",
668
+ count: reply.count,
669
+ turn: reply.turn,
670
+ omitted: reply.omitted
671
+ };
672
+ return {
673
+ kind: "degraded",
674
+ reason: typeof reply?.reason === "string" ? reply.reason : "checkpoint restore helper failed or timed out"
675
+ };
1848
676
  }
1849
- cleanupResources() {
1850
- this.transport?.dispose();
1851
- this.transport = void 0;
1852
- this.connection = void 0;
1853
- try {
1854
- this.kernel?.kill("SIGKILL");
1855
- } catch {}
1856
- this.kernel = void 0;
1857
- if (this.tempDir) try {
1858
- rmSync(this.tempDir, {
1859
- recursive: true,
1860
- force: true
1861
- });
1862
- } catch {}
1863
- this.tempDir = void 0;
677
+ dispose() {
678
+ this.disposePromise ??= this.doDispose();
679
+ return this.disposePromise;
1864
680
  }
1865
- /** Graceful teardown: shutdown_request, then SIGKILL after the dispose budget. */
1866
- async dispose() {
1867
- if (this.state === "shutdown") {
1868
- this.cleanupResources();
681
+ async doDispose() {
682
+ this.state = "shutdown";
683
+ const child = this.child;
684
+ if (!child) {
685
+ this.callbackServer?.closeAllConnections();
686
+ this.callbackServer?.close();
1869
687
  return;
1870
688
  }
1871
- this.state = "shutdown";
1872
- try {
1873
- if (this.transport) {
1874
- this.transport.send("control", buildMessage("shutdown_request", { restart: false }, this.session, this.options.username));
1875
- await setTimeout$1(SHUTDOWN_DRAIN_MS);
1876
- }
1877
- } catch {}
1878
- const kernel = this.kernel;
1879
- this.cleanupResources();
1880
- if (kernel && kernel.exitCode === null && kernel.signalCode === null) await new Promise((resolve$1) => {
1881
- const timer = setTimeout(() => {
1882
- kernel.removeListener("exit", onExit);
1883
- try {
1884
- kernel.kill("SIGKILL");
1885
- } catch {}
1886
- resolve$1();
1887
- }, this.options.disposeTimeoutMs);
1888
- timer.unref?.();
1889
- const onExit = () => {
689
+ if (!this.active && child.exitCode === null && child.signalCode === null) {
690
+ child.stdin?.end(JSON.stringify({ type: "exit" }) + "\n");
691
+ await this.waitClose(Math.min(200, this.options.disposeTimeoutMs));
692
+ }
693
+ this.killGroup();
694
+ await this.waitClose(this.options.disposeTimeoutMs);
695
+ if (this.active) this.settle({
696
+ outcome: "forced",
697
+ failure: this.active.pendingForce ?? this.deathFailure ?? {
698
+ kind: "worker-exit",
699
+ message: "Python disposed during cell execution"
700
+ },
701
+ streamText: this.active.streamText
702
+ });
703
+ for (const reader of this.ownedReaders) reader.close();
704
+ for (const stream of child.stdio) stream?.destroy();
705
+ this.callbackServer?.closeAllConnections();
706
+ this.callbackServer?.close();
707
+ this.child = void 0;
708
+ }
709
+ async waitClose(ms) {
710
+ if (!this.closePromise) return;
711
+ await new Promise((resolve$1) => {
712
+ const timer = setTimeout(resolve$1, ms);
713
+ this.closePromise.then(() => {
1890
714
  clearTimeout(timer);
1891
715
  resolve$1();
1892
- };
1893
- kernel.once("exit", onExit);
716
+ });
1894
717
  });
1895
718
  }
1896
719
  };
@@ -1898,15 +721,8 @@ var IpyKernelBridge = class {
1898
721
  //#endregion
1899
722
  //#region src/runtime.ts
1900
723
  const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/;
1901
- /**
1902
- * Names the kernel-side shim owns (`_dashr`/`__dashr` prefixed, any case —
1903
- * bootstrap helpers plus the per-run `__dashr_completion__` and
1904
- * `__dashr_injected__`), refused as binding globals on top of the seam's
1905
- * shared set.
1906
- */
1907
- const KERNEL_OWNED_NAME = /^_+dashr/i;
1908
- /** Manifest format version; a snapshot written under a different version is not replayable. */
1909
- const SNAPSHOT_FORMAT = 1;
724
+ /** Runner internals and legacy reserved shim prefix are never injectable variables. */
725
+ const KERNEL_OWNED_NAME = /^(?:_+dashr|__omp_)/i;
1910
726
  function messageOf(error) {
1911
727
  return error instanceof Error ? error.message : String(error);
1912
728
  }
@@ -1931,7 +747,7 @@ function keyDirectoryName(principal) {
1931
747
  */
1932
748
  var DashrRuntime = class extends ReplRuntime {
1933
749
  static Config = Schema.object({
1934
- python: Schema.string().default("python3"),
750
+ python: Schema.string(),
1935
751
  startupTimeoutMs: Schema.number().default(3e4),
1936
752
  runTimeoutMs: Schema.number().default(12e4),
1937
753
  interruptGraceMs: Schema.number().default(2e3),
@@ -1941,9 +757,6 @@ var DashrRuntime = class extends ReplRuntime {
1941
757
  maxOutputBytes: Schema.number().default(67108864),
1942
758
  snapshotDir: Schema.string(),
1943
759
  snapshotSizeCapBytes: Schema.number().default(268435456),
1944
- kernelEnvDir: Schema.string(),
1945
- kernelPythonVersion: Schema.string(),
1946
- kernelAutoInstall: Schema.boolean().default(true),
1947
760
  username: Schema.string().default("dashr")
1948
761
  });
1949
762
  language = "python";
@@ -1953,11 +766,14 @@ var DashrRuntime = class extends ReplRuntime {
1953
766
  /** One entry per session principal that has run code (lazy — never pre-seeded). */
1954
767
  kernels = /* @__PURE__ */ new Map();
1955
768
  disposed = false;
1956
- /** Lazily-resolved (and, when managed, provisioned) kernel interpreter. */
1957
- kernelEnvPromise;
769
+ transactions = /* @__PURE__ */ new Map();
1958
770
  constructor(ctx, config) {
1959
771
  super(ctx);
1960
- this.config = config;
772
+ const profile = ctx.get("profileContext");
773
+ this.config = {
774
+ ...config,
775
+ snapshotDir: config.snapshotDir ?? (profile ? join(profile.home, "better-dsh", "python-repl", createHash("sha256").update(profile.dir).digest("hex")) : void 0)
776
+ };
1961
777
  for (const key of [
1962
778
  "startupTimeoutMs",
1963
779
  "runTimeoutMs",
@@ -1997,9 +813,21 @@ var DashrRuntime = class extends ReplRuntime {
1997
813
  * @param request - the program, its bindings, and the abort signal.
1998
814
  * @returns the run's outcome per the seam contract.
1999
815
  */
2000
- async run(request) {
816
+ transaction(key, operation) {
817
+ const result = (this.transactions.get(key) ?? Promise.resolve()).catch(() => void 0).then(operation);
818
+ this.transactions.set(key, result);
819
+ result.finally(() => {
820
+ if (this.transactions.get(key) === result) this.transactions.delete(key);
821
+ }).catch(() => void 0);
822
+ return result;
823
+ }
824
+ run(request) {
825
+ return this.transaction(request.principal || AGENTLESS_KEY, () => this.runTransaction(request));
826
+ }
827
+ async runTransaction(request) {
2001
828
  if (this.disposed) throw new Error("dashr-repl: run() after disposal");
2002
829
  const bindings = this.validateBindings(request);
830
+ const bindingNames = request.bindings.flatMap((binding) => [binding.global, ...binding.errorClass ? [binding.errorClass.name] : []]);
2003
831
  if (request.signal?.aborted) return {
2004
832
  logs: [],
2005
833
  error: {
@@ -2012,7 +840,7 @@ var DashrRuntime = class extends ReplRuntime {
2012
840
  const dead = this.kernels.get(key);
2013
841
  if (dead?.bridge.isDead) {
2014
842
  this.kernels.delete(key);
2015
- if (dead.ranCells) return await this.reviveAfterDeath(key, dead);
843
+ if (dead.ranCells) return await this.reviveAfterDeath(key, dead, false, bindingNames);
2016
844
  }
2017
845
  let entry;
2018
846
  try {
@@ -2025,8 +853,8 @@ var DashrRuntime = class extends ReplRuntime {
2025
853
  }
2026
854
  if (!entry.restoreAttempted) {
2027
855
  entry.restoreAttempted = true;
2028
- const restore = await this.restoreEntry(entry);
2029
- if (restore.kind === "restored") entry.pendingNotice = `namespace restored from the turn-${restore.turn} snapshot`;
856
+ const restore = await this.restoreEntry(entry, bindingNames);
857
+ if (restore.kind === "restored") entry.pendingNotice = `namespace restored from the turn-${restore.turn} snapshot${this.omissionNotice(restore.omitted)}`;
2030
858
  else if (restore.kind === "degraded") entry.pendingNotice = `snapshot not replayable (${restore.reason}); started from an EMPTY namespace — re-create any state you need`;
2031
859
  }
2032
860
  const kernel = entry.bridge;
@@ -2049,17 +877,13 @@ var DashrRuntime = class extends ReplRuntime {
2049
877
  entry.turn += 1;
2050
878
  } else if (outcome.outcome === "forced" && outcome.failure.kind === "worker-exit" && kernel.isDead && !this.disposed) {
2051
879
  if (this.kernels.get(key) === entry) this.kernels.delete(key);
2052
- if (entry.ranCells) return await this.reviveAfterDeath(key, entry);
880
+ const revived = await this.reviveAfterDeath(key, entry, true, bindingNames);
881
+ return finalizeOutput(extractStream(outcome.streamText, sentinel).logs, revived.value, revived.error, this.config.maxOutputBytes);
2053
882
  }
2054
883
  let result = this.assembleResult(outcome, sentinel);
2055
884
  if (outcome.outcome === "completed") {
2056
885
  const snapshot = await this.snapshotTurnEnd(entry);
2057
- const notices = [];
2058
- if (snapshot.skipped && !entry.warnedSnapshotSkip) {
2059
- entry.warnedSnapshotSkip = true;
2060
- const detail = snapshot.estimateBytes !== void 0 ? `estimated ${snapshot.estimateBytes} bytes` : `${snapshot.sizeBytes ?? "unknown"} bytes serialized`;
2061
- notices.push(`[dashr] namespace snapshot skipped: ${detail} exceeds snapshotSizeCapBytes (${this.config.snapshotSizeCapBytes}) — later turn-end snapshots are skipped while the namespace stays this large`);
2062
- }
886
+ const notices = snapshot ? [snapshot] : [];
2063
887
  if (entry.pendingNotice) {
2064
888
  notices.push(`[dashr] ${entry.pendingNotice}`);
2065
889
  entry.pendingNotice = void 0;
@@ -2079,7 +903,10 @@ var DashrRuntime = class extends ReplRuntime {
2079
903
  * @param name - the variable name, or empty to list namespace names.
2080
904
  * @param principal - the session key (absent → the shared default).
2081
905
  */
2082
- async queryVar(name$1, principal) {
906
+ queryVar(name$1, principal) {
907
+ return this.transaction(principal || AGENTLESS_KEY, () => this.queryTransaction(name$1, principal));
908
+ }
909
+ async queryTransaction(name$1, principal) {
2083
910
  if (this.disposed) throw new Error("dashr-repl: queryVar() after disposal");
2084
911
  const key = principal && principal.length > 0 ? principal : AGENTLESS_KEY;
2085
912
  const bridge = this.kernels.get(key)?.bridge;
@@ -2115,12 +942,15 @@ var DashrRuntime = class extends ReplRuntime {
2115
942
  * @param value - the lossless-JSON value to bind.
2116
943
  * @param principal - the session key (absent → the shared default).
2117
944
  */
2118
- async setVar(name$1, value, principal) {
945
+ setVar(name$1, value, principal) {
946
+ return this.transaction(principal || AGENTLESS_KEY, () => this.setTransaction(name$1, value, principal));
947
+ }
948
+ async setTransaction(name$1, value, principal) {
2119
949
  if (this.disposed) throw new Error("dashr-repl: setVar() after disposal");
2120
950
  const trimmed = name$1.trim();
2121
951
  if (trimmed.length === 0) throw new Error("dashr-repl: setVar() requires a non-empty variable name");
2122
952
  if (!IDENTIFIER.test(trimmed)) throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is not a usable identifier`);
2123
- if (KERNEL_OWNED_NAME.test(trimmed)) throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is reserved by the kernel`);
953
+ if (KERNEL_OWNED_NAME.test(trimmed) || RESERVED_BINDING_GLOBALS.has(trimmed) || trimmed === "__doc__") throw new Error(`dashr-repl: setVar() name ${JSON.stringify(trimmed)} is reserved by the kernel`);
2124
954
  const jsonValue = snapshotJsonValue(value);
2125
955
  if (jsonValue === void 0) throw new Error("dashr-repl: setVar() value must be lossless JSON");
2126
956
  const key = principal && principal.length > 0 ? principal : AGENTLESS_KEY;
@@ -2128,6 +958,9 @@ var DashrRuntime = class extends ReplRuntime {
2128
958
  if (!bridge || !bridge.isRunning) throw new Error("dashr-repl: setVar() requires a live kernel for this session — run a cell first");
2129
959
  const outcome = await bridge.setVar(trimmed, JSON.stringify(jsonValue));
2130
960
  if (outcome.kind === "failed") throw new Error(`dashr-repl: setVar(${JSON.stringify(trimmed)}) failed: ${outcome.reason}`);
961
+ const entry = this.kernels.get(key);
962
+ const notice = await this.snapshotTurnEnd(entry);
963
+ if (notice) entry.pendingNotice = [entry.pendingNotice, notice].filter(Boolean).join("; ");
2131
964
  }
2132
965
  /** Map one cell outcome onto the seam's result shape under the output ledger. */
2133
966
  assembleResult(outcome, sentinel) {
@@ -2193,8 +1026,8 @@ var DashrRuntime = class extends ReplRuntime {
2193
1026
  ok: false,
2194
1027
  message: "host request must be a binding.call with global and name"
2195
1028
  };
2196
- const record = bindings.get(data.global)?.functions;
2197
- const fn = record && Object.hasOwn(record, data.name) ? record[data.name] : void 0;
1029
+ const record$1 = bindings.get(data.global)?.functions;
1030
+ const fn = record$1 && Object.hasOwn(record$1, data.name) ? record$1[data.name] : void 0;
2198
1031
  if (typeof fn !== "function") return {
2199
1032
  ok: false,
2200
1033
  message: `unknown binding ${JSON.stringify(`${data.global}.${data.name}`)}`
@@ -2223,23 +1056,6 @@ var DashrRuntime = class extends ReplRuntime {
2223
1056
  result: value
2224
1057
  };
2225
1058
  }
2226
- /** Resolve (and provision, when managed) the kernel interpreter once per runtime. */
2227
- resolveKernelPython() {
2228
- this.kernelEnvPromise ??= resolveKernelEnv({
2229
- python: this.config.python,
2230
- venvDir: this.config.kernelEnvDir,
2231
- pythonVersion: this.config.kernelPythonVersion,
2232
- autoInstall: this.config.kernelAutoInstall,
2233
- log: (level, message) => {
2234
- if (level === "warn") this.logger.warn(message);
2235
- else this.logger.info(message);
2236
- }
2237
- }).catch((error) => {
2238
- this.kernelEnvPromise = void 0;
2239
- throw error;
2240
- });
2241
- return this.kernelEnvPromise;
2242
- }
2243
1059
  /**
2244
1060
  * Spawn-or-reuse the kernel for one key. The lazy map guarantees one
2245
1061
  * entry is created here and nowhere else, so a key that never runs never
@@ -2251,150 +1067,129 @@ var DashrRuntime = class extends ReplRuntime {
2251
1067
  let entry = this.kernels.get(key);
2252
1068
  if (!entry) {
2253
1069
  const kernelCwd = cwd && existsSync(cwd) ? cwd : void 0;
1070
+ const environment = await resolveKernelEnv({
1071
+ python: this.config.python,
1072
+ cwd: kernelCwd
1073
+ });
2254
1074
  entry = {
2255
1075
  key,
2256
1076
  cwd: kernelCwd,
2257
1077
  bridge: new IpyKernelBridge({
2258
- python: (await this.resolveKernelPython()).python,
1078
+ python: environment.python,
1079
+ env: environment.env,
2259
1080
  ...kernelCwd ? { cwd: kernelCwd } : {},
2260
1081
  startupTimeoutMs: this.config.startupTimeoutMs,
2261
1082
  disposeTimeoutMs: this.config.disposeTimeoutMs,
2262
1083
  interruptConfirmMs: this.config.interruptConfirmMs,
2263
1084
  snapshotTimeoutMs: this.config.snapshotTimeoutMs,
2264
- username: this.config.username
1085
+ username: this.config.username,
1086
+ snapshotDirectory: this.config.snapshotDir ? join(this.config.snapshotDir, keyDirectoryName(key)) : void 0,
1087
+ snapshotSizeCapBytes: this.config.snapshotSizeCapBytes
2265
1088
  }),
2266
1089
  ranCells: false,
2267
1090
  turn: 0
2268
1091
  };
2269
1092
  this.kernels.set(key, entry);
2270
1093
  }
1094
+ if (this.disposed) {
1095
+ await entry.bridge.dispose();
1096
+ throw new Error("runtime disposed during startup");
1097
+ }
2271
1098
  await entry.bridge.start();
2272
1099
  return entry;
2273
1100
  }
2274
- /**
2275
- * Restore a key's on-disk snapshot into a freshly booted kernel, when one
2276
- * exists and its manifest is plausible. Called once per entry boot. The
2277
- * kernel-side restore cell performs the authoritative environment checks
2278
- * (python version, interpreter identity, skills); this method only gates on
2279
- * the manifest the host can read cheaply, then records the turn so the
2280
- * first-run notice and the revive chain can name it.
2281
- */
2282
- async restoreEntry(entry) {
1101
+ /** Restore once before user admission; any degraded load retires that child. */
1102
+ async restoreEntry(entry, bindingNames) {
2283
1103
  if (!this.config.snapshotDir) return { kind: "none" };
2284
1104
  const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
2285
- const payloadPath = join(dir, "state.dill");
2286
1105
  const manifestPath = join(dir, "manifest.json");
2287
- const manifest = this.readSnapshotManifest(entry.key);
2288
- if (manifest === void 0) return { kind: "none" };
2289
- if (manifest.snapshotFormat !== SNAPSHOT_FORMAT) return {
2290
- kind: "degraded",
2291
- reason: `snapshot format ${String(manifest.snapshotFormat)} is not replayable`
2292
- };
2293
- if (manifest.skipped === true) return { kind: "none" };
2294
- if (!existsSync(payloadPath)) return {
2295
- kind: "degraded",
2296
- reason: "snapshot payload is missing"
2297
- };
2298
- const restored = await entry.bridge.restoreState(payloadPath, manifestPath);
1106
+ if (!existsSync(manifestPath)) return { kind: "none" };
1107
+ const restored = await entry.bridge.restoreState(join(dir, "state.dill"), manifestPath, bindingNames);
2299
1108
  if (restored.kind === "restored") {
2300
- entry.turn = typeof manifest.turn === "number" && Number.isSafeInteger(manifest.turn) && manifest.turn >= 0 ? manifest.turn : 0;
2301
- entry.lastSnapshotTurn = entry.turn;
1109
+ entry.turn = restored.turn;
1110
+ entry.lastSnapshotTurn = restored.turn;
2302
1111
  entry.ranCells = true;
2303
1112
  return {
2304
1113
  kind: "restored",
2305
- turn: entry.turn
1114
+ turn: restored.turn,
1115
+ omitted: restored.omitted
2306
1116
  };
2307
1117
  }
1118
+ await entry.bridge.dispose();
1119
+ this.kernels.delete(entry.key);
1120
+ entry.bridge = (await this.ensureKernel(entry.key, entry.cwd)).bridge;
1121
+ this.kernels.set(entry.key, entry);
2308
1122
  return {
2309
1123
  kind: "degraded",
2310
1124
  reason: restored.reason
2311
1125
  };
2312
1126
  }
2313
- /** Read the per-key manifest's host-relevant fields; absent when unreadable/missing. */
2314
- readSnapshotManifest(key) {
2315
- if (!this.config.snapshotDir) return void 0;
2316
- try {
2317
- return JSON.parse(readFileSync(join(this.config.snapshotDir, keyDirectoryName(key), "manifest.json"), "utf8"));
2318
- } catch {
2319
- return;
2320
- }
1127
+ omissionNotice(omitted) {
1128
+ const entries = Object.entries(omitted);
1129
+ return entries.length ? `; partial coverage, omitted ${entries.map(([name$1, reason]) => `${name$1} (${reason})`).join(", ")}` : "";
2321
1130
  }
2322
- /**
2323
- * Revive one session's kernel after death: respawn a fresh kernel, restore
2324
- * the nearest replayable snapshot onto it (the normal restore path), and
2325
- * return the death-observing run's explicit error naming the turn. When no
2326
- * replayable snapshot exists the message falls back to M3-A's fresh-empty
2327
- * contract. The run itself does NOT execute — an error that lies about a
2328
- * NameError costs the model more than one dropped cell (blueprint §8.3).
2329
- * @param key - the run principal.
2330
- * @param dead - the entry that died (its `turn` is the last completed turn).
2331
- */
2332
- async reviveAfterDeath(key, dead) {
1131
+ /** Restore a fresh child and report whether the observing call had started. */
1132
+ async reviveAfterDeath(key, dead, started, bindingNames) {
1133
+ const execution = started ? "This cell may have executed partially, including external side effects; inspect their state before choosing any retry. No user code was replayed." : "This observing call did not execute. No user code was replayed.";
2333
1134
  let entry;
2334
1135
  try {
2335
1136
  entry = await this.ensureKernel(key, dead.cwd);
2336
1137
  } catch (error) {
2337
1138
  return finalizeOutput([], void 0, {
2338
1139
  kind: "worker-exit",
2339
- message: `kernel died and respawn failed (${messageOf(error)}); the next run will retry a fresh kernel`
1140
+ message: `kernel died and respawn failed (${messageOf(error)}); the next run will retry a fresh kernel. ${execution}`
2340
1141
  }, this.config.maxOutputBytes);
2341
1142
  }
2342
1143
  entry.restoreAttempted = true;
2343
- const restore = await this.restoreEntry(entry);
2344
- const observingTurn = dead.turn + 1;
2345
- if (restore.kind === "restored") {
2346
- const lost = observingTurn - restore.turn;
2347
- const rounds = lost === 1 ? "round" : "rounds";
2348
- return finalizeOutput([], void 0, {
2349
- kind: "worker-exit",
2350
- message: `the kernel for this session died; its namespace was restored from the turn-${restore.turn} snapshot — the last ${lost} ${rounds} of variable operations must be replayed. This run did not execute.`
2351
- }, this.config.maxOutputBytes);
2352
- }
1144
+ const restore = await this.restoreEntry(entry, bindingNames);
1145
+ entry.turn = Math.max(entry.turn, dead.turn);
1146
+ if (restore.kind === "restored") return finalizeOutput([], void 0, {
1147
+ kind: "worker-exit",
1148
+ message: `the kernel for this session died; its namespace was restored from the turn-${restore.turn} snapshot${this.omissionNotice(restore.omitted)}. Changes after that checkpoint may be lost. ${execution}`
1149
+ }, this.config.maxOutputBytes);
2353
1150
  return finalizeOutput([], void 0, {
2354
1151
  kind: "worker-exit",
2355
- message: `the kernel for this session died and no replayable snapshot was available${restore.kind === "degraded" ? ` (${restore.reason})` : ""}; the next run starts a fresh kernel with an EMPTY namespace — re-create any state you still need. This run did not execute.`
1152
+ message: `the kernel for this session died and no usable snapshot was available${restore.kind === "degraded" ? ` (${restore.reason})` : ""}; the next run uses an EMPTY namespace. ${execution}`
2356
1153
  }, this.config.maxOutputBytes);
2357
1154
  }
2358
- /**
2359
- * Write one key's turn-end snapshot, when a snapshot directory is
2360
- * configured. A size-cap skip is reported (so the run can warn the model
2361
- * once); other failures log without disturbing the run.
2362
- */
1155
+ /** Save completed mutations and expose partial/failed coverage within output limits. */
2363
1156
  async snapshotTurnEnd(entry) {
2364
- if (!this.config.snapshotDir) return { skipped: false };
2365
- const kernel = entry.bridge;
2366
- if (!kernel.isRunning) return { skipped: false };
1157
+ if (!this.config.snapshotDir || !entry.bridge.isRunning || this.disposed) return;
2367
1158
  const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
2368
- mkdirSync(dir, { recursive: true });
2369
1159
  const spec = {
2370
1160
  turn: entry.turn,
2371
1161
  skills: [],
2372
1162
  sizeCapBytes: this.config.snapshotSizeCapBytes
2373
1163
  };
2374
- const outcome = await kernel.snapshotState(join(dir, "state.dill"), join(dir, "manifest.json"), spec);
1164
+ let outcome;
1165
+ try {
1166
+ mkdirSync(dir, {
1167
+ recursive: true,
1168
+ mode: 448
1169
+ });
1170
+ outcome = await entry.bridge.snapshotState(join(dir, "state.dill"), join(dir, "manifest.json"), spec);
1171
+ } catch (error) {
1172
+ outcome = {
1173
+ kind: "failed",
1174
+ reason: messageOf(error)
1175
+ };
1176
+ }
2375
1177
  if (outcome.kind === "ok") {
2376
- entry.lastSnapshotTurn = entry.turn;
2377
- return { skipped: false };
1178
+ entry.lastSnapshotTurn = outcome.turn;
1179
+ const omission = this.omissionNotice(outcome.omitted);
1180
+ const warning = outcome.warning ? `; ${outcome.warning}` : "";
1181
+ return omission || warning ? `[dashr] turn-${outcome.turn} checkpoint saved${omission}${warning}` : void 0;
2378
1182
  }
2379
- if (outcome.kind === "skipped") return {
2380
- skipped: true,
2381
- ...outcome.estimateBytes !== void 0 ? { estimateBytes: outcome.estimateBytes } : {},
2382
- ...outcome.sizeBytes !== void 0 ? { sizeBytes: outcome.sizeBytes } : {}
2383
- };
2384
- this.logger.warn(`turn-end snapshot for ${JSON.stringify(entry.key)} failed: ${outcome.reason}`);
2385
- return { skipped: false };
1183
+ const latest = entry.lastSnapshotTurn === void 0 ? "no successful checkpoint available" : `latest successful checkpoint is turn-${entry.lastSnapshotTurn}`;
1184
+ return `[dashr] namespace snapshot ${outcome.kind === "skipped" ? "skipped" : "failed"}: ${outcome.reason}; ${latest}`;
2386
1185
  }
2387
- /**
2388
- * Dispose to quiescence: snapshot every live key's namespace when a
2389
- * snapshot directory is configured (each under its own per-key
2390
- * subdirectory), then shut the subprocesses down. Registered as the
2391
- * plugin's `ctx.effect` disposer.
2392
- */
1186
+ /** Cancel active work, drain admitted transactions, and reap every owned child. */
2393
1187
  async teardown() {
2394
1188
  this.disposed = true;
2395
- const entries = [...this.kernels.values()];
1189
+ await Promise.all([...this.kernels.values()].map((entry) => entry.bridge.dispose()));
1190
+ await Promise.allSettled([...this.transactions.values()]);
1191
+ await Promise.all([...this.kernels.values()].map((entry) => entry.bridge.dispose()));
2396
1192
  this.kernels.clear();
2397
- await Promise.all(entries.map((entry) => this.teardownKernel(entry)));
2398
1193
  }
2399
1194
  /**
2400
1195
  * Tear one session's kernel down (session end via the `agent/disposed`
@@ -2402,15 +1197,17 @@ var DashrRuntime = class extends ReplRuntime {
2402
1197
  * which is what makes hearing every agent's disposal safe.
2403
1198
  */
2404
1199
  async destroyKey(key) {
2405
- const entry = this.kernels.get(key);
2406
- if (!entry) return;
2407
- this.kernels.delete(key);
2408
- await this.teardownKernel(entry);
1200
+ await this.kernels.get(key)?.bridge.dispose();
1201
+ await this.transaction(key, async () => {
1202
+ const entry = this.kernels.get(key);
1203
+ this.kernels.delete(key);
1204
+ await entry?.bridge.dispose();
1205
+ });
2409
1206
  }
2410
1207
  /**
2411
1208
  * Reset one session's kernel to a fresh, empty namespace: dispose the live
2412
1209
  * subprocess WITHOUT a turn-end snapshot and clear its on-disk snapshot, so
2413
- * the next ensureKernel spawns empty (restore finds nothing to replay).
1210
+ * the next ensureKernel spawns empty (restore finds no checkpoint).
2414
1211
  */
2415
1212
  async resetKey(key) {
2416
1213
  const entry = this.kernels.get(key);
@@ -2423,24 +1220,6 @@ var DashrRuntime = class extends ReplRuntime {
2423
1220
  force: true
2424
1221
  });
2425
1222
  }
2426
- /** Snapshot (when configured) then dispose one key's kernel; failures log, never throw into a listener. */
2427
- async teardownKernel(entry) {
2428
- const kernel = entry.bridge;
2429
- if (this.config.snapshotDir && kernel.isRunning) try {
2430
- const dir = join(this.config.snapshotDir, keyDirectoryName(entry.key));
2431
- mkdirSync(dir, { recursive: true });
2432
- const spec = {
2433
- turn: entry.turn,
2434
- skills: [],
2435
- sizeCapBytes: this.config.snapshotSizeCapBytes
2436
- };
2437
- const saved = await kernel.snapshotState(join(dir, "state.dill"), join(dir, "manifest.json"), spec);
2438
- if (saved.kind !== "ok" && saved.kind !== "skipped") this.logger.warn(`kernel namespace snapshot for ${JSON.stringify(entry.key)} failed: ${saved.reason}`);
2439
- } catch (error) {
2440
- this.logger.warn(`kernel namespace snapshot for ${JSON.stringify(entry.key)} failed: ${messageOf(error)}`);
2441
- }
2442
- await kernel.dispose();
2443
- }
2444
1223
  };
2445
1224
 
2446
1225
  //#endregion
@@ -3389,7 +2168,7 @@ function createRunCellTool(registry, options) {
3389
2168
  },
3390
2169
  reset: {
3391
2170
  type: "boolean",
3392
- description: "Optional: reset the persistent pad namespace to EMPTY before running — variables, imports, and definitions from earlier cells are discarded and the pad restarts fresh. Omit to keep the namespace."
2171
+ description: "Optional: reset the persistent pad namespace to EMPTY before running and delete its saved checkpoints — variables, imports, and definitions from earlier cells are discarded and the pad restarts fresh. Omit to keep the namespace."
3393
2172
  }
3394
2173
  },
3395
2174
  output: {
@@ -3701,10 +2480,7 @@ const RUNTIME_CONFIG_KEYS = [
3701
2480
  "maxOutputBytes",
3702
2481
  "snapshotDir",
3703
2482
  "snapshotSizeCapBytes",
3704
- "username",
3705
- "kernelEnvDir",
3706
- "kernelPythonVersion",
3707
- "kernelAutoInstall"
2483
+ "username"
3708
2484
  ];
3709
2485
  /** The runtime slice of the merged config — the keys `DashrRuntime` owns. */
3710
2486
  function pickRuntimeConfig(config) {
@@ -3716,20 +2492,6 @@ function apply(ctx, config) {
3716
2492
  ctx.plugin(DashrRuntime, pickRuntimeConfig(config));
3717
2493
  const logger = ctx.logger("dashr-repl");
3718
2494
  const maxParallel = resolveMaxParallelSubCalls(config.maxParallelSubCalls);
3719
- {
3720
- const kernelConfig = pickRuntimeConfig(config);
3721
- resolveKernelEnv({
3722
- python: kernelConfig.python,
3723
- venvDir: kernelConfig.kernelEnvDir,
3724
- pythonVersion: kernelConfig.kernelPythonVersion,
3725
- autoInstall: kernelConfig.kernelAutoInstall ?? true,
3726
- log: (level, message) => {
3727
- logger[level](message);
3728
- }
3729
- }).catch((error) => {
3730
- logger.error(`dashr-repl: spin-up kernel check failed (non-blocking): ${error instanceof Error ? error.message : String(error)}`);
3731
- });
3732
- }
3733
2495
  ctx.inject(["replRuntime"], (runtimeCtx) => {
3734
2496
  const requireRuntime = () => {
3735
2497
  const runtime = runtimeCtx.get("replRuntime");