@bubblebrain-ai/bubble 0.0.21 → 0.0.23

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 (65) hide show
  1. package/README.md +197 -34
  2. package/dist/agent/abort-errors.d.ts +14 -0
  3. package/dist/agent/abort-errors.js +21 -0
  4. package/dist/agent/budget-ledger.d.ts +41 -0
  5. package/dist/agent/budget-ledger.js +64 -0
  6. package/dist/agent/child-runner.d.ts +55 -0
  7. package/dist/agent/child-runner.js +312 -0
  8. package/dist/agent/internal-reminder-sanitizer.js +29 -9
  9. package/dist/agent/profiles.d.ts +8 -0
  10. package/dist/agent/profiles.js +27 -5
  11. package/dist/agent/result-integrator.d.ts +22 -0
  12. package/dist/agent/result-integrator.js +50 -0
  13. package/dist/agent/subagent-control.d.ts +31 -0
  14. package/dist/agent/subagent-control.js +27 -0
  15. package/dist/agent/subagent-lifecycle-reminder.js +11 -2
  16. package/dist/agent/subagent-scheduler.d.ts +95 -0
  17. package/dist/agent/subagent-scheduler.js +256 -0
  18. package/dist/agent/subagent-store.d.ts +41 -0
  19. package/dist/agent/subagent-store.js +149 -0
  20. package/dist/agent/subagent-summary.d.ts +30 -0
  21. package/dist/agent/subagent-summary.js +74 -0
  22. package/dist/agent/worktree.d.ts +29 -0
  23. package/dist/agent/worktree.js +73 -0
  24. package/dist/agent.d.ts +63 -5
  25. package/dist/agent.js +360 -287
  26. package/dist/approval/controller.js +9 -1
  27. package/dist/approval/tool-helper.js +2 -0
  28. package/dist/approval/types.d.ts +17 -1
  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 +1 -0
  33. package/dist/main.js +38 -2
  34. package/dist/model-catalog.js +6 -0
  35. package/dist/network/errors.d.ts +28 -0
  36. package/dist/network/errors.js +24 -0
  37. package/dist/orchestrator/default-hooks.js +5 -1
  38. package/dist/prompt/compose.js +3 -0
  39. package/dist/prompt/delegation.d.ts +14 -0
  40. package/dist/prompt/delegation.js +64 -0
  41. package/dist/prompt/task-reminders.d.ts +5 -1
  42. package/dist/prompt/task-reminders.js +10 -2
  43. package/dist/provider-anthropic.js +23 -0
  44. package/dist/provider-transform.js +14 -0
  45. package/dist/provider.js +23 -3
  46. package/dist/slash-commands/commands.js +29 -2
  47. package/dist/slash-commands/types.d.ts +2 -0
  48. package/dist/tools/agent-lifecycle.d.ts +29 -3
  49. package/dist/tools/agent-lifecycle.js +394 -40
  50. package/dist/tools/child-tools.d.ts +31 -0
  51. package/dist/tools/child-tools.js +106 -0
  52. package/dist/tools/index.js +1 -1
  53. package/dist/tui/run.d.ts +17 -1
  54. package/dist/tui/run.js +155 -10
  55. package/dist/tui/session-picker-data.d.ts +18 -0
  56. package/dist/tui/session-picker-data.js +21 -0
  57. package/dist/tui/trace-groups.js +41 -5
  58. package/dist/tui/wordmark.d.ts +2 -0
  59. package/dist/tui/wordmark.js +31 -4
  60. package/dist/tui-ink/approval/approval-dialog.js +10 -0
  61. package/dist/tui-opentui/approval/approval-dialog.js +10 -0
  62. package/dist/types.d.ts +17 -0
  63. package/dist/update/index.d.ts +18 -4
  64. package/dist/update/index.js +41 -19
  65. package/package.json +1 -1
@@ -1,6 +1,32 @@
1
- import type { ToolRegistryEntry } from "../types.js";
2
- export declare function createSpawnAgentTool(): ToolRegistryEntry;
1
+ import type { AgentProfile } from "../agent/profiles.js";
2
+ import type { ApprovalController } from "../approval/types.js";
3
+ import type { ToolRegistryEntry, ToolResult } from "../types.js";
4
+ export interface AgentLifecycleToolOptions {
5
+ /** Working directory used for profile discovery in tool descriptions. */
6
+ cwd?: string;
7
+ /** Trust gate for project-local .bubble/agents profiles (design §10.2). */
8
+ approval?: ApprovalController;
9
+ }
10
+ /**
11
+ * Session-scoped trust decisions for project profiles, keyed by file path +
12
+ * content hash so an edited file re-prompts (design §10.2). Shared across the
13
+ * lifecycle tools created by one factory call.
14
+ */
15
+ declare class ProjectProfileTrust {
16
+ private readonly approval?;
17
+ private readonly approved;
18
+ constructor(approval?: ApprovalController | undefined);
19
+ /** Returns undefined when trusted, else a blocked ToolResult. */
20
+ ensureTrusted(profile: AgentProfile): Promise<ToolResult | undefined>;
21
+ }
22
+ export declare function createSpawnAgentTool(options?: AgentLifecycleToolOptions, sharedTrust?: ProjectProfileTrust): ToolRegistryEntry;
3
23
  export declare function createWaitAgentTool(): ToolRegistryEntry;
