@bubblebrain-ai/bubble 0.0.20 → 0.0.22

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 (98) hide show
  1. package/dist/agent/abort-errors.d.ts +14 -0
  2. package/dist/agent/abort-errors.js +21 -0
  3. package/dist/agent/budget-ledger.d.ts +41 -0
  4. package/dist/agent/budget-ledger.js +64 -0
  5. package/dist/agent/child-runner.d.ts +55 -0
  6. package/dist/agent/child-runner.js +312 -0
  7. package/dist/agent/profiles.d.ts +8 -0
  8. package/dist/agent/profiles.js +27 -5
  9. package/dist/agent/result-integrator.d.ts +22 -0
  10. package/dist/agent/result-integrator.js +50 -0
  11. package/dist/agent/subagent-control.d.ts +31 -0
  12. package/dist/agent/subagent-control.js +27 -0
  13. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  14. package/dist/agent/subagent-scheduler.d.ts +95 -0
  15. package/dist/agent/subagent-scheduler.js +256 -0
  16. package/dist/agent/subagent-store.d.ts +41 -0
  17. package/dist/agent/subagent-store.js +149 -0
  18. package/dist/agent/subagent-summary.d.ts +30 -0
  19. package/dist/agent/subagent-summary.js +74 -0
  20. package/dist/agent/worktree.d.ts +29 -0
  21. package/dist/agent/worktree.js +73 -0
  22. package/dist/agent.d.ts +64 -5
  23. package/dist/agent.js +365 -288
  24. package/dist/approval/controller.js +9 -1
  25. package/dist/approval/tool-helper.js +2 -0
  26. package/dist/approval/types.d.ts +17 -1
  27. package/dist/checkpoints.d.ts +57 -0
  28. package/dist/checkpoints.js +0 -0
  29. package/dist/config.d.ts +8 -0
  30. package/dist/config.js +17 -0
  31. package/dist/feishu/agent-host/approval-card.js +9 -0
  32. package/dist/feishu/agent-host/run-driver.js +2 -0
  33. package/dist/main.js +88 -13
  34. package/dist/network/errors.d.ts +28 -0
  35. package/dist/network/errors.js +24 -0
  36. package/dist/orchestrator/default-hooks.js +5 -1
  37. package/dist/prompt/compose.js +3 -0
  38. package/dist/prompt/delegation.d.ts +14 -0
  39. package/dist/prompt/delegation.js +64 -0
  40. package/dist/prompt/task-reminders.d.ts +5 -1
  41. package/dist/prompt/task-reminders.js +10 -2
  42. package/dist/provider-anthropic.js +23 -0
  43. package/dist/provider.js +23 -3
  44. package/dist/session.d.ts +31 -0
  45. package/dist/session.js +69 -0
  46. package/dist/slash-commands/commands.js +109 -2
  47. package/dist/slash-commands/types.d.ts +6 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/bash.js +4 -0
  51. package/dist/tools/child-tools.d.ts +31 -0
  52. package/dist/tools/child-tools.js +106 -0
  53. package/dist/tools/edit.d.ts +2 -1
  54. package/dist/tools/edit.js +2 -1
  55. package/dist/tools/index.d.ts +7 -0
  56. package/dist/tools/index.js +3 -3
  57. package/dist/tools/write.d.ts +2 -1
  58. package/dist/tools/write.js +2 -1
  59. package/dist/tui/image-paste.d.ts +18 -0
  60. package/dist/tui/image-paste.js +60 -0
  61. package/dist/tui/run.d.ts +11 -1
  62. package/dist/tui/run.js +399 -71
  63. package/dist/tui/session-picker-data.d.ts +18 -0
  64. package/dist/tui/session-picker-data.js +21 -0
  65. package/dist/tui/trace-groups.d.ts +16 -0
  66. package/dist/tui/trace-groups.js +42 -1
  67. package/dist/tui/transcript-scroll.d.ts +25 -0
  68. package/dist/tui/transcript-scroll.js +20 -0
  69. package/dist/tui/wordmark.d.ts +2 -0
  70. package/dist/tui/wordmark.js +31 -4
  71. package/dist/tui-ink/app.d.ts +4 -1
  72. package/dist/tui-ink/app.js +301 -247
  73. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  74. package/dist/tui-ink/display-history.d.ts +16 -1
  75. package/dist/tui-ink/display-history.js +50 -21
  76. package/dist/tui-ink/footer.d.ts +6 -12
  77. package/dist/tui-ink/footer.js +10 -29
  78. package/dist/tui-ink/image-paste.d.ts +59 -0
  79. package/dist/tui-ink/image-paste.js +277 -0
  80. package/dist/tui-ink/input-box.d.ts +26 -1
  81. package/dist/tui-ink/input-box.js +171 -41
  82. package/dist/tui-ink/message-list.d.ts +1 -1
  83. package/dist/tui-ink/message-list.js +46 -29
  84. package/dist/tui-ink/run.d.ts +7 -2
  85. package/dist/tui-ink/run.js +73 -23
  86. package/dist/tui-ink/terminal-mouse.d.ts +1 -0
  87. package/dist/tui-ink/terminal-mouse.js +4 -0
  88. package/dist/tui-ink/trace-groups.d.ts +16 -0
  89. package/dist/tui-ink/trace-groups.js +50 -2
  90. package/dist/tui-ink/transcript-viewport-math.d.ts +11 -0
  91. package/dist/tui-ink/transcript-viewport-math.js +17 -0
  92. package/dist/tui-ink/transcript-viewport.d.ts +24 -0
  93. package/dist/tui-ink/transcript-viewport.js +83 -0
  94. package/dist/tui-ink/welcome.d.ts +9 -7
  95. package/dist/tui-ink/welcome.js +7 -33
  96. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  97. package/dist/types.d.ts +17 -0
  98. package/package.json +1 -1
