@meowlynxsea/koi 0.1.2 → 0.1.3

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/src/tools/bash.ts CHANGED
@@ -1,19 +1,22 @@
1
1
  /**
2
- * BashTool — Shell execution with safety controls
2
+ * BashTool — Shell execution with PTY isolation
3
3
  *
4
4
  * Features:
5
- * - Timeout with SIGTERM SIGKILL escalation
6
- * - Output size limit (capped to prevent context overflow)
7
- * - Dangerous command warnings (displayed but not blocking)
5
+ * - PTY-based execution (full terminal isolation)
6
+ * - No I/O leakage to outer environment
7
+ * - Timeout handling: transfer to monitor instead of killing
8
+ * - Support for interactive commands (sudo, vim, etc.)
8
9
  */
9
10
 
10
11
  import { Type } from "typebox";
11
- import { spawn } from "child_process";
12
12
  import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
13
13
  import type { TextContent } from "@mariozechner/pi-ai";
14
14
  import { checkPermission, isDangerousBashCommand } from "../agent/check-permissions.js";
15
15
  import { requestPermission } from "../agent/permission-ui.js";
16
16
  import { withWriteLock } from "../agent/tool-orchestration.js";
17
+ import { monitorRegistry } from "../agent/monitor-registry.js";
18
+ import { spawnPty, PtySession, generatePtyId } from "./pty.js";
19
+ import { activeSessionRef } from "../agent/hooks.js";
17
20
  import type { ToolResultWithError } from "./types.js";
18
21
 
