@code-yeongyu/senpi-codemode 2026.10.10-2 → 2026.10.10-4
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 +34 -0
- package/README.md +80 -2
- package/package.json +4 -4
- package/src/bridge/protocol.ts +8 -3
- package/src/extension/kernel-registration.ts +7 -2
- package/src/extension/runtime-factory.ts +8 -1
- package/src/extension/runtime-info.ts +6 -2
- package/src/extension/session-manager.ts +9 -2
- package/src/kernels/AGENTS.md +2 -0
- package/src/kernels/js/context-manager.ts +23 -24
- package/src/kernels/js/control-frames.ts +24 -0
- package/src/kernels/js/kernel-contract.ts +14 -1
- package/src/kernels/js/kernel-memory-bridge.ts +1 -1
- package/src/kernels/js/kernel-memory.ts +79 -0
- package/src/kernels/js/process-drain-scan.d.ts +8 -0
- package/src/kernels/js/process-drain-scan.js +28 -0
- package/src/kernels/js/process-entry.js +462 -0
- package/src/kernels/js/process-memory-host.ts +83 -0
- package/src/kernels/js/process-worker.ts +298 -0
- package/src/kernels/js/run-queue.ts +7 -0
- package/src/kernels/js/worker-core.js +4 -2
- package/src/kernels/js/worker-cwd.js +5 -2
- package/src/kernels/js/worker-host.ts +9 -2
- package/src/kernels/js/worker-slot.ts +9 -0
- package/src/kernels/js/worker-startup.ts +48 -1
- package/src/kernels/js/worker-webview.js +8 -0
- package/src/kernels/sandbox/sandbox-cell.ts +26 -1
- package/src/kernels/shared/subprocess-process.ts +14 -0
- package/src/tool/AGENTS.md +1 -0
- package/src/tool/cell-runtime.ts +4 -2
- package/src/tool/detached-cell-manager.ts +4 -4
- package/src/tool/detached-cell-snapshot.ts +17 -0
- package/src/tool/detached-eval-result.ts +13 -7
- package/src/tool/interrupt-note.ts +4 -5
- package/src/tool/render.ts +2 -0
- package/src/tool/run-eval-cell.ts +3 -2
- package/src/tool/runtime-label.ts +2 -1
- package/src/tool/types.ts +2 -0
package/CHANGELOG.md
CHANGED
|
@@ -12,6 +12,40 @@
|
|
|
12
12
|
|
|
13
13
|
### Removed
|
|
14
14
|
|
|
15
|
+
## [2026.10.10-4] - 2026-10-06
|
|
16
|
+
|
|
17
|
+
### Breaking Changes
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- A detached eval cell that waits for its kernel to start now says so (`waiting for the js kernel to be ready`) instead of `queued behind in the js kernel` with an empty predecessor ([#2790](https://github.com/code-yeongyu/senpi/issues/2790)).
|
|
26
|
+
|
|
27
|
+
- A stopped (or failed) detached cell's result and notification show its buffered output instead of the live `1/1 cells running` frame, so a cancelled cell no longer reads as still running. The kernel-state note now says plainly whether the kernel was restarted: `The JavaScript worker was not restarted; variables from earlier cells are kept.` instead of `... remains running; its existing variables are preserved.`, or `The JavaScript worker was restarted; variables from earlier cells are lost.` instead of `... was unresponsive to interrupt and was restarted ...` (the worker may have answered the interrupt and still needed a restart) ([#2791](https://github.com/code-yeongyu/senpi/issues/2791)).
|
|
28
|
+
|
|
29
|
+
- An isolated (`isolate: true`) eval cell's result names QuickJS as its runtime (`quickjs <version>, sandbox`) instead of the persistent kernel's Bun or Node runtime ([#2811](https://github.com/code-yeongyu/senpi/issues/2811)).
|
|
30
|
+
|
|
31
|
+
### Removed
|
|
32
|
+
|
|
33
|
+
## [2026.10.10-3] - 2026-10-06
|
|
34
|
+
|
|
35
|
+
### Breaking Changes
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- An opt-in process-isolated JavaScript kernel (`isolation.js: "process"` or `SENPI_CODEMODE_JS_ISOLATION=process`) runs each JavaScript kernel in its own subprocess instead of a worker thread, so a kernel crash (`SIGSEGV`, out-of-memory, `process.exit`, an uncaught error) can no longer take down the host session; the next cell runs on a replacement child with a restart notice naming the crash. It isolates crashes, not hostile code: a cell is trusted as in worker mode, and hostile code belongs in `isolate: true` sandbox cells ([#2752](https://github.com/code-yeongyu/senpi/issues/2752) tracks hostile-cell isolation). The child runs on the host's own runtime, exits as soon as its host is gone (including `SIGKILL`), carries large output and `BigInt`/`undefined` values as worker mode does, and its frames carry a per-process token so stray output is never taken for a frame. The default stays `"worker"` and worker-mode behaviour is unchanged ([#2452](https://github.com/code-yeongyu/senpi/issues/2452)).
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- The README now documents every eval surface (helpers, magic cells, settings, JavaScript isolation modes and sandbox cells), and CI checks it against the helper census, so a new helper cannot ship undocumented ([#2787](https://github.com/code-yeongyu/senpi/pull/2787)).
|
|
44
|
+
|
|
45
|
+
### Fixed
|
|
46
|
+
|
|
47
|
+
### Removed
|
|
48
|
+
|
|
15
49
|
## [2026.10.10-2] - 2026-10-05
|
|
16
50
|
|
|
17
51
|
### Breaking Changes
|
package/README.md
CHANGED
|
@@ -59,6 +59,75 @@ task-tool names are known.
|
|
|
59
59
|
A missing optional interpreter removes that language from the session's `eval`
|
|
60
60
|
schema; it is not an installation failure.
|
|
61
61
|
|
|
62
|
+
### JavaScript process isolation
|
|
63
|
+
|
|
64
|
+
By default the JavaScript kernel runs as a worker thread on the host's own
|
|
65
|
+
runtime (`isolation.js: "worker"`). Setting `isolation.js: "process"` (or
|
|
66
|
+
`SENPI_CODEMODE_JS_ISOLATION=process`, which wins over the file) runs each
|
|
67
|
+
JavaScript kernel in its own subprocess instead, so a kernel crash (a native
|
|
68
|
+
`SIGSEGV`, an out-of-memory, `process.exit`, an uncaught error) cannot take down
|
|
69
|
+
the host session. The next cell runs on a replacement child, and its result
|
|
70
|
+
carries the restart notice and names the crash.
|
|
71
|
+
|
|
72
|
+
**Trust model.** Process mode isolates crashes, not hostile code. A cell runs in
|
|
73
|
+
the same process that talks to the host, so it can reach everything that process
|
|
74
|
+
holds, exactly as a worker-mode cell can. The frame token described below guards
|
|
75
|
+
only against accidental corruption of the channel. Hostile code belongs in
|
|
76
|
+
`isolate: true` sandbox cells. Hostile-cell isolation for process mode is tracked
|
|
77
|
+
in [#2752](https://github.com/code-yeongyu/senpi/issues/2752).
|
|
78
|
+
|
|
79
|
+
**Core dumps (Linux).** The kernel child marks itself non-dumpable at start
|
|
80
|
+
(`prctl(PR_SET_DUMPABLE, 0)`). A Linux system that pipes core dumps to
|
|
81
|
+
`systemd-coredump` or apport otherwise holds a crashed child until its dump is
|
|
82
|
+
read, which froze the cell for about 30 s; without the dump the crash is reported
|
|
83
|
+
at once, still naming its signal. Two side effects: no core file is written for a
|
|
84
|
+
crash of the kernel child itself, and a same-user debugger cannot attach to it
|
|
85
|
+
(`ptrace` needs a dumpable process). A cell's own subprocesses are unaffected,
|
|
86
|
+
because `exec` resets the flag. Set `SENPI_KERNEL_CORE_DUMPS=1` to keep dumps and
|
|
87
|
+
debugger access while debugging the kernel.
|
|
88
|
+
|
|
89
|
+
How it works:
|
|
90
|
+
|
|
91
|
+
- **Runtime.** The child runs on the host's own runtime: bun when senpi runs on
|
|
92
|
+
Bun, node otherwise. A compiled binary whose executable is neither falls back to
|
|
93
|
+
`bun` or `node` on `PATH`, preferring the host's kind. The badge names the
|
|
94
|
+
runtime that actually runs the child, for example `js (bun 1.4.x, process)`.
|
|
95
|
+
- **Frames.** The host writes a random token as the child's first line on fd 0,
|
|
96
|
+
and every frame the child sends carries it. The host parses only lines that
|
|
97
|
+
carry the token; any other line (a cell's raw fd write, a child process's
|
|
98
|
+
output) is delivered as text. Frames are written in full. Large text is split
|
|
99
|
+
below the 10 MiB frame limit, and a result too large to return fails its cell,
|
|
100
|
+
not the kernel. A `BigInt` or an `undefined` field reaches the host as it does
|
|
101
|
+
in worker mode.
|
|
102
|
+
- **File descriptors.** Under Bun, the control channel moves to a private
|
|
103
|
+
duplicate of fd 0, fd 0 becomes `/dev/null`, and fd 1 is re-pointed at a
|
|
104
|
+
pipe that a reader thread turns into `text` frames as it is written. The pipe is kept
|
|
105
|
+
blocking (a child that switches it to non-blocking is undone before every cell and around
|
|
106
|
+
every child process a cell starts),
|
|
107
|
+
so output is never held in memory while a cell is busy. A cell's direct
|
|
108
|
+
`process.stdout.write` or fd 1 write (or a child process inheriting fd 1)
|
|
109
|
+
arrives before that cell's result. In a bun child, raw fd 1 bytes are not
|
|
110
|
+
interleaved with `console` output in the order they were written. A node
|
|
111
|
+
child cannot move its fds: cell output shares the channel, and fd 0 is the
|
|
112
|
+
channel. If libc cannot be loaded through `bun:ffi`, a bun child runs
|
|
113
|
+
the same way and still starts; the first cell's stderr says why.
|
|
114
|
+
- **Lifetime.** The child exits as soon as its control channel closes. That
|
|
115
|
+
covers every way the host ends, including `SIGKILL`. A watchdog thread also
|
|
116
|
+
compares the parent pid with the one recorded at start, so a child whose cell
|
|
117
|
+
never yields (a busy loop, a blocking call) still ends with its host, including
|
|
118
|
+
under a Linux subreaper. A process-mode kernel never outlives its host.
|
|
119
|
+
- **Failed start.** There is no inline fallback in process mode. A failed start
|
|
120
|
+
settles the waiting cell with a capability-gap result naming the missing
|
|
121
|
+
runtime (`Install bun or node, or use isolation.js: "worker"`). The result's
|
|
122
|
+
memory reading is the child's process footprint.
|
|
123
|
+
|
|
124
|
+
The default stays `"worker"`, and worker-mode behaviour is unchanged.
|
|
125
|
+
|
|
126
|
+
To run the `environments` suites with bun children (the runtime the product
|
|
127
|
+
uses), run them under bun: `SENPI_CODEMODE_JS_ISOLATION=process bunx --bun vitest
|
|
128
|
+
run test/environments`. Under plain `vitest` the children follow the test
|
|
129
|
+
runner's runtime, which is node.
|
|
130
|
+
|
|
62
131
|
Python startup waits for the interpreter's `ready` event. It reports progress
|
|
63
132
|
through `stdlib-imports`, `runtime-init`, and `host-init`; advancing to the next
|
|
64
133
|
stage resets an inactivity guard rather than consuming a total startup budget.
|
|
@@ -266,7 +335,7 @@ Configuration is loaded in this order:
|
|
|
266
335
|
| `prompt.advertiseHelpers` | `false` | When `true`, one pointer line to `tool_schema('eval:helpers')` is appended to the eval description. |
|
|
267
336
|
| `kernelTools.enabled` | `true` | Allows cells to define kernel tools. `false` makes JavaScript `tool(fn)` and Python `@tool` refuse with `tools_unavailable`, naming this setting. |
|
|
268
337
|
|
|
269
|
-
Every key in the table takes the effect its row describes.
|
|
338
|
+
Every key in the table takes the effect its row describes.
|
|
270
339
|
|
|
271
340
|
`SENPI_CODEMODE_PY`, `SENPI_CODEMODE_JS`, `SENPI_CODEMODE_RB`, and
|
|
272
341
|
`SENPI_CODEMODE_JL` override the corresponding file setting. `1` or `true`
|
|
@@ -288,6 +357,8 @@ options object and asynchronous helpers are `await`-able.
|
|
|
288
357
|
| --- | --- |
|
|
289
358
|
| `display(value)` | Emits text, structured JSON, markdown, or image display data. Images reach the model only through `display`: pass a figure, raw image bytes (PNG/JPEG/GIF/WebP/BMP sniffed), a `data:` URL, a `Blob`-like or `Bun.Image` value, a marshalled tool result, or one of its `images[i]` frames. |
|
|
290
359
|
| `print(value, ...)` | Emits text output. |
|
|
360
|
+
| `text(value)` (rb, jl) | Emits `value` as one text item without separators (`print` joins its arguments and calls it). |
|
|
361
|
+
| `display_image(base64, mime_type)` (rb, jl) | Emits an image from base64 data; `mime_type` defaults to `image/png` (Ruby: `mime_type:` keyword). |
|
|
291
362
|
| `read(path, offset?, limit?)` | Reads text with 1-indexed line slicing. `local://` paths resolve under the session artifact root. |
|
|
292
363
|
| `write(path, content)` | Creates parent directories and writes text. `local://` paths persist in the session artifact root. |
|
|
293
364
|
| `env(key?, value?)` | Reads all kernel environment values, one value, or sets one value. Includes the session's `PI_*` values (see [Session environment](#session-environment)). |
|
|
@@ -299,7 +370,7 @@ options object and asynchronous helpers are `await`-able.
|
|
|
299
370
|
| `completion(prompt, model?, system?, schema?)` | Requests a one-shot host completion; `schema` asks the host to parse structured output. |
|
|
300
371
|
| `agent(prompt, ...)` | Delegates to the configured active `taskTools.task` tool. Supports background handles and structured JSON results. |
|
|
301
372
|
| `wait(handles, timeout?, mode?)` | Blocks the cell until the given handles settle (agent handle records, `handle()` views, completion handles, closed workpools, or saved `{kind, id, run_epoch}` references). `mode` is `all` (values in input order; the first failed, cancelled, or lost handle raises), `any` (`{index, ref, value}` of the first success), or `settled` (one outcome per input slot). `timeout` is wall-clock seconds from entry; on expiry `eval_wait_timeout` is raised and nothing is cancelled. Rides the bridge-call path, so the run budget pauses while parked. Agent and workpool handles need the host's `EvalHandleHost` capability (`eval_wait_unavailable` without it); completion handles always work. Julia extends `Base.wait` for handle views (`wait(handle(node))`). Details: `tool_schema("eval:wait")`. |
|
|
302
|
-
| `handle(node \| ref \| {pool_id})` | Returns a rich view: the legacy record's fields plus a non-enumerable `control` (Python: attribute on a `dict` subclass; Ruby: singleton method) with
|
|
373
|
+
| `handle(node \| ref \| {pool_id})` | Returns a rich view: the legacy record's fields plus a non-enumerable `control` (Python: attribute on a `dict` subclass; Ruby: singleton method) with `.status()`, `.output(format?, offset?, limit?)`, `.send(message)` (agent handles only), `.cancel()` (idempotent for that run epoch; never touches a successor run), and `.wait(timeout?)`. Every control call is fenced by owner, id, and `run_epoch` inside the task owner (`eval_handle_stale`, `eval_handle_forbidden`). `completion(prompt, handle: true)` returns such a view for a host completion. The `agent(..., handle: true)` record itself is unchanged. Details: `tool_schema("eval:helpers")`. |
|
|
303
374
|
| `workpool(agent, name, mode?, tools?)` | Creates a thin adapter over the normal host `workpool` tool; exposes `pool_id`, `push(items)`, `close()`, `inspect()`, and `cancel()`. JS awaits creation and operations. `tools` is a list of kernel-tool names this cell defined; the pool's workers may call exactly those, the host refuses any name the caller doesn't hold, and a value that isn't a list of names raises `invalid_tools`. Ruby and Julia define no kernel tools, so they have nothing to grant. |
|
|
304
375
|
| `output(ids, format?, offset?, limit?)` | Delegates transcript retrieval to the configured active `taskTools.output` tool. |
|
|
305
376
|
| `parallel(thunks)` | Runs thunks through the configured bounded pool while preserving input order. |
|
|
@@ -491,6 +562,13 @@ original sizes, then a plain-path notice such as
|
|
|
491
562
|
package.
|
|
492
563
|
- Task transcript formats are limited to full (`raw`) and trailing (`tail`)
|
|
493
564
|
output. Query, JSON, and stripped metadata formats are task-engine concerns.
|
|
565
|
+
- There is no `pool.wait()`: a workpool's aggregate is delivered by the host after
|
|
566
|
+
`close()`, so a kernel never blocks on another engine's workers.
|
|
567
|
+
- There is no `code_mode_only` mode and no speculative cell execution; both are
|
|
568
|
+
deferred to a separate design (omo#9232) rather than approximated here.
|
|
569
|
+
- Kernels are never shared across owners: each session (and each in-process child
|
|
570
|
+
with its own eval tool) owns its kernels, and handles are fenced by owner, id and
|
|
571
|
+
run epoch.
|
|
494
572
|
|
|
495
573
|
## Security and lifecycle
|
|
496
574
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@code-yeongyu/senpi-codemode",
|
|
3
|
-
"version": "2026.10.10-
|
|
3
|
+
"version": "2026.10.10-4",
|
|
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-
|
|
36
|
+
"@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.10-4",
|
|
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-
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.10-4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.10-
|
|
44
|
+
"@code-yeongyu/senpi": "2026.10.10-4"
|
|
45
45
|
},
|
|
46
46
|
"keywords": [
|
|
47
47
|
"senpi",
|
package/src/bridge/protocol.ts
CHANGED
|
@@ -237,10 +237,15 @@ export function parseBridgeJsonLine(line: string, options: BridgeFrameOptions =
|
|
|
237
237
|
export function decodeBridgeFrame(line: string, options: BridgeFrameOptions = {}): BridgeMessageDecodeResult {
|
|
238
238
|
const parsed = parseBridgeJsonLine(line, options);
|
|
239
239
|
if (!parsed.ok) return parsed;
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
return validateBridgeMessage(parsed.value);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Checks an already-parsed frame against the bridge schema (for transports that transform values before checking). */
|
|
244
|
+
export function validateBridgeMessage(value: unknown): BridgeMessageDecodeResult {
|
|
245
|
+
if (Value.Check(bridgeMessageSchema, value)) {
|
|
246
|
+
return { ok: true, message: value };
|
|
242
247
|
}
|
|
243
|
-
const firstError = Value.Errors(bridgeMessageSchema,
|
|
248
|
+
const firstError = Value.Errors(bridgeMessageSchema, value)[0];
|
|
244
249
|
const message = firstError ? `Invalid bridge message: ${firstError.message}` : "Invalid bridge message";
|
|
245
250
|
return { ok: false, error: { code: "invalid_message", message } };
|
|
246
251
|
}
|
|
@@ -63,11 +63,16 @@ export class SessionKernelRegistrations {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
export function javaScriptKernelMemory(kernel: JavaScriptKernel): RegisteredKernelSource {
|
|
66
|
+
const measure = kernel.mode === "process" ? "footprint" : "heap";
|
|
66
67
|
return {
|
|
67
|
-
measure
|
|
68
|
+
measure,
|
|
68
69
|
lastLiveBytes: () => kernel.lastLiveBytes,
|
|
69
70
|
busy: () => kernel.queueSnapshot().activeCellId !== null,
|
|
70
|
-
queryMemory: () =>
|
|
71
|
+
queryMemory: async () => {
|
|
72
|
+
const reading = await kernel.queryMemory();
|
|
73
|
+
return reading === undefined ? undefined : { liveBytes: reading.liveBytes, measure: reading.measure };
|
|
74
|
+
},
|
|
75
|
+
...(measure === "footprint" ? { pid: () => kernel.processPid } : {}),
|
|
71
76
|
};
|
|
72
77
|
}
|
|
73
78
|
|
|
@@ -2,6 +2,7 @@ import { type ExtensionContext, withBundledBunCommands } from "@code-yeongyu/sen
|
|
|
2
2
|
import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
|
|
3
3
|
import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
|
|
4
4
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
5
|
+
import { resolveJsIsolation } from "../config/feature-settings.ts";
|
|
5
6
|
import { withoutUntrustedInterpreter } from "../config/project-trust.ts";
|
|
6
7
|
import {
|
|
7
8
|
type CodemodeSettings,
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
getInterpreterAvailability,
|
|
18
19
|
type InterpreterAvailability,
|
|
19
20
|
} from "../interpreters/detect.ts";
|
|
21
|
+
import { processRuntimeInfo } from "../kernels/js/process-worker.ts";
|
|
20
22
|
import { sessionEnvironmentFrom } from "../kernels/session-env.ts";
|
|
21
23
|
import { resolveSessionArtifactsDir } from "../output/streaming-output.ts";
|
|
22
24
|
import type { EnabledEvalLanguages, EvalLanguage, EvalRuntimes } from "../tool/types.ts";
|
|
@@ -81,6 +83,7 @@ export async function createRuntime(
|
|
|
81
83
|
...(pyReason === undefined ? [] : [pyReason]),
|
|
82
84
|
]);
|
|
83
85
|
const enabledLanguages = enabledLanguagesFrom(settings, availability);
|
|
86
|
+
const jsProcessIsolation = resolveJsIsolation(settings) === "process";
|
|
84
87
|
const artifacts = resolveSessionArtifactsDir(ctx.sessionManager.getSessionFile());
|
|
85
88
|
const activeTools = new Set(pi.getActiveTools());
|
|
86
89
|
const executeTool = createExecuteTool(pi, activeTools);
|
|
@@ -108,7 +111,11 @@ export async function createRuntime(
|
|
|
108
111
|
parallelPoolWidth,
|
|
109
112
|
manager,
|
|
110
113
|
enabledLanguages,
|
|
111
|
-
runtimes: runtimesFromAvailability(
|
|
114
|
+
runtimes: runtimesFromAvailability(
|
|
115
|
+
availability,
|
|
116
|
+
(jsProcessIsolation ? processRuntimeInfo(undefined) : undefined) ??
|
|
117
|
+
jsRuntimeInfo(process.versions, process.execPath, jsProcessIsolation),
|
|
118
|
+
),
|
|
112
119
|
settings,
|
|
113
120
|
artifactsDir: artifacts.dir,
|
|
114
121
|
executeTool,
|
|
@@ -10,10 +10,14 @@ export interface JsRuntimeVersions {
|
|
|
10
10
|
export function jsRuntimeInfo(
|
|
11
11
|
versions: JsRuntimeVersions = process.versions,
|
|
12
12
|
execPath: string = process.execPath,
|
|
13
|
+
processIsolation = false,
|
|
13
14
|
): EvalRuntimeInfo {
|
|
14
15
|
const bun = versions.bun;
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const base =
|
|
17
|
+
bun !== undefined && bun.length > 0
|
|
18
|
+
? { name: "bun", version: bun, path: execPath }
|
|
19
|
+
: { name: "node", version: versions.node, path: execPath };
|
|
20
|
+
return processIsolation ? { ...base, isolation: "process" } : base;
|
|
17
21
|
}
|
|
18
22
|
|
|
19
23
|
/** Short host-line segment, e.g. "node 26.7.0" or "bun 1.4.0". */
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { join } from "node:path";
|
|
2
2
|
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
3
|
+
import { readProcessFootprint } from "@code-yeongyu/senpi";
|
|
3
4
|
import { type BridgeServerHandle, startBridgeServer } from "../bridge/http-server.ts";
|
|
4
5
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
6
|
import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
|
|
6
|
-
import { resolveKernelToolsEnabled } from "../config/feature-settings.ts";
|
|
7
|
+
import { resolveJsIsolation, resolveKernelToolsEnabled } from "../config/feature-settings.ts";
|
|
7
8
|
import { resolveKernelMemoryThresholds } from "../config/memory-settings.ts";
|
|
8
9
|
import { collectOrphanedChildren } from "../host-sdk.ts";
|
|
9
10
|
import { JavaScriptKernel } from "../kernels/js/context-manager.ts";
|
|
@@ -254,6 +255,8 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
254
255
|
this.#options.localRoots ??
|
|
255
256
|
(this.#options.artifactsDir ? { local: join(this.#options.artifactsDir, "local") } : undefined);
|
|
256
257
|
if (language === "js") {
|
|
258
|
+
const memoryThresholds = resolveKernelMemoryThresholds(this.#options.settings.memory);
|
|
259
|
+
const processIsolation = resolveJsIsolation(this.#options.settings) === "process";
|
|
257
260
|
const kernel = new JavaScriptKernel({
|
|
258
261
|
sessionId: this.#options.sessionId,
|
|
259
262
|
cwd: this.#options.cwd,
|
|
@@ -261,8 +264,12 @@ class DefaultCodemodeSessionManager implements CodemodeSessionManager {
|
|
|
261
264
|
onMessage,
|
|
262
265
|
hostToolNames: () => this.#options.listTools?.().map((tool) => tool.name) ?? [],
|
|
263
266
|
foreignLanguageNames: () => this.#foreignKernelToolNames(),
|
|
264
|
-
memory:
|
|
267
|
+
memory: memoryThresholds,
|
|
265
268
|
collectOrphanedChildren,
|
|
269
|
+
...(processIsolation ? { isolation: "process" as const } : {}),
|
|
270
|
+
...(processIsolation && memoryThresholds !== undefined
|
|
271
|
+
? { processMemory: { thresholds: memoryThresholds, readFootprint: readProcessFootprint } }
|
|
272
|
+
: {}),
|
|
266
273
|
...(resolveKernelToolsEnabled(this.#options.settings) ? {} : { kernelToolsEnabled: false }),
|
|
267
274
|
...(this.#options.sessionEnv ? { sessionEnv: this.#options.sessionEnv } : {}),
|
|
268
275
|
...(localRoots ? { localRoots: { ...localRoots } } : {}),
|
package/src/kernels/AGENTS.md
CHANGED
|
@@ -16,6 +16,8 @@ runner/prelude assets).
|
|
|
16
16
|
| Julia kernel | `jl/kernel.ts` + `jl/prelude.jl`, `jl/runner.jl` |
|
|
17
17
|
| Shared subprocess layer | `shared/subprocess-kernel.ts`, `subprocess-{contract,process,queue,run}.ts`, `runtime-asset.ts` |
|
|
18
18
|
| Session environment | `session-env.ts` (PI_* contract shared by all kernels; mirrors the core bash tool) |
|
|
19
|
+
| JS process isolation | `js/process-worker.ts` (host side), `js/process-entry.js` (child entry), `js/kernel-memory.ts`, `js/control-frames.ts` |
|
|
20
|
+
| Sandbox cells | `sandbox/sandbox-cell.ts` (host executor); `sandbox/vendor/pi-codemode/**` (vendored runtime, see its `VENDORED.md`) |
|
|
19
21
|
|
|
20
22
|
## CONVENTIONS
|
|
21
23
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
-
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
2
|
import type { KernelInterruptHandle } from "../../tool/types.ts";
|
|
4
3
|
import { KernelToolHostPump } from "../shared/kernel-tools-pump.ts";
|
|
5
4
|
import { ActiveCellControl } from "./active-cell-control.ts";
|
|
6
5
|
import { dispatchCell } from "./cell-dispatch.ts";
|
|
6
|
+
import { consumeControlFrame } from "./control-frames.ts";
|
|
7
7
|
import { HostEntries } from "./host-entries.ts";
|
|
8
8
|
import { DEFAULT_INTERRUPT_BOUNDS, JS_INTERRUPT_GRACE_MS, type WorkerRetirement } from "./interrupt-bounds.ts";
|
|
9
9
|
import {
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type ResultMessage,
|
|
15
15
|
type ToolCallMessage,
|
|
16
16
|
} from "./kernel-contract.ts";
|
|
17
|
-
import {
|
|
17
|
+
import { JavaScriptKernelMemory } from "./kernel-memory.ts";
|
|
18
|
+
import type { JavaScriptMemoryReading } from "./kernel-memory-bridge.ts";
|
|
18
19
|
import { kernelToolError } from "./kernel-tools-errors.ts";
|
|
19
20
|
import type {
|
|
20
21
|
KernelToolsDescribeResult,
|
|
@@ -25,7 +26,6 @@ import { type JavaScriptKernelOptions, LocalModuleLoader } from "./local-module-
|
|
|
25
26
|
import { JavaScriptRunQueue, type PendingJavaScriptRun } from "./run-queue.ts";
|
|
26
27
|
import { ToolCallQueue } from "./tool-call-queue.ts";
|
|
27
28
|
import { WorkerChildren } from "./worker-children.ts";
|
|
28
|
-
import { crashedResult } from "./worker-host.ts";
|
|
29
29
|
import { WorkerRecovery } from "./worker-recovery.ts";
|
|
30
30
|
import { WorkerSlot } from "./worker-slot.ts";
|
|
31
31
|
|
|
@@ -57,7 +57,7 @@ export class JavaScriptKernel {
|
|
|
57
57
|
);
|
|
58
58
|
readonly #toolCalls = new ToolCallQueue();
|
|
59
59
|
readonly #children: WorkerChildren;
|
|
60
|
-
readonly #memory:
|
|
60
|
+
readonly #memory: JavaScriptKernelMemory;
|
|
61
61
|
readonly #activeCell: ActiveCellControl;
|
|
62
62
|
readonly #recovery = new WorkerRecovery({
|
|
63
63
|
runs: this.#runs,
|
|
@@ -76,7 +76,7 @@ export class JavaScriptKernel {
|
|
|
76
76
|
recover: () => void this.#recovery.recover(() => Promise.resolve()),
|
|
77
77
|
clearToolCalls: () => this.#toolCalls.clear(),
|
|
78
78
|
});
|
|
79
|
-
this.#memory = new
|
|
79
|
+
this.#memory = new JavaScriptKernelMemory(options, () => this.#slot.processPid);
|
|
80
80
|
this.#children = new WorkerChildren(options.collectOrphanedChildren);
|
|
81
81
|
this.#moduleLoader = new LocalModuleLoader(options);
|
|
82
82
|
this.#slot = new WorkerSlot(options, {
|
|
@@ -90,15 +90,20 @@ export class JavaScriptKernel {
|
|
|
90
90
|
return this.#slot.mode;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/** The
|
|
93
|
+
/** The kernel process's pid in process mode; none in worker mode or before it starts. */
|
|
94
|
+
get processPid(): number | undefined {
|
|
95
|
+
return this.#slot.processPid;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The last memory reading (a result, an idle collection, a query); none before the first. */
|
|
94
99
|
get lastLiveBytes(): number | undefined {
|
|
95
100
|
return this.#memory.lastLiveBytes;
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
/** A heap reading taken between cells without running one; nothing when no worker is live to ask. */
|
|
99
104
|
async queryMemory(): Promise<JavaScriptMemoryReading | undefined> {
|
|
100
|
-
|
|
101
|
-
return await this.#memory.query((message) => this.#slot.postMessage(message));
|
|
105
|
+
const ready = this.#lifecycle === "open" && this.#slot.present && !this.#slot.startingUp;
|
|
106
|
+
return await this.#memory.query(ready ? (message) => this.#slot.postMessage(message) : undefined);
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
get kernelToolEvents(): EventTarget {
|
|
@@ -202,7 +207,13 @@ export class JavaScriptKernel {
|
|
|
202
207
|
|
|
203
208
|
async #ensureReady(): Promise<void> {
|
|
204
209
|
assertJavaScriptKernelOpen(this.#lifecycle, "run");
|
|
205
|
-
|
|
210
|
+
try {
|
|
211
|
+
await this.#slot.ensureReady();
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (this.#options.isolation !== "process") throw error;
|
|
214
|
+
// Process mode reports a failed start on the waiting cell, never as a rejection of run().
|
|
215
|
+
if (this.#lifecycle === "open") this.#runs.settleAll(error instanceof Error ? error.message : String(error));
|
|
216
|
+
}
|
|
206
217
|
}
|
|
207
218
|
|
|
208
219
|
#startNext(): void {
|
|
@@ -224,15 +235,7 @@ export class JavaScriptKernel {
|
|
|
224
235
|
|
|
225
236
|
#handleMessage(message: KernelToHostMessage): void {
|
|
226
237
|
if (this.#kernelTools.consume(message) && message.type !== "tool-call") return;
|
|
227
|
-
if (this.#memory
|
|
228
|
-
if (message.type === "status" && message.event.op === INTERRUPT_ACK_OP) {
|
|
229
|
-
this.#runs.acknowledgeInterrupt(message.event);
|
|
230
|
-
return;
|
|
231
|
-
}
|
|
232
|
-
if (message.type === "status" && message.event.op === CHILD_LIFECYCLE_OP) {
|
|
233
|
-
this.#children.track(message.event);
|
|
234
|
-
return;
|
|
235
|
-
}
|
|
238
|
+
if (consumeControlFrame(message, { memory: this.#memory, runs: this.#runs, children: this.#children })) return;
|
|
236
239
|
(this.#runs.active?.input.onMessage ?? this.#options.onMessage)?.(message);
|
|
237
240
|
if (message.type === "tool-call") {
|
|
238
241
|
this.#toolCalls.push(message);
|
|
@@ -255,14 +258,10 @@ export class JavaScriptKernel {
|
|
|
255
258
|
if (!active && this.#slot.startingUp) return;
|
|
256
259
|
this.#activeCell.disarm();
|
|
257
260
|
this.#kernelTools.rejectAll(kernelToolError("kernel_tool_stale", error.message));
|
|
258
|
-
this.#memory.
|
|
261
|
+
this.#memory.crashed(error);
|
|
259
262
|
// A crash mid-install stops the install (process group killed, nothing published); it settles itself once stopped.
|
|
260
263
|
if (active && !this.#hostEntries.abort(active, `JavaScript worker crashed: ${error.message}`)) {
|
|
261
|
-
this.#runs.
|
|
262
|
-
this.#runs.settle(
|
|
263
|
-
active,
|
|
264
|
-
crashedResult(active.input.cellId, error, this.#runs.durationMs(active, performance.now())),
|
|
265
|
-
);
|
|
264
|
+
this.#runs.settleCrashed(active, error);
|
|
266
265
|
}
|
|
267
266
|
this.#toolCalls.clear();
|
|
268
267
|
void this.#restartAfterStop();
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { CHILD_LIFECYCLE_OP, INTERRUPT_ACK_OP } from "../../bridge/reserved.ts";
|
|
3
|
+
import type { JavaScriptKernelMemory } from "./kernel-memory.ts";
|
|
4
|
+
import type { JavaScriptRunQueue } from "./run-queue.ts";
|
|
5
|
+
import type { WorkerChildren } from "./worker-children.ts";
|
|
6
|
+
|
|
7
|
+
export interface ControlFrameOwners {
|
|
8
|
+
readonly memory: Pick<JavaScriptKernelMemory, "consume">;
|
|
9
|
+
readonly runs: Pick<JavaScriptRunQueue, "acknowledgeInterrupt">;
|
|
10
|
+
readonly children: Pick<WorkerChildren, "track">;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Routes a worker's side-channel frames (memory readings, interrupt acks, child lifecycle) to their owners. */
|
|
14
|
+
export function consumeControlFrame(message: KernelToHostMessage, owners: ControlFrameOwners): boolean {
|
|
15
|
+
if (owners.memory.consume(message)) return true;
|
|
16
|
+
if (message.type !== "status") return false;
|
|
17
|
+
if (message.event.op === INTERRUPT_ACK_OP) {
|
|
18
|
+
owners.runs.acknowledgeInterrupt(message.event);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
if (message.event.op !== CHILD_LIFECYCLE_OP) return false;
|
|
22
|
+
owners.children.track(message.event);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
@@ -7,7 +7,7 @@ import type { JavaScriptInterruptBounds } from "./interrupt-bounds.ts";
|
|
|
7
7
|
export type ResultMessage = Extract<KernelToHostMessage, { type: "result" }>;
|
|
8
8
|
export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
|
|
9
9
|
|
|
10
|
-
export type JavaScriptKernelMode = "worker" | "inline";
|
|
10
|
+
export type JavaScriptKernelMode = "worker" | "inline" | "process";
|
|
11
11
|
|
|
12
12
|
/** Snapshot or live provider consulted when the worker registry checks collisions. */
|
|
13
13
|
export type KernelToolNameSource = readonly string[] | (() => readonly string[]);
|
|
@@ -30,6 +30,19 @@ export interface JavaScriptKernelOptions {
|
|
|
30
30
|
readonly workerEntryUrl?: URL;
|
|
31
31
|
/** Per-session PI_* values applied to the worker environment before the first cell runs. */
|
|
32
32
|
readonly sessionEnv?: SessionEnvironment;
|
|
33
|
+
/** Opt-in child-process kernel; absent keeps the worker-thread kernel. */
|
|
34
|
+
readonly isolation?: "process";
|
|
35
|
+
/** Resolution root for the process-mode runtime (bun, then node); `""` names a machine with none on PATH. */
|
|
36
|
+
readonly processCommandPath?: string;
|
|
37
|
+
/** The host's executable, which runs the child when it is bun or node; defaults to `process.execPath`. */
|
|
38
|
+
readonly processExecPath?: string;
|
|
39
|
+
/** How long a process-mode child gets to report ready; defaults to `PROCESS_STARTUP_DEADLINE_MS`. */
|
|
40
|
+
readonly processStartupDeadlineMs?: number;
|
|
41
|
+
/** Process-mode memory policy: the host reads the child's footprint instead of the in-heap worker reading. */
|
|
42
|
+
readonly processMemory?: {
|
|
43
|
+
readonly thresholds: KernelMemoryThresholds;
|
|
44
|
+
readonly readFootprint?: (pid: number) => { readonly bytes: number } | undefined;
|
|
45
|
+
};
|
|
33
46
|
/** Host tool names denied as JS kernel-tool identifiers (init protocol). */
|
|
34
47
|
readonly hostToolNames?: KernelToolNameSource;
|
|
35
48
|
/** `kernelTools.enabled`; false makes `tool(fn)` refuse with `tools_unavailable`. Unset keeps them on. */
|
|
@@ -6,7 +6,7 @@ import type { ResultMessage } from "./kernel-contract.ts";
|
|
|
6
6
|
|
|
7
7
|
export interface JavaScriptMemoryReading {
|
|
8
8
|
readonly liveBytes: number;
|
|
9
|
-
readonly measure: "heap";
|
|
9
|
+
readonly measure: "heap" | "footprint";
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** A query waits at most this long: the worker answers between cells, never inside one. */
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { restartNotice } from "../shared/kernel-death.ts";
|
|
3
|
+
import type { ResultMessage } from "./kernel-contract.ts";
|
|
4
|
+
import { type JavaScriptMemoryReading, KernelMemoryBridge } from "./kernel-memory-bridge.ts";
|
|
5
|
+
import type { JavaScriptKernelOptions } from "./local-module-loader.ts";
|
|
6
|
+
import { JavaScriptProcessMemoryHost } from "./process-memory-host.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The kernel's one view of memory: the worker's heap bridge, or in process mode the child process's footprint, plus
|
|
10
|
+
* the restart notice a crash leaves for the next result.
|
|
11
|
+
*/
|
|
12
|
+
export class JavaScriptKernelMemory {
|
|
13
|
+
readonly #bridge: KernelMemoryBridge;
|
|
14
|
+
readonly #process: JavaScriptProcessMemoryHost | null;
|
|
15
|
+
readonly #pid: () => number | undefined;
|
|
16
|
+
readonly #processMode: boolean;
|
|
17
|
+
#restartNotice: string | null = null;
|
|
18
|
+
|
|
19
|
+
constructor(options: JavaScriptKernelOptions, pid: () => number | undefined) {
|
|
20
|
+
const processMode = options.isolation === "process";
|
|
21
|
+
this.#bridge = new KernelMemoryBridge(processMode ? undefined : options.memory, options.onMemoryCollected);
|
|
22
|
+
this.#process =
|
|
23
|
+
processMode && options.processMemory !== undefined
|
|
24
|
+
? new JavaScriptProcessMemoryHost(options.processMemory.thresholds, options.processMemory.readFootprint)
|
|
25
|
+
: null;
|
|
26
|
+
this.#pid = pid;
|
|
27
|
+
this.#processMode = processMode;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
get lastLiveBytes(): number | undefined {
|
|
31
|
+
return this.#process === null ? this.#bridge.lastLiveBytes : this.#process.lastLiveBytes(this.#pid);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A reading between cells. The worker is asked only through `post`, given when a worker is ready to answer. */
|
|
35
|
+
async query(
|
|
36
|
+
post: ((message: HostToKernelMessage) => void) | undefined,
|
|
37
|
+
): Promise<JavaScriptMemoryReading | undefined> {
|
|
38
|
+
if (this.#process !== null) return this.#process.query(this.#pid);
|
|
39
|
+
return post === undefined ? undefined : await this.#bridge.query(post);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
consume(message: KernelToHostMessage): boolean {
|
|
43
|
+
return this.#bridge.consume(message);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
settled(message: ResultMessage): ResultMessage {
|
|
47
|
+
if (this.#process !== null) return this.#process.settled(message, this.#pid);
|
|
48
|
+
const settled = this.#bridge.settled(message);
|
|
49
|
+
if (this.#restartNotice === null) return settled;
|
|
50
|
+
const notice = this.#restartNotice;
|
|
51
|
+
this.#restartNotice = null;
|
|
52
|
+
return { ...settled, notice, kernelState: "restarted" };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether the kernel should restart for memory now; only once nothing is running or queued (`idle`). */
|
|
56
|
+
claimRecycle(idle: boolean): boolean {
|
|
57
|
+
const worker = this.#bridge.claimRecycle(idle);
|
|
58
|
+
return this.#process?.claimRecycle(idle) === true || worker;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The worker was retired: pending readings fail. */
|
|
62
|
+
workerLost(error: Error): void {
|
|
63
|
+
this.#bridge.workerLost(error);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The kernel crashed: pending readings fail. In process mode the next result also says the kernel restarted (a
|
|
68
|
+
* crashed child loses every global); worker mode reports its crash as it always has, with no notice.
|
|
69
|
+
*/
|
|
70
|
+
crashed(error: Error): void {
|
|
71
|
+
this.#bridge.workerLost(error);
|
|
72
|
+
if (this.#process !== null) {
|
|
73
|
+
this.#process.workerLost();
|
|
74
|
+
this.#process.markRestarted(error);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.#processMode) this.#restartNotice = restartNotice("js", error.message);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits text read from the process kernel's fd 1 pipe into the output it carries and the result keys its drain
|
|
3
|
+
* markers name, in order. A read boundary can cut a marker, so a trailing prefix of one is handed back as `carry` for
|
|
4
|
+
* the next read rather than sent as output.
|
|
5
|
+
*
|
|
6
|
+
* Self-contained on purpose: its source also runs inside the pipe reader thread (process-entry.js `DRAINER`).
|
|
7
|
+
*/
|
|
8
|
+
export function scanDrainText(carry, chunk, marker) {
|
|
9
|
+
let text = carry + chunk;
|
|
10
|
+
const parts = [];
|
|
11
|
+
for (;;) {
|
|
12
|
+
const at = text.indexOf(marker);
|
|
13
|
+
if (at === -1) break;
|
|
14
|
+
const close = text.indexOf("\u0000", at + marker.length);
|
|
15
|
+
if (close === -1) break;
|
|
16
|
+
if (at > 0) parts.push({ text: text.slice(0, at) });
|
|
17
|
+
parts.push({ key: text.slice(at + marker.length, close) });
|
|
18
|
+
text = text.slice(close + 1);
|
|
19
|
+
}
|
|
20
|
+
let nextCarry = "";
|
|
21
|
+
const partial = text.lastIndexOf("\u0000");
|
|
22
|
+
if (partial !== -1 && marker.startsWith(text.slice(partial, partial + marker.length))) {
|
|
23
|
+
nextCarry = text.slice(partial);
|
|
24
|
+
text = text.slice(0, partial);
|
|
25
|
+
}
|
|
26
|
+
if (text.length > 0) parts.push({ text });
|
|
27
|
+
return { parts, carry: nextCarry };
|
|
28
|
+
}
|