4
24
  export declare function createSendInputTool(): ToolRegistryEntry;
5
25
  export declare function createCloseAgentTool(): ToolRegistryEntry;
6
- export declare function createAgentLifecycleTools(): ToolRegistryEntry[];
26
+ export declare function createListAgentsTool(): ToolRegistryEntry;
27
+ /** Items bound for one agent_team call (design §1.2). */
28
+ export declare const AGENT_TEAM_MIN_ITEMS = 2;
29
+ export declare const AGENT_TEAM_MAX_ITEMS = 32;
30
+ export declare function createAgentTeamTool(options?: AgentLifecycleToolOptions, sharedTrust?: ProjectProfileTrust): ToolRegistryEntry;
31
+ export declare function createAgentLifecycleTools(options?: AgentLifecycleToolOptions): ToolRegistryEntry[];
32
+ export {};
@@ -1,21 +1,122 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
1
3
  import { discoverAgentProfiles, findAgentProfile } from "../agent/profiles.js";
2
4
  import { formatSubagentRoute } from "../agent/subagent-route-format.js";
3
- export function createSpawnAgentTool() {
5
+ /**
6
+ * Session-scoped trust decisions for project profiles, keyed by file path +
7
+ * content hash so an edited file re-prompts (design §10.2). Shared across the
8
+ * lifecycle tools created by one factory call.
9
+ */
10
+ class ProjectProfileTrust {
11
+ approval;
12
+ approved = new Set();
13
+ constructor(approval) {
14
+ this.approval = approval;
15
+ }
16
+ /** Returns undefined when trusted, else a blocked ToolResult. */
17
+ async ensureTrusted(profile) {
18
+ if (profile.source !== "project")
19
+ return undefined;
20
+ const filePath = profile.filePath ?? "<unknown>";
21
+ let content;
22
+ try {
23
+ content = profile.filePath ? readFileSync(profile.filePath, "utf8") : profile.prompt;
24
+ }
25
+ catch {
26
+ content = profile.prompt;
27
+ }
28
+ const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
29
+ const key = `${filePath}:${contentHash}`;
30
+ if (this.approved.has(key))
31
+ return undefined;
32
+ if (!this.approval) {
33
+ return {
34
+ content: [
35
+ `Blocked: subagent profile "${profile.name}" comes from project-local .bubble/agents and needs the user's approval,`,
36
+ "but no approval flow is available in this session. Use a built-in or user-level profile instead.",
37
+ ].join("\n"),
38
+ isError: true,
39
+ status: "blocked",
40
+ };
41
+ }
42
+ const decision = await this.approval.request({
43
+ type: "agent_profile",
44
+ name: profile.name,
45
+ path: filePath,
46
+ contentHash,
47
+ promptPreview: profile.prompt.split("\n").slice(0, 6).join("\n").slice(0, 600),
48
+ });
49
+ if (decision.action === "approve") {
50
+ this.approved.add(key);
51
+ return undefined;
52
+ }
53
+ const feedback = decision.feedback?.trim();
54
+ return {
55
+ content: [
56
+ `Blocked: the user declined to trust project agent profile "${profile.name}".`,
57
+ feedback ? `User feedback: ${feedback}` : "Use a built-in or user-level profile instead.",
58
+ ].join("\n"),
59
+ isError: true,
60
+ status: "blocked",
61
+ };
62
+ }
63
+ }
64
+ const PROFILE_DESCRIPTION_TTL_MS = 5_000;
65
+ /**
66
+ * Custom profile descriptions must reach the model or the whole custom-profile
67
+ * system is unreachable (design §10.1): the agent_type description is built
68
+ * from live profile discovery, refreshed with a short TTL so file edits are
69
+ * picked up on the next turn.
70
+ */
71
+ function createProfileLister(cwd) {
72
+ let cachedAt = 0;
73
+ let cached = "";
74
+ return () => {
75
+ if (!cwd)
76
+ return "";
77
+ const now = Date.now();
78
+ if (now - cachedAt < PROFILE_DESCRIPTION_TTL_MS && cached)
79
+ return cached;
80
+ cachedAt = now;
81
+ try {
82
+ const { profiles } = discoverAgentProfiles(cwd, "both");
83
+ const lines = profiles
84
+ .filter((profile) => !profile.name.startsWith("builtin:"))
85
+ .map((profile) => {
86
+ const tag = profile.source === "project" ? " [project: requires user approval on first use]" : "";
87
+ return `- ${profile.name}${tag} — ${truncateText(profile.description, 120)}`;
88
+ });
89
+ cached = lines.length > 0 ? ` Available profiles:\n${lines.join("\n")}` : "";
90
+ }
91
+ catch {
92
+ cached = "";
93
+ }
94
+ return cached;
95
+ };
96
+ }
97
+ export function createSpawnAgentTool(options = {}, sharedTrust) {
98
+ const trust = sharedTrust ?? new ProjectProfileTrust(options.approval);
99
+ const listProfiles = createProfileLister(options.cwd);
100
+ const baseDescription = [
101
+ "Start a child subagent in the background and return its agent_id plus random nickname.",
102
+ "The child has an independent thread; call wait_agent later to collect its result.",
103
+ "Proactively delegate multi-file investigations whose intermediate steps would be noise in the main conversation.",
104
+ "Do the work yourself when it takes only a couple of tool calls or needs conversation context — unless the user explicitly asks for a subagent, in which case spawn one.",
105
+ "The child starts with zero context: write the task as a self-contained work order — state the goal, include known file paths or commands, and never make it rediscover knowledge you already hold.",
106
+ "After spawning, do not duplicate the same delegated work locally; either wait for the child or do clearly non-overlapping work.",
107
+ "A child may start as queued when concurrency slots are busy; it starts automatically, no action needed.",
108
+ ].join(" ");
4
109
  return {
5
110
  name: "spawn_agent",
6
111
  readOnly: true,
7
112
  effect: "read",
8
- description: [
9
- "Start a child subagent in the background and return its agent_id plus random nickname.",
10
- "Use this for Codex-style delegation. The child has an independent thread; call wait_agent later to collect its result.",
11
- "When the user asks to use a subagent, spawn first with a clear task instead of doing the delegated investigation yourself.",
12
- "After spawning, do not duplicate the same delegated work locally; either wait for the child or do clearly non-overlapping work.",
13
- "agent_type defaults to default. Built-in types include default, explorer, and worker.",
14
- ].join(" "),
113
+ get description() {
114
+ return baseDescription + listProfiles();
115
+ },
15
116
  parameters: {
16
117
  type: "object",
17
118
  properties: {
18
- agent_type: { type: "string", description: "Subagent profile or role name. Defaults to default." },
119
+ agent_type: { type: "string", description: "Subagent profile or role name. Defaults to default. Built-in types include default, explorer, and worker; see the tool description for custom profiles." },
19
120
  agent: { type: "string", description: "Alias for agent_type." },
20
121
  category: { type: "string", description: "Optional semantic category for model/thinking routing, such as quick, deep, explore, review, frontend, or writing." },
21
122
  message: { type: "string", description: "Initial task for the subagent." },
@@ -24,11 +125,7 @@ export function createSpawnAgentTool() {
24
125
  agentScope: {
25
126
  type: "string",
26
127
  enum: ["user", "project", "both"],
27
- description: "Which profile locations to load. Defaults to user profiles plus built-ins.",
28
- },
29
- allowProjectAgents: {
30
- type: "boolean",
31
- description: "Required to run profiles loaded from project-local .bubble/agents.",
128
+ description: "Which profile locations to load. Defaults to built-ins plus user and project profiles; project profiles need the user's one-time approval.",
32
129
  },
33
130
  approval: {
34
131
  type: "string",
@@ -47,12 +144,15 @@ export function createSpawnAgentTool() {
47
144
  return { content: "Error: spawn_agent requires message or task.", isError: true };
48
145
  }
49
146
  const profileName = stringArg(args.agent_type) ?? stringArg(args.agent) ?? "default";
50
- const resolved = resolveProfile(ctx.cwd, profileName, parseScope(args.agentScope), args.allowProjectAgents === true);
147
+ const resolved = resolveProfile(ctx.cwd, profileName, parseScope(args.agentScope));
51
148
  if ("error" in resolved)
52
149
  return resolved.error;
53
- if (resolved.profile.mode !== "readonly") {
54
- return unsupportedProfile(resolved.profile);
55
- }
150
+ const modeBlock = unsupportedProfile(resolved.profile);
151
+ if (modeBlock)
152
+ return modeBlock;
153
+ const trustBlock = await trust.ensureTrusted(resolved.profile);
154
+ if (trustBlock)
155
+ return trustBlock;
56
156
  try {
57
157
  const snapshot = await ctx.agent.spawnSubAgent(message, ctx.cwd, {
58
158
  profile: resolved.profile,
@@ -67,7 +167,7 @@ export function createSpawnAgentTool() {
67
167
  `agent_id: ${snapshot.agentId}`,
68
168
  `status: ${snapshot.status}`,
69
169
  ...formatRouteLines(snapshot),
70
- `next: call wait_agent for ${snapshot.agentId} before reporting this subagent's current status or final result`,
170
+ ...spawnNextSteps(snapshot),
71
171
  "counting: this spawn result creates one unique subagent; later wait_agent results for the same agent_id are updates, not additional subagents",
72
172
  ]);
73
173
  }
@@ -84,7 +184,7 @@ export function createWaitAgentTool() {
84
184
  effect: "read",
85
185
  description: [
86
186
  "Wait for one or more spawned subagents to reach a final status and return snapshots.",
87
- "If the wait times out while children are still running, call wait_agent again with a longer timeout instead of redoing the same delegated work locally.",
187
+ "If the wait times out while children are still queued or running, call wait_agent again with a longer timeout instead of redoing the same delegated work locally.",
88
188
  ].join(" "),
89
189
  parameters: {
90
190
  type: "object",
@@ -119,7 +219,7 @@ export function createWaitAgentTool() {
119
219
  if (snapshots.some((snapshot) => !isFinalSnapshotStatus(snapshot.status))) {
120
220
  return formatLifecycleResult("wait_agent", snapshots, [
121
221
  "wait_agent timed out before a delegated result was ready.",
122
- "The subagent is still running; call wait_agent again with a longer timeout instead of duplicating the same work locally.",
222
+ ...waitTimeoutGuidance(snapshots),
123
223
  "",
124
224
  ...snapshots.flatMap((snapshot) => [...formatSnapshot(snapshot), ""]),
125
225
  ]);
@@ -137,7 +237,7 @@ export function createSendInputTool() {
137
237
  name: "send_input",
138
238
  readOnly: true,
139
239
  effect: "read",
140
- description: "Send a follow-up message to an existing subagent thread. If it is still running, pass interrupt:true to cancel and redirect it.",
240
+ description: "Send a follow-up message to an existing subagent thread. If it is still running, pass interrupt:true to cancel and redirect it. Restarting a finished child goes through the scheduler like any spawn.",
141
241
  parameters: {
142
242
  type: "object",
143
243
  properties: {
@@ -168,6 +268,7 @@ export function createSendInputTool() {
168
268
  `Sent input to ${snapshot.nickname} (${snapshot.agentName})`,
169
269
  `agent_id: ${snapshot.agentId}`,
170
270
  `status: ${snapshot.status}`,
271
+ ...(snapshot.status === "queued" ? [queuedStatusLine(snapshot)] : []),
171
272
  ]);
172
273
  }
173
274
  catch (error) {
@@ -212,15 +313,196 @@ export function createCloseAgentTool() {
212
313
  },
213
314
  };
214
315
  }
215
- export function createAgentLifecycleTools() {
316
+ export function createListAgentsTool() {
317
+ return {
318
+ name: "list_agents",
319
+ readOnly: true,
320
+ effect: "read",
321
+ description: "List this session's subagents with their current status. Use it to recall which children exist before spawning duplicates or narrating progress.",
322
+ parameters: {
323
+ type: "object",
324
+ properties: {
325
+ status_filter: {
326
+ type: "array",
327
+ description: "Only include these statuses (queued, running, completed, failed, blocked, cancelled, closed).",
328
+ items: { type: "string" },
329
+ },
330
+ include_closed: { type: "boolean", description: "Include closed subagents. Defaults to false." },
331
+ },
332
+ additionalProperties: false,
333
+ },
334
+ async execute(args, ctx) {
335
+ if (!ctx.agent?.listSubAgents) {
336
+ return toolRuntimeMissing("list_agents");
337
+ }
338
+ const filter = Array.isArray(args.status_filter)
339
+ ? new Set(args.status_filter.filter((item) => typeof item === "string"))
340
+ : undefined;
341
+ const includeClosed = args.include_closed === true;
342
+ const snapshots = ctx.agent.listSubAgents()
343
+ .filter((snapshot) => includeClosed || snapshot.status !== "closed")
344
+ .filter((snapshot) => !filter || filter.size === 0 || filter.has(snapshot.status));
345
+ if (snapshots.length === 0) {
346
+ return {
347
+ content: "No subagents match. Spawn one with spawn_agent when delegation helps.",
348
+ metadata: { kind: "subagent", mode: "lifecycle", subagents: [] },
349
+ };
350
+ }
351
+ const lines = [
352
+ `${snapshots.length} subagent${snapshots.length === 1 ? "" : "s"}:`,
353
+ ...snapshots.map((snapshot) => {
354
+ const usage = snapshot.usage ? ` tokens=${snapshot.usage.totalTokens}` : "";
355
+ const queued = snapshot.status === "queued" && snapshot.queuePosition !== undefined
356
+ ? ` queue_position=${snapshot.queuePosition}`
357
+ : "";
358
+ return `- ${snapshot.nickname} (${formatSnapshotRole(snapshot)}) agent_id=${snapshot.agentId} status=${snapshot.status}${queued}${usage} task=${truncateText(snapshot.task, 80)}`;
359
+ }),
360
+ ];
361
+ return {
362
+ content: lines.join("\n"),
363
+ metadata: {
364
+ kind: "subagent",
365
+ mode: "lifecycle",
366
+ subagents: snapshots.map(snapshotToMetadata),
367
+ },
368
+ };
369
+ },
370
+ };
371
+ }
372
+ /** Items bound for one agent_team call (design §1.2). */
373
+ export const AGENT_TEAM_MIN_ITEMS = 2;
374
+ export const AGENT_TEAM_MAX_ITEMS = 32;
375
+ export function createAgentTeamTool(options = {}, sharedTrust) {
376
+ const trust = sharedTrust ?? new ProjectProfileTrust(options.approval);
377
+ return {
378
+ name: "agent_team",
379
+ readOnly: true,
380
+ effect: "read",
381
+ description: [
382
+ "Run the same task template over many items as parallel subagents (homogeneous map fan-out).",
383
+ "Proactively use this when a task naturally splits into the same read-only operation over several independent items.",
384
+ "Each item becomes one child with its own agent_id; the call blocks until every member reaches a final state and returns results in item order.",
385
+ "Failed members can be resumed individually with send_input afterwards.",
386
+ `Use ${AGENT_TEAM_MIN_ITEMS}-${AGENT_TEAM_MAX_ITEMS} items. agent_team must be the ONLY tool call in your response; run other tools or further teams after it returns.`,
387
+ "Scoping rule: split items so members never overlap or conflict with each other.",
388
+ ].join(" "),
389
+ parameters: {
390
+ type: "object",
391
+ properties: {
392
+ description: { type: "string", description: "Short (3-5 word) description of the team, shown in the UI." },
393
+ agent_type: { type: "string", description: "Subagent profile for every member. Defaults to default." },
394
+ category: { type: "string", description: "Optional semantic category for model/thinking routing." },
395
+ prompt_template: { type: "string", description: "Task template applied to each item. Must contain the literal placeholder {{item}}." },
396
+ items: {
397
+ type: "array",
398
+ description: `Items to fan out over (${AGENT_TEAM_MIN_ITEMS}-${AGENT_TEAM_MAX_ITEMS} unique strings); each becomes one subagent.`,
399
+ items: { type: "string" },
400
+ },
401
+ },
402
+ required: ["description", "prompt_template", "items"],
403
+ additionalProperties: false,
404
+ },
405
+ async execute(args, ctx) {
406
+ if (!ctx.agent?.runAgentTeam) {
407
+ return toolRuntimeMissing("agent_team");
408
+ }
409
+ const template = stringArg(args.prompt_template);
410
+ if (!template) {
411
+ return { content: "Error: agent_team requires prompt_template.", isError: true };
412
+ }
413
+ if (!template.includes("{{item}}")) {
414
+ return {
415
+ content: "Error: prompt_template must contain the literal placeholder {{item}} — it is replaced with each item. Example: \"Review {{item}} for risks.\"",
416
+ isError: true,
417
+ };
418
+ }
419
+ const rawItems = Array.isArray(args.items)
420
+ ? args.items.filter((item) => typeof item === "string" && !!item.trim()).map((item) => item.trim())
421
+ : [];
422
+ const items = [...new Set(rawItems)];
423
+ if (items.length < AGENT_TEAM_MIN_ITEMS) {
424
+ return {
425
+ content: `Error: agent_team needs at least ${AGENT_TEAM_MIN_ITEMS} unique items after deduplication (got ${items.length}). For a single task use spawn_agent instead.`,
426
+ isError: true,
427
+ };
428
+ }
429
+ if (items.length > AGENT_TEAM_MAX_ITEMS) {
430
+ return {
431
+ content: `Error: agent_team accepts at most ${AGENT_TEAM_MAX_ITEMS} items (got ${items.length}). Split the work into sequential teams.`,
432
+ isError: true,
433
+ };
434
+ }
435
+ const profileName = stringArg(args.agent_type) ?? "default";
436
+ const resolved = resolveProfile(ctx.cwd, profileName, "both");
437
+ if ("error" in resolved)
438
+ return resolved.error;
439
+ const modeBlock = unsupportedProfile(resolved.profile);
440
+ if (modeBlock)
441
+ return modeBlock;
442
+ const trustBlock = await trust.ensureTrusted(resolved.profile);
443
+ if (trustBlock)
444
+ return trustBlock;
445
+ try {
446
+ const snapshots = await ctx.agent.runAgentTeam(ctx.cwd, {
447
+ profile: resolved.profile,
448
+ category: stringArg(args.category),
449
+ promptTemplate: template,
450
+ items,
451
+ parentToolCallId: ctx.toolCall?.id ?? snapshotFallbackId(),
452
+ emitUpdate: ctx.emitUpdate,
453
+ abortSignal: ctx.abortSignal,
454
+ });
455
+ const counts = teamStatusCounts(snapshots);
456
+ const lines = [
457
+ `agent_team "${stringArg(args.description) ?? "team"}": ${snapshots.length} members — ${counts}`,
458
+ "Failed or cancelled members can be resumed individually with send_input (see per-member guidance below).",
459
+ "",
460
+ ...snapshots.flatMap((snapshot, index) => [
461
+ `### item ${index + 1}: ${truncateText(items[index] ?? "", 100)}`,
462
+ ...formatSnapshot(snapshot),
463
+ "",
464
+ ]),
465
+ ];
466
+ return {
467
+ content: lines.join("\n").trim(),
468
+ status: snapshots.every((snapshot) => snapshot.status === "completed")
469
+ ? "success"
470
+ : snapshots.some((snapshot) => snapshot.status === "completed")
471
+ ? "partial"
472
+ : "blocked",
473
+ isError: snapshots.length > 0 && snapshots.every((snapshot) => snapshot.status !== "completed"),
474
+ metadata: {
475
+ kind: "subagent",
476
+ mode: "team",
477
+ subagents: snapshots.map(snapshotToMetadata),
478
+ },
479
+ };
480
+ }
481
+ catch (error) {
482
+ return toolError("agent_team", error);
483
+ }
484
+ },
485
+ };
486
+ }
487
+ function teamStatusCounts(snapshots) {
488
+ const counts = new Map();
489
+ for (const snapshot of snapshots) {
490
+ counts.set(snapshot.status, (counts.get(snapshot.status) ?? 0) + 1);
491
+ }
492
+ return [...counts.entries()].map(([status, count]) => `${status} ${count}`).join(" / ");
493
+ }
494
+ export function createAgentLifecycleTools(options = {}) {
495
+ const trust = new ProjectProfileTrust(options.approval);
216
496
  return [
217
- createSpawnAgentTool(),
497
+ createSpawnAgentTool(options, trust),
218
498
  createWaitAgentTool(),
219
499
  createSendInputTool(),
220
500
  createCloseAgentTool(),
501
+ createListAgentsTool(),
502
+ createAgentTeamTool(options, trust),
221
503
  ];
222
504
  }
223
- function resolveProfile(cwd, name, scope, allowProjectAgents) {
505
+ function resolveProfile(cwd, name, scope) {
224
506
  const discovered = discoverAgentProfiles(cwd, scope);
225
507
  const profile = findAgentProfile(discovered.profiles, name);
226
508
  if (!profile) {
@@ -232,20 +514,38 @@ function resolveProfile(cwd, name, scope, allowProjectAgents) {
232
514
  },
233
515
  };
234
516
  }
235
- if (profile.source === "project" && !allowProjectAgents) {
236
- return {
237
- error: {
238
- content: [
239
- `Blocked: subagent profile "${profile.name}" was loaded from project-local .bubble/agents.`,
240
- "Pass allowProjectAgents: true only when you trust this repository's agent profile prompts.",
241
- ].join("\n"),
242
- isError: true,
243
- status: "blocked",
244
- },
245
- };
246
- }
247
517
  return { profile };
248
518
  }
519
+ function spawnNextSteps(snapshot) {
520
+ if (snapshot.status === "queued" && snapshot.queuePosition !== undefined && snapshot.queuePosition > 0) {
521
+ return [
522
+ queuedStatusLine(snapshot),
523
+ "next: continue other non-overlapping work, then call wait_agent to collect the result",
524
+ ];
525
+ }
526
+ return [
527
+ `next: call wait_agent for ${snapshot.agentId} before reporting this subagent's current status or final result`,
528
+ ];
529
+ }
530
+ function queuedStatusLine(snapshot) {
531
+ const behind = snapshot.queuePosition !== undefined && snapshot.queuePosition > 1
532
+ ? ` behind ${snapshot.queuePosition - 1} child${snapshot.queuePosition - 1 === 1 ? "" : "ren"}`
533
+ : "";
534
+ return `queued: waiting for a concurrency slot${behind}; it starts automatically — no action needed`;
535
+ }
536
+ function waitTimeoutGuidance(snapshots) {
537
+ const lines = [];
538
+ const queued = snapshots.filter((snapshot) => snapshot.status === "queued");
539
+ const running = snapshots.filter((snapshot) => snapshot.status === "running");
540
+ if (queued.length > 0) {
541
+ lines.push(`${queued.length} child${queued.length === 1 ? " is" : "ren are"} queued for a concurrency slot and will start automatically.`);
542
+ }
543
+ if (running.length > 0) {
544
+ lines.push(`${running.length} child${running.length === 1 ? " is" : "ren are"} still running.`);
545
+ }
546
+ lines.push("Call wait_agent again with a longer timeout instead of duplicating the same work locally.");
547
+ return lines;
548
+ }
249
549
  function formatLifecycleResult(toolName, snapshots, header) {
250
550
  const lines = header ?? [`${toolName}: ${snapshots.length} subagent${snapshots.length === 1 ? "" : "s"}`];
251
551
  if (!header)
@@ -267,7 +567,7 @@ function formatLifecycleResult(toolName, snapshots, header) {
267
567
  };
268
568
  }
269
569
  function lifecycleStatus(toolName, snapshots) {
270
- if (toolName === "spawn_agent" || toolName === "send_input" || toolName === "close_agent") {
570
+ if (toolName === "spawn_agent" || toolName === "send_input" || toolName === "close_agent" || toolName === "list_agents") {
271
571
  return "success";
272
572
  }
273
573
  if (snapshots.some((snapshot) => !isFinalSnapshotStatus(snapshot.status))) {
@@ -294,20 +594,66 @@ function formatSnapshot(snapshot) {
294
594
  }
295
595
  lines.push(...formatRouteLines(snapshot));
296
596
  lines.push(`task: ${snapshot.task}`);
597
+ if (snapshot.status === "queued") {
598
+ lines.push(queuedStatusLine(snapshot));
599
+ }
297
600
  if (snapshot.summary) {
298
601
  lines.push("", "Summary:", snapshot.summary);
299
602
  }
300
603
  else if (snapshot.status === "completed") {
301
604
  lines.push("", "Summary: (no final text summary was produced)");
302
605
  }
606
+ if (snapshot.worktree && isFinalSnapshotStatus(snapshot.status)) {
607
+ if (snapshot.worktree.changed) {
608
+ lines.push("", `Worktree with changes: ${snapshot.worktree.path}`, "Review the diff there and apply / cherry-pick / discard; the parent working tree was never touched.", ...(snapshot.worktree.diffStat ? ["Diff stat:", snapshot.worktree.diffStat] : []));
609
+ }
610
+ else {
611
+ lines.push("", "Worktree: no changes were left behind (removed automatically).");
612
+ }
613
+ }
303
614
  if (snapshot.toolNotes.length > 0) {
304
615
  lines.push("", "Recent tool notes:", ...snapshot.toolNotes.slice(-8).map((note) => `- ${note}`));
305
616
  }
306
617
  if (snapshot.error) {
307
618
  lines.push("", `Error: ${snapshot.error}`);
308
619
  }
620
+ lines.push(...finalGuidanceLines(snapshot));
309
621
  return lines;
310
622
  }
623
+ /**
624
+ * Per-reason guidance (design §3.1): a resume hint is rendered iff the
625
+ * runtime judged the run resumable. Wait timeouts are not final states and
626
+ * never reach this function with a finalReason.
627
+ */
628
+ function finalGuidanceLines(snapshot) {
629
+ if (!isFinalSnapshotStatus(snapshot.status) || snapshot.status === "completed" || snapshot.status === "closed") {
630
+ return [];
631
+ }
632
+ switch (snapshot.finalReason) {
633
+ case "rate_limited_exhausted":
634
+ return [
635
+ "",
636
+ resumeLine(snapshot.agentId),
637
+ "note: the provider was rate limited; prefer resuming later or running fewer children at once",
638
+ ];
639
+ case "failed_transient":
640
+ case "cancelled_interrupt":
641
+ case "cancelled_user":
642
+ case "cancelled_parent_abort":
643
+ return ["", resumeLine(snapshot.agentId)];
644
+ case "cancelled_budget":
645
+ return ["", "budget exhausted — do not resume this child; integrate what it already reported and narrow the task if more is needed"];
646
+ case "blocked":
647
+ return ["", "blocked: re-spawn with an adjusted profile or approval setting — resuming as-is would hit the same block"];
648
+ case "failed_fatal":
649
+ return [];
650
+ default:
651
+ return snapshot.resumable ? ["", resumeLine(snapshot.agentId)] : [];
652
+ }
653
+ }
654
+ function resumeLine(agentId) {
655
+ return `resume: call send_input with agent_id ${agentId} to continue this child with its context intact`;
656
+ }
311
657
  function formatSnapshotRole(snapshot) {
312
658
  return [snapshot.agentName, snapshot.category ? `/${snapshot.category}` : ""].join("") || "default";
313
659
  }
@@ -321,6 +667,8 @@ function snapshotToMetadata(snapshot) {
321
667
  agentName: snapshot.agentName,
322
668
  nickname: snapshot.nickname,
323
669
  status: snapshot.status === "closed" ? "cancelled" : snapshot.status,
670
+ finalReason: snapshot.finalReason,
671
+ resumable: snapshot.resumable,
324
672
  profileSource: snapshot.profileSource,
325
673
  category: snapshot.category,
326
674
  route: snapshot.route,
@@ -332,14 +680,16 @@ function snapshotToMetadata(snapshot) {
332
680
  };
333
681
  }
334
682
  function unsupportedProfile(profile) {
683
+ if (profile.mode === "readonly" || profile.mode === "write_worktree")
684
+ return undefined;
335
685
  return {
336
- content: `Error: subagent profile "${profile.name}" uses mode "${profile.mode}", but this runtime only supports readonly lifecycle subagents.`,
686
+ content: `Error: subagent profile "${profile.name}" uses mode "${profile.mode}", which is not supported. Use "readonly" for investigation or "write_worktree" for isolated write work.`,
337
687
  isError: true,
338
688
  status: "blocked",
339
689
  };
340
690
  }
341
691
  function parseScope(value) {
342
- return value === "project" || value === "both" ? value : "user";
692
+ return value === "project" || value === "user" ? value : "both";
343
693
  }
344
694
  function parseApproval(value) {
345
695
  return value === "fail" || value === "disabled" ? value : undefined;
@@ -359,6 +709,10 @@ function normalizeAgentIds(value, single) {
359
709
  function stringArg(value) {
360
710
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
361
711
  }
712
+ function truncateText(value, max) {
713
+ const oneLine = value.replace(/\s+/g, " ").trim();
714
+ return oneLine.length <= max ? oneLine : `${oneLine.slice(0, max - 3)}...`;
715
+ }
362
716
  function snapshotFallbackId() {
363
717
  return `spawn_${Date.now().toString(36)}`;
364
718
  }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-child tool factory for write_worktree subagents (design doc §8).
3
+ *
4
+ * Parent tools close over the parent cwd at creation, so a write child needs
5
+ * fresh instances bound to its worktree — with their own FileStateTracker —
6
+ * plus a worktree-scoped approval policy: file operations are runtime-checked
7
+ * to stay under the worktree root (the tools' own workspace fence does this
8
+ * structurally), bash auto-approves inside the worktree when the command
9
+ * passes a deny-list of escaping operations, and everything else fails fast.
10
+ */
11
+ import type { ApprovalController, ApprovalDecision, ApprovalRequest } from "../approval/types.js";
12
+ import type { PermissionCheckResult } from "../permissions/types.js";
13
+ import type { ToolRegistryEntry } from "../types.js";
14
+ export declare function isPathInsideWorktree(worktreeRoot: string, candidate: string): boolean;
15
+ /**
16
+ * Approval policy for a worktree child: containment is enforced by code
17
+ * (path checks, deny-list), never by prompt text. There is no interactive
18
+ * fallback — anything outside the policy fails fast (design §11).
19
+ */
20
+ export declare class WorktreeApprovalController implements ApprovalController {
21
+ private readonly worktreeRoot;
22
+ constructor(worktreeRoot: string);
23
+ request(req: ApprovalRequest): Promise<ApprovalDecision>;
24
+ checkRules(): PermissionCheckResult;
25
+ }
26
+ /**
27
+ * Builds the write child's toolset bound to its worktree: fresh instances
28
+ * with their own FileStateTracker and the worktree approval policy. A
29
+ * profile's tools list can narrow the set but never widen it.
30
+ */
31
+ export declare function createWorktreeChildTools(worktreeCwd: string, include?: string[]): ToolRegistryEntry[];