@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.
Files changed (63) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/LICENSE +22 -0
  3. package/README.md +161 -0
  4. package/package.json +58 -0
  5. package/src/bridge/http-server.ts +236 -0
  6. package/src/bridge/protocol.ts +198 -0
  7. package/src/bridge/reserved.ts +9 -0
  8. package/src/bridges/agent-bridge.ts +197 -0
  9. package/src/bridges/output-bridge.ts +96 -0
  10. package/src/bridges/schema-injection.ts +3 -0
  11. package/src/codemode/runtime.ts +258 -0
  12. package/src/codemode/tools.ts +106 -0
  13. package/src/completion/handler.ts +192 -0
  14. package/src/completion/tool-bridge.ts +55 -0
  15. package/src/config/settings.ts +215 -0
  16. package/src/extension/runtime-factory.ts +114 -0
  17. package/src/extension/session-manager-proxy.ts +116 -0
  18. package/src/extension/session-manager.ts +215 -0
  19. package/src/host-sdk.ts +1 -0
  20. package/src/index.ts +181 -0
  21. package/src/interpreters/detect.ts +161 -0
  22. package/src/kernels/jl/kernel.ts +37 -0
  23. package/src/kernels/jl/prelude.jl +283 -0
  24. package/src/kernels/jl/runner.jl +327 -0
  25. package/src/kernels/js/context-manager.ts +296 -0
  26. package/src/kernels/js/inline-worker-entry.js +23 -0
  27. package/src/kernels/js/inline-worker.ts +15 -0
  28. package/src/kernels/js/kernel-contract.ts +38 -0
  29. package/src/kernels/js/local-module-loader.ts +108 -0
  30. package/src/kernels/js/prelude.ts +15 -0
  31. package/src/kernels/js/rewrite-imports.ts +164 -0
  32. package/src/kernels/js/run-queue.ts +82 -0
  33. package/src/kernels/js/worker-core.d.ts +18 -0
  34. package/src/kernels/js/worker-core.js +94 -0
  35. package/src/kernels/js/worker-entry.js +23 -0
  36. package/src/kernels/js/worker-host.ts +117 -0
  37. package/src/kernels/js/worker-indirect-eval.js +88 -0
  38. package/src/kernels/js/worker-runtime.js +401 -0
  39. package/src/kernels/py/kernel-contract.ts +32 -0
  40. package/src/kernels/py/kernel.ts +290 -0
  41. package/src/kernels/py/prelude.py +954 -0
  42. package/src/kernels/py/process.ts +119 -0
  43. package/src/kernels/py/transport.ts +237 -0
  44. package/src/kernels/rb/kernel.ts +26 -0
  45. package/src/kernels/rb/prelude.rb +270 -0
  46. package/src/kernels/rb/runner.rb +204 -0
  47. package/src/kernels/shared/subprocess-contract.ts +22 -0
  48. package/src/kernels/shared/subprocess-kernel.ts +266 -0
  49. package/src/kernels/shared/subprocess-process.ts +174 -0
  50. package/src/kernels/shared/subprocess-queue.ts +101 -0
  51. package/src/kernels/shared/subprocess-run.ts +98 -0
  52. package/src/output/output-meta.ts +89 -0
  53. package/src/output/streaming-output.ts +296 -0
  54. package/src/prompt/eval-prompt.ts +319 -0
  55. package/src/timeouts/bridge-timeout.ts +16 -0
  56. package/src/timeouts/idle-timeout.ts +84 -0
  57. package/src/tool/cell-handler.ts +279 -0
  58. package/src/tool/eval-tool.ts +285 -0
  59. package/src/tool/image.ts +274 -0
  60. package/src/tool/json-tree.ts +247 -0
  61. package/src/tool/render.ts +876 -0
  62. package/src/tool/status-events.ts +12 -0
  63. 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
+ }