19
22
  export const bashSchema = Type.Object({
@@ -28,102 +31,207 @@ export type BashToolInput = {
28
31
 
29
32
  const MAX_OUTPUT_CHARS = 200_000;
30
33
 
31
- function execBash(command: string, timeoutSec?: number): Promise<{ stdout: string; stderr: string; exitCode: number; timedOut: boolean }> {
32
- return new Promise((resolve, reject) => {
33
- const shell = process.platform === "win32" ? "cmd" : "bash";
34
- const shellFlag = process.platform === "win32" ? "/c" : "-c";
35
- const child = spawn(shell, [shellFlag, command], {
36
- cwd: process.cwd(),
37
- env: { ...process.env, CLAUDECODE: "1", GIT_EDITOR: "true" },
38
- stdio: ["ignore", "pipe", "pipe"],
39
- });
34
+ export interface BashResult {
35
+ content: TextContent[];
36
+ details: {
37
+ exitCode?: number;
38
+ timedOut: boolean;
39
+ monitorId?: string;
40
+ };
41
+ }
40
42
 
41
- let stdout = "";
42
- let stderr = "";
43
- let timedOut = false;
44
- let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
45
-
46
- const effectiveTimeout = timeoutSec ?? 60;
47
- if (effectiveTimeout > 0) {
48
- timeoutHandle = setTimeout(() => {
49
- timedOut = true;
50
- child.kill("SIGTERM");
51
- setTimeout(() => {
52
- if (!child.killed) child.kill("SIGKILL");
53
- }, 5000);
54
- }, effectiveTimeout * 1000);
55
- }
43
+ /**
44
+ * Execute bash command with PTY and timeout handling.
45
+ *
46
+ * If timeout occurs:
47
+ * - Does NOT kill the process
48
+ * - Transfers the PTY to a new monitor
49
+ * - Returns the monitor ID for the agent to continue
50
+ *
51
+ * This ensures complete I/O isolation - no password prompts or
52
+ * interactive input/output can leak to the outer environment.
53
+ */
54
+ async function execBashWithPty(
55
+ command: string,
56
+ timeoutSec: number = 60,
57
+ onData?: (data: string) => void,
58
+ signal?: AbortSignal,
59
+ onTimeout?: (monitorId: string) => void
60
+ ): Promise<{
61
+ exitCode?: number;
62
+ output: string;
63
+ timedOut: boolean;
64
+ transferredMonitorId?: string;
65
+ session?: PtySession;
66
+ }> {
67
+ type ExecBashResult = {
68
+ exitCode?: number;
69
+ output: string;
70
+ timedOut: boolean;
71
+ transferredMonitorId?: string;
72
+ session?: PtySession;
73
+ };
74
+ const sessionId = activeSessionRef.current?.sessionId ?? "unknown";
75
+ const ptyId = generatePtyId();
56
76
 
57
- child.stdout?.on("data", (chunk: Buffer) => {
58
- stdout += chunk.toString("utf-8");
59
- if (stdout.length + stderr.length > MAX_OUTPUT_CHARS * 2) {
60
- child.kill("SIGKILL");
61
- }
62
- });
63
- child.stderr?.on("data", (chunk: Buffer) => {
64
- stderr += chunk.toString("utf-8");
65
- if (stdout.length + stderr.length > MAX_OUTPUT_CHARS * 2) {
66
- child.kill("SIGKILL");
67
- }
68
- });
77
+ let output = "";
78
+ let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
79
+ let timedOut = false;
80
+ let ptySession: PtySession | undefined;
81
+ let resolvePromise: ((value: ExecBashResult) => void) | undefined;
69
82
 
70
- child.on("error", (err) => {
71
- if (timeoutHandle) clearTimeout(timeoutHandle);
72
- reject(err);
73
- });
83
+ // Create PTY
84
+ const ptyProcess = spawnPty({
85
+ command: "bash",
86
+ args: ["-c", command],
87
+ });
88
+
89
+ // Collect output directly from PTY (no PtySession wrapper)
90
+ ptyProcess.onData((data: string) => {
91
+ output += data;
92
+ onData?.(data);
93
+ });
94
+
95
+ ptySession = new PtySession(ptyId, ptyProcess, command);
96
+
97
+ // Set up timeout
98
+ if (timeoutSec > 0) {
99
+ timeoutHandle = setTimeout(() => {
100
+ timedOut = true;
101
+
102
+ // DO NOT kill the process - transfer to monitor instead
103
+ const monitorId = monitorRegistry.adopt(
104
+ ptySession!,
105
+ sessionId,
106
+ command,
107
+ `Bash timeout transfer: ${command.slice(0, 50)}${command.length > 50 ? "…" : ""}`
108
+ );
109
+
110
+ // Clean up the old session's listeners so only monitor receives events
111
+ ptySession!.cleanup();
74
112
 
75
- child.on("close", (code) => {
113
+ // Notify caller about the timeout
114
+ onTimeout?.(monitorId);
115
+
116
+ // Notify via signal if available
117
+ signal?.removeEventListener("abort", onAbort);
118
+
119
+ // Resolve the promise now
120
+ resolvePromise?.({
121
+ exitCode: undefined,
122
+ output,
123
+ timedOut: true,
124
+ transferredMonitorId: monitorId,
125
+ session: undefined,
126
+ });
127
+ }, timeoutSec * 1000);
128
+ }
129
+
130
+ // Handle abort signal
131
+ const onAbort = () => {
132
+ if (!timedOut) {
133
+ ptySession?.kill("SIGTERM");
134
+ }
135
+ };
136
+ signal?.addEventListener("abort", onAbort);
137
+
138
+ // Wait for PTY exit
139
+ return new Promise((resolve) => {
140
+ resolvePromise = resolve;
141
+ ptyProcess.onExit(({ exitCode }) => {
76
142
  if (timeoutHandle) clearTimeout(timeoutHandle);
77
- resolve({ stdout, stderr, exitCode: code ?? 0, timedOut });
143
+ signal?.removeEventListener("abort", onAbort);
144
+
145
+ resolve({
146
+ exitCode: exitCode ?? 0,
147
+ output,
148
+ timedOut: false,
149
+ transferredMonitorId: undefined,
150
+ session: undefined,
151
+ });
78
152
  });
79
153
  });
80
154
  }
81
155
 
82
- export async function executeBash(params: BashToolInput): Promise<{ content: TextContent[]; details: { exitCode: number; timedOut: boolean } }> {
83
- const { stdout, stderr, exitCode, timedOut } = await execBash(params.command, params.timeout);
84
-
85
- let output = stdout;
86
- if (stderr) {
87
- output += (output ? "\n\n" : "") + `[stderr]\n${stderr}`;
88
- }
89
- if (timedOut) {
90
- output += "\n\n[Command timed out and was terminated]";
156
+ export async function executeBash(params: BashToolInput): Promise<BashResult> {
157
+ let monitorId: string | undefined;
158
+
159
+ const { exitCode, output, timedOut, transferredMonitorId } = await execBashWithPty(
160
+ params.command,
161
+ params.timeout,
162
+ undefined,
163
+ undefined,
164
+ (id) => { monitorId = id; }
165
+ );
166
+
167
+ // Use the monitorId from callback if available
168
+ if (timedOut && !monitorId) {
169
+ monitorId = transferredMonitorId;
91
170
  }
92
- if (output.length > MAX_OUTPUT_CHARS) {
93
- output = output.slice(0, MAX_OUTPUT_CHARS) + `\n\n[Output truncated: ${output.length} chars total, limit: ${MAX_OUTPUT_CHARS}]`;
171
+
172
+ let displayOutput = output;
173
+
174
+ // Truncate if needed
175
+ if (displayOutput.length > MAX_OUTPUT_CHARS) {
176
+ displayOutput =
177
+ displayOutput.slice(0, MAX_OUTPUT_CHARS) +
178
+ `\n\n[Output truncated: ${output.length} chars total, limit: ${MAX_OUTPUT_CHARS}]`;
94
179
  }
95
180
 
181
+ // Warning for dangerous commands
96
182
  const warning = isDangerousBashCommand(params.command)
97
183
  ? "\n\n[Warning: This command may be destructive. Proceed with caution.]"
98
184
  : "";
99
185
 
186
+ if (timedOut && monitorId) {
187
+ // Timeout occurred - process transferred to monitor
188
+ return {
189
+ content: [
190
+ {
191
+ type: "text",
192
+ text:
193
+ `Command timed out after ${params.timeout}s.\n\n` +
194
+ `The process has been transferred to a background monitor.\n\n` +
195
+ `Monitor ID: ${monitorId}\n` +
196
+ `Use SendToMonitor to provide input (e.g., passwords) or InterruptMonitor to stop it.\n\n` +
197
+ `Latest output:\n${displayOutput.slice(-2000)}${warning}`,
198
+ } satisfies TextContent,
199
+ ],
200
+ details: { exitCode: undefined, timedOut: true, monitorId },
201
+ };
202
+ }
203
+
204
+ // Normal completion
100
205
  return {
101
- content: [{ type: "text", text: output + warning }],
102
- details: { exitCode, timedOut },
206
+ content: [{ type: "text", text: displayOutput + warning }],
207
+ details: { exitCode, timedOut: false },
103
208
  };
104
209
  }
105
210
 
106
- export function createBashToolDefinition(_cwd: string): ToolDefinition<typeof bashSchema, { exitCode: number; timedOut: boolean }> {
211
+ export function createBashToolDefinition(_cwd: string): ToolDefinition<typeof bashSchema, { exitCode?: number; timedOut: boolean; monitorId?: string }> {
107
212
  return {
108
213
  name: "bash",
109
214
  label: "Bash",
110
215
  description:
111
- "Execute a bash command in the environment.\n\n" +
216
+ "Execute a bash command in the environment with full PTY isolation.\n\n" +
112
217
  "IMPORTANT: Avoid using this tool to run `find`, `grep`, `cat`, `head`, `tail`, `sed`, `awk`, or `echo` commands. " +
113
218
  "Instead, use the appropriate dedicated tool.\n\n" +
114
219
  "Git Safety Protocol:\n" +
115
220
  "- NEVER update the git config\n" +
116
221
  "- NEVER run destructive git commands (push --force, reset --hard, checkout .) unless explicitly requested\n" +
117
222
  "- NEVER use git commands with the -i flag (interactive)\n" +
118
- "- NEVER skip hooks (--no-verify, --no-gpg-sign) unless explicitly asked",
119
- promptSnippet: "Bash: execute shell commands (last resort — prefer dedicated tools)",
223
+ "- NEVER skip hooks (--no-verify, --no-gpg-sign) unless explicitly asked\n\n" +
224
+ "Timeout Handling:\n" +
225
+ "If the command times out, it will be transferred to a background monitor instead of being killed. " +
226
+ "Use SendToMonitor to interact with the process (e.g., enter sudo passwords).",
227
+ promptSnippet: "Bash: execute shell commands with PTY isolation (last resort — prefer dedicated tools)",
120
228
  parameters: bashSchema,
121
229
  executionMode: "parallel",
122
230
  async execute(_toolCallId, params, _signal, _onUpdate) {
123
231
  return withWriteLock(async () => {
124
232
  const perm = checkPermission("bash", params);
125
233
  if (perm.decision === "deny") {
126
- const result: ToolResultWithError<{ exitCode: number; timedOut: boolean }> = {
234
+ const result: ToolResultWithError<{ exitCode?: number; timedOut: boolean; monitorId?: string }> = {
127
235
  content: [{ type: "text", text: `Permission denied: ${perm.reason ?? "bash operation blocked"}` }],
128
236
  details: { exitCode: 1, timedOut: false },
129
237
  isError: true,
@@ -133,7 +241,7 @@ export function createBashToolDefinition(_cwd: string): ToolDefinition<typeof ba
133
241
  if (perm.decision === "ask") {
134
242
  const allowed = await requestPermission({ toolName: "bash", args: params, reason: perm.reason ?? "Confirm shell command" });
135
243
  if (!allowed) {
136
- const result: ToolResultWithError<{ exitCode: number; timedOut: boolean }> = {
244
+ const result: ToolResultWithError<{ exitCode?: number; timedOut: boolean; monitorId?: string }> = {
137
245
  content: [{ type: "text", text: "User denied permission to execute command." }],
138
246
  details: { exitCode: 1, timedOut: false },
139
247
  isError: true,
@@ -30,6 +30,7 @@ import {
30
30
  createMonitorToolDefinition,
31
31
  createCancelMonitorToolDefinition,
32
32
  } from "./monitor.js";
33
+ import { createSendToMonitorToolDefinition } from "./send-to-monitor.js";
33
34
  import { createSkillToolDefinition } from "./skill.js";
34
35
  import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
35
36
 
@@ -56,6 +57,7 @@ export function createCodingToolDefinitions(
56
57
  createTaskUpdateToolDefinition(_cwd, taskManager),
57
58
  createMonitorToolDefinition(),
58
59
  createCancelMonitorToolDefinition(),
60
+ createSendToMonitorToolDefinition(),
59
61
  createSkillToolDefinition(),
60
62
  ] as ToolDefinition[];
61
63
  }
@@ -0,0 +1,285 @@
1
+ /**
2
+ * PTY Utilities — Bun.spawn terminal 封装
3
+ *
4
+ * 提供跨平台 PTY (Pseudo-Terminal) 功能,用于:
5
+ * - 完全隔离输入输出流
6
+ * - 支持交互式命令(如 sudo、vim)
7
+ * - 支持 monitor 内的进程输入
8
+ */
9
+
10
+ import { EventEmitter } from "events";
11
+
12
+ export interface PtyOptions {
13
+ command?: string;
14
+ args?: string[];
15
+ cwd?: string;
16
+ env?: Record<string, string>;
17
+ cols?: number;
18
+ rows?: number;
19
+ name?: string;
20
+ }
21
+
22
+ export interface PtyData {
23
+ type: "data" | "exit" | "error";
24
+ data?: string;
25
+ exitCode?: number;
26
+ error?: string;
27
+ }
28
+
29
+ export type PtyDataCallback = (data: PtyData) => void;
30
+
31
+ const DEFAULT_COLS = 120;
32
+ const DEFAULT_ROWS = 30;
33
+
34
+ /**
35
+ * IPty 兼容接口 — 与 node-pty 的 IPty 保持一致
36
+ */
37
+ export interface IPty {
38
+ pid: number;
39
+ onData(handler: (data: string) => void): void;
40
+ onExit(handler: (exit: { exitCode: number; signal: string }) => void): void;
41
+ write(data: string): void;
42
+ resize(cols: number, rows: number): void;
43
+ kill(signal?: string): void;
44
+ }
45
+
46
+ /**
47
+ * Bun PTY 适配器 — 使用 Bun.spawn 的 terminal 选项
48
+ */
49
+ class BunPty extends EventEmitter implements IPty {
50
+ readonly pid: number;
51
+ private proc: ReturnType<typeof Bun.spawn>;
52
+ private textDecoder = new TextDecoder();
53
+
54
+ constructor(options: PtyOptions) {
55
+ super();
56
+ const shell = process.platform === "win32" ? "powershell.exe" : "bash";
57
+ const args = process.platform === "win32" ? [] : ["--login"];
58
+
59
+ const command = options.command ?? shell;
60
+ const commandArgs = options.args ?? args;
61
+
62
+ const self = this;
63
+
64
+ this.proc = Bun.spawn([command, ...commandArgs], {
65
+ cwd: options.cwd ?? process.cwd(),
66
+ env: {
67
+ ...process.env,
68
+ ...options.env,
69
+ CLAUDECODE: "1",
70
+ TERM: "xterm-256color",
71
+ } as Record<string, string>,
72
+ terminal: {
73
+ name: options.name ?? "xterm-256color",
74
+ cols: options.cols ?? DEFAULT_COLS,
75
+ rows: options.rows ?? DEFAULT_ROWS,
76
+ data(_terminal, data) {
77
+ self.emit("data", self.textDecoder.decode(data));
78
+ },
79
+ },
80
+ onExit(_subprocess, exitCode, signalCode) {
81
+ self.emit("exit", { exitCode: exitCode ?? 0, signal: signalCode ?? "" });
82
+ },
83
+ });
84
+
85
+ this.pid = this.proc.pid;
86
+ }
87
+
88
+ onData(handler: (data: string) => void): void {
89
+ this.on("data", handler);
90
+ }
91
+
92
+ onExit(handler: (exit: { exitCode: number; signal: string }) => void): void {
93
+ this.on("exit", handler);
94
+ }
95
+
96
+ write(data: string): void {
97
+ this.proc.terminal?.write(data);
98
+ }
99
+
100
+ resize(cols: number, rows: number): void {
101
+ try {
102
+ this.proc.terminal?.resize(cols, rows);
103
+ } catch {
104
+ // 忽略调整大小失败
105
+ }
106
+ }
107
+
108
+ kill(signal?: string): void {
109
+ try {
110
+ this.proc.kill(signal as number | NodeJS.Signals);
111
+ } catch {
112
+ // ignore
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * 创建并启动一个 PTY 进程
119
+ * 注意:这个函数不设置任何监听器。调用者应该:
120
+ * 1. 直接使用 pty.onData/pty.onExit 设置监听器
121
+ * 2. 或者用 PtySession 包装(它会自动设置监听器)
122
+ */
123
+ export function spawnPty(options: PtyOptions): IPty {
124
+ return new BunPty(options);
125
+ }
126
+
127
+ /**
128
+ * PtySession 类 — 封装一个 PTY 会话
129
+ * 支持发送输入、调整大小、获取输出等
130
+ */
131
+ export class PtySession extends EventEmitter {
132
+ readonly id: string;
133
+ readonly command: string;
134
+ readonly startTime: number;
135
+
136
+ /** 暴露底层 PTY 对象,用于清理监听器 */
137
+ readonly pty: IPty;
138
+
139
+ private outputBuffer: string[] = [];
140
+ private lastOutput: string = "";
141
+ private _exitCode?: number;
142
+ private _isRunning: boolean = true;
143
+ private _dataHandler: (data: string) => void;
144
+ private _exitHandler: (exit: { exitCode: number; signal: string }) => void;
145
+
146
+ constructor(id: string, pty: IPty, command: string) {
147
+ super();
148
+ this.id = id;
149
+ this.pty = pty;
150
+ this.command = command;
151
+ this.startTime = Date.now();
152
+
153
+ this._dataHandler = (data: string) => {
154
+ this.lastOutput = data;
155
+ this.outputBuffer.push(data);
156
+ this.emit("data", data);
157
+ };
158
+
159
+ this._exitHandler = ({ exitCode, signal }) => {
160
+ this._isRunning = false;
161
+ this._exitCode = exitCode;
162
+ this.emit("exit", { exitCode, signal });
163
+ };
164
+
165
+ pty.onData(this._dataHandler);
166
+ pty.onExit(this._exitHandler);
167
+ }
168
+
169
+ /**
170
+ * 向 PTY 进程发送输入
171
+ */
172
+ write(data: string): void {
173
+ if (this._isRunning) {
174
+ this.pty.write(data);
175
+ }
176
+ }
177
+
178
+ /**
179
+ * 发送带换行的输入
180
+ */
181
+ sendLine(line: string): void {
182
+ this.write(line + "\n");
183
+ }
184
+
185
+ /**
186
+ * 发送 Ctrl+C
187
+ */
188
+ sendInterrupt(): void {
189
+ this.write("\x03"); // Ctrl+C
190
+ }
191
+
192
+ /**
193
+ * 调整 PTY 大小
194
+ */
195
+ resize(cols: number, rows: number): void {
196
+ try {
197
+ this.pty.resize(cols, rows);
198
+ } catch (e) {
199
+ // 忽略调整大小失败
200
+ }
201
+ }
202
+
203
+ /**
204
+ * 获取当前是否在运行
205
+ */
206
+ get isRunning(): boolean {
207
+ return this._isRunning;
208
+ }
209
+
210
+ /**
211
+ * 获取退出码
212
+ */
213
+ get exitCode(): number | undefined {
214
+ return this._exitCode;
215
+ }
216
+
217
+ /**
218
+ * 获取最后输出
219
+ */
220
+ get currentOutput(): string {
221
+ return this.lastOutput;
222
+ }
223
+
224
+ /**
225
+ * 获取所有输出(合并后的字符串)
226
+ */
227
+ getAllOutput(): string {
228
+ return this.outputBuffer.join("");
229
+ }
230
+
231
+ /**
232
+ * 获取所有输出(按行分割)
233
+ */
234
+ getOutputLines(): string[] {
235
+ const all = this.getAllOutput();
236
+ return all.split("\n").filter((line) => line.length > 0);
237
+ }
238
+
239
+ /**
240
+ * 清理 PTY 监听器(用于 adopt 场景)
241
+ * 调用后此 PtySession 将不再接收 PTY 事件
242
+ */
243
+ cleanup(): void {
244
+ // BunPty extends EventEmitter, so removeListener works
245
+ try {
246
+ const ptyAsEmitter = this.pty as unknown as EventEmitter;
247
+ if (typeof ptyAsEmitter.removeListener === "function") {
248
+ ptyAsEmitter.removeListener("data", this._dataHandler);
249
+ ptyAsEmitter.removeListener("exit", this._exitHandler);
250
+ }
251
+ } catch {
252
+ // ignore if removeListener fails
253
+ }
254
+ // 清理 PtySession 自身的事件监听器
255
+ this.removeAllListeners();
256
+ this._isRunning = false;
257
+ }
258
+
259
+ /**
260
+ * 关闭 PTY(不杀进程)- 已废弃,请使用 cleanup()
261
+ * @deprecated 使用 cleanup() 代替
262
+ */
263
+ detach(): void {
264
+ this.cleanup();
265
+ }
266
+
267
+ /**
268
+ * 终止 PTY 进程
269
+ */
270
+ kill(signal: "SIGTERM" | "SIGKILL" = "SIGTERM"): void {
271
+ try {
272
+ this.pty.kill(signal);
273
+ } catch {
274
+ // ignore
275
+ }
276
+ this._isRunning = false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * 生成唯一的 PtySession ID
282
+ */
283
+ export function generatePtyId(): string {
284
+ return `pty-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
285
+ }