@bubblebrain-ai/bubble 0.0.58 → 0.0.60

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 (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -1,47 +1,68 @@
1
- const MAX_SKILL_DESC_CHARS = 200;
2
- const SKILLS_BUDGET_CHARS = 6000;
1
+ /** Default size of the always-present skill catalog (the listed lines, not the header). */
2
+ export const SKILL_CATALOG_CHARS = 12_000;
3
+ /** Each description is cut to this length; it only has to tell the model when to load. */
4
+ export const SKILL_CATALOG_DESC_CHARS = 120;
5
+ export const SKILL_CATALOG_HEADING = "## Skills";
6
+ /** First line under the heading; together they mark the catalog unambiguously. */
7
+ export const SKILL_CATALOG_INTRO = "Skills are task-specific instructions loaded on demand. When a task matches a skill below, call `skill` with its exact name before starting, then follow it. Load only what clearly applies.";
3
8
  const SOURCE_PRIORITY = {
4
9
  project: 0,
5
10
  configured: 1,
6
11
  user: 2,
7
12
  };
8
- export function formatSkillsPrompt(skills) {
9
- if (skills.length === 0)
10
- return "";
13
+ /**
14
+ * The always-present skill catalog: name and a one-line description per skill,
15
+ * so the model knows what exists and loads a body with `skill` only when it
16
+ * applies. Ordering is deterministic (source, then name) so the system prompt
17
+ * stays byte-identical, and cacheable, while the skill set is unchanged.
18
+ */
19
+ export function buildSkillCatalog(skills, options = {}) {
20
+ const budgetChars = Math.max(0, Math.floor(options.budgetChars ?? SKILL_CATALOG_CHARS));
21
+ const empty = { text: "", listed: 0, unlisted: skills.length, chars: 0, budgetChars };
22
+ if (skills.length === 0 || budgetChars === 0)
23
+ return empty;
11
24
  const sorted = [...skills].sort((a, b) => {
12
25
  const ap = SOURCE_PRIORITY[a.source ?? "user"] ?? 3;
13
26
  const bp = SOURCE_PRIORITY[b.source ?? "user"] ?? 3;
14
27
  if (ap !== bp)
15
28
  return ap - bp;
16
- return a.name.localeCompare(b.name);
29
+ // Code-point order, not locale collation: the prompt must be byte-identical across runtimes.
30
+ return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
17
31
  });
18
- const header = [
19
- "Skills provide specialized instructions and workflows for specific tasks.",
20
- "Use the skill system when a task clearly matches a skill description.",
21
- "Available skills:",
22
- ];
23
32
  const lines = [];
24
33
  let used = 0;
25
- let dropped = 0;
26
34
  for (const skill of sorted) {
27
35
  const line = formatSkillLine(skill);
28
- if (used + line.length + 1 > SKILLS_BUDGET_CHARS) {
29
- dropped = sorted.length - lines.length;
36
+ if (used + line.length + 1 > budgetChars)
30
37
  break;
31
- }
32
38
  lines.push(line);
33
39
  used += line.length + 1;
34
40
  }
35
- if (dropped > 0) {
36
- lines.push(`- ... and ${dropped} more skills (use /skills to list all)`);
41
+ if (lines.length === 0)
42
+ return empty;
43
+ const unlisted = sorted.length - lines.length;
44
+ if (unlisted > 0) {
45
+ lines.push(options.searchable
46
+ ? `- … and ${unlisted} more skills not listed here; use skill_search to find them.`
47
+ : `- … and ${unlisted} more skills not listed here.`);
37
48
  }
38
- return [...header, ...lines].join("\n");
49
+ const text = [
50
+ SKILL_CATALOG_HEADING,
51
+ SKILL_CATALOG_INTRO,
52
+ ...lines,
53
+ ].join("\n");
54
+ return { text, listed: sorted.length - unlisted, unlisted, chars: used, budgetChars };
55
+ }
56
+ /** The catalog section text (see buildSkillCatalog). */
57
+ export function formatSkillsPrompt(skills, options = {}) {
58
+ return buildSkillCatalog(skills, options).text;
39
59
  }
40
60
  function formatSkillLine(skill) {
41
- const tagSuffix = skill.tags && skill.tags.length > 0 ? ` [tags: ${skill.tags.join(", ")}]` : "";
42
- const rawDesc = skill.description ?? "";
43
- const desc = rawDesc.length > MAX_SKILL_DESC_CHARS
44
- ? rawDesc.slice(0, MAX_SKILL_DESC_CHARS - 1) + "…"
45
- : rawDesc;
46
- return `- ${skill.name}: ${desc}${tagSuffix}`;
61
+ const raw = (skill.description ?? "").replace(/\s+/g, " ").trim();
62
+ // Cut by code points so an emoji is never split into a lone surrogate, which some APIs reject.
63
+ const points = Array.from(raw);
64
+ const desc = points.length > SKILL_CATALOG_DESC_CHARS
65
+ ? points.slice(0, SKILL_CATALOG_DESC_CHARS - 1).join("").trimEnd() + "…"
66
+ : raw;
67
+ return desc ? `- ${skill.name}: ${desc}` : `- ${skill.name}`;
47
68
  }
@@ -1,4 +1,7 @@
1
+ import type { SlashCommand } from "./types.js";
1
2
  import type { UnifiedCommand } from "./unified.js";
3
+ /** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
4
+ export declare function syncSystemPrompt(ctx: Pick<Parameters<SlashCommand["handler"]>[1], "agent" | "cwd">, model: string): void;
2
5
  /**
3
6
  * Public export — built-in commands tagged with `source: "builtin"` so the
4
7
  * registry and TUI can group them uniformly alongside MCP-derived commands.
@@ -3,6 +3,7 @@ import { formatContextUsage } from "../context/usage.js";
3
3
  import { formatDiagnostics } from "../lsp/index.js";
4
4
  import { normalizeNameForMCP } from "../mcp/name.js";
5
5
  import { parseRule } from "../permissions/rule.js";
6
+ import { describeRepoCapabilities } from "../permissions/trust.js";
6
7
  import { encodeModel, decodeModel, displayModel, BUILTIN_PROVIDERS, isUserVisibleProvider } from "../provider-registry.js";
7
8
  import { getAvailableThinkingLevels, getDefaultThinkingLevel, normalizeThinkingLevel } from "../provider-transform.js";
8
9
  import { SessionManager } from "../session.js";
@@ -181,16 +182,18 @@ function persistSelectedModel(model, ctx) {
181
182
  ctx.sessionManager.appendMarker("model_switch", model);
182
183
  }
183
184
  }
184
- function syncSystemPrompt(ctx, model) {
185
+ /** Rebuild the system prompt from the agent's live options (model, tools, memory, Skills catalog). */
186
+ export function syncSystemPrompt(ctx, model) {
185
187
  const { providerId } = decodeModel(model);
186
188
  const toolPromptOptions = typeof ctx.agent.getSystemPromptToolOptions === "function"
187
189
  ? ctx.agent.getSystemPromptToolOptions()
188
190
  : {};
189
191
  ctx.agent.setSystemPrompt(buildSystemPrompt({
190
192
  agentName: "Bubble",
191
- configuredProvider: providerId,
192
- configuredModel: displayModel(model),
193
- configuredModelId: model,
193
+ // Same placeholders as startup when no model is configured yet.
194
+ configuredProvider: providerId || "none",
195
+ configuredModel: model ? displayModel(model) : "none",
196
+ configuredModelId: model || "none",
194
197
  thinkingLevel: ctx.agent.thinking,
195
198
  workingDir: ctx.cwd,
196
199
  ...toolPromptOptions,
@@ -697,7 +700,9 @@ const builtinSlashCommandEntries = [
697
700
  name: "login",
698
701
  description: "Login to OpenAI OAuth or Grok Subscription. Usage: /login [openai|grok]",
699
702
  async handler(args, ctx) {
700
- const providerId = args?.trim() || "openai";
703
+ // No default provider: a bare command must ask which account, never
704
+ // silently act on OpenAI now that more than one OAuth provider exists.
705
+ const providerId = args?.trim();
701
706
  if (!providerId) {
702
707
  ctx.openPicker("login");
703
708
  return;
@@ -825,7 +830,9 @@ const builtinSlashCommandEntries = [
825
830
  name: "logout",
826
831
  description: "Remove local login credentials. Usage: /logout [openai|grok]",
827
832
  async handler(args, ctx) {
828
- const providerId = args?.trim() || "openai";
833
+ // No default provider: a bare command must ask which account, never
834
+ // silently act on OpenAI now that more than one OAuth provider exists.
835
+ const providerId = args?.trim();
829
836
  if (!providerId) {
830
837
  ctx.openPicker("logout");
831
838
  return;
@@ -833,10 +840,13 @@ const builtinSlashCommandEntries = [
833
840
  if (isGrokSubscriptionProviderId(providerId)) {
834
841
  return logoutGrokSubscription(ctx);
835
842
  }
836
- if (!ctx.registry.getAuthStorage().has(providerId)) {
843
+ // Alias-aware: `openai` may be backed by the legacy `openai-codex` key.
844
+ const loginKeys = ctx.registry.getOAuthLoginKeys(providerId);
845
+ if (loginKeys.length === 0) {
837
846
  return `No OAuth credentials found for ${providerId}.`;
838
847
  }
839
- ctx.registry.getAuthStorage().remove(providerId);
848
+ for (const key of loginKeys)
849
+ ctx.registry.getAuthStorage().remove(key);
840
850
  const fallback = ctx.registry.getDefault();
841
851
  if (fallback?.apiKey) {
842
852
  const fallbackModel = ctx.registry.getDefaultModel(fallback.id);
@@ -878,7 +888,7 @@ const builtinSlashCommandEntries = [
878
888
  },
879
889
  {
880
890
  name: "permissions",
881
- description: "Inspect or edit allow/deny rules. Subcommands: add <scope> <list> <rule>, remove <scope> <list> <rule>, clear (session allowlist), reload.",
891
+ description: "Inspect or edit allow/deny rules. Subcommands: add <scope> <list> <rule>, remove <scope> <list> <rule>, clear (session approvals), reload.",
882
892
  async handler(args, ctx) {
883
893
  const tokens = args.trim().split(/\s+/).filter(Boolean);
884
894
  const sub = tokens[0] ?? "";
@@ -892,7 +902,7 @@ const builtinSlashCommandEntries = [
892
902
  if (size === 0)
893
903
  return "Bash allowlist is already empty.";
894
904
  ctx.bashAllowlist.clear();
895
- return `Cleared ${size} bash prefix${size === 1 ? "" : "es"} from the session allowlist.`;
905
+ return `Cleared ${size} session-approved bash command${size === 1 ? "" : "s"}.`;
896
906
  }
897
907
  if (sub === "reload") {
898
908
  if (!ctx.settingsManager)
@@ -920,6 +930,11 @@ const builtinSlashCommandEntries = [
920
930
  for (const r of merged.ruleSet.allow)
921
931
  lines.push(` ${r.source}`);
922
932
  }
933
+ const untrusted = merged.untrusted;
934
+ if (untrusted.allow.length + untrusted.mcpServers.length + untrusted.lspServers.length > 0) {
935
+ lines.push("", "This folder is not trusted, so these stay off (Bubble asks at startup):");
936
+ lines.push(...describeRepoCapabilities(untrusted).map((line) => ` ${line}`));
937
+ }
923
938
  lines.push("", `Deny rules (${merged.ruleSet.deny.length}):`);
924
939
  if (merged.ruleSet.deny.length === 0) {
925
940
  lines.push(" (none)");
@@ -939,13 +954,13 @@ const builtinSlashCommandEntries = [
939
954
  const entries = ctx.bashAllowlist.list();
940
955
  if (lines.length > 0)
941
956
  lines.push("");
942
- lines.push(`Session bash allowlist (${entries.length}):`);
957
+ lines.push(`Session-approved bash commands (${entries.length}):`);
943
958
  if (entries.length === 0) {
944
- lines.push(' (none) — approving "Yes, and don\'t ask again for <prefix>" adds entries here');
959
+ lines.push(' (none) — "don\'t ask again for this exact command this session" adds entries here');
945
960
  }
946
961
  else {
947
- for (const prefix of entries)
948
- lines.push(` ${prefix}`);
962
+ for (const command of entries)
963
+ lines.push(` ${command}`);
949
964
  }
950
965
  }
951
966
  if (lines.length === 0) {
@@ -26,8 +26,10 @@ export interface SystemPromptOptions {
26
26
  mode?: PermissionMode;
27
27
  /** Current date override */
28
28
  currentDate?: string;
29
- /** Prompt-visible skills */
29
+ /** Prompt-visible skills, listed as the Skills catalog when `skill` is a tool */
30
30
  skills?: SkillSummary[];
31
+ /** Catalog budget in characters; 0 turns the catalog off */
32
+ skillCatalogChars?: number;
31
33
  /** Prompt-visible memory guidance and summaries */
32
34
  memoryPrompt?: string;
33
35
  /** Durable child-agent profile prompt used for subagents. */
@@ -20,6 +20,12 @@
20
20
  * process, so the event loop keeps owning it (design §2.2a). Reaping is
21
21
  * three-layered (design §2.2b): graceful shutdownTasks() with escalation, a
22
22
  * SIGKILL-only path for signal handlers, and a process.once("exit") backstop.
23
+ *
24
+ * Finalization waits for stdio to drain: Node may emit "exit" before the last
25
+ * pipe chunks are delivered, so a record is settled on "close" — with a short
26
+ * grace timer from "exit" as the fallback, because a detached grandchild that
27
+ * inherited the pipes (`some-server &`) can keep "close" from ever firing.
28
+ * Same pattern as POST_EXIT_STDIO_GRACE_MS in src/tools/bash.ts.
23
29
  */
24
30
  import { type ChildProcess } from "node:child_process";
25
31
  export type ManagedServerPurpose = "preview" | "verification";
@@ -99,6 +105,7 @@ export declare class ProcessManager {
99
105
  adoptTask(input: AdoptBackgroundTaskInput): BackgroundTaskInfo;
100
106
  private reserveTaskSlot;
101
107
  private registerTaskChild;
108
+ private finalizeExitedTask;
102
109
  private finalizeTask;
103
110
  getTask(id: string): BackgroundTaskInfo | undefined;
104
111
  listTasks(ownerSessionId?: string): BackgroundTaskInfo[];
@@ -20,6 +20,12 @@
20
20
  * process, so the event loop keeps owning it (design §2.2a). Reaping is
21
21
  * three-layered (design §2.2b): graceful shutdownTasks() with escalation, a
22
22
  * SIGKILL-only path for signal handlers, and a process.once("exit") backstop.
23
+ *
24
+ * Finalization waits for stdio to drain: Node may emit "exit" before the last
25
+ * pipe chunks are delivered, so a record is settled on "close" — with a short
26
+ * grace timer from "exit" as the fallback, because a detached grandchild that
27
+ * inherited the pipes (`some-server &`) can keep "close" from ever firing.
28
+ * Same pattern as POST_EXIT_STDIO_GRACE_MS in src/tools/bash.ts.
23
29
  */
24
30
  import { spawn } from "node:child_process";
25
31
  import { existsSync } from "node:fs";
@@ -28,6 +34,7 @@ import { platform } from "node:os";
28
34
  import { appendTailCapped, killProcessTree, stripAnsi, tail } from "./process-utils.js";
29
35
  const MAX_LOG_BYTES = 96 * 1024;
30
36
  const STOP_FORCE_AFTER_MS = 1000;
37
+ const POST_EXIT_STDIO_GRACE_MS = 150;
31
38
  const MAX_RUNNING_TASKS_PER_SESSION = 8;
32
39
  const MAX_FINISHED_TASKS = 20;
33
40
  function countLineBreaks(value) {
@@ -143,14 +150,18 @@ export class ProcessManager {
143
150
  append(Buffer.from(`\n[task failed to start: ${error.message}]\n`));
144
151
  this.finalizeTask(record, "failed", null);
145
152
  });
146
- child.once("exit", (code) => {
147
- if (record.status === "running") {
148
- this.finalizeTask(record, code === 0 ? "completed" : "failed", code);
149
- }
153
+ record.settled = onChildSettled(child, {
154
+ onExit: (code) => {
155
+ record.exited = { code };
156
+ },
157
+ onSettled: (code) => this.finalizeExitedTask(record, code),
150
158
  });
151
159
  this.bumpTaskState(record);
152
160
  return this.publicTask(record);
153
161
  }
162
+ finalizeExitedTask(record, code) {
163
+ this.finalizeTask(record, code === 0 ? "completed" : "failed", code);
164
+ }
154
165
  finalizeTask(record, status, exitCode) {
155
166
  if (record.status !== "running")
156
167
  return;
@@ -242,13 +253,20 @@ export class ProcessManager {
242
253
  if (record.status !== "running")
243
254
  return this.publicTask(record);
244
255
  const pid = record.child?.pid ?? record.pid;
245
- // Mark first so the exit listener does not double-finalize as failed.
246
- this.finalizeTask(record, "killed", null);
256
+ // Mark first so the exit listener does not double-finalize as failed. A
257
+ // task that already exited on its own keeps its real outcome instead, and
258
+ // is left to the close/grace path: finish listeners snapshot the output
259
+ // tail, so they must not fire before the pending pipe data is appended.
260
+ if (!record.exited)
261
+ this.finalizeTask(record, "killed", null);
247
262
  if (pid) {
263
+ // Also reaps pipe-holding grandchildren, which lets "close" fire promptly.
248
264
  killProcessTree(pid, "SIGTERM");
249
265
  const forceTimer = setTimeout(() => killProcessTree(pid, "SIGKILL"), STOP_FORCE_AFTER_MS);
250
266
  forceTimer.unref?.();
251
267
  }
268
+ if (record.exited)
269
+ await record.settled;
252
270
  return this.publicTask(record);
253
271
  }
254
272
  /** Graceful reaping for shutdownRuntime (design §2.2b layer 1). */
@@ -266,7 +284,12 @@ export class ProcessManager {
266
284
  const pid = task.child?.pid ?? task.pid;
267
285
  if (pid)
268
286
  killProcessTree(pid, "SIGKILL");
269
- if (task.status === "running")
287
+ if (task.status !== "running")
288
+ continue;
289
+ // Exited on its own inside the drain window: bounded wait for its output.
290
+ if (task.exited)
291
+ await task.settled;
292
+ else
270
293
  this.finalizeTask(task, "killed", null);
271
294
  }
272
295
  }
@@ -318,7 +341,7 @@ export class ProcessManager {
318
341
  return [...this.registry.values()].filter((record) => record.kind === "task");
319
342
  }
320
343
  publicTask(record) {
321
- const { child: _child, output: _output, outputLineBreaks: _outputLineBreaks, outputHasData: _outputHasData, outputEndsWithNewline: _outputEndsWithNewline, ...info } = record;
344
+ const { child: _child, exited: _exited, settled: _settled, output: _output, outputLineBreaks: _outputLineBreaks, outputHasData: _outputHasData, outputEndsWithNewline: _outputEndsWithNewline, ...info } = record;
322
345
  return { ...info };
323
346
  }
324
347
  // -- servers (moved verbatim from server-manager.ts, kind-filtered) --------
@@ -379,18 +402,31 @@ export class ProcessManager {
379
402
  record.status = "failed";
380
403
  appendServerLog(record, `\n[server failed: ${error.message}]\n`);
381
404
  });
382
- child.once("exit", (code) => {
383
- record.exitCode = code;
384
- if (record.status !== "stopped") {
385
- record.status = code === 0 ? "exited" : "failed";
386
- }
387
- record.child = undefined;
405
+ // Settle after stdio drains so a fast crash surfaces its logs in the
406
+ // "did not become ready" error instead of racing the status flip.
407
+ record.settled = onChildSettled(child, {
408
+ unrefTimer: true,
409
+ onExit: (code) => {
410
+ record.exited = { code };
411
+ },
412
+ onSettled: (code) => {
413
+ record.exitCode = code;
414
+ if (record.status !== "stopped") {
415
+ record.status = code === 0 ? "exited" : "failed";
416
+ }
417
+ record.child = undefined;
418
+ },
388
419
  });
389
420
  const timeoutSec = input.timeoutSec ?? 30;
390
421
  const ready = readinessUrl
391
422
  ? await waitForReadiness(record, readinessUrl, timeoutSec)
392
423
  : await waitForProcessToStayAlive(record, Math.min(timeoutSec, 2));
393
- if (!ready) {
424
+ // `exited` is checked on its own: the status flip lags "exit" by the drain
425
+ // window, so a server dying right at the deadline still reads as active.
426
+ if (!ready || record.exited) {
427
+ // Bounded: lets the crash output land before it is quoted below.
428
+ if (record.exited)
429
+ await record.settled;
394
430
  const logs = record.logs.trim();
395
431
  await this.stopManagedServer(id);
396
432
  throw new Error(`Server ${id} did not become ready within ${timeoutSec}s.${logs ? `\n\nLogs:\n${tail(logs, 4000)}` : ""}`);
@@ -479,6 +515,46 @@ process.once("exit", () => {
479
515
  // ---------------------------------------------------------------------------
480
516
  // Server internals (moved verbatim)
481
517
  // ---------------------------------------------------------------------------
518
+ /**
519
+ * Calls `onSettled` exactly once, after the child has exited AND its stdio has
520
+ * drained ("close"), or POST_EXIT_STDIO_GRACE_MS after "exit" when something
521
+ * else still holds the pipes open. `onExit` fires synchronously at "exit".
522
+ * The returned promise resolves right after `onSettled` has run.
523
+ */
524
+ function onChildSettled(child, handlers) {
525
+ let settled = false;
526
+ let resolveSettled;
527
+ const done = new Promise((resolve) => {
528
+ resolveSettled = resolve;
529
+ });
530
+ let graceTimer;
531
+ const settle = (code) => {
532
+ if (settled)
533
+ return;
534
+ settled = true;
535
+ if (graceTimer)
536
+ clearTimeout(graceTimer);
537
+ try {
538
+ handlers.onSettled(code);
539
+ }
540
+ finally {
541
+ resolveSettled();
542
+ }
543
+ };
544
+ child.once("exit", (code) => {
545
+ handlers.onExit?.(code);
546
+ if (settled)
547
+ return;
548
+ // Timers run before the poll phase: if the loop stalled past the grace
549
+ // period, output already sitting in the pipe has not been read yet. Yield
550
+ // through one poll phase (setImmediate) so it is appended before settling.
551
+ graceTimer = setTimeout(() => setImmediate(() => settle(code)), POST_EXIT_STDIO_GRACE_MS);
552
+ if (handlers.unrefTimer)
553
+ graceTimer.unref?.();
554
+ });
555
+ child.once("close", (code) => settle(code));
556
+ return done;
557
+ }
482
558
  function publicServerInfo(server) {
483
559
  return {
484
560
  id: server.id,
@@ -506,7 +582,7 @@ function unrefStream(stream) {
506
582
  async function waitForReadiness(server, url, timeoutSec) {
507
583
  const deadline = Date.now() + timeoutSec * 1000;
508
584
  while (Date.now() < deadline) {
509
- if (!isActiveStatus(server.status))
585
+ if (!isServerAlive(server))
510
586
  return false;
511
587
  if (await canFetch(url))
512
588
  return true;
@@ -517,11 +593,11 @@ async function waitForReadiness(server, url, timeoutSec) {
517
593
  async function waitForProcessToStayAlive(server, timeoutSec) {
518
594
  const deadline = Date.now() + timeoutSec * 1000;
519
595
  while (Date.now() < deadline) {
520
- if (!isActiveStatus(server.status))
596
+ if (!isServerAlive(server))
521
597
  return false;
522
598
  await new Promise((resolve) => setTimeout(resolve, 100));
523
599
  }
524
- return isActiveStatus(server.status);
600
+ return isServerAlive(server);
525
601
  }
526
602
  async function canFetch(url) {
527
603
  const controller = new AbortController();
@@ -537,6 +613,9 @@ async function canFetch(url) {
537
613
  clearTimeout(timeout);
538
614
  }
539
615
  }
616
+ function isServerAlive(server) {
617
+ return !server.exited && isActiveStatus(server.status);
618
+ }
540
619
  function isActiveStatus(status) {
541
620
  return status === "starting" || status === "ready" || status === "running";
542
621
  }
@@ -6,6 +6,7 @@ import { THINKING_LEVELS } from "../types.js";
6
6
  import { buildWorkflowResultBlock, workflowMemberWarning } from "../agent/workflow/control.js";
7
7
  import { precompileWorkflowScript } from "../agent/workflow/runtime.js";
8
8
  import { formatSubagentRoute } from "../agent/subagent-route-format.js";
9
+ import { SUBAGENT_WAIT_GUIDANCE } from "../agent/subagent-wait-guidance.js";
9
10
  /**
10
11
  * Session-scoped trust decisions for project profiles, keyed by file path +
11
12
  * content hash so an edited file re-prompts (design §10.2). Shared across the
@@ -121,6 +122,7 @@ export function createSpawnAgentTool(options = {}, sharedTrust) {
121
122
  parameters: {
122
123
  type: "object",
123
124
  properties: {
125
+ description: { type: "string", description: "Short task title for the activity card (under 60 characters). Put the full instructions in message." },
124
126
  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." },
125
127
  agent: { type: "string", description: "Alias for agent_type." },
126
128
  category: { type: "string", description: "Optional semantic category for model/thinking routing, such as quick, deep, explore, review, frontend, or writing." },
@@ -196,7 +198,7 @@ export function createWaitAgentTool() {
196
198
  effect: "read",
197
199
  description: [
198
200
  "Wait for one or more spawned subagents to reach a final status and return snapshots.",
199
- "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.",
201
+ ...SUBAGENT_WAIT_GUIDANCE,
200
202
  ].join(" "),
201
203
  parameters: {
202
204
  type: "object",
@@ -204,10 +206,10 @@ export function createWaitAgentTool() {
204
206
  agent_id: { type: "string", description: "A single agent id to wait for." },
205
207
  agent_ids: {
206
208
  type: "array",
207
- description: "Agent ids to wait for. If omitted, waits for any active subagent.",
209
+ description: "Agent ids to wait for; returns when any target finishes. Prefer explicit IDs for remaining work. If omitted, considers all non-closed subagents, including already-finished ones.",
208
210
  items: { type: "string" },
209
211
  },
210
- timeout_ms: { type: "number", description: "Maximum wait time in milliseconds. Defaults to 30000." },
212
+ timeout_ms: { type: "number", description: "Maximum wait time in milliseconds, not a mandatory delay; returns early when a target finishes. Choose according to your next decision. Defaults to 30000." },
211
213
  },
212
214
  additionalProperties: false,
213
215
  },
@@ -249,7 +251,7 @@ export function createSendInputTool() {
249
251
  name: "send_input",
250
252
  readOnly: true,
251
253
  effect: "read",
252
- 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.",
254
+ description: "Send a follow-up message to an existing subagent thread. While running or queued, text input is queued and applied at the next safe boundary without interruption. Use interrupt:true only to cancel and redirect it. Restarting a finished child goes through the scheduler like any spawn.",
253
255
  parameters: {
254
256
  type: "object",
255
257
  properties: {
@@ -277,7 +279,7 @@ export function createSendInputTool() {
277
279
  abortSignal: ctx.abortSignal,
278
280
  });
279
281
  return formatLifecycleResult("send_input", [snapshot], [
280
- `Sent input to ${snapshot.nickname} (${snapshot.agentName})`,
282
+ `${snapshot.pendingInputCount ? "Queued input for" : "Sent input to"} ${snapshot.nickname} (${snapshot.agentName})`,
281
283
  `agent_id: ${snapshot.agentId}`,
282
284
  `status: ${snapshot.status}`,
283
285
  ...(snapshot.status === "queued" ? [queuedStatusLine(snapshot)] : []),
@@ -562,7 +564,7 @@ function spawnNextSteps(snapshot) {
562
564
  ];
563
565
  }
564
566
  return [
565
- `next: call wait_agent for ${snapshot.agentId} before reporting this subagent's current status or final result`,
567
+ `next: continue useful non-overlapping work; call wait_agent for ${snapshot.agentId} when its result is needed for your next step or final synthesis`,
566
568
  ];
567
569
  }
568
570
  function queuedStatusLine(snapshot) {
@@ -581,7 +583,7 @@ function waitTimeoutGuidance(snapshots) {
581
583
  if (running.length > 0) {
582
584
  lines.push(`${running.length} child${running.length === 1 ? " is" : "ren are"} still running.`);
583
585
  }
584
- lines.push("Call wait_agent again with a longer timeout instead of duplicating the same work locally.");
586
+ lines.push("Reassess whether a child's result blocks your next meaningful step: continue useful independent work if available, otherwise call wait_agent again with a suitable timeout. Do not duplicate the delegated work.");
585
587
  return lines;
586
588
  }
587
589
  function formatLifecycleResult(toolName, snapshots, header) {
@@ -717,6 +719,8 @@ function snapshotToMetadata(snapshot) {
717
719
  error: snapshot.error,
718
720
  createdAt: snapshot.createdAt,
719
721
  updatedAt: snapshot.updatedAt,
722
+ pendingInputCount: snapshot.pendingInputCount,
723
+ inputDelivery: snapshot.inputDelivery,
720
724
  };
721
725
  }
722
726
  function unsupportedProfile(profile) {
@@ -33,7 +33,7 @@ export declare class WorktreeApprovalController implements ApprovalController {
33
33
  * exposes a cloneForChild hook (the standard `read`, which carries a
34
34
  * FileStateTracker) is rebuilt as a fresh per-child instance, so concurrent
35
35
  * members of a fan-out never share mutable tool state. Stateless tools
36
- * (glob/grep, web/memory/skill) and custom/mock tools without the hook are
36
+ * (ls/grep, web/memory/skill) and custom/mock tools without the hook are
37
37
  * passed through unchanged. Write children get full isolation via
38
38
  * createWorktreeChildTools instead.
39
39
  */
@@ -11,7 +11,7 @@
11
11
  import { isAbsolute, resolve, sep } from "node:path";
12
12
  import { createBashTool } from "./bash.js";
13
13
  import { createEditTool } from "./edit.js";
14
- import { createGlobTool } from "./glob.js";
14
+ import { createLsTool } from "./ls.js";
15
15
  import { createGrepTool } from "./grep.js";
16
16
  import { createReadTool } from "./read.js";
17
17
  import { createWriteTool } from "./write.js";
@@ -86,7 +86,7 @@ export class WorktreeApprovalController {
86
86
  return { decision: "ask" };
87
87
  }
88
88
  }
89
- const WORKTREE_TOOL_NAMES = new Set(["read", "glob", "grep", "edit", "write", "bash"]);
89
+ const WORKTREE_TOOL_NAMES = new Set(["read", "ls", "grep", "edit", "write", "bash"]);
90
90
  /**
91
91
  * Builds the write child's toolset bound to its worktree: fresh instances
92
92
  * with their own FileStateTracker and the worktree approval policy. A
@@ -97,7 +97,7 @@ const WORKTREE_TOOL_NAMES = new Set(["read", "glob", "grep", "edit", "write", "b
97
97
  * exposes a cloneForChild hook (the standard `read`, which carries a
98
98
  * FileStateTracker) is rebuilt as a fresh per-child instance, so concurrent
99
99
  * members of a fan-out never share mutable tool state. Stateless tools
100
- * (glob/grep, web/memory/skill) and custom/mock tools without the hook are
100
+ * (ls/grep, web/memory/skill) and custom/mock tools without the hook are
101
101
  * passed through unchanged. Write children get full isolation via
102
102
  * createWorktreeChildTools instead.
103
103
  */
@@ -109,7 +109,7 @@ export function createWorktreeChildTools(worktreeCwd, include) {
109
109
  const fileState = new FileStateTracker(worktreeCwd);
110
110
  const tools = [
111
111
  createReadTool(worktreeCwd, approval, undefined, fileState),
112
- createGlobTool(worktreeCwd),
112
+ createLsTool(worktreeCwd),
113
113
  createGrepTool(worktreeCwd),
114
114
  createEditTool(worktreeCwd, approval, undefined, fileState),
115
115
  createWriteTool(worktreeCwd, {}, approval, undefined, fileState),
@@ -2,13 +2,11 @@ export interface EditOperation {
2
2
  oldText: string;
3
3
  newText: string;
4
4
  }
5
- export type EditMatchMode = "exact" | "trimmed" | "unescaped" | "normalized-line" | "smart-line" | "markdown-table" | "single-line-whitespace";
6
5
  export interface EditApplyOptions {
7
6
  path?: string;
8
7
  }
9
8
  export interface EditMatchInfo {
10
9
  editIndex: number;
11
- mode: EditMatchMode;
12
10
  start: number;
13
11
  end: number;
14
12
  }
@@ -25,4 +23,3 @@ export declare class EditApplyError extends Error {
25
23
  constructor(message: string, status?: "no_match" | "blocked");
26
24
  }
27
25
  export declare function applyEditsToContent(rawContent: string, edits: EditOperation[], options?: EditApplyOptions): AppliedEditResult;
28
- export declare function formatEditMatchNotes(matches: EditMatchInfo[]): string;