@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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meowlynxsea/koi",
|
|
3
|
-
"version": "0.1.
|
|
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
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Monitor Registry — 后台监控任务管理器
|
|
2
|
+
* Monitor Registry — 后台监控任务管理器 (PTY 版本)
|
|
3
3
|
*
|
|
4
|
-
* Manages background process monitors
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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 {
|
|
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
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function escapeXml(str: string): string {
|
|
47
|
-
return str
|
|
65
|
+
const escaped = payload
|
|
48
66
|
.replace(/&/g, "&")
|
|
49
67
|
.replace(/</g, "<")
|
|
50
68
|
.replace(/>/g, ">")
|
|
51
69
|
.replace(/"/g, """)
|
|
52
70
|
.replace(/'/g, "'");
|
|
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
|
|
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
|
-
//
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
105
|
+
// Create PTY and session
|
|
106
|
+
const pty = spawnPty({
|
|
107
|
+
command: "bash",
|
|
108
|
+
args: ["-c", command],
|
|
90
109
|
});
|
|
91
110
|
|
|
92
|
-
|
|
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
|
-
|
|
122
|
+
this.sessions.set(id, session);
|
|
123
|
+
this.emit("change", this.getAll());
|
|
95
124
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
158
|
+
// Forward data to registry handlers
|
|
159
|
+
newSession.on("data", (data: string) => {
|
|
160
|
+
this.handlePtyData(id, { type: "data", data });
|
|
119
161
|
});
|
|
120
162
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
163
|
+
newSession.on("exit", ({ exitCode }) => {
|
|
164
|
+
this.handlePtyExit(id, exitCode ?? 0);
|
|
165
|
+
});
|
|
124
166
|
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
this.emit("change", this.getAll());
|
|
168
|
+
return id;
|
|
169
|
+
}
|
|
127
170
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
"
|
|
142
|
-
|
|
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
|
-
|
|
148
|
-
|
|
198
|
+
this.emit("change", this.getAll());
|
|
199
|
+
}
|
|
149
200
|
|
|
150
|
-
|
|
151
|
-
|
|
201
|
+
/**
|
|
202
|
+
* Handle PTY exit.
|
|
203
|
+
*/
|
|
204
|
+
private handlePtyExit(id: string, exitCode: number): void {
|
|
205
|
+
this.sessions.delete(id);
|
|
152
206
|
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
210
|
+
monitor.status = exitCode === 0 ? "completed" : "error";
|
|
211
|
+
monitor.exitCode = exitCode;
|
|
212
|
+
monitor.endTime = Date.now();
|
|
162
213
|
|
|
163
|
-
|
|
164
|
-
|
|
214
|
+
const notification = createMonitorNotification(
|
|
215
|
+
id,
|
|
216
|
+
"completed",
|
|
217
|
+
`Exited with code ${exitCode}`
|
|
218
|
+
);
|
|
219
|
+
this.notifyParent(notification);
|
|
165
220
|
|
|
166
|
-
|
|
221
|
+
this.emit("change", this.getAll());
|
|
167
222
|
}
|
|
168
223
|
|
|
169
224
|
/**
|
|
170
|
-
*
|
|
171
|
-
* Returns true if the monitor was found and killed.
|
|
225
|
+
* Write input to a monitor's PTY.
|
|
172
226
|
*/
|
|
173
|
-
|
|
174
|
-
const
|
|
175
|
-
if (!
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
|
272
|
-
|
|
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.
|
|
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 {
|
|
@@ -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
|
-
|
|
19
|
+
|
|
20
20
|
|
|
21
21
|
const MISSING_INSTRUCTION_MESSAGE = `Provide an instruction describing the batch change you want to make.
|
|
22
22
|
|