@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meowlynxsea/koi",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "A coding agent built on Pi SDK with TUI + Bun runtime",
5
5
  "module": "src/main.tsx",
6
6
  "type": "module",
@@ -44,6 +44,7 @@
44
44
  "@opentui/core": "latest",
45
45
  "@opentui/react": "latest",
46
46
  "@types/diff": "^8.0.0",
47
+ "@types/node": "^25.6.2",
47
48
  "axios": "^1.16.0",
48
49
  "bun": "^1.3.12",
49
50
  "commander": "^14.0.3",
package/src/agent/mode.ts CHANGED
@@ -95,6 +95,7 @@ const ALL_TOOLS = [
95
95
  "agent",
96
96
  "CreateMonitor",
97
97
  "CancelMonitor",
98
+ "SendToMonitor"
98
99
  ];
99
100
 
100
101
  const READONLY_TOOLS = [
@@ -1,19 +1,19 @@
1
1
  /**
2
- * Monitor Registry — 后台监控任务管理器
2
+ * Monitor Registry — 后台监控任务管理器 (PTY 版本)
3
3
  *
4
- * Manages background process monitors that watch command output and notify
5
- * the main agent when changes occur.
6
- *
7
- * Notification delivery:
8
- * - Main agent busy steer() to insert notification
9
- * - Main agent idle → prompt() to trigger a new run
4
+ * Manages background process monitors using PTY for full I/O isolation.
5
+ * Supports:
6
+ * - PTY-based execution (full terminal isolation)
7
+ * - Process input via SendToMonitor
8
+ * - External PTY handoff from bash tool
9
+ * - Output notifications to parent agent
10
10
  */
11
11
 
12
- import { spawn, ChildProcess } from "child_process";
12
+ import { spawnPty, PtySession, type PtyData } from "../tools/pty.js";
13
13
  import { EventEmitter } from "events";
14
14
  import { activeSessionRef } from "./hooks.js";
15
15
 
16
- export type MonitorStatus = "running" | "completed" | "killed" | "error";
16
+ export type MonitorStatus = "running" | "completed" | "killed" | "error" | "detached";
17
17
 
18
18
  export interface MonitorEntry {
19
19
  id: string;
@@ -27,29 +27,49 @@ export interface MonitorEntry {
27
27
  outputLines: string[];
28
28
  lastOutput?: string;
29
29
  error?: string;
30
+ hasPendingInput?: boolean; // 如果有等待输入的命令
30
31
  }
31
32
 
32
33
  type MonitorListener = (entries: MonitorEntry[]) => void;
33
34
 
35
+ // 检测是否需要交互式输入的关键词
36
+ const INTERACTIVE_PATTERNS = [
37
+ /password\s*[::]/i,
38
+ /passphrase\s*[::]/i,
39
+ /enter\s*(your)?\s*password/i,
40
+ /\[sudo\]\s*password/i,
41
+ /press\s*(any)?\s*key\s*to/i,
42
+ /press\s*enter\s*to/i,
43
+ /continue\s*\?\s*\[y\/n\]/i,
44
+ /yes\/no\s*\[\w\]\s*:/i,
45
+ /confirm\s*\?\s*\[y\/n\]/i,
46
+ ];
47
+
48
+ function detectInteractivePrompt(output: string): boolean {
49
+ for (const pattern of INTERACTIVE_PATTERNS) {
50
+ if (pattern.test(output)) {
51
+ return true;
52
+ }
53
+ }
54
+ return false;
55
+ }
56
+
34
57
  /**
35
58
  * Creates a monitor notification XML tag for internal LLM communication.
36
- * These tags are filtered out of the UI but visible to the agent.
37
59
  */
38
60
  function createMonitorNotification(
39
61
  monitorId: string,
40
- type: "output" | "completed" | "error",
62
+ type: "output" | "completed" | "error" | "interactive",
41
63
  payload: string
42
64
  ): string {
43
- return `<monitor-notification>\n <monitor-id>${monitorId}</monitor-id>\n <type>${type}</type>\n <payload>${escapeXml(payload)}</payload>\n</monitor-notification>`;
44
- }
45
-
46
- function escapeXml(str: string): string {
47
- return str
65
+ const escaped = payload
48
66
  .replace(/&/g, "&amp;")
49
67
  .replace(/</g, "&lt;")
50
68
  .replace(/>/g, "&gt;")
51
69
  .replace(/"/g, "&quot;")
52
70
  .replace(/'/g, "&apos;");
71
+
72
+ return `<monitor-notification>\n <monitor-id>${monitorId}</monitor-id>\n <type>${type}</type>\n <payload>${escaped}</payload>\n</monitor-notification>`;
53
73
  }
54
74
 
55
75
  /**
@@ -61,10 +81,10 @@ function generateMonitorId(): string {
61
81
 
62
82
  class MonitorRegistryImpl extends EventEmitter {
63
83
  private monitors = new Map<string, MonitorEntry>();
64
- private processes = new Map<string, ChildProcess>();
84
+ private sessions = new Map<string, PtySession>();
65
85
 
66
86
  /**
67
- * Launch a new background monitor.
87
+ * Launch a new background monitor with PTY.
68
88
  * Returns the monitor ID.
69
89
  */
70
90
  launch(sessionId: string, command: string, description: string = ""): string {
@@ -81,125 +101,169 @@ class MonitorRegistryImpl extends EventEmitter {
81
101
  };
82
102
 
83
103
  this.monitors.set(id, entry);
84
- this.emit("change", this.getAll());
85
104
 
86
- // Spawn the background process
87
- const child = spawn("bash", ["-c", command], {
88
- stdio: ["ignore", "pipe", "pipe"],
89
- detached: false, // Keep in same process group so we can kill it
105
+ // Create PTY and session
106
+ const pty = spawnPty({
107
+ command: "bash",
108
+ args: ["-c", command],
90
109
  });
91
110
 
92
- this.processes.set(id, child);
111
+ const session = new PtySession(id, pty, command);
112
+
113
+ // Forward data to registry handlers
114
+ session.on("data", (data: string) => {
115
+ this.handlePtyData(id, { type: "data", data });
116
+ });
117
+
118
+ session.on("exit", ({ exitCode }) => {
119
+ this.handlePtyExit(id, exitCode ?? 0);
120
+ });
93
121
 
94
- let stderrBuffer = "";
122
+ this.sessions.set(id, session);
123
+ this.emit("change", this.getAll());
95
124
 
96
- // Capture stdout line by line
97
- child.stdout?.on("data", (chunk: Buffer) => {
98
- const lines = chunk.toString().split("\n");
99
- for (const line of lines) {
100
- if (!line && lines.length === 1) continue; // Skip empty lines from partial chunks
101
- const trimmed = line.trimEnd();
102
- if (!trimmed) continue;
125
+ return id;
126
+ }
103
127
 
104
- const monitor = this.monitors.get(id);
105
- if (monitor) {
106
- monitor.outputLines.push(trimmed);
107
- monitor.lastOutput = trimmed;
108
- }
128
+ /**
129
+ * Adopt an existing PtySession into the registry.
130
+ * Used by bash tool when timeout occurs.
131
+ */
132
+ adopt(
133
+ oldSession: PtySession,
134
+ sessionId: string,
135
+ command: string,
136
+ description?: string
137
+ ): string {
138
+ const id = oldSession.id;
139
+
140
+ // Create a new PtySession with the same pty process
141
+ // This sets up new listeners while old session's listeners are cleaned up by caller
142
+ const pty = oldSession.pty;
143
+ const newSession = new PtySession(id, pty, command);
109
144
 
110
- const notification = createMonitorNotification(id, "output", trimmed);
111
- this.notifyParent(notification);
112
- this.emit("change", this.getAll());
113
- }
114
- });
145
+ const entry: MonitorEntry = {
146
+ id,
147
+ sessionId,
148
+ description: description || `Monitor: ${command.slice(0, 40)}${command.length > 40 ? "…" : ""}`,
149
+ command,
150
+ status: "running",
151
+ startTime: oldSession.startTime,
152
+ outputLines: [],
153
+ };
154
+
155
+ this.monitors.set(id, entry);
156
+ this.sessions.set(id, newSession);
115
157
 
116
- // Capture stderr
117
- child.stderr?.on("data", (chunk: Buffer) => {
118
- stderrBuffer += chunk.toString();
158
+ // Forward data to registry handlers
159
+ newSession.on("data", (data: string) => {
160
+ this.handlePtyData(id, { type: "data", data });
119
161
  });
120
162
 
121
- // Handle process exit
122
- child.on("close", (code: number | null) => {
123
- this.processes.delete(id);
163
+ newSession.on("exit", ({ exitCode }) => {
164
+ this.handlePtyExit(id, exitCode ?? 0);
165
+ });
124
166
 
125
- const monitor = this.monitors.get(id);
126
- if (!monitor) return;
167
+ this.emit("change", this.getAll());
168
+ return id;
169
+ }
127
170
 
128
- if (stderrBuffer.trim()) {
129
- monitor.error = stderrBuffer.trim();
130
- const notification = createMonitorNotification(id, "error", stderrBuffer.trim());
131
- this.notifyParent(notification);
132
- }
171
+ /**
172
+ * Handle PTY data.
173
+ */
174
+ private handlePtyData(id: string, data: PtyData): void {
175
+ const monitor = this.monitors.get(id);
176
+ if (!monitor) return;
133
177
 
134
- if (monitor.status === "running") {
135
- monitor.status = code === 0 ? "completed" : "error";
136
- monitor.exitCode = code ?? undefined;
137
- monitor.endTime = Date.now();
178
+ if (data.type === "data" && data.data) {
179
+ // 累积输出
180
+ monitor.outputLines.push(data.data);
181
+ monitor.lastOutput = data.data;
138
182
 
183
+ // 检测交互式提示
184
+ if (detectInteractivePrompt(data.data)) {
185
+ monitor.hasPendingInput = true;
139
186
  const notification = createMonitorNotification(
140
187
  id,
141
- "completed",
142
- `Exited with code ${code ?? "unknown"}`
188
+ "interactive",
189
+ data.data
143
190
  );
144
191
  this.notifyParent(notification);
192
+ } else {
193
+ const notification = createMonitorNotification(id, "output", data.data);
194
+ this.notifyParent(notification);
145
195
  }
196
+ }
146
197
 
147
- this.emit("change", this.getAll());
148
- });
198
+ this.emit("change", this.getAll());
199
+ }
149
200
 
150
- child.on("error", (err: Error) => {
151
- this.processes.delete(id);
201
+ /**
202
+ * Handle PTY exit.
203
+ */
204
+ private handlePtyExit(id: string, exitCode: number): void {
205
+ this.sessions.delete(id);
152
206
 
153
- const monitor = this.monitors.get(id);
154
- if (monitor) {
155
- monitor.status = "error";
156
- monitor.error = err.message;
157
- monitor.endTime = Date.now();
207
+ const monitor = this.monitors.get(id);
208
+ if (!monitor) return;
158
209
 
159
- const notification = createMonitorNotification(id, "error", err.message);
160
- this.notifyParent(notification);
161
- }
210
+ monitor.status = exitCode === 0 ? "completed" : "error";
211
+ monitor.exitCode = exitCode;
212
+ monitor.endTime = Date.now();
162
213
 
163
- this.emit("change", this.getAll());
164
- });
214
+ const notification = createMonitorNotification(
215
+ id,
216
+ "completed",
217
+ `Exited with code ${exitCode}`
218
+ );
219
+ this.notifyParent(notification);
165
220
 
166
- return id;
221
+ this.emit("change", this.getAll());
167
222
  }
168
223
 
169
224
  /**
170
- * Cancel a running monitor by killing its process.
171
- * Returns true if the monitor was found and killed.
225
+ * Write input to a monitor's PTY.
172
226
  */
173
- kill(id: string): boolean {
174
- const child = this.processes.get(id);
175
- if (!child) return false;
176
-
177
- try {
178
- // Kill the process group to ensure child processes are also terminated
179
- process.kill(child.pid!, "SIGTERM");
180
-
181
- // Give it a moment to clean up gracefully
182
- setTimeout(() => {
183
- const monitor = this.monitors.get(id);
184
- if (monitor && monitor.status === "running") {
185
- try {
186
- process.kill(child.pid!, 0); // Check if still alive
187
- } catch {
188
- // Process already dead, already handled by 'close' event
189
- return;
190
- }
191
- // Force kill if still alive
192
- try {
193
- process.kill(child.pid!, "SIGKILL");
194
- } catch {
195
- // ignore
196
- }
197
- }
198
- }, 500);
199
- } catch {
200
- // Process might have already exited
227
+ write(id: string, input: string): boolean {
228
+ const session = this.sessions.get(id);
229
+ if (!session) return false;
230
+
231
+ const monitor = this.monitors.get(id);
232
+ if (monitor) {
233
+ monitor.hasPendingInput = false;
201
234
  }
202
235
 
236
+ session.write(input);
237
+ return true;
238
+ }
239
+
240
+ /**
241
+ * Send a line of input to a monitor's PTY.
242
+ */
243
+ sendLine(id: string, line: string): boolean {
244
+ return this.write(id, line + "\n");
245
+ }
246
+
247
+ /**
248
+ * Send Ctrl+C to interrupt a monitor.
249
+ */
250
+ interrupt(id: string): boolean {
251
+ const session = this.sessions.get(id);
252
+ if (!session) return false;
253
+ session.sendInterrupt();
254
+ return true;
255
+ }
256
+
257
+ /**
258
+ * Cancel a running monitor.
259
+ */
260
+ kill(id: string): boolean {
261
+ const session = this.sessions.get(id);
262
+ if (!session) return false;
263
+
264
+ session.kill("SIGTERM");
265
+ this.sessions.delete(id);
266
+
203
267
  const monitor = this.monitors.get(id);
204
268
  if (monitor) {
205
269
  monitor.status = "killed";
@@ -217,6 +281,13 @@ class MonitorRegistryImpl extends EventEmitter {
217
281
  return this.monitors.get(id);
218
282
  }
219
283
 
284
+ /**
285
+ * Get a PtySession by monitor ID.
286
+ */
287
+ getSession(id: string): PtySession | undefined {
288
+ return this.sessions.get(id);
289
+ }
290
+
220
291
  /**
221
292
  * Get all monitor entries.
222
293
  */
@@ -254,10 +325,14 @@ class MonitorRegistryImpl extends EventEmitter {
254
325
  }
255
326
 
256
327
  /**
257
- * Remove a monitor from the registry.
258
- * Does not kill the process if it's still running.
328
+ * Remove a monitor from the registry without killing the process.
259
329
  */
260
330
  remove(id: string): boolean {
331
+ const session = this.sessions.get(id);
332
+ if (session) {
333
+ session.detach();
334
+ this.sessions.delete(id);
335
+ }
261
336
  const existed = this.monitors.has(id);
262
337
  this.monitors.delete(id);
263
338
  if (existed) this.emit("change", this.getAll());
@@ -268,22 +343,16 @@ class MonitorRegistryImpl extends EventEmitter {
268
343
  * Clear all monitors.
269
344
  */
270
345
  clear(): void {
271
- for (const [id, child] of this.processes) {
272
- try {
273
- process.kill(child.pid!, "SIGTERM");
274
- } catch {
275
- // ignore
276
- }
277
- this.monitors.delete(id);
346
+ for (const session of this.sessions.values()) {
347
+ session.kill("SIGTERM");
278
348
  }
279
- this.processes.clear();
349
+ this.sessions.clear();
280
350
  this.monitors.clear();
281
351
  this.emit("change", this.getAll());
282
352
  }
283
353
 
284
354
  /**
285
355
  * Notify the parent agent session of a monitor event.
286
- * Uses steer() if the agent is busy, prompt() if idle.
287
356
  */
288
357
  private notifyParent(notification: string): void {
289
358
  const parent = activeSessionRef.current;
@@ -376,7 +376,7 @@ class ToolAbortError extends Error {
376
376
  }
377
377
 
378
378
  /** Wraps a tool definition to support abort signal checking. */
379
- function wrapToolWithAbortSupport<TParams, TDetails>(
379
+ function wrapToolWithAbortSupport<TParams extends Type.TSchema, TDetails>(
380
380
  tool: ToolDefinition<TParams, TDetails>
381
381
  ): ToolDefinition<TParams, TDetails> {
382
382
  return {
@@ -18,6 +18,7 @@ export interface McpServerConfig {
18
18
  url?: string;
19
19
  headers?: Record<string, string>;
20
20
  authToken?: string;
21
+ enabled?: boolean;
21
22
  }
22
23
 
23
24
  // Config Scope
@@ -16,7 +16,7 @@ const WORKER_INSTRUCTIONS = `After implementing the change:
16
16
  3. **Commit** — Commit changes with a clear message
17
17
  4. **Report** — End with a summary of what was done`;
18
18
 
19
- const NOT_A_GIT_REPO_MESSAGE = `This is not a git repository. The \`/batch\` command requires a git repo. Initialize a repo first, or run this from inside an existing one.`;
19
+
20
20
 
21
21
  const MISSING_INSTRUCTION_MESSAGE = `Provide an instruction describing the batch change you want to make.
22
22
 
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { registerBundledSkill } from "../bundled.js";
9
9
 
10
- const DEFAULT_DEBUG_LINES_READ = 30;
10
+
11
11
 
12
12
  export function registerDebugSkill(): void {
13
13
  registerBundledSkill({