@bubblebrain-ai/bubble 0.0.5 → 0.0.7

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 (42) hide show
  1. package/dist/agent/execution-governor.d.ts +5 -13
  2. package/dist/agent/execution-governor.js +33 -142
  3. package/dist/agent/task-size.d.ts +9 -0
  4. package/dist/agent/task-size.js +33 -0
  5. package/dist/agent/tool-intent.d.ts +1 -0
  6. package/dist/agent/tool-intent.js +1 -1
  7. package/dist/agent.js +46 -2
  8. package/dist/main.js +57 -42
  9. package/dist/orchestrator/default-hooks.js +83 -84
  10. package/dist/orchestrator/hooks.d.ts +5 -8
  11. package/dist/prompt/compose.js +3 -0
  12. package/dist/prompt/environment.js +2 -0
  13. package/dist/prompt/provider-prompts/deepseek.js +1 -2
  14. package/dist/prompt/provider-prompts/kimi.js +1 -2
  15. package/dist/prompt/reminders.d.ts +21 -3
  16. package/dist/prompt/reminders.js +44 -17
  17. package/dist/prompt/runtime.js +17 -23
  18. package/dist/provider.d.ts +10 -1
  19. package/dist/provider.js +87 -34
  20. package/dist/slash-commands/commands.js +0 -17
  21. package/dist/tools/bash.d.ts +2 -1
  22. package/dist/tools/bash.js +1 -1
  23. package/dist/tools/edit-apply.js +37 -6
  24. package/dist/tools/edit.d.ts +2 -1
  25. package/dist/tools/edit.js +18 -6
  26. package/dist/tools/file-state.d.ts +25 -0
  27. package/dist/tools/file-state.js +52 -0
  28. package/dist/tools/index.d.ts +2 -0
  29. package/dist/tools/index.js +6 -4
  30. package/dist/tools/read.d.ts +2 -1
  31. package/dist/tools/read.js +5 -1
  32. package/dist/tools/write.d.ts +4 -3
  33. package/dist/tools/write.js +133 -54
  34. package/dist/tui/display-history.d.ts +2 -0
  35. package/dist/tui/run.js +115 -23
  36. package/dist/tui/streaming-tool-args.d.ts +15 -0
  37. package/dist/tui/streaming-tool-args.js +30 -0
  38. package/dist/tui/tool-renderers/write-preview.d.ts +1 -1
  39. package/dist/tui/tool-renderers/write-preview.js +9 -1
  40. package/dist/tui/tool-renderers/write.js +13 -7
  41. package/dist/types.d.ts +15 -0
  42. package/package.json +1 -1
@@ -3,21 +3,16 @@ import type { TaskType } from "./task-classifier.js";
3
3
  export interface GovernorDecision {
4
4
  blockedResult?: ToolResult;
5
5
  }
6
- type WorkPhase = "explore" | "modify" | "verify";
7
6
  export declare class ExecutionGovernor {
8
- private taskType;
9
7
  private budget;
10
8
  private history;
11
9
  private totalSteps;
12
10
  private searchSteps;
13
11
  private readSteps;
14
- private explorationStepsWithoutWrite;
15
- private searchFrozen;
16
- private explorationFrozen;
17
- private phase;
18
- private codeChanged;
12
+ private mutationVersion;
19
13
  private reminderQueue;
20
14
  private warnedFamilies;
15
+ private warnedSignatures;
21
16
  private softTotalWarned;
22
17
  private softSearchWarned;
23
18
  private softReadWarned;
@@ -29,16 +24,13 @@ export declare class ExecutionGovernor {
29
24
  readSteps: number;
30
25
  searchFrozen: boolean;
31
26
  explorationFrozen: boolean;
32
- phase: WorkPhase;
27
+ phase: "observe";
33
28
  };
34
29
  filterToolDefinitions(toolDefinitions: ToolRegistryEntry[]): ToolRegistryEntry[];
35
30
  beforeToolCall(toolCall: ParsedToolCall): GovernorDecision;
36
31
  afterToolResult(toolCall: ParsedToolCall, result: ToolResult): void;
37
32
  private trailingNoProgressCount;
38
- private freezeSearch;
39
- private enterModifyPhase;
40
- private isModificationTask;
41
- private historyCount;
33
+ private hasCurrentMutationObservation;
34
+ private warnOnce;
42
35
  private maybeWarnOnSoftBudgets;
43
36
  }
