@code-yeongyu/senpi-codemode 2026.7.25-2 → 2026.7.28

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.
@@ -1,8 +1,9 @@
1
1
  import type { AgentToolResult, AgentToolUpdateCallback, ExtensionContext } from "@code-yeongyu/senpi";
2
2
  import type { KernelToHostMessage } from "../bridge/protocol.ts";
3
- import { RESERVED_AGENT_TOOL, RESERVED_OUTPUT_TOOL } from "../bridge/reserved.ts";
4
- import { type AgentExecuteTool, runEvalAgent } from "../bridges/agent-bridge.ts";
5
- import { runEvalOutput } from "../bridges/output-bridge.ts";
3
+ import type { AgentExecuteTool } from "../bridges/agent-bridge.ts";
4
+ import { isReservedToolName, runReservedTool } from "../bridges/reserved-dispatch.ts";
5
+ import type { EvalSchemaToolInfo } from "../bridges/schema-bridge.ts";
6
+ import { appendSchemaHint } from "../bridges/schema-hint.ts";
6
7
  import type { CompletionRequest, CompletionResult } from "../completion/handler.ts";
7
8
  import { handleCompletionToolCall } from "../completion/tool-bridge.ts";
8
9
  import type { ResolvedCodemodeSettings } from "../config/settings.ts";
@@ -36,6 +37,7 @@ export interface CellState {
36
37
 
37
38
  export interface CellBridgeRuntime {
38
39
  readonly executeTool: AgentExecuteTool;
40
+ readonly listTools?: () => readonly EvalSchemaToolInfo[];
39
41
  readonly settings: ResolvedCodemodeSettings;
40
42
  readonly complete?: (request: CompletionRequest, ctx: ExtensionContext) => Promise<CompletionResult>;
41
43
  readonly ctx: ExtensionContext;
@@ -151,25 +153,17 @@ export class CellHandler {
151
153
  });
152
154
  return;
153
155
  }
