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.
@@ -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 bounded to the turn that created the registry — `cancelAll()` on
9
- * turn teardown guarantees no background process leaks into the next turn.
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?: Bun.Subprocess<"ignore", "pipe", "pipe">;
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
- private snapshot(record: JobRecord): JobRecord {
83
- return Object.freeze({ ...record });
84
- }
85
-
86
- private emit(event: MonitorJobEvent): void {
87
- for (const listener of this.monitorListeners) {
88
- try {
89
- listener(event);
90
- } catch {
91
- // Monitor observers are advisory and must not affect process lifecycle.
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", record.command], {
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(record.id, err instanceof Error ? err.message : String(err));
170
- this.entries.set(record.id, { record, promise: Promise.resolve(), monitor: monitor ? { lineBuffer: "", deliveredLine: false } : undefined });
171
- if (monitor) {
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>, stdout: boolean) => {
81
+ const drain = async (stream: ReadableStream<Uint8Array>) => {
179
82
  const decoder = new TextDecoder();
180
83
  for await (const chunk of stream) {
181
- const text = decoder.decode(chunk, { stream: true });
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
- entry.promise = (async () => {
88
+ const promise = (async () => {
199
89
  try {
200
- await Promise.all([drain(proc.stdout, true), drain(proc.stderr, false)]);
90
+ await Promise.all([drain(proc.stdout), drain(proc.stderr)]);
201
91
  const exitCode = await proc.exited;
202
- if (record.status !== "killed") {
203
- record.status = "exited";
204
- record.exitCode = exitCode;
205
- }
92
+ if (record.status === "killed") return;
93
+ record.status = "exited";
94
+ record.exitCode = exitCode;
206
95
  } catch (err) {
207
- if (record.status !== "killed") {
208
- record.status = "failed";
209
- this.append(record.id, err instanceof Error ? err.message : String(err));
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
- if (monitor) {
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 (turn teardown / Ctrl-C). */
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
  }
@@ -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 turn. Launch one with job {action:"start", command}.` };
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: true, output: `${head}\n\n${recs.map(rowLine).join("\n")}` };
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, _onProgress, signal): Promise<ToolResult> => {
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, signal);
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
+ }