@code-yeongyu/senpi-codemode 2026.10.10-10 → 2026.10.10-11

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 (47) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/package.json +4 -4
  3. package/src/bridge/memory-protocol.ts +8 -1
  4. package/src/bridge/protocol.ts +1 -1
  5. package/src/bridges/eval-environment-schemas.ts +5 -1
  6. package/src/environments/editable-check.ts +1 -1
  7. package/src/extension/session-cwd.ts +25 -9
  8. package/src/extension/session-manager.ts +4 -4
  9. package/src/kernels/jl/runner.jl +11 -22
  10. package/src/kernels/js/group-signal-notice.js +117 -0
  11. package/src/kernels/js/inline-worker.ts +2 -0
  12. package/src/kernels/js/local-module-loader.ts +8 -0
  13. package/src/kernels/js/process-tree-host.ts +4 -8
  14. package/src/kernels/js/process-tree.d.ts +11 -0
  15. package/src/kernels/js/process-tree.js +55 -10
  16. package/src/kernels/js/windows-tree-kill-host.ts +117 -0
  17. package/src/kernels/js/windows-tree-kill.d.ts +19 -0
  18. package/src/kernels/js/windows-tree-kill.js +100 -0
  19. package/src/kernels/js/worker-cwd.js +41 -10
  20. package/src/kernels/js/worker-runtime.js +23 -4
  21. package/src/kernels/js/worker-shell-capture.js +4 -0
  22. package/src/kernels/js/worker-slot.ts +2 -3
  23. package/src/kernels/py/prelude.py +36 -25
  24. package/src/kernels/py/transport.ts +4 -0
  25. package/src/kernels/rb/runner.rb +9 -19
  26. package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
  27. package/src/kernels/shared/bun-ffi.ts +24 -0
  28. package/src/kernels/shared/kernel-memory-host.ts +30 -7
  29. package/src/kernels/shared/process-cpu.ts +7 -3
  30. package/src/kernels/shared/process-group-cpu.ts +6 -3
  31. package/src/kernels/shared/subprocess-kernel.ts +29 -3
  32. package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
  33. package/src/kernels/shared/subprocess-process.ts +20 -12
  34. package/src/kernels/shared/subprocess-queue.ts +1 -0
  35. package/src/tool/eval-tool.ts +2 -1
  36. package/src/tool/render-agent.ts +110 -0
  37. package/src/tool/render-blocks.ts +252 -0
  38. package/src/tool/render-call.ts +130 -0
  39. package/src/tool/render-cell.ts +165 -0
  40. package/src/tool/render-detail.ts +152 -0
  41. package/src/tool/render-live.ts +288 -0
  42. package/src/tool/render-result.ts +202 -0
  43. package/src/tool/render-status.ts +166 -0
  44. package/src/tool/render-tail.ts +139 -0
  45. package/src/tool/render.ts +3 -1128
  46. package/src/tool/run-eval-cell.ts +7 -12
  47. package/src/tool/types.ts +11 -2
