@code-yeongyu/senpi-codemode 2026.7.31 → 2026.8.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.
@@ -0,0 +1,157 @@
1
+ import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
2
+ import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
+ import { type EvalImageResizer, EvalOutputCollector, type EvalOutputResult } from "./image.ts";
4
+ import type { EvalStatusEvent, EvalToolDetails, EvalToolInput } from "./types.ts";
5
+
6
+ type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
7
+ type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
8
+ type ToolCall = EvalToolDetails["toolCalls"] extends readonly (infer Item)[] ? Item : never;
9
+
10
+ export interface CellState {
11
+ readonly input: EvalToolInput;
12
+ readonly signal: AbortSignal;
13
+ readonly onUpdate: AgentToolUpdateCallback<EvalToolDetails> | undefined;
14
+ readonly toolCalls: ToolCall[];
15
+ readonly pendingBridgeCalls: Promise<void>[];
16
+ readonly statusEvents: EvalStatusEvent[];
17
+ active: boolean;
18
+ output: string;
19
+ phase: string | undefined;
20
+ durationMs: number;
21
+ status: "pending" | "running" | "complete" | "error";
22
+ }
23
+
24
+ export interface CellResultBuilderOptions {
25
+ readonly artifactPath?: string;
26
+ readonly headBytes: number;
27
+ readonly imageResizer?: EvalImageResizer;
28
+ readonly maxColumns: number;
29
+ readonly model: ExtensionContext["model"];
30
+ readonly state: CellState;
31
+ }
32
+
33
+ export class CellResultBuilder {
34
+ readonly #output: EvalOutputCollector;
35
+ readonly #state: CellState;
36
+
37
+ constructor(options: CellResultBuilderOptions) {
38
+ this.#state = options.state;
39
+ this.#output = new EvalOutputCollector({
40
+ headBytes: options.headBytes,
41
+ maxColumns: options.maxColumns,
42
+ model: options.model,
43
+ ...(options.artifactPath === undefined ? {} : { artifactPath: options.artifactPath }),
44
+ ...(options.imageResizer === undefined ? {} : { imageResizer: options.imageResizer }),
45
+ onChunk: (_aggregate, cell) => {
46
+ options.state.output = cell;
47
+ this.emitUpdate(false);
48
+ },
49
+ });
50
+ options.state.status = "running";
51
+ this.emitUpdate(false);
52
+ }
53
+
54
+ push(text: string): void {
55
+ this.#output.push(text);
56
+ }
57
+
58
+ display(message: DisplayMessage): void {
59
+ this.#output.display(message);
60
+ }
61
+
62
+ setPhase(title: string): void {
63
+ this.#state.phase = title;
64
+ this.emitUpdate(false);
65
+ }
66
+
67
+ async finalize(result: KernelResult): Promise<AgentToolResult<EvalToolDetails>> {
68
+ this.#state.durationMs = result.durationMs;
69
+ if (result.ok) {
70
+ if (result.valueRepr) this.#output.push(`${result.valueRepr}\n`);
71
+ this.#state.status = "complete";
72
+ } else {
73
+ this.#output.push(`${result.error.message}\n`);
74
+ this.#state.status = "error";
75
+ }
76
+ return await this.#finish(!result.ok);
77
+ }
78
+
79
+ async finalizeCancellation(error: Error): Promise<AgentToolResult<EvalToolDetails>> {
80
+ this.#output.push(`${error.message}\n`);
81
+ this.#state.status = "error";
82
+ return await this.#finish(true);
83
+ }
84
+
85
+ async flushOutput(): Promise<void> {
86
+ await this.#output.flush();
87
+ }
88
+
89
+ liveResult(): AgentToolResult<EvalToolDetails> {
90
+ return {
91
+ content: [{ type: "text", text: this.#liveUpdateText() }],
92
+ details: this.#details(undefined, this.#state.status === "error"),
93
+ };
94
+ }
95
+
96
+ emitUpdate(isError: boolean): void {
97
+ if (!this.#state.active) return;
98
+ this.#state.onUpdate?.({
99
+ content: [{ type: "text", text: this.#liveUpdateText() }],
100
+ details: this.#details(undefined, isError),
101
+ });
102
+ }
103
+
104
+ async #finish(isError: boolean): Promise<AgentToolResult<EvalToolDetails>> {
105
+ const output = await this.#output.finish();
106
+ this.#state.output = output.output;
107
+ const details = this.#details(output, isError);
108
+ this.emitUpdate(isError);
109
+ const text =
110
+ output.output ||
111
+ (output.images.length > 0
112
+ ? `(displayed ${output.images.length} image${output.images.length === 1 ? "" : "s"}; no text output)`
113
+ : "(no output)");
114
+ return { content: [{ type: "text", text }, ...output.images], details };
115
+ }
116
+
117
+ #details(output: EvalOutputResult | undefined, isError: boolean): EvalToolDetails {
118
+ const statusEvents = this.#state.statusEvents.length > 0 ? [...this.#state.statusEvents] : undefined;
119
+ return {
120
+ language: this.#state.input.language,
121
+ languages: [this.#state.input.language],
122
+ ...(this.#state.input.title === undefined ? {} : { title: this.#state.input.title }),
123
+ durationMs: this.#state.durationMs,
124
+ toolCalls: [...this.#state.toolCalls],
125
+ truncated: output?.truncated ?? false,
126
+ ...(isError ? { isError: true } : {}),
127
+ ...(this.#state.phase === undefined ? {} : { phase: this.#state.phase }),
128
+ cells: [
129
+ {
130
+ index: 0,
131
+ ...(this.#state.input.title === undefined ? {} : { title: this.#state.input.title }),
132
+ code: this.#state.input.code,
133
+ language: this.#state.input.language,
134
+ output: this.#state.output,
135
+ status: this.#state.status,
136
+ durationMs: this.#state.durationMs,
137
+ ...(statusEvents === undefined ? {} : { statusEvents }),
138
+ ...(output?.hasMarkdown ? { hasMarkdown: true } : {}),
139
+ },
140
+ ],
141
+ ...(statusEvents === undefined ? {} : { statusEvents }),
142
+ ...(output === undefined || output.jsonOutputs.length === 0 ? {} : { jsonOutputs: output.jsonOutputs }),
143
+ ...(output?.notice === undefined ? {} : { notice: output.notice }),
144
+ ...(output?.meta === undefined ? {} : { meta: output.meta }),
145
+ };
146
+ }
147
+
148
+ #liveUpdateText(): string {
149
+ const title = this.#state.input.title === undefined ? "" : ` ${this.#state.input.title}`;
150
+ const aggregateOutput = this.#output.aggregateText();
151
+ const outputLines = aggregateOutput.split("\n");
152
+ const hasTrailingNewline = aggregateOutput.endsWith("\n");
153
+ if (hasTrailingNewline) outputLines.pop();
154
+ const output = `${outputLines.slice(-8).join("\n")}${hasTrailingNewline ? "\n" : ""}`;
155
+ return `1/1 cells ${this.#state.status}\n[1] ${this.#state.input.language}${title} ${this.#state.status}${output.length === 0 ? "" : `\n${output}`}`;
156
+ }
157
+ }
@@ -1,26 +1,32 @@
1
- import { mkdir, writeFile } from "node:fs/promises";
2
- import { dirname, join } from "node:path";
3
1
  import type { AgentToolResult } from "@code-yeongyu/senpi";
2
+ import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
3
+ import { currentDetachedResult, detachedErrorResult, snapshotDetachedCell } from "./detached-cell-snapshot.ts";
4
+ import {
5
+ activeDetachedCellReuseError,
6
+ allowsDetachedCellTransition,
7
+ detachedCellIsActive,
8
+ } from "./detached-cell-state.ts";
9
+ import { DetachedNotificationQueue } from "./detached-notification-queue.ts";
4
10
  import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
5
11
 
6
- const NOTIFICATION_TAIL_BYTES = 512;
7
-
8
12
  export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
9
13
 
14
+ type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
15
+
10
16
  type ManagedCell = {
11
17
  readonly cellId: string;
12
18
  readonly input: EvalToolInput;
13
19
  readonly spillPath: string | undefined;
20
+ readonly startedAtMs: number;
21
+ readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
14
22
  state: EvalDetachedCellState;
15
23
  canDetach: boolean;
16
24
  wasDetached: boolean;
17
25
  kernel: EvalKernel | undefined;
18
- /** Set after an interrupt-driven stop; undefined until the kernel reports its fate. */
19
26
  stateRetained: boolean | undefined;
20
- outputTail: (() => string) | undefined;
21
- result: AgentToolResult<EvalToolDetails> | undefined;
27
+ liveResult: LiveResultProvider | undefined;
28
+ terminalResult: AgentToolResult<EvalToolDetails> | undefined;
22
29
  notificationQueued: boolean;
23
- readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
24
30
  };
25
31
 
26
32
  export interface EvalDetachedCellSnapshot {
@@ -28,7 +34,7 @@ export interface EvalDetachedCellSnapshot {
28
34
  readonly language: EvalLanguage;
29
35
  readonly state: EvalDetachedCellState;
30
36
  readonly outputTail: string;
31
- readonly result: AgentToolResult<EvalToolDetails> | undefined;
37
+ readonly result: AgentToolResult<EvalToolDetails>;
32
38
  readonly stateRetained: boolean | undefined;
33
39
  }
34
40
 
@@ -41,63 +47,53 @@ export interface EvalDetachedCellNotifier {
41
47
  notify(cells: readonly EvalDetachedCellNotification[]): void;
42
48
  }
43
49
 
44
- /** One live detached cell, as shown in the footer status line. */
45
50
  export interface EvalDetachedCellStatusEntry {
46
51
  readonly cellId: string;
47
52
  readonly language: EvalLanguage;
48
53
  readonly title?: string;
54
+ readonly startedAtMs: number;
49
55
  }
50
56
 
51
57
  export interface EvalDetachedCellManagerOptions {
52
58
  readonly artifactsDir?: string;
53
59
  readonly notifier?: EvalDetachedCellNotifier;
54
- /** Called with every detached cell whenever that set changes; empty clears the status. */
55
60
  readonly onStatusChange?: (entries: readonly EvalDetachedCellStatusEntry[]) => void;
61
+ readonly now?: () => number;
56
62
  }
57
63
 
58
- /**
59
- * Session-owned lifecycle owner for eval cells that outlive their tool call.
60
- *
61
- * All state changes are synchronous and funnel through #transition. That is the
62
- * atomic boundary for timeout, kernel completion, explicit stop, and session
63
- * disposal races; exactly one path can release a language's detached busy mark.
64
- */
65
64
  export class EvalDetachedCellManager {
66
65
  readonly #artifactsDir: string | undefined;
67
- readonly #notifier: EvalDetachedCellNotifier | undefined;
68
66
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
69
67
  readonly #cells = new Map<string, ManagedCell>();
70
68
  readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
71
- #notificationQueue: ManagedCell[] = [];
72
- #notificationFlush: Promise<void> | undefined;
69
+ readonly #notificationQueue: DetachedNotificationQueue;
70
+ readonly #now: () => number;
73
71
 
74
72
  constructor(options: EvalDetachedCellManagerOptions = {}) {
75
73
  this.#artifactsDir = options.artifactsDir;
76
- this.#notifier = options.notifier;
77
74
  this.#onStatusChange = options.onStatusChange;
75
+ this.#notificationQueue = new DetachedNotificationQueue(options.notifier, options.artifactsDir);
76
+ this.#now = options.now ?? Date.now;
78
77
  }
79
78
 
80
79
  create(cellId: string, input: EvalToolInput): ManagedCell {
81
80
  const existing = this.#cells.get(cellId);
82
81
  if (existing !== undefined) {
83
- if (existing.state === "running" || existing.state === "detached") throw activeCellReuseError(existing);
82
+ if (detachedCellIsActive(existing.state)) throw activeDetachedCellReuseError(existing);
84
83
  this.#cells.delete(cellId);
85
84
  }
86
- const spillPath =
87
- this.#artifactsDir === undefined
88
- ? undefined
89
- : join(this.#artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
90
85
  const cell: ManagedCell = {
91
86
  cellId,
92
87
  input,
93
- spillPath,
88
+ spillPath: detachedNotificationSpillPath(this.#artifactsDir, cellId),
89
+ startedAtMs: this.#now(),
94
90
  state: "running",
95
91
  canDetach: false,
96
92
  wasDetached: false,
97
93
  kernel: undefined,
98
94
  stateRetained: undefined,
99
- outputTail: undefined,
100
- result: undefined,
95
+ liveResult: undefined,
96
+ terminalResult: undefined,
101
97
  notificationQueued: false,
102
98
  terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
103
99
  };
@@ -105,15 +101,16 @@ export class EvalDetachedCellManager {
105
101
  return cell;
106
102
  }
107
103
 
108
- markRunning(cell: ManagedCell, kernel: EvalKernel, outputTail: () => string): void {
104
+ markRunning(cell: ManagedCell, kernel: EvalKernel, liveResult: LiveResultProvider): void {
109
105
  if (cell.state !== "running") return;
110
106
  cell.kernel = kernel;
111
- cell.outputTail = outputTail;
107
+ cell.liveResult = liveResult;
112
108
  cell.canDetach = true;
113
109
  }
114
110
 
115
111
  detach(cell: ManagedCell): boolean {
116
- if (!cell.canDetach || !this.#transition(cell, "detached")) return false;
112
+ if (!cell.canDetach || !allowsDetachedCellTransition(cell.state, "detached")) return false;
113
+ cell.state = "detached";
117
114
  cell.wasDetached = true;
118
115
  this.#detachedByLanguage.set(cell.input.language, cell);
119
116
  this.#emitStatus();
@@ -121,22 +118,19 @@ export class EvalDetachedCellManager {
121
118
  }
122
119
 
123
120
  complete(cell: ManagedCell, result: AgentToolResult<EvalToolDetails>): boolean {
124
- cell.result = result;
125
- return this.#transition(cell, result.details.isError === true ? "failed" : "completed");
121
+ return this.#settle(cell, result.details.isError === true ? "failed" : "completed", result);
126
122
  }
127
123
 
128
124
  fail(cell: ManagedCell, error: Error): boolean {
129
- const result = errorResult(cell, error);
130
- cell.result = result;
131
- return this.#transition(cell, "failed");
125
+ return this.#settle(cell, "failed", detachedErrorResult(cell, error));
132
126
  }
133
127
 
134
128
  async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
135
129
  const cell = this.#get(cellId);
136
- if (cell.state === "detached" && this.#transition(cell, "cancelled")) {
137
- const kernel = cell.kernel;
138
- if (kernel !== undefined) {
139
- const handle = await kernel.interrupt(reason);
130
+ if (cell.state === "detached") {
131
+ const claimed = this.#settle(cell, "cancelled", currentDetachedResult(cell));
132
+ if (claimed && cell.kernel !== undefined) {
133
+ const handle = await cell.kernel.interrupt(reason);
140
134
  cell.stateRetained = await handle.stateRetained;
141
135
  }
142
136
  }
@@ -161,85 +155,51 @@ export class EvalDetachedCellManager {
161
155
  await Promise.allSettled(
162
156
  detached.map(async (cell) => await this.stop(cell.cellId, "Session ended; detached eval cell cancelled")),
163
157
  );
164
- await this.flushNotifications();
158
+ await this.#notificationQueue.flush();
165
159
  }
166
160
 
167
161
  async flushNotifications(): Promise<void> {
168
- const flush = this.#notificationFlush;
169
- if (flush !== undefined) await flush;
170
- }
171
-
172
- #transition(cell: ManagedCell, next: EvalDetachedCellState): boolean {
173
- if (!allowsTransition(cell.state, next)) return false;
174
- cell.state = next;
175
- if (next !== "running" && next !== "detached") cell.terminal.resolve(this.#snapshot(cell));
176
- if (cell.wasDetached && next !== "detached") {
162
+ await this.#notificationQueue.flush();
163
+ }
164
+
165
+ #settle(
166
+ cell: ManagedCell,
167
+ state: "completed" | "failed" | "cancelled",
168
+ result: AgentToolResult<EvalToolDetails>,
169
+ ): boolean {
170
+ if (!allowsDetachedCellTransition(cell.state, state)) return false;
171
+ cell.state = state;
172
+ cell.terminalResult = result;
173
+ cell.liveResult = undefined;
174
+ cell.terminal.resolve(this.#snapshot(cell));
175
+ if (cell.wasDetached) {
177
176
  if (this.#detachedByLanguage.get(cell.input.language) === cell)
178
177
  this.#detachedByLanguage.delete(cell.input.language);
179
178
  this.#emitStatus();
180
- this.#queueNotification(cell);
179
+ if (!cell.notificationQueued) {
180
+ cell.notificationQueued = true;
181
+ this.#notificationQueue.enqueue({
182
+ snapshot: () => this.#snapshot(cell),
183
+ spillPath: cell.spillPath,
184
+ });
185
+ }
181
186
  }
182
187
  return true;
183
188
  }
184
189
 
185
190
  #emitStatus(): void {
186
- const emit = this.#onStatusChange;
187
- if (emit === undefined) return;
188
- emit(
191
+ this.#onStatusChange?.(
189
192
  [...this.#detachedByLanguage.values()].map((cell) => ({
190
193
  cellId: cell.cellId,
191
194
  language: cell.input.language,
195
+ startedAtMs: cell.startedAtMs,
192
196
  ...(cell.input.title === undefined ? {} : { title: cell.input.title }),
193
197
  })),
194
198
  );
195
199
  }
196
200
 
197
- #queueNotification(cell: ManagedCell): void {
198
- if (cell.notificationQueued) return;
199
- cell.notificationQueued = true;
200
- this.#notificationQueue.push(cell);
201
- this.#scheduleNotificationFlush();
202
- }
203
-
204
- #scheduleNotificationFlush(): void {
205
- if (this.#notificationFlush !== undefined) return;
206
- const flush = Promise.resolve().then(async () => {
207
- const cells = this.#notificationQueue.splice(0);
208
- const notifications = await Promise.all(cells.map(async (candidate) => await this.#notification(candidate)));
209
- this.#notifier?.notify(notifications);
210
- });
211
- this.#notificationFlush = flush;
212
- void flush.then(
213
- () => this.#finishNotificationFlush(flush),
214
- () => this.#finishNotificationFlush(flush),
215
- );
216
- }
217
-
218
- #finishNotificationFlush(flush: Promise<void>): void {
219
- if (this.#notificationFlush !== flush) return;
220
- this.#notificationFlush = undefined;
221
- if (this.#notificationQueue.length > 0) this.#scheduleNotificationFlush();
222
- }
223
-
224
- async #notification(cell: ManagedCell): Promise<EvalDetachedCellNotification> {
225
- const snapshot = this.#snapshot(cell);
226
- const body = notificationBody(snapshot);
227
- const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
228
- let spillNotice = "";
229
- if (overflow && cell.spillPath !== undefined) {
230
- try {
231
- await mkdir(dirname(cell.spillPath), { recursive: true });
232
- await writeFile(cell.spillPath, body, "utf8");
233
- spillNotice = `\nBuffered output overflowed; full output: ${localUri(cell.spillPath, this.#artifactsDir)}`;
234
- } catch (error) {
235
- const message = error instanceof Error ? error.message : String(error);
236
- spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
237
- }
238
- }
239
- return {
240
- cellId: cell.cellId,
241
- content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
242
- };
201
+ #snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
202
+ return snapshotDetachedCell(cell, this.#now());
243
203
  }
244
204
 
245
205
  #get(cellId: string): ManagedCell {
@@ -247,110 +207,4 @@ export class EvalDetachedCellManager {
247
207
  if (cell === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
248
208
  return cell;
249
209
  }
250
-
251
- #snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
252
- return {
253
- cellId: cell.cellId,
254
- language: cell.input.language,
255
- state: cell.state,
256
- outputTail: cell.outputTail?.() ?? "",
257
- result: cell.result,
258
- stateRetained: cell.stateRetained,
259
- };
260
- }
261
- }
262
-
263
- function activeCellReuseError(cell: ManagedCell): Error {
264
- return new Error(
265
- `Eval cell ${cell.cellId} from a previous call is still ${cell.state} in the ${cell.input.language} kernel. Use eval({ action: "peek", cell_id: "${cell.cellId}" }) to read it or eval({ action: "stop", cell_id: "${cell.cellId}" }) to end it before its id can be reused.`,
266
- );
267
- }
268
-
269
- function allowsTransition(from: EvalDetachedCellState, to: EvalDetachedCellState): boolean {
270
- if (from === "running") return to === "detached" || to === "completed" || to === "failed" || to === "cancelled";
271
- if (from === "detached") return to === "completed" || to === "failed" || to === "cancelled";
272
- return false;
273
- }
274
-
275
- function errorResult(cell: ManagedCell, error: Error): AgentToolResult<EvalToolDetails> {
276
- const output = cell.outputTail?.() ?? "";
277
- return {
278
- content: [{ type: "text", text: output.length > 0 ? `${output}\n${error.message}` : error.message }],
279
- details: {
280
- language: cell.input.language,
281
- languages: [cell.input.language],
282
- durationMs: 0,
283
- toolCalls: [],
284
- truncated: false,
285
- isError: true,
286
- cells: [
287
- {
288
- index: 0,
289
- code: cell.input.code,
290
- language: cell.input.language,
291
- output,
292
- status: "error",
293
- },
294
- ],
295
- },
296
- };
297
- }
298
-
299
- function notificationBody(cell: EvalDetachedCellSnapshot): string {
300
- const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
301
- const resultText =
302
- cell.result?.content
303
- .filter((part) => part.type === "text")
304
- .map((part) => part.text)
305
- .join("\n") ?? cell.outputTail;
306
- const stateNote =
307
- cell.state === "cancelled" && cell.language === "js"
308
- ? "JavaScript worker was restarted; VM state was lost."
309
- : cell.state === "cancelled" && cell.language === "py"
310
- ? "Python kernel was interrupted; its existing variables are preserved."
311
- : "Kernel state updated - variables are available to the next eval cell.";
312
- return [
313
- `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
314
- resultText.length === 0 ? "(no output)" : resultText,
315
- `${stateNote}</system-reminder>`,
316
- ].join("\n");
317
- }
318
-
319
- function notificationPreview(cell: EvalDetachedCellSnapshot): string {
320
- const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
321
- const resultText =
322
- cell.result?.content
323
- .filter((part) => part.type === "text")
324
- .map((part) => part.text)
325
- .join("\n") ?? cell.outputTail;
326
- const stateNote =
327
- cell.state === "cancelled" && cell.language === "js"
328
- ? "JavaScript worker was restarted; VM state was lost."
329
- : cell.state === "cancelled" && cell.language === "py"
330
- ? "Python kernel was interrupted; its existing variables are preserved."
331
- : "Kernel state updated - variables are available to the next eval cell.";
332
- return [
333
- `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
334
- "Buffered output tail:",
335
- truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
336
- `${stateNote}</system-reminder>`,
337
- ].join("\n");
338
- }
339
-
340
- function safeCellId(cellId: string): string {
341
- return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
342
- }
343
-
344
- function localUri(path: string, artifactsDir: string | undefined): string {
345
- if (artifactsDir === undefined) return `local://${path}`;
346
- const root = join(artifactsDir, "local");
347
- return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
348
- }
349
-
350
- function truncateTailUtf8(text: string, maxBytes: number): string {
351
- if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
352
- const bytes = Buffer.from(text, "utf8");
353
- let start = bytes.length - maxBytes;
354
- while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
355
- return bytes.subarray(start).toString("utf8");
356
210
  }
@@ -0,0 +1,98 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { EvalDetachedCellNotification, EvalDetachedCellSnapshot } from "./detached-cell-manager.ts";
4
+
5
+ const NOTIFICATION_TAIL_BYTES = 512;
6
+
7
+ export function detachedNotificationSpillPath(artifactsDir: string | undefined, cellId: string): string | undefined {
8
+ if (artifactsDir === undefined) return undefined;
9
+ return join(artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
10
+ }
11
+
12
+ export async function buildDetachedCellNotification(
13
+ snapshot: EvalDetachedCellSnapshot,
14
+ spillPath: string | undefined,
15
+ artifactsDir: string | undefined,
16
+ ): Promise<EvalDetachedCellNotification> {
17
+ const body = notificationBody(snapshot);
18
+ const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
19
+ let spillNotice = "";
20
+ if (overflow && spillPath !== undefined) {
21
+ try {
22
+ await mkdir(dirname(spillPath), { recursive: true });
23
+ await writeFile(spillPath, body, "utf8");
24
+ spillNotice = `\nBuffered output overflowed; full output: ${localUri(spillPath, artifactsDir)}`;
25
+ } catch (error) {
26
+ const message = error instanceof Error ? error.message : String(error);
27
+ spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
28
+ }
29
+ }
30
+ return {
31
+ cellId: snapshot.cellId,
32
+ content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
33
+ };
34
+ }
35
+
36
+ function notificationBody(cell: EvalDetachedCellSnapshot): string {
37
+ const outcome = outcomeOf(cell);
38
+ const resultText = textContent(cell);
39
+ const stateNote = stateNoteOf(cell);
40
+ return [
41
+ `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
42
+ resultText.length === 0 ? "(no output)" : resultText,
43
+ `${stateNote}</system-reminder>`,
44
+ ].join("\n");
45
+ }
46
+
47
+ function notificationPreview(cell: EvalDetachedCellSnapshot): string {
48
+ const outcome = outcomeOf(cell);
49
+ const resultText = textContent(cell);
50
+ const stateNote = stateNoteOf(cell);
51
+ return [
52
+ `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
53
+ "Buffered output tail:",
54
+ truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
55
+ `${stateNote}</system-reminder>`,
56
+ ].join("\n");
57
+ }
58
+
59
+ function textContent(cell: EvalDetachedCellSnapshot): string {
60
+ return (
61
+ cell.result.content
62
+ .filter((part) => part.type === "text")
63
+ .map((part) => part.text)
64
+ .join("\n") || cell.outputTail
65
+ );
66
+ }
67
+
68
+ function outcomeOf(cell: EvalDetachedCellSnapshot): string {
69
+ if (cell.state === "completed") return "completed";
70
+ if (cell.state === "cancelled") return "cancelled";
71
+ return "failed";
72
+ }
73
+
74
+ function stateNoteOf(cell: EvalDetachedCellSnapshot): string {
75
+ if (cell.state === "cancelled" && cell.language === "js")
76
+ return "JavaScript worker was restarted; VM state was lost.";
77
+ if (cell.state === "cancelled" && cell.language === "py")
78
+ return "Python kernel was interrupted; its existing variables are preserved.";
79
+ return "Kernel state updated - variables are available to the next eval cell.";
80
+ }
81
+
82
+ function safeCellId(cellId: string): string {
83
+ return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
84
+ }
85
+
86
+ function localUri(path: string, artifactsDir: string | undefined): string {
87
+ if (artifactsDir === undefined) return `local://${path}`;
88
+ const root = join(artifactsDir, "local");
89
+ return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
90
+ }
91
+
92
+ function truncateTailUtf8(text: string, maxBytes: number): string {
93
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
94
+ const bytes = Buffer.from(text, "utf8");
95
+ let start = bytes.length - maxBytes;
96
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
97
+ return bytes.subarray(start).toString("utf8");
98
+ }