@bubblebrain-ai/bubble 0.0.36 → 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 (52) 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 +309 -21
  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 +18 -7
  46. package/dist/tui-ink/model-picker.js +33 -10
  47. package/dist/tui-ink/run.d.ts +6 -0
  48. package/dist/tui-ink/run.js +1 -1
  49. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  50. package/dist/tui-ink/subagent-inspector.js +23 -7
  51. package/dist/tui-ink/welcome.js +4 -0
  52. package/package.json +1 -1
@@ -167,6 +167,8 @@ const INTERNAL_REASONING_REFERENCE_PATTERNS = [
167
167
  /Subagent lifecycle truth/i,
168
168
  /Count unique agent_id values only/i,
169
169
  /Do not describe a subagent as running or still working/i,
170
+ /Background task truth/i,
171
+ /Never re-run work a finished task already did/i,
170
172
  ];
171
173
  function consumeInternalBlockAtStart(text, final) {
172
174
  if (text.startsWith(INTERNAL_TAG_PREFIX)) {
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Background-task lifecycle reminder (background-tasks design §2.3a).
3
+ *
4
+ * Emission is STATE-CHANGE gated, never per-model-call: the reminder channel
5
+ * is append-only (each injection is a persistent meta message that survives
6
+ * pruning and compaction), so the caller compares the manager's task state
7
+ * version and builds a reminder only when the owned task set changed since
8
+ * the last emission. This mirrors the subagent lifecycle reminder's activity
9
+ * gate rather than firing while tasks are merely alive.
10
+ *
11
+ * Because compaction preserves meta messages but drops session markers, this
12
+ * reminder is also how the model recovers task ids after /compact.
13
+ */
14
+ import type { BackgroundTaskInfo } from "../tasks/manager.js";
15
+ export interface TaskReminderInput {
16
+ tasks: BackgroundTaskInfo[];
17
+ outputTail: (id: string) => string | undefined;
18
+ /**
19
+ * The task tools are already unlocked (the spawn path calls
20
+ * unlockDeferredTools). When true the tool_search hint is dropped — field
21
+ * test 2026-07-19 showed the unconditional hint makes the model burn a
22
+ * tool_search call for tools it already has.
23
+ */
24
+ toolsAvailable?: boolean;
25
+ }
26
+ export declare function buildTaskLifecycleReminder({ tasks, outputTail, toolsAvailable }: TaskReminderInput): string | undefined;
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Background-task lifecycle reminder (background-tasks design §2.3a).
3
+ *
4
+ * Emission is STATE-CHANGE gated, never per-model-call: the reminder channel
5
+ * is append-only (each injection is a persistent meta message that survives
6
+ * pruning and compaction), so the caller compares the manager's task state
7
+ * version and builds a reminder only when the owned task set changed since
8
+ * the last emission. This mirrors the subagent lifecycle reminder's activity
9
+ * gate rather than firing while tasks are merely alive.
10
+ *
11
+ * Because compaction preserves meta messages but drops session markers, this
12
+ * reminder is also how the model recovers task ids after /compact.
13
+ */
14
+ const OUTPUT_TAIL_CHARS = 2000;
15
+ export function buildTaskLifecycleReminder({ tasks, outputTail, toolsAvailable }) {
16
+ if (tasks.length === 0)
17
+ return undefined;
18
+ const running = tasks.filter((task) => task.status === "running");
19
+ const finishedUndelivered = tasks.filter((task) => task.status !== "running" && task.deliveredAt === undefined);
20
+ const finishedDelivered = tasks.filter((task) => task.status !== "running" && task.deliveredAt !== undefined);
21
+ if (running.length === 0 && finishedUndelivered.length === 0 && finishedDelivered.length === 0) {
22
+ return undefined;
23
+ }
24
+ const lines = ["Background task truth:"];
25
+ for (const task of running) {
26
+ const elapsed = Math.round((Date.now() - task.startedAt) / 1000);
27
+ lines.push(`- running: ${task.id} (${label(task)}, ${elapsed}s elapsed)`);
28
+ }
29
+ for (const task of finishedUndelivered) {
30
+ const elapsed = Math.round(((task.endedAt ?? Date.now()) - task.startedAt) / 1000);
31
+ lines.push(`- finished since last update: ${task.id} (${label(task)}) ${task.status}${task.exitCode != null ? ` exit ${task.exitCode}` : ""} in ${elapsed}s`);
32
+ const tail = outputTail(task.id)?.trim();
33
+ if (tail) {
34
+ lines.push(` output tail:`);
35
+ lines.push(...clampTail(tail).split("\n").map((line) => ` ${line}`));
36
+ lines.push(" Call task_output only if you need more of the output.");
37
+ }
38
+ }
39
+ // Delivered final results already reached the model in full — id + status
40
+ // only, never the output again (mirrors the subagent delivered demotion).
41
+ for (const task of finishedDelivered) {
42
+ lines.push(`- delivered: ${task.id} ${task.status}${task.exitCode != null ? ` (exit ${task.exitCode})` : ""}`);
43
+ }
44
+ lines.push(toolsAvailable
45
+ ? "- Check tasks with task_output; stop them with kill_task. Both are already in your tool list — do NOT call tool_search for them."
46
+ : "- Check tasks with task_output; stop them with kill_task (deferred: load via tool_search with query 'select:task_output,kill_task' if not yet available).");
47
+ lines.push("- Never re-run work a finished task already did; never poll with foreground sleep.");
48
+ return lines.join("\n");
49
+ }
50
+ function label(task) {
51
+ return task.description?.trim() || truncate(task.command, 60);
52
+ }
53
+ function clampTail(tail) {
54
+ return tail.length <= OUTPUT_TAIL_CHARS ? tail : `…${tail.slice(-OUTPUT_TAIL_CHARS)}`;
55
+ }
56
+ function truncate(value, max) {
57
+ const oneLine = value.replace(/\s+/g, " ").trim();
58
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
59
+ }
package/dist/agent.d.ts CHANGED
@@ -101,6 +101,17 @@ export declare class Agent {
101
101
  messages: Message[];
102
102
  private provider;
103
103
  private sessionID?;
104
+ /**
105
+ * Bridge to the unified process manager's background tasks, wired by the
106
+ * host (main.ts) when the host supports them (background-tasks design
107
+ * §2.3a). list() is pre-filtered to the agent's bound session.
108
+ */
109
+ backgroundTasks?: {
110
+ list: () => import("./tasks/manager.js").BackgroundTaskInfo[];
111
+ version: () => number;
112
+ outputTail: (id: string) => string | undefined;
113
+ };
114
+ private lastTaskReminderVersion;
104
115
  private _providerId;
105
116
  private _model;
106
117
  private tools;
@@ -165,6 +176,21 @@ export declare class Agent {
165
176
  * the prompt's tool list.
166
177
  */
167
178
  injectDeferredToolsReminder(): void;
179
+ /**
180
+ * Rebinds the agent to a new session (TUI session switch). Keeps
181
+ * ownerSessionId on newly spawned background tasks and managed servers
182
+ * accurate (background-tasks design §2.2c).
183
+ */
184
+ setSessionID(sessionID: string | undefined): void;
185
+ getSessionID(): string | undefined;
186
+ /**
187
+ * Background-task reminder, state-change gated (design §2.3a): returns a
188
+ * reminder only when the owned task set changed since the last emission
189
+ * (started/finished/killed). The reminder channel is append-only, so
190
+ * emitting on every model call while tasks are merely alive would stack
191
+ * persistent duplicates that neither pruning nor compaction removes.
192
+ */
193
+ consumeBackgroundTaskReminder(): string | undefined;
168
194
  private runExternalHook;
169
195
  private injectHookModelContext;
170
196
  /** Whether a tool is registered on this agent (e.g. delegation tools on parents). */
package/dist/agent.js CHANGED
@@ -19,6 +19,7 @@ import { buildDeferredToolsReminder, buildToolFreezeReminder, reminderForMode }
19
19
  import { HookBus } from "./orchestrator/hooks.js";
20
20
  import { normalizeHookInput, truncateHookText, } from "./hooks/index.js";
21
21
  import { createDefaultHooks } from "./orchestrator/default-hooks.js";
22
+ import { buildTaskLifecycleReminder } from "./agent/task-lifecycle-reminder.js";
22
23
  import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
23
24
  import { DEFAULT_AGENT_ROUTING, nearModelMatches, sanitizeAgentRouting, tierContextFromSnapshot } from "./agent/routing-catalog.js";
24
25
  import { buildModelRoutingPrompt } from "./prompt/routing.js";
@@ -100,6 +101,13 @@ export class Agent {
100
101
  messages = [];
101
102
  provider;
102
103
  sessionID;
104
+ /**
105
+ * Bridge to the unified process manager's background tasks, wired by the
106
+ * host (main.ts) when the host supports them (background-tasks design
107
+ * §2.3a). list() is pre-filtered to the agent's bound session.
108
+ */
109
+ backgroundTasks;
110
+ lastTaskReminderVersion = -1;
103
111
  _providerId;
104
112
  _model;
105
113
  tools = new Map();
@@ -261,6 +269,37 @@ export class Agent {
261
269
  this.injectSystemReminder(buildDeferredToolsReminder(deferredNames));
262
270
  }
263
271
  }
272
+ /**
273
+ * Rebinds the agent to a new session (TUI session switch). Keeps
274
+ * ownerSessionId on newly spawned background tasks and managed servers
275
+ * accurate (background-tasks design §2.2c).
276
+ */
277
+ setSessionID(sessionID) {
278
+ this.sessionID = sessionID;
279
+ }
280
+ getSessionID() {
281
+ return this.sessionID;
282
+ }
283
+ /**
284
+ * Background-task reminder, state-change gated (design §2.3a): returns a
285
+ * reminder only when the owned task set changed since the last emission
286
+ * (started/finished/killed). The reminder channel is append-only, so
287
+ * emitting on every model call while tasks are merely alive would stack
288
+ * persistent duplicates that neither pruning nor compaction removes.
289
+ */
290
+ consumeBackgroundTaskReminder() {
291
+ if (!this.backgroundTasks)
292
+ return undefined;
293
+ const version = this.backgroundTasks.version();
294
+ if (version === this.lastTaskReminderVersion)
295
+ return undefined;
296
+ this.lastTaskReminderVersion = version;
297
+ return buildTaskLifecycleReminder({
298
+ tasks: this.backgroundTasks.list(),
299
+ outputTail: (id) => this.backgroundTasks?.outputTail(id),
300
+ toolsAvailable: this.unlockedDeferred?.has("task_output") ?? false,
301
+ });
302
+ }
264
303
  async runExternalHook(request, abortSignal) {
265
304
  const events = [];
266
305
  if (!this.externalHooks) {
@@ -33,7 +33,9 @@ export interface ApprovalControllerOptions {
33
33
  *
34
34
  * deny rule match → reject (applies even under bypassPermissions)
35
35
  * bypassPermissions → auto-approve, no prompt
36
- * default + edit|write → auto-approve
36
+ * default + edit|write → auto-approve (workspace paths only; a
37
+ * path outside the workspace falls through
38
+ * to allow rules / the interactive prompt)
37
39
  * plan → reject with instructions to use exit_plan_mode
38
40
  * allow rule match → auto-approve
39
41
  * bash in session allowlist → auto-approve
@@ -5,7 +5,9 @@ import { truncateHookText } from "../hooks/index.js";
5
5
  *
6
6
  * deny rule match → reject (applies even under bypassPermissions)
7
7
  * bypassPermissions → auto-approve, no prompt
8
- * default + edit|write → auto-approve
8
+ * default + edit|write → auto-approve (workspace paths only; a
9
+ * path outside the workspace falls through
10
+ * to allow rules / the interactive prompt)
9
11
  * plan → reject with instructions to use exit_plan_mode
10
12
  * allow rule match → auto-approve
11
13
  * bash in session allowlist → auto-approve
@@ -46,7 +48,8 @@ export class PermissionAwareApprovalController {
46
48
  if (mode === "bypassPermissions") {
47
49
  return finalize({ action: "approve" });
48
50
  }
49
- if (mode === "default" && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
51
+ const outsideWorkspace = (req.type === "edit" || req.type === "write") && req.outsideWorkspace === true;
52
+ if (mode === "default" && !outsideWorkspace && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
50
53
  return finalize({ action: "approve" });
51
54
  }
52
55
  // Project profile trust is a user decision, not a destructive action:
@@ -16,7 +16,16 @@ export async function gateToolAction(approval, req) {
16
16
  const message = feedback
17
17
  ? `${label} was rejected by the user. User feedback: ${feedback}`
18
18
  : `${label} was rejected by the user.`;
19
- return { approved: false, result: { content: message, isError: true } };
19
+ return {
20
+ approved: false,
21
+ result: {
22
+ content: message,
23
+ isError: true,
24
+ // Surfaces the rejection reason in subagent tool notes (which prefer
25
+ // metadata.reason over the truncated first content line).
26
+ metadata: { kind: "security", reason: feedback || message },
27
+ },
28
+ };
20
29
  }
21
30
  function approvalRequestLabel(req) {
22
31
  switch (req.type) {
@@ -12,6 +12,12 @@ export interface EditApprovalRequest {
12
12
  /** Unified patch (with context lines) to show the user. */
13
13
  diff: string;
14
14
  fileExists: boolean;
15
+ /**
16
+ * The path resolves outside the session workspace. Escalates the request
17
+ * past default-mode auto-approval so the user explicitly confirms it
18
+ * (bypassPermissions still auto-approves, consistent with bash).
19
+ */
20
+ outsideWorkspace?: boolean;
15
21
  }
16
22
  export interface WriteApprovalRequest {
17
23
  type: "write";
@@ -21,6 +27,8 @@ export interface WriteApprovalRequest {
21
27
  /** Unified patch from existing contents to pending contents. */
22
28
  diff?: string;
23
29
  fileExists: boolean;
30
+ /** See EditApprovalRequest.outsideWorkspace. */
31
+ outsideWorkspace?: boolean;
24
32
  }
25
33
  export interface PatchApprovalRequest {
26
34
  type: "patch";
@@ -39,6 +47,13 @@ export interface BashApprovalRequest {
39
47
  type: "bash";
40
48
  command: string;
41
49
  cwd: string;
50
+ /**
51
+ * The command will run as a background task that outlives the turn
52
+ * (background-tasks design §2.4). Shown in the approval dialog and passed
53
+ * to permission hooks so org policy can discriminate; allow rules, the
54
+ * session allowlist, and bypass approve it like foreground by design.
55
+ */
56
+ background?: boolean;
42
57
  }
43
58
  export interface LspApprovalRequest {
44
59
  type: "lsp";
package/dist/config.d.ts CHANGED
@@ -35,6 +35,10 @@ export interface UserConfigData {
35
35
  /** Subagent model-routing knobs: autoTier (default true), allowCrossProvider (default true). */
36
36
  agentRouting?: Partial<AgentRoutingConfig>;
37
37
  subagents?: SubagentsUserConfig;
38
+ /** Background tasks (design §2.3b): autoResume defaults ON; false keeps notices + reminders only. */
39
+ tasks?: {
40
+ autoResume?: boolean;
41
+ };
38
42
  }
39
43
  export interface SubagentsUserConfig {
40
44
  /** Global cap on concurrently running children. Default 8. */
@@ -50,6 +54,8 @@ export declare class UserConfig {
50
54
  getDefaultThinkingLevel(): ThinkingLevel | undefined;
51
55
  setDefaultThinkingLevel(level: ThinkingLevel): void;
52
56
  getRecentModels(): string[];
57
+ /** Auto-resume on background-task completion (design §2.3b). Default ON. */
58
+ getTasksAutoResume(): boolean;
53
59
  pushRecentModel(model: string): void;
54
60
  getApiKey(): string | undefined;
55
61
  setApiKey(key: string): void;
package/dist/config.js CHANGED
@@ -139,6 +139,10 @@ export class UserConfig {
139
139
  getRecentModels() {
140
140
  return sanitizeRecentModels(this.data.recentModels)?.slice() ?? [];
141
141
  }
142
+ /** Auto-resume on background-task completion (design §2.3b). Default ON. */
143
+ getTasksAutoResume() {
144
+ return this.data.tasks?.autoResume !== false;
145
+ }
142
146
  pushRecentModel(model) {
143
147
  if (isHiddenProviderId(modelProviderId(model))) {
144
148
  return;
@@ -0,0 +1,29 @@
1
+ /**
2
+ * /loop — recurring prompts (background-tasks design §2.6).
3
+ *
4
+ * Pure decision logic kept out of the TUI, mirroring goal/engine.ts: the TUI
5
+ * owns a 1s ticker and calls decideLoopFiring per loop; this module owns
6
+ * parsing, caps, and the defer-not-stack rule (a firing due while a turn is
7
+ * running reschedules with a notice instead of piling up).
8
+ */
9
+ export interface LoopState {
10
+ id: number;
11
+ prompt: string;
12
+ intervalMs: number;
13
+ nextFireAt: number;
14
+ fires: number;
15
+ }
16
+ export declare const MAX_ACTIVE_LOOPS = 5;
17
+ export declare const MIN_LOOP_INTERVAL_MS = 60000;
18
+ export interface ParsedLoopCommand {
19
+ action: "start" | "list" | "stop" | "help";
20
+ intervalMs?: number;
21
+ prompt?: string;
22
+ stopId?: number;
23
+ error?: string;
24
+ }
25
+ export declare function parseLoopCommand(raw: string): ParsedLoopCommand;
26
+ export type LoopFiringDecision = "fire" | "defer" | "wait";
27
+ export declare function decideLoopFiring(loop: LoopState, now: number, turnRunning: boolean): LoopFiringDecision;
28
+ export declare function formatLoopList(loops: LoopState[], now: number): string;
29
+ export declare function formatInterval(intervalMs: number): string;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * /loop — recurring prompts (background-tasks design §2.6).
3
+ *
4
+ * Pure decision logic kept out of the TUI, mirroring goal/engine.ts: the TUI
5
+ * owns a 1s ticker and calls decideLoopFiring per loop; this module owns
6
+ * parsing, caps, and the defer-not-stack rule (a firing due while a turn is
7
+ * running reschedules with a notice instead of piling up).
8
+ */
9
+ export const MAX_ACTIVE_LOOPS = 5;
10
+ export const MIN_LOOP_INTERVAL_MS = 60_000;
11
+ export function parseLoopCommand(raw) {
12
+ const rest = raw.trim().replace(/^\/loop\s*/, "").trim();
13
+ if (!rest)
14
+ return { action: "help" };
15
+ if (rest === "list")
16
+ return { action: "list" };
17
+ const stop = rest.match(/^stop(?:\s+(\d+))?$/);
18
+ if (stop) {
19
+ return { action: "stop", stopId: stop[1] ? Number(stop[1]) : undefined };
20
+ }
21
+ const match = rest.match(/^(\d+)([smh])\s+(.+)$/s);
22
+ if (!match) {
23
+ return {
24
+ action: "start",
25
+ error: "Usage: /loop <interval> <prompt> — interval like 90s, 5m, 1h (min 60s). Also: /loop list, /loop stop [id].",
26
+ };
27
+ }
28
+ const value = Number(match[1]);
29
+ const unit = match[2];
30
+ const multiplier = unit === "s" ? 1000 : unit === "m" ? 60_000 : 3_600_000;
31
+ const intervalMs = Math.max(MIN_LOOP_INTERVAL_MS, value * multiplier);
32
+ const prompt = match[3].trim();
33
+ if (!prompt) {
34
+ return { action: "start", error: "Usage: /loop <interval> <prompt> — the prompt is required." };
35
+ }
36
+ return { action: "start", intervalMs, prompt };
37
+ }
38
+ export function decideLoopFiring(loop, now, turnRunning) {
39
+ if (now < loop.nextFireAt)
40
+ return "wait";
41
+ return turnRunning ? "defer" : "fire";
42
+ }
43
+ export function formatLoopList(loops, now) {
44
+ if (loops.length === 0)
45
+ return "No active loops. Start one with /loop <interval> <prompt>.";
46
+ return loops
47
+ .map((loop) => {
48
+ const nextIn = Math.max(0, Math.round((loop.nextFireAt - now) / 1000));
49
+ return `#${loop.id} every ${formatInterval(loop.intervalMs)} · fired ${loop.fires}x · next in ${nextIn}s · ${truncate(loop.prompt, 60)}`;
50
+ })
51
+ .join("\n");
52
+ }
53
+ export function formatInterval(intervalMs) {
54
+ if (intervalMs % 3_600_000 === 0)
55
+ return `${intervalMs / 3_600_000}h`;
56
+ if (intervalMs % 60_000 === 0)
57
+ return `${intervalMs / 60_000}m`;
58
+ return `${Math.round(intervalMs / 1000)}s`;
59
+ }
60
+ function truncate(value, max) {
61
+ const oneLine = value.replace(/\s+/g, " ").trim();
62
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 1)}…`;
63
+ }
package/dist/main.js CHANGED
@@ -18,6 +18,8 @@ import { createRoutableModelIndex, createRoutingSnapshotAccessor } from "./agent
18
18
  import { buildModelRoutingPrompt } from "./prompt/routing.js";
19
19
  import { SkillRegistry } from "./skills/registry.js";
20
20
  import { buildToolPromptOptions, createAllTools } from "./tools/index.js";
21
+ import { getProcessManager } from "./tasks/manager.js";
22
+ import { PromotionChannel } from "./tasks/promotion.js";
21
23
  import { FileStateTracker } from "./tools/file-state.js";
22
24
  import { GoalStore } from "./goal/store.js";
23
25
  import { PermissionAwareApprovalController } from "./approval/controller.js";
@@ -165,6 +167,13 @@ async function main() {
165
167
  // Shared between the goal tool (model-facing update_goal) and the
166
168
  // TUI's auto-continuation engine / status-line indicator.
167
169
  const goalStore = new GoalStore();
170
+ // Background tasks are TUI-only (design §2.0): print mode exits (and reaps)
171
+ // right after the single run, so a backgrounded command would be killed
172
+ // immediately after the model was told it started. Feishu/desktop hosts
173
+ // never reach this call site and stay off until they wire wake seams.
174
+ const processManager = getProcessManager();
175
+ const allowBackgroundTasks = !printMode;
176
+ const promotionChannel = new PromotionChannel();
168
177
  const tools = createAllTools(args.cwd, skillRegistry, {
169
178
  todoStore,
170
179
  planController,
@@ -174,6 +183,9 @@ async function main() {
174
183
  lspService,
175
184
  fileStateTracker,
176
185
  goalStore,
186
+ processManager,
187
+ allowBackgroundTasks,
188
+ promotionChannel,
177
189
  // Lazy: sessionManager is resolved after tools are created.
178
190
  checkpoints: () => sessionManager?.getCheckpoints(),
179
191
  });
@@ -222,6 +234,9 @@ async function main() {
222
234
  }
223
235
  };
224
236
  const shutdownSidecars = async () => {
237
+ // Signal path (design §2.2b layer 2): kill-only, no escalation wait —
238
+ // the process is about to exit and shutdownRuntime never runs here.
239
+ processManager.reapTasksSync();
225
240
  await Promise.allSettled([shutdownMcp(), shutdownExternalRuntime()]);
226
241
  };
227
242
  process.once("SIGINT", () => { void shutdownSidecars().then(() => process.exit(130)); });
@@ -433,6 +448,15 @@ async function main() {
433
448
  externalHooks: hookController,
434
449
  });
435
450
  agentRef = agent;
451
+ if (allowBackgroundTasks) {
452
+ // Reminder bridge (design §2.3a): list() is owner-filtered at call time so
453
+ // the reminder never leaks another session's tasks after a switch.
454
+ agent.backgroundTasks = {
455
+ list: () => processManager.listTasks(agent.getSessionID()),
456
+ version: () => processManager.getTaskStateVersion(),
457
+ outputTail: (id) => processManager.taskOutputTail(id, 2000),
458
+ };
459
+ }
436
460
  if (sessionManager) {
437
461
  sessionTitleUpdater = createSessionTitleUpdater({
438
462
  sessionManager,
@@ -486,6 +510,8 @@ async function main() {
486
510
  shutdownMcp(),
487
511
  shutdownExternalRuntime(),
488
512
  lspService.shutdown(),
513
+ // Graceful task reaping (design §2.2b layer 1): SIGTERM → SIGKILL.
514
+ processManager.shutdownTasks(),
489
515
  ]);
490
516
  for (const result of results) {
491
517
  if (result.status === "rejected") {
@@ -653,6 +679,9 @@ async function main() {
653
679
  runMemoryCompaction,
654
680
  runMemorySummary,
655
681
  runMemoryRefresh,
682
+ processManager,
683
+ tasksAutoResume: userConfig.getTasksAutoResume(),
684
+ promotionChannel,
656
685
  };
657
686
  // Grok Subscription is a first-class interactive provider target. Manager
658
687
  // construction is deliberately lazy: it does not inspect, spawn, or reach
@@ -53,6 +53,14 @@ export function createDefaultHooks() {
53
53
  ctx.state.workflowPhase = "investigate";
54
54
  ctx.state.workflowKey = "";
55
55
  }
56
+ // Background-task truth at turn start: completions that landed while
57
+ // the agent was idle (or during another session's turn) reach the
58
+ // model here; the same state-change gate prevents duplicates with the
59
+ // beforeContinuation emission (background-tasks design §2.3a).
60
+ const backgroundTaskReminder = ctx.agent.consumeBackgroundTaskReminder?.();
61
+ if (backgroundTaskReminder) {
62
+ ctx.queueReminder(backgroundTaskReminder);
63
+ }
56
64
  for (const reminder of ctx.state.governor.consumePendingReminders()) {
57
65
  ctx.queueReminder(reminder);
58
66
  }
@@ -167,6 +175,14 @@ export function createDefaultHooks() {
167
175
  ctx.queueReminder(reminder);
168
176
  }
169
177
  }
178
+ // Background-task truth: state-change gated inside the agent (a task
179
+ // started/finished/killed since the last emission), so a mid-turn
180
+ // completion reaches the model without stacking per-call duplicates
181
+ // (background-tasks design §2.3a).
182
+ const taskReminder = ctx.agent.consumeBackgroundTaskReminder?.();
183
+ if (taskReminder) {
184
+ ctx.queueReminder(taskReminder);
185
+ }
170
186
  // Routing detector note (model-routing design §6): rides the same
171
187
  // channel as the lifecycle reminder, once per session. Consuming also
172
188
  // closes the per-fan-out counting window.
@@ -31,7 +31,7 @@ export interface SessionMetadata {
31
31
  */
32
32
  externalRuntime?: ExternalRuntimeSessionMetadata;
33
33
  }
34
- export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch" | "runtime_switch" | "conversation_clear";
34
+ export type SessionMarkerKind = "model_switch" | "provider_switch" | "thinking_level_switch" | "skill_activated" | "mode_switch" | "runtime_switch" | "conversation_clear" | "task_started" | "task_finished" | "task_killed";
35
35
  interface BaseSessionLogEntry {
36
36
  id: string;
37
37
  timestamp: number;
@@ -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>;