@bubblebrain-ai/bubble 0.0.37 → 0.0.38

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 (51) hide show
  1. package/dist/agent/internal-reminder-sanitizer.js +2 -0
  2. package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
  3. package/dist/agent/task-lifecycle-reminder.js +59 -0
  4. package/dist/agent.d.ts +26 -0
  5. package/dist/agent.js +39 -0
  6. package/dist/approval/controller.d.ts +3 -1
  7. package/dist/approval/controller.js +5 -2
  8. package/dist/approval/tool-helper.js +10 -1
  9. package/dist/approval/types.d.ts +15 -0
  10. package/dist/config.d.ts +6 -0
  11. package/dist/config.js +4 -0
  12. package/dist/loop/engine.d.ts +29 -0
  13. package/dist/loop/engine.js +63 -0
  14. package/dist/main.js +29 -0
  15. package/dist/orchestrator/default-hooks.js +16 -0
  16. package/dist/session-types.d.ts +1 -1
  17. package/dist/tasks/manager.d.ts +136 -0
  18. package/dist/tasks/manager.js +530 -0
  19. package/dist/tasks/process-utils.d.ts +10 -0
  20. package/dist/tasks/process-utils.js +49 -0
  21. package/dist/tasks/promotion.d.ts +17 -0
  22. package/dist/tasks/promotion.js +30 -0
  23. package/dist/tasks/wake.d.ts +53 -0
  24. package/dist/tasks/wake.js +125 -0
  25. package/dist/tools/bash.d.ts +15 -1
  26. package/dist/tools/bash.js +103 -3
  27. package/dist/tools/child-tools.js +3 -1
  28. package/dist/tools/edit.js +4 -12
  29. package/dist/tools/index.d.ts +13 -0
  30. package/dist/tools/index.js +8 -1
  31. package/dist/tools/server-manager.d.ts +6 -36
  32. package/dist/tools/server-manager.js +6 -234
  33. package/dist/tools/task-tools.d.ts +10 -0
  34. package/dist/tools/task-tools.js +129 -0
  35. package/dist/tools/write.js +6 -12
  36. package/dist/tui-ink/app.d.ts +20 -2
  37. package/dist/tui-ink/app.js +307 -19
  38. package/dist/tui-ink/approval/approval-dialog.js +4 -4
  39. package/dist/tui-ink/display-history.d.ts +1 -1
  40. package/dist/tui-ink/footer.d.ts +3 -3
  41. package/dist/tui-ink/footer.js +3 -3
  42. package/dist/tui-ink/input-box.d.ts +6 -0
  43. package/dist/tui-ink/markdown.d.ts +10 -2
  44. package/dist/tui-ink/markdown.js +13 -12
  45. package/dist/tui-ink/message-list.js +14 -3
  46. package/dist/tui-ink/run.d.ts +6 -0
  47. package/dist/tui-ink/run.js +1 -1
  48. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  49. package/dist/tui-ink/subagent-inspector.js +23 -7
  50. package/dist/tui-ink/welcome.js +4 -0
  51. package/package.json +1 -1
