@code-yeongyu/senpi-codemode 2026.9.28 → 2026.9.29-3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/README.md +13 -1
  3. package/package.json +4 -4
  4. package/src/bridge/memory-protocol.ts +39 -0
  5. package/src/bridge/protocol.ts +18 -0
  6. package/src/bridge/reserved.ts +2 -0
  7. package/src/config/memory-settings.ts +149 -0
  8. package/src/config/settings.ts +8 -2
  9. package/src/extension/session-manager-proxy.ts +6 -0
  10. package/src/extension/session-manager.ts +27 -25
  11. package/src/host-sdk.ts +1 -1
  12. package/src/index.ts +4 -0
  13. package/src/kernels/jl/kernel.ts +4 -1
  14. package/src/kernels/js/context-manager.ts +43 -5
  15. package/src/kernels/js/inline-worker.ts +2 -1
  16. package/src/kernels/js/interrupt-bounds.ts +8 -2
  17. package/src/kernels/js/kernel-contract.ts +13 -0
  18. package/src/kernels/js/local-module-loader.ts +2 -0
  19. package/src/kernels/js/webview-host.ts +59 -0
  20. package/src/kernels/js/worker-core.js +32 -3
  21. package/src/kernels/js/worker-global-sizes.js +123 -0
  22. package/src/kernels/js/worker-heap.js +59 -0
  23. package/src/kernels/js/worker-host.ts +2 -1
  24. package/src/kernels/js/worker-memory.js +94 -0
  25. package/src/kernels/js/worker-runtime.js +7 -2
  26. package/src/kernels/js/worker-slot.ts +16 -5
  27. package/src/kernels/js/worker-startup.ts +1 -0
  28. package/src/kernels/js/worker-webview-client.js +79 -0
  29. package/src/kernels/js/worker-webview.js +204 -0
  30. package/src/kernels/py/kernel-contract.ts +3 -0
  31. package/src/kernels/py/kernel.ts +15 -2
  32. package/src/kernels/py/prelude.py +327 -11
  33. package/src/kernels/py/transport.ts +4 -1
  34. package/src/kernels/rb/kernel.ts +4 -1
  35. package/src/kernels/shared/kernel-memory-host.ts +63 -0
  36. package/src/kernels/shared/kernel-memory.ts +136 -0
  37. package/src/kernels/shared/subprocess-contract.ts +10 -1
  38. package/src/kernels/shared/subprocess-kernel.ts +23 -2
  39. package/src/kernels/shared/subprocess-queue.ts +2 -0
  40. package/src/output/streaming-output-buffer.ts +229 -12
  41. package/src/output/streaming-output.ts +2 -2
  42. package/src/prompt/eval-prompt.ts +1 -1
  43. package/src/skill/bun-1-4/references/runtime-apis.md +1 -0
  44. package/src/tool/cell-runtime.ts +52 -10
  45. package/src/tool/detached-cell-contract.ts +4 -0
  46. package/src/tool/detached-cell-manager.ts +16 -1
  47. package/src/tool/detached-cell-notification.ts +10 -1
  48. package/src/tool/image.ts +6 -2
  49. package/src/tool/run-eval-cell.ts +19 -17
  50. package/src/tool/settled-image-spill.ts +108 -0
  51. package/src/tool/terminal-snapshot-store.ts +82 -10
  52. package/src/tool/types.ts +10 -0
@@ -4,6 +4,7 @@ import type { KernelInterruptHandle } from "../../tool/types.ts";
4
4
  import type { KernelToolsInvokeOptions } from "../js/kernel-tools-types.ts";
5
5
  import { rejectKernelToolsUnavailable } from "../kernel-tools-unavailable.ts";
6
6
  import { applySessionEnvironment } from "../session-env.ts";
7
+ import { KernelMemoryHost } from "./kernel-memory-host.ts";
7
8
  import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
8
9
  import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
9
10
  import { SubprocessRunQueue } from "./subprocess-queue.ts";
@@ -21,13 +22,20 @@ import {
21
22
  timeoutResult,
22
23
  } from "./subprocess-run.ts";
23
24
 
24
- export type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
25
+ export type {
26
+ KernelResult,
27
+ KernelRunInput,
28
+ SubprocessKernelMemory,
29
+ SubprocessKernelOptions,
30
+ ToolCallMessage,
31
+ } from "./subprocess-contract.ts";
25
32
  export type { SubprocessLike, SubprocessSpawn };
26
33
 
