@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,119 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { Readable, Writable } from "node:stream";
3
+
4
+ export interface KernelChild {
5
+ readonly stdin: Writable;
6
+ readonly stdout: Readable;
7
+ readonly stderr: Readable;
8
+ readonly pid?: number;
9
+ readonly killed: boolean;
10
+ kill(signal?: NodeJS.Signals): boolean;
11
+ on(event: string, listener: (...args: unknown[]) => void): this;
12
+ once(event: string, listener: (...args: unknown[]) => void): this;
13
+ off(event: string, listener: (...args: unknown[]) => void): this;
14
+ }
15
+
16
+ export interface KernelSpawnOptions {
17
+ readonly command: string;
18
+ readonly args: readonly string[];
19
+ readonly cwd: string;
20
+ readonly env: NodeJS.ProcessEnv;
21
+ }
22
+
23
+ export type KernelSpawnProcess = (options: KernelSpawnOptions) => KernelChild;
24
+
25
+ export class PythonKernelRetirementError extends Error {
26
+ constructor(pid: number | undefined) {
27
+ super(`Python kernel process${pid === undefined ? "" : ` ${pid}`} did not exit after SIGKILL`);
28
+ this.name = "PythonKernelRetirementError";
29
+ }
30
+ }
31
+
32
+ export function defaultSpawn(options: KernelSpawnOptions): KernelChild {
33
+ return spawn(options.command, [...options.args], {
34
+ cwd: options.cwd,
35
+ env: options.env,
36
+ stdio: "pipe",
37
+ detached: process.platform !== "win32",
38
+ windowsHide: true,
39
+ });
40
+ }
41
+
42
+ export function splitCommand(commandLine: string): { readonly command: string; readonly args: readonly string[] } {
43
+ const [command, ...args] = commandLine.split(" ").filter(Boolean);
44
+ if (!command) throw new Error("Python interpreter path is empty");
45
+ return { command, args };
46
+ }
47
+
48
+ export function numberOrNull(value: unknown): number | null {
49
+ return typeof value === "number" ? value : null;
50
+ }
51
+
52
+ export function signalOrNull(value: unknown): string | null {
53
+ return typeof value === "string" ? value : null;
54
+ }
55
+
56
+ export async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
57
+ let timer: NodeJS.Timeout | undefined;
58
+ const timeout = new Promise<never>((_, reject) => {
59
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
60
+ });
61
+ try {
62
+ return await Promise.race([promise, timeout]);
63
+ } finally {
64
+ if (timer) clearTimeout(timer);
65
+ }
66
+ }
67
+
68
+ export async function waitForExit(child: KernelChild, timeoutMs: number): Promise<boolean> {
69
+ return await new Promise<boolean>((resolve) => {
70
+ let timer: NodeJS.Timeout | undefined;
71
+ const settle = (exited: boolean) => {
72
+ child.off("exit", onExit);
73
+ if (timer) clearTimeout(timer);
74
+ resolve(exited);
75
+ };
76
+ const onExit = () => settle(true);
77
+ child.on("exit", onExit);
78
+ timer = setTimeout(() => settle(false), timeoutMs);
79
+ timer.unref?.();
80
+ });
81
+ }
82
+
83
+ export async function hardKill(child: KernelChild, timeoutMs: number): Promise<void> {
84
+ await new Promise<void>((resolve, reject) => {
85
+ let timer: NodeJS.Timeout | undefined;
86
+ let settled = false;
87
+ const settle = (error?: Error) => {
88
+ if (settled) return;
89
+ settled = true;
90
+ child.off("exit", onExit);
91
+ if (timer) clearTimeout(timer);
92
+ if (error) reject(error);
93
+ else resolve();
94
+ };
95
+ const onExit = () => settle();
96
+ child.on("exit", onExit);
97
+ let signalDelivered = true;
98
+ if (child.pid !== undefined && process.platform !== "win32") {
99
+ try {
100
+ process.kill(-child.pid, "SIGKILL");
101
+ } catch (error) {
102
+ if (!(error instanceof Error)) {
103
+ settle(new Error(String(error)));
104
+ return;
105
+ }
106
+ signalDelivered = child.kill("SIGKILL");
107
+ }
108
+ } else {
109
+ signalDelivered = child.kill("SIGKILL");
110
+ }
111
+ if (!signalDelivered) {
112
+ settle();
113
+ return;
114
+ }
115
+ if (settled) return;
116
+ timer = setTimeout(() => settle(new PythonKernelRetirementError(child.pid)), timeoutMs);
117
+ timer.unref?.();
118
+ });
119
+ }
@@ -0,0 +1,237 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import {
4
+ type BridgeConnectionConfig,
5
+ decodeBridgeFrame,
6
+ encodeBridgeFrame,
7
+ type HostToKernelMessage,
8
+ isKernelToHostMessage,
9
+ type KernelToHostMessage,
10
+ } from "../../bridge/protocol.ts";
11
+ import {
12
+ defaultSpawn,
13
+ hardKill,
14
+ type KernelChild,
15
+ type KernelSpawnOptions,
16
+ type KernelSpawnProcess,
17
+ numberOrNull,
18
+ signalOrNull,
19
+ splitCommand,
20
+ waitForExit,
21
+ withTimeout,
22
+ } from "./process.ts";
23
+
24
+ export type PythonTransportResult = Extract<KernelToHostMessage, { type: "result" }>;
25
+
26
+ export interface PythonTransportRunInput {
27
+ readonly cellId: string;
28
+ readonly code: string;
29
+ readonly timeoutMs?: number;
30
+ }
31
+
32
+ export interface PythonTransportOptions {
33
+ readonly interpreterPath: string;
34
+ readonly sessionId: string;
35
+ readonly cwd: string;
36
+ readonly connection: BridgeConnectionConfig;
37
+ readonly env?: NodeJS.ProcessEnv;
38
+ readonly startupTimeoutMs: number;
39
+ readonly onMessage?: (message: KernelToHostMessage) => void;
40
+ readonly spawnProcess?: KernelSpawnProcess;
41
+ readonly isOwned: () => boolean;
42
+ readonly onRetirementFailure: (transport: PythonKernelTransport, error: Error) => void;
43
+ readonly onResult: (transport: PythonKernelTransport, result: PythonTransportResult) => void;
44
+ readonly onError: (transport: PythonKernelTransport, error: Error) => void;
45
+ readonly onExit: (transport: PythonKernelTransport, error: Error) => void;
46
+ }
47
+
48
+ const hardKillWaitMs = 500;
49
+
50
+ export class PythonKernelTransport {
51
+ readonly #options: PythonTransportOptions;
52
+ readonly #child: KernelChild;
53
+ #stdoutBuffer = "";
54
+ #stderrTail = "";
55
+ #settleReady: ((error?: Error) => void) | null = null;
56
+ #detachChildListeners: (() => void) | null = null;
57
+ #active = true;
58
+ #exited = false;
59
+ #retirement: Promise<void> | null = null;
60
+
61
+ private constructor(options: PythonTransportOptions, child: KernelChild) {
62
+ this.#options = options;
63
+ this.#child = child;
64
+ }
65
+
66
+ static async start(options: PythonTransportOptions): Promise<PythonKernelTransport> {
67
+ const scriptPath = join(dirname(fileURLToPath(import.meta.url)), "prelude.py");
68
+ const invocation = splitCommand(options.interpreterPath);
69
+ const spawnOptions: KernelSpawnOptions = {
70
+ command: invocation.command,
71
+ args: [...invocation.args, "-u", scriptPath],
72
+ cwd: options.cwd,
73
+ env: { ...process.env, ...options.env, PYTHONUNBUFFERED: "1", PYTHONIOENCODING: "utf-8" },
74
+ };
75
+ const child = (options.spawnProcess ?? defaultSpawn)(spawnOptions);
76
+ const transport = new PythonKernelTransport(options, child);
77
+ try {
78
+ await transport.#initialize();
79
+ if (!transport.#active) throw new Error("Python kernel exited during startup");
80
+ if (!options.isOwned()) throw new Error("Python kernel startup was superseded");
81
+ } catch (error) {
82
+ try {
83
+ await transport.retire();
84
+ } catch (retirementError) {
85
+ options.onRetirementFailure(
86
+ transport,
87
+ retirementError instanceof Error ? retirementError : new Error(String(retirementError)),
88
+ );
89
+ }
90
+ throw error;
91
+ }
92
+ return transport;
93
+ }
94
+
95
+ run(input: PythonTransportRunInput): void {
96
+ this.#write({ type: "run", cellId: input.cellId, code: input.code, timeoutMs: input.timeoutMs });
97
+ }
98
+
99
+ interrupt(reason: string): void {
100
+ try {
101
+ this.#write({ type: "interrupt", reason });
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ this.#stderrTail = `${this.#stderrTail}Python interrupt frame write failed: ${message}\n`.slice(-4_000);
105
+ }
106
+ if (process.platform === "win32") this.#child.kill();
107
+ else this.#child.kill("SIGINT");
108
+ }
109
+
110
+ async close(): Promise<void> {
111
+ if (this.#exited) return;
112
+ if (this.#retirement) {
113
+ await this.#retirement;
114
+ return;
115
+ }
116
+ if (!this.#active) {
117
+ await hardKill(this.#child, hardKillWaitMs);
118
+ return;
119
+ }
120
+ this.#active = false;
121
+ const exited = waitForExit(this.#child, hardKillWaitMs);
122
+ try {
123
+ this.#write({ type: "close" });
124
+ } catch (error) {
125
+ if (!(error instanceof Error)) throw error;
126
+ }
127
+ if (!(await exited)) await hardKill(this.#child, hardKillWaitMs);
128
+ }
129
+
130
+ retire(): Promise<void> {
131
+ if (this.#exited) return Promise.resolve();
132
+ if (this.#retirement) return this.#retirement;
133
+ this.#active = false;
134
+ const retirement = hardKill(this.#child, hardKillWaitMs).finally(() => {
135
+ this.#detachListeners();
136
+ if (this.#retirement === retirement) this.#retirement = null;
137
+ });
138
+ this.#retirement = retirement;
139
+ return retirement;
140
+ }
141
+
142
+ async #initialize(): Promise<void> {
143
+ const ready = new Promise<void>((resolve, reject) => {
144
+ this.#settleReady = (error) => (error ? reject(error) : resolve());
145
+ });
146
+ const onStdout = (chunk: unknown) => this.#onStdout(String(chunk));
147
+ const onStderr = (chunk: unknown) => this.#onStderr(String(chunk));
148
+ const onError = (error: unknown) => this.#onError(error instanceof Error ? error : new Error(String(error)));
149
+ const onExit = (code: unknown, signal: unknown) => this.#onExit(numberOrNull(code), signalOrNull(signal));
150
+ this.#detachChildListeners = () => {
151
+ this.#child.stdout.off("data", onStdout);
152
+ this.#child.stderr.off("data", onStderr);
153
+ this.#child.off("error", onError);
154
+ this.#child.off("exit", onExit);
155
+ };
156
+ this.#child.stdout.on("data", onStdout);
157
+ this.#child.stderr.on("data", onStderr);
158
+ this.#child.on("error", onError);
159
+ this.#child.on("exit", onExit);
160
+ this.#write({ type: "init", sessionId: this.#options.sessionId, connection: this.#options.connection });
161
+ await withTimeout(ready, this.#options.startupTimeoutMs, "Python kernel did not become ready");
162
+ }
163
+
164
+ #write(message: HostToKernelMessage): void {
165
+ this.#child.stdin.write(encodeBridgeFrame(message));
166
+ }
167
+
168
+ #onStdout(chunk: string): void {
169
+ if (!this.#active) return;
170
+ this.#stdoutBuffer += chunk;
171
+ let newline = this.#stdoutBuffer.indexOf("\n");
172
+ while (newline >= 0) {
173
+ const line = this.#stdoutBuffer.slice(0, newline + 1);
174
+ this.#stdoutBuffer = this.#stdoutBuffer.slice(newline + 1);
175
+ this.#handleLine(line);
176
+ newline = this.#stdoutBuffer.indexOf("\n");
177
+ }
178
+ }
179
+
180
+ #onStderr(chunk: string): void {
181
+ if (!this.#active) return;
182
+ this.#stderrTail = `${this.#stderrTail}${chunk}`.slice(-4_000);
183
+ this.#options.onMessage?.({ type: "text", stream: "stderr", data: chunk });
184
+ }
185
+
186
+ #handleLine(line: string): void {
187
+ const decoded = decodeBridgeFrame(line);
188
+ if (!decoded.ok) {
189
+ this.#options.onMessage?.({ type: "text", stream: "stderr", data: `${decoded.error.message}\n` });
190
+ return;
191
+ }
192
+ if (!isKernelToHostMessage(decoded.message)) return;
193
+ const message = decoded.message;
194
+ if (message.type === "ready") this.#settleStartup();
195
+ else if (message.type === "init-failed") this.#settleStartup(new Error(message.error.message));
196
+ else if (message.type === "result") this.#options.onResult(this, message);
197
+ this.#options.onMessage?.(message);
198
+ }
199
+
200
+ #onExit(code: number | null, signal: string | null): void {
201
+ if (this.#exited) return;
202
+ this.#exited = true;
203
+ const active = this.#active;
204
+ this.#active = false;
205
+ const error = new Error(this.#stderrTail.trim() || `Python kernel exited (${code ?? signal ?? "unknown"})`);
206
+ this.#detachListeners();
207
+ if (!active) return;
208
+ if (!this.#settleStartup(error)) this.#options.onExit(this, error);
209
+ }
210
+
211
+ #onError(error: Error): void {
212
+ if (this.#exited || !this.#active) return;
213
+ this.#active = false;
214
+ if (!this.#settleStartup(error)) this.#options.onError(this, error);
215
+ }
216
+
217
+ #detachListeners(): void {
218
+ const detach = this.#detachChildListeners;
219
+ if (!detach) return;
220
+ this.#detachChildListeners = null;
221
+ detach();
222
+ this.#stdoutBuffer = "";
223
+ this.#stderrTail = "";
224
+ }
225
+
226
+ #settleStartup(error?: Error): boolean {
227
+ const settle = this.#settleReady;
228
+ if (!settle) return false;
229
+ this.#settleReady = null;
230
+ settle(error);
231
+ return true;
232
+ }
233
+ }
234
+
235
+ export function failedPythonResult(cellId: string, message: string, stack?: string): PythonTransportResult {
236
+ return { type: "result", cellId, ok: false, error: stack ? { message, stack } : { message }, durationMs: 0 };
237
+ }
@@ -0,0 +1,26 @@
1
+ import { join } from "node:path";
2
+ import type { BridgeConnectionConfig, KernelToHostMessage } from "../../bridge/protocol.ts";
3
+ import { SubprocessKernel, type SubprocessSpawn } from "../shared/subprocess-kernel.ts";
4
+
5
+ export interface RubyKernelStartOptions {
6
+ readonly cwd: string;
7
+ readonly sessionId: string;
8
+ readonly connection: BridgeConnectionConfig;
9
+ readonly command?: string;
10
+ readonly spawn?: SubprocessSpawn;
11
+ readonly onMessage?: (message: KernelToHostMessage) => void;
12
+ }
13
+
14
+ export class RubyKernel extends SubprocessKernel {
15
+ static start(options: RubyKernelStartOptions): RubyKernel {
16
+ return new RubyKernel({
17
+ command: options.command ?? "ruby",
18
+ args: [join(import.meta.dirname, "runner.rb")],
19
+ cwd: options.cwd,
20
+ sessionId: options.sessionId,
21
+ connection: options.connection,
22
+ spawn: options.spawn,
23
+ onMessage: options.onMessage,
24
+ });
25
+ }
26
+ }
@@ -0,0 +1,270 @@
1
+ require "base64"
2
+ require "fileutils"
3
+ require "json"
4
+ require "uri"
5
+
6
+ SENPI_RESERVED_AGENT_TOOL = "__agent__"
7
+ SENPI_RESERVED_OUTPUT_TOOL = "__output__"
8
+ SENPI_INTERNAL_URL = Regexp.new("\\A([a-z][a-z0-9+.\\-]*)://(.*)\\z", Regexp::IGNORECASE)
9
+
10
+ def __senpi_status_enabled?
11
+ connection = $__senpi_connection
12
+ return true unless connection.is_a?(Hash)
13
+ connection["statusEvents"] != false
14
+ end
15
+
16
+ def __senpi_emit_status(op, fields = {}, force: false)
17
+ return unless force || __senpi_status_enabled?
18
+ __senpi_emit({ "type" => "status", "event" => { "op" => op }.merge(fields.transform_keys(&:to_s)) })
19
+ end
20
+
21
+ def __senpi_resolve_path(value)
22
+ raw = value.to_s
23
+ match = SENPI_INTERNAL_URL.match(raw)
24
+ return File.expand_path(raw) unless match
25
+
26
+ scheme = match[1].downcase
27
+ roots = $__senpi_connection.is_a?(Hash) ? $__senpi_connection["localRoots"] : nil
28
+ root = roots[scheme] if roots.is_a?(Hash)
29
+ raise "Protocol paths are not supported by this helper: #{raw}" unless root.is_a?(String) && !root.empty?
30
+
31
+ relative = URI::DEFAULT_PARSER.unescape(match[2].tr("\\", "/"))
32
+ root_path = File.expand_path(root)
33
+ return root_path if relative.empty?
34
+ if relative.start_with?("/") || relative.split("/").include?("..")
35
+ raise "Unsafe #{scheme}:// path (absolute or traversal): #{raw}"
36
+ end
37
+
38
+ resolved = File.expand_path(relative, root_path)
39
+ unless resolved == root_path || resolved.start_with?(root_path + File::SEPARATOR)
40
+ raise "#{scheme}:// path escapes its root: #{raw}"
41
+ end
42
+ resolved
43
+ end
44
+
45
+ def __senpi_display_payload(value)
46
+ if value.is_a?(Hash)
47
+ kind = value["type"] || value[:type]
48
+ text_value = value["text"] || value[:text]
49
+ return ["text/markdown", text_value.to_s] if kind == "markdown" && !text_value.nil?
50
+ return ["image/png", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/png"
51
+ return ["image/jpeg", value["data"].to_s] if kind == "image" && value["mimeType"] == "image/jpeg"
52
+ return ["application/json", JSON.generate(value)]
53
+ end
54
+ return ["application/json", JSON.generate(value)] if value.is_a?(Array)
55
+ ["text/plain", value.to_s]
56
+ end
57
+
58
+ def display(value)
59
+ mime_type, payload = __senpi_display_payload(value)
60
+ data_base64 = mime_type.start_with?("image/") ? payload : Base64.strict_encode64(payload)
61
+ __senpi_emit({ "type" => "display", "mimeType" => mime_type, "dataBase64" => data_base64 })
62
+ nil
63
+ end
64
+
65
+ def display_image(base64, mime_type: "image/png")
66
+ __senpi_emit({ "type" => "display", "mimeType" => mime_type.to_s, "dataBase64" => base64.to_s })
67
+ nil
68
+ end
69
+
70
+ def text(data)
71
+ __senpi_emit({ "type" => "text", "stream" => "stdout", "data" => data.to_s })
72
+ nil
73
+ end
74
+
75
+ def print(*values)
76
+ text(values.join)
77
+ end
78
+
79
+ def read(path, offset = 1, limit = nil)
80
+ resolved = __senpi_resolve_path(path)
81
+ data = File.read(resolved, encoding: Encoding::UTF_8)
82
+ if offset > 1 || !limit.nil?
83
+ lines = data.lines
84
+ start = [offset.to_i - 1, 0].max
85
+ data = lines[start, limit || lines.length].to_a.join
86
+ end
87
+ __senpi_emit_status("read", { "path" => resolved, "chars" => data.length, "preview" => data[0, 500].to_s })
88
+ data
89
+ end
90
+
91
+ def write(path, content)
92
+ resolved = __senpi_resolve_path(path)
93
+ FileUtils.mkdir_p(File.dirname(resolved))
94
+ data = content.to_s
95
+ File.write(resolved, data)
96
+ __senpi_emit_status("write", { "path" => resolved, "chars" => data.length })
97
+ resolved
98
+ end
99
+
100
+ def env(key = nil, value = nil)
101
+ if key.nil?
102
+ entries = ENV.to_h.sort.to_h
103
+ __senpi_emit_status("env", { "count" => entries.length, "keys" => entries.keys.first(20) })
104
+ return entries
105
+ end
106
+
107
+ name = key.to_s
108
+ if value.nil?
109
+ resolved = ENV[name]
110
+ __senpi_emit_status("env", { "key" => name, "value" => resolved, "action" => "get" })
111
+ return resolved
112
+ end
113
+
114
+ resolved = value.to_s
115
+ ENV[name] = resolved
116
+ __senpi_emit_status("env", { "key" => name, "value" => resolved, "action" => "set" })
117
+ resolved
118
+ end
119
+
120
+ def __senpi_bridge_request(path, payload)
121
+ connection = $__senpi_connection
122
+ raise "Ruby tool bridge is not initialized" unless connection.is_a?(Hash)
123
+ port = connection["port"]
124
+ token = connection["token"]
125
+ raise "Ruby tool bridge is not initialized" unless port.is_a?(Integer) && token.is_a?(String)
126
+
127
+ uri = URI("http://127.0.0.1:#{port}#{path}")
128
+ request = Net::HTTP::Post.new(uri)
129
+ request["authorization"] = "Bearer #{token}"
130
+ request["content-type"] = "application/json"
131
+ request.body = JSON.generate(payload)
132
+ __senpi_emit_status("timeout-pause", force: true)
133
+ begin
134
+ response = Net::HTTP.start(uri.hostname, uri.port, open_timeout: 10, read_timeout: 60) { |http| http.request(request) }
135
+ ensure
136
+ __senpi_emit_status("timeout-resume", force: true)
137
+ end
138
+ body = JSON.parse(response.body.to_s)
139
+ return body["value"] if body.is_a?(Hash) && body["ok"] == true
140
+
141
+ error = body.is_a?(Hash) ? body["error"] : body
142
+ raise(error.is_a?(Hash) ? error["message"].to_s : error.to_s)
143
+ end
144
+
145
+ def __senpi_call_tool(name, args)
146
+ __senpi_bridge_request("/call", { "callId" => "rb-#{Process.pid}-#{rand(1_000_000)}", "toolName" => name, "args" => args })
147
+ end
148
+
149
+ class SenpiToolCallable
150
+ def initialize(name)
151
+ @name = name
152
+ end
153
+
154
+ def call(args = nil, **kwargs)
155
+ merged = args.nil? ? {} : args.is_a?(Hash) ? args.transform_keys(&:to_s) : raise(ArgumentError, "tool.#{@name}(...) expects a Hash of arguments")
156
+ kwargs.each { |key, value| merged[key.to_s] = value }
157
+ __senpi_call_tool(@name, merged)
158
+ end
159
+ end
160
+
161
+ class SenpiToolProxy < BasicObject
162
+ def method_missing(name, args = nil, **kwargs)
163
+ ::SenpiToolCallable.new(name.to_s).call(args, **kwargs)
164
+ end
165
+
166
+ def [](name)
167
+ ::SenpiToolCallable.new(name.to_s)
168
+ end
169
+
170
+ def respond_to_missing?(_name, _private = false)
171
+ true
172
+ end
173
+ end
174
+
175
+ def tool
176
+ $__senpi_tool_proxy ||= SenpiToolProxy.new
177
+ end
178
+
179
+ def completion(prompt, model: "default", system: nil, schema: nil, **kwargs)
180
+ options = { "model" => model }.merge(kwargs.transform_keys(&:to_s))
181
+ options["system"] = system unless system.nil?
182
+ options["schema"] = schema unless schema.nil?
183
+ result = __senpi_bridge_request("/completion", { "prompt" => prompt.to_s, "opts" => options })
184
+ return result unless result.is_a?(Hash)
185
+ return result["value"] if result.key?("value")
186
+
187
+ result.fetch("text", result)
188
+ end
189
+
190
+ def output(*ids, format: "raw", offset: nil, limit: nil)
191
+ raise ArgumentError, "At least one output ID is required" if ids.empty?
192
+ raise ArgumentError, "output() format must be 'raw' or 'tail'" unless ["raw", "tail"].include?(format)
193
+ args = { "ids" => ids.map(&:to_s), "format" => format }
194
+ args["offset"] = offset unless offset.nil?
195
+ args["limit"] = limit unless limit.nil?
196
+ __senpi_call_tool(SENPI_RESERVED_OUTPUT_TOOL, args)
197
+ end
198
+
199
+ def agent(prompt, agent: "task", model: nil, label: nil, schema: nil, isolated: nil, apply: nil, merge: nil, handle: false)
200
+ args = { "prompt" => prompt.to_s, "agent" => agent }
201
+ { "model" => model, "label" => label, "schema" => schema, "isolated" => isolated, "apply" => apply, "merge" => merge }.each do |key, value|
202
+ args[key] = value unless value.nil?
203
+ end
204
+ args["handle"] = true if handle
205
+ response = __senpi_call_tool(SENPI_RESERVED_AGENT_TOOL, args)
206
+ record = response.is_a?(Hash) ? response : {}
207
+ text_value = record.fetch("text", response)
208
+ result = schema.nil? ? text_value : record.key?("data") ? record["data"] : JSON.parse(text_value.to_s)
209
+ return result unless handle
210
+ { "text" => text_value, "output" => text_value, "handle" => record["handle"] || (record["id"] && "agent://#{record["id"]}"), "id" => record["id"], "agent" => record.fetch("agent", agent) }.tap do |node|
211
+ node["data"] = result unless schema.nil?
212
+ end
213
+ end
214
+
215
+ def __senpi_pool_map(items)
216
+ values = items.to_a
217
+ return [] if values.empty?
218
+ connection = $__senpi_connection
219
+ configured_width = connection.is_a?(Hash) ? connection["parallelPoolWidth"] : nil
220
+ width = configured_width.is_a?(Numeric) ? configured_width.to_i : 4
221
+ workers = [[width, 1].max, values.length].min
222
+ results = Array.new(values.length)
223
+ failures = {}
224
+ failure_mutex = Mutex.new
225
+ queue = Queue.new
226
+ values.each_index { |index| queue << index }
227
+ threads = workers.times.map do
228
+ Thread.new do
229
+ loop do
230
+ index = queue.pop(true) rescue nil
231
+ break if index.nil?
232
+ begin
233
+ results[index] = yield(values[index])
234
+ rescue Exception => error
235
+ failure_mutex.synchronize { failures[index] = error }
236
+ end
237
+ end
238
+ end
239
+ end
240
+ threads.each(&:join)
241
+ raise failures[failures.keys.min] unless failures.empty?
242
+ results
243
+ end
244
+
245
+ def parallel(thunks)
246
+ values = thunks.to_a
247
+ values.each do |thunk|
248
+ raise TypeError, "parallel() expects an iterable of zero-arg callables" unless thunk.respond_to?(:call)
249
+ end
250
+ __senpi_pool_map(values) { |thunk| thunk.call }
251
+ end
252
+
253
+ def pipeline(items, *stages)
254
+ values = items.to_a
255
+ stages.each do |stage|
256
+ raise TypeError, "pipeline() stages must be callables" unless stage.respond_to?(:call)
257
+ values = __senpi_pool_map(values) { |value| stage.call(value) }
258
+ end
259
+ values
260
+ end
261
+
262
+ def log(message)
263
+ __senpi_emit({ "type" => "log", "message" => message.to_s })
264
+ nil
265
+ end
266
+
267
+ def phase(title)
268
+ __senpi_emit({ "type" => "phase", "title" => title.to_s })
269
+ nil
270
+ end