package/CHANGELOG.md CHANGED
@@ -12,6 +12,38 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.10-11] - 2026-10-09
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ ### Changed
22
+
23
+ - Replaced the plan-authored type assertions the F2 audit listed, in this package's production code and tests, with real types: `EvalKernel` now declares the optional `describeKernelTools` / `invokeKernelTool` capabilities, the JS `WorkerLike` carries an optional `pid`, `bun:ffi` is read through one shared `isBunFfi` guard, the sandbox `credits` buffer is typed as `Int32Array<SharedArrayBuffer>`, the gate allowlist is validated by the existing typebox `allowlistSchema`, and test fakes use `satisfies`, typed factories and narrowing guards instead of casts. No behavior change, except that a malformed base allowlist is now refused by the same schema the head allowlist already required. ([#3005](https://github.com/code-yeongyu/senpi/issues/3005))
24
+ - The bench judges `crash-queue-100` on Ruby and Julia by an absolute head budget instead of the head/base ratio: all 100 queued cells must run exactly once after one kernel replacement, with the head's median wall time under 820 ms (Ruby) or 2,700 ms (Julia). Their base never recovered from the crash, so the ratio compared a failure path with the real work. ([#3025](https://github.com/code-yeongyu/senpi/issues/3025))
25
+ - A streaming, queued or running eval row now shows the cell's code in a fixed-height block that scrolls upward as lines arrive: the newest line always stays visible, older lines fold into an "N earlier code lines" row counted inside the block, and once output or status events arrive they share the block's rows instead of growing it, so the row never grows the transcript at any terminal width. The header stays one row, keeping the live spinner and render-clock elapsed time (a queued cell keeps its queued badge). Once a cell finishes, errs or is cancelled, the collapsed row is one line (icon, summary, status and duration), and expanding it shows the full code, output and status events. ([#2933](https://github.com/code-yeongyu/senpi/issues/2933))
26
+
27
+ ### Fixed
28
+
29
+ - Python cells use a C-backed FIFO and one combined stdout/stderr routing scope, reducing per-cell bookkeeping while retaining interpreter ownership, parked-cell callbacks, invocation scope, cancellation, and stale-descriptor fencing ([#3034](https://github.com/code-yeongyu/senpi/issues/3034)).
30
+
31
+ - Ruby and Julia cells below the host-read memory notice threshold and ceiling no longer walk all globals or serialize an unused memory payload. Above either threshold, successful and raised cells both collect the same bounded globals diagnostic, including when hysteresis suppresses the notice text. Stopping while a finished cell waits for that optional diagnostic now settles its completed result without globals and keeps kernel state. Current-footprint accounting and ceiling enforcement still run on every cell ([#3028](https://github.com/code-yeongyu/senpi/issues/3028)).
32
+
33
+ - Warm eval cells now recheck the session working directory synchronously instead of awaiting a filesystem thread-pool round trip. Every cell still refuses a deleted or non-directory cwd with the same error, and other filesystem errors still surface; kernel startup retains its asynchronous pre/post checks ([#3033](https://github.com/code-yeongyu/senpi/issues/3033)).
34
+
35
+ - When a Python, Ruby, Julia or process-isolated JavaScript kernel dies while a frame is being sent to it, the failed write (EPIPE) is reported as that kernel's error instead of surfacing as an unhandled error. Before, that unhandled error could end the host, or make a test run where every test passed exit 1 ([#3016](https://github.com/code-yeongyu/senpi/issues/3016)).
36
+
37
+ - On macOS and Linux, when a JavaScript eval cell ends or is interrupted, a process its child started and left behind (re-parented to init after the child exited, as with `(sleep 30 &)`) is now stopped too: every process group the kernel gave a cell child is signalled at retirement. The agent's own group is never signalled, nor a group whose leader exited and whose pid was since reused, and children the cell started with `detached: true` are left running ([#3020](https://github.com/code-yeongyu/senpi/issues/3020)).
38
+
39
+ - On Windows, ending a kernel's process tree (cell timeout, reset, shutdown) no longer uses `taskkill /T`: the tree is computed from creation times and each process is ended by pid, so an older, unrelated process holding a recycled parent pid is never killed ([#2999](https://github.com/code-yeongyu/senpi/issues/2999)).
40
+
41
+ - `tool_schema("eval:environments")` now documents the `packages.install(manager, requirements, {timeout?})` cell helper that shipped in #2877: the signature (Python spells the option `timeout=`, default 600 s), the managers per language (`pip` for Python; `bun`/`npm` for JavaScript), the receipt fields, cancellation by `stop`, and the `environment_install_timeout` error code it raises. A two-way contract test now keeps every `environment_*`/`eval_isolate_*` code the source raises documented in its owning entry. ([#3003](https://github.com/code-yeongyu/senpi/issues/3003))
42
+
43
+ - On macOS and Linux, a process started from a JavaScript eval cell with `node:child_process` or `Bun.spawn` now gets its own process group. A cell that later stops that job by its group (`kill -TERM -- -$PGID`) therefore no longer stops the agent itself. Named imports of `node:child_process`, `node:fs` and `node:path` in a cell now get the session cwd handling too. `Bun.$`, `exec`/`execFile` and the synchronous spawners (`spawnSync`, `execSync`, `execFileSync`, `Bun.spawnSync`, which keep the terminal) still share the agent's group, so a command there that signals a process group (including `pkill -g` and `killall`) prints a notice naming the agent's group. The notice is a warning only and never blocks the cell ([#2995](https://github.com/code-yeongyu/senpi/issues/2995)).
44
+
45
+ ### Removed
46
+
15
47
  ## [2026.10.10-10] - 2026-10-09
16
48
 
17
49
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.10-10",
3
+ "version": "2026.10.10-11",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -33,15 +33,15 @@
33
33
  },
34
34
  "dependencies": {
35
35
  "@babel/parser": "8.0.4",
36
- "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-10",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-11",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.10-10"
41
+ "@code-yeongyu/senpi": "2026.10.10-11"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.10-10"
44
+ "@code-yeongyu/senpi": "2026.10.10-11"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -24,7 +24,7 @@ export const kernelMemoryReportSchema = Type.Object({
24
24
  approximate: Type.Optional(Type.Boolean()),
25
25
  /** `liveBytes` was measured right after a full collection in this cell. */
26
26
  gcRan: Type.Optional(Type.Boolean()),
27
- /** Largest user globals, measured only when live memory after collection reached the notice threshold. */
27
+ /** Largest user globals, measured after collection or on demand when the host footprint reaches a threshold. */
28
28
  globals: Type.Optional(Type.Array(kernelMemoryGlobalSchema, { maxItems: 8 })),
29
29
  /** Host-set: live memory after collection reached the ceiling; the kernel restarts once its queue drains. */
30
30
  overCeiling: Type.Optional(Type.Boolean()),
@@ -37,6 +37,8 @@ export const kernelMemoryReportSchema = Type.Object({
37
37
  /** On-demand heap reading between cells (JS kernels): no cell runs and no collection is forced. */
38
38
  export const kernelMemoryQueryHostToKernelSchemas = [
39
39
  Type.Object({ type: Type.Literal("memory-query"), requestId: Type.String({ minLength: 1 }) }),
40
+ /** Ruby/Julia only: bounded, read-only globals sizing before the owning cell settles. */
41
+ Type.Object({ type: Type.Literal("memory-globals"), cellId: Type.String({ minLength: 1 }) }),
40
42
  ] as const;
41
43
 
42
44
  export const kernelMemoryQueryKernelToHostSchemas = [
@@ -46,6 +48,11 @@ export const kernelMemoryQueryKernelToHostSchemas = [
46
48
  liveBytes: Type.Integer({ minimum: 0 }),
47
49
  measure: Type.Literal("heap"),
48
50
  }),
51
+ Type.Object({
52
+ type: Type.Literal("memory-globals-result"),
53
+ cellId: Type.String({ minLength: 1 }),
54
+ globals: Type.Array(kernelMemoryGlobalSchema, { maxItems: 8 }),
55
+ }),
49
56
  ] as const;
50
57
 
51
58
  export type KernelMemoryThresholds = Static<typeof kernelMemoryThresholdsSchema>;
@@ -101,7 +101,7 @@ const hostToKernelMessageSchema = Type.Union([
101
101
  const kernelStateSchema = Type.Union([Type.Literal("lost"), Type.Literal("restarted"), Type.Literal("not-run")]);
102
102
 
103
103
  const kernelToHostMessageSchema = Type.Union([
104
- Type.Object({ type: Type.Literal("ready") }),
104
+ Type.Object({ type: Type.Literal("ready"), memoryGlobals: Type.Optional(Type.Boolean()) }),
105
105
  Type.Object({ type: Type.Literal("init-failed"), error: bridgeErrorSchema }),
106
106
  Type.Object({
107
107
  type: Type.Literal("text"),
@@ -16,7 +16,8 @@ export const environmentsEntry: NamedEvalSchema = {
16
16
  "py and js: %environment managed | project selects where installs go. managed (the default) uses per-session revisions under the session's artifacts directory (or environments.managedRoot) and never touches project files; project installs into the session directory (py: .senpi/python-packages; js: the project root).",
17
17
  "py and js: %load <file> runs a local file as a cell, read when its turn comes in the queue, with the file's name in tracebacks; definitions persist like any cell. It accepts a path relative to the session directory, local:// or file://, reads files up to 8 MiB and never fetches remote URLs.",
18
18
  "Installs pause the run budget like a bridge call, stream the installer's output into the cell, detach past the foreground window like any cell, and are stopped by stop.",
19
- "Install failures are cell errors that start with their code: environment_install_failed (a bad argument or an installer failure, with the installer's stderr tail), environment_install_cancelled (stopped before or during the install), environment_installer_unavailable (no interpreter or no pip; no JavaScript package environment in this session; neither bun nor npm on PATH, or the chosen one missing; the installer failed to start; or installs turned off with environments.autoProvision: false), environment_resolution_conflict (py: pip reported a dependency conflict). A %load failure, and a magic refused because more code follows it, are plain cell errors without a code.",
19
+ 'js and py: packages.install(manager, requirements, {timeout?}) runs the same install as a call instead of a magic: manager "pip" in a Python cell, "bun" or "npm" in a JavaScript cell; requirements is a non-empty string or a non-empty list of non-empty strings, and timeout is a positive number of seconds (default 600; py spells it packages.install("pip", [...], timeout=...)). pip receives the requirement list as separate arguments, never re-split; a js package spec cannot contain whitespace. It drives the same session environment the magic uses, returns that install\'s receipt (py: manager, mode, root, revision, requested, resolved, changed; js: installer, mode, revision, added, shadowed) and is cancelled by stop like a magic install. Unlike the magic, the installer\'s output is not streamed into the cell: the receipt is the result, and a failure carries the installer\'s stderr tail.',
20
+ "Install failures are cell errors that start with their code: environment_install_failed (an argument the host rejects: an unknown manager, an empty requirement list, a bun/npm requirement with whitespace or a non-positive timeout; or an installer failure, with the installer's stderr tail. A JS options value that is not an object, or a Python timeout that float() cannot convert, fails earlier in the cell with a plain TypeError or ValueError), environment_install_timeout (a packages.install() still running after its timeout, default 600 s, was stopped), environment_install_cancelled (stopped before or during the install), environment_installer_unavailable (no interpreter or no pip; no JavaScript package environment in this session; neither bun nor npm on PATH, or the chosen one missing; the installer failed to start; installs turned off with environments.autoProvision: false; or pip asked of a session with no Python interpreter, or bun/npm asked of a Python cell), environment_resolution_conflict (py: pip reported a dependency conflict). A %load failure, and a magic refused because more code follows it, are plain cell errors without a code.",
20
21
  ].join("\n"),
21
22
  parameters: {
22
23
  type: "object",
@@ -26,6 +27,9 @@ export const environmentsEntry: NamedEvalSchema = {
26
27
  "%npm": { description: "js: %npm add <packages ...>" },
27
28
  "%environment": { enum: ["managed", "project"] },
28
29
  "%load": { description: "py and js: %load <path | local://... | file://...>" },
30
+ "packages.install": {
31
+ description: "js and py: packages.install(manager, requirements, {timeout?}) (py: timeout=...)",
32
+ },
29
33
  },
30
34
  },
31
35
  };
@@ -60,7 +60,7 @@ async function refuseOutsideLinks(root: string, dir: string): Promise<void> {
60
60
  export async function assertInstalledInRevision(stdout: string, staging: string): Promise<void> {
61
61
  const line = stdout.split("\n").find((entry) => entry.startsWith("Successfully installed "));
62
62
  if (line === undefined) return;
63
- const present = new Set((await readdir(staging).catch(() => [] as string[])).flatMap(installedName));
63
+ const present = new Set((await readdir(staging).catch((): string[] => [])).flatMap(installedName));
64
64
  for (const token of line.slice("Successfully installed ".length).trim().split(/\s+/u)) {
65
65
  const name = token.slice(0, token.lastIndexOf("-"));
66
66
  if (!present.has(canonicalName(name))) {
@@ -1,3 +1,4 @@
1
+ import { statSync } from "node:fs";
1
2
  import { stat } from "node:fs/promises";
2
3
 
3
4
  // Only these codes mean the directory is gone or was never one; anything else (EACCES, ELOOP,
@@ -21,15 +22,30 @@ export async function assertSessionCwdAvailable(cwd: string): Promise<void> {
21
22
  try {
22
23
  isDirectory = (await stat(cwd)).isDirectory();
23
24
  } catch (error) {
24
- if (
25
- error instanceof Error &&
26
- "code" in error &&
27
- typeof error.code === "string" &&
28
- MISSING_DIRECTORY_CODES.has(error.code)
29
- ) {
30
- throw new CodemodeSessionCwdUnavailableError(cwd, error.code);
31
- }
32
- throw error;
25
+ throw sessionCwdStatError(cwd, error);
33
26
  }
34
27
  if (!isDirectory) throw new CodemodeSessionCwdUnavailableError(cwd, "not a directory");
35
28
  }
29
+
30
+ /** Warm reuse checks every cell without a filesystem thread-pool round trip. */
31
+ export function assertSessionCwdAvailableSync(cwd: string): void {
32
+ let isDirectory: boolean;
33
+ try {
34
+ isDirectory = statSync(cwd).isDirectory();
35
+ } catch (error) {
36
+ throw sessionCwdStatError(cwd, error);
37
+ }
38
+ if (!isDirectory) throw new CodemodeSessionCwdUnavailableError(cwd, "not a directory");
39
+ }
40
+
41
+ function sessionCwdStatError(cwd: string, error: unknown): unknown {
42
+ if (
43
+ error instanceof Error &&
44
+ "code" in error &&
45
+ typeof error.code === "string" &&
46
+ MISSING_DIRECTORY_CODES.has(error.code)
47
+ ) {
48
+ return new CodemodeSessionCwdUnavailableError(cwd, error.code);
49
+ }
50
+ return error;
51
+ }
@@ -24,7 +24,7 @@ import {
24
24
  } from "./kernel-registration.ts";
25
25
  import type { RegisteredKernelSource } from "./kernel-registry.ts";
26
26
  import { ReplaceableKernel } from "./kernel-replacement.ts";
27
- import { assertSessionCwdAvailable } from "./session-cwd.ts";
27
+ import { assertSessionCwdAvailable, assertSessionCwdAvailableSync } from "./session-cwd.ts";
28
28
  import type {
29
29
  BridgeEndpoint,
30
30
  CodemodeSessionManager,
@@ -114,7 +114,7 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
114
114
  this.#onMessageRefs.set(language, onMessage);
115
115
  const existing = this.#kernels.get(language);
116
116
  if (existing) {
117
- await assertSessionCwdAvailable(this.#options.cwd);
117
+ assertSessionCwdAvailableSync(this.#options.cwd);
118
118
  return existing;
119
119
  }
120
120
  const pending = this.#kernelCreations.get(language);
@@ -311,8 +311,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
311
311
  /** Only an already running JavaScript kernel can define a colliding name; none is started for this. */
312
312
  #javaScriptKernelToolsDescribe(names: readonly string[]): Promise<KernelToolsDescribeResult> | undefined {
313
313
  const js = this.#kernels.get("js");
314
- if (!js || !("describeKernelTools" in js) || typeof js.describeKernelTools !== "function") return undefined;
315
- return (js.describeKernelTools as (names: readonly string[]) => Promise<KernelToolsDescribeResult>)(names);
314
+ if (!js || typeof js.describeKernelTools !== "function") return undefined;
315
+ return js.describeKernelTools(names);
316
316
  }
317
317
 
318
318
  #foreignKernelToolNames(): string[] {
@@ -16,6 +16,7 @@ include("prelude.jl")
16
16
  const senpi_connection = Dict{String, Any}()
17
17
  const senpi_write_lock = ReentrantLock()
18
18
  global senpi_current_cell = nothing
19
+ global senpi_memory_cell = nothing
19
20
 
20
21
  function senpi_escape(text::AbstractString)
21
22
  out = IOBuffer()
@@ -290,9 +291,7 @@ function senpi_set_connection(value)
290
291
  end
291
292
  end
292
293
 
293
- const senpi_memory = Ref{Any}(nothing)
294
-
295
- const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_memory, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
294
+ const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
296
295
 
297
296
  const SENPI_SIZER_SAMPLE = 1_000
298
297
  const SENPI_SIZER_NODE_BUDGET = 5_000
@@ -421,21 +420,6 @@ function senpi_largest_globals(limit::Int)
421
420
  end
422
421
  end
423
422
 
424
- function senpi_memory_report()
425
- try
426
- senpi_memory[] === nothing && return nothing
427
- # The host reads the interpreter's current footprint and decides on the notice; the runner only
428
- # names its largest globals, with a bounded walk, so a cell never pays for an unbounded scan.
429
- named = senpi_largest_globals(5)
430
- # The host replaces liveBytes with the interpreter footprint it reads itself; only globals is used.
431
- report = Dict{String, Any}("liveBytes" => 0, "measure" => "footprint")
432
- isempty(named) || (report["globals"] = named)
433
- report
434
- catch
435
- nothing
436
- end
437
- end
438
-
439
423
  function senpi_run_cell(message)
440
424
  cell_id = string(get(message, "cellId", ""))
441
425
  code = string(get(message, "code", ""))
@@ -452,13 +436,12 @@ function senpi_run_cell(message)
452
436
  yield()
453
437
  frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
454
438
  value !== nothing && senpi_should_display_result(parsed) && (frame["valueRepr"] = senpi_json(value))
455
- memory = senpi_memory_report()
456
- memory !== nothing && (frame["memory"] = memory)
457
439
  senpi_emit(frame)
458
440
  catch error
459
441
  senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
460
442
  finally
461
443
  global senpi_current_cell = nothing
444
+ global senpi_memory_cell = cell_id
462
445
  end
463
446
  end
464
447
 
@@ -472,10 +455,16 @@ while !eof(SENPI_ORIGINAL_STDIN)
472
455
  if kind == "init"
473
456
  senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
474
457
  senpi_set_connection(get(message, "connection", nothing))
475
- senpi_memory[] = get(message, "memory", nothing)
476
- senpi_emit(Dict("type" => "ready"))
458
+ senpi_emit(Dict("type" => "ready", "memoryGlobals" => true))
477
459
  elseif kind == "run"
460
+ global senpi_memory_cell = nothing
478
461
  senpi_run_cell(message)
462
+ elseif kind == "memory-globals"
463
+ cell_id = get(message, "cellId", nothing)
464
+ if cell_id == senpi_memory_cell && senpi_current_cell === nothing
465
+ senpi_emit(Dict("type" => "memory-globals-result", "cellId" => cell_id, "globals" => senpi_largest_globals(5)))
466
+ global senpi_memory_cell = nothing
467
+ end
479
468
  elseif kind == "close"
480
469
  senpi_emit(Dict("type" => "closed"))
481
470
  break
@@ -0,0 +1,117 @@
1
+ // senpi#2995: Bun.$, node:child_process exec/execFile and the synchronous spawners leave their children in the
2
+ // agent's process group (Bun.$ has no such option, Bun ignores `detached` for exec/execFile, and a synchronous call
3
+ // keeps the terminal), so a group-wide signal from the same cell stops the agent. Such a command gets a notice; the
4
+ // notice only warns and never fails the cell.
5
+
6
+ // A command starts a (possibly indented) line, follows `;`, `|`, `&`, `(`, a backtick, `{`, `!`, a shell keyword
7
+ // (`then`, `do`, `else`) or a runner (`nohup`, `time`, `xargs`, `env`, `exec`, `sudo` with flags), or opens a `-c '...'` script; a
8
+ // `kill` word anywhere else (`git log --grep kill`, `echo kill ...`) is text.
9
+ const COMMAND_POSITION = String.raw`(?:^\s*|[;|&(\x60]\s*|\b(?:then|do|else|nohup|time|xargs|env|exec)\s+|[{!]\s+|\bsudo(?:\s+-\S+)*\s+|\s-[A-Za-z]*c\s+['"]\s*)`;
10
+
11
+ // A group target is a dash argument after the signal options: a number, `$var`, `${var}` or `$(...)`.
12
+ const GROUP_TARGET = String.raw`-(?:\d+|\$\$|\$\{?\w+\}?|\$\()`;
13
+ const SIGNAL_OPTION = String.raw`(?:-s\s+\w+|-n\s+\d+|-[A-Za-z]+\d*|-\d+)`;
14
+ // The first dash argument is a signal, so a group is one after `--`, after a signal option, or after a plain pid:
15
+ // `kill -- -<pgid>`, `kill -SIG -<pgid>`, `kill -TERM 1234 -5678`, `kill -- -$$`.
16
+ const PID = String.raw`[^\s\-;|&][^\s;|&]*`;
17
+ const KILL_GROUP = new RegExp(
18
+ String.raw`${COMMAND_POSITION}kill\b(?:` +
19
+ String.raw`\s+--\s+${GROUP_TARGET}` +
20
+ String.raw`|(?:\s+${SIGNAL_OPTION})+(?:\s+${PID})*\s+(?:--\s+)?${GROUP_TARGET}` +
21
+ String.raw`|(?:\s+${SIGNAL_OPTION})*(?:\s+${PID})+\s+${GROUP_TARGET})`,
22
+ "mu",
23
+ );
24
+ const PKILL_GROUP = new RegExp(String.raw`${COMMAND_POSITION}pkill\b[^\n;|&]*\s(?:-g\d*|--pgroup)\b`, "mu");
25
+ // `killall` in command position (also after `sudo` or as a `-c` script) signals every process with that name, which
26
+ // can include the agent's own (bun, node, senpi). Inside an `echo` string it is only text.
27
+ const KILLALL = new RegExp(String.raw`${COMMAND_POSITION}killall\s`, "mu");
28
+
29
+ export const GROUP_SIGNAL_NOTICE_TAG = "[senpi:group-signal]";
30
+
31
+ export function signalsProcessGroup(commandText) {
32
+ return KILL_GROUP.test(commandText) || PKILL_GROUP.test(commandText) || KILLALL.test(commandText);
33
+ }
34
+
35
+ export function groupSignalNotice(api, readGroup = agentProcessGroup) {
36
+ const pgid = readGroup();
37
+ const group = pgid === undefined ? "the agent's process group" : `the agent's process group (${pgid})`;
38
+ return (
39
+ `${GROUP_SIGNAL_NOTICE_TAG} This command signals a process group. Children of ${api} share ${group}, so ` +
40
+ "signalling that group can stop this session. Start background jobs with Bun.spawn or child_process.spawn, " +
41
+ "which give them their own group, or signal them by pid.\n"
42
+ );
43
+ }
44
+
45
+ let cachedGroup;
46
+
47
+ // A bounded `ps` lookup, kept once it succeeds; a missing `ps` or a failed run only drops the number from this notice.
48
+ export function agentProcessGroup(spawnSync = globalThis.Bun?.spawnSync) {
49
+ if (cachedGroup !== undefined) return cachedGroup;
50
+ let text = "";
51
+ try {
52
+ const result = spawnSync?.(["ps", "-o", "pgid=", "-p", String(process.pid)], { timeout: 2_000 });
53
+ text = result?.success ? String(result.stdout).trim() : "";
54
+ } catch {
55
+ text = "";
56
+ }
57
+ if (!/^\d+$/u.test(text)) return undefined;
58
+ cachedGroup = text;
59
+ return cachedGroup;
60
+ }
61
+
62
+ export function shellCommandText(strings, expressions) {
63
+ if (!Array.isArray(strings)) return String(strings ?? "");
64
+ return strings.reduce((text, part, index) => text + part + (index < expressions.length ? String(expressions[index]) : ""), "");
65
+ }
66
+
67
+ const { promisify } = process.getBuiltinModule("node:util");
68
+ const SCANNED_SPAWNERS = ["exec", "execFile", "execSync", "execFileSync", "spawnSync"];
69
+
70
+ function commandTextOf(name, args) {
71
+ if (name === "exec" || name === "execSync") return String(args[0]);
72
+ return [args[0], ...(Array.isArray(args[1]) ? args[1] : [])].join(" ");
73
+ }
74
+
75
+ export function noticeChildProcessGroupSignals(emitText, isActive) {
76
+ const childProcess = process.getBuiltinModule("node:child_process");
77
+ const originals = new Map();
78
+ for (const name of SCANNED_SPAWNERS) {
79
+ const original = childProcess[name];
80
+ if (typeof original !== "function") continue;
81
+ originals.set(name, original);
82
+ const scan = (args) => {
83
+ try {
84
+ if (isActive() && signalsProcessGroup(commandTextOf(name, args))) {
85
+ emitText("stderr", groupSignalNotice(`child_process.${name}`));
86
+ }
87
+ } catch {
88
+ // The notice is advisory; the call itself always runs.
89
+ }
90
+ };
91
+ const scanned = function scannedSpawner(...args) {
92
+ scan(args);
93
+ return original.apply(this, args);
94
+ };
95
+ // Every own property, symbols included: `util.promisify.custom` is what makes promisify(exec) resolve to
96
+ // { stdout, stderr }. That custom function calls the original directly, so it is scanned too.
97
+ for (const key of Reflect.ownKeys(original)) {
98
+ if (key === "length" || key === "name" || key === "prototype" || key === promisify.custom) continue;
99
+ const descriptor = Object.getOwnPropertyDescriptor(original, key);
100
+ if (descriptor) Object.defineProperty(scanned, key, descriptor);
101
+ }
102
+ const customPromisify = original[promisify.custom];
103
+ if (typeof customPromisify === "function") {
104
+ Object.defineProperty(scanned, promisify.custom, {
105
+ configurable: true,
106
+ value: function scannedPromisified(...args) {
107
+ scan(args);
108
+ return customPromisify.apply(this, args);
109
+ },
110
+ });
111
+ }
112
+ childProcess[name] = scanned;
113
+ }
114
+ return () => {
115
+ for (const [name, original] of originals) childProcess[name] = original;
116
+ };
117
+ }
@@ -19,6 +19,8 @@ export function resolveInlineWorkerEntryUrl(options: JavaScriptInlineWorkerEntry
19
19
 
20
20
  export interface WorkerLike {
21
21
  readonly mode: JavaScriptKernelMode;
22
+ /** The child pid when `mode` is `"process"`; the slot reports it for orphan accounting after a crash. */
23
+ readonly pid?: number;
22
24
  postMessage(message: HostToKernelMessage, transfer?: readonly MessagePort[]): void;
23
25
  onMessage(handler: (message: KernelToHostMessage) => void): () => void;
24
26
  onError(handler: (error: Error) => void): () => void;
@@ -79,6 +79,14 @@ function loaderPrelude(context: RuntimeModuleContext): string {
79
79
  " const specifier = String(source);",
80
80
  " const kernelBun = globalThis[Symbol.for('senpi.kernel.bun')];",
81
81
  " if (specifier === 'bun' && kernelBun) return { ...(await import('bun')), WebView: kernelBun.WebView, default: kernelBun };",
82
+ // Under Bun a cell's named import (`import { spawn } from 'node:child_process'`) binds the builtin's original
83
+ // export even after the worker patched the module object (worker-cwd.js), so the session cwd and the cell
84
+ // children's own process group (senpi#2995) were skipped. The patched module objects are served instead.
85
+ " const patched = /^(?:node:)?(child_process|fs|fs\\/promises|path)$/.exec(specifier);",
86
+ " if (patched && typeof process.getBuiltinModule === 'function') {",
87
+ " const builtin = process.getBuiltinModule('node:' + patched[1]);",
88
+ " if (builtin) return { ...builtin, default: builtin };",
89
+ " }",
82
90
  " const match = /^([a-z][a-z0-9+.-]*):\\/\\/(.*)$/i.exec(specifier);",
83
91
  " let target = specifier;",
84
92
  " if (match) {",
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { killWindowsTree, listWindowsRows } from "./windows-tree-kill-host.ts";
2
3
 
3
4
  // Host-side twin of worker-runtime's `process-tree.js`. The worker file must stay plain
4
5
  // JavaScript (a Node worker thread spawned from a `.js` entry has no TypeScript loader), and
@@ -100,7 +101,7 @@ function sleep(ms: number): Promise<void> {
100
101
  /**
101
102
  * Retire whole process trees: snapshot the descendants first (a parent that dies before the
102
103
  * snapshot would reparent them to init), SIGTERM everything, wait `graceMs` for the pids to
103
- * disappear, then SIGKILL whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
104
+ * disappear, then SIGKILL whatever is left. On Windows the checked tree is killed by pid (`windows-tree-kill.js`).
104
105
  */
105
106
  export async function terminateProcessTrees(
106
107
  roots: readonly number[],
@@ -109,7 +110,8 @@ export async function terminateProcessTrees(
109
110
  const liveRoots = roots.filter(isProcessAlive);
110
111
  if (liveRoots.length === 0) return;
111
112
  if (process.platform === "win32") {
112
- for (const pid of liveRoots) await taskkillTree(pid);
113
+ const listing = listWindowsRows();
114
+ for (const pid of liveRoots) await killWindowsTree(pid, listing);
113
115
  return;
114
116
  }
115
117
  const table = await readProcessTable();
@@ -150,9 +152,3 @@ async function withoutZombies(pids: number[]): Promise<number[]> {
150
152
  }
151
153
  return pids.filter((pid) => !zombies.has(pid));
152
154
  }
153
-
154
- function taskkillTree(pid: number): Promise<void> {
155
- return new Promise((resolve) => {
156
- execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
157
- });
158
- }
@@ -22,3 +22,14 @@ export function collectDescendants(table: ProcessTable, roots: readonly number[]
22
22
  export function ownedRoots(table: ProcessTable, parentPid: number, roots: readonly number[]): number[];
23
23
 
24
24
  export function terminateProcessTrees(roots: readonly number[], options: TerminateProcessTreesOptions): Promise<void>;
25
+ export interface CellProcessGroup {
26
+ readonly pgid: number;
27
+ readonly leaderExited: boolean;
28
+ }
29
+ export interface TerminateProcessGroupsOptions {
30
+ readonly graceMs: number;
31
+ }
32
+ export function terminateProcessGroups(
33
+ groups: readonly CellProcessGroup[],
34
+ options: TerminateProcessGroupsOptions,
35
+ ): Promise<void>;
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { killWindowsTree, listWindowsRows } from "./windows-tree-kill.js";
2
3
 
3
4
  // Plain JS (with process-tree.d.ts) because the worker runtime imports it and
4
5
  // worker files cannot import TypeScript; the host imports the same module so a
@@ -6,6 +7,8 @@ import { execFile } from "node:child_process";
6
7
 
7
8
  const PS_MAX_BUFFER = 16 * 1024 * 1024;
8
9
  const EXIT_POLL_MS = 25;
10
+ // A hung `ps` must not hold a cell's retirement; empty output then means nothing is signalled.
11
+ const PS_LOOKUP_TIMEOUT_MS = 2_000;
9
12
  const ZOMBIE_CHECK_INTERVAL_MS = 250;
10
13
 
11
14
  export function isProcessAlive(pid) {
@@ -30,7 +33,7 @@ export function signalProcess(pid, signal) {
30
33
 
31
34
  /**
32
35
  * Parent-to-children map of every process visible to `ps`. Windows has no
33
- * `ps`; callers there kill through `taskkill /T`, which walks the tree itself.
36
+ * `ps`; callers there kill the checked tree from `windows-tree-kill.js`.
34
37
  */
35
38
  export async function readProcessTable() {
36
39
  if (process.platform === "win32") return new Map();
@@ -78,13 +81,14 @@ function sleep(ms) {
78
81
  * Retire whole process trees: snapshot the descendants first (a parent that
79
82
  * dies before the snapshot would reparent them to init), SIGTERM everything,
80
83
  * wait `graceMs` for `settled` (or for the pids to disappear), then SIGKILL
81
- * whatever is left. On Windows `taskkill /T /F` does the walk and the kill.
84
+ * whatever is left. On Windows the checked tree is killed by pid (`windows-tree-kill.js`).
82
85
  */
83
86
  export async function terminateProcessTrees(roots, options) {
84
87
  const liveRoots = roots.filter(isProcessAlive);
85
88
  if (liveRoots.length === 0) return;
86
89
  if (process.platform === "win32") {
87
- for (const pid of liveRoots) await taskkillTree(pid);
90
+ const listing = listWindowsRows();
91
+ for (const pid of liveRoots) await killWindowsTree(pid, listing);
88
92
  return;
89
93
  }
90
94
  const table = await readProcessTable();
@@ -98,6 +102,52 @@ export async function terminateProcessTrees(roots, options) {
98
102
  await waitForExit(survivors, options.killWaitMs ?? options.graceMs, options.settled);
99
103
  }
100
104
 
105
+ /**
106
+ * Retire whole process groups the worker created (POSIX): SIGTERM each group that still has members, wait `graceMs`,
107
+ * then SIGKILL what is left. A group whose leader already exited is skipped while a live process holds the leader's
108
+ * pid, since that pid was reused. The agent's own group is never signalled.
109
+ */
110
+ export async function terminateProcessGroups(groups, options) {
111
+ if (process.platform === "win32") return;
112
+ const ours = (group) => groupHasMembers(group.pgid) && !(group.leaderExited && isProcessAlive(group.pgid));
113
+ const candidates = groups.filter((group) => Number.isInteger(group.pgid) && group.pgid > 1 && ours(group));
114
+ if (candidates.length === 0) return;
115
+ // Without the agent's own group there is no way to rule it out, so nothing is signalled.
116
+ const ownGroup = await processGroupOf(process.pid);
117
+ if (ownGroup === undefined) return;
118
+ const live = candidates.filter((group) => group.pgid !== ownGroup && ours(group));
119
+ for (const group of live) signalGroup(group.pgid, "SIGTERM");
120
+ const deadline = Date.now() + options.graceMs;
121
+ let remaining = live;
122
+ while (remaining.length > 0 && Date.now() < deadline) {
123
+ await sleep(EXIT_POLL_MS);
124
+ remaining = remaining.filter(ours);
125
+ }
126
+ for (const group of remaining) signalGroup(group.pgid, "SIGKILL");
127
+ }
128
+
129
+ function groupHasMembers(pgid) {
130
+ try {
131
+ process.kill(-pgid, 0);
132
+ return true;
133
+ } catch (error) {
134
+ return isRecord(error) && error.code === "EPERM";
135
+ }
136
+ }
137
+
138
+ function signalGroup(pgid, signal) {
139
+ try {
140
+ process.kill(-pgid, signal);
141
+ } catch {
142
+ // The group emptied between the check and the signal.
143
+ }
144
+ }
145
+
146
+ async function processGroupOf(pid) {
147
+ const text = (await ps(["-o", "pgid=", "-p", String(pid)], { timeout: PS_LOOKUP_TIMEOUT_MS })).trim();
148
+ return /^\d+$/u.test(text) ? Number(text) : undefined;
149
+ }
150
+
101
151
  // `kill(pid, 0)` still succeeds for a zombie, and a child whose owning worker is
102
152
  // blocked or gone is never reaped, so liveness also consults `ps` state.
103
153
  async function waitForExit(pids, graceMs, settled) {
@@ -132,19 +182,14 @@ async function withoutZombies(pids) {
132
182
  }
133
183
 
134
184
  // `ps -p` exits non-zero when any listed pid is gone but still prints the rest.
135
- function ps(args) {
185
+ function ps(args, options = {}) {
136
186
  return new Promise((resolve) => {
137
- execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true }, (_error, output) =>
187
+ execFile("ps", args, { maxBuffer: PS_MAX_BUFFER, windowsHide: true, ...options }, (_error, output) =>
138
188
  resolve(typeof output === "string" ? output : String(output ?? "")),
139
189
  );
140
190
  });
141
191
  }
142
192
 
143
- function taskkillTree(pid) {
144
- return new Promise((resolve) => {
145
- execFile("taskkill", ["/T", "/F", "/PID", String(pid)], { windowsHide: true }, () => resolve());
146
- });
147
- }
148
193
 
149
194
  function isRecord(value) {
150
195
  return typeof value === "object" && value !== null;