@code-yeongyu/senpi-codemode 2026.7.25-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 +250 -0
- package/LICENSE +22 -0
- package/README.md +161 -0
- package/package.json +58 -0
- package/src/bridge/http-server.ts +236 -0
- package/src/bridge/protocol.ts +198 -0
- package/src/bridge/reserved.ts +9 -0
- package/src/bridges/agent-bridge.ts +197 -0
- package/src/bridges/output-bridge.ts +96 -0
- package/src/bridges/schema-injection.ts +3 -0
- package/src/codemode/runtime.ts +258 -0
- package/src/codemode/tools.ts +106 -0
- package/src/completion/handler.ts +192 -0
- package/src/completion/tool-bridge.ts +55 -0
- package/src/config/settings.ts +215 -0
- package/src/extension/runtime-factory.ts +114 -0
- package/src/extension/session-manager-proxy.ts +116 -0
- package/src/extension/session-manager.ts +215 -0
- package/src/host-sdk.ts +1 -0
- package/src/index.ts +181 -0
- package/src/interpreters/detect.ts +161 -0
- package/src/kernels/jl/kernel.ts +37 -0
- package/src/kernels/jl/prelude.jl +283 -0
- package/src/kernels/jl/runner.jl +327 -0
- package/src/kernels/js/context-manager.ts +296 -0
- package/src/kernels/js/inline-worker-entry.js +23 -0
- package/src/kernels/js/inline-worker.ts +15 -0
- package/src/kernels/js/kernel-contract.ts +38 -0
- package/src/kernels/js/local-module-loader.ts +108 -0
- package/src/kernels/js/prelude.ts +15 -0
- package/src/kernels/js/rewrite-imports.ts +164 -0
- package/src/kernels/js/run-queue.ts +82 -0
- package/src/kernels/js/worker-core.d.ts +18 -0
- package/src/kernels/js/worker-core.js +94 -0
- package/src/kernels/js/worker-entry.js +23 -0
- package/src/kernels/js/worker-host.ts +117 -0
- package/src/kernels/js/worker-indirect-eval.js +88 -0
- package/src/kernels/js/worker-runtime.js +401 -0
- package/src/kernels/py/kernel-contract.ts +32 -0
- package/src/kernels/py/kernel.ts +290 -0
- package/src/kernels/py/prelude.py +954 -0
- package/src/kernels/py/process.ts +119 -0
- package/src/kernels/py/transport.ts +237 -0
- package/src/kernels/rb/kernel.ts +26 -0
- package/src/kernels/rb/prelude.rb +270 -0
- package/src/kernels/rb/runner.rb +204 -0
- package/src/kernels/shared/subprocess-contract.ts +22 -0
- package/src/kernels/shared/subprocess-kernel.ts +266 -0
- package/src/kernels/shared/subprocess-process.ts +174 -0
- package/src/kernels/shared/subprocess-queue.ts +101 -0
- package/src/kernels/shared/subprocess-run.ts +98 -0
- package/src/output/output-meta.ts +89 -0
- package/src/output/streaming-output.ts +296 -0
- package/src/prompt/eval-prompt.ts +319 -0
- package/src/timeouts/bridge-timeout.ts +16 -0
- package/src/timeouts/idle-timeout.ts +84 -0
- package/src/tool/cell-handler.ts +279 -0
- package/src/tool/eval-tool.ts +285 -0
- package/src/tool/image.ts +274 -0
- package/src/tool/json-tree.ts +247 -0
- package/src/tool/render.ts +876 -0
- package/src/tool/status-events.ts +12 -0
- package/src/tool/types.ts +114 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "uri"
|
|
4
|
+
|
|
5
|
+
$__senpi_binding = TOPLEVEL_BINDING
|
|
6
|
+
$__senpi_frame_mutex = Mutex.new
|
|
7
|
+
$__senpi_current_cell = nil
|
|
8
|
+
$__senpi_capture_cell = nil
|
|
9
|
+
$__senpi_connection = nil
|
|
10
|
+
$__senpi_frame_io = STDOUT.dup
|
|
11
|
+
$__senpi_frame_io.sync = true
|
|
12
|
+
|
|
13
|
+
begin
|
|
14
|
+
stdout_read, stdout_write = IO.pipe
|
|
15
|
+
STDOUT.reopen(stdout_write)
|
|
16
|
+
STDOUT.sync = true
|
|
17
|
+
stdout_write.close
|
|
18
|
+
$__senpi_stdout_capture = stdout_read
|
|
19
|
+
rescue StandardError
|
|
20
|
+
$__senpi_stdout_capture = nil
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
stderr_read, stderr_write = IO.pipe
|
|
25
|
+
STDERR.reopen(stderr_write)
|
|
26
|
+
STDERR.sync = true
|
|
27
|
+
stderr_write.close
|
|
28
|
+
$__senpi_stderr_capture = stderr_read
|
|
29
|
+
rescue StandardError
|
|
30
|
+
$__senpi_stderr_capture = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
begin
|
|
34
|
+
$__senpi_protocol_stdin = STDIN.dup
|
|
35
|
+
STDIN.reopen(File.open(File::NULL, "r"))
|
|
36
|
+
rescue StandardError
|
|
37
|
+
$__senpi_protocol_stdin = STDIN
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def __senpi_emit(frame)
|
|
41
|
+
line = JSON.generate(frame)
|
|
42
|
+
$__senpi_frame_mutex.synchronize do
|
|
43
|
+
$__senpi_frame_io.write(line)
|
|
44
|
+
$__senpi_frame_io.write("\n")
|
|
45
|
+
$__senpi_frame_io.flush
|
|
46
|
+
end
|
|
47
|
+
rescue StandardError
|
|
48
|
+
nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def __senpi_error(error)
|
|
52
|
+
{ "name" => error.class.name, "message" => error.message.to_s, "stack" => error.backtrace&.join("\n") }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def __senpi_emit_stream(stream, data)
|
|
56
|
+
return if data.nil? || data.empty? || $__senpi_capture_cell.nil?
|
|
57
|
+
__senpi_emit({ "type" => "text", "stream" => stream, "data" => data })
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
class SenpiStreamProxy
|
|
61
|
+
def initialize(stream)
|
|
62
|
+
@stream = stream
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def write(*values)
|
|
66
|
+
data = values.join
|
|
67
|
+
__senpi_emit_stream(@stream, data)
|
|
68
|
+
data.bytesize
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def print(*values)
|
|
72
|
+
write(*values)
|
|
73
|
+
nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def puts(*values)
|
|
77
|
+
values = [""] if values.empty?
|
|
78
|
+
values.each { |value| write(value.to_s.end_with?("\n") ? value.to_s : "#{value}\n") }
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def printf(format, *values)
|
|
83
|
+
write(Kernel.format(format, *values))
|
|
84
|
+
nil
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def flush
|
|
88
|
+
self
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def sync
|
|
92
|
+
true
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def sync=(_value)
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def tty?
|
|
100
|
+
false
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def __senpi_start_capture(io, stream)
|
|
105
|
+
return if io.nil?
|
|
106
|
+
Thread.new do
|
|
107
|
+
loop do
|
|
108
|
+
data = io.readpartial(65_536)
|
|
109
|
+
__senpi_emit_stream(stream, data)
|
|
110
|
+
rescue EOFError, IOError, Errno::EBADF
|
|
111
|
+
break
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def __senpi_value_repr(value)
|
|
117
|
+
JSON.generate(value)
|
|
118
|
+
rescue JSON::GeneratorError
|
|
119
|
+
value.inspect
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
SENPI_NON_DISPLAY_NODES = %i[
|
|
123
|
+
LASGN IASGN GASGN CVASGN DASGN OP_ASGN OP_CDECL CDECL MASGN CASGN
|
|
124
|
+
DEFN DEFS CLASS MODULE SCLASS ALIAS UNDEF
|
|
125
|
+
].freeze
|
|
126
|
+
|
|
127
|
+
def __senpi_ast_last(node)
|
|
128
|
+
return nil unless node.is_a?(RubyVM::AbstractSyntaxTree::Node)
|
|
129
|
+
case node.type
|
|
130
|
+
when :SCOPE
|
|
131
|
+
__senpi_ast_last(node.children[2])
|
|
132
|
+
when :BLOCK
|
|
133
|
+
children = node.children.compact
|
|
134
|
+
children.empty? ? nil : __senpi_ast_last(children.last)
|
|
135
|
+
else
|
|
136
|
+
node
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def __senpi_should_display_result?(source)
|
|
141
|
+
return true unless defined?(RubyVM::AbstractSyntaxTree)
|
|
142
|
+
node = RubyVM::AbstractSyntaxTree.parse(source)
|
|
143
|
+
last = __senpi_ast_last(node)
|
|
144
|
+
return true if last.nil?
|
|
145
|
+
!SENPI_NON_DISPLAY_NODES.include?(last.type)
|
|
146
|
+
rescue StandardError, SyntaxError
|
|
147
|
+
true
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
require_relative "prelude"
|
|
151
|
+
|
|
152
|
+
$stdout = SenpiStreamProxy.new("stdout")
|
|
153
|
+
$stderr = SenpiStreamProxy.new("stderr")
|
|
154
|
+
__senpi_start_capture($__senpi_stdout_capture, "stdout")
|
|
155
|
+
__senpi_start_capture($__senpi_stderr_capture, "stderr")
|
|
156
|
+
|
|
157
|
+
def __senpi_run_cell(message)
|
|
158
|
+
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
159
|
+
cell_id = message["cellId"].to_s
|
|
160
|
+
$__senpi_current_cell = cell_id
|
|
161
|
+
$__senpi_capture_cell = cell_id
|
|
162
|
+
begin
|
|
163
|
+
source = message["code"].to_s
|
|
164
|
+
value = eval(source, $__senpi_binding, "(senpi-rb)")
|
|
165
|
+
STDOUT.flush
|
|
166
|
+
STDERR.flush
|
|
167
|
+
Thread.pass
|
|
168
|
+
frame = {
|
|
169
|
+
"type" => "result",
|
|
170
|
+
"cellId" => cell_id,
|
|
171
|
+
"ok" => true,
|
|
172
|
+
"durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
173
|
+
}
|
|
174
|
+
frame["valueRepr"] = __senpi_value_repr(value) if !value.nil? && __senpi_should_display_result?(source)
|
|
175
|
+
__senpi_emit(frame)
|
|
176
|
+
rescue Exception => error
|
|
177
|
+
__senpi_emit({
|
|
178
|
+
"type" => "result",
|
|
179
|
+
"cellId" => cell_id,
|
|
180
|
+
"ok" => false,
|
|
181
|
+
"error" => __senpi_error(error),
|
|
182
|
+
"durationMs" => ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round,
|
|
183
|
+
})
|
|
184
|
+
ensure
|
|
185
|
+
$__senpi_capture_cell = nil
|
|
186
|
+
$__senpi_current_cell = nil
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
$__senpi_protocol_stdin.each_line do |line|
|
|
191
|
+
message = JSON.parse(line)
|
|
192
|
+
case message["type"]
|
|
193
|
+
when "init"
|
|
194
|
+
$__senpi_connection = message["connection"]
|
|
195
|
+
__senpi_emit({ "type" => "ready" })
|
|
196
|
+
when "run"
|
|
197
|
+
__senpi_run_cell(message)
|
|
198
|
+
when "close"
|
|
199
|
+
__senpi_emit({ "type" => "closed" })
|
|
200
|
+
exit 0
|
|
201
|
+
end
|
|
202
|
+
rescue JSON::ParserError => error
|
|
203
|
+
__senpi_emit({ "type" => "init-failed", "error" => __senpi_error(error) })
|
|
204
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import type { SubprocessSpawn } from "./subprocess-process.ts";
|
|
3
|
+
|
|
4
|
+
export interface KernelRunInput {
|
|
5
|
+
readonly cellId: string;
|
|
6
|
+
readonly code: string;
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type KernelResult = Extract<KernelToHostMessage, { type: "result" }>;
|
|
11
|
+
export type ToolCallMessage = Extract<KernelToHostMessage, { type: "tool-call" }>;
|
|
12
|
+
|
|
13
|
+
export interface SubprocessKernelOptions {
|
|
14
|
+
readonly command: string;
|
|
15
|
+
readonly args: readonly string[];
|
|
16
|
+
readonly cwd?: string;
|
|
17
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
readonly connection: BridgeConnectionConfig;
|
|
20
|
+
readonly spawn?: SubprocessSpawn;
|
|
21
|
+
readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
22
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { HostToKernelMessage, KernelToHostMessage } from "../../bridge/protocol.ts";
|
|
2
|
+
import { decodeBridgeFrame, encodeBridgeFrame, isKernelToHostMessage } from "../../bridge/protocol.ts";
|
|
3
|
+
import type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
4
|
+
import { type SubprocessLike, SubprocessProcess, type SubprocessSpawn, spawnSubprocess } from "./subprocess-process.ts";
|
|
5
|
+
import { SubprocessRunQueue } from "./subprocess-queue.ts";
|
|
6
|
+
import {
|
|
7
|
+
CellInterruptedError,
|
|
8
|
+
failureResult,
|
|
9
|
+
KernelClosedError,
|
|
10
|
+
KernelClosingError,
|
|
11
|
+
KernelExitedError,
|
|
12
|
+
KernelProcessError,
|
|
13
|
+
KernelResetError,
|
|
14
|
+
KernelRetirementError,
|
|
15
|
+
KernelStartupError,
|
|
16
|
+
type PendingRun,
|
|
17
|
+
timeoutResult,
|
|
18
|
+
} from "./subprocess-run.ts";
|
|
19
|
+
|
|
20
|
+
export type { KernelResult, KernelRunInput, SubprocessKernelOptions, ToolCallMessage } from "./subprocess-contract.ts";
|
|
21
|
+
export type { SubprocessLike, SubprocessSpawn };
|
|
22
|
+
|
|
23
|
+
export class SubprocessKernel {
|
|
24
|
+
private readonly options: SubprocessKernelOptions;
|
|
25
|
+
private readonly onMessage?: (message: KernelToHostMessage) => void;
|
|
26
|
+
private readonly runs = new SubprocessRunQueue();
|
|
27
|
+
private process: SubprocessProcess | null = null;
|
|
28
|
+
private retirementPromise: Promise<void> | null = null;
|
|
29
|
+
private retirementProcess: SubprocessProcess | null = null;
|
|
30
|
+
private retirementFailure: Error | null = null;
|
|
31
|
+
private failure: Error | null = null;
|
|
32
|
+
private closed = false;
|
|
33
|
+
|
|
34
|
+
constructor(options: SubprocessKernelOptions) {
|
|
35
|
+
this.options = options;
|
|
36
|
+
this.onMessage = options.onMessage;
|
|
37
|
+
this.spawnProcess();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
run(input: KernelRunInput): Promise<KernelResult> {
|
|
41
|
+
if (this.closed) throw new KernelClosedError();
|
|
42
|
+
const run = this.runs.enqueue(input);
|
|
43
|
+
this.pumpRuns();
|
|
44
|
+
return run;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async interrupt(reason = "interrupted"): Promise<void> {
|
|
48
|
+
if (this.closed) return;
|
|
49
|
+
if (!this.runs.active) {
|
|
50
|
+
if (!this.retirementPromise) return;
|
|
51
|
+
const queued = this.runs.takeWaiting();
|
|
52
|
+
if (queued) this.runs.settle(queued, failureResult(queued, new CellInterruptedError(reason)));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const process = this.process;
|
|
56
|
+
process?.retire();
|
|
57
|
+
this.runs.clearToolCalls();
|
|
58
|
+
const run = this.runs.active;
|
|
59
|
+
if (!run) return;
|
|
60
|
+
this.runs.releaseActive(run);
|
|
61
|
+
this.runs.settle(run, failureResult(run, new CellInterruptedError(reason)));
|
|
62
|
+
const signal = globalThis.process.platform === "win32" ? "SIGTERM" : "SIGINT";
|
|
63
|
+
await this.restartProcess(process, signal, 5_000);
|
|
64
|
+
if (this.failure) throw this.failure;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
nextToolCall(): Promise<ToolCallMessage> {
|
|
68
|
+
return this.runs.nextToolCall();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
deliverToolReply(message: Extract<HostToKernelMessage, { type: "tool-reply" }>): void {
|
|
72
|
+
this.process?.send(encodeBridgeFrame(message));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async reset(): Promise<void> {
|
|
76
|
+
if (this.closed) throw new KernelClosedError();
|
|
77
|
+
this.settleAll(new KernelResetError());
|
|
78
|
+
this.runs.clearToolCalls();
|
|
79
|
+
await this.restartProcess(this.process);
|
|
80
|
+
if (this.failure) throw this.failure;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async close(): Promise<void> {
|
|
84
|
+
const wasClosed = this.closed;
|
|
85
|
+
this.closed = true;
|
|
86
|
+
if (!wasClosed) {
|
|
87
|
+
this.settleAll(new KernelClosingError());
|
|
88
|
+
this.runs.clearToolCalls();
|
|
89
|
+
}
|
|
90
|
+
if (this.retirementPromise) {
|
|
91
|
+
await this.retirementPromise;
|
|
92
|
+
if (this.retirementFailure) throw this.retirementFailure;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const process = this.process;
|
|
96
|
+
if (!process) return;
|
|
97
|
+
const closeFrame = wasClosed ? undefined : encodeBridgeFrame({ type: "close" });
|
|
98
|
+
if (!(await process.shutdown(closeFrame))) {
|
|
99
|
+
const error = new KernelRetirementError();
|
|
100
|
+
this.retirementFailure = error;
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
if (this.process === process) this.process = null;
|
|
104
|
+
this.retirementFailure = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private pumpRuns(): void {
|
|
108
|
+
const process = this.process;
|
|
109
|
+
if (this.closed || this.runs.active || !process || process.isRetiring) return;
|
|
110
|
+
const run = this.runs.startNext(performance.now());
|
|
111
|
+
if (!run) return;
|
|
112
|
+
const timeoutMs = run.input.timeoutMs;
|
|
113
|
+
if (timeoutMs !== undefined) run.timer = setTimeout(() => this.handleTimeout(run, timeoutMs), timeoutMs);
|
|
114
|
+
try {
|
|
115
|
+
process.send(encodeBridgeFrame({ type: "run", cellId: run.input.cellId, code: run.input.code, timeoutMs }));
|
|
116
|
+
} catch (error) {
|
|
117
|
+
this.failClosed(new KernelProcessError(error instanceof Error ? error.message : String(error)));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private handleTimeout(run: PendingRun, timeoutMs: number): void {
|
|
122
|
+
if (this.runs.active !== run || run.settled) return;
|
|
123
|
+
const process = this.process;
|
|
124
|
+
process?.retire();
|
|
125
|
+
this.runs.clearToolCalls();
|
|
126
|
+
this.runs.releaseActive(run);
|
|
127
|
+
this.runs.settle(run, timeoutResult(run, timeoutMs));
|
|
128
|
+
void this.restartProcess(process);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private spawnProcess(): void {
|
|
132
|
+
const child = spawnSubprocess(this.options.spawn, this.options);
|
|
133
|
+
const process = new SubprocessProcess(child, {
|
|
134
|
+
onLine: (source, line) => this.handleLine(source, line),
|
|
135
|
+
onStderr: (source, data) => this.handleMessage(source, { type: "text", stream: "stderr", data }),
|
|
136
|
+
onExit: (source, code, signal) => this.handleExit(source, code, signal),
|
|
137
|
+
onError: (source, error) => {
|
|
138
|
+
if (this.accepts(source)) this.failClosed(new KernelProcessError(error.message));
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
this.process = process;
|
|
142
|
+
try {
|
|
143
|
+
process.send(
|
|
144
|
+
encodeBridgeFrame({ type: "init", sessionId: this.options.sessionId, connection: this.options.connection }),
|
|
145
|
+
);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
const failure = new KernelStartupError(error instanceof Error ? error.message : String(error));
|
|
148
|
+
this.failClosed(failure);
|
|
149
|
+
throw failure;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private handleLine(process: SubprocessProcess, line: string): void {
|
|
154
|
+
if (!this.accepts(process)) return;
|
|
155
|
+
const decoded = decodeBridgeFrame(line);
|
|
156
|
+
if (!decoded.ok) {
|
|
157
|
+
this.handleMessage(process, { type: "text", stream: "stderr", data: `${decoded.error.message}\n` });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (isKernelToHostMessage(decoded.message)) this.handleMessage(process, decoded.message);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private handleMessage(process: SubprocessProcess, message: KernelToHostMessage): void {
|
|
164
|
+
if (!this.accepts(process)) return;
|
|
165
|
+
if (this.runs.handleMessage(message, this.onMessage)) this.pumpRuns();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private handleExit(process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null): void {
|
|
169
|
+
if (this.process !== process) return;
|
|
170
|
+
if (process.isRetiring) {
|
|
171
|
+
this.process = null;
|
|
172
|
+
this.retirementFailure = null;
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
this.process = null;
|
|
176
|
+
this.failClosed(new KernelExitedError(signal ?? code ?? "unknown"));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private accepts(process: SubprocessProcess): boolean {
|
|
180
|
+
return !this.closed && this.process === process && !process.isRetiring;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private restartProcess(
|
|
184
|
+
process: SubprocessProcess | null,
|
|
185
|
+
initialSignal: NodeJS.Signals = "SIGTERM",
|
|
186
|
+
escalationMs = 1_500,
|
|
187
|
+
): Promise<void> {
|
|
188
|
+
if (!process || this.closed) return Promise.resolve();
|
|
189
|
+
if (this.retirementPromise) return this.retirementPromise;
|
|
190
|
+
process.retire();
|
|
191
|
+
const retirement = this.replaceAfterRetirement(process, initialSignal, escalationMs);
|
|
192
|
+
return this.trackRetirement(process, retirement);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private async replaceAfterRetirement(
|
|
196
|
+
process: SubprocessProcess,
|
|
197
|
+
initialSignal: NodeJS.Signals,
|
|
198
|
+
escalationMs: number,
|
|
199
|
+
): Promise<void> {
|
|
200
|
+
const termination = await process.terminateSafely(initialSignal, escalationMs);
|
|
201
|
+
if (!termination.ok) {
|
|
202
|
+
const error = new KernelProcessError(termination.error.message);
|
|
203
|
+
this.retirementFailure = error;
|
|
204
|
+
this.failClosed(error);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (!termination.exited) {
|
|
208
|
+
const error = new KernelRetirementError();
|
|
209
|
+
this.retirementFailure = error;
|
|
210
|
+
this.failClosed(error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (this.process === process) this.process = null;
|
|
214
|
+
this.retirementFailure = null;
|
|
215
|
+
if (this.closed) return;
|
|
216
|
+
try {
|
|
217
|
+
this.spawnProcess();
|
|
218
|
+
} catch (error) {
|
|
219
|
+
if (!(error instanceof KernelStartupError)) {
|
|
220
|
+
this.failClosed(new KernelStartupError(error instanceof Error ? error.message : String(error)));
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
this.pumpRuns();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
private settleAll(error: Error): void {
|
|
228
|
+
this.runs.settleAll(error);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
private failClosed(error: Error): void {
|
|
232
|
+
const process = this.process;
|
|
233
|
+
this.failure = error;
|
|
234
|
+
this.closed = true;
|
|
235
|
+
process?.retire();
|
|
236
|
+
this.runs.clearToolCalls();
|
|
237
|
+
this.settleAll(error);
|
|
238
|
+
if (!process || this.retirementProcess === process) return;
|
|
239
|
+
this.trackRetirement(process, this.terminateOwnedProcess(process));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
private async terminateOwnedProcess(process: SubprocessProcess): Promise<void> {
|
|
243
|
+
const termination = await process.terminateSafely();
|
|
244
|
+
if (!termination.ok) {
|
|
245
|
+
this.retirementFailure = new KernelProcessError(termination.error.message);
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (!termination.exited) {
|
|
249
|
+
this.retirementFailure = new KernelRetirementError();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (this.process === process) this.process = null;
|
|
253
|
+
this.retirementFailure = null;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
private trackRetirement(process: SubprocessProcess, retirement: Promise<void>): Promise<void> {
|
|
257
|
+
this.retirementProcess = process;
|
|
258
|
+
this.retirementPromise = retirement;
|
|
259
|
+
void retirement.then(() => {
|
|
260
|
+
if (this.retirementPromise !== retirement) return;
|
|
261
|
+
this.retirementPromise = null;
|
|
262
|
+
this.retirementProcess = null;
|
|
263
|
+
});
|
|
264
|
+
return retirement;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { createInterface, type Interface as ReadlineInterface } from "node:readline";
|
|
3
|
+
|
|
4
|
+
export interface SubprocessLike {
|
|
5
|
+
readonly pid?: number;
|
|
6
|
+
readonly stdin: { write(chunk: string): unknown };
|
|
7
|
+
readonly stdout: NodeJS.ReadableStream;
|
|
8
|
+
readonly stderr: NodeJS.ReadableStream;
|
|
9
|
+
on(event: "error", listener: (error: Error) => void): this;
|
|
10
|
+
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): this;
|
|
11
|
+
removeListener(event: "error", listener: (error: Error) => void): this;
|
|
12
|
+
kill(signal?: NodeJS.Signals): boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type SubprocessSpawn = (
|
|
16
|
+
command: string,
|
|
17
|
+
args: readonly string[],
|
|
18
|
+
options: { readonly cwd?: string; readonly env?: NodeJS.ProcessEnv },
|
|
19
|
+
) => SubprocessLike;
|
|
20
|
+
|
|
21
|
+
export interface SubprocessSpawnRequest {
|
|
22
|
+
readonly command: string;
|
|
23
|
+
readonly args: readonly string[];
|
|
24
|
+
readonly cwd?: string;
|
|
25
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface SubprocessProcessHandlers {
|
|
29
|
+
readonly onLine: (process: SubprocessProcess, line: string) => void;
|
|
30
|
+
readonly onStderr: (process: SubprocessProcess, data: string) => void;
|
|
31
|
+
readonly onExit: (process: SubprocessProcess, code: number | null, signal: NodeJS.Signals | null) => void;
|
|
32
|
+
readonly onError: (process: SubprocessProcess, error: Error) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type SubprocessTermination =
|
|
36
|
+
| { readonly ok: true; readonly exited: boolean }
|
|
37
|
+
| { readonly ok: false; readonly error: Error };
|
|
38
|
+
|
|
39
|
+
const gracefulExitWaitMs = 1_500;
|
|
40
|
+
const forcedExitWaitMs = 500;
|
|
41
|
+
|
|
42
|
+
export class SubprocessProcess {
|
|
43
|
+
readonly child: SubprocessLike;
|
|
44
|
+
private readonly handlers: SubprocessProcessHandlers;
|
|
45
|
+
private readonly exit = Promise.withResolvers<void>();
|
|
46
|
+
private readonly stdoutReader: ReadlineInterface;
|
|
47
|
+
private readonly stderrListener: (chunk: string | Buffer) => void;
|
|
48
|
+
private readonly errorListener: (error: Error) => void;
|
|
49
|
+
private exited = false;
|
|
50
|
+
private retiring = false;
|
|
51
|
+
private outputDetached = false;
|
|
52
|
+
private errorReported = false;
|
|
53
|
+
private terminationPromise: Promise<boolean> | null = null;
|
|
54
|
+
|
|
55
|
+
constructor(child: SubprocessLike, handlers: SubprocessProcessHandlers) {
|
|
56
|
+
this.child = child;
|
|
57
|
+
this.handlers = handlers;
|
|
58
|
+
this.stdoutReader = createInterface({ input: child.stdout });
|
|
59
|
+
this.stderrListener = (chunk) => {
|
|
60
|
+
if (!this.retiring) this.handlers.onStderr(this, String(chunk));
|
|
61
|
+
};
|
|
62
|
+
this.errorListener = (error) => {
|
|
63
|
+
if (this.errorReported) return;
|
|
64
|
+
this.errorReported = true;
|
|
65
|
+
this.handlers.onError(this, error);
|
|
66
|
+
};
|
|
67
|
+
this.stdoutReader.on("line", (line) => {
|
|
68
|
+
if (!this.retiring) this.handlers.onLine(this, `${line}\n`);
|
|
69
|
+
});
|
|
70
|
+
child.stderr.on("data", this.stderrListener);
|
|
71
|
+
child.on("error", this.errorListener);
|
|
72
|
+
child.once("exit", (code, signal) => {
|
|
73
|
+
this.exited = true;
|
|
74
|
+
this.detachOutput();
|
|
75
|
+
child.removeListener("error", this.errorListener);
|
|
76
|
+
this.exit.resolve();
|
|
77
|
+
this.handlers.onExit(this, code, signal);
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get isRetiring(): boolean {
|
|
82
|
+
return this.retiring;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
send(frame: string): boolean {
|
|
86
|
+
if (this.retiring) return false;
|
|
87
|
+
this.child.stdin.write(frame);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
retire(): void {
|
|
92
|
+
if (this.retiring) return;
|
|
93
|
+
this.retiring = true;
|
|
94
|
+
this.detachOutput();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
terminate(initialSignal: NodeJS.Signals = "SIGTERM", escalationMs = gracefulExitWaitMs): Promise<boolean> {
|
|
98
|
+
if (this.exited) return Promise.resolve(true);
|
|
99
|
+
if (this.terminationPromise) return this.terminationPromise;
|
|
100
|
+
this.terminationPromise = this.performTermination(initialSignal, escalationMs);
|
|
101
|
+
return this.terminationPromise;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async terminateSafely(initialSignal?: NodeJS.Signals, escalationMs?: number): Promise<SubprocessTermination> {
|
|
105
|
+
try {
|
|
106
|
+
return { ok: true, exited: await this.terminate(initialSignal, escalationMs) };
|
|
107
|
+
} catch (error) {
|
|
108
|
+
return { ok: false, error: error instanceof Error ? error : new Error(String(error)) };
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async shutdown(frame?: string): Promise<boolean> {
|
|
113
|
+
if (frame !== undefined) {
|
|
114
|
+
try {
|
|
115
|
+
this.send(frame);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (!(error instanceof Error)) throw error;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
this.retire();
|
|
121
|
+
return await this.terminate();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private async performTermination(initialSignal: NodeJS.Signals, escalationMs: number): Promise<boolean> {
|
|
125
|
+
this.retire();
|
|
126
|
+
if (this.exited) return true;
|
|
127
|
+
this.kill(initialSignal);
|
|
128
|
+
if (await this.waitForExit(escalationMs)) return true;
|
|
129
|
+
this.kill("SIGKILL");
|
|
130
|
+
return await this.waitForExit(forcedExitWaitMs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private async waitForExit(timeoutMs: number): Promise<boolean> {
|
|
134
|
+
if (this.exited) return true;
|
|
135
|
+
let timer: NodeJS.Timeout | undefined;
|
|
136
|
+
const timeout = new Promise<false>((resolve) => {
|
|
137
|
+
timer = setTimeout(() => resolve(false), timeoutMs);
|
|
138
|
+
});
|
|
139
|
+
try {
|
|
140
|
+
return await Promise.race([this.exit.promise.then(() => true as const), timeout]);
|
|
141
|
+
} finally {
|
|
142
|
+
if (timer) clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private detachOutput(): void {
|
|
147
|
+
if (this.outputDetached) return;
|
|
148
|
+
this.outputDetached = true;
|
|
149
|
+
this.stdoutReader.close();
|
|
150
|
+
this.child.stderr.removeListener("data", this.stderrListener);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private kill(signal: NodeJS.Signals): void {
|
|
154
|
+
if (this.child.pid !== undefined) {
|
|
155
|
+
try {
|
|
156
|
+
globalThis.process.kill(-this.child.pid, signal);
|
|
157
|
+
return;
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (!(error instanceof Error)) throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
this.child.kill(signal);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function spawnSubprocess(spawn: SubprocessSpawn | undefined, request: SubprocessSpawnRequest): SubprocessLike {
|
|
167
|
+
if (spawn) return spawn(request.command, request.args, { cwd: request.cwd, env: request.env });
|
|
168
|
+
return nodeSpawn(request.command, [...request.args], {
|
|
169
|
+
cwd: request.cwd,
|
|
170
|
+
detached: true,
|
|
171
|
+
env: request.env,
|
|
172
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
173
|
+
});
|
|
174
|
+
}
|