154
- if (message.toolName === RESERVED_AGENT_TOOL) {
156
+ if (isReservedToolName(message.toolName)) {
155
157
  await this.#deliverToolReply(message, async () => ({
156
- value: await runEvalAgent(message.args, {
158
+ value: await runReservedTool(message.toolName, {
157
159
  callId: message.callId,
158
- taskToolName: this.#runtime.settings.taskTools.task,
160
+ args: message.args,
159
161
  executeTool: this.#runtime.executeTool,
160
- signal: this.#state.signal,
161
- emitStatus: (event) => this.#recordStatus(event),
162
- }),
163
- toolCallOk: true,
164
- }));
165
- return;
166
- }
167
- if (message.toolName === RESERVED_OUTPUT_TOOL) {
168
- await this.#deliverToolReply(message, async () => ({
169
- value: await runEvalOutput(message.args, {
162
+ taskToolName: this.#runtime.settings.taskTools.task,
170
163
  taskOutputToolName: this.#runtime.settings.taskTools.output,
171
- executeTool: this.#runtime.executeTool,
164
+ listTools: this.#runtime.listTools,
172
165
  signal: this.#state.signal,
166
+ emitStatus: (event) => this.#recordStatus(event),
173
167
  marshalToolResult,
174
168
  }),
175
169
  toolCallOk: true,
@@ -210,7 +204,11 @@ export class CellHandler {
210
204
  this.#kernel.deliverToolReply({ type: "tool-reply", callId: message.callId, ok: true, value: reply.value });
211
205
  } catch (error) {
212
206
  if (!this.#state.active) return;
213
- const text = error instanceof Error ? error.message : String(error);
207
+ const text = appendSchemaHint(
208
+ error instanceof Error ? error.message : String(error),
209
+ message.toolName,
210
+ this.#toolParameters(message.toolName),
211
+ );
214
212
  this.#state.toolCalls.push({ name: message.toolName, ok: false, error: text });
215
213
  this.#kernel.deliverToolReply({
216
214
  type: "tool-reply",
@@ -222,6 +220,10 @@ export class CellHandler {
222
220
  this.#emitUpdate(false);
223
221
  }
224
222
 
223
+ #toolParameters(toolName: string): unknown {
224
+ return this.#runtime.listTools?.().find((tool) => tool.name === toolName)?.parameters;
225
+ }
226
+
225
227
  #recordStatus(event: EvalStatusEvent): void {
226
228
  if (!this.#runtime.settings.statusEvents) return;
227
229
  upsertStatusEvent(this.#state.statusEvents, event);
@@ -0,0 +1,322 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { AgentToolResult } from "@code-yeongyu/senpi";
4
+ import type { EvalKernel, EvalLanguage, EvalToolDetails, EvalToolInput } from "./types.ts";
5
+
6
+ const NOTIFICATION_TAIL_BYTES = 512;
7
+
8
+ export type EvalDetachedCellState = "running" | "detached" | "completed" | "failed" | "cancelled";
9
+
10
+ type ManagedCell = {
11
+ readonly cellId: string;
12
+ readonly input: EvalToolInput;
13
+ readonly spillPath: string | undefined;
14
+ state: EvalDetachedCellState;
15
+ canDetach: boolean;
16
+ wasDetached: boolean;
17
+ kernel: EvalKernel | undefined;
18
+ /** Set after an interrupt-driven stop; undefined until the kernel reports its fate. */
19
+ stateRetained: boolean | undefined;
20
+ outputTail: (() => string) | undefined;
21
+ result: AgentToolResult<EvalToolDetails> | undefined;
22
+ notificationQueued: boolean;
23
+ readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
24
+ };
25
+
26
+ export interface EvalDetachedCellSnapshot {
27
+ readonly cellId: string;
28
+ readonly language: EvalLanguage;
29
+ readonly state: EvalDetachedCellState;
30
+ readonly outputTail: string;
31
+ readonly result: AgentToolResult<EvalToolDetails> | undefined;
32
+ readonly stateRetained: boolean | undefined;
33
+ }
34
+
35
+ export interface EvalDetachedCellNotification {
36
+ readonly cellId: string;
37
+ readonly content: string;
38
+ }
39
+
40
+ export interface EvalDetachedCellNotifier {
41
+ notify(cells: readonly EvalDetachedCellNotification[]): void;
42
+ }
43
+
44
+ export interface EvalDetachedCellManagerOptions {
45
+ readonly artifactsDir?: string;
46
+ readonly notifier?: EvalDetachedCellNotifier;
47
+ }
48
+
49
+ /**
50
+ * Session-owned lifecycle owner for eval cells that outlive their tool call.
51
+ *
52
+ * All state changes are synchronous and funnel through #transition. That is the
53
+ * atomic boundary for timeout, kernel completion, explicit stop, and session
54
+ * disposal races; exactly one path can release a language's detached busy mark.
55
+ */
56
+ export class EvalDetachedCellManager {
57
+ readonly #artifactsDir: string | undefined;
58
+ readonly #notifier: EvalDetachedCellNotifier | undefined;
59
+ readonly #cells = new Map<string, ManagedCell>();
60
+ readonly #detachedByLanguage = new Map<EvalLanguage, ManagedCell>();
61
+ #notificationQueue: ManagedCell[] = [];
62
+ #notificationFlush: Promise<void> | undefined;
63
+
64
+ constructor(options: EvalDetachedCellManagerOptions = {}) {
65
+ this.#artifactsDir = options.artifactsDir;
66
+ this.#notifier = options.notifier;
67
+ }
68
+
69
+ create(cellId: string, input: EvalToolInput): ManagedCell {
70
+ const existing = this.#cells.get(cellId);
71
+ if (existing !== undefined) throw new Error(`Eval cell ${cellId} is already managed`);
72
+ const spillPath =
73
+ this.#artifactsDir === undefined
74
+ ? undefined
75
+ : join(this.#artifactsDir, "local", `detached-eval-${safeCellId(cellId)}.log`);
76
+ const cell: ManagedCell = {
77
+ cellId,
78
+ input,
79
+ spillPath,
80
+ state: "running",
81
+ canDetach: false,
82
+ wasDetached: false,
83
+ kernel: undefined,
84
+ stateRetained: undefined,
85
+ outputTail: undefined,
86
+ result: undefined,
87
+ notificationQueued: false,
88
+ terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
89
+ };
90
+ this.#cells.set(cellId, cell);
91
+ return cell;
92
+ }
93
+
94
+ markRunning(cell: ManagedCell, kernel: EvalKernel, outputTail: () => string): void {
95
+ if (cell.state !== "running") return;
96
+ cell.kernel = kernel;
97
+ cell.outputTail = outputTail;
98
+ cell.canDetach = true;
99
+ }
100
+
101
+ detach(cell: ManagedCell): boolean {
102
+ if (!cell.canDetach || !this.#transition(cell, "detached")) return false;
103
+ cell.wasDetached = true;
104
+ this.#detachedByLanguage.set(cell.input.language, cell);
105
+ return true;
106
+ }
107
+
108
+ complete(cell: ManagedCell, result: AgentToolResult<EvalToolDetails>): boolean {
109
+ cell.result = result;
110
+ return this.#transition(cell, result.details.isError === true ? "failed" : "completed");
111
+ }
112
+
113
+ fail(cell: ManagedCell, error: Error): boolean {
114
+ const result = errorResult(cell, error);
115
+ cell.result = result;
116
+ return this.#transition(cell, "failed");
117
+ }
118
+
119
+ async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
120
+ const cell = this.#get(cellId);
121
+ if (cell.state === "detached" && this.#transition(cell, "cancelled")) {
122
+ const kernel = cell.kernel;
123
+ if (kernel !== undefined) {
124
+ const handle = await kernel.interrupt(reason);
125
+ cell.stateRetained = await handle.stateRetained;
126
+ }
127
+ }
128
+ return this.#snapshot(cell);
129
+ }
130
+
131
+ peek(cellId: string): EvalDetachedCellSnapshot {
132
+ return this.#snapshot(this.#get(cellId));
133
+ }
134
+
135
+ busyFor(language: EvalLanguage): EvalDetachedCellSnapshot | undefined {
136
+ const cell = this.#detachedByLanguage.get(language);
137
+ return cell === undefined ? undefined : this.#snapshot(cell);
138
+ }
139
+
140
+ async waitForTerminal(cellId: string): Promise<EvalDetachedCellSnapshot> {
141
+ return await this.#get(cellId).terminal.promise;
142
+ }
143
+
144
+ async dispose(): Promise<void> {
145
+ const detached = [...this.#detachedByLanguage.values()];
146
+ await Promise.allSettled(
147
+ detached.map(async (cell) => await this.stop(cell.cellId, "Session ended; detached eval cell cancelled")),
148
+ );
149
+ await this.flushNotifications();
150
+ }
151
+
152
+ async flushNotifications(): Promise<void> {
153
+ const flush = this.#notificationFlush;
154
+ if (flush !== undefined) await flush;
155
+ }
156
+
157
+ #transition(cell: ManagedCell, next: EvalDetachedCellState): boolean {
158
+ if (!allowsTransition(cell.state, next)) return false;
159
+ cell.state = next;
160
+ if (next !== "running" && next !== "detached") cell.terminal.resolve(this.#snapshot(cell));
161
+ if (cell.wasDetached && next !== "detached") {
162
+ if (this.#detachedByLanguage.get(cell.input.language) === cell)
163
+ this.#detachedByLanguage.delete(cell.input.language);
164
+ this.#queueNotification(cell);
165
+ }
166
+ return true;
167
+ }
168
+
169
+ #queueNotification(cell: ManagedCell): void {
170
+ if (cell.notificationQueued) return;
171
+ cell.notificationQueued = true;
172
+ this.#notificationQueue.push(cell);
173
+ this.#scheduleNotificationFlush();
174
+ }
175
+
176
+ #scheduleNotificationFlush(): void {
177
+ if (this.#notificationFlush !== undefined) return;
178
+ const flush = Promise.resolve().then(async () => {
179
+ const cells = this.#notificationQueue.splice(0);
180
+ const notifications = await Promise.all(cells.map(async (candidate) => await this.#notification(candidate)));
181
+ this.#notifier?.notify(notifications);
182
+ });
183
+ this.#notificationFlush = flush;
184
+ void flush.then(
185
+ () => this.#finishNotificationFlush(flush),
186
+ () => this.#finishNotificationFlush(flush),
187
+ );
188
+ }
189
+
190
+ #finishNotificationFlush(flush: Promise<void>): void {
191
+ if (this.#notificationFlush !== flush) return;
192
+ this.#notificationFlush = undefined;
193
+ if (this.#notificationQueue.length > 0) this.#scheduleNotificationFlush();
194
+ }
195
+
196
+ async #notification(cell: ManagedCell): Promise<EvalDetachedCellNotification> {
197
+ const snapshot = this.#snapshot(cell);
198
+ const body = notificationBody(snapshot);
199
+ const overflow = Buffer.byteLength(body, "utf8") > NOTIFICATION_TAIL_BYTES;
200
+ let spillNotice = "";
201
+ if (overflow && cell.spillPath !== undefined) {
202
+ try {
203
+ await mkdir(dirname(cell.spillPath), { recursive: true });
204
+ await writeFile(cell.spillPath, body, "utf8");
205
+ spillNotice = `\nBuffered output overflowed; full output: ${localUri(cell.spillPath, this.#artifactsDir)}`;
206
+ } catch (error) {
207
+ const message = error instanceof Error ? error.message : String(error);
208
+ spillNotice = `\nBuffered output overflow could not be spilled: ${message}`;
209
+ }
210
+ }
211
+ return {
212
+ cellId: cell.cellId,
213
+ content: `${overflow ? notificationPreview(snapshot) : body}${overflow ? "\n[…notification tail capped…]" : ""}${spillNotice}`,
214
+ };
215
+ }
216
+
217
+ #get(cellId: string): ManagedCell {
218
+ const cell = this.#cells.get(cellId);
219
+ if (cell === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
220
+ return cell;
221
+ }
222
+
223
+ #snapshot(cell: ManagedCell): EvalDetachedCellSnapshot {
224
+ return {
225
+ cellId: cell.cellId,
226
+ language: cell.input.language,
227
+ state: cell.state,
228
+ outputTail: cell.outputTail?.() ?? "",
229
+ result: cell.result,
230
+ stateRetained: cell.stateRetained,
231
+ };
232
+ }
233
+ }
234
+
235
+ function allowsTransition(from: EvalDetachedCellState, to: EvalDetachedCellState): boolean {
236
+ if (from === "running") return to === "detached" || to === "completed" || to === "failed" || to === "cancelled";
237
+ if (from === "detached") return to === "completed" || to === "failed" || to === "cancelled";
238
+ return false;
239
+ }
240
+
241
+ function errorResult(cell: ManagedCell, error: Error): AgentToolResult<EvalToolDetails> {
242
+ const output = cell.outputTail?.() ?? "";
243
+ return {
244
+ content: [{ type: "text", text: output.length > 0 ? `${output}\n${error.message}` : error.message }],
245
+ details: {
246
+ language: cell.input.language,
247
+ languages: [cell.input.language],
248
+ durationMs: 0,
249
+ toolCalls: [],
250
+ truncated: false,
251
+ isError: true,
252
+ cells: [
253
+ {
254
+ index: 0,
255
+ code: cell.input.code,
256
+ language: cell.input.language,
257
+ output,
258
+ status: "error",
259
+ },
260
+ ],
261
+ },
262
+ };
263
+ }
264
+
265
+ function notificationBody(cell: EvalDetachedCellSnapshot): string {
266
+ const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
267
+ const resultText =
268
+ cell.result?.content
269
+ .filter((part) => part.type === "text")
270
+ .map((part) => part.text)
271
+ .join("\n") ?? cell.outputTail;
272
+ const stateNote =
273
+ cell.state === "cancelled" && cell.language === "js"
274
+ ? "JavaScript worker was restarted; VM state was lost."
275
+ : cell.state === "cancelled" && cell.language === "py"
276
+ ? "Python kernel was interrupted; its existing variables are preserved."
277
+ : "Kernel state updated - variables are available to the next eval cell.";
278
+ return [
279
+ `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
280
+ resultText.length === 0 ? "(no output)" : resultText,
281
+ `${stateNote}</system-reminder>`,
282
+ ].join("\n");
283
+ }
284
+
285
+ function notificationPreview(cell: EvalDetachedCellSnapshot): string {
286
+ const outcome = cell.state === "completed" ? "completed" : cell.state === "cancelled" ? "cancelled" : "failed";
287
+ const resultText =
288
+ cell.result?.content
289
+ .filter((part) => part.type === "text")
290
+ .map((part) => part.text)
291
+ .join("\n") ?? cell.outputTail;
292
+ const stateNote =
293
+ cell.state === "cancelled" && cell.language === "js"
294
+ ? "JavaScript worker was restarted; VM state was lost."
295
+ : cell.state === "cancelled" && cell.language === "py"
296
+ ? "Python kernel was interrupted; its existing variables are preserved."
297
+ : "Kernel state updated - variables are available to the next eval cell.";
298
+ return [
299
+ `<system-reminder>Detached eval cell ${cell.cellId} (${cell.language}) ${outcome}.`,
300
+ "Buffered output tail:",
301
+ truncateTailUtf8(resultText, NOTIFICATION_TAIL_BYTES),
302
+ `${stateNote}</system-reminder>`,
303
+ ].join("\n");
304
+ }
305
+
306
+ function safeCellId(cellId: string): string {
307
+ return cellId.replace(/[^a-zA-Z0-9_-]/gu, "_");
308
+ }
309
+
310
+ function localUri(path: string, artifactsDir: string | undefined): string {
311
+ if (artifactsDir === undefined) return `local://${path}`;
312
+ const root = join(artifactsDir, "local");
313
+ return path.startsWith(`${root}/`) ? `local://${path.slice(root.length + 1)}` : `local://${path}`;
314
+ }
315
+
316
+ function truncateTailUtf8(text: string, maxBytes: number): string {
317
+ if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
318
+ const bytes = Buffer.from(text, "utf8");
319
+ let start = bytes.length - maxBytes;
320
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
321
+ return bytes.subarray(start).toString("utf8");
322
+ }