@bubblebrain-ai/bubble 0.0.37 → 0.0.39

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.
Files changed (61) hide show
  1. package/dist/agent/internal-reminder-sanitizer.js +4 -0
  2. package/dist/agent/profiles.d.ts +6 -0
  3. package/dist/agent/profiles.js +32 -0
  4. package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
  5. package/dist/agent/task-lifecycle-reminder.js +59 -0
  6. package/dist/agent.d.ts +43 -0
  7. package/dist/agent.js +70 -0
  8. package/dist/approval/controller.d.ts +3 -1
  9. package/dist/approval/controller.js +5 -2
  10. package/dist/approval/tool-helper.js +10 -1
  11. package/dist/approval/types.d.ts +15 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +11 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +4 -0
  16. package/dist/loop/engine.d.ts +29 -0
  17. package/dist/loop/engine.js +63 -0
  18. package/dist/main.js +92 -24
  19. package/dist/orchestrator/default-hooks.js +94 -1
  20. package/dist/orchestrator/hooks.d.ts +7 -0
  21. package/dist/print-output.d.ts +54 -0
  22. package/dist/print-output.js +103 -0
  23. package/dist/prompt/delegation.js +6 -3
  24. package/dist/prompt/task-reminders.d.ts +17 -0
  25. package/dist/prompt/task-reminders.js +15 -0
  26. package/dist/session-types.d.ts +1 -1
  27. package/dist/tasks/manager.d.ts +136 -0
  28. package/dist/tasks/manager.js +530 -0
  29. package/dist/tasks/process-utils.d.ts +10 -0
  30. package/dist/tasks/process-utils.js +49 -0
  31. package/dist/tasks/promotion.d.ts +17 -0
  32. package/dist/tasks/promotion.js +30 -0
  33. package/dist/tasks/wake.d.ts +53 -0
  34. package/dist/tasks/wake.js +125 -0
  35. package/dist/tools/bash.d.ts +15 -1
  36. package/dist/tools/bash.js +103 -3
  37. package/dist/tools/child-tools.js +3 -1
  38. package/dist/tools/edit.js +4 -12
  39. package/dist/tools/index.d.ts +13 -0
  40. package/dist/tools/index.js +8 -1
  41. package/dist/tools/server-manager.d.ts +6 -36
  42. package/dist/tools/server-manager.js +6 -234
  43. package/dist/tools/task-tools.d.ts +10 -0
  44. package/dist/tools/task-tools.js +129 -0
  45. package/dist/tools/write.js +6 -12
  46. package/dist/tui-ink/app.d.ts +20 -2
  47. package/dist/tui-ink/app.js +307 -19
  48. package/dist/tui-ink/approval/approval-dialog.js +4 -4
  49. package/dist/tui-ink/display-history.d.ts +1 -1
  50. package/dist/tui-ink/footer.d.ts +3 -3
  51. package/dist/tui-ink/footer.js +3 -3
  52. package/dist/tui-ink/input-box.d.ts +6 -0
  53. package/dist/tui-ink/markdown.d.ts +10 -2
  54. package/dist/tui-ink/markdown.js +13 -12
  55. package/dist/tui-ink/message-list.js +14 -3
  56. package/dist/tui-ink/run.d.ts +6 -0
  57. package/dist/tui-ink/run.js +1 -1
  58. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  59. package/dist/tui-ink/subagent-inspector.js +23 -7
  60. package/dist/tui-ink/welcome.js +4 -0
  61. package/package.json +1 -1
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Unified process manager: background tasks + managed servers
3
+ * (docs/background-tasks-design.md §2.2).
4
+ *
5
+ * One registry owns every child process Bubble manages, discriminated by
6
+ * `kind: "task" | "server"` — NOT `purpose`, which is a shipped model-facing
7
+ * server field (preview|verification). Server semantics (port conflict scan,
8
+ * readiness probe, lifecycle, lastUsedAt touch-on-read) are preserved
9
+ * verbatim from the absorbed src/tools/server-manager.ts; that module remains
10
+ * as a re-export shim so server tools, agent.ts, and their tests are
11
+ * untouched.
12
+ *
13
+ * State is per-process (as the old server registry was). main.ts fetches the
14
+ * instance via getProcessManager() and passes it explicitly to createAllTools
15
+ * and the TUI App — the TUI bridge is subscription-based (onChange /
16
+ * onTaskFinished), never a hidden import (design §2.2a).
17
+ *
18
+ * Task children are spawned detached (own process group, killable as a tree)
19
+ * but NOT unref'd — unlike servers, a task must never outlive the parent
20
+ * process, so the event loop keeps owning it (design §2.2a). Reaping is
21
+ * three-layered (design §2.2b): graceful shutdownTasks() with escalation, a
22
+ * SIGKILL-only path for signal handlers, and a process.once("exit") backstop.
23
+ */
24
+ import { type ChildProcess } from "node:child_process";
25
+ export type ManagedServerPurpose = "preview" | "verification";
26
+ export type ManagedServerLifecycle = "auto" | "keep_alive";
27
+ export type ManagedServerStatus = "starting" | "ready" | "running" | "exited" | "failed" | "stopped";
28
+ export interface ManagedServerInfo {
29
+ id: string;
30
+ command: string;
31
+ cwd: string;
32
+ port?: number;
33
+ url?: string;
34
+ ownerSessionId?: string;
35
+ ownerRunId?: string;
36
+ purpose: ManagedServerPurpose;
37
+ lifecycle: ManagedServerLifecycle;
38
+ startedAt: number;
39
+ lastUsedAt: number;
40
+ status: ManagedServerStatus;
41
+ pid?: number;
42
+ exitCode?: number | null;
43
+ }
44
+ export interface StartManagedServerInput {
45
+ command: string;
46
+ cwd: string;
47
+ port?: number;
48
+ readinessUrl?: string;
49
+ timeoutSec?: number;
50
+ ownerSessionId?: string;
51
+ ownerRunId?: string;
52
+ purpose?: ManagedServerPurpose;
53
+ lifecycle?: ManagedServerLifecycle;
54
+ }
55
+ export type BackgroundTaskStatus = "running" | "completed" | "failed" | "killed";
56
+ export interface BackgroundTaskInfo {
57
+ kind: "task";
58
+ id: string;
59
+ command: string;
60
+ description?: string;
61
+ cwd: string;
62
+ pid?: number;
63
+ status: BackgroundTaskStatus;
64
+ exitCode?: number | null;
65
+ startedAt: number;
66
+ endedAt?: number;
67
+ outputTruncated: boolean;
68
+ /** Wake fired or model read the task via task_output (design §2.3a). */
69
+ deliveredAt?: number;
70
+ ownerSessionId?: string;
71
+ }
72
+ export interface StartBackgroundTaskInput {
73
+ command: string;
74
+ description?: string;
75
+ cwd: string;
76
+ ownerSessionId?: string;
77
+ }
78
+ export interface AdoptBackgroundTaskInput extends StartBackgroundTaskInput {
79
+ /** Already-running child handed over by Ctrl+G promotion (design §2.5). */
80
+ child: ChildProcess;
81
+ /** Output accumulated before promotion, seeds the ring buffer. */
82
+ outputSoFar?: string;
83
+ startedAt?: number;
84
+ }
85
+ export type TaskEventListener = (task: BackgroundTaskInfo) => void;
86
+ export declare class ProcessManager {
87
+ private readonly registry;
88
+ private nextServerId;
89
+ private nextTaskId;
90
+ /** Bumped on task start/finish/kill — the reminder's state-change gate (design §2.3a). */
91
+ private taskStateVersion;
92
+ private readonly changeListeners;
93
+ private readonly finishListeners;
94
+ getTaskStateVersion(): number;
95
+ startTask(input: StartBackgroundTaskInput): BackgroundTaskInfo;
96
+ /** Ctrl+G promotion: adopt a child the bash tool already spawned (§2.5). */
97
+ adoptTask(input: AdoptBackgroundTaskInput): BackgroundTaskInfo;
98
+ private reserveTaskSlot;
99
+ private registerTaskChild;
100
+ private finalizeTask;
101
+ getTask(id: string): BackgroundTaskInfo | undefined;
102
+ listTasks(ownerSessionId?: string): BackgroundTaskInfo[];
103
+ taskOutputTail(id: string, maxChars?: number): string | undefined;
104
+ markTaskDelivered(id: string): void;
105
+ waitTasks(ids: string[], options?: {
106
+ timeoutMs?: number;
107
+ mode?: "any" | "all";
108
+ }): Promise<BackgroundTaskInfo[]>;
109
+ killTask(id: string): Promise<BackgroundTaskInfo | undefined>;
110
+ /** Graceful reaping for shutdownRuntime (design §2.2b layer 1). */
111
+ shutdownTasks(): Promise<void>;
112
+ /** SIGKILL-only reaping for synchronous signal/exit paths (§2.2b layers 2-3). */
113
+ reapTasksSync(): void;
114
+ onTaskFinished(listener: TaskEventListener): () => void;
115
+ onChange(listener: TaskEventListener): () => void;
116
+ private bumpTaskState;
117
+ private evictFinishedTasks;
118
+ private taskRecords;
119
+ private publicTask;
120
+ startManagedServer(input: StartManagedServerInput): Promise<ManagedServerInfo>;
121
+ listManagedServers(): ManagedServerInfo[];
122
+ getManagedServer(id: string): ManagedServerInfo | undefined;
123
+ getManagedServerLogs(id: string, maxChars?: number): string | undefined;
124
+ stopManagedServer(id: string): Promise<ManagedServerInfo | undefined>;
125
+ stopAutoServersForSession(sessionID?: string): Promise<void>;
126
+ /** process.once("exit") backstop (§2.2b layer 3). */
127
+ reapOnExit(): void;
128
+ private serverRecords;
129
+ }
130
+ export declare function getProcessManager(): ProcessManager;
131
+ export declare const startManagedServer: (input: StartManagedServerInput) => Promise<ManagedServerInfo>;
132
+ export declare const listManagedServers: () => ManagedServerInfo[];
133
+ export declare const getManagedServer: (id: string) => ManagedServerInfo | undefined;
134
+ export declare const getManagedServerLogs: (id: string, maxChars?: number) => string | undefined;
135
+ export declare const stopManagedServer: (id: string) => Promise<ManagedServerInfo | undefined>;
136
+ export declare const stopAutoServersForSession: (sessionID?: string) => Promise<void>;
@@ -0,0 +1,530 @@
1
+ /**
2
+ * Unified process manager: background tasks + managed servers
3
+ * (docs/background-tasks-design.md §2.2).
4
+ *
5
+ * One registry owns every child process Bubble manages, discriminated by
6
+ * `kind: "task" | "server"` — NOT `purpose`, which is a shipped model-facing
7
+ * server field (preview|verification). Server semantics (port conflict scan,
8
+ * readiness probe, lifecycle, lastUsedAt touch-on-read) are preserved
9
+ * verbatim from the absorbed src/tools/server-manager.ts; that module remains
10
+ * as a re-export shim so server tools, agent.ts, and their tests are
11
+ * untouched.
12
+ *
13
+ * State is per-process (as the old server registry was). main.ts fetches the
14
+ * instance via getProcessManager() and passes it explicitly to createAllTools
15
+ * and the TUI App — the TUI bridge is subscription-based (onChange /
16
+ * onTaskFinished), never a hidden import (design §2.2a).
17
+ *
18
+ * Task children are spawned detached (own process group, killable as a tree)
19
+ * but NOT unref'd — unlike servers, a task must never outlive the parent
20
+ * process, so the event loop keeps owning it (design §2.2a). Reaping is
21
+ * three-layered (design §2.2b): graceful shutdownTasks() with escalation, a
22
+ * SIGKILL-only path for signal handlers, and a process.once("exit") backstop.
23
+ */
24
+ import { spawn } from "node:child_process";
25
+ import { existsSync } from "node:fs";
26
+ import net from "node:net";
27
+ import { platform } from "node:os";
28
+ import { appendTailCapped, killProcessTree, stripAnsi, tail } from "./process-utils.js";
29
+ const MAX_LOG_BYTES = 96 * 1024;
30
+ const STOP_FORCE_AFTER_MS = 1000;
31
+ const MAX_RUNNING_TASKS_PER_SESSION = 8;
32
+ const MAX_FINISHED_TASKS = 20;
33
+ // ---------------------------------------------------------------------------
34
+ // Manager
35
+ // ---------------------------------------------------------------------------
36
+ export class ProcessManager {
37
+ registry = new Map();
38
+ nextServerId = 1;
39
+ nextTaskId = 1;
40
+ /** Bumped on task start/finish/kill — the reminder's state-change gate (design §2.3a). */
41
+ taskStateVersion = 0;
42
+ changeListeners = new Set();
43
+ finishListeners = new Set();
44
+ // -- tasks ----------------------------------------------------------------
45
+ getTaskStateVersion() {
46
+ return this.taskStateVersion;
47
+ }
48
+ startTask(input) {
49
+ if (!existsSync(input.cwd)) {
50
+ throw new Error(`Working directory does not exist: ${input.cwd}`);
51
+ }
52
+ const command = input.command.trim();
53
+ if (!command)
54
+ throw new Error("command is required");
55
+ // Atomic reserve (design §2.2d): the cap check and record insertion are
56
+ // synchronous with no await between them; spawn failures roll back below.
57
+ this.reserveTaskSlot(input.ownerSessionId);
58
+ const shell = platform() === "win32" ? "cmd.exe" : "bash";
59
+ const shellArgs = platform() === "win32" ? ["/c", command] : ["-c", command];
60
+ let child;
61
+ try {
62
+ child = spawn(shell, shellArgs, {
63
+ cwd: input.cwd,
64
+ stdio: ["ignore", "pipe", "pipe"],
65
+ env: process.env,
66
+ detached: platform() !== "win32",
67
+ windowsHide: true,
68
+ });
69
+ }
70
+ catch (error) {
71
+ throw error instanceof Error ? error : new Error(String(error));
72
+ }
73
+ return this.registerTaskChild(child, { ...input, command });
74
+ }
75
+ /** Ctrl+G promotion: adopt a child the bash tool already spawned (§2.5). */
76
+ adoptTask(input) {
77
+ this.reserveTaskSlot(input.ownerSessionId);
78
+ return this.registerTaskChild(input.child, input, {
79
+ outputSoFar: input.outputSoFar,
80
+ startedAt: input.startedAt,
81
+ });
82
+ }
83
+ reserveTaskSlot(ownerSessionId) {
84
+ const running = this.taskRecords().filter((task) => task.status === "running" && task.ownerSessionId === ownerSessionId);
85
+ if (running.length >= MAX_RUNNING_TASKS_PER_SESSION) {
86
+ throw new Error(`Background task limit reached (${MAX_RUNNING_TASKS_PER_SESSION} running). `
87
+ + "Wait for one to finish with task_output, or kill one with kill_task.");
88
+ }
89
+ }
90
+ registerTaskChild(child, input, seed) {
91
+ const id = `task_${String(this.nextTaskId++).padStart(4, "0")}`;
92
+ const record = {
93
+ kind: "task",
94
+ id,
95
+ command: input.command,
96
+ description: input.description,
97
+ cwd: input.cwd,
98
+ pid: child.pid,
99
+ status: "running",
100
+ startedAt: seed?.startedAt ?? Date.now(),
101
+ output: seed?.outputSoFar ?? "",
102
+ outputTruncated: false,
103
+ ownerSessionId: input.ownerSessionId,
104
+ child,
105
+ };
106
+ this.registry.set(id, record);
107
+ const append = (data) => {
108
+ const before = record.output;
109
+ record.output = appendTailCapped(record.output, data.toString(), MAX_LOG_BYTES);
110
+ if (!record.outputTruncated && before.length + data.length > record.output.length) {
111
+ record.outputTruncated = true;
112
+ }
113
+ };
114
+ child.stdout?.on("data", append);
115
+ child.stderr?.on("data", append);
116
+ child.once("error", (error) => {
117
+ record.output = appendTailCapped(record.output, `\n[task failed to start: ${error.message}]\n`, MAX_LOG_BYTES);
118
+ this.finalizeTask(record, "failed", null);
119
+ });
120
+ child.once("exit", (code) => {
121
+ if (record.status === "running") {
122
+ this.finalizeTask(record, code === 0 ? "completed" : "failed", code);
123
+ }
124
+ });
125
+ this.bumpTaskState(record);
126
+ return this.publicTask(record);
127
+ }
128
+ finalizeTask(record, status, exitCode) {
129
+ if (record.status !== "running")
130
+ return;
131
+ record.status = status;
132
+ record.exitCode = exitCode;
133
+ record.endedAt = Date.now();
134
+ record.child = undefined;
135
+ this.evictFinishedTasks();
136
+ this.bumpTaskState(record);
137
+ const snapshot = this.publicTask(record);
138
+ for (const listener of this.finishListeners) {
139
+ try {
140
+ listener(snapshot);
141
+ }
142
+ catch {
143
+ // Listener failures must never break task lifecycle.
144
+ }
145
+ }
146
+ }
147
+ getTask(id) {
148
+ const record = this.registry.get(id);
149
+ if (!record || record.kind !== "task")
150
+ return undefined;
151
+ return this.publicTask(record);
152
+ }
153
+ listTasks(ownerSessionId) {
154
+ return this.taskRecords()
155
+ .filter((task) => ownerSessionId === undefined || task.ownerSessionId === ownerSessionId)
156
+ .map((task) => this.publicTask(task));
157
+ }
158
+ taskOutputTail(id, maxChars = 12000) {
159
+ const record = this.registry.get(id);
160
+ if (!record || record.kind !== "task")
161
+ return undefined;
162
+ // Single choke point for every task-output consumer (wake, reminder,
163
+ // task_output tool, inspector): strip ANSI color noise before tailing.
164
+ return tail(stripAnsi(record.output), maxChars);
165
+ }
166
+ markTaskDelivered(id) {
167
+ const record = this.registry.get(id);
168
+ if (!record || record.kind !== "task")
169
+ return;
170
+ if (record.deliveredAt === undefined) {
171
+ record.deliveredAt = Date.now();
172
+ // Delivered tasks drop their output to bound memory (design §2.2d);
173
+ // keep the metadata line for the transcript/reminder demotion.
174
+ if (record.status !== "running")
175
+ record.output = "";
176
+ }
177
+ }
178
+ async waitTasks(ids, options = {}) {
179
+ const timeoutMs = options.timeoutMs ?? 30000;
180
+ const mode = options.mode ?? "any";
181
+ const wanted = new Set(ids);
182
+ const satisfied = () => {
183
+ const finished = ids.filter((id) => {
184
+ const task = this.registry.get(id);
185
+ return task?.kind === "task" && task.status !== "running";
186
+ });
187
+ return mode === "all" ? finished.length === wanted.size : finished.length > 0;
188
+ };
189
+ if (!satisfied() && timeoutMs > 0) {
190
+ await new Promise((resolve) => {
191
+ let settled = false;
192
+ const finish = () => {
193
+ if (settled)
194
+ return;
195
+ settled = true;
196
+ this.finishListeners.delete(onFinish);
197
+ clearTimeout(timer);
198
+ resolve();
199
+ };
200
+ const onFinish = (task) => {
201
+ if (wanted.has(task.id) && satisfied())
202
+ finish();
203
+ };
204
+ const timer = setTimeout(finish, timeoutMs);
205
+ timer.unref?.();
206
+ this.finishListeners.add(onFinish);
207
+ });
208
+ }
209
+ return ids
210
+ .map((id) => this.getTask(id))
211
+ .filter((task) => task !== undefined);
212
+ }
213
+ async killTask(id) {
214
+ const record = this.registry.get(id);
215
+ if (!record || record.kind !== "task")
216
+ return undefined;
217
+ if (record.status !== "running")
218
+ return this.publicTask(record);
219
+ const pid = record.child?.pid ?? record.pid;
220
+ // Mark first so the exit listener does not double-finalize as failed.
221
+ this.finalizeTask(record, "killed", null);
222
+ if (pid) {
223
+ killProcessTree(pid, "SIGTERM");
224
+ const forceTimer = setTimeout(() => killProcessTree(pid, "SIGKILL"), STOP_FORCE_AFTER_MS);
225
+ forceTimer.unref?.();
226
+ }
227
+ return this.publicTask(record);
228
+ }
229
+ /** Graceful reaping for shutdownRuntime (design §2.2b layer 1). */
230
+ async shutdownTasks() {
231
+ const running = this.taskRecords().filter((task) => task.status === "running");
232
+ if (running.length === 0)
233
+ return;
234
+ for (const task of running) {
235
+ const pid = task.child?.pid ?? task.pid;
236
+ if (pid)
237
+ killProcessTree(pid, "SIGTERM");
238
+ }
239
+ await new Promise((resolve) => setTimeout(resolve, STOP_FORCE_AFTER_MS));
240
+ for (const task of running) {
241
+ const pid = task.child?.pid ?? task.pid;
242
+ if (pid)
243
+ killProcessTree(pid, "SIGKILL");
244
+ if (task.status === "running")
245
+ this.finalizeTask(task, "killed", null);
246
+ }
247
+ }
248
+ /** SIGKILL-only reaping for synchronous signal/exit paths (§2.2b layers 2-3). */
249
+ reapTasksSync() {
250
+ for (const task of this.taskRecords()) {
251
+ if (task.status !== "running")
252
+ continue;
253
+ const pid = task.child?.pid ?? task.pid;
254
+ if (pid)
255
+ killProcessTree(pid, "SIGKILL");
256
+ task.status = "killed";
257
+ task.endedAt = Date.now();
258
+ task.child = undefined;
259
+ }
260
+ }
261
+ onTaskFinished(listener) {
262
+ this.finishListeners.add(listener);
263
+ return () => this.finishListeners.delete(listener);
264
+ }
265
+ onChange(listener) {
266
+ this.changeListeners.add(listener);
267
+ return () => this.changeListeners.delete(listener);
268
+ }
269
+ bumpTaskState(record) {
270
+ this.taskStateVersion += 1;
271
+ const snapshot = this.publicTask(record);
272
+ for (const listener of this.changeListeners) {
273
+ try {
274
+ listener(snapshot);
275
+ }
276
+ catch {
277
+ // Listener failures must never break task lifecycle.
278
+ }
279
+ }
280
+ }
281
+ evictFinishedTasks() {
282
+ const finished = this.taskRecords()
283
+ .filter((task) => task.status !== "running")
284
+ .sort((a, b) => (a.endedAt ?? 0) - (b.endedAt ?? 0));
285
+ // Evict oldest DELIVERED first; fall back to oldest finished overall.
286
+ while (finished.length > MAX_FINISHED_TASKS) {
287
+ const victim = finished.find((task) => task.deliveredAt !== undefined) ?? finished[0];
288
+ finished.splice(finished.indexOf(victim), 1);
289
+ this.registry.delete(victim.id);
290
+ }
291
+ }
292
+ taskRecords() {
293
+ return [...this.registry.values()].filter((record) => record.kind === "task");
294
+ }
295
+ publicTask(record) {
296
+ const { child: _child, output: _output, ...info } = record;
297
+ return { ...info };
298
+ }
299
+ // -- servers (moved verbatim from server-manager.ts, kind-filtered) --------
300
+ async startManagedServer(input) {
301
+ if (!existsSync(input.cwd)) {
302
+ throw new Error(`Working directory does not exist: ${input.cwd}`);
303
+ }
304
+ const command = input.command.trim();
305
+ if (!command) {
306
+ throw new Error("command is required");
307
+ }
308
+ if (input.port !== undefined) {
309
+ const managed = this.serverRecords().find((server) => server.port === input.port && isActiveStatus(server.status));
310
+ if (managed) {
311
+ throw new Error(`Port ${input.port} is already managed by ${managed.id}. Stop it before starting another server.`);
312
+ }
313
+ if (await isPortOpen(input.port)) {
314
+ throw new Error(`Port ${input.port} is already in use by an unmanaged process.`);
315
+ }
316
+ }
317
+ const shell = platform() === "win32" ? "cmd.exe" : "bash";
318
+ const shellArgs = platform() === "win32" ? ["/c", command] : ["-c", command];
319
+ const child = spawn(shell, shellArgs, {
320
+ cwd: input.cwd,
321
+ stdio: ["ignore", "pipe", "pipe"],
322
+ env: process.env,
323
+ detached: platform() !== "win32",
324
+ windowsHide: true,
325
+ });
326
+ child.unref();
327
+ unrefStream(child.stdout);
328
+ unrefStream(child.stderr);
329
+ const now = Date.now();
330
+ const id = `server_${String(this.nextServerId++).padStart(4, "0")}`;
331
+ const readinessUrl = input.readinessUrl ?? (input.port ? `http://localhost:${input.port}` : undefined);
332
+ const record = {
333
+ kind: "server",
334
+ id,
335
+ command,
336
+ cwd: input.cwd,
337
+ ...(input.port !== undefined ? { port: input.port } : {}),
338
+ ...(readinessUrl ? { url: readinessUrl } : {}),
339
+ ownerSessionId: input.ownerSessionId,
340
+ ownerRunId: input.ownerRunId,
341
+ purpose: input.purpose ?? "preview",
342
+ lifecycle: input.lifecycle ?? "keep_alive",
343
+ startedAt: now,
344
+ lastUsedAt: now,
345
+ status: "starting",
346
+ pid: child.pid,
347
+ child,
348
+ logs: "",
349
+ };
350
+ this.registry.set(id, record);
351
+ child.stdout.on("data", (data) => appendServerLog(record, data.toString()));
352
+ child.stderr.on("data", (data) => appendServerLog(record, data.toString()));
353
+ child.once("error", (error) => {
354
+ record.status = "failed";
355
+ appendServerLog(record, `\n[server failed: ${error.message}]\n`);
356
+ });
357
+ child.once("exit", (code) => {
358
+ record.exitCode = code;
359
+ if (record.status !== "stopped") {
360
+ record.status = code === 0 ? "exited" : "failed";
361
+ }
362
+ record.child = undefined;
363
+ });
364
+ const timeoutSec = input.timeoutSec ?? 30;
365
+ const ready = readinessUrl
366
+ ? await waitForReadiness(record, readinessUrl, timeoutSec)
367
+ : await waitForProcessToStayAlive(record, Math.min(timeoutSec, 2));
368
+ if (!ready) {
369
+ const logs = record.logs.trim();
370
+ await this.stopManagedServer(id);
371
+ throw new Error(`Server ${id} did not become ready within ${timeoutSec}s.${logs ? `\n\nLogs:\n${tail(logs, 4000)}` : ""}`);
372
+ }
373
+ record.status = readinessUrl ? "ready" : "running";
374
+ record.lastUsedAt = Date.now();
375
+ return publicServerInfo(record);
376
+ }
377
+ listManagedServers() {
378
+ return this.serverRecords().map(publicServerInfo);
379
+ }
380
+ getManagedServer(id) {
381
+ const server = this.registry.get(id);
382
+ if (!server || server.kind !== "server")
383
+ return undefined;
384
+ server.lastUsedAt = Date.now();
385
+ return publicServerInfo(server);
386
+ }
387
+ getManagedServerLogs(id, maxChars = 12000) {
388
+ const server = this.registry.get(id);
389
+ if (!server || server.kind !== "server")
390
+ return undefined;
391
+ server.lastUsedAt = Date.now();
392
+ return tail(server.logs, maxChars);
393
+ }
394
+ async stopManagedServer(id) {
395
+ const server = this.registry.get(id);
396
+ if (!server || server.kind !== "server")
397
+ return undefined;
398
+ server.status = "stopped";
399
+ server.lastUsedAt = Date.now();
400
+ const child = server.child;
401
+ if (child?.pid) {
402
+ killProcessTree(child.pid, "SIGTERM");
403
+ await new Promise((resolve) => setTimeout(resolve, STOP_FORCE_AFTER_MS));
404
+ killProcessTree(child.pid, "SIGKILL");
405
+ }
406
+ server.child = undefined;
407
+ return publicServerInfo(server);
408
+ }
409
+ async stopAutoServersForSession(sessionID) {
410
+ if (!sessionID)
411
+ return;
412
+ const owned = this.serverRecords().filter((server) => server.ownerSessionId === sessionID && server.lifecycle === "auto" && isActiveStatus(server.status));
413
+ await Promise.all(owned.map((server) => this.stopManagedServer(server.id)));
414
+ }
415
+ /** process.once("exit") backstop (§2.2b layer 3). */
416
+ reapOnExit() {
417
+ for (const record of this.registry.values()) {
418
+ if (record.kind === "task") {
419
+ if (record.status === "running") {
420
+ const pid = record.child?.pid ?? record.pid;
421
+ if (pid)
422
+ killProcessTree(pid, "SIGKILL");
423
+ }
424
+ continue;
425
+ }
426
+ if (record.child?.pid && record.lifecycle !== "keep_alive") {
427
+ killProcessTree(record.child.pid, "SIGKILL");
428
+ }
429
+ }
430
+ }
431
+ serverRecords() {
432
+ return [...this.registry.values()].filter((record) => record.kind === "server");
433
+ }
434
+ }
435
+ // ---------------------------------------------------------------------------
436
+ // Process-wide instance + compatibility surface
437
+ // ---------------------------------------------------------------------------
438
+ // One registry per process (exactly the scope the old module-level server Map
439
+ // had). main.ts passes this instance down explicitly; the server-manager shim
440
+ // delegates to it so existing imports keep working.
441
+ const defaultManager = new ProcessManager();
442
+ export function getProcessManager() {
443
+ return defaultManager;
444
+ }
445
+ export const startManagedServer = defaultManager.startManagedServer.bind(defaultManager);
446
+ export const listManagedServers = defaultManager.listManagedServers.bind(defaultManager);
447
+ export const getManagedServer = defaultManager.getManagedServer.bind(defaultManager);
448
+ export const getManagedServerLogs = defaultManager.getManagedServerLogs.bind(defaultManager);
449
+ export const stopManagedServer = defaultManager.stopManagedServer.bind(defaultManager);
450
+ export const stopAutoServersForSession = defaultManager.stopAutoServersForSession.bind(defaultManager);
451
+ process.once("exit", () => {
452
+ defaultManager.reapOnExit();
453
+ });
454
+ // ---------------------------------------------------------------------------
455
+ // Server internals (moved verbatim)
456
+ // ---------------------------------------------------------------------------
457
+ function publicServerInfo(server) {
458
+ return {
459
+ id: server.id,
460
+ command: server.command,
461
+ cwd: server.cwd,
462
+ port: server.port,
463
+ url: server.url,
464
+ ownerSessionId: server.ownerSessionId,
465
+ ownerRunId: server.ownerRunId,
466
+ purpose: server.purpose,
467
+ lifecycle: server.lifecycle,
468
+ startedAt: server.startedAt,
469
+ lastUsedAt: server.lastUsedAt,
470
+ status: server.status,
471
+ pid: server.pid,
472
+ exitCode: server.exitCode,
473
+ };
474
+ }
475
+ function appendServerLog(server, chunk) {
476
+ server.logs = appendTailCapped(server.logs, chunk, MAX_LOG_BYTES);
477
+ }
478
+ function unrefStream(stream) {
479
+ stream.unref?.();
480
+ }
481
+ async function waitForReadiness(server, url, timeoutSec) {
482
+ const deadline = Date.now() + timeoutSec * 1000;
483
+ while (Date.now() < deadline) {
484
+ if (!isActiveStatus(server.status))
485
+ return false;
486
+ if (await canFetch(url))
487
+ return true;
488
+ await new Promise((resolve) => setTimeout(resolve, 250));
489
+ }
490
+ return false;
491
+ }
492
+ async function waitForProcessToStayAlive(server, timeoutSec) {
493
+ const deadline = Date.now() + timeoutSec * 1000;
494
+ while (Date.now() < deadline) {
495
+ if (!isActiveStatus(server.status))
496
+ return false;
497
+ await new Promise((resolve) => setTimeout(resolve, 100));
498
+ }
499
+ return isActiveStatus(server.status);
500
+ }
501
+ async function canFetch(url) {
502
+ const controller = new AbortController();
503
+ const timeout = setTimeout(() => controller.abort(), 1000);
504
+ try {
505
+ const response = await fetch(url, { signal: controller.signal });
506
+ return response.status < 500;
507
+ }
508
+ catch {
509
+ return false;
510
+ }
511
+ finally {
512
+ clearTimeout(timeout);
513
+ }
514
+ }
515
+ function isActiveStatus(status) {
516
+ return status === "starting" || status === "ready" || status === "running";
517
+ }
518
+ async function isPortOpen(port) {
519
+ return await new Promise((resolve) => {
520
+ const socket = net.createConnection({ host: "127.0.0.1", port });
521
+ const done = (open) => {
522
+ socket.removeAllListeners();
523
+ socket.destroy();
524
+ resolve(open);
525
+ };
526
+ socket.once("connect", () => done(true));
527
+ socket.once("error", () => done(false));
528
+ socket.setTimeout(250, () => done(false));
529
+ });
530
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Shared child-process mechanics for the unified process manager (background
3
+ * tasks + managed servers) and the bash tool: process-tree kill with Windows
4
+ * fallback, tail-keeping capped log buffers.
5
+ */
6
+ export declare function killProcessTree(pid: number, signal: NodeJS.Signals): void;
7
+ /** Keep the LAST maxBytes of a growing log (ring-buffer semantics). */
8
+ export declare function appendTailCapped(current: string, chunk: string, maxBytes: number): string;
9
+ export declare function tail(value: string, maxChars: number): string;
10
+ export declare function stripAnsi(value: string): string;