27
34
  export class SubprocessKernel {
28
35
  private readonly options: SubprocessKernelOptions;
29
36
  private readonly onMessage?: (message: KernelToHostMessage) => void;
30
37
  private readonly runs = new SubprocessRunQueue();
38
+ private readonly memory: KernelMemoryHost | null;
31
39
  private process: SubprocessProcess | null = null;
32
40
  private processReady = false;
33
41
  private retirementPromise: Promise<void> | null = null;
@@ -39,6 +47,8 @@ export class SubprocessKernel {
39
47
  constructor(options: SubprocessKernelOptions) {
40
48
  this.options = options;
41
49
  this.onMessage = options.onMessage;
50
+ const memory = options.memory;
51
+ this.memory = memory ? new KernelMemoryHost(memory.language, memory.thresholds, memory) : null;
42
52
  this.spawnProcess();
43
53
  }
44
54
 
@@ -176,6 +186,7 @@ export class SubprocessKernel {
176
186
  });
177
187
  this.process = process;
178
188
  this.processReady = false;
189
+ this.memory?.processReplaced();
179
190
  try {
180
191
  process.send(
181
192
  encodeBridgeFrame({ type: "init", sessionId: this.options.sessionId, connection: this.options.connection }),
@@ -210,7 +221,17 @@ export class SubprocessKernel {
210
221
  this.failClosed(new KernelStartupError(message.error.message));
211
222
  return;
212
223
  }
213
- if (this.runs.handleMessage(message, this.onMessage)) this.pumpRuns();
224
+ const ownResult = message.type === "result" && this.runs.active?.input.cellId === message.cellId;
225
+ const settled = ownResult && this.memory ? this.memory.annotate(message, process.child.pid) : message;
226
+ if (!this.runs.handleMessage(settled, this.onMessage)) return;
227
+ this.pumpRuns();
228
+ this.recycleOverCeilingWhenIdle();
229
+ }
230
+
231
+ /** An over-ceiling interpreter restarts only once no cell is running or queued on it. */
232
+ private recycleOverCeilingWhenIdle(): void {
233
+ const idle = this.runs.active === null && this.runs.snapshot().queuedCellIds.length === 0;
234
+ if (this.memory?.claimRecycle(idle)) void this.restartProcess(this.process);
214
235
  }
215
236
 
216
237
  private handleExit(process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null): void {
@@ -113,6 +113,8 @@ export class SubprocessRunQueue {
113
113
  case "kernel-tool-invoke-reply":
114
114
  onMessage?.(message);
115
115
  return false;
116
+ case "webview-connect":
117
+ return false;
116
118
  default: {
117
119
  const exhaustive: never = message;
118
120
  return exhaustive;
@@ -23,10 +23,47 @@ export function truncateTailBytes(text: string, maxBytes: number): ByteSlice {
23
23
  return { text: slice.toString("utf8"), bytes: slice.length };
24
24
  }
25
25
 
26
+ /** UTF-8 width of the code point starting at `index`; a lone surrogate counts 3 bytes, as the encoder writes U+FFFD. */
27
+ function utf8Width(codePoint: number): number {
28
+ if (codePoint < 0x80) return 1;
29
+ if (codePoint < 0x800) return 2;
30
+ if (codePoint < 0x10000) return 3;
31
+ return 4;
32
+ }
33
+
34
+ /**
35
+ * The shortest suffix of `text` whose leading whole code points total at least `excess` UTF-8 bytes, i.e. exactly
36
+ * what truncateTailBytes keeps, computed by walking the dropped prefix only. Lone surrogates in the kept text become
37
+ * U+FFFD as they would after a UTF-8 round trip.
38
+ */
39
+ function dropLeadingBytes(text: string, excess: number): ByteSlice {
40
+ const totalBytes = Buffer.byteLength(text, "utf8");
41
+ let index = 0;
42
+ let dropped = 0;
43
+ while (dropped < excess && index < text.length) {
44
+ const codePoint = text.codePointAt(index) ?? 0;
45
+ dropped += utf8Width(codePoint);
46
+ index += codePoint > 0xffff ? 2 : 1;
47
+ }
48
+ const kept = text.slice(index);
49
+ return { text: kept.isWellFormed() ? kept : kept.toWellFormed(), bytes: totalBytes - dropped };
50
+ }
51
+
52
+ /**
53
+ * Trailing byte window over a chunk stream. Appends only queue the chunk and drop whole chunks that fall
54
+ * out of the window, so a streaming append costs time and memory proportional to the chunk (#2262); the
55
+ * exact code-point-aligned trim runs when the window is read, with the same result as truncating the whole
56
+ * stream to `maxBytes` after every append.
57
+ */
26
58
  export class TailBuffer {
27
59
  readonly #maxBytes: number;
28
- #text = "";
60
+ #chunks: string[] = [];
61
+ #chunkBytes: number[] = [];
62
+ #head = 0;
63
+ /** Sum of the queued chunks' UTF-8 sizes; exact after #normalize. */
29
64
  #bytes = 0;
65
+ /** The stream exceeded the window since the last normalization, so the next read must trim. */
66
+ #overflowed = false;
30
67
 
31
68
  constructor(maxBytes: number) {
32
69
  this.#maxBytes = Math.max(0, Math.floor(maxBytes));
@@ -34,25 +71,205 @@ export class TailBuffer {
34
71
 
35
72
  append(text: string): void {
36
73
  if (text.length === 0) return;
37
- if (this.#maxBytes === 0) {
38
- this.#text = "";
39
- this.#bytes = 0;
74
+ if (this.#maxBytes === 0) return;
75
+ const incomingBytes = Buffer.byteLength(text, "utf8");
76
+ if (incomingBytes >= this.#maxBytes) {
77
+ this.#chunks = [text];
78
+ this.#chunkBytes = [incomingBytes];
79
+ this.#head = 0;
80
+ this.#bytes = incomingBytes;
81
+ this.#overflowed = true;
40
82
  return;
41
83
  }
42
- const incomingBytes = Buffer.byteLength(text, "utf8");
43
- const next =
44
- incomingBytes >= this.#maxBytes
45
- ? truncateTailBytes(text, this.#maxBytes)
46
- : truncateTailBytes(this.#text + text, this.#maxBytes);
47
- this.#text = next.text;
48
- this.#bytes = next.bytes;
84
+ this.#chunks.push(text);
85
+ this.#chunkBytes.push(incomingBytes);
86
+ this.#bytes += incomingBytes;
87
+ if (this.#bytes > this.#maxBytes) this.#overflowed = true;
88
+ // Keep a small margin so a surrogate pair split across chunks cannot make the kept window short.
89
+ while (
90
+ this.#head < this.#chunks.length - 1 &&
91
+ this.#bytes - (this.#chunkBytes[this.#head] ?? 0) >= this.#maxBytes + 4
92
+ ) {
93
+ this.#bytes -= this.#chunkBytes[this.#head] ?? 0;
94
+ this.#head++;
95
+ }
96
+ if (this.#head > 1024 && this.#head * 2 > this.#chunks.length) {
97
+ this.#chunks = this.#chunks.slice(this.#head);
98
+ this.#chunkBytes = this.#chunkBytes.slice(this.#head);
99
+ this.#head = 0;
100
+ }
49
101
  }
50
102
 
51
103
  text(): string {
52
- return this.#text;
104
+ return this.#normalize();
53
105
  }
54
106
 
55
107
  bytes(): number {
108
+ this.#normalize();
56
109
  return this.#bytes;
57
110
  }
111
+
112
+ #normalize(): string {
113
+ if (this.#maxBytes === 0) return "";
114
+ const pending = this.#chunks.length - this.#head;
115
+ if (pending === 0) return "";
116
+ const joined = pending === 1 ? (this.#chunks[this.#head] ?? "") : this.#chunks.slice(this.#head).join("");
117
+ const total = Buffer.byteLength(joined, "utf8");
118
+ const kept = this.#overflowed
119
+ ? dropLeadingBytes(joined, Math.max(0, total - this.#maxBytes))
120
+ : { text: joined, bytes: total };
121
+ this.#chunks = [kept.text];
122
+ this.#chunkBytes = [kept.bytes];
123
+ this.#head = 0;
124
+ this.#bytes = kept.bytes;
125
+ this.#overflowed = false;
126
+ return kept.text;
127
+ }
128
+ }
129
+
130
+ export interface TailLineRingOptions {
131
+ readonly maxBytes: number;
132
+ readonly maxLines: number;
133
+ }
134
+
135
+ /**
136
+ * Rolling live-output window over a chunk stream: `text()` returns the last
137
+ * `maxLines` lines of the trailing `maxBytes` byte window, byte-identical to
138
+ * truncating the whole stream to `maxBytes` bytes and keeping its final lines,
139
+ * while `append` costs time proportional to the chunk (#2262).
140
+ *
141
+ * Invariant: the retained content (compacted line groups plus recent lines,
142
+ * joined with "\n", plus the partial current line) is a suffix of the stream
143
+ * that either covers the whole stream or keeps the last-`maxLines`-lines window
144
+ * reachable — older content is only dropped as whole lines when the remaining
145
+ * lines still cover the line window, and any line trimmed from its start (only
146
+ * for lines larger than the byte budget) is halved so repeated trims stay
147
+ * amortized and never drop below what the byte window still needs.
148
+ */
149
+ export class TailLineRing {
150
+ readonly #maxBytes: number;
151
+ readonly #maxLines: number;
152
+ readonly #blobs: string[] = [];
153
+ readonly #blobBytes: number[] = [];
154
+ readonly #lines: string[] = [];
155
+ readonly #lineBytes: number[] = [];
156
+ #current = "";
157
+ #currentBytes = 0;
158
+ #bytes = 0;
159
+
160
+ constructor(options: TailLineRingOptions) {
161
+ this.#maxBytes = Math.max(0, Math.floor(options.maxBytes));
162
+ this.#maxLines = Math.max(1, Math.floor(options.maxLines));
163
+ }
164
+
165
+ append(chunk: string): void {
166
+ if (chunk.length === 0) return;
167
+ let start = 0;
168
+ for (;;) {
169
+ const newline = chunk.indexOf("\n", start);
170
+ const end = newline === -1 ? chunk.length : newline;
171
+ if (end > start) {
172
+ const piece = chunk.slice(start, end);
173
+ const pieceBytes = Buffer.byteLength(piece, "utf8");
174
+ this.#current += piece;
175
+ this.#currentBytes += pieceBytes;
176
+ this.#bytes += pieceBytes;
177
+ }
178
+ if (newline === -1) break;
179
+ this.#completeLine();
180
+ start = newline + 1;
181
+ }
182
+ this.#enforce();
183
+ }
184
+
185
+ text(): string {
186
+ const endsWithNewline = this.#current === "" && this.#bytes > 0;
187
+ const keptLines = endsWithNewline ? this.#maxLines : this.#maxLines - 1;
188
+ if (this.#lines.length >= keptLines) {
189
+ const parts = this.#lines.slice(-keptLines);
190
+ if (!endsWithNewline) parts.push(this.#current);
191
+ const window = `${parts.join("\n")}${endsWithNewline ? "\n" : ""}`;
192
+ if (Buffer.byteLength(window, "utf8") <= this.#maxBytes) return window;
193
+ }
194
+ return this.#bytePathText(endsWithNewline);
195
+ }
196
+
197
+ #completeLine(): void {
198
+ this.#lines.push(this.#current);
199
+ this.#lineBytes.push(this.#currentBytes);
200
+ this.#current = "";
201
+ this.#currentBytes = 0;
202
+ this.#bytes += 1;
203
+ const keep = Math.max(this.#maxLines * 4, 64);
204
+ if (this.#lines.length > keep * 2) this.#compact(keep);
205
+ }
206
+
207
+ /**
208
+ * Folds the oldest individual lines into one compacted group so a long stream of
209
+ * tiny lines cannot grow the line array without bound. The recent lines kept
210
+ * always outnumber the window, so compaction never touches rendered content.
211
+ */
212
+ #compact(keep: number): void {
213
+ const movedCount = this.#lines.length - keep;
214
+ const moved = this.#lines.splice(0, movedCount);
215
+ const movedBytes = this.#lineBytes.splice(0, movedCount);
216
+ let bytes = 0;
217
+ for (const entry of movedBytes) bytes += entry;
218
+ this.#blobs.push(moved.join("\n"));
219
+ this.#blobBytes.push(bytes + Math.max(0, moved.length - 1));
220
+ }
221
+
222
+ #enforce(): void {
223
+ while (this.#bytes > this.#maxBytes) {
224
+ if (this.#blobs.length > 0) {
225
+ if (!this.#shedFront(this.#blobs, this.#blobBytes, this.#lines.length)) return;
226
+ continue;
227
+ }
228
+ if (this.#lines.length > 0) {
229
+ if (!this.#shedFront(this.#lines, this.#lineBytes, this.#lines.length - 1)) return;
230
+ continue;
231
+ }
232
+ if (this.#currentBytes <= this.#maxBytes) return;
233
+ const kept = truncateTailBytes(this.#current, Math.max(this.#maxBytes, Math.floor(this.#currentBytes / 2)));
234
+ this.#bytes -= this.#currentBytes - kept.bytes;
235
+ this.#current = kept.text;
236
+ this.#currentBytes = kept.bytes;
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Drops or trims the oldest retained unit. Dropping is safe when the remaining
242
+ * bytes still cover the byte window or the remaining individual lines still cover
243
+ * the line window; otherwise the unit is trimmed from its start (halved, never
244
+ * below what the byte window still needs), which keeps the retained content a
245
+ * suffix holding at least `maxBytes` bytes. Returns false when nothing more can
246
+ * be shed without breaking the invariant.
247
+ */
248
+ #shedFront(texts: string[], byteCosts: number[], retainedLines: number): boolean {
249
+ const unitBytes = byteCosts[0];
250
+ const restBytes = this.#bytes - unitBytes - 1;
251
+ if (restBytes >= this.#maxBytes || retainedLines >= this.#maxLines) {
252
+ texts.shift();
253
+ byteCosts.shift();
254
+ this.#bytes = restBytes;
255
+ return true;
256
+ }
257
+ const needBytes = this.#maxBytes - restBytes;
258
+ const keepBytes = Math.max(needBytes, unitBytes >> 1);
259
+ if (keepBytes >= unitBytes) return false;
260
+ const kept = truncateTailBytes(texts[0], keepBytes);
261
+ texts[0] = kept.text;
262
+ byteCosts[0] = kept.bytes;
263
+ this.#bytes -= unitBytes - kept.bytes;
264
+ return true;
265
+ }
266
+
267
+ #bytePathText(endsWithNewline: boolean): string {
268
+ const units = [...this.#blobs, ...this.#lines];
269
+ const joined = units.length === 0 ? this.#current : `${units.join("\n")}\n${this.#current}`;
270
+ const tail = truncateTailBytes(joined, this.#maxBytes).text;
271
+ let tailLines = tail.split("\n");
272
+ if (endsWithNewline) tailLines = tailLines.slice(0, -1);
273
+ return `${tailLines.slice(-this.#maxLines).join("\n")}${endsWithNewline ? "\n" : ""}`;
274
+ }
58
275
  }
@@ -2,11 +2,11 @@ import { createWriteStream, mkdirSync, type WriteStream } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, truncateTail } from "../host-sdk.ts";
4
4
  import { formatMiddleElisionMarker } from "./output-meta.ts";
5
- import { TailBuffer, truncateHeadBytes } from "./streaming-output-buffer.ts";
5
+ import { TailBuffer, TailLineRing, truncateHeadBytes } from "./streaming-output-buffer.ts";
6
6
 
7
7
  export { artifactNotice, formatMiddleElisionMarker, resolveSessionArtifactsDir } from "./output-meta.ts";
8
8
  export { truncateHeadBytes, truncateTailBytes } from "./streaming-output-buffer.ts";
9
- export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, truncateTail };
9
+ export { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TailBuffer, TailLineRing, truncateTail };
10
10
 
11
11
  export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024;
12
12
 
@@ -112,7 +112,7 @@ export function buildEvalPrompt(
112
112
  promptSnippet: "Run one incremental code cell in a persistent language kernel.",
113
113
  promptGuidelines: [
114
114
  style === "gpt" && context.monitor === true ? GPT_MONITOR_BATCHING_GUIDELINE : BATCHING_GUIDELINES[style],
115
- "Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language.",
115
+ "Use eval reset only when a language kernel must be wiped; reset is scoped to the selected language. A bracketed kernel memory notice in a result names the globals holding the most memory; drop the ones you no longer need.",
116
116
  ],
117
117
  };
118
118
  }
@@ -34,6 +34,7 @@ await Bun.write("page.png", await view.screenshot());
34
34
  ```
35
35
 
36
36
  - macOS: system WebKit, nothing to install. macOS/Linux/Windows: can drive installed Chrome/Chromium/Edge.
37
+ - In the eval js kernel, Chrome-backed views (the default off macOS, or `backend: "chrome"`) are served by the process main thread; the API is the same, a constructor error surfaces on the first awaited call, and `Bun.WebView.closeAll()` closes only this kernel's views. A kernel reset closes every view the kernel left open.
37
38
  - Clicks/scrolls are trusted user input (`event.isTrusted === true`).
38
39
  - Extends `EventTarget`; screenshots are `Blob`s; `.cdp(method, params?)` escape hatch for raw Chrome DevTools Protocol.
39
40
 
@@ -1,8 +1,14 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { KernelMemoryReport } from "../bridge/memory-protocol.ts";
2
3
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
4
+ import { DEFAULT_MAX_BYTES, TailLineRing } from "../output/streaming-output.ts";
3
5
  import type { EvalToolCallMetric } from "./call-capture.ts";
4
6
  import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
5
- import type { EvalRuntimeInfo, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
7
+ import type { EvalMemoryDetails, EvalRuntimeInfo, EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
8
+
9
+ const LIVE_UPDATE_LINES = 8;
10
+ /** Same cadence as the core bash tool's streaming updates (BASH_UPDATE_THROTTLE_MS). */
11
+ const LIVE_OUTPUT_UPDATE_THROTTLE_MS = 100;
6
12
 
7
13
  type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
8
14
  type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
@@ -40,6 +46,10 @@ export interface CellResultBuilderOptions {
40
46
  export class CellResultBuilder {
41
47
  readonly #output: EvalOutputCollector;
42
48
  readonly #state: CellState;
49
+ #memory: KernelMemoryReport | undefined;
50
+ readonly #liveLines = new TailLineRing({ maxBytes: DEFAULT_MAX_BYTES * 2, maxLines: LIVE_UPDATE_LINES });
51
+ #lastOutputUpdateAt = 0;
52
+ #outputUpdateTimer: ReturnType<typeof setTimeout> | undefined;
43
53
 
44
54
  constructor(options: CellResultBuilderOptions) {
45
55
  this.#state = options.state;
@@ -49,9 +59,9 @@ export class CellResultBuilder {
49
59
  model: options.model,
50
60
  ...(options.artifactPath === undefined ? {} : { artifactPath: options.artifactPath }),
51
61
  ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
52
- onChunk: (_aggregate, cell) => {
53
- options.state.output = cell;
54
- this.emitUpdate(false);
62
+ onChunk: (chunk) => {
63
+ this.#liveLines.append(chunk);
64
+ this.#scheduleOutputUpdate();
55
65
  },
56
66
  });
57
67
  if (options.state.status !== "queued") options.state.status = "running";
@@ -73,6 +83,7 @@ export class CellResultBuilder {
73
83
 
74
84
  async finalize(result: KernelResult): Promise<AgentToolResult<EvalToolDetails>> {
75
85
  this.#state.durationMs = result.durationMs;
86
+ this.#memory = result.memory;
76
87
  if (result.ok) {
77
88
  if (result.valueRepr) this.#output.push(`${result.valueRepr}\n`);
78
89
  this.#state.status = "complete";
@@ -96,6 +107,7 @@ export class CellResultBuilder {
96
107
  }
97
108
 
98
109
  liveResult(): AgentToolResult<EvalToolDetails> {
110
+ this.#state.output = this.#output.cellTailText();
99
111
  return {
100
112
  content: [{ type: "text", text: this.#liveUpdateText() }],
101
113
  details: this.#details(undefined, this.#state.status === "error"),
@@ -110,7 +122,33 @@ export class CellResultBuilder {
110
122
  });
111
123
  }
112
124
 
125
+ #scheduleOutputUpdate(): void {
126
+ const delay = LIVE_OUTPUT_UPDATE_THROTTLE_MS - (Date.now() - this.#lastOutputUpdateAt);
127
+ if (delay <= 0) {
128
+ this.#clearOutputUpdateTimer();
129
+ this.#emitOutputUpdate();
130
+ return;
131
+ }
132
+ this.#outputUpdateTimer ??= setTimeout(() => {
133
+ this.#outputUpdateTimer = undefined;
134
+ this.#emitOutputUpdate();
135
+ }, delay);
136
+ }
137
+
138
+ #emitOutputUpdate(): void {
139
+ this.#lastOutputUpdateAt = Date.now();
140
+ this.#state.output = this.#output.cellTailText();
141
+ this.emitUpdate(false);
142
+ }
143
+
144
+ #clearOutputUpdateTimer(): void {
145
+ if (this.#outputUpdateTimer === undefined) return;
146
+ clearTimeout(this.#outputUpdateTimer);
147
+ this.#outputUpdateTimer = undefined;
148
+ }
149
+
113
150
  async #finish(isError: boolean): Promise<AgentToolResult<EvalToolDetails>> {
151
+ this.#clearOutputUpdateTimer();
114
152
  const output = await this.#output.finish();
115
153
  this.#state.output = output.output;
116
154
  const details = this.#details(output, isError);
@@ -120,7 +158,9 @@ export class CellResultBuilder {
120
158
  (output.images.length > 0
121
159
  ? `(displayed ${output.images.length} image${output.images.length === 1 ? "" : "s"}; no text output)`
122
160
  : "(no output)");
123
- return { content: [{ type: "text", text }, ...output.images], details };
161
+ const notice = this.#memory?.notice;
162
+ const noticePart = notice === undefined ? [] : [{ type: "text" as const, text: notice }];
163
+ return { content: [{ type: "text", text }, ...noticePart, ...output.images], details };
124
164
  }
125
165
 
126
166
  #details(output: EvalOutputResult | undefined, isError: boolean): EvalToolDetails {
@@ -159,6 +199,7 @@ export class CellResultBuilder {
159
199
  ...(output === undefined || output.jsonOutputs.length === 0 ? {} : { jsonOutputs: output.jsonOutputs }),
160
200
  ...(output?.notice === undefined ? {} : { notice: output.notice }),
161
201
  ...(output?.meta === undefined ? {} : { meta: output.meta }),
202
+ ...(output === undefined || this.#memory === undefined ? {} : { memory: memoryDetails(this.#memory) }),
162
203
  };
163
204
  }
164
205
 
@@ -167,11 +208,12 @@ export class CellResultBuilder {
167
208
  return `queued behind ${this.#state.queuedBehind.join(", ")} in the ${this.#state.input.language} kernel`;
168
209
  }
169
210
  const summary = this.#state.input.summary === undefined ? "" : ` ${this.#state.input.summary}`;
170
- const aggregateOutput = this.#output.aggregateText();
171
- const outputLines = aggregateOutput.split("\n");
172
- const hasTrailingNewline = aggregateOutput.endsWith("\n");
173
- if (hasTrailingNewline) outputLines.pop();
174
- const output = `${outputLines.slice(-8).join("\n")}${hasTrailingNewline ? "\n" : ""}`;
211
+ const output = this.#liveLines.text();
175
212
  return `1/1 cells ${this.#state.status}\n[1] ${this.#state.input.language}${summary} ${this.#state.status}${output.length === 0 ? "" : `\n${output}`}`;
176
213
  }
177
214
  }
215
+
216
+ function memoryDetails(report: KernelMemoryReport): EvalMemoryDetails {
217
+ const { notice: _notice, ...details } = report;
218
+ return details;
219
+ }
@@ -47,6 +47,10 @@ export interface EvalDetachedCellManagerOptions {
47
47
  readonly runBudgetSeconds?: number;
48
48
  /** Global detached-cell capacity; defaults to 15. Full capacity keeps new cells foreground. */
49
49
  readonly maxDetachedCells?: number;
50
+ /** Estimated in-memory byte budget for settled-cell snapshots kept for peek/list; 0 keeps only the count cap. */
51
+ readonly retainedResultsBytes?: number;
52
+ /** Disk budget for settled-cell images spilled under `artifactsDir`; 0 keeps only the count cap. */
53
+ readonly retainedImagesBytes?: number;
50
54
  readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
51
55
  /** Receives a full per-source liveness snapshot on every detached-cell transition; used by the goal builtin. */
52
56
  readonly onWakeSourceState?: (state: WakeSourceState) => void;
@@ -1,4 +1,6 @@
1
+ import { join } from "node:path";
1
2
  import type { AgentToolResult } from "@code-yeongyu/senpi";
3
+ import { DEFAULT_RETAINED_IMAGES_MB, DEFAULT_RETAINED_RESULTS_MB } from "../config/memory-settings.ts";
2
4
  import {
3
5
  DEFAULT_HARD_LIMIT_SECONDS,
4
6
  DEFAULT_MAX_DETACHED_CELLS,
@@ -20,6 +22,7 @@ import {
20
22
  import { detachedStatusEntries, detachedWakeSourceState } from "./detached-cell-status.ts";
21
23
  import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
22
24
  import { createManagedCell, type LiveResultProvider, type ManagedCell } from "./managed-cell.ts";
25
+ import { SettledImageSpill } from "./settled-image-spill.ts";
23
26
  import { TerminalSnapshotStore } from "./terminal-snapshot-store.ts";
24
27
  import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
25
28
 
@@ -38,7 +41,7 @@ export class EvalDetachedCellManager {
38
41
  readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
39
42
  readonly #cells = new Map<string, ManagedCell>();
40
43
  readonly #detached = new Map<string, ManagedCell>();
41
- readonly #terminalSnapshots = new TerminalSnapshotStore();
44
+ readonly #terminalSnapshots: TerminalSnapshotStore;
42
45
  readonly #notificationQueue: DetachedNotificationQueue;
43
46
  readonly #now: () => number;
44
47
  readonly #hardLimitSeconds: number;
@@ -54,6 +57,18 @@ export class EvalDetachedCellManager {
54
57
  this.#hardLimitSeconds = options.hardLimitSeconds ?? DEFAULT_HARD_LIMIT_SECONDS;
55
58
  this.#runBudgetSeconds = options.runBudgetSeconds ?? DEFAULT_RUN_BUDGET_SECONDS;
56
59
  this.#maxDetachedCells = options.maxDetachedCells ?? DEFAULT_MAX_DETACHED_CELLS;
60
+ const artifactsDir = options.artifactsDir;
61
+ this.#terminalSnapshots = new TerminalSnapshotStore({
62
+ byteBudget: options.retainedResultsBytes ?? DEFAULT_RETAINED_RESULTS_MB * 1024 * 1024,
63
+ ...(artifactsDir === undefined
64
+ ? {}
65
+ : {
66
+ spill: new SettledImageSpill({
67
+ dir: join(artifactsDir, "settled-images"),
68
+ byteBudget: options.retainedImagesBytes ?? DEFAULT_RETAINED_IMAGES_MB * 1024 * 1024,
69
+ }),
70
+ }),
71
+ });
57
72
  }
58
73
 
59
74
  get maxDetachedCells(): number {
@@ -2,6 +2,7 @@ import { mkdir, writeFile } from "node:fs/promises";
2
2
  import { dirname, join } from "node:path";
3
3
  import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
4
4
  import { interruptionStateNote, unknownInterruptionStateNote } from "./interrupt-note.ts";
5
+ import type { EvalMemoryDetails } from "./types.ts";
5
6
 
6
7
  const NOTIFICATION_TAIL_BYTES = 512;
7
8
 
@@ -77,11 +78,19 @@ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
77
78
  }
78
79
 
79
80
  function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
80
- if (cell.state !== "cancelled") return "Kernel state updated - variables are available to the next eval cell.";
81
+ if (cell.state !== "cancelled") return memoryStateNote(cell.result.details?.memory);
81
82
  const note = interruptionStateNote(cell.language, cell.stateRetained) ?? unknownInterruptionStateNote(cell.language);
82
83
  return cell.interruptNote === undefined ? note : `${note} ${cell.interruptNote.trim()}`;
83
84
  }
84
85
 
86
+ export function memoryStateNote(memory: EvalMemoryDetails | undefined): string {
87
+ if (memory?.overCeiling === true)
88
+ return "Kernel memory is over its ceiling - the kernel restarts before the next eval cell and every global is lost.";
89
+ if (memory?.recycled === true)
90
+ return "The kernel was restarted before this cell ran - globals from earlier cells are gone; this cell's variables are available to the next eval cell.";
91
+ return "Kernel state updated - variables are available to the next eval cell.";
92
+ }
93
+
85
94
  function safeCellId(cellId: string): string {
86
95
  return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
87
96
  }
package/src/tool/image.ts CHANGED
@@ -36,7 +36,7 @@ export interface EvalOutputOptions {
36
36
  readonly maxColumns: number;
37
37
  readonly model: ExtensionContext["model"];
38
38
  readonly imageResizer?: EvalImageResizer;
39
- readonly onChunk: (aggregateText: string, cellText: string) => void;
39
+ readonly onChunk: (chunk: string) => void;
40
40
  }
41
41
 
42
42
  export interface EvalOutputResult {
@@ -82,7 +82,7 @@ export class EvalOutputCollector {
82
82
  onChunk: (chunk) => {
83
83
  this.#aggregateTail.append(chunk);
84
84
  this.#cellTail.append(chunk);
85
- options.onChunk(this.#aggregateTail.text(), this.#cellTail.text());
85
+ options.onChunk(chunk);
86
86
  },
87
87
  });
88
88
  }
@@ -129,6 +129,10 @@ export class EvalOutputCollector {
129
129
  return this.#aggregateTail.text();
130
130
  }
131
131
 
132
+ cellTailText(): string {
133
+ return this.#cellTail.text();
134
+ }
135
+
132
136
  async finish(): Promise<EvalOutputResult> {
133
137
  await this.#processImages();
134
138
  const elided = this.#displayImagesElided + this.#jsonOutputsElided;
@@ -188,24 +188,24 @@ async function executeCell(
188
188
  onReady: () => void,
189
189
  ): Promise<AgentToolResult<EvalToolDetails>> {
190
190
  let handler: CellHandler | undefined;
191
- try {
192
- const onMessage = (message: KernelToHostMessage): void => {
193
- if (!state.active || handler === undefined) return;
194
- if (message.type === "status") {
195
- if (message.event.op === TIMEOUT_PAUSE_OP) {
196
- execution.pause();
197
- cellManager.pause(cell);
198
- return;
199
- }
200
- if (message.event.op === TIMEOUT_RESUME_OP) {
201
- execution.resume();
202
- cellManager.resume(cell);
203
- return;
204
- }
191
+ const onMessage = (message: KernelToHostMessage): void => {
192
+ if (!state.active || handler === undefined) return;
193
+ if (message.type === "status") {
194
+ if (message.event.op === TIMEOUT_PAUSE_OP) {
195
+ execution.pause();
196
+ cellManager.pause(cell);
197
+ return;
205
198
  }
206
- const pending = handler.handle(message);
207
- void pending.catch((error: unknown) => execution.cancel(error));
208
- };
199
+ if (message.event.op === TIMEOUT_RESUME_OP) {
200
+ execution.resume();
201
+ cellManager.resume(cell);
202
+ return;
203
+ }
204
+ }
205
+ const pending = handler.handle(message);
206
+ void pending.catch((error: unknown) => execution.cancel(error));
207
+ };
208
+ try {
209
209
  const kernel = await execution.wait(options.kernelManager.getKernel(invocation.input.language, onMessage));
210
210
  // Computed before the handler so the cell's capability can be entered per host tool call from the
211
211
  // worker's message loop, which runs outside the `kernelToolsStorage.run` context below (#1754).
@@ -287,6 +287,8 @@ async function executeCell(
287
287
  bridgeAbortController.abort();
288
288
  execution.finish();
289
289
  if (handler) await handler.flushOutput();
290
+ // The cell settled: stop the kernel dispatcher from holding this cell's listener (#2260).
291
+ options.kernelManager.releaseKernelListener?.(invocation.input.language, onMessage);
290
292
  }
291
293
  }
292
294