jeo-code 0.8.43 → 0.9.0
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/CHANGELOG.md +13 -19
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/ast-edit-tool.ts +2 -2
- package/src/agent/engine.ts +7 -44
- package/src/agent/job-registry.ts +34 -189
- package/src/agent/job-tool.ts +2 -2
- package/src/agent/lsp-rename-tool.ts +2 -2
- package/src/agent/monitor-registry.ts +233 -0
- package/src/agent/monitor-tool.ts +80 -31
- package/src/agent/subagent-registry.ts +46 -5
- package/src/agent/subagent-tool.ts +3 -3
- package/src/agent/subagents.ts +2 -2
- package/src/agent/task-tool.ts +19 -46
- package/src/agent/tool-schemas.ts +7 -6
- package/src/agent/tools.ts +4 -36
- package/src/commands/launch/stream.ts +1 -16
- package/src/commands/launch.ts +49 -55
- package/src/tui/app.ts +47 -59
|
@@ -5,12 +5,14 @@
|
|
|
5
5
|
* can keep working and later list / tail / await / cancel it via the `job` control
|
|
6
6
|
* tool while stdout/stderr drain into a bounded buffer in the background.
|
|
7
7
|
*
|
|
8
|
-
* Lifecycle is
|
|
9
|
-
*
|
|
8
|
+
* Lifecycle is owned by the launch session — jobs survive turn boundaries and remain
|
|
9
|
+
* controllable across prompts; `cancelAll()` on session teardown prevents leaks.
|
|
10
|
+
*
|
|
11
|
+
* `cancelAll()` is also safe for explicit Ctrl-C cleanup.
|
|
12
|
+
*
|
|
10
13
|
*/
|
|
11
14
|
|
|
12
15
|
export type JobStatus = "running" | "exited" | "killed" | "failed";
|
|
13
|
-
export type MonitorCategory = "log" | "poll" | "watch" | "other";
|
|
14
16
|
|
|
15
17
|
export interface JobRecord {
|
|
16
18
|
/** Stable id, e.g. "job-1". */
|
|
@@ -21,49 +23,21 @@ export interface JobRecord {
|
|
|
21
23
|
startedAt: number;
|
|
22
24
|
finishedAt?: number;
|
|
23
25
|
exitCode?: number | null;
|
|
24
|
-
/** Monitor classification; ordinary background jobs use "other". */
|
|
25
|
-
category: MonitorCategory;
|
|
26
|
-
/** Human-readable monitor purpose; ordinary jobs use their command. */
|
|
27
|
-
description: string;
|
|
28
|
-
/** Whether this monitor remains active after its first stdout line. */
|
|
29
|
-
persistent: boolean;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export interface MonitorStartOptions {
|
|
33
|
-
category: MonitorCategory;
|
|
34
|
-
description: string;
|
|
35
|
-
persistent?: boolean;
|
|
36
|
-
/** Public monitor timeout in seconds. */
|
|
37
|
-
timeout?: number;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export type MonitorJobEvent =
|
|
41
|
-
| { type: "start"; record: JobRecord }
|
|
42
|
-
| { type: "line"; record: JobRecord; line: string }
|
|
43
|
-
| { type: "done"; record: JobRecord };
|
|
44
|
-
|
|
45
|
-
interface MonitorState {
|
|
46
|
-
lineBuffer: string;
|
|
47
|
-
deliveredLine: boolean;
|
|
48
26
|
}
|
|
49
27
|
|
|
50
28
|
interface Entry {
|
|
51
29
|
record: JobRecord;
|
|
52
|
-
proc
|
|
30
|
+
proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
53
31
|
promise: Promise<void>;
|
|
54
|
-
monitor?: MonitorState;
|
|
55
|
-
timeout?: ReturnType<typeof setTimeout>;
|
|
56
32
|
}
|
|
57
33
|
|
|
58
34
|
const OUTPUT_CAP = 20000;
|
|
59
35
|
const TRUNCATION_MARKER = "[…output truncated…]\n";
|
|
60
|
-
const MONITOR_LINE_CAP = 4_000;
|
|
61
36
|
|
|
62
37
|
export class JobRegistry {
|
|
63
38
|
private readonly entries = new Map<string, Entry>();
|
|
64
39
|
private readonly buffers = new Map<string, string>();
|
|
65
40
|
private readonly truncated = new Set<string>();
|
|
66
|
-
private readonly monitorListeners = new Set<(event: MonitorJobEvent) => void>();
|
|
67
41
|
private seq = 0;
|
|
68
42
|
|
|
69
43
|
private append(id: string, chunk: string): void {
|
|
@@ -79,183 +53,58 @@ export class JobRegistry {
|
|
|
79
53
|
this.buffers.set(id, buf);
|
|
80
54
|
}
|
|
81
55
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
private emitMonitor(type: MonitorJobEvent["type"], record: JobRecord, line?: string): void {
|
|
97
|
-
const snapshot = this.snapshot(record);
|
|
98
|
-
if (type === "line") this.emit({ type, record: snapshot, line: line! });
|
|
99
|
-
else this.emit({ type, record: snapshot });
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Subscribe to monitor lifecycle events. The returned function removes this
|
|
104
|
-
* listener; exceptions thrown by listeners are ignored.
|
|
105
|
-
*/
|
|
106
|
-
subscribeMonitor(listener: (event: MonitorJobEvent) => void): () => void {
|
|
107
|
-
this.monitorListeners.add(listener);
|
|
108
|
-
return () => this.monitorListeners.delete(listener);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
private boundedMonitorLine(line: string): string {
|
|
112
|
-
const normalized = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
113
|
-
return normalized.length > MONITOR_LINE_CAP
|
|
114
|
-
? `${normalized.slice(0, MONITOR_LINE_CAP - 1)}…`
|
|
115
|
-
: normalized;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
private deliverStdout(id: string, chunk: string, flush = false): void {
|
|
119
|
-
const entry = this.entries.get(id);
|
|
120
|
-
if (!entry?.monitor || entry.record.status !== "running") return;
|
|
121
|
-
|
|
122
|
-
entry.monitor.lineBuffer += chunk;
|
|
123
|
-
let newline = entry.monitor.lineBuffer.indexOf("\n");
|
|
124
|
-
while (newline !== -1) {
|
|
125
|
-
const line = this.boundedMonitorLine(entry.monitor.lineBuffer.slice(0, newline));
|
|
126
|
-
entry.monitor.lineBuffer = entry.monitor.lineBuffer.slice(newline + 1);
|
|
127
|
-
if (!entry.monitor.deliveredLine || entry.record.persistent) {
|
|
128
|
-
entry.monitor.deliveredLine = true;
|
|
129
|
-
this.emitMonitor("line", entry.record, line);
|
|
130
|
-
if (!entry.record.persistent) {
|
|
131
|
-
this.cancel([id]);
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
newline = entry.monitor.lineBuffer.indexOf("\n");
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (flush && entry.monitor.lineBuffer) {
|
|
139
|
-
const line = this.boundedMonitorLine(entry.monitor.lineBuffer);
|
|
140
|
-
entry.monitor.lineBuffer = "";
|
|
141
|
-
if (!entry.monitor.deliveredLine || entry.record.persistent) {
|
|
142
|
-
entry.monitor.deliveredLine = true;
|
|
143
|
-
this.emitMonitor("line", entry.record, line);
|
|
144
|
-
if (!entry.record.persistent) this.cancel([id]);
|
|
145
|
-
}
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// A command that never terminates a line must not grow registry memory forever.
|
|
150
|
-
if (entry.monitor.lineBuffer.length > OUTPUT_CAP) {
|
|
151
|
-
entry.monitor.lineBuffer = entry.monitor.lineBuffer.slice(-OUTPUT_CAP);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private spawn(record: JobRecord, monitor = false, timeoutSeconds?: number): JobRecord {
|
|
156
|
-
this.buffers.set(record.id, "");
|
|
56
|
+
/** Spawn and START a background OS process; returns the (running) record immediately. */
|
|
57
|
+
start(command: string, cwd: string): JobRecord {
|
|
58
|
+
this.seq += 1;
|
|
59
|
+
const id = `job-${this.seq}`;
|
|
60
|
+
const record: JobRecord = {
|
|
61
|
+
id,
|
|
62
|
+
command,
|
|
63
|
+
cwd,
|
|
64
|
+
status: "running",
|
|
65
|
+
startedAt: Date.now(),
|
|
66
|
+
};
|
|
67
|
+
this.buffers.set(id, "");
|
|
157
68
|
|
|
158
69
|
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
159
70
|
try {
|
|
160
|
-
proc = Bun.spawn(["bash", "-c",
|
|
161
|
-
cwd: record.cwd,
|
|
162
|
-
stdin: "ignore",
|
|
163
|
-
stdout: "pipe",
|
|
164
|
-
stderr: "pipe",
|
|
165
|
-
});
|
|
71
|
+
proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
166
72
|
} catch (err) {
|
|
167
73
|
record.status = "failed";
|
|
168
74
|
record.finishedAt = Date.now();
|
|
169
|
-
this.append(
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
this.emitMonitor("start", record);
|
|
173
|
-
this.emitMonitor("done", record);
|
|
174
|
-
}
|
|
75
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
76
|
+
// No process handle to store — nothing further to drain or cancel.
|
|
77
|
+
this.entries.set(id, { record, proc: undefined as any, promise: Promise.resolve() });
|
|
175
78
|
return record;
|
|
176
79
|
}
|
|
177
80
|
|
|
178
|
-
const drain = async (stream: ReadableStream<Uint8Array
|
|
81
|
+
const drain = async (stream: ReadableStream<Uint8Array>) => {
|
|
179
82
|
const decoder = new TextDecoder();
|
|
180
83
|
for await (const chunk of stream) {
|
|
181
|
-
|
|
182
|
-
this.append(record.id, text);
|
|
183
|
-
if (stdout) this.deliverStdout(record.id, text);
|
|
84
|
+
this.append(id, decoder.decode(chunk, { stream: true }));
|
|
184
85
|
}
|
|
185
|
-
const final = decoder.decode();
|
|
186
|
-
this.append(record.id, final);
|
|
187
|
-
if (stdout) this.deliverStdout(record.id, final, true);
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
const entry: Entry = {
|
|
191
|
-
record,
|
|
192
|
-
proc,
|
|
193
|
-
promise: Promise.resolve(),
|
|
194
|
-
monitor: monitor ? { lineBuffer: "", deliveredLine: false } : undefined,
|
|
195
86
|
};
|
|
196
|
-
this.entries.set(record.id, entry);
|
|
197
87
|
|
|
198
|
-
|
|
88
|
+
const promise = (async () => {
|
|
199
89
|
try {
|
|
200
|
-
await Promise.all([drain(proc.stdout
|
|
90
|
+
await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
|
|
201
91
|
const exitCode = await proc.exited;
|
|
202
|
-
if (record.status
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
92
|
+
if (record.status === "killed") return;
|
|
93
|
+
record.status = "exited";
|
|
94
|
+
record.exitCode = exitCode;
|
|
206
95
|
} catch (err) {
|
|
207
|
-
if (record.status
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
}
|
|
96
|
+
if (record.status === "killed") return;
|
|
97
|
+
record.status = "failed";
|
|
98
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
211
99
|
} finally {
|
|
212
|
-
if (entry.timeout !== undefined) clearTimeout(entry.timeout);
|
|
213
100
|
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
214
|
-
if (monitor) this.emitMonitor("done", record);
|
|
215
101
|
}
|
|
216
102
|
})();
|
|
217
103
|
|
|
218
|
-
|
|
219
|
-
if (timeoutSeconds !== undefined) {
|
|
220
|
-
const timeout = setTimeout(() => this.cancel([record.id]), timeoutSeconds * 1000);
|
|
221
|
-
timeout.unref?.();
|
|
222
|
-
entry.timeout = timeout;
|
|
223
|
-
}
|
|
224
|
-
this.emitMonitor("start", record);
|
|
225
|
-
}
|
|
104
|
+
this.entries.set(id, { record, proc, promise });
|
|
226
105
|
return record;
|
|
227
106
|
}
|
|
228
107
|
|
|
229
|
-
/** Spawn and START a background OS process; returns the (running) record immediately. */
|
|
230
|
-
start(command: string, cwd: string): JobRecord {
|
|
231
|
-
this.seq += 1;
|
|
232
|
-
return this.spawn({
|
|
233
|
-
id: `job-${this.seq}`,
|
|
234
|
-
command,
|
|
235
|
-
cwd,
|
|
236
|
-
status: "running",
|
|
237
|
-
startedAt: Date.now(),
|
|
238
|
-
category: "other",
|
|
239
|
-
description: command,
|
|
240
|
-
persistent: false,
|
|
241
|
-
});
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/** Start a monitor using the same bounded process and output lifecycle as `start()`. */
|
|
245
|
-
startMonitor(command: string, cwd: string, options: MonitorStartOptions): JobRecord {
|
|
246
|
-
this.seq += 1;
|
|
247
|
-
return this.spawn({
|
|
248
|
-
id: `job-${this.seq}`,
|
|
249
|
-
command,
|
|
250
|
-
cwd,
|
|
251
|
-
status: "running",
|
|
252
|
-
startedAt: Date.now(),
|
|
253
|
-
category: options.category,
|
|
254
|
-
description: options.description,
|
|
255
|
-
persistent: options.persistent ?? false,
|
|
256
|
-
}, true, options.timeout);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
108
|
list(): JobRecord[] {
|
|
260
109
|
return [...this.entries.values()].map(e => e.record);
|
|
261
110
|
}
|
|
@@ -303,10 +152,6 @@ export class JobRegistry {
|
|
|
303
152
|
if (e.record.status === "running") {
|
|
304
153
|
e.record.status = "killed";
|
|
305
154
|
e.record.finishedAt = Date.now();
|
|
306
|
-
if (e.timeout !== undefined) {
|
|
307
|
-
clearTimeout(e.timeout);
|
|
308
|
-
e.timeout = undefined;
|
|
309
|
-
}
|
|
310
155
|
e.proc?.kill();
|
|
311
156
|
}
|
|
312
157
|
out.push(e.record);
|
|
@@ -314,7 +159,7 @@ export class JobRegistry {
|
|
|
314
159
|
return out;
|
|
315
160
|
}
|
|
316
161
|
|
|
317
|
-
/** Kill every still-running job (
|
|
162
|
+
/** Kill every still-running job (session teardown / Ctrl-C). */
|
|
318
163
|
cancelAll(): JobRecord[] {
|
|
319
164
|
return this.cancel(this.running().map(r => r.id));
|
|
320
165
|
}
|
package/src/agent/job-tool.ts
CHANGED
|
@@ -55,7 +55,7 @@ export function createJobTool(registry: JobRegistry): ToolHandler {
|
|
|
55
55
|
if (action === "list") {
|
|
56
56
|
const rows = registry.list();
|
|
57
57
|
if (rows.length === 0) {
|
|
58
|
-
return { success: true, output: `No background jobs this
|
|
58
|
+
return { success: true, output: `No background jobs in this session. Launch one with job {action:"start", command}.` };
|
|
59
59
|
}
|
|
60
60
|
const running = rows.filter(r => r.status === "running").length;
|
|
61
61
|
return { success: true, output: `${rows.length} job(s), ${running} running:\n${rows.map(rowLine).join("\n")}` };
|
|
@@ -89,7 +89,7 @@ export function createJobTool(registry: JobRegistry): ToolHandler {
|
|
|
89
89
|
const head = stillRunning > 0
|
|
90
90
|
? `Awaited ${recs.length} job(s); ${stillRunning} still running after the ${timeoutMs}ms timeout — await again or cancel.`
|
|
91
91
|
: `Awaited ${recs.length} job(s); all settled.`;
|
|
92
|
-
return { success:
|
|
92
|
+
return { success: stillRunning === 0, output: `${head}\n\n${recs.map(rowLine).join("\n")}` };
|
|
93
93
|
}
|
|
94
94
|
|
|
95
95
|
if (action === "cancel") {
|
|
@@ -29,7 +29,7 @@ function err(message: string): ToolResult {
|
|
|
29
29
|
const IDENTIFIER_RE = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
30
30
|
|
|
31
31
|
export function createLspRenameTool(): ToolHandler {
|
|
32
|
-
return async (args: Record<string, any>, cwd: string
|
|
32
|
+
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
33
33
|
const fileArg = typeof args.file === "string" ? args.file.trim() : "";
|
|
34
34
|
if (!fileArg) return err(`lsp_rename requires a non-empty "file".`);
|
|
35
35
|
|
|
@@ -95,7 +95,7 @@ export function createLspRenameTool(): ToolHandler {
|
|
|
95
95
|
for (const span of [...spans].sort((a, b) => b.start - a.start)) {
|
|
96
96
|
newText = newText.slice(0, span.start) + newName + newText.slice(span.start + span.length);
|
|
97
97
|
}
|
|
98
|
-
const res = await writeTool(rel, newText, cwd
|
|
98
|
+
const res = await writeTool(rel, newText, cwd);
|
|
99
99
|
if (!res.success) return err(`${rel}: ${res.error ?? "write failed"}`);
|
|
100
100
|
}
|
|
101
101
|
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process registry for line-oriented background monitor commands.
|
|
3
|
+
*
|
|
4
|
+
* Monitors are real background OS processes, but unlike jobs they surface each
|
|
5
|
+
* stdout/stderr line immediately through `onLine`. A non-persistent monitor is
|
|
6
|
+
* intentionally one-shot: its process is killed after the first emitted line.
|
|
7
|
+
* The launch session owns each registry; monitors survive turn boundaries until
|
|
8
|
+
* `cancelAll()` is called during explicit session teardown.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type MonitorStatus = "running" | "exited" | "killed" | "failed";
|
|
12
|
+
|
|
13
|
+
export interface MonitorRecord {
|
|
14
|
+
/** Stable id, e.g. `monitor-1`. */
|
|
15
|
+
id: string;
|
|
16
|
+
command: string;
|
|
17
|
+
cwd: string;
|
|
18
|
+
status: MonitorStatus;
|
|
19
|
+
startedAt: number;
|
|
20
|
+
finishedAt?: number;
|
|
21
|
+
exitCode?: number | null;
|
|
22
|
+
/** Persistent monitors continue after the first output line. */
|
|
23
|
+
persistent: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type MonitorLineHandler = (record: MonitorRecord, line: string) => void | Promise<void>;
|
|
27
|
+
|
|
28
|
+
export interface MonitorRegistryOptions {
|
|
29
|
+
/** Called once for every complete stdout/stderr line. */
|
|
30
|
+
onLine?: MonitorLineHandler;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface Entry {
|
|
34
|
+
record: MonitorRecord;
|
|
35
|
+
proc?: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
36
|
+
promise: Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const OUTPUT_CAP = 20_000;
|
|
40
|
+
const TRUNCATION_MARKER = "[…output truncated…]\n";
|
|
41
|
+
|
|
42
|
+
export class MonitorRegistry {
|
|
43
|
+
private readonly entries = new Map<string, Entry>();
|
|
44
|
+
private readonly buffers = new Map<string, string>();
|
|
45
|
+
private readonly truncated = new Set<string>();
|
|
46
|
+
private readonly onLine?: MonitorLineHandler;
|
|
47
|
+
private seq = 0;
|
|
48
|
+
constructor(options: MonitorRegistryOptions | MonitorLineHandler = {}) {
|
|
49
|
+
this.onLine = typeof options === "function" ? options : options.onLine;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private append(id: string, chunk: string): void {
|
|
53
|
+
if (!chunk) return;
|
|
54
|
+
let buf = (this.buffers.get(id) ?? "") + chunk;
|
|
55
|
+
if (buf.length > OUTPUT_CAP) {
|
|
56
|
+
buf = buf.slice(buf.length - OUTPUT_CAP);
|
|
57
|
+
if (!this.truncated.has(id)) {
|
|
58
|
+
this.truncated.add(id);
|
|
59
|
+
buf = TRUNCATION_MARKER + buf;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
this.buffers.set(id, buf);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async emit(record: MonitorRecord, line: string): Promise<void> {
|
|
66
|
+
try {
|
|
67
|
+
await this.onLine?.(record, line);
|
|
68
|
+
} catch {
|
|
69
|
+
// A UI/event sink must never turn a monitor's settled process into an
|
|
70
|
+
// unhandled rejection. The monitor output remains available through tail.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private stopAfterFirstLine(entry: Entry): void {
|
|
75
|
+
if (entry.record.status !== "running") return;
|
|
76
|
+
entry.record.status = "killed";
|
|
77
|
+
entry.record.finishedAt = Date.now();
|
|
78
|
+
try {
|
|
79
|
+
entry.proc?.kill();
|
|
80
|
+
} catch {
|
|
81
|
+
// The process may have exited between the line event and kill().
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private async drain(entry: Entry, stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
86
|
+
const decoder = new TextDecoder();
|
|
87
|
+
let pending = "";
|
|
88
|
+
try {
|
|
89
|
+
for await (const chunk of stream) {
|
|
90
|
+
const text = decoder.decode(chunk, { stream: true });
|
|
91
|
+
this.append(entry.record.id, text);
|
|
92
|
+
pending += text;
|
|
93
|
+
while (true) {
|
|
94
|
+
const match = pending.match(/(?:\r\n|\n|\r)/);
|
|
95
|
+
if (!match || match.index === undefined) break;
|
|
96
|
+
const line = pending.slice(0, match.index);
|
|
97
|
+
pending = pending.slice(match.index + match[0].length);
|
|
98
|
+
if (entry.record.status !== "running") return;
|
|
99
|
+
await this.emit(entry.record, line);
|
|
100
|
+
if (!entry.record.persistent) {
|
|
101
|
+
this.stopAfterFirstLine(entry);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const tail = decoder.decode();
|
|
107
|
+
if (tail) {
|
|
108
|
+
this.append(entry.record.id, tail);
|
|
109
|
+
pending += tail;
|
|
110
|
+
}
|
|
111
|
+
if (pending.length > 0 && entry.record.status === "running") {
|
|
112
|
+
await this.emit(entry.record, pending);
|
|
113
|
+
if (!entry.record.persistent) this.stopAfterFirstLine(entry);
|
|
114
|
+
}
|
|
115
|
+
} catch {
|
|
116
|
+
// SIGTERM commonly closes a Bun stream with an iteration error. Cancellation
|
|
117
|
+
// is a normal terminal path, not a rejected monitor promise.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Spawn and start a monitor; returns its running record immediately. */
|
|
122
|
+
start(command: string, cwd: string, persistentOrOptions: boolean | { persistent?: boolean } = false): MonitorRecord {
|
|
123
|
+
this.seq += 1;
|
|
124
|
+
const id = `monitor-${this.seq}`;
|
|
125
|
+
const persistent = typeof persistentOrOptions === "boolean"
|
|
126
|
+
? persistentOrOptions
|
|
127
|
+
: persistentOrOptions.persistent === true;
|
|
128
|
+
const record: MonitorRecord = {
|
|
129
|
+
id,
|
|
130
|
+
command,
|
|
131
|
+
cwd,
|
|
132
|
+
status: "running",
|
|
133
|
+
startedAt: Date.now(),
|
|
134
|
+
persistent,
|
|
135
|
+
};
|
|
136
|
+
this.buffers.set(id, "");
|
|
137
|
+
|
|
138
|
+
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
139
|
+
try {
|
|
140
|
+
proc = Bun.spawn(["bash", "-c", command], { cwd, stdin: "ignore", stdout: "pipe", stderr: "pipe" });
|
|
141
|
+
} catch (err) {
|
|
142
|
+
record.status = "failed";
|
|
143
|
+
record.finishedAt = Date.now();
|
|
144
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
145
|
+
const failed: Entry = { record, promise: Promise.resolve() };
|
|
146
|
+
this.entries.set(id, failed);
|
|
147
|
+
return record;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const entry = { record, proc, promise: Promise.resolve() } as Entry;
|
|
151
|
+
this.entries.set(id, entry);
|
|
152
|
+
entry.promise = (async () => {
|
|
153
|
+
try {
|
|
154
|
+
await Promise.all([this.drain(entry, proc.stdout), this.drain(entry, proc.stderr)]);
|
|
155
|
+
const exitCode = await proc.exited;
|
|
156
|
+
if (record.status === "killed") return;
|
|
157
|
+
record.status = "exited";
|
|
158
|
+
record.exitCode = exitCode;
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (record.status === "killed") return;
|
|
161
|
+
record.status = "failed";
|
|
162
|
+
this.append(id, err instanceof Error ? err.message : String(err));
|
|
163
|
+
} finally {
|
|
164
|
+
if (record.finishedAt === undefined) record.finishedAt = Date.now();
|
|
165
|
+
}
|
|
166
|
+
})();
|
|
167
|
+
// Keep the promise observed even when callers only use list/tail; all paths
|
|
168
|
+
// above handle process and stream errors so this never becomes unhandled.
|
|
169
|
+
void entry.promise.catch(() => undefined);
|
|
170
|
+
return record;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
list(): MonitorRecord[] {
|
|
174
|
+
return [...this.entries.values()].map(e => e.record);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
get(id: string): MonitorRecord | undefined {
|
|
178
|
+
return this.entries.get(id)?.record;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
running(): MonitorRecord[] {
|
|
182
|
+
return this.list().filter(r => r.status === "running");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Current buffered stdout/stderr for one monitor. */
|
|
186
|
+
tail(id: string): string {
|
|
187
|
+
return this.buffers.get(id) ?? "";
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Wait for the given ids. A positive timeout leaves unfinished records running. */
|
|
191
|
+
async awaitIds(ids: string[], timeoutMs?: number): Promise<MonitorRecord[]> {
|
|
192
|
+
const targets = ids
|
|
193
|
+
.map(id => this.entries.get(id))
|
|
194
|
+
.filter((entry): entry is Entry => entry !== undefined);
|
|
195
|
+
const all = Promise.all(targets.map(entry => entry.promise)).then(() => undefined);
|
|
196
|
+
if (timeoutMs !== undefined && timeoutMs > 0) {
|
|
197
|
+
let handle: ReturnType<typeof setTimeout> | undefined;
|
|
198
|
+
const timer = new Promise<void>(resolve => {
|
|
199
|
+
handle = setTimeout(resolve, timeoutMs);
|
|
200
|
+
});
|
|
201
|
+
await Promise.race([all, timer]);
|
|
202
|
+
clearTimeout(handle);
|
|
203
|
+
} else {
|
|
204
|
+
await all;
|
|
205
|
+
}
|
|
206
|
+
return targets.map(entry => entry.record);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Kill the given monitors. Already-terminal records are returned unchanged. */
|
|
210
|
+
cancel(ids: string[]): MonitorRecord[] {
|
|
211
|
+
const out: MonitorRecord[] = [];
|
|
212
|
+
for (const id of ids) {
|
|
213
|
+
const entry = this.entries.get(id);
|
|
214
|
+
if (!entry) continue;
|
|
215
|
+
if (entry.record.status === "running") {
|
|
216
|
+
entry.record.status = "killed";
|
|
217
|
+
entry.record.finishedAt = Date.now();
|
|
218
|
+
try {
|
|
219
|
+
entry.proc?.kill();
|
|
220
|
+
} catch {
|
|
221
|
+
// Process exited concurrently; the terminal state is already correct.
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
out.push(entry.record);
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Kill every monitor still running at session teardown / Ctrl-C. */
|
|
230
|
+
cancelAll(): MonitorRecord[] {
|
|
231
|
+
return this.cancel(this.running().map(record => record.id));
|
|
232
|
+
}
|
|
233
|
+
}
|