@code-yeongyu/senpi-codemode 2026.10.10-9 → 2026.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.
- package/CHANGELOG.md +72 -0
- package/package.json +4 -4
- package/src/bridge/memory-protocol.ts +8 -1
- package/src/bridge/protocol.ts +1 -1
- package/src/bridges/eval-environment-schemas.ts +5 -1
- package/src/environments/editable-check.ts +1 -1
- package/src/extension/session-cwd.ts +25 -9
- package/src/extension/session-manager.ts +4 -4
- package/src/kernels/jl/globals.jl +128 -0
- package/src/kernels/jl/prelude.jl +15 -2
- package/src/kernels/jl/runner.jl +35 -143
- package/src/kernels/js/group-signal-notice.js +117 -0
- package/src/kernels/js/inline-worker.ts +2 -0
- package/src/kernels/js/local-module-loader.ts +8 -0
- package/src/kernels/js/process-tree-host.ts +4 -8
- package/src/kernels/js/process-tree.d.ts +11 -0
- package/src/kernels/js/process-tree.js +55 -10
- package/src/kernels/js/windows-tree-kill-host.ts +117 -0
- package/src/kernels/js/windows-tree-kill.d.ts +19 -0
- package/src/kernels/js/windows-tree-kill.js +100 -0
- package/src/kernels/js/worker-cwd.js +41 -10
- package/src/kernels/js/worker-runtime.js +23 -4
- package/src/kernels/js/worker-shell-capture.js +4 -0
- package/src/kernels/js/worker-slot.ts +2 -3
- package/src/kernels/py/prelude.py +36 -25
- package/src/kernels/py/transport.ts +4 -0
- package/src/kernels/rb/runner.rb +9 -19
- package/src/kernels/sandbox/vendor/pi-codemode/runtime/host.ts +2 -2
- package/src/kernels/shared/bun-ffi.ts +24 -0
- package/src/kernels/shared/kernel-memory-host.ts +30 -7
- package/src/kernels/shared/process-cpu.ts +7 -3
- package/src/kernels/shared/process-group-cpu.ts +6 -3
- package/src/kernels/shared/subprocess-kernel.ts +29 -3
- package/src/kernels/shared/subprocess-memory-globals.ts +101 -0
- package/src/kernels/shared/subprocess-process.ts +20 -12
- package/src/kernels/shared/subprocess-queue.ts +1 -0
- package/src/tool/eval-tool.ts +2 -1
- package/src/tool/render-agent.ts +110 -0
- package/src/tool/render-blocks.ts +252 -0
- package/src/tool/render-call.ts +130 -0
- package/src/tool/render-cell.ts +165 -0
- package/src/tool/render-detail.ts +152 -0
- package/src/tool/render-live.ts +288 -0
- package/src/tool/render-result.ts +202 -0
- package/src/tool/render-status.ts +166 -0
- package/src/tool/render-tail.ts +139 -0
- package/src/tool/render.ts +3 -1128
- package/src/tool/run-eval-cell.ts +7 -12
- package/src/tool/types.ts +11 -2
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,78 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.11] - 2026-10-11
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Julia loads handle helpers at first use and installs globals sizing declarations only when a globals diagnostic is requested, reducing fresh-kernel CPU work. This is a partial fix; the remaining startup regression is tracked separately (Refs [#3048](https://github.com/code-yeongyu/senpi/issues/3048)).
|
|
26
|
+
|
|
27
|
+
### Removed
|
|
28
|
+
|
|
29
|
+
## [2026.10.10-12] - 2026-10-10
|
|
30
|
+
|
|
31
|
+
### Breaking Changes
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Benchmark interpreter CPU is sampled by the host after complete result reception, including result encoding and flushing. Saved reports record this boundary and refuse legacy embedded-clock CPU samples that need remeasurement (Refs [#3048](https://github.com/code-yeongyu/senpi/issues/3048)).
|
|
40
|
+
|
|
41
|
+
### Removed
|
|
42
|
+
|
|
43
|
+
## [2026.10.10-11] - 2026-10-09
|
|
44
|
+
|
|
45
|
+
### Breaking Changes
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
### Changed
|
|
50
|
+
|
|
51
|
+
- 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))
|
|
52
|
+
- 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))
|
|
53
|
+
- 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))
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- 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)).
|
|
58
|
+
|
|
59
|
+
- 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)).
|
|
60
|
+
|
|
61
|
+
- 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)).
|
|
62
|
+
|
|
63
|
+
- 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)).
|
|
64
|
+
|
|
65
|
+
- 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)).
|
|
66
|
+
|
|
67
|
+
- 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)).
|
|
68
|
+
|
|
69
|
+
- `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))
|
|
70
|
+
|
|
71
|
+
- 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)).
|
|
72
|
+
|
|
73
|
+
### Removed
|
|
74
|
+
|
|
75
|
+
## [2026.10.10-10] - 2026-10-09
|
|
76
|
+
|
|
77
|
+
### Breaking Changes
|
|
78
|
+
|
|
79
|
+
### Added
|
|
80
|
+
|
|
81
|
+
### Changed
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
### Removed
|
|
86
|
+
|
|
15
87
|
## [2026.10.10-9] - 2026-10-08
|
|
16
88
|
|
|
17
89
|
### Breaking Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.
|
|
3
|
+
"version": "2026.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.
|
|
36
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.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.
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.11"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
44
|
+
"@code-yeongyu/senpi": "2026.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
|
|
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>;
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -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
|
-
"
|
|
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(()
|
|
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
|
-
|
|
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
|
-
|
|
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 ||
|
|
315
|
-
return
|
|
314
|
+
if (!js || typeof js.describeKernelTools !== "function") return undefined;
|
|
315
|
+
return js.describeKernelTools(names);
|
|
316
316
|
}
|
|
317
317
|
|
|
318
318
|
#foreignKernelToolNames(): string[] {
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const SENPI_MEMORY_INTERNALS = Set([:senpi_current_cell, :senpi_connection, :senpi_frame_io, :senpi_stdout_capture, :senpi_stderr_capture, :senpi_protocol_stdin])
|
|
2
|
+
|
|
3
|
+
const SENPI_SIZER_SAMPLE = 1_000
|
|
4
|
+
const SENPI_SIZER_NODE_BUDGET = 5_000
|
|
5
|
+
const SENPI_SIZER_MAX_DEPTH = 64
|
|
6
|
+
const SENPI_SIZER_POINTER = 8
|
|
7
|
+
const SENPI_SIZER_OBJECT = 16
|
|
8
|
+
const SENPI_SIZER_MIN_REPORTED = 1024 * 1024
|
|
9
|
+
|
|
10
|
+
# Sizes a global with sampling and a node budget per global (leaves count too), so one deep global
|
|
11
|
+
# never hides the ones measured after it and a huge container costs a bounded walk. Only concrete Base
|
|
12
|
+
# containers are iterated; any other AbstractDict or AbstractSet is sized as an opaque struct, so no user
|
|
13
|
+
# length or iterate method runs during a memory report.
|
|
14
|
+
mutable struct SenpiSizer
|
|
15
|
+
seen::IdDict{Any, Nothing}
|
|
16
|
+
nodes::Int
|
|
17
|
+
approximate::Bool
|
|
18
|
+
end
|
|
19
|
+
SenpiSizer() = SenpiSizer(IdDict{Any, Nothing}(), 0, false)
|
|
20
|
+
|
|
21
|
+
senpi_over_budget(sizer::SenpiSizer) = sizer.nodes >= SENPI_SIZER_NODE_BUDGET
|
|
22
|
+
|
|
23
|
+
function senpi_size(sizer::SenpiSizer, value, depth::Int)::Int
|
|
24
|
+
sizer.nodes += 1
|
|
25
|
+
value isa Type && return 0
|
|
26
|
+
value isa Union{Number, Char, Bool, Symbol, Nothing} && return isbits(value) ? sizeof(value) : 0
|
|
27
|
+
value isa String && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
28
|
+
if ismutable(value)
|
|
29
|
+
haskey(sizer.seen, value) && return 0
|
|
30
|
+
sizer.seen[value] = nothing
|
|
31
|
+
end
|
|
32
|
+
if depth >= SENPI_SIZER_MAX_DEPTH || senpi_over_budget(sizer)
|
|
33
|
+
sizer.approximate = true
|
|
34
|
+
return SENPI_SIZER_OBJECT
|
|
35
|
+
end
|
|
36
|
+
if value isa Array
|
|
37
|
+
isbitstype(eltype(value)) && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
38
|
+
return SENPI_SIZER_OBJECT + length(value) * SENPI_SIZER_POINTER +
|
|
39
|
+
senpi_sampled(sizer, length(value), depth) do index
|
|
40
|
+
isassigned(value, index) ? value[index] : nothing
|
|
41
|
+
end
|
|
42
|
+
elseif value isa Union{Dict, IdDict}
|
|
43
|
+
count = length(value)
|
|
44
|
+
total = 0
|
|
45
|
+
taken = 0
|
|
46
|
+
for (key, item) in value
|
|
47
|
+
senpi_over_budget(sizer) && break
|
|
48
|
+
total += senpi_size(sizer, key, depth + 1) + senpi_size(sizer, item, depth + 1)
|
|
49
|
+
taken += 1
|
|
50
|
+
taken >= SENPI_SIZER_SAMPLE && break
|
|
51
|
+
end
|
|
52
|
+
taken < count && (sizer.approximate = true)
|
|
53
|
+
return SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
54
|
+
elseif value isa Union{Set, Tuple}
|
|
55
|
+
count = length(value)
|
|
56
|
+
total = 0
|
|
57
|
+
taken = 0
|
|
58
|
+
for item in value
|
|
59
|
+
senpi_over_budget(sizer) && break
|
|
60
|
+
total += senpi_size(sizer, item, depth + 1)
|
|
61
|
+
taken += 1
|
|
62
|
+
taken >= SENPI_SIZER_SAMPLE && break
|
|
63
|
+
end
|
|
64
|
+
taken < count && (sizer.approximate = true)
|
|
65
|
+
return SENPI_SIZER_OBJECT + count * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
66
|
+
end
|
|
67
|
+
isbits(value) && return sizeof(value)
|
|
68
|
+
fields = fieldcount(typeof(value))
|
|
69
|
+
return SENPI_SIZER_OBJECT + fields * SENPI_SIZER_POINTER +
|
|
70
|
+
senpi_sampled(sizer, fields, depth) do index
|
|
71
|
+
isdefined(value, index) ? getfield(value, index) : nothing
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Up to SENPI_SIZER_SAMPLE evenly spaced elements; once the budget runs out part-way, the elements measured
|
|
76
|
+
# so far stand in for the rest.
|
|
77
|
+
function senpi_sampled(at, sizer::SenpiSizer, count::Int, depth::Int)::Int
|
|
78
|
+
count == 0 && return 0
|
|
79
|
+
picks = min(count, SENPI_SIZER_SAMPLE)
|
|
80
|
+
picks < count && (sizer.approximate = true)
|
|
81
|
+
step = count / picks
|
|
82
|
+
total = 0
|
|
83
|
+
measured = 0
|
|
84
|
+
for sample in 1:picks
|
|
85
|
+
if senpi_over_budget(sizer)
|
|
86
|
+
sizer.approximate = true
|
|
87
|
+
break
|
|
88
|
+
end
|
|
89
|
+
total += senpi_size(sizer, at(1 + floor(Int, (sample - 1) * step)), depth + 1)
|
|
90
|
+
measured += 1
|
|
91
|
+
end
|
|
92
|
+
return measured == 0 ? 0 : round(Int, total / measured * count)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
function senpi_largest_globals(limit::Int)
|
|
96
|
+
try
|
|
97
|
+
sizer = SenpiSizer()
|
|
98
|
+
candidates = Tuple{String, Int, Bool}[]
|
|
99
|
+
Base.invokelatest() do
|
|
100
|
+
for name in names(Main, all = true)
|
|
101
|
+
name in SENPI_MEMORY_INTERNALS && continue
|
|
102
|
+
name in (:Main, :Base, :Core, :Ans, :ans) && continue
|
|
103
|
+
lowered = lowercase(string(name))
|
|
104
|
+
startswith(lowered, "senpi_") && continue
|
|
105
|
+
startswith(string(name), "Senpi") && continue
|
|
106
|
+
startswith(string(name), "#") && continue
|
|
107
|
+
isdefined(Main, name) || continue
|
|
108
|
+
value = getfield(Main, name)
|
|
109
|
+
value isa Module && continue
|
|
110
|
+
value isa IO && continue
|
|
111
|
+
value isa Type && continue
|
|
112
|
+
value isa Function && continue
|
|
113
|
+
sizer.approximate = false
|
|
114
|
+
sizer.nodes = 0
|
|
115
|
+
bytes = try
|
|
116
|
+
senpi_size(sizer, value, 0) + SENPI_SIZER_POINTER
|
|
117
|
+
catch
|
|
118
|
+
0
|
|
119
|
+
end
|
|
120
|
+
bytes >= SENPI_SIZER_MIN_REPORTED && push!(candidates, (string(name), bytes, sizer.approximate))
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
sort!(candidates, by = entry -> entry[2], rev = true)
|
|
124
|
+
[approximate ? Dict{String, Any}("name" => name, "bytes" => bytes, "approximate" => true) : Dict{String, Any}("name" => name, "bytes" => bytes) for (name, bytes, approximate) in candidates[1:min(limit, end)]]
|
|
125
|
+
catch
|
|
126
|
+
Dict{String, Any}[]
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -231,12 +231,25 @@ function workpool(agent::AbstractDict, name::AbstractString; mode=nothing, tools
|
|
|
231
231
|
throw(SenpiBridgeError("Host did not return a workpool identity", "workpool_unavailable"))
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
const SENPI_HANDLES_INCLUDED = Ref(false)
|
|
235
|
+
const SENPI_HANDLES_INCLUDE_LOCK = ReentrantLock()
|
|
236
|
+
|
|
237
|
+
function senpi_ensure_handles()
|
|
238
|
+
lock(SENPI_HANDLES_INCLUDE_LOCK) do
|
|
239
|
+
if !SENPI_HANDLES_INCLUDED[]
|
|
240
|
+
include(joinpath(@__DIR__, "handles.jl"))
|
|
241
|
+
SENPI_HANDLES_INCLUDED[] = true
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
nothing
|
|
245
|
+
end
|
|
235
246
|
|
|
236
247
|
# Rich view of an agent record, workpool, completion handle or saved reference; see tool_schema("eval:helpers").
|
|
237
248
|
# The barrier is `Base.wait` on the view types (never a `Main.wait`): wait(handle(node)) or wait([handle(a), handle(b)]).
|
|
238
249
|
function handle(value)
|
|
239
|
-
|
|
250
|
+
senpi_ensure_handles()
|
|
251
|
+
# Julia 1.12 ages global bindings too: resolve the new helper inside the latest world.
|
|
252
|
+
Base.invokelatest(() -> senpi_handle_view(value))
|
|
240
253
|
end
|
|
241
254
|
|
|
242
255
|
function completion(prompt::AbstractString; model="default", system=nothing, schema=nothing, kwargs...)
|
package/src/kernels/jl/runner.jl
CHANGED
|
@@ -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,150 +291,31 @@ function senpi_set_connection(value)
|
|
|
290
291
|
end
|
|
291
292
|
end
|
|
292
293
|
|
|
293
|
-
const
|
|
294
|
+
const SENPI_GLOBALS_INCLUDED = Ref(false)
|
|
294
295
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const SENPI_SIZER_MIN_REPORTED = 1024 * 1024
|
|
303
|
-
|
|
304
|
-
# Sizes a global with sampling and a node budget per global (leaves count too), so one deep global
|
|
305
|
-
# never hides the ones measured after it and a huge container costs a bounded walk. Only concrete Base
|
|
306
|
-
# containers are iterated; any other AbstractDict or AbstractSet is sized as an opaque struct, so no user
|
|
307
|
-
# length or iterate method runs during a memory report.
|
|
308
|
-
mutable struct SenpiSizer
|
|
309
|
-
seen::IdDict{Any, Nothing}
|
|
310
|
-
nodes::Int
|
|
311
|
-
approximate::Bool
|
|
312
|
-
end
|
|
313
|
-
SenpiSizer() = SenpiSizer(IdDict{Any, Nothing}(), 0, false)
|
|
314
|
-
|
|
315
|
-
senpi_over_budget(sizer::SenpiSizer) = sizer.nodes >= SENPI_SIZER_NODE_BUDGET
|
|
316
|
-
|
|
317
|
-
function senpi_size(sizer::SenpiSizer, value, depth::Int)::Int
|
|
318
|
-
sizer.nodes += 1
|
|
319
|
-
value isa Type && return 0
|
|
320
|
-
value isa Union{Number, Char, Bool, Symbol, Nothing} && return isbits(value) ? sizeof(value) : 0
|
|
321
|
-
value isa String && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
322
|
-
if ismutable(value)
|
|
323
|
-
haskey(sizer.seen, value) && return 0
|
|
324
|
-
sizer.seen[value] = nothing
|
|
325
|
-
end
|
|
326
|
-
if depth >= SENPI_SIZER_MAX_DEPTH || senpi_over_budget(sizer)
|
|
327
|
-
sizer.approximate = true
|
|
328
|
-
return SENPI_SIZER_OBJECT
|
|
329
|
-
end
|
|
330
|
-
if value isa Array
|
|
331
|
-
isbitstype(eltype(value)) && return SENPI_SIZER_OBJECT + sizeof(value)
|
|
332
|
-
return SENPI_SIZER_OBJECT + length(value) * SENPI_SIZER_POINTER +
|
|
333
|
-
senpi_sampled(sizer, length(value), depth) do index
|
|
334
|
-
isassigned(value, index) ? value[index] : nothing
|
|
335
|
-
end
|
|
336
|
-
elseif value isa Union{Dict, IdDict}
|
|
337
|
-
count = length(value)
|
|
338
|
-
total = 0
|
|
339
|
-
taken = 0
|
|
340
|
-
for (key, item) in value
|
|
341
|
-
senpi_over_budget(sizer) && break
|
|
342
|
-
total += senpi_size(sizer, key, depth + 1) + senpi_size(sizer, item, depth + 1)
|
|
343
|
-
taken += 1
|
|
344
|
-
taken >= SENPI_SIZER_SAMPLE && break
|
|
345
|
-
end
|
|
346
|
-
taken < count && (sizer.approximate = true)
|
|
347
|
-
return SENPI_SIZER_OBJECT + count * 2 * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
348
|
-
elseif value isa Union{Set, Tuple}
|
|
349
|
-
count = length(value)
|
|
350
|
-
total = 0
|
|
351
|
-
taken = 0
|
|
352
|
-
for item in value
|
|
353
|
-
senpi_over_budget(sizer) && break
|
|
354
|
-
total += senpi_size(sizer, item, depth + 1)
|
|
355
|
-
taken += 1
|
|
356
|
-
taken >= SENPI_SIZER_SAMPLE && break
|
|
357
|
-
end
|
|
358
|
-
taken < count && (sizer.approximate = true)
|
|
359
|
-
return SENPI_SIZER_OBJECT + count * SENPI_SIZER_POINTER + (taken == 0 ? 0 : div(total * count, taken))
|
|
360
|
-
end
|
|
361
|
-
isbits(value) && return sizeof(value)
|
|
362
|
-
fields = fieldcount(typeof(value))
|
|
363
|
-
return SENPI_SIZER_OBJECT + fields * SENPI_SIZER_POINTER +
|
|
364
|
-
senpi_sampled(sizer, fields, depth) do index
|
|
365
|
-
isdefined(value, index) ? getfield(value, index) : nothing
|
|
366
|
-
end
|
|
367
|
-
end
|
|
368
|
-
|
|
369
|
-
# Up to SENPI_SIZER_SAMPLE evenly spaced elements; once the budget runs out part-way, the elements measured
|
|
370
|
-
# so far stand in for the rest.
|
|
371
|
-
function senpi_sampled(at, sizer::SenpiSizer, count::Int, depth::Int)::Int
|
|
372
|
-
count == 0 && return 0
|
|
373
|
-
picks = min(count, SENPI_SIZER_SAMPLE)
|
|
374
|
-
picks < count && (sizer.approximate = true)
|
|
375
|
-
step = count / picks
|
|
376
|
-
total = 0
|
|
377
|
-
measured = 0
|
|
378
|
-
for sample in 1:picks
|
|
379
|
-
if senpi_over_budget(sizer)
|
|
380
|
-
sizer.approximate = true
|
|
381
|
-
break
|
|
296
|
+
function senpi_memory_globals(limit::Int)
|
|
297
|
+
if !SENPI_GLOBALS_INCLUDED[]
|
|
298
|
+
try
|
|
299
|
+
include(joinpath(@__DIR__, "globals.jl"))
|
|
300
|
+
SENPI_GLOBALS_INCLUDED[] = true
|
|
301
|
+
catch
|
|
302
|
+
return Dict{String, Any}[]
|
|
382
303
|
end
|
|
383
|
-
total += senpi_size(sizer, at(1 + floor(Int, (sample - 1) * step)), depth + 1)
|
|
384
|
-
measured += 1
|
|
385
304
|
end
|
|
386
|
-
|
|
305
|
+
Base.invokelatest(() -> senpi_largest_globals(limit))
|
|
387
306
|
end
|
|
388
307
|
|
|
389
|
-
function
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
name in (:Main, :Base, :Core, :Ans, :ans) && continue
|
|
397
|
-
lowered = lowercase(string(name))
|
|
398
|
-
startswith(lowered, "senpi_") && continue
|
|
399
|
-
startswith(string(name), "Senpi") && continue
|
|
400
|
-
startswith(string(name), "#") && continue
|
|
401
|
-
isdefined(Main, name) || continue
|
|
402
|
-
value = getfield(Main, name)
|
|
403
|
-
value isa Module && continue
|
|
404
|
-
value isa IO && continue
|
|
405
|
-
value isa Type && continue
|
|
406
|
-
value isa Function && continue
|
|
407
|
-
sizer.approximate = false
|
|
408
|
-
sizer.nodes = 0
|
|
409
|
-
bytes = try
|
|
410
|
-
senpi_size(sizer, value, 0) + SENPI_SIZER_POINTER
|
|
411
|
-
catch
|
|
412
|
-
0
|
|
413
|
-
end
|
|
414
|
-
bytes >= SENPI_SIZER_MIN_REPORTED && push!(candidates, (string(name), bytes, sizer.approximate))
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
sort!(candidates, by = entry -> entry[2], rev = true)
|
|
418
|
-
[approximate ? Dict{String, Any}("name" => name, "bytes" => bytes, "approximate" => true) : Dict{String, Any}("name" => name, "bytes" => bytes) for (name, bytes, approximate) in candidates[1:min(limit, end)]]
|
|
419
|
-
catch
|
|
420
|
-
Dict{String, Any}[]
|
|
421
|
-
end
|
|
422
|
-
end
|
|
423
|
-
|
|
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
|
|
308
|
+
function senpi_needs_handles(expression)
|
|
309
|
+
if expression isa Symbol
|
|
310
|
+
expression in (:handle, :completion, :eval, :include, :getfield, :getglobal, :deserialize) && return true
|
|
311
|
+
name = string(expression)
|
|
312
|
+
return startswith(name, "SenpiHandle") || startswith(name, "senpi_handle") ||
|
|
313
|
+
startswith(name, "senpi_wait") ||
|
|
314
|
+
(startswith(name, "SENPI_") && (occursin("HANDLE", name) || occursin("WAIT", name)))
|
|
436
315
|
end
|
|
316
|
+
expression isa QuoteNode && return senpi_needs_handles(expression.value)
|
|
317
|
+
expression isa Expr && return any(senpi_needs_handles, expression.args)
|
|
318
|
+
false
|
|
437
319
|
end
|
|
438
320
|
|
|
439
321
|
function senpi_run_cell(message)
|
|
@@ -446,19 +328,23 @@ function senpi_run_cell(message)
|
|
|
446
328
|
if parsed isa Expr && parsed.head === :error
|
|
447
329
|
error(string(parsed.args[1]))
|
|
448
330
|
end
|
|
331
|
+
# Install bindings before user functions/type annotations capture their world.
|
|
332
|
+
!SENPI_HANDLES_INCLUDED[] && senpi_needs_handles(parsed) && senpi_ensure_handles()
|
|
449
333
|
value = Core.eval(Main, parsed)
|
|
450
334
|
flush(stdout)
|
|
451
335
|
flush(stderr)
|
|
452
336
|
yield()
|
|
453
337
|
frame = Dict{String, Any}("type" => "result", "cellId" => cell_id, "ok" => true, "durationMs" => round(Int, (time() - started) * 1000))
|
|
454
|
-
value !== nothing && senpi_should_display_result(parsed)
|
|
455
|
-
|
|
456
|
-
|
|
338
|
+
if value !== nothing && senpi_should_display_result(parsed)
|
|
339
|
+
# Core.eval does not advance this caller's world after a first lazy include.
|
|
340
|
+
frame["valueRepr"] = SENPI_HANDLES_INCLUDED[] ? Base.invokelatest(senpi_json, value) : senpi_json(value)
|
|
341
|
+
end
|
|
457
342
|
senpi_emit(frame)
|
|
458
343
|
catch error
|
|
459
344
|
senpi_emit(Dict("type" => "result", "cellId" => cell_id, "ok" => false, "error" => senpi_error(error), "durationMs" => round(Int, (time() - started) * 1000)))
|
|
460
345
|
finally
|
|
461
346
|
global senpi_current_cell = nothing
|
|
347
|
+
global senpi_memory_cell = cell_id
|
|
462
348
|
end
|
|
463
349
|
end
|
|
464
350
|
|
|
@@ -472,10 +358,16 @@ while !eof(SENPI_ORIGINAL_STDIN)
|
|
|
472
358
|
if kind == "init"
|
|
473
359
|
senpi_emit(Dict("type" => "status", "event" => Dict("op" => "kernel-startup", "stage" => "host-init")))
|
|
474
360
|
senpi_set_connection(get(message, "connection", nothing))
|
|
475
|
-
|
|
476
|
-
senpi_emit(Dict("type" => "ready"))
|
|
361
|
+
senpi_emit(Dict("type" => "ready", "memoryGlobals" => true))
|
|
477
362
|
elseif kind == "run"
|
|
363
|
+
global senpi_memory_cell = nothing
|
|
478
364
|
senpi_run_cell(message)
|
|
365
|
+
elseif kind == "memory-globals"
|
|
366
|
+
cell_id = get(message, "cellId", nothing)
|
|
367
|
+
if cell_id == senpi_memory_cell && senpi_current_cell === nothing
|
|
368
|
+
senpi_emit(Dict("type" => "memory-globals-result", "cellId" => cell_id, "globals" => senpi_memory_globals(5)))
|
|
369
|
+
global senpi_memory_cell = nothing
|
|
370
|
+
end
|
|
479
371
|
elseif kind == "close"
|
|
480
372
|
senpi_emit(Dict("type" => "closed"))
|
|
481
373
|
break
|