@code-yeongyu/senpi-codemode 2026.10.11 → 2026.10.12

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 CHANGED
@@ -12,6 +12,24 @@
12
12
 
13
13
  ### Removed
14
14
 
15
+ ## [2026.10.12] - 2026-10-12
16
+
17
+ ### Breaking Changes
18
+
19
+ ### Added
20
+
21
+ - Session-owned eval cell RPC handlers share the eval tool's manager, bound output tails, return typed unknown-cell failures and confirmed stop snapshots, and preserve ordinary detached completion notifications ([#3072](https://github.com/code-yeongyu/senpi/issues/3072)).
22
+
23
+ ### Changed
24
+
25
+ - The eval tool's stop action also cancels listed running foreground cells, returning their terminal outcome. Concurrent stops await the same interrupt and confirm whether kernel state survived ([#3072](https://github.com/code-yeongyu/senpi/issues/3072)).
26
+
27
+ ### Fixed
28
+
29
+ - Pip installs preserve successful results, typed install errors and cancellation when scratch cleanup fails. Cleanup retries before reporting exhausted retries as a warning ([#3103](https://github.com/code-yeongyu/senpi/pull/3103)).
30
+
31
+ ### Removed
32
+
15
33
  ## [2026.10.11] - 2026-10-11
16
34
 
17
35
  ### Breaking Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-codemode",
3
- "version": "2026.10.11",
3
+ "version": "2026.10.12",
4
4
  "description": "Source-only senpi extension package for codemode evaluation tools",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "gate": "bun scripts/gate-eval.ts",
21
21
  "bench": "bun scripts/bench-eval.ts",
22
- "test": "vitest run test/",
22
+ "test": "node ../../scripts/run-tests.mjs vitest run test/",
23
23
  "clean": "node -e \"\"",
24
24
  "build": "node -e \"\""
25
25
  },
@@ -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.11",
36
+ "@earendil-works/pi-ai": "npm:@code-yeongyu/senpi-ai@2026.10.12",
37
37
  "quickjs-wasi": "3.6.2",
38
38
  "typebox": "1.3.34"
39
39
  },
40
40
  "peerDependencies": {
41
- "@code-yeongyu/senpi": "2026.10.11"
41
+ "@code-yeongyu/senpi": "2026.10.12"
42
42
  },
43
43
  "devDependencies": {
44
- "@code-yeongyu/senpi": "2026.10.11"
44
+ "@code-yeongyu/senpi": "2026.10.12"
45
45
  },