@@ -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;
@@ -0,0 +1,49 @@
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
+ import { spawn } from "node:child_process";
7
+ import { platform } from "node:os";
8
+ export function killProcessTree(pid, signal) {
9
+ if (platform() === "win32") {
10
+ try {
11
+ spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
12
+ stdio: "ignore",
13
+ windowsHide: true,
14
+ });
15
+ }
16
+ catch {
17
+ // Process may already be gone or taskkill may be unavailable.
18
+ }
19
+ return;
20
+ }
21
+ try {
22
+ process.kill(-pid, signal);
23
+ }
24
+ catch {
25
+ try {
26
+ process.kill(pid, signal);
27
+ }
28
+ catch {
29
+ // Process already exited.
30
+ }
31
+ }
32
+ }
33
+ /** Keep the LAST maxBytes of a growing log (ring-buffer semantics). */
34
+ export function appendTailCapped(current, chunk, maxBytes) {
35
+ const next = current + chunk;
36
+ if (Buffer.byteLength(next, "utf-8") <= maxBytes)
37
+ return next;
38
+ return Buffer.from(next, "utf-8").subarray(-maxBytes).toString("utf-8");
39
+ }
40
+ export function tail(value, maxChars) {
41
+ return value.length <= maxChars ? value : value.slice(-maxChars);
42
+ }
43
+ // Test runners color their output; raw escape sequences in a model-facing
44
+ // tail are token noise (observed with vitest in the first field test).
45
+ // eslint-disable-next-line no-control-regex
46
+ const ANSI_PATTERN = /\u001b\[[0-9;?]*[a-zA-Z]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?/g;
47
+ export function stripAnsi(value) {
48
+ return value.replace(ANSI_PATTERN, "");
49
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Ctrl+G send-to-background promotion channel (background-tasks design §2.5).
3
+ *
4
+ * A running foreground bash tool call registers a promotion handler keyed by
5
+ * its toolCall id at spawn; the TUI requests promotion when the user presses
6
+ * Ctrl+G. The handler returns the new task id on success, or undefined when
7
+ * it is too late (the command already reached a terminal state — exit,
8
+ * timeout, cancel — a benign race, not an error).
9
+ */
10
+ export type PromotionHandler = () => string | undefined;
11
+ export declare class PromotionChannel {
12
+ private readonly handlers;
13
+ register(toolCallId: string, handler: PromotionHandler): () => void;
14
+ /** Returns the promoted task id, or undefined when nothing was promotable. */
15
+ requestPromotion(toolCallId: string): string | undefined;
16
+ hasPromotable(toolCallId: string): boolean;
17
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Ctrl+G send-to-background promotion channel (background-tasks design §2.5).
3
+ *
4
+ * A running foreground bash tool call registers a promotion handler keyed by
5
+ * its toolCall id at spawn; the TUI requests promotion when the user presses
6
+ * Ctrl+G. The handler returns the new task id on success, or undefined when
7
+ * it is too late (the command already reached a terminal state — exit,
8
+ * timeout, cancel — a benign race, not an error).
9
+ */
10
+ export class PromotionChannel {
11
+ handlers = new Map();
12
+ register(toolCallId, handler) {
13
+ this.handlers.set(toolCallId, handler);
14
+ return () => {
15
+ if (this.handlers.get(toolCallId) === handler) {
16
+ this.handlers.delete(toolCallId);
17
+ }
18
+ };
19
+ }
20
+ /** Returns the promoted task id, or undefined when nothing was promotable. */
21
+ requestPromotion(toolCallId) {
22
+ const handler = this.handlers.get(toolCallId);
23
+ if (!handler)
24
+ return undefined;
25
+ return handler();
26
+ }
27
+ hasPromotable(toolCallId) {
28
+ return this.handlers.has(toolCallId);
29
+ }
30
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Auto-resume wake decision + coalescing (background-tasks design §2.3b).
3
+ *
4
+ * Kept pure and out of the TUI so the guard rails are unit-testable:
5
+ * - debounce-before-fire coalescing (the idle input queue drains in ~0ms, so
6
+ * merging can only happen BEFORE the wake is submitted);
7
+ * - user preemption (queued user input or a running turn suppresses the wake
8
+ * — the state-change reminder covers the completion on the next turn);
9
+ * - config kill switch (tasks.auto_resume = false);
10
+ * - killed tasks never wake (killing is an explicit decision, not news).
11
+ */
12
+ import type { BackgroundTaskInfo } from "./manager.js";
13
+ export interface TaskWakeGateInput {
14
+ autoResume: boolean;
15
+ turnRunning: boolean;
16
+ queuedInputs: number;
17
+ exiting?: boolean;
18
+ }
19
+ export declare function shouldFireTaskWake(input: TaskWakeGateInput): boolean;
20
+ export declare function taskEligibleForWake(task: BackgroundTaskInfo): boolean;
21
+ export declare function formatTaskWakeSummary(tasks: BackgroundTaskInfo[], outputTail: (id: string) => string | undefined): string;
22
+ export interface DanglingTaskStart {
23
+ id: string;
24
+ pid?: number;
25
+ startedAt?: number;
26
+ command?: string;
27
+ description?: string;
28
+ }
29
+ /**
30
+ * Resume-time orphan detection (design §2.2c): task_started markers with no
31
+ * matching task_finished/task_killed belong to a previous process. The caller
32
+ * probes pid liveness before reporting.
33
+ */
34
+ export declare function findDanglingTaskStarts(entries: Array<{
35
+ type: string;
36
+ kind?: string;
37
+ value?: string;
38
+ }>): DanglingTaskStart[];
39
+ export declare function isPidAlive(pid: number | undefined): boolean;
40
+ /**
41
+ * Debounce-then-fire coalescer: the first eligible completion arms a timer;
42
+ * completions inside the window fold into one wake payload.
43
+ */
44
+ export declare class TaskWakeCoalescer {
45
+ private readonly delayMs;
46
+ private readonly fire;
47
+ private pending;
48
+ private timer;
49
+ constructor(delayMs: number, fire: (tasks: BackgroundTaskInfo[]) => void);
50
+ add(task: BackgroundTaskInfo): void;
51
+ /** Drop everything armed (session switch / exit). Returns what was pending. */
52
+ cancel(): BackgroundTaskInfo[];
53
+ }