44
- export {};
@@ -1,118 +1,84 @@
1
1
  import { analyzeToolIntent } from "./tool-intent.js";
2
- import { buildExplorationFreezeReminder, buildInvestigationReminder, buildLoopWarningReminder, buildSearchFreezeReminder } from "../prompt/reminders.js";
2
+ import { buildInvestigationReminder, buildLoopWarningReminder } from "../prompt/reminders.js";
3
3
  const BUDGETS = {
4
4
  security_investigation: {
5
5
  softTotalSteps: 14,
6
6
  softSearchSteps: 6,
7
7
  softReadSteps: 8,
8
- maxExplorationStepsWithoutWrite: 12,
9
- maxNoProgressReadExactRepeats: 2,
10
- maxNoProgressExactRepeats: 2,
11
- maxNoProgressFamilyRepeats: 3,
8
+ warningExactRepeats: 2,
12
9
  warningFamilyRepeats: 2,
13
10
  },
14
11
  code_search: {
15
12
  softTotalSteps: 16,
16
13
  softSearchSteps: 8,
17
14
  softReadSteps: 10,
18
- maxExplorationStepsWithoutWrite: 14,
19
- maxNoProgressReadExactRepeats: 2,
20
- maxNoProgressExactRepeats: 3,
21
- maxNoProgressFamilyRepeats: 4,
15
+ warningExactRepeats: 2,
22
16
  warningFamilyRepeats: 3,
23
17
  },
24
18
  debugging: {
25
19
  softTotalSteps: 18,
26
20
  softSearchSteps: 8,
27
21
  softReadSteps: 7,
28
- maxExplorationStepsWithoutWrite: 10,
29
- maxNoProgressReadExactRepeats: 1,
30
- maxNoProgressExactRepeats: 3,
31
- maxNoProgressFamilyRepeats: 4,
22
+ warningExactRepeats: 1,
32
23
  warningFamilyRepeats: 3,
33
24
  },
34
25
  implementation: {
35
26
  softTotalSteps: 18,
36
27
  softSearchSteps: 8,
37
28
  softReadSteps: 6,
38
- maxExplorationStepsWithoutWrite: 8,
39
- maxNoProgressReadExactRepeats: 1,
40
- maxNoProgressExactRepeats: 3,
41
- maxNoProgressFamilyRepeats: 4,
29
+ warningExactRepeats: 1,
42
30
  warningFamilyRepeats: 3,
43
31
  },
44
32
  code_review: {
45
33
  softTotalSteps: 14,
46
34
  softSearchSteps: 6,
47
35
  softReadSteps: 8,
48
- maxExplorationStepsWithoutWrite: 12,
49
- maxNoProgressReadExactRepeats: 2,
50
- maxNoProgressExactRepeats: 3,
51
- maxNoProgressFamilyRepeats: 4,
36
+ warningExactRepeats: 2,
52
37
  warningFamilyRepeats: 3,
53
38
  },
54
39
  code_explanation: {
55
40
  softTotalSteps: 12,
56
41
  softSearchSteps: 6,
57
42
  softReadSteps: 8,
58
- maxExplorationStepsWithoutWrite: 12,
59
- maxNoProgressReadExactRepeats: 2,
60
- maxNoProgressExactRepeats: 3,
61
- maxNoProgressFamilyRepeats: 4,
43
+ warningExactRepeats: 2,
62
44
  warningFamilyRepeats: 3,
63
45
  },
64
46
  repo_orientation: {
65
47
  softTotalSteps: 12,
66
48
  softSearchSteps: 6,
67
49
  softReadSteps: 8,
68
- maxExplorationStepsWithoutWrite: 12,
69
- maxNoProgressReadExactRepeats: 2,
70
- maxNoProgressExactRepeats: 3,
71
- maxNoProgressFamilyRepeats: 4,
50
+ warningExactRepeats: 2,
72
51
  warningFamilyRepeats: 3,
73
52
  },
74
53
  product_discussion: {
75
54
  softTotalSteps: 10,
76
55
  softSearchSteps: 4,
77
56
  softReadSteps: 4,
78
- maxExplorationStepsWithoutWrite: 8,
79
- maxNoProgressReadExactRepeats: 2,
80
- maxNoProgressExactRepeats: 2,
81
- maxNoProgressFamilyRepeats: 3,
57
+ warningExactRepeats: 2,
82
58
  warningFamilyRepeats: 2,
83
59
  },
84
60
  general: {
85
61
  softTotalSteps: 18,
86
62
  softSearchSteps: 8,
87
63
  softReadSteps: 10,
88
- maxExplorationStepsWithoutWrite: 14,
89
- maxNoProgressReadExactRepeats: 2,
90
- maxNoProgressExactRepeats: 3,
91
- maxNoProgressFamilyRepeats: 4,
64
+ warningExactRepeats: 2,
92
65
  warningFamilyRepeats: 3,
93
66
  },
94
67
  };
