@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/dist/main.js +580 -250
- package/package.json +2 -1
- package/src/agent/mode.ts +1 -0
- package/src/agent/monitor-registry.ts +186 -117
- package/src/agent/session-store.ts +1 -1
- package/src/services/mcp/types.ts +1 -0
- package/src/skills/bundled/batch.ts +1 -1
- package/src/skills/bundled/debug.ts +1 -1
- package/src/tools/bash.ts +174 -66
- package/src/tools/index.ts +2 -0
- package/src/tools/pty.ts +285 -0
- package/src/tools/send-to-monitor.ts +158 -0
- package/src/tui/components/connecting-modal.tsx +2 -1
- package/src/tui/components/side-bar.tsx +1 -1
package/src/tools/bash.ts
CHANGED
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* BashTool — Shell execution with
|
|
2
|
+
* BashTool — Shell execution with PTY isolation
|
|
3
3
|
*
|
|
4
4
|
* Features:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
});
|
|
34
|
+
export interface BashResult {
|
|
35
|
+
content: TextContent[];
|
|
36
|
+
details: {
|
|
37
|
+
exitCode?: number;
|
|
38
|
+
timedOut: boolean;
|
|
39
|
+
monitorId?: string;
|
|
40
|
+
};
|
|
41
|
+
}
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
93
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
package/src/tools/index.ts
CHANGED
|
@@ -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
|
}
|
package/src/tools/pty.ts
ADDED
|
@@ -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
|
+
}
|