@bubblebrain-ai/bubble 0.0.37 → 0.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/agent/internal-reminder-sanitizer.js +4 -0
  2. package/dist/agent/profiles.d.ts +6 -0
  3. package/dist/agent/profiles.js +32 -0
  4. package/dist/agent/task-lifecycle-reminder.d.ts +26 -0
  5. package/dist/agent/task-lifecycle-reminder.js +59 -0
  6. package/dist/agent.d.ts +43 -0
  7. package/dist/agent.js +70 -0
  8. package/dist/approval/controller.d.ts +3 -1
  9. package/dist/approval/controller.js +5 -2
  10. package/dist/approval/tool-helper.js +10 -1
  11. package/dist/approval/types.d.ts +15 -0
  12. package/dist/cli.d.ts +2 -0
  13. package/dist/cli.js +11 -0
  14. package/dist/config.d.ts +6 -0
  15. package/dist/config.js +4 -0
  16. package/dist/loop/engine.d.ts +29 -0
  17. package/dist/loop/engine.js +63 -0
  18. package/dist/main.js +92 -24
  19. package/dist/orchestrator/default-hooks.js +94 -1
  20. package/dist/orchestrator/hooks.d.ts +7 -0
  21. package/dist/print-output.d.ts +54 -0
  22. package/dist/print-output.js +103 -0
  23. package/dist/prompt/delegation.js +6 -3
  24. package/dist/prompt/task-reminders.d.ts +17 -0
  25. package/dist/prompt/task-reminders.js +15 -0
  26. package/dist/session-types.d.ts +1 -1
  27. package/dist/tasks/manager.d.ts +136 -0
  28. package/dist/tasks/manager.js +530 -0
  29. package/dist/tasks/process-utils.d.ts +10 -0
  30. package/dist/tasks/process-utils.js +49 -0
  31. package/dist/tasks/promotion.d.ts +17 -0
  32. package/dist/tasks/promotion.js +30 -0
  33. package/dist/tasks/wake.d.ts +53 -0
  34. package/dist/tasks/wake.js +125 -0
  35. package/dist/tools/bash.d.ts +15 -1
  36. package/dist/tools/bash.js +103 -3
  37. package/dist/tools/child-tools.js +3 -1
  38. package/dist/tools/edit.js +4 -12
  39. package/dist/tools/index.d.ts +13 -0
  40. package/dist/tools/index.js +8 -1
  41. package/dist/tools/server-manager.d.ts +6 -36
  42. package/dist/tools/server-manager.js +6 -234
  43. package/dist/tools/task-tools.d.ts +10 -0
  44. package/dist/tools/task-tools.js +129 -0
  45. package/dist/tools/write.js +6 -12
  46. package/dist/tui-ink/app.d.ts +20 -2
  47. package/dist/tui-ink/app.js +307 -19
  48. package/dist/tui-ink/approval/approval-dialog.js +4 -4
  49. package/dist/tui-ink/display-history.d.ts +1 -1
  50. package/dist/tui-ink/footer.d.ts +3 -3
  51. package/dist/tui-ink/footer.js +3 -3
  52. package/dist/tui-ink/input-box.d.ts +6 -0
  53. package/dist/tui-ink/markdown.d.ts +10 -2
  54. package/dist/tui-ink/markdown.js +13 -12
  55. package/dist/tui-ink/message-list.js +14 -3
  56. package/dist/tui-ink/run.d.ts +6 -0
  57. package/dist/tui-ink/run.js +1 -1
  58. package/dist/tui-ink/subagent-inspector.d.ts +5 -1
  59. package/dist/tui-ink/subagent-inspector.js +23 -7
  60. package/dist/tui-ink/welcome.js +4 -0
  61. package/package.json +1 -1
@@ -167,6 +167,10 @@ 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,
172
+ /Large-change checkpoint/i,
173
+ /delegation only adds merge risk/i,
170
174
  ];
