@bubblebrain-ai/bubble 0.0.13 → 0.0.15
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.
- package/dist/agent/execution-governor.js +1 -1
- package/dist/agent/tool-intent.js +1 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.js +589 -316
- package/dist/approval/controller.d.ts +1 -0
- package/dist/approval/controller.js +20 -3
- package/dist/approval/tool-helper.js +2 -0
- package/dist/approval/types.d.ts +14 -1
- package/dist/cli.d.ts +3 -1
- package/dist/cli.js +12 -0
- package/dist/context/compact.js +9 -3
- package/dist/context/projector.js +27 -12
- package/dist/debug-trace.d.ts +27 -0
- package/dist/debug-trace.js +385 -0
- package/dist/feishu/agent-host/approval-card.js +9 -0
- package/dist/feishu/serve.js +7 -1
- package/dist/main.js +41 -0
- package/dist/model-catalog.js +1 -0
- package/dist/orchestrator/default-hooks.js +19 -8
- package/dist/orchestrator/hooks.d.ts +1 -0
- package/dist/prompt/environment.js +2 -0
- package/dist/prompt/reminders.d.ts +5 -6
- package/dist/prompt/reminders.js +8 -9
- package/dist/prompt/runtime.js +2 -2
- package/dist/provider-openai-codex.d.ts +7 -0
- package/dist/provider-openai-codex.js +265 -124
- package/dist/provider-registry.d.ts +2 -0
- package/dist/provider-registry.js +58 -9
- package/dist/provider.d.ts +3 -0
- package/dist/provider.js +5 -1
- package/dist/session-log.js +13 -1
- package/dist/slash-commands/commands.js +12 -0
- package/dist/slash-commands/types.d.ts +2 -0
- package/dist/stats/usage.d.ts +52 -0
- package/dist/stats/usage.js +414 -0
- package/dist/tools/apply-patch.d.ts +9 -0
- package/dist/tools/apply-patch.js +330 -0
- package/dist/tools/bash.js +205 -44
- package/dist/tools/edit-apply.d.ts +5 -2
- package/dist/tools/edit-apply.js +221 -31
- package/dist/tools/edit.js +12 -3
- package/dist/tools/file-mutation-queue.d.ts +1 -0
- package/dist/tools/file-mutation-queue.js +12 -1
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +7 -1
- package/dist/tools/patch-apply.d.ts +41 -0
- package/dist/tools/patch-apply.js +312 -0
- package/dist/tools/server-manager.d.ts +36 -0
- package/dist/tools/server-manager.js +234 -0
- package/dist/tools/server.d.ts +6 -0
- package/dist/tools/server.js +245 -0
- package/dist/tools/write.d.ts +3 -6
- package/dist/tools/write.js +26 -46
- package/dist/tui/display-history.d.ts +1 -0
- package/dist/tui/display-history.js +5 -4
- package/dist/tui/edit-diff.js +6 -1
- package/dist/tui/model-picker-data.d.ts +10 -0
- package/dist/tui/model-picker-data.js +32 -0
- package/dist/tui/run.d.ts +2 -0
- package/dist/tui/run.js +717 -122
- package/dist/tui/tool-renderers/fallback.js +1 -1
- package/dist/tui/tool-renderers/write-preview.js +2 -0
- package/dist/tui/trace-groups.js +10 -3
- package/dist/tui-ink/app.js +1 -4
- package/dist/tui-ink/approval/approval-dialog.js +7 -1
- package/dist/tui-ink/display-history.d.ts +1 -0
- package/dist/tui-ink/display-history.js +5 -4
- package/dist/tui-ink/message-list.js +14 -8
- package/dist/tui-ink/trace-groups.js +1 -1
- package/dist/tui-opentui/app.js +2 -0
- package/dist/tui-opentui/approval/approval-dialog.js +7 -1
- package/dist/tui-opentui/display-history.d.ts +1 -0
- package/dist/tui-opentui/display-history.js +5 -4
- package/dist/tui-opentui/edit-diff.js +6 -1
- package/dist/tui-opentui/message-list.js +6 -3
- package/dist/tui-opentui/trace-groups.js +10 -3
- package/dist/types.d.ts +12 -2
- package/dist/update/index.d.ts +46 -0
- package/dist/update/index.js +240 -0
- package/package.json +1 -1
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import { createTwoFilesPatch } from "diff";
|
|
5
|
+
import { gateToolAction } from "../approval/tool-helper.js";
|
|
6
|
+
import { countUnifiedDiffChanges } from "../diff-stats.js";
|
|
7
|
+
import { formatDiagnosticBlocks } from "../lsp/index.js";
|
|
8
|
+
import { isWithinWorkspace } from "./file-state.js";
|
|
9
|
+
import { withFileMutationQueues } from "./file-mutation-queue.js";
|
|
10
|
+
import { resolveToolPath } from "./path-utils.js";
|
|
11
|
+
import { applyPatchChunks, buildAddedFileContent, parseApplyPatch, PatchApplyError, } from "./patch-apply.js";
|
|
12
|
+
export function createApplyPatchTool(cwd, approval, lsp, fileState) {
|
|
13
|
+
return {
|
|
14
|
+
name: "apply_patch",
|
|
15
|
+
effect: "write_patch",
|
|
16
|
+
requiresApproval: true,
|
|
17
|
+
description: "Apply a structured patch for multi-file or larger changes. Use edit for small targeted replacements, write for full-file generation, and apply_patch for related adds/updates/deletes/moves.",
|
|
18
|
+
parameters: {
|
|
19
|
+
type: "object",
|
|
20
|
+
properties: {
|
|
21
|
+
patch: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Patch text using *** Begin Patch / *** Add File / *** Update File / *** Delete File / *** End Patch markers.",
|
|
24
|
+
},
|
|
25
|
+
patchText: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Alias for patch; accepted for compatibility with other coding agents.",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
async execute(args) {
|
|
32
|
+
const patchText = typeof args.patch === "string"
|
|
33
|
+
? args.patch
|
|
34
|
+
: typeof args.patchText === "string"
|
|
35
|
+
? args.patchText
|
|
36
|
+
: "";
|
|
37
|
+
if (!patchText.trim()) {
|
|
38
|
+
return {
|
|
39
|
+
content: "Error: apply_patch requires a non-empty patch string.",
|
|
40
|
+
isError: true,
|
|
41
|
+
status: "blocked",
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
let operations;
|
|
45
|
+
try {
|
|
46
|
+
operations = parseApplyPatch(patchText).operations;
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
if (err instanceof PatchApplyError) {
|
|
50
|
+
return { content: err.message, isError: true, status: err.status };
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
const lockPaths = collectOperationPaths(cwd, operations);
|
|
55
|
+
return withFileMutationQueues(lockPaths, async () => {
|
|
56
|
+
let plan;
|
|
57
|
+
try {
|
|
58
|
+
plan = await buildPatchPlan(cwd, operations);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof PatchApplyError) {
|
|
62
|
+
return { content: err.message, isError: true, status: err.status };
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
const gate = await gateToolAction(approval, {
|
|
67
|
+
type: "patch",
|
|
68
|
+
path: summarizePaths(plan.paths),
|
|
69
|
+
paths: plan.paths,
|
|
70
|
+
files: plan.changes.map((change) => ({ path: change.path, kind: change.kind })),
|
|
71
|
+
diff: plan.diff,
|
|
72
|
+
});
|
|
73
|
+
if (!gate.approved)
|
|
74
|
+
return gate.result;
|
|
75
|
+
const stale = await checkPlanFresh(plan);
|
|
76
|
+
if (stale)
|
|
77
|
+
return stale;
|
|
78
|
+
try {
|
|
79
|
+
await writePatchPlan(plan);
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
await rollbackPatchPlan(plan).catch(() => undefined);
|
|
83
|
+
return {
|
|
84
|
+
content: `Error: apply_patch failed while writing files: ${err instanceof Error ? err.message : String(err)}`,
|
|
85
|
+
isError: true,
|
|
86
|
+
status: "partial",
|
|
87
|
+
metadata: {
|
|
88
|
+
kind: "patch",
|
|
89
|
+
paths: plan.paths,
|
|
90
|
+
diff: plan.diff,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
await observePatchPlan(fileState, plan);
|
|
95
|
+
let output = `Applied patch to ${plan.changes.length} file${plan.changes.length === 1 ? "" : "s"}.`;
|
|
96
|
+
if (plan.fallbackCount > 0) {
|
|
97
|
+
output += ` ${plan.fallbackCount} hunk${plan.fallbackCount === 1 ? "" : "s"} used normalized matching.`;
|
|
98
|
+
}
|
|
99
|
+
if (lsp) {
|
|
100
|
+
for (const change of plan.changes) {
|
|
101
|
+
if (change.newContent === undefined)
|
|
102
|
+
continue;
|
|
103
|
+
try {
|
|
104
|
+
await lsp.touchFile(change.path, "document");
|
|
105
|
+
output += formatDiagnosticBlocks(cwd, change.path, lsp.diagnostics());
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// LSP diagnostics should not turn a successful patch into a failed tool call.
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const totals = plan.changes.reduce((acc, change) => ({
|
|
113
|
+
added: acc.added + change.addedLines,
|
|
114
|
+
removed: acc.removed + change.removedLines,
|
|
115
|
+
}), { added: 0, removed: 0 });
|
|
116
|
+
return {
|
|
117
|
+
content: output,
|
|
118
|
+
status: "success",
|
|
119
|
+
metadata: {
|
|
120
|
+
kind: "patch",
|
|
121
|
+
paths: plan.paths,
|
|
122
|
+
diff: plan.diff,
|
|
123
|
+
addedLines: totals.added,
|
|
124
|
+
removedLines: totals.removed,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function buildPatchPlan(cwd, operations) {
|
|
132
|
+
const states = new Map();
|
|
133
|
+
let fallbackCount = 0;
|
|
134
|
+
const stateFor = async (path) => {
|
|
135
|
+
const absolutePath = resolveToolPath(cwd, path);
|
|
136
|
+
assertWorkspacePath(cwd, absolutePath);
|
|
137
|
+
const existing = states.get(absolutePath);
|
|
138
|
+
if (existing)
|
|
139
|
+
return existing;
|
|
140
|
+
const originalContent = await readExistingFile(absolutePath);
|
|
141
|
+
const state = {
|
|
142
|
+
path: absolutePath,
|
|
143
|
+
originalContent,
|
|
144
|
+
currentContent: originalContent,
|
|
145
|
+
};
|
|
146
|
+
states.set(absolutePath, state);
|
|
147
|
+
return state;
|
|
148
|
+
};
|
|
149
|
+
for (const operation of operations) {
|
|
150
|
+
if (operation.type === "add") {
|
|
151
|
+
const state = await stateFor(operation.path);
|
|
152
|
+
if (state.currentContent !== undefined) {
|
|
153
|
+
throw new PatchApplyError(`Error: Cannot add ${operation.path}; file already exists.`, "blocked");
|
|
154
|
+
}
|
|
155
|
+
state.currentContent = buildAddedFileContent(operation.lines);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
if (operation.type === "delete") {
|
|
159
|
+
const state = await stateFor(operation.path);
|
|
160
|
+
if (state.currentContent === undefined) {
|
|
161
|
+
throw new PatchApplyError(`Error: Cannot delete ${operation.path}; file does not exist.`, "blocked");
|
|
162
|
+
}
|
|
163
|
+
state.currentContent = undefined;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const source = await stateFor(operation.path);
|
|
167
|
+
if (source.currentContent === undefined) {
|
|
168
|
+
throw new PatchApplyError(`Error: Cannot update ${operation.path}; file does not exist.`, "blocked");
|
|
169
|
+
}
|
|
170
|
+
let nextContent = source.currentContent;
|
|
171
|
+
if (operation.chunks.length > 0) {
|
|
172
|
+
const patched = applyPatchChunks(source.currentContent, operation.chunks, operation.path);
|
|
173
|
+
nextContent = patched.content;
|
|
174
|
+
if (patched.usedFallback)
|
|
175
|
+
fallbackCount++;
|
|
176
|
+
}
|
|
177
|
+
if (operation.movePath) {
|
|
178
|
+
const target = await stateFor(operation.movePath);
|
|
179
|
+
if (target.path === source.path) {
|
|
180
|
+
throw new PatchApplyError(`Error: Cannot move ${operation.path} to itself.`, "blocked");
|
|
181
|
+
}
|
|
182
|
+
if (target.currentContent !== undefined) {
|
|
183
|
+
throw new PatchApplyError(`Error: Cannot move ${operation.path} to ${operation.movePath}; target already exists.`, "blocked");
|
|
184
|
+
}
|
|
185
|
+
source.currentContent = undefined;
|
|
186
|
+
target.currentContent = nextContent;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
source.currentContent = nextContent;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
const changes = [...states.values()]
|
|
193
|
+
.filter((state) => state.originalContent !== state.currentContent)
|
|
194
|
+
.map((state) => fileStateToChange(state));
|
|
195
|
+
if (changes.length === 0) {
|
|
196
|
+
throw new PatchApplyError("Error: Patch produced no file changes.", "blocked");
|
|
197
|
+
}
|
|
198
|
+
const diff = changes.map((change) => change.diff).join("\n");
|
|
199
|
+
return {
|
|
200
|
+
changes,
|
|
201
|
+
diff,
|
|
202
|
+
paths: changes.map((change) => change.path),
|
|
203
|
+
fallbackCount,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function fileStateToChange(state) {
|
|
207
|
+
const oldContent = state.originalContent;
|
|
208
|
+
const newContent = state.currentContent;
|
|
209
|
+
const kind = oldContent === undefined
|
|
210
|
+
? "add"
|
|
211
|
+
: newContent === undefined
|
|
212
|
+
? "delete"
|
|
213
|
+
: "update";
|
|
214
|
+
const diff = createTwoFilesPatch(state.path, state.path, oldContent ?? "", newContent ?? "", "original", "modified", { context: 3 });
|
|
215
|
+
const stats = countUnifiedDiffChanges(diff);
|
|
216
|
+
return {
|
|
217
|
+
path: state.path,
|
|
218
|
+
kind,
|
|
219
|
+
oldContent,
|
|
220
|
+
newContent,
|
|
221
|
+
diff,
|
|
222
|
+
addedLines: stats.added,
|
|
223
|
+
removedLines: stats.removed,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
async function readExistingFile(path) {
|
|
227
|
+
try {
|
|
228
|
+
const info = await stat(path);
|
|
229
|
+
if (info.isDirectory()) {
|
|
230
|
+
throw new PatchApplyError(`Error: Cannot patch directory: ${path}`, "blocked");
|
|
231
|
+
}
|
|
232
|
+
await access(path, constants.R_OK | constants.W_OK);
|
|
233
|
+
return await readFile(path, "utf-8");
|
|
234
|
+
}
|
|
235
|
+
catch (err) {
|
|
236
|
+
if (err instanceof PatchApplyError)
|
|
237
|
+
throw err;
|
|
238
|
+
if (isMissingPathError(err))
|
|
239
|
+
return undefined;
|
|
240
|
+
throw new PatchApplyError(`Error: Cannot read/write file for patch: ${path}`, "blocked");
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
async function checkPlanFresh(plan) {
|
|
244
|
+
for (const change of plan.changes) {
|
|
245
|
+
let current;
|
|
246
|
+
try {
|
|
247
|
+
current = await readExistingFile(change.path);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
if (err instanceof PatchApplyError) {
|
|
251
|
+
return { content: err.message, isError: true, status: err.status };
|
|
252
|
+
}
|
|
253
|
+
throw err;
|
|
254
|
+
}
|
|
255
|
+
if (current !== change.oldContent) {
|
|
256
|
+
return {
|
|
257
|
+
content: `Error: Cannot safely apply patch because ${change.path} changed after the patch was prepared.\n\n`
|
|
258
|
+
+ "Re-read the affected file and regenerate the patch against the latest content.",
|
|
259
|
+
isError: true,
|
|
260
|
+
status: "blocked",
|
|
261
|
+
metadata: {
|
|
262
|
+
kind: "patch",
|
|
263
|
+
path: change.path,
|
|
264
|
+
paths: plan.paths,
|
|
265
|
+
reason: "changed",
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return undefined;
|
|
271
|
+
}
|
|
272
|
+
async function writePatchPlan(plan) {
|
|
273
|
+
for (const change of plan.changes) {
|
|
274
|
+
if (change.newContent === undefined)
|
|
275
|
+
continue;
|
|
276
|
+
await mkdir(dirname(change.path), { recursive: true });
|
|
277
|
+
await writeFile(change.path, change.newContent, "utf-8");
|
|
278
|
+
}
|
|
279
|
+
for (const change of plan.changes) {
|
|
280
|
+
if (change.newContent !== undefined)
|
|
281
|
+
continue;
|
|
282
|
+
await rm(change.path, { force: true });
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async function rollbackPatchPlan(plan) {
|
|
286
|
+
for (const change of [...plan.changes].reverse()) {
|
|
287
|
+
if (change.oldContent === undefined) {
|
|
288
|
+
await rm(change.path, { force: true });
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
await mkdir(dirname(change.path), { recursive: true });
|
|
292
|
+
await writeFile(change.path, change.oldContent, "utf-8");
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
async function observePatchPlan(fileState, plan) {
|
|
297
|
+
if (!fileState)
|
|
298
|
+
return;
|
|
299
|
+
await Promise.all(plan.changes.map(async (change) => {
|
|
300
|
+
if (change.newContent === undefined)
|
|
301
|
+
return;
|
|
302
|
+
await fileState.observe(change.path, "edit", change.newContent).catch(() => undefined);
|
|
303
|
+
}));
|
|
304
|
+
}
|
|
305
|
+
function collectOperationPaths(cwd, operations) {
|
|
306
|
+
const paths = [];
|
|
307
|
+
for (const operation of operations) {
|
|
308
|
+
paths.push(resolveToolPath(cwd, operation.path));
|
|
309
|
+
if (operation.type === "update" && operation.movePath) {
|
|
310
|
+
paths.push(resolveToolPath(cwd, operation.movePath));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return paths;
|
|
314
|
+
}
|
|
315
|
+
function assertWorkspacePath(cwd, filePath) {
|
|
316
|
+
if (!isWithinWorkspace(cwd, filePath)) {
|
|
317
|
+
throw new PatchApplyError(`Error: Patch path is outside the workspace: ${filePath}`, "blocked");
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
function summarizePaths(paths) {
|
|
321
|
+
if (paths.length === 1)
|
|
322
|
+
return paths[0];
|
|
323
|
+
return `${paths[0]} (+${paths.length - 1} more)`;
|
|
324
|
+
}
|
|
325
|
+
function isMissingPathError(error) {
|
|
326
|
+
return (typeof error === "object"
|
|
327
|
+
&& error !== null
|
|
328
|
+
&& "code" in error
|
|
329
|
+
&& (error.code === "ENOENT" || error.code === "ENOTDIR"));
|
|
330
|
+
}
|
package/dist/tools/bash.js
CHANGED
|
@@ -8,12 +8,15 @@ import { gateToolAction } from "../approval/tool-helper.js";
|
|
|
8
8
|
import { parseReadBashCommand, parseSearchBashCommand } from "../agent/tool-intent.js";
|
|
9
9
|
import { referencesSensitivePath } from "./sensitive-paths.js";
|
|
10
10
|
const MAX_OUTPUT = 50 * 1024;
|
|
11
|
+
const POST_EXIT_STDIO_GRACE_MS = 150;
|
|
12
|
+
const FORCE_KILL_AFTER_MS = 750;
|
|
13
|
+
const ABORT_SETTLE_AFTER_MS = 1500;
|
|
11
14
|
export function createBashTool(cwd, approval, _fileState) {
|
|
12
15
|
return {
|
|
13
16
|
name: "bash",
|
|
14
17
|
effect: "unknown",
|
|
15
18
|
requiresApproval: true,
|
|
16
|
-
description: "Execute a bash command in the working directory. Use timeout for long-running commands.",
|
|
19
|
+
description: "Execute a bounded bash command in the working directory. Use timeout for long-running commands. For persistent dev servers or watchers such as npm run dev, next dev, vite, or webpack --watch, use start_server instead of backgrounding a bash command.",
|
|
17
20
|
parameters: {
|
|
18
21
|
type: "object",
|
|
19
22
|
properties: {
|
|
@@ -51,39 +54,47 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
51
54
|
cwd,
|
|
52
55
|
stdio: ["ignore", "pipe", "pipe"],
|
|
53
56
|
env: process.env,
|
|
57
|
+
detached: platform() !== "win32",
|
|
58
|
+
windowsHide: true,
|
|
54
59
|
});
|
|
55
60
|
let stdout = "";
|
|
56
61
|
let stderr = "";
|
|
57
|
-
let
|
|
58
|
-
let
|
|
59
|
-
|
|
60
|
-
|
|
62
|
+
let stdoutTruncated = false;
|
|
63
|
+
let stderrTruncated = false;
|
|
64
|
+
let stdoutEnded = child.stdout === null;
|
|
65
|
+
let stderrEnded = child.stderr === null;
|
|
66
|
+
let exitCode = null;
|
|
67
|
+
let terminal;
|
|
68
|
+
let terminalError;
|
|
69
|
+
let resolved = false;
|
|
70
|
+
let timeoutHandle;
|
|
71
|
+
let forceKillHandle;
|
|
72
|
+
let settleHandle;
|
|
73
|
+
let postExitHandle;
|
|
74
|
+
const appendOutput = (target, data) => {
|
|
75
|
+
const current = target === "stdout" ? stdout : stderr;
|
|
76
|
+
const truncated = target === "stdout" ? stdoutTruncated : stderrTruncated;
|
|
77
|
+
if (truncated)
|
|
78
|
+
return;
|
|
79
|
+
const next = current + data.toString();
|
|
80
|
+
if (Buffer.byteLength(next, "utf-8") <= MAX_OUTPUT) {
|
|
81
|
+
if (target === "stdout")
|
|
82
|
+
stdout = next;
|
|
83
|
+
else
|
|
84
|
+
stderr = next;
|
|
61
85
|
return;
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
86
|
+
}
|
|
87
|
+
const capped = Buffer.from(next, "utf-8").subarray(0, MAX_OUTPUT).toString("utf-8");
|
|
88
|
+
if (target === "stdout") {
|
|
89
|
+
stdout = capped;
|
|
90
|
+
stdoutTruncated = true;
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
stderr = capped;
|
|
94
|
+
stderrTruncated = true;
|
|
95
|
+
}
|
|
65
96
|
};
|
|
66
|
-
const
|
|
67
|
-
timedOut = true;
|
|
68
|
-
abortChild();
|
|
69
|
-
}, timeoutSec * 1000);
|
|
70
|
-
if (ctx.abortSignal?.aborted)
|
|
71
|
-
abortChild();
|
|
72
|
-
ctx.abortSignal?.addEventListener("abort", abortChild, { once: true });
|
|
73
|
-
child.stdout?.on("data", (data) => {
|
|
74
|
-
stdout += data.toString();
|
|
75
|
-
});
|
|
76
|
-
child.stderr?.on("data", (data) => {
|
|
77
|
-
stderr += data.toString();
|
|
78
|
-
});
|
|
79
|
-
child.on("error", (err) => {
|
|
80
|
-
clearTimeout(timeoutHandle);
|
|
81
|
-
ctx.abortSignal?.removeEventListener("abort", abortChild);
|
|
82
|
-
resolve({ content: `Error: ${err.message}`, isError: true });
|
|
83
|
-
});
|
|
84
|
-
child.on("close", (code) => {
|
|
85
|
-
clearTimeout(timeoutHandle);
|
|
86
|
-
ctx.abortSignal?.removeEventListener("abort", abortChild);
|
|
97
|
+
const buildOutput = (suffix) => {
|
|
87
98
|
let output = "";
|
|
88
99
|
if (stdout)
|
|
89
100
|
output += `stdout:\n${stdout}\n`;
|
|
@@ -91,10 +102,61 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
91
102
|
output += `stderr:\n${stderr}\n`;
|
|
92
103
|
if (output === "")
|
|
93
104
|
output = "(no output)\n";
|
|
94
|
-
if (
|
|
95
|
-
output +=
|
|
105
|
+
if (stdoutTruncated || stderrTruncated) {
|
|
106
|
+
output += "\n[Output truncated]";
|
|
107
|
+
}
|
|
108
|
+
if (Buffer.byteLength(output, "utf-8") > MAX_OUTPUT) {
|
|
109
|
+
output = Buffer.from(output, "utf-8").subarray(0, MAX_OUTPUT).toString("utf-8");
|
|
110
|
+
output += "\n[Output truncated]";
|
|
111
|
+
}
|
|
112
|
+
if (suffix)
|
|
113
|
+
output += `${output.endsWith("\n") ? "" : "\n"}${suffix}`;
|
|
114
|
+
return output.trim();
|
|
115
|
+
};
|
|
116
|
+
const cleanup = () => {
|
|
117
|
+
if (timeoutHandle)
|
|
118
|
+
clearTimeout(timeoutHandle);
|
|
119
|
+
if (forceKillHandle)
|
|
120
|
+
clearTimeout(forceKillHandle);
|
|
121
|
+
if (settleHandle)
|
|
122
|
+
clearTimeout(settleHandle);
|
|
123
|
+
if (postExitHandle)
|
|
124
|
+
clearTimeout(postExitHandle);
|
|
125
|
+
ctx.abortSignal?.removeEventListener("abort", abortChild);
|
|
126
|
+
child.stdout?.removeListener("data", onStdoutData);
|
|
127
|
+
child.stderr?.removeListener("data", onStderrData);
|
|
128
|
+
child.stdout?.removeListener("end", onStdoutEnd);
|
|
129
|
+
child.stdout?.removeListener("close", onStdoutEnd);
|
|
130
|
+
child.stderr?.removeListener("end", onStderrEnd);
|
|
131
|
+
child.stderr?.removeListener("close", onStderrEnd);
|
|
132
|
+
child.removeListener("error", onError);
|
|
133
|
+
child.removeListener("exit", onExit);
|
|
134
|
+
child.removeListener("close", onClose);
|
|
135
|
+
};
|
|
136
|
+
const destroyStreams = () => {
|
|
137
|
+
child.stdout?.destroy();
|
|
138
|
+
child.stderr?.destroy();
|
|
139
|
+
};
|
|
140
|
+
const cleanupBackgroundGroup = () => {
|
|
141
|
+
if (!child.pid || terminal !== "exit")
|
|
142
|
+
return;
|
|
143
|
+
killProcessTree(child.pid, "SIGTERM");
|
|
144
|
+
setTimeout(() => killProcessTree(child.pid, "SIGKILL"), FORCE_KILL_AFTER_MS).unref?.();
|
|
145
|
+
};
|
|
146
|
+
const finish = () => {
|
|
147
|
+
if (resolved)
|
|
148
|
+
return;
|
|
149
|
+
resolved = true;
|
|
150
|
+
cleanup();
|
|
151
|
+
cleanupBackgroundGroup();
|
|
152
|
+
destroyStreams();
|
|
153
|
+
if (terminal === "error") {
|
|
154
|
+
resolve({ content: `Error: ${terminalError?.message ?? "Failed to start command"}`, isError: true });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (terminal === "timeout") {
|
|
96
158
|
resolve({
|
|
97
|
-
content:
|
|
159
|
+
content: buildOutput(`[Command timed out after ${timeoutSec}s]`),
|
|
98
160
|
isError: true,
|
|
99
161
|
status: "timeout",
|
|
100
162
|
metadata: {
|
|
@@ -105,12 +167,11 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
105
167
|
});
|
|
106
168
|
return;
|
|
107
169
|
}
|
|
108
|
-
if (
|
|
109
|
-
output += "[Command cancelled]";
|
|
170
|
+
if (terminal === "cancelled") {
|
|
110
171
|
resolve({
|
|
111
|
-
content:
|
|
172
|
+
content: buildOutput("[Command cancelled]"),
|
|
112
173
|
isError: true,
|
|
113
|
-
status: "
|
|
174
|
+
status: "cancelled",
|
|
114
175
|
metadata: {
|
|
115
176
|
kind: parsedSearch ? "search" : parsedRead ? "read" : "shell",
|
|
116
177
|
pattern: parsedSearch?.pattern,
|
|
@@ -120,12 +181,8 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
120
181
|
});
|
|
121
182
|
return;
|
|
122
183
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
output += "\n[Output truncated]";
|
|
126
|
-
}
|
|
127
|
-
const normalizedOutput = output.trim();
|
|
128
|
-
if (parsedSearch && code === 1 && !stderr.trim()) {
|
|
184
|
+
const normalizedOutput = buildOutput();
|
|
185
|
+
if (parsedSearch && exitCode === 1 && !stderr.trim()) {
|
|
129
186
|
resolve({
|
|
130
187
|
content: normalizedOutput === "(no output)" ? "stdout:\n(no matches)" : normalizedOutput,
|
|
131
188
|
isError: false,
|
|
@@ -140,7 +197,7 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
140
197
|
});
|
|
141
198
|
return;
|
|
142
199
|
}
|
|
143
|
-
const isError =
|
|
200
|
+
const isError = exitCode !== 0;
|
|
144
201
|
resolve({
|
|
145
202
|
content: normalizedOutput,
|
|
146
203
|
isError,
|
|
@@ -153,11 +210,115 @@ export function createBashTool(cwd, approval, _fileState) {
|
|
|
153
210
|
matches: parsedSearch ? countSearchMatches(stdout) : undefined,
|
|
154
211
|
},
|
|
155
212
|
});
|
|
156
|
-
}
|
|
213
|
+
};
|
|
214
|
+
const maybeFinishAfterExit = () => {
|
|
215
|
+
if (terminal !== "exit" && terminal !== "timeout" && terminal !== "cancelled")
|
|
216
|
+
return;
|
|
217
|
+
if (stdoutEnded && stderrEnded) {
|
|
218
|
+
finish();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (!postExitHandle) {
|
|
222
|
+
postExitHandle = setTimeout(finish, POST_EXIT_STDIO_GRACE_MS);
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
const abortChild = () => {
|
|
226
|
+
if (resolved || terminal)
|
|
227
|
+
return;
|
|
228
|
+
terminal = "cancelled";
|
|
229
|
+
if (child.pid) {
|
|
230
|
+
killProcessTree(child.pid, "SIGTERM");
|
|
231
|
+
forceKillHandle = setTimeout(() => {
|
|
232
|
+
if (child.pid)
|
|
233
|
+
killProcessTree(child.pid, "SIGKILL");
|
|
234
|
+
}, FORCE_KILL_AFTER_MS);
|
|
235
|
+
}
|
|
236
|
+
settleHandle = setTimeout(finish, ABORT_SETTLE_AFTER_MS);
|
|
237
|
+
};
|
|
238
|
+
const timeoutChild = () => {
|
|
239
|
+
if (resolved || terminal)
|
|
240
|
+
return;
|
|
241
|
+
terminal = "timeout";
|
|
242
|
+
if (child.pid) {
|
|
243
|
+
killProcessTree(child.pid, "SIGTERM");
|
|
244
|
+
forceKillHandle = setTimeout(() => {
|
|
245
|
+
if (child.pid)
|
|
246
|
+
killProcessTree(child.pid, "SIGKILL");
|
|
247
|
+
}, FORCE_KILL_AFTER_MS);
|
|
248
|
+
}
|
|
249
|
+
settleHandle = setTimeout(finish, ABORT_SETTLE_AFTER_MS);
|
|
250
|
+
};
|
|
251
|
+
const onStdoutData = (data) => appendOutput("stdout", data);
|
|
252
|
+
const onStderrData = (data) => appendOutput("stderr", data);
|
|
253
|
+
const onStdoutEnd = () => {
|
|
254
|
+
stdoutEnded = true;
|
|
255
|
+
maybeFinishAfterExit();
|
|
256
|
+
};
|
|
257
|
+
const onStderrEnd = () => {
|
|
258
|
+
stderrEnded = true;
|
|
259
|
+
maybeFinishAfterExit();
|
|
260
|
+
};
|
|
261
|
+
const onError = (err) => {
|
|
262
|
+
if (!terminal) {
|
|
263
|
+
terminal = "error";
|
|
264
|
+
terminalError = err;
|
|
265
|
+
}
|
|
266
|
+
finish();
|
|
267
|
+
};
|
|
268
|
+
const onExit = (code) => {
|
|
269
|
+
exitCode = code;
|
|
270
|
+
if (!terminal)
|
|
271
|
+
terminal = "exit";
|
|
272
|
+
maybeFinishAfterExit();
|
|
273
|
+
};
|
|
274
|
+
const onClose = (code) => {
|
|
275
|
+
exitCode = code;
|
|
276
|
+
if (!terminal)
|
|
277
|
+
terminal = "exit";
|
|
278
|
+
finish();
|
|
279
|
+
};
|
|
280
|
+
timeoutHandle = setTimeout(timeoutChild, timeoutSec * 1000);
|
|
281
|
+
if (ctx.abortSignal?.aborted)
|
|
282
|
+
abortChild();
|
|
283
|
+
ctx.abortSignal?.addEventListener("abort", abortChild, { once: true });
|
|
284
|
+
child.stdout?.on("data", onStdoutData);
|
|
285
|
+
child.stderr?.on("data", onStderrData);
|
|
286
|
+
child.stdout?.once("end", onStdoutEnd);
|
|
287
|
+
child.stdout?.once("close", onStdoutEnd);
|
|
288
|
+
child.stderr?.once("end", onStderrEnd);
|
|
289
|
+
child.stderr?.once("close", onStderrEnd);
|
|
290
|
+
child.once("error", onError);
|
|
291
|
+
child.once("exit", onExit);
|
|
292
|
+
child.once("close", onClose);
|
|
157
293
|
});
|
|
158
294
|
},
|
|
159
295
|
};
|
|
160
296
|
}
|
|
297
|
+
function killProcessTree(pid, signal) {
|
|
298
|
+
if (platform() === "win32") {
|
|
299
|
+
try {
|
|
300
|
+
spawn("taskkill", ["/pid", String(pid), "/f", "/t"], {
|
|
301
|
+
stdio: "ignore",
|
|
302
|
+
windowsHide: true,
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
catch {
|
|
306
|
+
// Process may already be gone or taskkill may be unavailable.
|
|
307
|
+
}
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
process.kill(-pid, signal);
|
|
312
|
+
}
|
|
313
|
+
catch {
|
|
314
|
+
try {
|
|
315
|
+
process.kill(pid, signal);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
// Process already exited.
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
161
322
|
function countSearchMatches(stdout) {
|
|
162
323
|
return stdout
|
|
163
324
|
.split("\n")
|
|
@@ -2,7 +2,10 @@ export interface EditOperation {
|
|
|
2
2
|
oldText: string;
|
|
3
3
|
newText: string;
|
|
4
4
|
}
|
|
5
|
-
export type EditMatchMode = "exact" | "normalized-line";
|
|
5
|
+
export type EditMatchMode = "exact" | "trimmed" | "unescaped" | "normalized-line" | "smart-line" | "markdown-table" | "single-line-whitespace";
|
|
6
|
+
export interface EditApplyOptions {
|
|
7
|
+
path?: string;
|
|
8
|
+
}
|
|
6
9
|
export interface EditMatchInfo {
|
|
7
10
|
editIndex: number;
|
|
8
11
|
mode: EditMatchMode;
|
|
@@ -21,5 +24,5 @@ export declare class EditApplyError extends Error {
|
|
|
21
24
|
readonly status: "no_match" | "blocked";
|
|
22
25
|
constructor(message: string, status?: "no_match" | "blocked");
|
|
23
26
|
}
|
|
24
|
-
export declare function applyEditsToContent(rawContent: string, edits: EditOperation[]): AppliedEditResult;
|
|
27
|
+
export declare function applyEditsToContent(rawContent: string, edits: EditOperation[], options?: EditApplyOptions): AppliedEditResult;
|
|
25
28
|
export declare function formatEditMatchNotes(matches: EditMatchInfo[]): string;
|