@@ -49,7 +49,9 @@ export class PermissionAwareApprovalController {
49
49
  if (mode === "default" && (req.type === "edit" || req.type === "write" || req.type === "patch")) {
50
50
  return finalize({ action: "approve" });
51
51
  }
52
- if (mode === "plan") {
52
+ // Project profile trust is a user decision, not a destructive action:
53
+ // spawn_agent is legal in plan mode, so the gate prompts there too.
54
+ if (mode === "plan" && req.type !== "agent_profile") {
53
55
  return finalize({
54
56
  action: "reject",
55
57
  feedback: "Plan mode is active. Do not call destructive tools directly — propose your changes via exit_plan_mode and wait for user approval.",
@@ -83,6 +85,8 @@ export class PermissionAwareApprovalController {
83
85
  return { tool: "Edit", path: req.path, cwd: this.options.cwd };
84
86
  case "lsp":
85
87
  return { tool: "Lsp", path: req.path, cwd: this.options.cwd };
88
+ case "agent_profile":
89
+ return { tool: "AgentProfile" };
86
90
  }
87
91
  }
88
92
  checkRequestRules(req) {
@@ -170,6 +174,8 @@ function approvalTarget(req) {
170
174
  return "Edit";
171
175
  case "lsp":
172
176
  return "Lsp";
177
+ case "agent_profile":
178
+ return "AgentProfile";
173
179
  }
174
180
  }
175
181
  function summarizeApprovalRequest(req) {
@@ -184,5 +190,7 @@ function summarizeApprovalRequest(req) {
184
190
  return { type: req.type, path: req.path, paths: req.paths, files: req.files, diffLength: req.diff.length };
185
191
  case "lsp":
186
192
  return { type: req.type, path: req.path, operation: req.operation };
193
+ case "agent_profile":
194
+ return { type: req.type, name: req.name, path: req.path, contentHash: req.contentHash };
187
195
  }
188
196
  }
@@ -30,5 +30,7 @@ function approvalRequestLabel(req) {
30
30
  return `Bash command \`${req.command}\``;
31
31
  case "lsp":
32
32
  return `LSP ${req.operation} on ${req.path}`;
33
+ case "agent_profile":
34
+ return `Project agent profile "${req.name}"`;
33
35
  }
34
36
  }
@@ -45,7 +45,23 @@ export interface LspApprovalRequest {
45
45
  path: string;
46
46
  operation: string;
47
47
  }
48
- export type ApprovalRequest = EditApprovalRequest | WriteApprovalRequest | PatchApprovalRequest | BashApprovalRequest | LspApprovalRequest;
48
+ /**
49
+ * Trust gate for project-local agent profiles (.bubble/agents). The user —
50
+ * not the model — decides whether a repository's profile prompt may drive a
51
+ * subagent; approvals are remembered per content hash for the session.
52
+ */
53
+ export interface AgentProfileApprovalRequest {
54
+ type: "agent_profile";
55
+ /** Profile name as referenced by spawn_agent. */
56
+ name: string;
57
+ /** Absolute path of the profile file inside the repository. */
58
+ path: string;
59
+ /** Content hash; a changed file re-prompts. */
60
+ contentHash: string;
61
+ /** First lines of the profile prompt so the user can judge it. */
62
+ promptPreview: string;
63
+ }
64
+ export type ApprovalRequest = EditApprovalRequest | WriteApprovalRequest | PatchApprovalRequest | BashApprovalRequest | LspApprovalRequest | AgentProfileApprovalRequest;
49
65
  export type ApprovalDecision = {
50
66
  action: "approve";
51
67
  feedback?: string;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Checkpoint store - pre-mutation file snapshots, keyed by conversation turn.
3
+ *
4
+ * Before the edit/write tools persist a change, they record the file's prior
5
+ * content here so /rewind can restore the workspace to the state it had just
6
+ * before a given user message. Changes made by bash commands are NOT tracked;
7
+ * checkpoints complement git, they do not replace it.
8
+ *
9
+ * On-disk layout (sibling of the session JSONL):
10
+ * <session>.checkpoints/
11
+ * blobs/<sha256> full file content, content-addressed (deduplicated)
12
+ * manifest.jsonl one {turn, path, blob, timestamp} line per first
13
+ * capture of a file within a turn
14
+ *
15
+ * blob === null means the file did not exist before that turn, so rewinding
16
+ * deletes it.
17
+ */
18
+ export interface CheckpointManifestEntry {
19
+ turn: string;
20
+ path: string;
21
+ blob: string | null;
22
+ timestamp: number;
23
+ }
24
+ export interface CheckpointRestoreResult {
25
+ /** Files whose pre-turn content was written back. */
26
+ restored: string[];
27
+ /** Files deleted because they were created during the rewound turns. */
28
+ deleted: string[];
29
+ /** Files that could not be restored (I/O errors). */
30
+ failed: string[];
31
+ }
32
+ export declare class CheckpointStore {
33
+ private readonly dir;
34
+ private readonly currentTurn;
35
+ private seen?;
36
+ constructor(dir: string, currentTurn: () => string);
37
+ /**
38
+ * Record a file's content before it is mutated. `priorContent` is the
39
+ * current on-disk content, or null when the file does not exist yet.
40
+ * Capturing must never break the mutation itself, so all errors are
41
+ * swallowed.
42
+ */
43
+ captureBefore(filePath: string, priorContent: string | null): Promise<void>;
44
+ listEntries(): CheckpointManifestEntry[];
45
+ /** Unique files first captured at or after the given turn. */
46
+ filesTouchedSince(turn: string): string[];
47
+ /** Unique files captured during exactly the given turn. */
48
+ filesTouchedAt(turn: string): string[];
49
+ /**
50
+ * Restore every tracked file to its content from just before `turn`.
51
+ * For each file the earliest capture at-or-after the cutoff wins (it holds
52
+ * the oldest pre-mutation content). Consumed manifest entries are pruned so
53
+ * a later rewind over reused turn ids cannot resurrect stale state.
54
+ */
55
+ restoreTo(turn: string): Promise<CheckpointRestoreResult>;
56
+ private loadSeen;
57
+ }
Binary file
package/dist/config.d.ts CHANGED
@@ -30,6 +30,13 @@ export interface UserConfigData {
30
30
  providers?: ProviderProfile[];
31
31
  defaultProvider?: string;
32
32
  agentCategories?: AgentCategoriesConfig;
33
+ subagents?: SubagentsUserConfig;
34
+ }
35
+ export interface SubagentsUserConfig {
36
+ /** Global cap on concurrently running children. Default 8. */
37
+ maxActiveSubagents?: number;
38
+ /** Absolute per-child soft token cap. Default 200000. */
39
+ childTokenCap?: number;
33
40
  }
34
41
  export declare class UserConfig {
35
42
  private data;
@@ -56,6 +63,7 @@ export declare class UserConfig {
56
63
  setThemeMode(mode: ThemeMode): void;
57
64
  setThemeOverrides(overrides: Record<string, string>): void;
58
65
  getAgentCategories(): AgentCategoriesConfig;
66
+ getSubagents(): SubagentsUserConfig;
59
67
  }
60
68
  /** Mask an API key for safe display. */
61
69
  export declare function maskKey(key: string): string;
package/dist/config.js CHANGED
@@ -37,6 +37,19 @@ function sanitizeDefaultModel(model) {
37
37
  function sanitizeDefaultProvider(providerId) {
38
38
  return isHiddenProviderId(providerId) ? undefined : providerId;
39
39
  }
40
+ function sanitizeSubagentsConfig(value) {
41
+ if (!value || typeof value !== "object" || Array.isArray(value))
42
+ return undefined;
43
+ const raw = value;
44
+ const out = {};
45
+ if (typeof raw.maxActiveSubagents === "number" && Number.isFinite(raw.maxActiveSubagents)) {
46
+ out.maxActiveSubagents = Math.max(1, Math.floor(raw.maxActiveSubagents));
47
+ }
48
+ if (typeof raw.childTokenCap === "number" && Number.isFinite(raw.childTokenCap)) {
49
+ out.childTokenCap = Math.max(1_000, Math.floor(raw.childTokenCap));
50
+ }
51
+ return Object.keys(out).length > 0 ? out : undefined;
52
+ }
40
53
  function sanitizeTheme(value) {
41
54
  if (value == null)
42
55
  return undefined;
@@ -89,6 +102,7 @@ export class UserConfig {
89
102
  providers: sanitizeProviders(parsed.providers),
90
103
  defaultProvider: sanitizeDefaultProvider(parsed.defaultProvider),
91
104
  agentCategories: sanitizeAgentCategories(parsed.agentCategories),
105
+ subagents: sanitizeSubagentsConfig(parsed.subagents),
92
106
  theme: sanitizeTheme(parsed.theme),
93
107
  };
94
108
  }
@@ -188,6 +202,9 @@ export class UserConfig {
188
202
  getAgentCategories() {
189
203
  return sanitizeAgentCategories(this.data.agentCategories);
190
204
  }
205
+ getSubagents() {
206
+ return sanitizeSubagentsConfig(this.data.subagents) ?? {};
207
+ }
191
208
  }
192
209
  /** Mask an API key for safe display. */
193
210
  export function maskKey(key) {
@@ -46,6 +46,15 @@ export function formatApprovalRequest(req) {
46
46
  title: `LSP 操作 (${req.operation})`,
47
47
  body: `**path:** \`${truncate(req.path, PATH_PREVIEW_MAX)}\``,
48
48
  };
49
+ case "agent_profile":
50
+ return {
51
+ title: `使用项目 agent profile "${req.name}"`,
52
+ body: [
53
+ `**path:** \`${truncate(req.path, PATH_PREVIEW_MAX)}\``,
54
+ `\n**prompt preview:**\n\`\`\`\n${truncate(req.promptPreview, CONTENT_PREVIEW_MAX)}\n\`\`\``,
55
+ "\n该 profile 来自仓库本地 `.bubble/agents`,其 prompt 会驱动一个子代理。仅在信任该仓库时批准。",
56
+ ].join("\n"),
57
+ };
49
58
  }
50
59
  }
51
60
  function truncate(s, max) {
@@ -78,6 +78,7 @@ export class RunDriver {
78
78
  approvalController,
79
79
  lspService,
80
80
  fileStateTracker,
81
+ checkpoints: () => session.manager.getCheckpoints(),
81
82
  // questionController intentionally omitted — Feishu v1 doesn't surface
82
83
  // the question tool to the agent.
83
84
  });
@@ -139,6 +140,7 @@ export class RunDriver {
139
140
  memoryPrompt,
140
141
  fileStateTracker,
141
142
  agentCategories: this.opts.deps.userConfig.getAgentCategories(),
143
+ subagents: this.opts.deps.userConfig.getSubagents(),
142
144
  providerFactory: (route) => this.opts.deps.createProviderForRoute(route, promptCacheKey),
143
145
  externalHooks: hookController,
144
146
  });
package/dist/main.js CHANGED
@@ -30,6 +30,10 @@ import { basename } from "node:path";
30
30
  import { normalizeSingleLine, truncateVisual } from "./text-display.js";
31
31
  import { BUBBLE_WORDMARK } from "./tui/wordmark.js";
32
32
  import { configureDebugTrace, summarizeAgentEventForTrace, summarizeTraceMessage, traceEvent, } from "./debug-trace.js";
33
+ // OpenTUI is the default renderer. The React Ink implementation (alt-screen
34
+ // viewport, src/tui-ink) is feature-complete but still maturing — opt in with
35
+ // BUBBLE_TUI=ink.
36
+ const USE_OPENTUI = process.env.BUBBLE_TUI !== "ink";
33
37
  async function main() {
34
38
  const args = parseArgs(process.argv.slice(2));
35
39
  if (process.argv.includes("-h") || process.argv.includes("--help")) {
@@ -163,6 +167,8 @@ async function main() {
163
167
  toolSearchController,
164
168
  lspService,
165
169
  fileStateTracker,
170
+ // Lazy: sessionManager is resolved after tools are created.
171
+ checkpoints: () => sessionManager?.getCheckpoints(),
166
172
  });
167
173
  // Bring up MCP servers (if any). Failures are captured per-server and never
168
174
  // block the rest of startup; /mcp surfaces status at runtime.
@@ -225,7 +231,9 @@ async function main() {
225
231
  else {
226
232
  preResolvedTheme = themeConfig.mode;
227
233
  }
228
- const { runSessionPicker } = await import("./tui-opentui/run-session-picker.js");
234
+ const { runSessionPicker } = USE_OPENTUI
235
+ ? await import("./tui-opentui/run-session-picker.js")
236
+ : await import("./tui-ink/run-session-picker.js");
229
237
  const picked = await runSessionPicker({
230
238
  currentCwd: args.cwd,
231
239
  currentSessions,
@@ -304,7 +312,7 @@ async function main() {
304
312
  sessionFile: sessionManager?.getSessionFile(),
305
313
  provider: activeProviderId || "none",
306
314
  model: activeModel || "none",
307
- renderer: printMode ? "print" : "opentui-core",
315
+ renderer: printMode ? "print" : USE_OPENTUI ? "opentui-core" : "ink",
308
316
  });
309
317
  if (traceInfo.enabled) {
310
318
  traceEvent("run_start", {
@@ -372,6 +380,7 @@ async function main() {
372
380
  memoryPrompt,
373
381
  fileStateTracker,
374
382
  agentCategories: userConfig.getAgentCategories(),
383
+ subagents: userConfig.getSubagents(),
375
384
  providerFactory: createProviderForRoute,
376
385
  externalHooks: hookController,
377
386
  });
@@ -503,8 +512,41 @@ async function main() {
503
512
  else {
504
513
  detectedTheme = themeConfig.mode;
505
514
  }
515
+ // In-place session switch for the /session picker: rebind every closure
516
+ // that persists to the session (onMessageAppend, markers, title updater)
517
+ // by reassigning the outer `sessionManager`, then replace the agent's
518
+ // history the same way startup resume does.
519
+ const switchSession = (sessionFile) => {
520
+ try {
521
+ const next = new SessionManager(sessionFile);
522
+ const history = next.getMessages();
523
+ sessionManager = next;
524
+ sessionPromptCacheKey = next.getOrCreatePromptCacheKey();
525
+ sessionTitleUpdater = createSessionTitleUpdater({
526
+ sessionManager: next,
527
+ complete: (messages, completeOptions) => agent.complete(messages, completeOptions),
528
+ });
529
+ next.updateMetadata({
530
+ ...(agent.model ? { model: agent.model } : {}),
531
+ cwd: args.cwd,
532
+ thinkingLevel: agent.thinking,
533
+ reasoningEffort: agent.thinking,
534
+ });
535
+ // Keep the live system/meta head (mode reminders survive the switch),
536
+ // mirroring the /rewind history-replacement pattern.
537
+ const head = agent.messages.filter((m) => m.role === "system" || m.role === "meta");
538
+ agent.messages = [...head, ...history];
539
+ agent.setTodos(next.getTodos());
540
+ agent.resetContextUsageAnchor();
541
+ return { manager: next };
542
+ }
543
+ catch (error) {
544
+ return { error: error instanceof Error ? error.message : String(error) };
545
+ }
546
+ };
506
547
  const commonOptions = {
507
548
  sessionManager,
549
+ switchSession,
508
550
  createProvider,
509
551
  registry,
510
552
  skillRegistry,
@@ -523,19 +565,37 @@ async function main() {
523
565
  };
524
566
  const { getStartupUpdateNotice } = await import("./update/index.js");
525
567
  const updateNotice = await getStartupUpdateNotice();
526
- const { runTui } = await import("./tui/run.js");
527
- await runTui(agent, args, {
528
- ...commonOptions,
529
- themeMode: themeConfig.mode,
530
- themeOverrides: themeConfig.overrides,
531
- detectedTheme,
532
- onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
533
- updateNotice: updateNotice ?? undefined,
534
- });
568
+ // Two explicit branches (not a dynamic ternary import) so TypeScript
569
+ // checks each renderer's RunTuiOptions shape independently.
570
+ let exitWallMs;
571
+ if (USE_OPENTUI) {
572
+ const { runTui } = await import("./tui/run.js");
573
+ await runTui(agent, args, {
574
+ ...commonOptions,
575
+ themeMode: themeConfig.mode,
576
+ themeOverrides: themeConfig.overrides,
577
+ detectedTheme,
578
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
579
+ updateNotice: updateNotice ?? undefined,
580
+ });
581
+ }
582
+ else {
583
+ const { runTui } = await import("./tui-ink/run.js");
584
+ const summary = await runTui(agent, args, {
585
+ ...commonOptions,
586
+ themeMode: themeConfig.mode,
587
+ themeOverrides: themeConfig.overrides,
588
+ detectedTheme,
589
+ onThemeModeChange: (mode) => userConfig.setThemeMode(mode),
590
+ updateNotice: updateNotice ?? undefined,
591
+ });
592
+ exitWallMs = summary?.wallMs;
593
+ }
535
594
  if (sessionManager) {
536
- printOpenTuiExitSummary(sessionManager, {
595
+ printExitSummary(sessionManager, {
537
596
  resumed: resumedExistingSession,
538
597
  theme: detectedTheme,
598
+ wallMs: exitWallMs,
539
599
  });
540
600
  }
541
601
  }
@@ -545,7 +605,7 @@ async function main() {
545
605
  traceEvent("run_shutdown_end");
546
606
  }
547
607
  }
548
- function printOpenTuiExitSummary(sessionManager, options) {
608
+ function printExitSummary(sessionManager, options) {
549
609
  if (!process.stdout.isTTY)
550
610
  return;
551
611
  const sessionName = basename(sessionManager.getSessionFile());
@@ -589,6 +649,21 @@ function printOpenTuiExitSummary(sessionManager, options) {
589
649
  console.log();
590
650
  console.log(`${label("Session")}${colors.value(sessionLabel)}`);
591
651
  console.log(`${label("Continue")}${colors.value(continueCommand)}`);
652
+ if (options.wallMs !== undefined) {
653
+ console.log(`${label("Duration")}${colors.value(formatWallDuration(options.wallMs))}`);
654
+ }
655
+ }
656
+ function formatWallDuration(ms) {
657
+ const totalSeconds = Math.max(0, Math.round(ms / 1000));
658
+ if (totalSeconds < 60)
659
+ return `${totalSeconds}s`;
660
+ const minutes = Math.floor(totalSeconds / 60);
661
+ const seconds = totalSeconds % 60;
662
+ if (minutes < 60)
663
+ return `${minutes}m ${seconds}s`;
664
+ const hours = Math.floor(minutes / 60);
665
+ const minutesRest = minutes % 60;
666
+ return `${hours}h ${minutesRest}m ${seconds}s`;
592
667
  }
593
668
  async function readPipedStdin() {
594
669
  if (process.stdin.isTTY)
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Typed transport errors shared between providers and the subagent runtime.
3
+ *
4
+ * `RateLimitError` is the contract for 429 handling (design doc
5
+ * docs/subagent-runtime-design.md §4.5): under `rateLimitPolicy: "defer"` the
6
+ * transport performs no 429 backoff of its own and throws this error
7
+ * immediately so the subagent scheduler can be the single backoff layer.
8
+ */
9
+ export declare class RateLimitError extends Error {
10
+ readonly isRateLimitError = true;
11
+ readonly status: number;
12
+ readonly retryAfterMs?: number;
13
+ constructor(message: string, options?: {
14
+ status?: number;
15
+ retryAfterMs?: number;
16
+ cause?: unknown;
17
+ });
18
+ }
19
+ export declare function isRateLimitError(error: unknown): error is RateLimitError;
20
+ /**
21
+ * How a provider transport should treat HTTP 429 responses.
22
+ *
23
+ * - "handle": retry inside the transport with backoff (parent traffic default).
24
+ * - "defer": do not retry 429 at all; throw RateLimitError immediately so the
25
+ * caller (subagent scheduler) owns the backoff. Other retryable
26
+ * statuses are unaffected.
27
+ */
28
+ export type RateLimitPolicy = "handle" | "defer";
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Typed transport errors shared between providers and the subagent runtime.
3
+ *
4
+ * `RateLimitError` is the contract for 429 handling (design doc
5
+ * docs/subagent-runtime-design.md §4.5): under `rateLimitPolicy: "defer"` the
6
+ * transport performs no 429 backoff of its own and throws this error
7
+ * immediately so the subagent scheduler can be the single backoff layer.
8
+ */
9
+ export class RateLimitError extends Error {
10
+ isRateLimitError = true;
11
+ status;
12
+ retryAfterMs;
13
+ constructor(message, options) {
14
+ super(message, options?.cause !== undefined ? { cause: options.cause } : undefined);
15
+ this.name = "RateLimitError";
16
+ this.status = options?.status ?? 429;
17
+ this.retryAfterMs = options?.retryAfterMs;
18
+ }
19
+ }
20
+ export function isRateLimitError(error) {
21
+ return !!error
22
+ && typeof error === "object"
23
+ && error.isRateLimitError === true;
24
+ }
@@ -20,7 +20,11 @@ export function createDefaultHooks() {
20
20
  input: ctx.input,
21
21
  enabled: taskType === "repo_orientation",
22
22
  });
23
- const taskReminder = reminderForTaskType(taskType);
23
+ const taskReminder = reminderForTaskType(taskType, {
24
+ // Only parent agents carry the delegation tools; the nudge must
25
+ // never reach a child that cannot spawn anything.
26
+ canDelegate: ctx.agent.hasToolAvailable("spawn_agent"),
27
+ });
24
28
  if (taskReminder) {
25
29
  ctx.queueReminder(taskReminder);
26
30
  }
@@ -6,6 +6,7 @@ import { buildGeminiProviderPrompt } from "./provider-prompts/gemini.js";
6
6
  import { buildGlmProviderPrompt } from "./provider-prompts/glm.js";
7
7
  import { buildGptProviderPrompt } from "./provider-prompts/gpt.js";
8
8
  import { buildKimiProviderPrompt } from "./provider-prompts/kimi.js";
9
+ import { buildDelegationPolicyPrompt } from "./delegation.js";
9
10
  import { buildEnvironmentPrompt, defaultToolNames } from "./environment.js";
10
11
  import { buildRuntimePrompt } from "./runtime.js";
11
12
  export function composeSystemPrompt(options = {}) {
@@ -25,10 +26,12 @@ export function composeSystemPrompt(options = {}) {
25
26
  mode: options.mode,
26
27
  guidelines: buildGuidelines(options.tools ?? defaultToolNames, options.guidelines ?? []),
27
28
  });
29
+ const delegationPrompt = buildDelegationPolicyPrompt(options.tools ?? defaultToolNames);
28
30
  return [
29
31
  providerPrompt,
30
32
  environmentPrompt,
31
33
  runtimePrompt,
34
+ delegationPrompt,
32
35
  options.agentProfilePrompt,
33
36
  options.memoryPrompt,
34
37
  ].filter(Boolean).join("\n\n");
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Delegation policy section for the parent agent's system prompt.
3
+ *
4
+ * Two-sided by design (review 2026-06-12): positive triggers are quantified
5
+ * and read-only-scoped, negative clauses get equal weight — the user's hard
6
+ * constraint is "proactive, but never delegate-everything". Gated on the
7
+ * delegation tools being present, so child agents (whose tool sets never
8
+ * include spawn_agent/agent_team) never see it.
9
+ */
10
+ /**
11
+ * Returns the delegation policy section when the agent actually has the
12
+ * delegation tools; child agents and stripped-down tool sets get nothing.
13
+ */
14
+ export declare function buildDelegationPolicyPrompt(tools: string[]): string | undefined;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Delegation policy section for the parent agent's system prompt.
3
+ *
4
+ * Two-sided by design (review 2026-06-12): positive triggers are quantified
5
+ * and read-only-scoped, negative clauses get equal weight — the user's hard
6
+ * constraint is "proactive, but never delegate-everything". Gated on the
7
+ * delegation tools being present, so child agents (whose tool sets never
8
+ * include spawn_agent/agent_team) never see it.
9
+ */
10
+ const DELEGATION_POLICY = `## Delegation policy (subagents)
11
+
12
+ You can delegate work to background subagents (spawn_agent) and batch
13
+ fan-outs (agent_team). Delegate deliberately, not by default.
14
+
15
+ Delegate when:
16
+ - An investigation will clearly require more than four search or read
17
+ operations, or spans multiple files and patterns, and the conversation only
18
+ needs the conclusion — delegate to a subagent so the intermediate noise
19
+ stays out of the main context. Launch multiple subagents concurrently for
20
+ independent questions.
21
+ - The task naturally splits into the same read-only investigation or
22
+ analysis (review, audit, summarize) over several independent items (files,
23
+ modules, endpoints) — use agent_team.
24
+ - A side-investigation is independent of your current main-line work and can
25
+ run in the background while you continue.
26
+
27
+ Briefing a subagent: it starts with zero context, so the task message must be
28
+ a self-contained work order — state the goal, list everything you already
29
+ know, and write known file paths or commands directly into the task. Never
30
+ outsource knowledge you already hold: if the task hinges on a specific path
31
+ or line number, pin it down yourself first and put it in the briefing. When
32
+ earlier work lives in an existing subagent, prefer send_input to resume it
33
+ over spawning a fresh one.
34
+
35
+ Do NOT delegate when:
36
+ - The task requires editing files or running state-changing commands.
37
+ Built-in subagents are read-only; do edits and writes yourself unless a
38
+ write-capable (write_worktree) profile is explicitly available.
39
+ - The task takes one or two tool calls (reading a single file, looking up one
40
+ definition). The handoff overhead costs more than the task.
41
+ - Doing it well depends on conversation context (preferences the user stated
42
+ this session, decisions made in this discussion). Subagents start without
43
+ the conversation, and fork_context is not the fix: it copies only a recent
44
+ slice of the history, re-pays it as child tokens, and still loses earlier
45
+ decisions — do context-heavy work yourself.
46
+ - You already read the relevant files in this conversation; a subagent would
47
+ re-read everything from scratch.
48
+ - You already delegated it. Never redo delegated work locally, and never
49
+ re-spawn the same task to a second subagent.
50
+
51
+ When in doubt about a one-off task, do it yourself. When a task is clearly
52
+ the same read-only operation over three or more independent items — where
53
+ each item alone would take more than a couple of tool calls — prefer
54
+ agent_team over doing them sequentially yourself. For just two small items,
55
+ do them yourself with parallel tool calls.`;
56
+ /**
57
+ * Returns the delegation policy section when the agent actually has the
58
+ * delegation tools; child agents and stripped-down tool sets get nothing.
59
+ */
60
+ export function buildDelegationPolicyPrompt(tools) {
61
+ if (!tools.includes("spawn_agent"))
62
+ return undefined;
63
+ return DELEGATION_POLICY;
64
+ }
@@ -1,2 +1,6 @@
1
1
  import type { TaskType } from "../agent/task-classifier.js";
2
- export declare function reminderForTaskType(taskType: TaskType): string | undefined;
2
+ export interface TaskReminderOptions {
3
+ /** Whether this agent has the delegation tools (parent agents only). */
4
+ canDelegate?: boolean;
5
+ }
6
+ export declare function reminderForTaskType(taskType: TaskType, options?: TaskReminderOptions): string | undefined;
@@ -1,5 +1,13 @@
1
1
  import { wrapInSystemReminder } from "./reminders.js";
2
- export function reminderForTaskType(taskType) {
2
+ /**
3
+ * Delegation nudge for exploration-shaped tasks: injected at the decision
4
+ * point (start of turn), where it carries far more weight for weakly
5
+ * delegating models than the session-start system prompt. Task-type gating
6
+ * keeps it away from ordinary implementation/debugging turns, so it cannot
7
+ * amplify over-delegation.
8
+ */
9
+ const DELEGATION_NUDGE = "- If answering needs scanning many files and only the conclusion matters, delegate to a background subagent (spawn_agent); when it is the same read-only question over several independent items, fan out with agent_team.";
10
+ export function reminderForTaskType(taskType, options = {}) {
3
11
  switch (taskType) {
4
12
  case "debugging":
5
13
  return wrapInSystemReminder(`
@@ -39,7 +47,7 @@ Repository orientation workflow:
39
47
  - Start with the repo purpose and main execution paths.
40
48
  - Inspect README/package metadata plus core runtime files before summarizing.
41
49
  - Keep the first pass read-only unless the user asks for changes or runtime verification.
42
- `);
50
+ ${options.canDelegate ? `${DELEGATION_NUDGE}\n` : ""}`);
43
51
  case "product_discussion":
44
52
  return wrapInSystemReminder(`
45
53
  Product discussion workflow:
@@ -1,4 +1,5 @@
1
1
  import { getAvailableThinkingLevels, normalizeThinkingLevel } from "./provider-transform.js";
2
+ import { RateLimitError } from "./network/errors.js";
2
3
  import { isProviderTransportError, normalizeProviderNetworkError, providerFetch } from "./network/provider-transport.js";
3
4
  import { computeRetryDelayMs, getProviderMaxRetries, isRetryableHttpStatus, ProviderStreamInterruptedError, retryAfterMsFromResponse, sleepBeforeRetry, } from "./network/retry.js";
4
5
  const ANTHROPIC_VERSION = "2023-06-01";
@@ -32,6 +33,7 @@ export function createAnthropicMessagesProvider(options) {
32
33
  method: "POST",
33
34
  body: JSON.stringify(body),
34
35
  signal: chatOptions.abortSignal,
36
+ rateLimitPolicy: chatOptions.rateLimitPolicy,
35
37
  });
36
38
  yield* translateAnthropicStream(events);
37
39
  yield { type: "done" };
@@ -428,6 +430,27 @@ async function fetchAnthropicResponseWithRetry(options, request) {
428
430
  if (response.ok)
429
431
  return response;
430
432
  const detail = await readAnthropicErrorDetail(response);
433
+ // Rate-limit contract (design §4.5): under "defer" the transport performs
434
+ // no 429 backoff and throws the typed error immediately; under "handle"
435
+ // an exhausted 429 retry budget still surfaces as the typed error so the
436
+ // caller can recognize it without string matching.
437
+ if (response.status === 429) {
438
+ const retryAfterMs = retryAfterMsFromResponse(response);
439
+ if (request.rateLimitPolicy === "defer") {
440
+ throw new RateLimitError(`Anthropic Messages API rate limited (429): ${detail || response.statusText}`, {
441
+ status: 429,
442
+ retryAfterMs,
443
+ });
444
+ }
445
+ if (request.signal?.aborted || attempt >= maxRetries) {
446
+ throw new RateLimitError(`Anthropic Messages API rate limited (429) after ${attempt + 1} attempts: ${detail || response.statusText}`, {
447
+ status: 429,
448
+ retryAfterMs,
449
+ });
450
+ }
451
+ await sleepBeforeRetry(computeRetryDelayMs(attempt + 1, { retryAfterMs }), request.signal);
452
+ continue;
453
+ }
431
454
  const error = new Error(`Anthropic Messages API error ${response.status}: ${detail || response.statusText}`);
432
455
  if (request.signal?.aborted || attempt >= maxRetries || !isRetryableAnthropicHttpError(response.status, detail)) {
433
456
  throw error;