171
175
  function consumeInternalBlockAtStart(text, final) {
172
176
  if (text.startsWith(INTERNAL_TAG_PREFIX)) {
@@ -56,6 +56,12 @@ export interface AgentProfileDiagnostic {
56
56
  export type AgentProfileScope = "user" | "project" | "both";
57
57
  export declare function discoverAgentProfiles(cwd: string, scope?: AgentProfileScope): DiscoverAgentProfilesResult;
58
58
  export declare function builtinAgentProfiles(): AgentProfile[];
59
+ /**
60
+ * The delegation nudge only fires when a write-capable spawn surface exists
61
+ * (large-task-delegation design §3): defense in depth for hosts that strip
62
+ * or override the builtin profiles.
63
+ */
64
+ export declare function hasWriteWorktreeProfile(profiles: AgentProfile[]): boolean;
59
65
  export declare function findAgentProfile(profiles: AgentProfile[], name: string): AgentProfile | undefined;
60
66
  export declare function assignAgentNickname(profile: AgentProfile, activeNicknames?: Iterable<string>): string;
61
67
  /**
@@ -134,8 +134,40 @@ export function builtinAgentProfiles() {
134
134
  toProfile("security_investigation"),
135
135
  toProfile("evidence_correlation"),
136
136
  toProfile("general_readonly"),
137
+ // The write-capable builtin (large-task-delegation design §3): before
138
+ // this, write_worktree was reachable only via user-authored profile
139
+ // frontmatter, so the delegation nudge would have recommended a surface
140
+ // that does not exist in a stock session.
141
+ {
142
+ name: "implementer",
143
+ description: "Write-capable implementation subagent (isolated git worktree)",
144
+ source: "builtin",
145
+ mode: "write_worktree",
146
+ model: "inherit",
147
+ tools: {
148
+ preset: "explicit",
149
+ include: ["read", "glob", "grep", "edit", "write", "bash", "lsp", "todo_write"],
150
+ exclude: [],
151
+ },
152
+ approval: "fail",
153
+ nicknameCandidates: DEFAULT_NICKNAME_CANDIDATES,
154
+ prompt: [
155
+ "You are an implementer subagent working in your own isolated git worktree — the parent tree is never touched.",
156
+ "Your worktree forks from the last commit; changes the parent made after that commit are NOT present unless included in your briefing.",
157
+ "Make the smallest coherent edits that complete your assigned group. Stay strictly inside the file set you were given; if a needed change falls outside it, report that instead of editing.",
158
+ "Verify your changes narrowly (targeted tests or typecheck) when possible, and end with a concise summary of files changed and verification results — the parent reviews and applies your worktree.",
159
+ ].join("\n"),
160
+ },
137
161
  ];
138
162
  }
163
+ /**
164
+ * The delegation nudge only fires when a write-capable spawn surface exists
165
+ * (large-task-delegation design §3): defense in depth for hosts that strip
166
+ * or override the builtin profiles.
167
+ */
168
+ export function hasWriteWorktreeProfile(profiles) {
169
+ return profiles.some((profile) => profile.mode === "write_worktree");
170
+ }
139
171
  export function findAgentProfile(profiles, name) {
140
172
  return profiles.find((profile) => profile.name === name)
141
173
  ?? profiles.find((profile) => profile.name === `builtin:${name}`);
@@ -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,25 @@ 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;
115
+ /**
116
+ * Once-per-session latch for the large-task delegation nudge
117
+ * (large-task-delegation design §2): goal-loop continuations rebuild
118
+ * TurnHookState every run, so a per-turn latch would nag on every turn.
119
+ * If the model read the nudge once and chose not to delegate, repeating
120
+ * it is noise.
121
+ */
122
+ largeTaskNudgeConsumed: boolean;
104
123
  private _providerId;
105
124
  private _model;
106
125
  private tools;
@@ -165,6 +184,21 @@ export declare class Agent {
165
184
  * the prompt's tool list.
166
185
  */
167
186
  injectDeferredToolsReminder(): void;
187
+ /**
188
+ * Rebinds the agent to a new session (TUI session switch). Keeps
189
+ * ownerSessionId on newly spawned background tasks and managed servers
190
+ * accurate (background-tasks design §2.2c).
191
+ */
192
+ setSessionID(sessionID: string | undefined): void;
193
+ getSessionID(): string | undefined;
194
+ /**
195
+ * Background-task reminder, state-change gated (design §2.3a): returns a
196
+ * reminder only when the owned task set changed since the last emission
197
+ * (started/finished/killed). The reminder channel is append-only, so
198
+ * emitting on every model call while tasks are merely alive would stack
199
+ * persistent duplicates that neither pruning nor compaction removes.
200
+ */
201
+ consumeBackgroundTaskReminder(): string | undefined;
168
202
  private runExternalHook;
169
203
  private injectHookModelContext;
170
204
  /** Whether a tool is registered on this agent (e.g. delegation tools on parents). */
@@ -269,6 +303,15 @@ export declare class Agent {
269
303
  abortSignal?: AbortSignal;
270
304
  }): Promise<SubagentThreadSnapshot>;
271
305
  closeSubAgent(agentId: string): Promise<SubagentThreadSnapshot>;
306
+ /**
307
+ * Live (non-final) children only — the delegation-nudge gate
308
+ * (large-task-delegation design §2). listSubAgents() is a grows-only
309
+ * session history (finished + resumed children stay forever), so it must
310
+ * never be used to answer "am I currently delegating?".
311
+ */
312
+ activeSubAgentCount(): number;
313
+ /** run_workflow children are workflowInternal and invisible to listSubAgents. */
314
+ hasRunningWorkflow(): boolean;
272
315
  listSubAgents(): SubagentThreadSnapshot[];
273
316
  /**
274
317
  * Dynamic workflow (option C): runs an LLM-authored JS orchestration script in
package/dist/agent.js CHANGED
@@ -19,6 +19,8 @@ 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";
23
+ import { classifyTask } from "./agent/task-classifier.js";
22
24
  import { mergeAgentCategories, parseThinkingLevel, resolveModelRoute, resolveSubagentRoute } from "./agent/categories.js";
23
25
  import { DEFAULT_AGENT_ROUTING, nearModelMatches, sanitizeAgentRouting, tierContextFromSnapshot } from "./agent/routing-catalog.js";
24
26
  import { buildModelRoutingPrompt } from "./prompt/routing.js";
@@ -100,6 +102,21 @@ export class Agent {
100
102
  messages = [];
101
103
  provider;
102
104
  sessionID;
105
+ /**
106
+ * Bridge to the unified process manager's background tasks, wired by the
107
+ * host (main.ts) when the host supports them (background-tasks design
108
+ * §2.3a). list() is pre-filtered to the agent's bound session.
109
+ */
110
+ backgroundTasks;
111
+ lastTaskReminderVersion = -1;
112
+ /**
113
+ * Once-per-session latch for the large-task delegation nudge
114
+ * (large-task-delegation design §2): goal-loop continuations rebuild
115
+ * TurnHookState every run, so a per-turn latch would nag on every turn.
116
+ * If the model read the nudge once and chose not to delegate, repeating
117
+ * it is noise.
118
+ */
119
+ largeTaskNudgeConsumed = false;
103
120
  _providerId;
104
121
  _model;
105
122
  tools = new Map();
@@ -261,6 +278,37 @@ export class Agent {
261
278
  this.injectSystemReminder(buildDeferredToolsReminder(deferredNames));
262
279
  }
263
280
  }
281
+ /**
282
+ * Rebinds the agent to a new session (TUI session switch). Keeps
283
+ * ownerSessionId on newly spawned background tasks and managed servers
284
+ * accurate (background-tasks design §2.2c).
285
+ */
286
+ setSessionID(sessionID) {
287
+ this.sessionID = sessionID;
288
+ }
289
+ getSessionID() {
290
+ return this.sessionID;
291
+ }
292
+ /**
293
+ * Background-task reminder, state-change gated (design §2.3a): returns a
294
+ * reminder only when the owned task set changed since the last emission
295
+ * (started/finished/killed). The reminder channel is append-only, so
296
+ * emitting on every model call while tasks are merely alive would stack
297
+ * persistent duplicates that neither pruning nor compaction removes.
298
+ */
299
+ consumeBackgroundTaskReminder() {
300
+ if (!this.backgroundTasks)
301
+ return undefined;
302
+ const version = this.backgroundTasks.version();
303
+ if (version === this.lastTaskReminderVersion)
304
+ return undefined;
305
+ this.lastTaskReminderVersion = version;
306
+ return buildTaskLifecycleReminder({
307
+ tasks: this.backgroundTasks.list(),
308
+ outputTail: (id) => this.backgroundTasks?.outputTail(id),
309
+ toolsAvailable: this.unlockedDeferred?.has("task_output") ?? false,
310
+ });
311
+ }
264
312
  async runExternalHook(request, abortSignal) {
265
313
  const events = [];
266
314
  if (!this.externalHooks) {
@@ -509,6 +557,15 @@ export class Agent {
509
557
  events.push(...hook.events);
510
558
  this.injectHookModelContext(hook.result);
511
559
  this.appendMessage({ role: "user", content: input.content });
560
+ // Large-task detector (large-task-delegation design §1): a steer
561
+ // redirects the task — stale breadth evidence must not fire a nudge
562
+ // about the pre-steer task, and the classifier should reason about
563
+ // the redirected one.
564
+ hookState.exploredFiles?.clear();
565
+ hookState.largeTaskCheckpointDone = false;
566
+ if (typeof input.content === "string" && input.content.trim()) {
567
+ hookState.taskType = classifyTask(input.content);
568
+ }
512
569
  events.push({
513
570
  type: "input_applied",
514
571
  id: input.id,
@@ -1636,6 +1693,19 @@ export class Agent {
1636
1693
  this.subagentStore.notifyWaiters(record);
1637
1694
  return this.snapshotSubagent(record);
1638
1695
  }
1696
+ /**
1697
+ * Live (non-final) children only — the delegation-nudge gate
1698
+ * (large-task-delegation design §2). listSubAgents() is a grows-only
1699
+ * session history (finished + resumed children stay forever), so it must
1700
+ * never be used to answer "am I currently delegating?".
1701
+ */
1702
+ activeSubAgentCount() {
1703
+ return this.subagentStore.activeCount();
1704
+ }
1705
+ /** run_workflow children are workflowInternal and invisible to listSubAgents. */
1706
+ hasRunningWorkflow() {
1707
+ return [...this.workflowRuns.values()].some((record) => record.status === "running");
1708
+ }
1639
1709
  listSubAgents() {
1640
1710
  return this.subagentStore.values()
1641
1711
  .filter((record) => !record.workflowInternal)
@@ -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/cli.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface CliArgs {
11
11
  resume?: boolean;
12
12
  sessionName?: string;
13
13
  print?: boolean;
14
+ /** Print-mode output: plain (default) or a single JSON object on stdout. */
15
+ outputFormat?: "plain" | "json";
14
16
  prompt?: string;
15
17
  thinkingLevel?: ThinkingLevel;
16
18
  mode?: PermissionMode;
package/dist/cli.js CHANGED
@@ -55,6 +55,15 @@ export function parseArgs(argv) {
55
55
  case "-p":
56
56
  args.print = true;
57
57
  break;
58
+ case "--output-format": {
59
+ const value = argv[++i];
60
+ if (value !== "plain" && value !== "json") {
61
+ console.error(`Invalid --output-format: ${value ?? "(missing)"}. Expected plain or json.`);
62
+ process.exit(1);
63
+ }
64
+ args.outputFormat = value;
65
+ break;
66
+ }
58
67
  case "--plan":
59
68
  args.mode = "plan";
60
69
  break;
@@ -108,6 +117,8 @@ Options (default):
108
117
  --dangerously-skip-permissions
109
118
  Enable bypass mode (auto-approve EVERY tool; disables all safety prompts)
110
119
  -p, --print Non-interactive mode (single prompt)
120
+ --output-format <fmt> Print-mode output: plain (default) or json
121
+ (one JSON object on stdout: text, usage, num_turns)
111
122
  -v, --version Print the installed version and exit
112
123
  -h, --help Show this help
113
124
 
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
+ }