@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.
|
|
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.
|
|
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.
|
|
41
|
+
"@code-yeongyu/senpi": "2026.10.12"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@code-yeongyu/senpi": "2026.10.
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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(
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
"
|
|
245
|
-
|
|
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?: {
|
|
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.
|
|
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
|
|
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
|
|
39
|
+
export function listEvalCells(cellManager: EvalDetachedCellManager): EvalListedCell[] {
|
|
40
40
|
const { live, recent } = cellManager.list();
|
|
41
|
-
|
|
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];
|