@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.
- package/dist/agent/internal-reminder-sanitizer.js +4 -0
- package/dist/agent/profiles.d.ts +6 -0
- package/dist/agent/profiles.js +32 -0
- package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
- package/dist/agent/task-lifecycle-reminder.js +59 -0
- package/dist/agent.d.ts +43 -0
- package/dist/agent.js +70 -0
- package/dist/approval/controller.d.ts +3 -1
- package/dist/approval/controller.js +5 -2
- package/dist/approval/tool-helper.js +10 -1
- package/dist/approval/types.d.ts +15 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +11 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.js +4 -0
- package/dist/loop/engine.d.ts +29 -0
- package/dist/loop/engine.js +63 -0
- package/dist/main.js +92 -24
- package/dist/orchestrator/default-hooks.js +94 -1
- package/dist/orchestrator/hooks.d.ts +7 -0
- package/dist/print-output.d.ts +54 -0
- package/dist/print-output.js +103 -0
- package/dist/prompt/delegation.js +6 -3
- package/dist/prompt/task-reminders.d.ts +17 -0
- package/dist/prompt/task-reminders.js +15 -0
- package/dist/session-types.d.ts +1 -1
- package/dist/tasks/manager.d.ts +136 -0
- package/dist/tasks/manager.js +530 -0
- package/dist/tasks/process-utils.d.ts +10 -0
- package/dist/tasks/process-utils.js +49 -0
- package/dist/tasks/promotion.d.ts +17 -0
- package/dist/tasks/promotion.js +30 -0
- package/dist/tasks/wake.d.ts +53 -0
- package/dist/tasks/wake.js +125 -0
- package/dist/tools/bash.d.ts +15 -1
- package/dist/tools/bash.js +103 -3
- package/dist/tools/child-tools.js +3 -1
- package/dist/tools/edit.js +4 -12
- package/dist/tools/index.d.ts +13 -0
- package/dist/tools/index.js +8 -1
- package/dist/tools/server-manager.d.ts +6 -36
- package/dist/tools/server-manager.js +6 -234
- package/dist/tools/task-tools.d.ts +10 -0
- package/dist/tools/task-tools.js +129 -0
- package/dist/tools/write.js +6 -12
- package/dist/tui-ink/app.d.ts +20 -2
- package/dist/tui-ink/app.js +307 -19
- package/dist/tui-ink/approval/approval-dialog.js +4 -4
- package/dist/tui-ink/display-history.d.ts +1 -1
- package/dist/tui-ink/footer.d.ts +3 -3
- package/dist/tui-ink/footer.js +3 -3
- package/dist/tui-ink/input-box.d.ts +6 -0
- package/dist/tui-ink/markdown.d.ts +10 -2
- package/dist/tui-ink/markdown.js +13 -12
- package/dist/tui-ink/message-list.js +14 -3
- package/dist/tui-ink/run.d.ts +6 -0
- package/dist/tui-ink/run.js +1 -1
- package/dist/tui-ink/subagent-inspector.d.ts +5 -1
- package/dist/tui-ink/subagent-inspector.js +23 -7
- package/dist/tui-ink/welcome.js +4 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
export function shouldFireTaskWake(input) {
|
|
13
|
+
if (!input.autoResume)
|
|
14
|
+
return false;
|
|
15
|
+
if (input.exiting)
|
|
16
|
+
return false;
|
|
17
|
+
if (input.turnRunning)
|
|
18
|
+
return false;
|
|
19
|
+
if (input.queuedInputs > 0)
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
export function taskEligibleForWake(task) {
|
|
24
|
+
return task.status === "completed" || task.status === "failed";
|
|
25
|
+
}
|
|
26
|
+
export function formatTaskWakeSummary(tasks, outputTail) {
|
|
27
|
+
const lines = [
|
|
28
|
+
tasks.length === 1
|
|
29
|
+
? "A background task you started has finished."
|
|
30
|
+
: `${tasks.length} background tasks you started have finished.`,
|
|
31
|
+
];
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
const elapsed = Math.round(((task.endedAt ?? Date.now()) - task.startedAt) / 1000);
|
|
34
|
+
lines.push(`- ${task.id}${task.description ? ` (${task.description})` : ""}: ${task.status}${task.exitCode != null ? ` exit ${task.exitCode}` : ""} in ${elapsed}s`);
|
|
35
|
+
lines.push(` command: ${task.command}`);
|
|
36
|
+
const tail = outputTail(task.id)?.trim();
|
|
37
|
+
if (tail) {
|
|
38
|
+
lines.push(" output tail:");
|
|
39
|
+
lines.push(...tail.split("\n").slice(-30).map((line) => ` ${line}`));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Field-test lesson (2026-07-19): an unconditional "continue the work"
|
|
43
|
+
// nudge pushed the model to invent follow-up work when nothing was left.
|
|
44
|
+
lines.push("If unfinished work depends on these results, continue it now. "
|
|
45
|
+
+ "If nothing does, report the outcome to the user in one or two sentences and stop — do not invent new work. "
|
|
46
|
+
+ "Never re-run what already finished.");
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Resume-time orphan detection (design §2.2c): task_started markers with no
|
|
51
|
+
* matching task_finished/task_killed belong to a previous process. The caller
|
|
52
|
+
* probes pid liveness before reporting.
|
|
53
|
+
*/
|
|
54
|
+
export function findDanglingTaskStarts(entries) {
|
|
55
|
+
const started = new Map();
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.type !== "marker" || !entry.value)
|
|
58
|
+
continue;
|
|
59
|
+
if (entry.kind === "task_started") {
|
|
60
|
+
const parsed = parseMarker(entry.value);
|
|
61
|
+
if (parsed?.id)
|
|
62
|
+
started.set(parsed.id, parsed);
|
|
63
|
+
}
|
|
64
|
+
else if (entry.kind === "task_finished" || entry.kind === "task_killed") {
|
|
65
|
+
const parsed = parseMarker(entry.value);
|
|
66
|
+
if (parsed?.id)
|
|
67
|
+
started.delete(parsed.id);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return [...started.values()];
|
|
71
|
+
}
|
|
72
|
+
export function isPidAlive(pid) {
|
|
73
|
+
if (!pid)
|
|
74
|
+
return false;
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function parseMarker(value) {
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(value);
|
|
86
|
+
return parsed && typeof parsed === "object" ? parsed : undefined;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Debounce-then-fire coalescer: the first eligible completion arms a timer;
|
|
94
|
+
* completions inside the window fold into one wake payload.
|
|
95
|
+
*/
|
|
96
|
+
export class TaskWakeCoalescer {
|
|
97
|
+
delayMs;
|
|
98
|
+
fire;
|
|
99
|
+
pending = [];
|
|
100
|
+
timer;
|
|
101
|
+
constructor(delayMs, fire) {
|
|
102
|
+
this.delayMs = delayMs;
|
|
103
|
+
this.fire = fire;
|
|
104
|
+
}
|
|
105
|
+
add(task) {
|
|
106
|
+
this.pending.push(task);
|
|
107
|
+
if (this.timer)
|
|
108
|
+
return;
|
|
109
|
+
this.timer = setTimeout(() => {
|
|
110
|
+
this.timer = undefined;
|
|
111
|
+
const tasks = this.pending.splice(0);
|
|
112
|
+
if (tasks.length > 0)
|
|
113
|
+
this.fire(tasks);
|
|
114
|
+
}, this.delayMs);
|
|
115
|
+
this.timer.unref?.();
|
|
116
|
+
}
|
|
117
|
+
/** Drop everything armed (session switch / exit). Returns what was pending. */
|
|
118
|
+
cancel() {
|
|
119
|
+
if (this.timer) {
|
|
120
|
+
clearTimeout(this.timer);
|
|
121
|
+
this.timer = undefined;
|
|
122
|
+
}
|
|
123
|
+
return this.pending.splice(0);
|
|
124
|
+
}
|
|
125
|
+
}
|
package/dist/tools/bash.d.ts
CHANGED
|
@@ -4,4 +4,18 @@
|
|
|
4
4
|
import type { ApprovalController } from "../approval/types.js";
|
|
5
5
|
import type { ToolRegistryEntry } from "../types.js";
|
|
6
6
|
import type { FileStateTracker } from "./file-state.js";
|
|
7
|
-
|
|
7
|
+
import type { ProcessManager } from "../tasks/manager.js";
|
|
8
|
+
import type { PromotionChannel } from "../tasks/promotion.js";
|
|
9
|
+
export interface BashToolOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Background tasks are a per-host capability (background-tasks design
|
|
12
|
+
* §2.0): only the interactive TUI wires the manager AND enables the flag.
|
|
13
|
+
* Print mode, feishu/desktop hosts, and worktree/workflow children leave
|
|
14
|
+
* this off, so run_in_background is rejected there with a clear error.
|
|
15
|
+
*/
|
|
16
|
+
processManager?: ProcessManager;
|
|
17
|
+
allowBackgroundTasks?: boolean;
|
|
18
|
+
/** Ctrl+G send-to-background requests, keyed by toolCall id (design §2.5). */
|
|
19
|
+
promotionChannel?: PromotionChannel;
|
|
20
|
+
}
|
|
21
|
+
export declare function createBashTool(cwd: string, approval?: ApprovalController, _fileState?: FileStateTracker, options?: BashToolOptions): ToolRegistryEntry;
|
package/dist/tools/bash.js
CHANGED
|
@@ -11,12 +11,17 @@ const MAX_OUTPUT = 50 * 1024;
|
|
|
11
11
|
const POST_EXIT_STDIO_GRACE_MS = 150;
|
|
12
12
|
const FORCE_KILL_AFTER_MS = 750;
|
|
13
13
|
const ABORT_SETTLE_AFTER_MS = 1500;
|
|
14
|
-
export function createBashTool(cwd, approval, _fileState) {
|
|
14
|
+
export function createBashTool(cwd, approval, _fileState, options = {}) {
|
|
15
|
+
const backgroundEnabled = options.allowBackgroundTasks === true && !!options.processManager;
|
|
15
16
|
return {
|
|
16
17
|
name: "bash",
|
|
17
18
|
effect: "unknown",
|
|
18
19
|
requiresApproval: true,
|
|
19
|
-
description: "Execute a bounded bash command in the working directory. Use timeout for long-running commands.
|
|
20
|
+
description: "Execute a bounded bash command in the working directory. Use timeout for long-running commands. "
|
|
21
|
+
+ (backgroundEnabled
|
|
22
|
+
? "For long one-shot commands with a definite end (full test suites, release builds, downloads), pass run_in_background: true — the command keeps running while you continue working and you are notified when it finishes; never poll with foreground sleep. "
|
|
23
|
+
: "")
|
|
24
|
+
+ "For persistent dev servers or watchers such as npm run dev, next dev, vite, or webpack --watch, use the managed server tools instead of backgrounding a bash command (deferred: load them first via tool_search with query 'select:start_server,server_status,server_logs,stop_server').",
|
|
20
25
|
parameters: {
|
|
21
26
|
type: "object",
|
|
22
27
|
properties: {
|
|
@@ -26,6 +31,14 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
26
31
|
description: "One short sentence (5-10 words) describing what this command does, shown to the user in the UI. Write it in the same language the user is conversing in.",
|
|
27
32
|
},
|
|
28
33
|
timeout: { type: "number", description: "Timeout in seconds (optional)" },
|
|
34
|
+
...(backgroundEnabled
|
|
35
|
+
? {
|
|
36
|
+
run_in_background: {
|
|
37
|
+
type: "boolean",
|
|
38
|
+
description: "Run as a background task: returns a task id immediately and the command keeps running while you continue. Only for long one-shot commands with a definite end; not for quick commands or persistent servers.",
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
: {}),
|
|
29
42
|
},
|
|
30
43
|
required: ["command"],
|
|
31
44
|
},
|
|
@@ -35,6 +48,7 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
35
48
|
}
|
|
36
49
|
const command = String(args.command);
|
|
37
50
|
const timeoutSec = typeof args.timeout === "number" ? args.timeout : 60;
|
|
51
|
+
const runInBackground = args.run_in_background === true;
|
|
38
52
|
const parsedSearch = parseSearchBashCommand(command);
|
|
39
53
|
const parsedRead = parsedSearch ? undefined : parseReadBashCommand(command);
|
|
40
54
|
if (referencesSensitivePath(command)) {
|
|
@@ -48,12 +62,50 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
48
62
|
},
|
|
49
63
|
};
|
|
50
64
|
}
|
|
51
|
-
|
|
65
|
+
if (runInBackground && !backgroundEnabled) {
|
|
66
|
+
return {
|
|
67
|
+
content: "Error: run_in_background is unavailable in this session (non-interactive host or subagent). "
|
|
68
|
+
+ "Run the command in the foreground with an explicit timeout instead.",
|
|
69
|
+
isError: true,
|
|
70
|
+
status: "blocked",
|
|
71
|
+
metadata: { kind: "shell", reason: "background_unavailable" },
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
const gate = await gateToolAction(approval, {
|
|
75
|
+
type: "bash",
|
|
76
|
+
command,
|
|
77
|
+
cwd,
|
|
78
|
+
...(runInBackground ? { background: true } : {}),
|
|
79
|
+
});
|
|
52
80
|
if (!gate.approved)
|
|
53
81
|
return gate.result;
|
|
82
|
+
if (runInBackground) {
|
|
83
|
+
try {
|
|
84
|
+
const task = options.processManager.startTask({
|
|
85
|
+
command,
|
|
86
|
+
description: typeof args.description === "string" ? args.description : undefined,
|
|
87
|
+
cwd: ctx.cwd || cwd,
|
|
88
|
+
ownerSessionId: ctx.sessionID,
|
|
89
|
+
});
|
|
90
|
+
// Make the read/kill path available immediately — no tool_search hop
|
|
91
|
+
// (design §2.1: programmatic unlock at first spawn).
|
|
92
|
+
ctx.agent?.unlockDeferredTools?.(["task_output", "kill_task"]);
|
|
93
|
+
return {
|
|
94
|
+
content: `Started background task ${task.id}${task.description ? ` (${task.description})` : ""}. `
|
|
95
|
+
+ "It runs while you continue working; you will be notified when it finishes. "
|
|
96
|
+
+ "Use task_output to check on it, kill_task to stop it.",
|
|
97
|
+
status: "success",
|
|
98
|
+
metadata: { kind: "shell", command, taskId: task.id, background: true },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return { content: `Error: ${error?.message ?? String(error)}`, isError: true };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
54
105
|
return new Promise((resolve) => {
|
|
55
106
|
const shell = platform() === "win32" ? "cmd.exe" : "bash";
|
|
56
107
|
const shellArgs = platform() === "win32" ? ["/c", command] : ["-c", command];
|
|
108
|
+
const spawnedAt = Date.now();
|
|
57
109
|
const child = spawn(shell, shellArgs, {
|
|
58
110
|
cwd,
|
|
59
111
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -63,6 +115,8 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
63
115
|
});
|
|
64
116
|
let stdout = "";
|
|
65
117
|
let stderr = "";
|
|
118
|
+
let promotedTaskId;
|
|
119
|
+
let unregisterPromotion;
|
|
66
120
|
let stdoutTruncated = false;
|
|
67
121
|
let stderrTruncated = false;
|
|
68
122
|
let stdoutEnded = child.stdout === null;
|
|
@@ -127,6 +181,7 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
127
181
|
if (postExitHandle)
|
|
128
182
|
clearTimeout(postExitHandle);
|
|
129
183
|
ctx.abortSignal?.removeEventListener("abort", abortChild);
|
|
184
|
+
unregisterPromotion?.();
|
|
130
185
|
child.stdout?.removeListener("data", onStdoutData);
|
|
131
186
|
child.stderr?.removeListener("data", onStderrData);
|
|
132
187
|
child.stdout?.removeListener("end", onStdoutEnd);
|
|
@@ -152,6 +207,17 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
152
207
|
return;
|
|
153
208
|
resolved = true;
|
|
154
209
|
cleanup();
|
|
210
|
+
if (terminal === "promoted") {
|
|
211
|
+
// Ctrl+G promotion (design §2.5): the process lives on under the
|
|
212
|
+
// task manager — skip the group kill AND the stream destruction
|
|
213
|
+
// (the manager attached its own data listeners in adoptTask).
|
|
214
|
+
resolve({
|
|
215
|
+
content: buildOutput(`[Moved to background: ${promotedTaskId}. The command keeps running; check it with task_output.]`),
|
|
216
|
+
status: "success",
|
|
217
|
+
metadata: { kind: "shell", command, taskId: promotedTaskId, background: true },
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
155
221
|
cleanupBackgroundGroup();
|
|
156
222
|
destroyStreams();
|
|
157
223
|
if (terminal === "error") {
|
|
@@ -281,6 +347,40 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
281
347
|
terminal = "exit";
|
|
282
348
|
finish();
|
|
283
349
|
};
|
|
350
|
+
// Ctrl+G promotion (design §2.5): flag-flip into the task manager.
|
|
351
|
+
// Registered only for background-capable hosts; a no-op once any
|
|
352
|
+
// terminal state is set (benign race with exit/timeout/cancel).
|
|
353
|
+
const promoteToBackground = () => {
|
|
354
|
+
if (resolved || terminal)
|
|
355
|
+
return undefined;
|
|
356
|
+
if (!options.processManager)
|
|
357
|
+
return undefined;
|
|
358
|
+
try {
|
|
359
|
+
const task = options.processManager.adoptTask({
|
|
360
|
+
command,
|
|
361
|
+
description: typeof args.description === "string" ? args.description : undefined,
|
|
362
|
+
cwd: ctx.cwd || cwd,
|
|
363
|
+
ownerSessionId: ctx.sessionID,
|
|
364
|
+
child,
|
|
365
|
+
outputSoFar: [stdout && `stdout:\n${stdout}`, stderr && `stderr:\n${stderr}`]
|
|
366
|
+
.filter(Boolean)
|
|
367
|
+
.join(""),
|
|
368
|
+
startedAt: spawnedAt,
|
|
369
|
+
});
|
|
370
|
+
promotedTaskId = task.id;
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
// Task cap reached or adopt failed — keep running in the foreground.
|
|
374
|
+
return undefined;
|
|
375
|
+
}
|
|
376
|
+
terminal = "promoted";
|
|
377
|
+
ctx.agent?.unlockDeferredTools?.(["task_output", "kill_task"]);
|
|
378
|
+
finish();
|
|
379
|
+
return promotedTaskId;
|
|
380
|
+
};
|
|
381
|
+
if (backgroundEnabled && options.promotionChannel && ctx.toolCall?.id) {
|
|
382
|
+
unregisterPromotion = options.promotionChannel.register(ctx.toolCall.id, promoteToBackground);
|
|
383
|
+
}
|
|
284
384
|
timeoutHandle = setTimeout(timeoutChild, timeoutSec * 1000);
|
|
285
385
|
if (ctx.abortSignal?.aborted)
|
|
286
386
|
abortChild();
|
|
@@ -65,9 +65,11 @@ export class WorktreeApprovalController {
|
|
|
65
65
|
}
|
|
66
66
|
case "edit":
|
|
67
67
|
case "write":
|
|
68
|
+
// Reason before the path: subagent tool notes truncate long lines,
|
|
69
|
+
// and a long absolute path would push the reason off the end.
|
|
68
70
|
return isPathInsideWorktree(this.worktreeRoot, req.path)
|
|
69
71
|
? { action: "approve" }
|
|
70
|
-
: { action: "reject", feedback: `Blocked by worktree policy:
|
|
72
|
+
: { action: "reject", feedback: `Blocked by worktree policy: the path is outside the subagent worktree (${req.path}).` };
|
|
71
73
|
case "patch":
|
|
72
74
|
return req.paths.every((path) => isPathInsideWorktree(this.worktreeRoot, path))
|
|
73
75
|
? { action: "approve" }
|
package/dist/tools/edit.js
CHANGED
|
@@ -114,18 +114,9 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
116
|
const filePath = resolveToolPath(cwd, args.path);
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
isError: true,
|
|
121
|
-
status: "blocked",
|
|
122
|
-
metadata: {
|
|
123
|
-
kind: "security",
|
|
124
|
-
path: filePath,
|
|
125
|
-
reason: "Edit path is outside the workspace.",
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|
|
117
|
+
// Outside-workspace paths escalate the approval request instead of
|
|
118
|
+
// being blocked outright — see the matching note in write.ts.
|
|
119
|
+
const outsideWorkspace = !isWithinWorkspace(cwd, filePath);
|
|
129
120
|
return withFileMutationQueue(filePath, async () => {
|
|
130
121
|
try {
|
|
131
122
|
await access(filePath, constants.R_OK | constants.W_OK);
|
|
@@ -161,6 +152,7 @@ export function createEditTool(cwd, approval, lsp, fileState, checkpoints) {
|
|
|
161
152
|
path: filePath,
|
|
162
153
|
diff,
|
|
163
154
|
fileExists: true,
|
|
155
|
+
outsideWorkspace,
|
|
164
156
|
});
|
|
165
157
|
if (!gate.approved)
|
|
166
158
|
return gate.result;
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -31,6 +31,8 @@ import type { QuestionController } from "../question/index.js";
|
|
|
31
31
|
import type { CheckpointStore } from "../checkpoints.js";
|
|
32
32
|
import { FileStateTracker } from "./file-state.js";
|
|
33
33
|
import type { GoalStore } from "../goal/store.js";
|
|
34
|
+
import type { ProcessManager } from "../tasks/manager.js";
|
|
35
|
+
import type { PromotionChannel } from "../tasks/promotion.js";
|
|
34
36
|
export interface CreateAllToolsOptions {
|
|
35
37
|
todoStore?: TodoStore;
|
|
36
38
|
planController?: PlanController;
|
|
@@ -47,5 +49,16 @@ export interface CreateAllToolsOptions {
|
|
|
47
49
|
checkpoints?: () => CheckpointStore | undefined;
|
|
48
50
|
/** Shared goal state; when present, registers the update_goal tool. */
|
|
49
51
|
goalStore?: GoalStore;
|
|
52
|
+
/**
|
|
53
|
+
* Unified process manager (background tasks + managed servers). Background
|
|
54
|
+
* tasks are a per-host capability (background-tasks design §2.0): pass the
|
|
55
|
+
* manager AND set allowBackgroundTasks only in hosts that wire the full
|
|
56
|
+
* completion story (currently the interactive TUI). Without it, bash
|
|
57
|
+
* rejects run_in_background and the task tools are not registered.
|
|
58
|
+
*/
|
|
59
|
+
processManager?: ProcessManager;
|
|
60
|
+
allowBackgroundTasks?: boolean;
|
|
61
|
+
/** Ctrl+G send-to-background requests from the TUI (design §2.5). */
|
|
62
|
+
promotionChannel?: PromotionChannel;
|
|
50
63
|
}
|
|
51
64
|
export declare function createAllTools(cwd: string, skillRegistry?: SkillRegistry, options?: CreateAllToolsOptions): ToolRegistryEntry[];
|
package/dist/tools/index.js
CHANGED
|
@@ -41,13 +41,20 @@ import { createQuestionTool } from "./question.js";
|
|
|
41
41
|
import { createMemoryTool } from "./memory.js";
|
|
42
42
|
import { FileStateTracker } from "./file-state.js";
|
|
43
43
|
import { createGoalTools } from "../goal/tools.js";
|
|
44
|
+
import { createBackgroundTaskTools } from "./task-tools.js";
|
|
44
45
|
export function createAllTools(cwd, skillRegistry, options = {}) {
|
|
45
46
|
const approval = options.approvalController;
|
|
46
47
|
const lsp = options.lspService ?? getLspService(cwd);
|
|
47
48
|
const fileState = options.fileStateTracker ?? new FileStateTracker(cwd);
|
|
49
|
+
const backgroundTasks = options.allowBackgroundTasks === true && !!options.processManager;
|
|
48
50
|
return [
|
|
49
51
|
createReadTool(cwd, approval, lsp, fileState),
|
|
50
|
-
createBashTool(cwd, approval, fileState
|
|
52
|
+
createBashTool(cwd, approval, fileState, {
|
|
53
|
+
processManager: options.processManager,
|
|
54
|
+
allowBackgroundTasks: backgroundTasks,
|
|
55
|
+
promotionChannel: backgroundTasks ? options.promotionChannel : undefined,
|
|
56
|
+
}),
|
|
57
|
+
...(backgroundTasks ? createBackgroundTaskTools(options.processManager) : []),
|
|
51
58
|
...createManagedServerTools(cwd, approval),
|
|
52
59
|
createWriteTool(cwd, {}, approval, lsp, fileState, options.checkpoints),
|
|
53
60
|
createEditTool(cwd, approval, lsp, fileState, options.checkpoints),
|
|
@@ -1,36 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
cwd: string;
|
|
8
|
-
port?: number;
|
|
9
|
-
url?: string;
|
|
10
|
-
ownerSessionId?: string;
|
|
11
|
-
ownerRunId?: string;
|
|
12
|
-
purpose: ManagedServerPurpose;
|
|
13
|
-
lifecycle: ManagedServerLifecycle;
|
|
14
|
-
startedAt: number;
|
|
15
|
-
lastUsedAt: number;
|
|
16
|
-
status: ManagedServerStatus;
|
|
17
|
-
pid?: number;
|
|
18
|
-
exitCode?: number | null;
|
|
19
|
-
}
|
|
20
|
-
export interface StartManagedServerInput {
|
|
21
|
-
command: string;
|
|
22
|
-
cwd: string;
|
|
23
|
-
port?: number;
|
|
24
|
-
readinessUrl?: string;
|
|
25
|
-
timeoutSec?: number;
|
|
26
|
-
ownerSessionId?: string;
|
|
27
|
-
ownerRunId?: string;
|
|
28
|
-
purpose?: ManagedServerPurpose;
|
|
29
|
-
lifecycle?: ManagedServerLifecycle;
|
|
30
|
-
}
|
|
31
|
-
export declare function startManagedServer(input: StartManagedServerInput): Promise<ManagedServerInfo>;
|
|
32
|
-
export declare function listManagedServers(): ManagedServerInfo[];
|
|
33
|
-
export declare function getManagedServer(id: string): ManagedServerInfo | undefined;
|
|
34
|
-
export declare function getManagedServerLogs(id: string, maxChars?: number): string | undefined;
|
|
35
|
-
export declare function stopManagedServer(id: string): Promise<ManagedServerInfo | undefined>;
|
|
36
|
-
export declare function stopAutoServersForSession(sessionID?: string): Promise<void>;
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility shim: the managed-server runtime moved into the unified
|
|
3
|
+
* process manager (src/tasks/manager.ts, background-tasks design §2.2).
|
|
4
|
+
* Existing imports — server tools, agent.ts, tests — keep working unchanged.
|
|
5
|
+
*/
|
|
6
|
+
export { getManagedServer, getManagedServerLogs, listManagedServers, startManagedServer, stopAutoServersForSession, stopManagedServer, type ManagedServerInfo, type ManagedServerLifecycle, type ManagedServerPurpose, type ManagedServerStatus, type StartManagedServerInput, } from "../tasks/manager.js";
|