46
46
  "keywords": [
47
47
  "senpi",
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { mkdtemp, rm } from "node:fs/promises";
2
3
  import { devNull } from "node:os";
4
+ import { join } from "node:path";
3
5
  import { terminateProcessTrees } from "../kernels/js/process-tree-host.ts";
4
6
 
5
7
  export type EnvironmentErrorCode =
@@ -180,7 +182,7 @@ export function isolatedPipEnv(): NodeJS.ProcessEnv {
180
182
  return { ...env, PIP_CONFIG_FILE: process.platform === "win32" ? "nul" : devNull, PYTHONNOUSERSITE: "1" };
181
183
  }
182
184
 
183
- export function runPipInstall(input: {
185
+ export async function runPipInstall(input: {
184
186
  readonly interpreter: string;
185
187
  readonly root: string;
186
188
  readonly args: readonly string[];
@@ -200,52 +202,82 @@ export function runPipInstall(input: {
200
202
  input.root,
201
203
  ...input.args,
202
204
  ];
203
- return new Promise((resolve, reject) => {
204
- if (input.signal.aborted) {
205
- reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"));
206
- return;
207
- }
208
- const child = spawn(input.interpreter, argv, {
209
- cwd: input.cwd,
210
- stdio: ["ignore", "pipe", "pipe"],
211
- env: isolatedPipEnv(),
212
- });
213
- let stderrTail = "";
214
- child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
215
- child.stderr.setEncoding("utf8").on("data", (data: string) => {
216
- stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
217
- input.onOutput?.("stderr", data);
218
- });
219
- // pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
220
- const onAbort = () => {
221
- const pid = child.pid;
222
- if (pid === undefined) child.kill("SIGKILL");
223
- else void terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => child.kill("SIGKILL"));
224
- };
225
- input.signal.addEventListener("abort", onAbort, { once: true });
226
- child.once("error", (error) => {
227
- input.signal.removeEventListener("abort", onAbort);
228
- reject(
229
- new EnvironmentError("environment_installer_unavailable", `${input.interpreter} -m pip: ${error.message}`),
230
- );
231
- });
232
- child.once("close", (code, signal) => {
233
- input.signal.removeEventListener("abort", onAbort);
205
+ if (input.signal.aborted) {
206
+ throw new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started");
207
+ }
208
+ const scratch = await mkdtemp(join(input.root, ".pip-"));
209
+ try {
210
+ await new Promise<void>((resolve, reject) => {
234
211
  if (input.signal.aborted) {
235
- reject(new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"));
236
- } else if (code === 0) resolve();
237
- else if (/No module named pip/.test(stderrTail)) {
238
- reject(new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`));
239
- } else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
240
- reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
241
- } else {
212
+ reject(
213
+ new EnvironmentError("environment_install_cancelled", "the install was cancelled before it started"),
214
+ );
215
+ return;
216
+ }
217
+ const child = spawn(input.interpreter, argv, {
218
+ cwd: input.cwd,
219
+ stdio: ["ignore", "pipe", "pipe"],
220
+ env: { ...isolatedPipEnv(), TMPDIR: scratch, TEMP: scratch, TMP: scratch },
221
+ });
222
+ let termination: Promise<void> | undefined;
223
+ let stderrTail = "";
224
+ child.stdout.setEncoding("utf8").on("data", (data: string) => input.onOutput?.("stdout", data));
225
+ child.stderr.setEncoding("utf8").on("data", (data: string) => {
226
+ stderrTail = (stderrTail + data).slice(-STDERR_TAIL_BYTES);
227
+ input.onOutput?.("stderr", data);
228
+ });
229
+ // pip's build backends run in their own subprocesses; stopping only pip would leave them writing.
230
+ const onAbort = () => {
231
+ const pid = child.pid;
232
+ if (pid === undefined) child.kill("SIGKILL");
233
+ else
234
+ termination = terminateProcessTrees([pid], { graceMs: PIP_TREE_GRACE_MS }).catch(() => {
235
+ child.kill("SIGKILL");
236
+ });
237
+ };
238
+ input.signal.addEventListener("abort", onAbort, { once: true });
239
+ child.once("error", (error) => {
240
+ input.signal.removeEventListener("abort", onAbort);
242
241
  reject(
243
242
  new EnvironmentError(
244
- "environment_install_failed",
245
- stderrTail.trim() || `pip exited with ${code ?? signal}`,
243
+ "environment_installer_unavailable",
244
+ `${input.interpreter} -m pip: ${error.message}`,
246
245
  ),
247
246
  );
248
- }
247
+ });
248
+ child.once("close", async (code, signal) => {
249
+ input.signal.removeEventListener("abort", onAbort);
250
+ await termination;
251
+ if (input.signal.aborted) {
252
+ reject(
253
+ new EnvironmentError("environment_install_cancelled", "the install was cancelled; pip was stopped"),
254
+ );
255
+ } else if (code === 0) resolve();
256
+ else if (/No module named pip/.test(stderrTail)) {
257
+ reject(
258
+ new EnvironmentError("environment_installer_unavailable", `${input.interpreter} has no pip module`),
259
+ );
260
+ } else if (/ResolutionImpossible|conflicting dependencies/.test(stderrTail)) {
261
+ reject(new EnvironmentError("environment_resolution_conflict", stderrTail.trim()));
262
+ } else {
263
+ reject(
264
+ new EnvironmentError(
265
+ "environment_install_failed",
266
+ stderrTail.trim() || `pip exited with ${code ?? signal}`,
267
+ ),
268
+ );
269
+ }
270
+ });
249
271
  });
250
- });
272
+ } finally {
273
+ // A killed pip/build backend cannot clean Python's tempfile directories itself.
274
+ try {
275
+ await rm(scratch, { recursive: true, force: true, maxRetries: 3 });
276
+ } catch (cause) {
277
+ // Keep the install's success or typed failure; cleanup is a separate diagnostic.
278
+ process.emitWarning(`pip scratch cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
279
+ code: "SENPI_PIP_SCRATCH_CLEANUP",
280
+ });
281
+ }
282
+ }
251
283
  }
@@ -0,0 +1,62 @@
1
+ import { EvalCellNotFoundError, type EvalDetachedCellManager } from "../tool/detached-cell-manager.ts";
2
+ import { listEvalCells } from "../tool/detached-eval-result.ts";
3
+
4
+ const DEFAULT_TAIL_BYTES = 8 * 1024;
5
+ const MAX_TAIL_BYTES = 64 * 1024;
6
+
7
+ class InvalidEvalCellRequestError extends Error {
8
+ readonly code = "invalid_eval_cell_request";
9
+
10
+ constructor(message: string) {
11
+ super(message);
12
+ this.name = "InvalidEvalCellRequestError";
13
+ }
14
+ }
15
+
16
+ /** Extension RPC handlers never invoke a model tool or write a session entry. */
17
+ export function registerEvalCellsRpc(
18
+ rpc: { handle?(name: string, handler: (data: unknown) => unknown | Promise<unknown>): void } | undefined,
19
+ getCells: () => EvalDetachedCellManager | undefined,
20
+ ): void {
21
+ rpc?.handle?.("eval_cells.list", () => {
22
+ const cells = getCells();
23
+ return cells === undefined ? [] : listEvalCells(cells);
24
+ });
25
+ for (const action of ["peek", "stop"] as const) {
26
+ rpc?.handle?.(`eval_cells.${action}`, async (data) => {
27
+ if (
28
+ typeof data !== "object" ||
29
+ data === null ||
30
+ !("cellId" in data) ||
31
+ typeof data.cellId !== "string" ||
32
+ data.cellId.trim().length === 0
33
+ ) {
34
+ throw new InvalidEvalCellRequestError("cellId must be a non-empty string");
35
+ }
36
+ const tailBytes = action === "peek" && "tailBytes" in data ? data.tailBytes : DEFAULT_TAIL_BYTES;
37
+ if (typeof tailBytes !== "number" || !Number.isSafeInteger(tailBytes) || tailBytes < 0) {
38
+ throw new InvalidEvalCellRequestError("tailBytes must be a non-negative safe integer");
39
+ }
40
+ const cells = getCells();
41
+ if (cells === undefined) throw new EvalCellNotFoundError(data.cellId);
42
+ const snapshot = action === "stop" ? await cells.stop(data.cellId) : cells.peek(data.cellId);
43
+ const bytes = Buffer.from(snapshot.outputTail, "utf8");
44
+ let start = Math.max(0, bytes.length - Math.min(tailBytes, MAX_TAIL_BYTES));
45
+ // Drop a partial leading UTF-8 character instead of expanding it into a replacement character.
46
+ while (start < bytes.length && (bytes[start] & 0xc0) === 0x80) start++;
47
+ return {
48
+ cellId: snapshot.cellId,
49
+ language: snapshot.language,
50
+ state: snapshot.state,
51
+ startedAtMs: snapshot.startedAtMs,
52
+ ...(snapshot.result.details.summary ? { summary: snapshot.result.details.summary } : {}),
53
+ ...(snapshot.queuedBehind === undefined ? {} : { queuedBehind: snapshot.queuedBehind }),
54
+ outputTail: bytes.subarray(start).toString("utf8"),
55
+ ...(snapshot.stateRetained === undefined ? {} : { stateRetained: snapshot.stateRetained }),
56
+ ...(snapshot.interruptNote === undefined ? {} : { interruptNote: snapshot.interruptNote }),
57
+ ...(snapshot.hardLimitSeconds === undefined ? {} : { hardLimitSeconds: snapshot.hardLimitSeconds }),
58
+ ...(snapshot.runBudgetSeconds === undefined ? {} : { runBudgetSeconds: snapshot.runBudgetSeconds }),
59
+ };
60
+ });
61
+ }
62
+ }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import {
10
10
  resolveMaxDetachedCells,
11
11
  resolveRunBudgetSeconds,
12
12
  } from "./config/settings.ts";
13
+ import { registerEvalCellsRpc } from "./extension/eval-cells-rpc.ts";
13
14
  import { type EvalNotificationContent, EvalNotifier } from "./extension/eval-notifier.ts";
14
15
  import { EVAL_CELLS_STATUS_KEY } from "./extension/eval-status.ts";
15
16
  import { EvalStatusTicker } from "./extension/eval-status-ticker.ts";
@@ -63,7 +64,10 @@ export interface CodemodeExtensionAPI {
63
64
  /** Optional host event bus; a host without one turns extension event emission into a harmless no-op. */
64
65
  events?: { emit(name: string, data: unknown): void };
65
66
  /** Optional host RPC surface for forwarding extension-owned events to connected clients. */
66
- rpc?: { emit(name: string, data: unknown): void };
67
+ rpc?: {
68
+ emit(name: string, data: unknown): void;
69
+ handle?(name: string, handler: (data: unknown) => unknown | Promise<unknown>): void;
70
+ };
67
71
  }
68
72
 
69
73
  export interface SenpiCodemodeOptions {
@@ -94,6 +98,7 @@ export default function senpiCodemode(pi: CodemodeExtensionAPI, options: SenpiCo
94
98
  let activeContext: ExtensionContext | undefined;
95
99
  let activeCells: EvalDetachedCellManager | undefined;
96
100
  let activeHandles: HandleRegistry | undefined;
101
+ registerEvalCellsRpc(pi.rpc, () => activeCells);
97
102
  let promptPreludeDocs = "";
98
103
  const notifier = new EvalNotifier({
99
104
  sendMessage: (message, notifyOptions) => pi.sendMessage(message, notifyOptions),
@@ -35,10 +35,20 @@ export type {
35
35
  EvalDetachedCellStatusEntry,
36
36
  } from "./detached-cell-contract.ts";
37
37
 
38
+ export class EvalCellNotFoundError extends Error {
39
+ readonly code = "eval_cell_not_found";
40
+
41
+ constructor(cellId: string) {
42
+ super(`Unknown detached eval cell "${cellId}"`);
43
+ this.name = "EvalCellNotFoundError";
44
+ }
45
+ }
46
+
38
47
  export class EvalDetachedCellManager {
39
48
  readonly #onStatusChange: ((entries: readonly EvalDetachedCellStatusEntry[]) => void) | undefined;
40
49
  readonly #onWakeSourceState: ((state: WakeSourceState) => void) | undefined;
41
50
  readonly #cells = new Map<string, ManagedCell>();
51
+ readonly #interrupting = new Map<string, ManagedCell>();
42
52
  readonly #detached = new Map<string, ManagedCell>();
43
53
  readonly #terminalSnapshots: TerminalSnapshotStore;
44
54
  readonly #notificationQueue: DetachedNotificationQueue;
@@ -156,10 +166,11 @@ export class EvalDetachedCellManager {
156
166
  }
157
167
 
158
168
  async stop(cellId: string, reason = "Stopped detached eval cell"): Promise<EvalDetachedCellSnapshot> {
159
- const live = this.#cells.get(cellId);
169
+ const live = this.#cells.get(cellId) ?? this.#interrupting.get(cellId);
160
170
  if (live === undefined) return this.#terminal(cellId);
161
171
  if (live.state === "queued") this.#cancelQueued(live, reason);
162
- else if (live.detached) await this.#cancel(live, reason);
172
+ else if (detachedCellIsActive(live.state)) await this.#cancel(live, reason);
173
+ await live.interruptOutcome?.promise;
163
174
  return this.#snapshot(live);
164
175
  }
165
176
 
@@ -273,6 +284,7 @@ export class EvalDetachedCellManager {
273
284
  async #cancel(cell: ManagedCell, reason: string): Promise<void> {
274
285
  const outcome = Promise.withResolvers<void>();
275
286
  cell.interruptOutcome = outcome;
287
+ this.#interrupting.set(cell.cellId, cell);
276
288
  try {
277
289
  if (!this.#settle(cell, "cancelled", cancelledDetachedResult(cell)) || cell.kernel === undefined) return;
278
290
  const handle = await cell.kernel.interrupt(reason, cell.cellId);
@@ -280,6 +292,7 @@ export class EvalDetachedCellManager {
280
292
  cell.stateRetained = await handle.stateRetained;
281
293
  } finally {
282
294
  outcome.resolve();
295
+ if (this.#interrupting.get(cell.cellId) === cell) this.#interrupting.delete(cell.cellId);
283
296
  this.#refreshTerminalSnapshot(cell);
284
297
  }
285
298
  }
@@ -304,7 +317,7 @@ export class EvalDetachedCellManager {
304
317
 
305
318
  #terminal(cellId: string): EvalDetachedCellSnapshot {
306
319
  const snapshot = this.#terminalSnapshots.get(cellId);
307
- if (snapshot === undefined) throw new Error(`Unknown detached eval cell "${cellId}"`);
320
+ if (snapshot === undefined) throw new EvalCellNotFoundError(cellId);
308
321
  return snapshot;
309
322
  }
310
323
  }
@@ -36,10 +36,9 @@ export async function executeEvalControl(
36
36
  return createDetachedControlResult(snapshot);
37
37
  }
38
38
 
39
- function createEvalListResult(cellManager: EvalDetachedCellManager): AgentToolResult<EvalListDetails> {
39
+ export function listEvalCells(cellManager: EvalDetachedCellManager): EvalListedCell[] {
40
40
  const { live, recent } = cellManager.list();
41
- const snapshots = [...live, ...recent];
42
- const cells: EvalListedCell[] = snapshots.map((snapshot) => {
41
+ return [...live, ...recent].map((snapshot) => {
43
42
  const summary = snapshot.result.details.summary;
44
43
  return {
45
44
  cellId: snapshot.cellId,
@@ -50,6 +49,12 @@ function createEvalListResult(cellManager: EvalDetachedCellManager): AgentToolRe
50
49
  ...(summary ? { summary } : {}),
51
50
  };
52
51
  });
52
+ }
53
+
54
+ function createEvalListResult(cellManager: EvalDetachedCellManager): AgentToolResult<EvalListDetails> {
55
+ const { live, recent } = cellManager.list();
56
+ const snapshots = [...live, ...recent];
57
+ const cells = listEvalCells(cellManager);
53
58
  const text = snapshots
54
59
  .map((snapshot, index) => {
55
60
  const cell = cells[index];