95
- const SEARCH_TOOLS_DISABLED = new Set(["grep", "web_search", "web_fetch"]);
96
- const EXPLORATION_TOOLS_DISABLED = new Set(["read", "glob", "grep", "web_search", "web_fetch", "spawn_agent", "wait_agent", "send_input", "tool_search"]);
97
68
  export class ExecutionGovernor {
98
- taskType;
99
69
  budget;
100
70
  history = [];
101
71
  totalSteps = 0;
102
72
  searchSteps = 0;
103
73
  readSteps = 0;
104
- explorationStepsWithoutWrite = 0;
105
- searchFrozen = false;
106
- explorationFrozen = false;
107
- phase = "explore";
108
- codeChanged = false;
74
+ mutationVersion = 0;
109
75
  reminderQueue = [];
110
76
  warnedFamilies = new Set();
77
+ warnedSignatures = new Set();
111
78
  softTotalWarned = false;
112
79
  softSearchWarned = false;
113
80
  softReadWarned = false;
114
81
  constructor(taskType) {
115
- this.taskType = taskType;
116
82
  this.budget = BUDGETS[taskType];
117
83
  if (taskType === "security_investigation") {
118
84
  this.reminderQueue.push(buildInvestigationReminder());
@@ -128,62 +94,33 @@ export class ExecutionGovernor {
128
94
  totalSteps: this.totalSteps,
129
95
  searchSteps: this.searchSteps,
130
96
  readSteps: this.readSteps,
131
- searchFrozen: this.searchFrozen,
132
- explorationFrozen: this.explorationFrozen,
133
- phase: this.phase,
97
+ searchFrozen: false,
98
+ explorationFrozen: false,
99
+ phase: "observe",
134
100
  };
135
101
  }
136
102
  filterToolDefinitions(toolDefinitions) {
137
- let filtered = toolDefinitions;
138
- if (this.explorationFrozen) {
139
- filtered = filtered.filter((tool) => !EXPLORATION_TOOLS_DISABLED.has(tool.name));
140
- }
141
- else if (this.searchFrozen) {
142
- filtered = filtered.filter((tool) => !SEARCH_TOOLS_DISABLED.has(tool.name));
143
- }
144
- return filtered;
103
+ return toolDefinitions;
145
104
  }
146
105
  beforeToolCall(toolCall) {
147
106
  const intent = analyzeToolIntent(toolCall);
148
- if (this.explorationFrozen && isExplorationIntent(intent)) {
149
- return {
150
- blockedResult: blockedResult("Exploration blocked: this implementation task already has enough context. Use edit/write, verify an existing change, or explain the blocker.", "blocked", "Exploration frozen because tool calls stopped producing task progress.", metadataKindForFamily(intent.family)),
151
- };
152
- }
153
- if (this.isModificationTask() && !this.codeChanged && intent.family === "read") {
107
+ if (intent.family === "read") {
154
108
  const signature = intent.read?.signature;
155
- if (signature && this.historyCount((entry) => entry.signature === signature) >= this.budget.maxNoProgressReadExactRepeats) {
156
- this.enterModifyPhase(`Repeated the same file range without making progress: ${signature}`);
157
- return {
158
- blockedResult: blockedResult("Read blocked: this file range was already read. You have enough context to make the requested change; use edit/write now or explain the blocker.", "blocked", "Repeated identical read before modification.", "read"),
159
- };
109
+ if (signature && this.hasCurrentMutationObservation((entry) => entry.signature === signature)) {
110
+ this.warnOnce(`read:${signature}`, "This exact file range was already read since the last successful edit/write. If the content is still available and nothing changed, use the prior result; otherwise it is okay to re-read to recover context or verify a change.");
160
111
  }
161
112
  }
162
113
  if (intent.family === "search") {
163
- if (this.searchFrozen) {
164
- return {
165
- blockedResult: blockedResult("Search blocked: repeated low-yield searching is now frozen for this task.", "blocked", "Search frozen due to repeated low-yield searching.", "search"),
166
- };
167
- }
168
114
  const signature = intent.search?.signature;
169
115
  const familyKey = intent.search?.familyKey;
170
- if (signature && this.trailingNoProgressCount((entry) => entry.signature === signature) >= this.budget.maxNoProgressExactRepeats) {
171
- this.freezeSearch(`Repeated the same search signature without new evidence: ${signature}`);
172
- return {
173
- blockedResult: blockedResult("Search blocked: repeated the same search multiple times without new evidence.", "blocked", "Repeated identical search without progress.", "search"),
174
- };
116
+ if (signature && this.trailingNoProgressCount((entry) => entry.signature === signature) >= this.budget.warningExactRepeats) {
117
+ this.warnOnce(`search:${signature}`, "This search is very similar to one you already ran and it did not produce new evidence. Change the query/path, follow a concrete lead, or summarize the strongest findings.");
175
118
  }
176
119
  if (familyKey) {
177
120
  const familyNoProgress = this.trailingNoProgressCount((entry) => entry.familyKey === familyKey);
178
- if (familyNoProgress >= this.budget.maxNoProgressFamilyRepeats) {
179
- this.freezeSearch(`Repeated the same search family without new evidence: ${familyKey}`);
180
- return {
181
- blockedResult: blockedResult("Search blocked: repeated the same search family without new evidence.", "blocked", "Repeated similar searches without progress.", "search"),
182
- };
183
- }
184
121
  if (familyNoProgress >= this.budget.warningFamilyRepeats && !this.warnedFamilies.has(familyKey)) {
185
122
  this.warnedFamilies.add(familyKey);
186
- this.reminderQueue.push(buildLoopWarningReminder("Repeated searches are yielding little new evidence. Change your hypothesis, narrow the path, or summarize current findings instead of repeating variants."));
123
+ this.reminderQueue.push(buildLoopWarningReminder("Repeated searches in the same family are yielding little new evidence. Change your hypothesis, narrow the path, follow a specific file lead, or summarize current findings instead of repeating variants."));
187
124
  }
188
125
  }
189
126
  }
@@ -194,9 +131,6 @@ export class ExecutionGovernor {
194
131
  if (intent.family === "read") {
195
132
  this.readSteps += 1;
196
133
  }
197
- if (isExplorationIntent(intent) && !this.codeChanged) {
198
- this.explorationStepsWithoutWrite += 1;
199
- }
200
134
  this.maybeWarnOnSoftBudgets(intent.family === "search", intent.family === "read");
201
135
  return {};
202
136
  }
@@ -211,23 +145,19 @@ export class ExecutionGovernor {
211
145
  signature: intent.search?.signature ?? intent.read?.signature,
212
146
  familyKey: intent.search?.familyKey ?? intent.read?.familyKey,
213
147
  progress,
148
+ mutationVersion: this.mutationVersion,
214
149
  });
215
150
  if (isSuccessfulWriteIntent(intent, result)) {
216
- this.codeChanged = true;
217
- this.phase = "verify";
218
- return;
219
- }
220
- if (this.isModificationTask()
221
- && !this.codeChanged
222
- && isExplorationIntent(intent)
223
- && this.explorationStepsWithoutWrite >= this.budget.maxExplorationStepsWithoutWrite) {
224
- this.enterModifyPhase(`Used ${this.explorationStepsWithoutWrite} exploration tools without editing files.`);
151
+ this.mutationVersion += 1;
225
152
  }
226
153
  }
227
154
  trailingNoProgressCount(predicate) {
228
155
  let count = 0;
229
156
  for (let index = this.history.length - 1; index >= 0; index--) {
230
157
  const entry = this.history[index];
158
+ if (entry.mutationVersion !== this.mutationVersion) {
159
+ break;
160
+ }
231
161
  if (!predicate(entry)) {
232
162
  break;
233
163
  }
@@ -238,27 +168,15 @@ export class ExecutionGovernor {
238
168
  }
239
169
  return count;
240
170
  }
241
- freezeSearch(reason) {
242
- if (this.searchFrozen) {
243
- return;
244
- }
245
- this.searchFrozen = true;
246
- this.reminderQueue.push(buildSearchFreezeReminder(reason));
171
+ hasCurrentMutationObservation(predicate) {
172
+ return this.history.some((entry) => entry.mutationVersion === this.mutationVersion && predicate(entry));
247
173
  }
248
- enterModifyPhase(reason) {
249
- if (this.explorationFrozen) {
174
+ warnOnce(key, reason) {
175
+ if (this.warnedSignatures.has(key)) {
250
176
  return;
251
177
  }
252
- this.phase = "modify";
253
- this.explorationFrozen = true;
254
- this.searchFrozen = true;
255
- this.reminderQueue.push(buildExplorationFreezeReminder(reason));
256
- }
257
- isModificationTask() {
258
- return this.taskType === "implementation" || this.taskType === "debugging";
259
- }
260
- historyCount(predicate) {
261
- return this.history.reduce((count, entry) => count + (predicate(entry) ? 1 : 0), 0);
178
+ this.warnedSignatures.add(key);
179
+ this.reminderQueue.push(buildLoopWarningReminder(reason));
262
180
  }
263
181
  maybeWarnOnSoftBudgets(isSearchStep, isReadStep) {
264
182
  if (!this.softTotalWarned && this.totalSteps >= this.budget.softTotalSteps) {
@@ -275,28 +193,12 @@ export class ExecutionGovernor {
275
193
  }
276
194
  }
277
195
  }
278
- function isExplorationIntent(intent) {
279
- return intent.family === "search" || intent.family === "read" || intent.family === "web";
280
- }
281
196
  function isSuccessfulWriteIntent(intent, result) {
282
197
  if (result.isError || result.status === "blocked" || result.status === "command_error") {
283
198
  return false;
284
199
  }
285
200
  return intent.family === "write" || intent.family === "edit" || result.metadata?.kind === "write" || result.metadata?.kind === "edit";
286
201
  }
287
- function metadataKindForFamily(family) {
288
- switch (family) {
289
- case "search":
290
- case "read":
291
- case "write":
292
- case "edit":
293
- case "shell":
294
- case "web":
295
- return family;
296
- default:
297
- return "security";
298
- }
299
- }
300
202
  function inferProgress(intent, result) {
301
203
  if (result.status === "blocked" || result.status === "timeout" || result.status === "command_error") {
302
204
  return false;
@@ -314,14 +216,3 @@ function inferProgress(intent, result) {
314
216
  }
315
217
  return !result.isError;
316
218
  }
317
- function blockedResult(content, status, reason, kind = "security") {
318
- return {
319
- content,
320
- isError: true,
321
- status,
322
- metadata: {
323
- kind,
324
- reason,
325
- },
326
- };
327
- }
@@ -0,0 +1,9 @@
1
+ import type { ContentPart } from "../types.js";
2
+ /**
3
+ * Coarse "is this a small / focused task" classifier. Used to inject a hint
4
+ * that suppresses the default exploration-first protocol when the user's
5
+ * request is clearly a one-shot create or single-file tweak. Counterpart to
6
+ * `task-classifier.ts` which categorizes by *kind*, not *size*.
7
+ */
8
+ export type TaskSize = "small" | "normal";
9
+ export declare function classifyTaskSize(input: string | ContentPart[]): TaskSize;
@@ -0,0 +1,33 @@
1
+ // Two-part match: a "create" verb AND a "deliverable" noun in the same
2
+ // sentence is a strong signal of a focused, one-shot task.
3
+ const SMALL_TASK_VERBS = [
4
+ /\b(write|create|generate|make|draft|build|add)\b/i,
5
+ /帮我写|帮我创建|帮我生成|写(个|一个|一份)|新建一个|做(个|一个|一份)|生成(个|一个|一份)|搞(个|一个|一份)/i,
6
+ ];
7
+ const SMALL_TASK_NOUNS = [
8
+ /\b(file|page|component|script|snippet|function|class|test|html|css|js|ts|tsx|jsx|md|markdown|hello world)\b/i,
9
+ /(文件|页面|组件|脚本|片段|函数|类|测试|html|css|介绍|文章|页|说明|示例|demo)/i,
10
+ ];
11
+ function matchesSmallTaskPattern(text) {
12
+ return SMALL_TASK_VERBS.some((re) => re.test(text))
13
+ && SMALL_TASK_NOUNS.some((re) => re.test(text));
14
+ }
15
+ const LARGE_TASK_NEGATIONS = [
16
+ /\b(refactor|rewrite|migrate|overhaul|integrate|architect|review the codebase|whole project|across the codebase)\b/i,
17
+ /多个|批量|全部|所有|整个项目|整个仓库|架构|重构|端到端|跨模块/i,
18
+ ];
19
+ export function classifyTaskSize(input) {
20
+ const text = (typeof input === "string"
21
+ ? input
22
+ : input
23
+ .filter((part) => part.type === "text")
24
+ .map((part) => part.text)
25
+ .join("\n")).trim();
26
+ if (!text)
27
+ return "normal";
28
+ // Up to ~120 chars with a small-task verb+noun match and no negation: small.
29
+ if (text.length <= 120 && matchesSmallTaskPattern(text)) {
30
+ return LARGE_TASK_NEGATIONS.some((re) => re.test(text)) ? "normal" : "small";
31
+ }
32
+ return "normal";
33
+ }
@@ -32,3 +32,4 @@ export interface ParsedReadCommand {
32
32
  }
33
33
  export declare function parseSearchBashCommand(command: string): ParsedSearchCommand | undefined;
34
34
  export declare function parseReadBashCommand(command: string): ParsedReadCommand | undefined;
35
+ export declare function shellSplit(command: string): string[];
@@ -256,7 +256,7 @@ function parseLineCount(value) {
256
256
  const parsed = Number(value.replace(/^\+/, ""));
257
257
  return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
258
258
  }
259
- function shellSplit(command) {
259
+ export function shellSplit(command) {
260
260
  const tokens = [];
261
261
  let current = "";
262
262
  let quote = null;
package/dist/agent.js CHANGED
@@ -338,6 +338,9 @@ export class Agent {
338
338
  if (chunk.argumentsFull !== undefined) {
339
339
  currentToolCall.args = chunk.argumentsFull;
340
340
  }
341
+ if (chunk.argumentsCorrupt) {
342
+ currentToolCall.argsCorrupt = true;
343
+ }
341
344
  if (chunk.arguments) {
342
345
  yield {
343
346
  type: "tool_call_delta",
@@ -353,6 +356,7 @@ export class Agent {
353
356
  id: currentToolCall.id,
354
357
  name: currentToolCall.name,
355
358
  arguments: currentToolCall.args,
359
+ ...(currentToolCall.argsCorrupt ? { argsCorrupt: true } : {}),
356
360
  });
357
361
  yield {
358
362
  type: "tool_call_end",
@@ -405,10 +409,14 @@ export class Agent {
405
409
  for (let index = 0; index < assistantMsg.toolCalls.length; index++) {
406
410
  const tc = assistantMsg.toolCalls[index];
407
411
  try {
408
- parsedCalls.push({ ...tc, parsedArgs: JSON.parse(tc.arguments) });
412
+ parsedCalls.push({
413
+ ...tc,
414
+ parsedArgs: JSON.parse(tc.arguments),
415
+ ...(tc.argsCorrupt ? { argsCorrupt: true } : {}),
416
+ });
409
417
  }
410
418
  catch {
411
- parsedCalls.push({ ...tc, parsedArgs: {} });
419
+ parsedCalls.push({ ...tc, parsedArgs: {}, argsCorrupt: true });
412
420
  }
413
421
  }
414
422
  const executedResults = [];
@@ -1084,6 +1092,25 @@ export class Agent {
1084
1092
  isError: true,
1085
1093
  };
1086
1094
  }
1095
+ if (toolCall.argsCorrupt) {
1096
+ return {
1097
+ content: `Error: The arguments for "${toolCall.name}" failed to parse as JSON, indicating the tool call was truncated or malformed mid-stream. ` +
1098
+ `Re-issue the call with valid JSON arguments; do not assume the previous attempt ran.`,
1099
+ isError: true,
1100
+ status: "blocked",
1101
+ metadata: { kind: "security", reason: "args_corrupt" },
1102
+ };
1103
+ }
1104
+ const missingRequired = findMissingRequiredArgs(tool.parameters, toolCall.parsedArgs);
1105
+ if (missingRequired.length > 0) {
1106
+ return {
1107
+ content: `Error: Tool "${toolCall.name}" was called without required argument${missingRequired.length === 1 ? "" : "s"}: ${missingRequired.map((name) => `"${name}"`).join(", ")}. ` +
1108
+ `Re-issue the call with all required fields filled. Do not assume the previous attempt ran with default values.`,
1109
+ isError: true,
1110
+ status: "blocked",
1111
+ metadata: { kind: "security", reason: "missing_required_args", missing: missingRequired },
1112
+ };
1113
+ }
1087
1114
  try {
1088
1115
  return await tool.execute(toolCall.parsedArgs, {
1089
1116
  cwd,
@@ -1102,6 +1129,23 @@ export class Agent {
1102
1129
  }
1103
1130
  }
1104
1131
  }
1132
+ function findMissingRequiredArgs(schema, args) {
1133
+ const required = schema?.required;
1134
+ if (!required || required.length === 0)
1135
+ return [];
1136
+ const missing = [];
1137
+ for (const name of required) {
1138
+ const value = args ? args[name] : undefined;
1139
+ // Empty strings/arrays are intentionally allowed — writing an empty file
1140
+ // or passing an empty list can be legitimate. Only undefined/null counts
1141
+ // as "missing", because the observed failure mode is `finalArgs: "{}"`
1142
+ // where the field is entirely absent.
1143
+ if (value === undefined || value === null) {
1144
+ missing.push(name);
1145
+ }
1146
+ }
1147
+ return missing;
1148
+ }
1105
1149
  function estimateResidentChars(messages) {
1106
1150
  let total = 0;
1107
1151
  for (const message of messages) {
package/dist/main.js CHANGED
@@ -125,9 +125,8 @@ async function main() {
125
125
  const { registry: slashRegistry } = await import("./slash-commands/index.js");
126
126
  slashRegistry.addDynamicSource(() => mcpManager.getPromptCommands());
127
127
  }
128
- // Signal-based shutdown for Ctrl-C / kill. For /quit the command handler
129
- // shuts MCP down directly process.once("exit", ...)
130
- // runs synchronously and can't await async work, so relying on it is a trap.
128
+ // Signal-based shutdown for Ctrl-C / kill. Normal /quit cleanup happens after
129
+ // the TUI renderer has been destroyed, avoiding native teardown races.
131
130
  const shutdownMcp = async () => {
132
131
  try {
133
132
  await mcpManager.shutdown();
@@ -261,6 +260,18 @@ async function main() {
261
260
  // Codex-style memory runs at startup over historical rollouts. Exit should
262
261
  // not perform an ad-hoc extraction of the just-finished session.
263
262
  };
263
+ const shutdownRuntime = async () => {
264
+ const results = await Promise.allSettled([
265
+ flushMemory(),
266
+ shutdownMcp(),
267
+ lspService.shutdown(),
268
+ ]);
269
+ for (const result of results) {
270
+ if (result.status === "rejected") {
271
+ // Shutdown is best-effort; never turn exit into a fatal error.
272
+ }
273
+ }
274
+ };
264
275
  const runMemoryCompaction = async () => formatMemoryStartupResult(await runMemoryStartupPipeline({
265
276
  cwd: args.cwd,
266
277
  complete: (messages, completeOptions) => agent.complete(messages, completeOptions),
@@ -296,49 +307,53 @@ async function main() {
296
307
  console.log(chalk.dim(`Resumed session: ${sessionManager.getSessionFile()}`));
297
308
  }
298
309
  }
299
- // Print mode: single prompt, then exit
300
- if (args.print || args.prompt) {
301
- const prompt = args.prompt || (await readPipedStdin()) || "";
302
- if (!prompt) {
303
- console.error(chalk.red("Error: No prompt provided."));
304
- process.exit(1);
305
- }
306
- for await (const event of agent.run(prompt, args.cwd)) {
307
- if (event.type === "text_delta") {
308
- process.stdout.write(event.content);
309
- }
310
- else if (event.type === "tool_start") {
311
- console.log(chalk.cyan(`\n[Tool: ${event.name}]`));
310
+ try {
311
+ // Print mode: single prompt, then exit
312
+ if (args.print || args.prompt) {
313
+ const prompt = args.prompt || (await readPipedStdin()) || "";
314
+ if (!prompt) {
315
+ console.error(chalk.red("Error: No prompt provided."));
316
+ process.exit(1);
312
317
  }
313
- else if (event.type === "tool_end") {
314
- const color = event.result.isError ? chalk.red : chalk.dim;
315
- console.log(color(`[Result: ${event.result.content.slice(0, 200)}${event.result.content.length > 200 ? "..." : ""}]`));
318
+ for await (const event of agent.run(prompt, args.cwd)) {
319
+ if (event.type === "text_delta") {
320
+ process.stdout.write(event.content);
321
+ }
322
+ else if (event.type === "tool_start") {
323
+ console.log(chalk.cyan(`\n[Tool: ${event.name}]`));
324
+ }
325
+ else if (event.type === "tool_end") {
326
+ const color = event.result.isError ? chalk.red : chalk.dim;
327
+ console.log(color(`[Result: ${event.result.content.slice(0, 200)}${event.result.content.length > 200 ? "..." : ""}]`));
328
+ }
316
329
  }
330
+ console.log();
331
+ return;
317
332
  }
318
- console.log();
319
- return;
333
+ // Interactive mode: OpenTUI uses Bun native FFI, matching opencode's TUI stack.
334
+ const { runTui } = await import("./tui/run.js");
335
+ await runTui(agent, args, {
336
+ sessionManager,
337
+ createProvider,
338
+ registry,
339
+ skillRegistry,
340
+ planHandlerRef,
341
+ approvalHandlerRef,
342
+ questionController,
343
+ bashAllowlist,
344
+ settingsManager,
345
+ lspService,
346
+ mcpManager,
347
+ theme: userConfig.getTheme(),
348
+ flushMemory,
349
+ runMemoryCompaction,
350
+ runMemorySummary,
351
+ runMemoryRefresh,
352
+ });
353
+ }
354
+ finally {
355
+ await shutdownRuntime();
320
356
  }
321
- // Interactive mode: OpenTUI uses Bun native FFI, matching opencode's TUI stack.
322
- const { runTui } = await import("./tui/run.js");
323
- await runTui(agent, args, {
324
- sessionManager,
325
- createProvider,
326
- registry,
327
- skillRegistry,
328
- planHandlerRef,
329
- approvalHandlerRef,
330
- questionController,
331
- bashAllowlist,
332
- settingsManager,
333
- lspService,
334
- mcpManager,
335
- theme: userConfig.getTheme(),
336
- flushMemory,
337
- runMemoryCompaction,
338
- runMemorySummary,
339
- runMemoryRefresh,
340
- });
341
- await flushMemory();
342
357
  }
343
358
  async function readPipedStdin() {
344
359
  if (process.stdin.isTTY)