@code-yeongyu/senpi-codemode 2026.9.13 → 2026.9.15-2
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 +45 -0
- package/README.md +16 -4
- package/package.json +4 -4
- package/src/bridge/reserved.ts +2 -0
- package/src/kernels/js/context-manager.ts +62 -13
- package/src/kernels/js/process-tree-host.ts +158 -0
- package/src/kernels/js/process-tree.d.ts +24 -0
- package/src/kernels/js/process-tree.js +151 -0
- package/src/kernels/js/worker-core.js +5 -1
- package/src/kernels/js/worker-runtime.js +35 -7
- package/src/kernels/js/worker-shell-capture.d.ts +2 -1
- package/src/kernels/js/worker-shell-capture.js +1 -1
- package/src/kernels/py/prelude.py +35 -1
- package/src/kernels/py/process.ts +20 -0
- package/src/kernels/py/transport.ts +5 -1
- package/src/kernels/session-env.ts +6 -0
- package/src/kernels/shared/subprocess-queue.ts +7 -1
- package/src/tool/detached-cell-manager.ts +42 -72
- package/src/tool/image-resize.ts +52 -0
- package/src/tool/image.ts +37 -72
- package/src/tool/managed-cell.ts +78 -0
- package/src/tool/render.ts +30 -5
- package/src/tool/run-eval-cell.ts +31 -12
- package/src/tool/terminal-snapshot-store.ts +35 -0
- package/src/tool/tool-result-marshal.ts +23 -0
- package/src/tool/tool-widgets.ts +16 -1
- package/src/tool/types.ts +1 -0
package/src/tool/image.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { writeFile } from "node:fs/promises";
|
|
2
|
-
import type {
|
|
3
|
-
import { convertToPng, formatDimensionNote, resizeImage } from "@code-yeongyu/senpi";
|
|
2
|
+
import type { ExtensionContext } from "@code-yeongyu/senpi";
|
|
4
3
|
import type { KernelToHostMessage } from "../bridge/protocol.ts";
|
|
5
4
|
import type { TruncationMeta } from "../output/output-meta.ts";
|
|
6
5
|
import {
|
|
@@ -12,24 +11,24 @@ import {
|
|
|
12
11
|
TailBuffer,
|
|
13
12
|
truncateTail,
|
|
14
13
|
} from "../output/streaming-output.ts";
|
|
14
|
+
import { type EvalImageContent, type EvalImageResizer, resizeEvalImage } from "./image-resize.ts";
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
}
|
|
16
|
+
export {
|
|
17
|
+
type EvalImageContent,
|
|
18
|
+
type EvalImageResizeResult,
|
|
19
|
+
type EvalImageResizer,
|
|
20
|
+
resizeEvalImage,
|
|
21
|
+
webpExclusionForModel,
|
|
22
|
+
} from "./image-resize.ts";
|
|
23
|
+
export { marshalToolResult, toolResultIsError } from "./tool-result-marshal.ts";
|
|
23
24
|
|
|
24
|
-
|
|
25
|
-
readonly image: EvalImageContent;
|
|
26
|
-
readonly dimensionNote?: string;
|
|
27
|
-
}
|
|
25
|
+
const MAX_DISPLAY_TEXT_BYTES = 8_000;
|
|
28
26
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
27
|
+
// Per-cell display caps: display payloads are retained for the whole result lifetime, so an unbounded
|
|
28
|
+
// burst pins base64 originals plus resized copies on the session heap (#1695).
|
|
29
|
+
const MAX_DISPLAY_IMAGES_PER_CELL = 8;
|
|
30
|
+
const MAX_DISPLAY_IMAGE_BYTES_PER_CELL = 24 * 1024 * 1024;
|
|
31
|
+
const MAX_JSON_OUTPUTS_PER_CELL = 64;
|
|
33
32
|
|
|
34
33
|
export interface EvalOutputOptions {
|
|
35
34
|
readonly artifactPath?: string;
|
|
@@ -51,7 +50,6 @@ export interface EvalOutputResult {
|
|
|
51
50
|
}
|
|
52
51
|
|
|
53
52
|
type DisplayMessage = Extract<KernelToHostMessage, { type: "display" }>;
|
|
54
|
-
type WebpModel = { readonly provider: string; readonly api: string } | undefined;
|
|
55
53
|
|
|
56
54
|
class DisplayPayloadError extends Error {
|
|
57
55
|
readonly name = "DisplayPayloadError";
|
|
@@ -69,6 +67,9 @@ export class EvalOutputCollector {
|
|
|
69
67
|
readonly #displayImages: EvalImageContent[] = [];
|
|
70
68
|
readonly #images: EvalImageContent[] = [];
|
|
71
69
|
readonly #jsonOutputs: unknown[] = [];
|
|
70
|
+
#displayImageBytes = 0;
|
|
71
|
+
#displayImagesElided = 0;
|
|
72
|
+
#jsonOutputsElided = 0;
|
|
72
73
|
#hasMarkdown = false;
|
|
73
74
|
#imagesProcessed = false;
|
|
74
75
|
|
|
@@ -92,11 +93,23 @@ export class EvalOutputCollector {
|
|
|
92
93
|
|
|
93
94
|
display(message: DisplayMessage): void {
|
|
94
95
|
if (message.mimeType.startsWith("image/")) {
|
|
96
|
+
if (
|
|
97
|
+
this.#displayImages.length >= MAX_DISPLAY_IMAGES_PER_CELL ||
|
|
98
|
+
this.#displayImageBytes + message.dataBase64.length > MAX_DISPLAY_IMAGE_BYTES_PER_CELL
|
|
99
|
+
) {
|
|
100
|
+
this.#displayImagesElided++;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
95
103
|
this.#displayImages.push({ type: "image", mimeType: message.mimeType, data: message.dataBase64 });
|
|
104
|
+
this.#displayImageBytes += message.dataBase64.length;
|
|
96
105
|
return;
|
|
97
106
|
}
|
|
98
107
|
const text = Buffer.from(message.dataBase64, "base64").toString("utf8");
|
|
99
108
|
if (message.mimeType === "application/json") {
|
|
109
|
+
if (this.#jsonOutputs.length >= MAX_JSON_OUTPUTS_PER_CELL) {
|
|
110
|
+
this.#jsonOutputsElided++;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
100
113
|
let value: unknown;
|
|
101
114
|
try {
|
|
102
115
|
value = JSON.parse(text);
|
|
@@ -118,6 +131,12 @@ export class EvalOutputCollector {
|
|
|
118
131
|
|
|
119
132
|
async finish(): Promise<EvalOutputResult> {
|
|
120
133
|
await this.#processImages();
|
|
134
|
+
const elided = this.#displayImagesElided + this.#jsonOutputsElided;
|
|
135
|
+
if (elided > 0) {
|
|
136
|
+
this.#sink.push(
|
|
137
|
+
`[${this.#displayImagesElided} display image(s) and ${this.#jsonOutputsElided} JSON output(s) elided beyond per-cell caps]\n`,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
121
140
|
const summary = await this.#finalSummary();
|
|
122
141
|
const meta = truncationMetaFromSummary(summary, this.#options.maxColumns);
|
|
123
142
|
const notice = summary.artifactId === undefined ? undefined : artifactNotice(summary.artifactId);
|
|
@@ -171,38 +190,6 @@ export class EvalOutputCollector {
|
|
|
171
190
|
}
|
|
172
191
|
}
|
|
173
192
|
|
|
174
|
-
export function webpExclusionForModel(model: WebpModel): true | undefined {
|
|
175
|
-
if (model === undefined) return undefined;
|
|
176
|
-
return model.provider === "ollama" ||
|
|
177
|
-
model.provider === "ollama-cloud" ||
|
|
178
|
-
model.provider === "llama.cpp" ||
|
|
179
|
-
model.provider === "lm-studio" ||
|
|
180
|
-
model.provider === "local-server" ||
|
|
181
|
-
model.api === "ollama-chat"
|
|
182
|
-
? true
|
|
183
|
-
: undefined;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export const resizeEvalImage: EvalImageResizer = async (image, model) => {
|
|
187
|
-
const excludeWebP = webpExclusionForModel(model);
|
|
188
|
-
const forceWebpConversion = excludeWebP === true && image.mimeType === "image/webp";
|
|
189
|
-
const resized = await resizeImage(
|
|
190
|
-
Buffer.from(image.data, "base64"),
|
|
191
|
-
image.mimeType,
|
|
192
|
-
forceWebpConversion ? { maxBytes: Buffer.byteLength(image.data, "utf8") } : undefined,
|
|
193
|
-
);
|
|
194
|
-
let output: EvalImageContent =
|
|
195
|
-
resized === null ? image : { type: "image", data: resized.data, mimeType: resized.mimeType };
|
|
196
|
-
if (excludeWebP === true && output.mimeType === "image/webp") {
|
|
197
|
-
const converted = await convertToPng(output.data, output.mimeType);
|
|
198
|
-
if (converted === null)
|
|
199
|
-
throw new TypeError(`Unable to convert ${output.mimeType} display output for the active model`);
|
|
200
|
-
output = { type: "image", data: converted.data, mimeType: converted.mimeType };
|
|
201
|
-
}
|
|
202
|
-
const dimensionNote = resized === null ? undefined : formatDimensionNote(resized);
|
|
203
|
-
return { image: output, ...(dimensionNote === undefined ? {} : { dimensionNote }) };
|
|
204
|
-
};
|
|
205
|
-
|
|
206
193
|
function formatDisplayJson(value: unknown): string {
|
|
207
194
|
let text: string;
|
|
208
195
|
try {
|
|
@@ -259,25 +246,3 @@ function truncationMetaFromSummary(summary: OutputSummary, maxColumns: number):
|
|
|
259
246
|
...artifact,
|
|
260
247
|
};
|
|
261
248
|
}
|
|
262
|
-
|
|
263
|
-
export function marshalToolResult(result: AgentToolResult<unknown>) {
|
|
264
|
-
const texts = result.content.filter((part) => part.type === "text").map((part) => part.text);
|
|
265
|
-
const images = result.content
|
|
266
|
-
.filter((part) => part.type === "image")
|
|
267
|
-
.map((part) => ({ mimeType: part.mimeType, dataBase64: part.data }));
|
|
268
|
-
const details =
|
|
269
|
-
typeof result.details === "object" &&
|
|
270
|
-
result.details !== null &&
|
|
271
|
-
!Array.isArray(result.details) &&
|
|
272
|
-
Object.keys(result.details).length === 0
|
|
273
|
-
? undefined
|
|
274
|
-
: result.details;
|
|
275
|
-
const hasError = toolResultIsError(result);
|
|
276
|
-
const text = texts.join("\n");
|
|
277
|
-
return images.length === 0 && details === undefined && !hasError ? { text } : { text, details, images, hasError };
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
export function toolResultIsError(result: AgentToolResult<unknown>): boolean {
|
|
281
|
-
const details = result.details;
|
|
282
|
-
return typeof details === "object" && details !== null && "isError" in details && details.isError === true;
|
|
283
|
-
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
import { type CellDeadlineExpiry, CellDeadlines } from "./cell-deadlines.ts";
|
|
3
|
+
import type { EvalDetachedCellSnapshot, EvalDetachedCellState } from "./detached-cell-contract.ts";
|
|
4
|
+
import { detachedNotificationSpillPath } from "./detached-cell-notification.ts";
|
|
5
|
+
import type { EvalKernel, EvalToolDetails, EvalToolInput } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export type LiveResultProvider = () => AgentToolResult<EvalToolDetails>;
|
|
8
|
+
|
|
9
|
+
export type ManagedCell = {
|
|
10
|
+
readonly cellId: string;
|
|
11
|
+
readonly input: EvalToolInput;
|
|
12
|
+
readonly spillPath: string | undefined;
|
|
13
|
+
readonly startedAtMs: number;
|
|
14
|
+
readonly terminal: PromiseWithResolvers<EvalDetachedCellSnapshot>;
|
|
15
|
+
state: EvalDetachedCellState;
|
|
16
|
+
canDetach: boolean;
|
|
17
|
+
wasDetached: boolean;
|
|
18
|
+
kernel: EvalKernel | undefined;
|
|
19
|
+
stateRetained: boolean | undefined;
|
|
20
|
+
interruptNote: string | undefined;
|
|
21
|
+
/** Holds the completion notification until the interrupt has reported whether kernel state survived. */
|
|
22
|
+
interruptOutcome: PromiseWithResolvers<void> | undefined;
|
|
23
|
+
liveResult: LiveResultProvider | undefined;
|
|
24
|
+
terminalResult: AgentToolResult<EvalToolDetails> | undefined;
|
|
25
|
+
notificationQueued: boolean;
|
|
26
|
+
readonly deadlines: CellDeadlines;
|
|
27
|
+
readonly hardLimitSeconds: number;
|
|
28
|
+
readonly runBudgetSeconds: number;
|
|
29
|
+
hardLimited: boolean;
|
|
30
|
+
runBudgetExhausted: boolean;
|
|
31
|
+
/** Foreground killer: the still-awaited CellExecution owns interrupting and rejecting its own call; bound from creation so a deadline firing during kernel boot still ends it. */
|
|
32
|
+
onKill: ((error: Error) => void) | undefined;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export interface ManagedCellInit {
|
|
36
|
+
readonly cellId: string;
|
|
37
|
+
readonly input: EvalToolInput;
|
|
38
|
+
readonly artifactsDir: string | undefined;
|
|
39
|
+
readonly now: () => number;
|
|
40
|
+
readonly defaultHardLimitSeconds: number;
|
|
41
|
+
readonly defaultRunBudgetSeconds: number;
|
|
42
|
+
readonly onKill: ((error: Error) => void) | undefined;
|
|
43
|
+
readonly onExpire: (cellId: string, expiry: CellDeadlineExpiry) => void;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function createManagedCell(init: ManagedCellInit): ManagedCell {
|
|
47
|
+
// An explicit longer per-call timeout raises the deadline, mirroring bash keeping explicit timeouts.
|
|
48
|
+
const hardLimitSeconds = Math.max(init.defaultHardLimitSeconds, init.input.timeout ?? 0);
|
|
49
|
+
const runBudgetSeconds = init.input.timeout ?? init.defaultRunBudgetSeconds;
|
|
50
|
+
return {
|
|
51
|
+
cellId: init.cellId,
|
|
52
|
+
input: init.input,
|
|
53
|
+
spillPath: detachedNotificationSpillPath(init.artifactsDir, init.cellId),
|
|
54
|
+
startedAtMs: init.now(),
|
|
55
|
+
state: "running",
|
|
56
|
+
canDetach: false,
|
|
57
|
+
wasDetached: false,
|
|
58
|
+
kernel: undefined,
|
|
59
|
+
stateRetained: undefined,
|
|
60
|
+
interruptNote: undefined,
|
|
61
|
+
interruptOutcome: undefined,
|
|
62
|
+
liveResult: undefined,
|
|
63
|
+
terminalResult: undefined,
|
|
64
|
+
notificationQueued: false,
|
|
65
|
+
deadlines: new CellDeadlines({
|
|
66
|
+
cellId: init.cellId,
|
|
67
|
+
hardLimitSeconds,
|
|
68
|
+
runBudgetSeconds,
|
|
69
|
+
onExpire: (expiry) => init.onExpire(init.cellId, expiry),
|
|
70
|
+
}),
|
|
71
|
+
hardLimitSeconds,
|
|
72
|
+
runBudgetSeconds,
|
|
73
|
+
hardLimited: false,
|
|
74
|
+
runBudgetExhausted: false,
|
|
75
|
+
onKill: init.onKill,
|
|
76
|
+
terminal: Promise.withResolvers<EvalDetachedCellSnapshot>(),
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/tool/render.ts
CHANGED
|
@@ -71,10 +71,15 @@ const TOOL_CALL_COLLAPSED_VISUAL_LINES = 4;
|
|
|
71
71
|
const TOOL_CALL_COLLAPSED_ERROR_CODE_POINTS = 512;
|
|
72
72
|
const TOOL_ERROR_OMISSION_MARKER = "[tool error omitted]";
|
|
73
73
|
const LIVE_ELAPSED_TICK_MS = 1_000;
|
|
74
|
+
// A live row repaints on every tick, so this many ticks without a render means the row is gone.
|
|
75
|
+
const LIVE_TICKER_MAX_IDLE_TICKS = 60;
|
|
74
76
|
|
|
75
77
|
class PlainTextComponent implements EvalRenderComponent {
|
|
76
78
|
#blocks: readonly RenderBlock[] = [];
|
|
77
79
|
#ticker: ReturnType<typeof setInterval> | undefined;
|
|
80
|
+
#live = false;
|
|
81
|
+
#invalidate: (() => void) | undefined;
|
|
82
|
+
#idleTicks = 0;
|
|
78
83
|
|
|
79
84
|
setBlocks(blocks: readonly RenderBlock[]): void {
|
|
80
85
|
this.#blocks = blocks;
|
|
@@ -84,16 +89,19 @@ class PlainTextComponent implements EvalRenderComponent {
|
|
|
84
89
|
* The host only animates tool rows for streaming args, `task`, and results carrying
|
|
85
90
|
* `details.progress`; an eval row matches none of them, so nothing repaints it between
|
|
86
91
|
* update events. While a cell is non-terminal this drives the repaint itself so the
|
|
87
|
-
* header's elapsed time advances, and it emits no tool updates or RPC traffic.
|
|
92
|
+
* header's elapsed time advances, and it emits no tool updates or RPC traffic. Detached
|
|
93
|
+
* and terminal cards never arm it, and a ticker whose row stopped rendering (transcript
|
|
94
|
+
* rebuild, session switch) stops itself after LIVE_TICKER_MAX_IDLE_TICKS and rearms on
|
|
95
|
+
* the next render, so dropped rows cannot accumulate intervals.
|
|
88
96
|
*/
|
|
89
97
|
syncLiveTicker(isLive: boolean, invalidate: () => void): void {
|
|
98
|
+
this.#live = isLive;
|
|
99
|
+
this.#invalidate = invalidate;
|
|
90
100
|
if (!isLive) {
|
|
91
101
|
this.stopLiveTicker();
|
|
92
102
|
return;
|
|
93
103
|
}
|
|
94
|
-
|
|
95
|
-
this.#ticker = setInterval(invalidate, LIVE_ELAPSED_TICK_MS);
|
|
96
|
-
this.#ticker.unref?.();
|
|
104
|
+
this.#armTicker();
|
|
97
105
|
}
|
|
98
106
|
|
|
99
107
|
stopLiveTicker(): void {
|
|
@@ -102,7 +110,24 @@ class PlainTextComponent implements EvalRenderComponent {
|
|
|
102
110
|
this.#ticker = undefined;
|
|
103
111
|
}
|
|
104
112
|
|
|
113
|
+
#armTicker(): void {
|
|
114
|
+
if (this.#ticker !== undefined || this.#invalidate === undefined) return;
|
|
115
|
+
this.#ticker = setInterval(() => this.#tick(), LIVE_ELAPSED_TICK_MS);
|
|
116
|
+
this.#ticker.unref?.();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#tick(): void {
|
|
120
|
+
this.#idleTicks += 1;
|
|
121
|
+
if (this.#idleTicks >= LIVE_TICKER_MAX_IDLE_TICKS) {
|
|
122
|
+
this.stopLiveTicker();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
this.#invalidate?.();
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
render(width: number): string[] {
|
|
129
|
+
this.#idleTicks = 0;
|
|
130
|
+
if (this.#live) this.#armTicker();
|
|
106
131
|
const lines: string[] = [];
|
|
107
132
|
for (const block of this.#blocks) {
|
|
108
133
|
switch (block.kind) {
|
|
@@ -269,7 +294,7 @@ function spinner(frame: number | undefined): string {
|
|
|
269
294
|
}
|
|
270
295
|
|
|
271
296
|
function isLiveCellStatus(status: CellStatus): boolean {
|
|
272
|
-
return status === "pending" || status === "running"
|
|
297
|
+
return status === "pending" || status === "running";
|
|
273
298
|
}
|
|
274
299
|
|
|
275
300
|
function cellPresentation(status: CellStatus, spinnerFrame: number | undefined): StatusPresentation {
|
|
@@ -49,6 +49,12 @@ export async function runEvalCell(
|
|
|
49
49
|
let detached = false;
|
|
50
50
|
let execution: CellExecution;
|
|
51
51
|
const cell = cellManager.create(invocation.cellId, invocation.input, (error) => execution.cancel(error));
|
|
52
|
+
const detach = (): boolean => {
|
|
53
|
+
if (!cellManager.detach(cell)) return false;
|
|
54
|
+
detached = true;
|
|
55
|
+
execution.detach();
|
|
56
|
+
return true;
|
|
57
|
+
};
|
|
52
58
|
execution = new CellExecution({
|
|
53
59
|
callerSignal: invocation.signal,
|
|
54
60
|
cellId: invocation.cellId,
|
|
@@ -58,12 +64,7 @@ export async function runEvalCell(
|
|
|
58
64
|
timeoutMs: detachAfterMs,
|
|
59
65
|
maxPauseGraceMs: foregroundWindowMs,
|
|
60
66
|
onTimeout: (error: Error) => {
|
|
61
|
-
if (
|
|
62
|
-
detached = true;
|
|
63
|
-
execution.detach();
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
execution.cancel(error);
|
|
67
|
+
if (!detach()) execution.cancel(error);
|
|
67
68
|
},
|
|
68
69
|
},
|
|
69
70
|
}
|
|
@@ -74,6 +75,16 @@ export async function runEvalCell(
|
|
|
74
75
|
bridgeAbortController.abort(error);
|
|
75
76
|
},
|
|
76
77
|
});
|
|
78
|
+
const steeringSignal =
|
|
79
|
+
detaches && invocation.ctx.mode !== "print" && invocation.ctx.mode !== "json"
|
|
80
|
+
? invocation.ctx.steeringSignal
|
|
81
|
+
: undefined;
|
|
82
|
+
const onSteering = (): void => {
|
|
83
|
+
if (!steeringSignal?.aborted || detached || !state.active || invocation.signal.aborted) return;
|
|
84
|
+
// Losing the slot keeps this call foreground; only the existing deadlines/caller may cancel it.
|
|
85
|
+
detach();
|
|
86
|
+
};
|
|
87
|
+
steeringSignal?.addEventListener("abort", onSteering, { once: true });
|
|
77
88
|
const running = executeCell(
|
|
78
89
|
options,
|
|
79
90
|
invocation,
|
|
@@ -83,6 +94,7 @@ export async function runEvalCell(
|
|
|
83
94
|
execution,
|
|
84
95
|
bridgeContext,
|
|
85
96
|
bridgeAbortController,
|
|
97
|
+
onSteering,
|
|
86
98
|
);
|
|
87
99
|
let settleEventEmitted = false;
|
|
88
100
|
const emitSettled = (outcome: EvalExecutionSettleOutcome): void => {
|
|
@@ -110,12 +122,16 @@ export async function runEvalCell(
|
|
|
110
122
|
throw error;
|
|
111
123
|
},
|
|
112
124
|
);
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
125
|
+
try {
|
|
126
|
+
const outcome = await Promise.race([
|
|
127
|
+
finalized.then((result) => ({ kind: "result" as const, result })),
|
|
128
|
+
execution.detached.then(() => ({ kind: "detached" as const })),
|
|
129
|
+
]);
|
|
130
|
+
if (outcome.kind === "detached") return resultAfterDetach(cellManager.peek(invocation.cellId), invocation.input);
|
|
131
|
+
return outcome.result;
|
|
132
|
+
} finally {
|
|
133
|
+
steeringSignal?.removeEventListener("abort", onSteering);
|
|
134
|
+
}
|
|
119
135
|
}
|
|
120
136
|
|
|
121
137
|
async function executeCell(
|
|
@@ -127,6 +143,7 @@ async function executeCell(
|
|
|
127
143
|
execution: CellExecution,
|
|
128
144
|
bridgeContext: ExtensionContext,
|
|
129
145
|
bridgeAbortController: AbortController,
|
|
146
|
+
onReady: () => void,
|
|
130
147
|
): Promise<AgentToolResult<EvalToolDetails>> {
|
|
131
148
|
let handler: CellHandler | undefined;
|
|
132
149
|
try {
|
|
@@ -168,6 +185,8 @@ async function executeCell(
|
|
|
168
185
|
() => activeHandler.liveResult(),
|
|
169
186
|
(error) => execution.cancel(error),
|
|
170
187
|
);
|
|
188
|
+
// Includes a steer already queued at execute start or received while acquiring the kernel.
|
|
189
|
+
onReady();
|
|
171
190
|
if ("setContext" in options.kernelManager && typeof options.kernelManager.setContext === "function") {
|
|
172
191
|
options.kernelManager.setContext(bridgeContext);
|
|
173
192
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { EvalDetachedCellSnapshot } from "./detached-cell-contract.ts";
|
|
2
|
+
|
|
3
|
+
export const TERMINAL_SNAPSHOT_CAP = 32;
|
|
4
|
+
|
|
5
|
+
/** Bounded LRU of settled-cell snapshots; without it every settled cell pins its result and closures for the session lifetime (#1695). */
|
|
6
|
+
export class TerminalSnapshotStore {
|
|
7
|
+
readonly #snapshots = new Map<string, EvalDetachedCellSnapshot>();
|
|
8
|
+
readonly #cap: number;
|
|
9
|
+
|
|
10
|
+
constructor(cap = TERMINAL_SNAPSHOT_CAP) {
|
|
11
|
+
this.#cap = cap;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
remember(snapshot: EvalDetachedCellSnapshot): void {
|
|
15
|
+
this.#snapshots.delete(snapshot.cellId);
|
|
16
|
+
this.#snapshots.set(snapshot.cellId, snapshot);
|
|
17
|
+
while (this.#snapshots.size > this.#cap) {
|
|
18
|
+
const oldest = this.#snapshots.keys().next();
|
|
19
|
+
if (oldest.done === true) break;
|
|
20
|
+
this.#snapshots.delete(oldest.value);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
get(cellId: string): EvalDetachedCellSnapshot | undefined {
|
|
25
|
+
return this.#snapshots.get(cellId);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
delete(cellId: string): void {
|
|
29
|
+
this.#snapshots.delete(cellId);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
clear(): void {
|
|
33
|
+
this.#snapshots.clear();
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AgentToolResult } from "@code-yeongyu/senpi";
|
|
2
|
+
|
|
3
|
+
export function marshalToolResult(result: AgentToolResult<unknown>) {
|
|
4
|
+
const texts = result.content.filter((part) => part.type === "text").map((part) => part.text);
|
|
5
|
+
const images = result.content
|
|
6
|
+
.filter((part) => part.type === "image")
|
|
7
|
+
.map((part) => ({ mimeType: part.mimeType, dataBase64: part.data }));
|
|
8
|
+
const details =
|
|
9
|
+
typeof result.details === "object" &&
|
|
10
|
+
result.details !== null &&
|
|
11
|
+
!Array.isArray(result.details) &&
|
|
12
|
+
Object.keys(result.details).length === 0
|
|
13
|
+
? undefined
|
|
14
|
+
: result.details;
|
|
15
|
+
const hasError = toolResultIsError(result);
|
|
16
|
+
const text = texts.join("\n");
|
|
17
|
+
return images.length === 0 && details === undefined && !hasError ? { text } : { text, details, images, hasError };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function toolResultIsError(result: AgentToolResult<unknown>): boolean {
|
|
21
|
+
const details = result.details;
|
|
22
|
+
return typeof details === "object" && details !== null && "isError" in details && details.isError === true;
|
|
23
|
+
}
|
package/src/tool/tool-widgets.ts
CHANGED
|
@@ -139,6 +139,15 @@ function renderWith<TParams extends TSchema, TDetails, TState>(
|
|
|
139
139
|
}
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
function grepDetailsSummary(summary: EvalToolCallSummary): string | undefined {
|
|
143
|
+
const details = summary.details;
|
|
144
|
+
if (details === undefined || details === null || typeof details !== "object") return undefined;
|
|
145
|
+
const record = details as { matchCount?: unknown; fileCount?: unknown; engine?: unknown };
|
|
146
|
+
if (typeof record.fileCount !== "number" || typeof record.engine !== "string") return undefined;
|
|
147
|
+
if (record.matchCount !== null && typeof record.matchCount !== "number") return undefined;
|
|
148
|
+
return `matches=${record.matchCount ?? "n/a"} files=${record.fileCount} (${record.engine})`;
|
|
149
|
+
}
|
|
150
|
+
|
|
142
151
|
function createCoreWidgets(cwd: string): CoreWidgets {
|
|
143
152
|
const bashDefinition = createBashToolDefinition(cwd);
|
|
144
153
|
const readDefinition = createReadToolDefinition(cwd);
|
|
@@ -155,7 +164,13 @@ function createCoreWidgets(cwd: string): CoreWidgets {
|
|
|
155
164
|
}),
|
|
156
165
|
read: (summary, options) => renderWith(readDefinition, summary, options, {}),
|
|
157
166
|
write: (summary, options) => renderWith(writeDefinition, summary, options, {}),
|
|
158
|
-
grep: (summary, options) =>
|
|
167
|
+
grep: (summary, options) => {
|
|
168
|
+
const lines = renderWith(grepDefinition, summary, options, {});
|
|
169
|
+
if (lines === undefined) return undefined;
|
|
170
|
+
const extra = grepDetailsSummary(summary);
|
|
171
|
+
if (extra !== undefined) lines.push(` ${style(options.theme, "muted", extra)}`);
|
|
172
|
+
return lines;
|
|
173
|
+
},
|
|
159
174
|
find: (summary, options) => renderWith(findDefinition, summary, options, {}),
|
|
160
175
|
ls: (summary, options) => renderWith(lsDefinition, summary, options, {}),
|
|
161
176
|
};
|
package/src/tool/types.ts
CHANGED
|
@@ -153,6 +153,7 @@ export interface EvalToolCallSummary {
|
|
|
153
153
|
readonly argsTruncated?: boolean;
|
|
154
154
|
readonly durationMs?: number;
|
|
155
155
|
readonly resultPreview?: string;
|
|
156
|
+
readonly details?: unknown;
|
|
156
157
|
}
|
|
157
158
|
|
|
158
159
|
export type EvalStatusEvent = { readonly op: string } & Readonly<Record<string, unknown>>;
|