@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.
- package/dist/agent/internal-reminder-sanitizer.js +2 -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 +26 -0
- package/dist/agent.js +39 -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/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 +29 -0
- package/dist/orchestrator/default-hooks.js +16 -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 +309 -21
- 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 +18 -7
- package/dist/tui-ink/model-picker.js +33 -10
- 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
|
@@ -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
|
-
|
|
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 {
|
|
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) {
|
package/dist/approval/types.d.ts
CHANGED
|
@@ -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.
|
package/dist/session-types.d.ts
CHANGED
|
@@ -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>;
|