tinker-agent 1.5.1 → 1.7.0
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/CHANGELOG.md +52 -1
- package/README.md +15 -7
- package/package.json +8 -7
- package/src/agent/assistant-text-delta.ts +10 -0
- package/src/agent/loop.ts +116 -22
- package/src/agent/runtime-session.ts +248 -1
- package/src/cli/command-line.ts +9 -1
- package/src/cli/config.ts +17 -4
- package/src/cli/main.ts +1 -0
- package/src/cli/public-cli-contract.ts +4 -0
- package/src/cli/public-config-contract.ts +25 -1
- package/src/cli/run-runner.ts +5 -0
- package/src/cli/runner-dependencies.ts +4 -1
- package/src/cli/tui-runner.tsx +17 -2
- package/src/events/bash-result-detail.ts +13 -6
- package/src/events/observation-text-log.ts +26 -1
- package/src/events/stdout-event-printer.ts +18 -2
- package/src/events/types.ts +14 -2
- package/src/model/fake-model-client.ts +177 -0
- package/src/model/model-client.ts +3 -0
- package/src/model/openai-chat-model-client.ts +54 -15
- package/src/model/openai-chat-stream.ts +95 -72
- package/src/observation/observation-builder.ts +54 -6
- package/src/session/session-catalog.ts +17 -11
- package/src/session/session-store.ts +2 -0
- package/src/tools/bash-guard.ts +131 -0
- package/src/tools/bash-task.ts +129 -90
- package/src/tools/bash.ts +75 -13
- package/src/tools/delete.ts +182 -0
- package/src/tools/edit.ts +68 -9
- package/src/tools/registry.ts +49 -3
- package/src/tools/shell-process.ts +296 -0
- package/src/tools/task-input.ts +229 -0
- package/src/tools/task-output-tool.ts +4 -1
- package/src/tools/terminal-screen.ts +105 -0
- package/src/tools/turn-undo-manager.ts +794 -0
- package/src/tools/types.ts +45 -0
- package/src/tools/write.ts +65 -14
- package/src/tui/app.tsx +161 -45
- package/src/tui/assistant-markdown-section-framer.ts +135 -0
- package/src/tui/components/background-tasks.tsx +3 -2
- package/src/tui/components/bash-confirmation.tsx +27 -0
- package/src/tui/components/context-status.tsx +11 -1
- package/src/tui/components/footer.tsx +8 -5
- package/src/tui/components/prompt-input.tsx +13 -1
- package/src/tui/components/resume-session-picker.tsx +292 -46
- package/src/tui/components/timeline.tsx +10 -0
- package/src/tui/context-format.ts +17 -0
- package/src/tui/event-store.ts +76 -4
- package/src/tui/slash-commands.ts +28 -0
- package/src/tui/tui-projection-store.ts +246 -7
- package/src/tui/tui-session-controller.ts +19 -1
package/src/tools/edit.ts
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { Buffer } from "node:buffer";
|
|
2
2
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import type { ToolCall } from "../agent/types";
|
|
3
4
|
import { throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
4
5
|
import { computeFilePatch } from "./file-diff";
|
|
5
6
|
import { ensureParentDirectory } from "./ensure-parent-directory";
|
|
6
7
|
import { sha256Bytes, sha256Text } from "./hash";
|
|
7
8
|
import { resolveWorkspacePath } from "./path-safety";
|
|
9
|
+
import type { TurnUndoManager } from "./turn-undo-manager";
|
|
8
10
|
import { defineToolExecutor } from "./types";
|
|
9
11
|
import type {
|
|
10
12
|
EditFileRawResult,
|
|
@@ -23,6 +25,7 @@ type EditArgs = {
|
|
|
23
25
|
export type EditToolOptions = {
|
|
24
26
|
workspaceRoot: string;
|
|
25
27
|
snapshots: FileSnapshotStore;
|
|
28
|
+
undoManager?: TurnUndoManager;
|
|
26
29
|
};
|
|
27
30
|
|
|
28
31
|
export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
@@ -58,7 +61,7 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
58
61
|
},
|
|
59
62
|
async execute(
|
|
60
63
|
args,
|
|
61
|
-
|
|
64
|
+
call,
|
|
62
65
|
context: ToolExecutionContext,
|
|
63
66
|
): Promise<EditFileRawResult> {
|
|
64
67
|
throwIfTurnCancelled(context.signal);
|
|
@@ -102,6 +105,8 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
102
105
|
target,
|
|
103
106
|
newContent: input.new_string,
|
|
104
107
|
snapshots: options.snapshots,
|
|
108
|
+
undoManager: options.undoManager,
|
|
109
|
+
call,
|
|
105
110
|
signal: context.signal,
|
|
106
111
|
});
|
|
107
112
|
}
|
|
@@ -173,6 +178,9 @@ export function createEditToolExecutor(options: EditToolOptions): ToolExecutor {
|
|
|
173
178
|
expectedSha256: target.sha256,
|
|
174
179
|
readRequiredOnChange: true,
|
|
175
180
|
snapshots: options.snapshots,
|
|
181
|
+
undoManager: options.undoManager,
|
|
182
|
+
call,
|
|
183
|
+
initialBefore: target,
|
|
176
184
|
signal: context.signal,
|
|
177
185
|
});
|
|
178
186
|
},
|
|
@@ -219,6 +227,8 @@ async function writeEmptyTarget(input: {
|
|
|
219
227
|
target: TargetFileState;
|
|
220
228
|
newContent: string;
|
|
221
229
|
snapshots: FileSnapshotStore;
|
|
230
|
+
undoManager?: TurnUndoManager;
|
|
231
|
+
call: ToolCall;
|
|
222
232
|
signal: AbortSignal;
|
|
223
233
|
}): Promise<EditFileRawResult> {
|
|
224
234
|
if (input.target.exists && input.target.content.length > 0) {
|
|
@@ -242,6 +252,9 @@ async function writeEmptyTarget(input: {
|
|
|
242
252
|
expectedSha256: input.target.exists ? input.target.sha256 : undefined,
|
|
243
253
|
readRequiredOnChange: false,
|
|
244
254
|
snapshots: input.snapshots,
|
|
255
|
+
undoManager: input.undoManager,
|
|
256
|
+
call: input.call,
|
|
257
|
+
initialBefore: input.target,
|
|
245
258
|
signal: input.signal,
|
|
246
259
|
});
|
|
247
260
|
}
|
|
@@ -258,10 +271,14 @@ async function writeEditedContent(input: {
|
|
|
258
271
|
expectedSha256?: string;
|
|
259
272
|
readRequiredOnChange: boolean;
|
|
260
273
|
snapshots: FileSnapshotStore;
|
|
274
|
+
undoManager?: TurnUndoManager;
|
|
275
|
+
call: ToolCall;
|
|
276
|
+
initialBefore: TargetFileState;
|
|
261
277
|
signal: AbortSignal;
|
|
262
278
|
}): Promise<EditFileRawResult> {
|
|
263
279
|
throwIfTurnCancelled(input.signal);
|
|
264
280
|
|
|
281
|
+
let verifiedBefore = input.initialBefore;
|
|
265
282
|
if (input.expectedSha256 !== undefined) {
|
|
266
283
|
const currentState = await targetFileState(input.absolutePath);
|
|
267
284
|
throwIfTurnCancelled(input.signal);
|
|
@@ -296,12 +313,27 @@ async function writeEditedContent(input: {
|
|
|
296
313
|
: "File changed while Edit was being prepared. Retry Edit with the current file state.",
|
|
297
314
|
};
|
|
298
315
|
}
|
|
316
|
+
verifiedBefore = currentState;
|
|
299
317
|
}
|
|
300
318
|
|
|
319
|
+
const undoCapture = await input.undoManager?.captureBeforeMutation({
|
|
320
|
+
turnId: input.call.turnId,
|
|
321
|
+
turnNumber: input.call.turnNumber,
|
|
322
|
+
absolutePath: input.absolutePath,
|
|
323
|
+
displayPath: input.filePath,
|
|
324
|
+
loadBefore: async () =>
|
|
325
|
+
verifiedBefore.exists
|
|
326
|
+
? { state: "present", bytes: verifiedBefore.bytes }
|
|
327
|
+
: { state: "absent" },
|
|
328
|
+
});
|
|
329
|
+
|
|
301
330
|
if (input.created) {
|
|
302
331
|
try {
|
|
303
332
|
await ensureParentDirectory(input.absolutePath);
|
|
304
333
|
} catch (error) {
|
|
334
|
+
if (undoCapture !== undefined) {
|
|
335
|
+
await input.undoManager?.recordMutationFailure(undoCapture);
|
|
336
|
+
}
|
|
305
337
|
return {
|
|
306
338
|
ok: false,
|
|
307
339
|
filePath: input.filePath,
|
|
@@ -312,14 +344,39 @@ async function writeEditedContent(input: {
|
|
|
312
344
|
throwIfTurnCancelled(input.signal);
|
|
313
345
|
}
|
|
314
346
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
347
|
+
let newSha256: string;
|
|
348
|
+
try {
|
|
349
|
+
throwIfTurnCancelled(input.signal);
|
|
350
|
+
await writeFile(input.absolutePath, input.newContent, "utf8");
|
|
351
|
+
const expectedSha256 = sha256Text(input.newContent);
|
|
352
|
+
const written = await targetFileState(input.absolutePath);
|
|
353
|
+
if (!written.ok) {
|
|
354
|
+
throw new Error(`Failed to verify edited file: ${written.error}`);
|
|
355
|
+
}
|
|
356
|
+
if (!written.exists) {
|
|
357
|
+
throw new Error("Failed to verify edited file: File does not exist.");
|
|
358
|
+
}
|
|
359
|
+
if (written.sha256 !== expectedSha256) {
|
|
360
|
+
throw new Error("File changed while Edit was being verified.");
|
|
361
|
+
}
|
|
362
|
+
newSha256 = written.sha256;
|
|
363
|
+
if (undoCapture !== undefined) {
|
|
364
|
+
input.undoManager?.recordMutationResult(undoCapture, {
|
|
365
|
+
state: "present",
|
|
366
|
+
sha256: newSha256,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
input.snapshots.set(input.absolutePath, {
|
|
370
|
+
sha256: newSha256,
|
|
371
|
+
mtimeMs: written.mtimeMs,
|
|
372
|
+
source: "edit",
|
|
373
|
+
});
|
|
374
|
+
} catch (error) {
|
|
375
|
+
if (undoCapture !== undefined) {
|
|
376
|
+
await input.undoManager?.recordMutationFailure(undoCapture);
|
|
377
|
+
}
|
|
378
|
+
throw error;
|
|
379
|
+
}
|
|
323
380
|
|
|
324
381
|
const patch = computeFilePatch({
|
|
325
382
|
filePath: input.filePath,
|
|
@@ -348,6 +405,7 @@ type TargetFileState =
|
|
|
348
405
|
ok: true;
|
|
349
406
|
exists: true;
|
|
350
407
|
content: string;
|
|
408
|
+
bytes: Buffer;
|
|
351
409
|
sha256: string;
|
|
352
410
|
mtimeMs: number;
|
|
353
411
|
};
|
|
@@ -371,6 +429,7 @@ async function targetFileState(
|
|
|
371
429
|
ok: true,
|
|
372
430
|
exists: true,
|
|
373
431
|
content: bytes.toString("utf8"),
|
|
432
|
+
bytes,
|
|
374
433
|
sha256: sha256Bytes(bytes),
|
|
375
434
|
mtimeMs: currentInfo.mtimeMs,
|
|
376
435
|
};
|
package/src/tools/registry.ts
CHANGED
|
@@ -1,18 +1,21 @@
|
|
|
1
1
|
import { createBashToolExecutor } from "./bash";
|
|
2
2
|
import { ShellTaskManager } from "./bash-task";
|
|
3
3
|
import { createCwdState } from "./cwd-state";
|
|
4
|
+
import { createDeleteToolExecutor } from "./delete";
|
|
4
5
|
import { createEditToolExecutor } from "./edit";
|
|
5
6
|
import { createGlobToolExecutor } from "./glob";
|
|
6
7
|
import { createGrepToolExecutor } from "./grep";
|
|
7
8
|
import { createReadToolExecutor } from "./read";
|
|
8
9
|
import { createRecallToolExecutor } from "./recall";
|
|
9
10
|
import { createTaskListToolExecutor } from "./task-list";
|
|
11
|
+
import { createTaskInputToolExecutor } from "./task-input";
|
|
10
12
|
import { createTaskOutputToolExecutor } from "./task-output-tool";
|
|
11
13
|
import { createTaskStopToolExecutor } from "./task-stop";
|
|
12
14
|
import { createWebFetchToolExecutor } from "./web-fetch";
|
|
13
15
|
import type { Refiner } from "./web-fetch/refiner";
|
|
14
16
|
import { createWebSearchToolExecutor } from "./web-search";
|
|
15
17
|
import { createWriteToolExecutor } from "./write";
|
|
18
|
+
import { TurnUndoManager } from "./turn-undo-manager";
|
|
16
19
|
import { cancellationError, throwIfTurnCancelled } from "../agent/turn-cancellation";
|
|
17
20
|
import type {
|
|
18
21
|
RuntimeSessionContext,
|
|
@@ -62,7 +65,17 @@ export class ToolRegistry {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
export class ToolRuntime {
|
|
65
|
-
constructor(
|
|
68
|
+
constructor(
|
|
69
|
+
private readonly registry: ToolRegistry,
|
|
70
|
+
private readonly bashGuard?: {
|
|
71
|
+
readonly surface: "tui" | "one-shot";
|
|
72
|
+
confirm(
|
|
73
|
+
call: ToolCall,
|
|
74
|
+
request: { command: string; reason: string },
|
|
75
|
+
signal: AbortSignal,
|
|
76
|
+
): Promise<"allow" | "deny">;
|
|
77
|
+
},
|
|
78
|
+
) {}
|
|
66
79
|
|
|
67
80
|
async execute(call: ToolCall, context: ToolExecutionContext): Promise<ToolRawResult> {
|
|
68
81
|
throwIfTurnCancelled(context.signal);
|
|
@@ -88,7 +101,16 @@ export class ToolRuntime {
|
|
|
88
101
|
}
|
|
89
102
|
|
|
90
103
|
try {
|
|
91
|
-
return await tool.execute(call.args, call,
|
|
104
|
+
return await tool.execute(call.args, call, {
|
|
105
|
+
...context,
|
|
106
|
+
...(this.bashGuard === undefined
|
|
107
|
+
? {}
|
|
108
|
+
: {
|
|
109
|
+
bashGuardSurface: this.bashGuard.surface,
|
|
110
|
+
confirmBashCommand: (request: { command: string; reason: string }) =>
|
|
111
|
+
this.bashGuard!.confirm(call, request, context.signal),
|
|
112
|
+
}),
|
|
113
|
+
});
|
|
92
114
|
} catch (error) {
|
|
93
115
|
if (context.signal.aborted) {
|
|
94
116
|
throw cancellationError(context.signal, error);
|
|
@@ -113,6 +135,7 @@ export type DefaultTooling = {
|
|
|
113
135
|
snapshots: FileSnapshotStore;
|
|
114
136
|
bashState: BashToolingState;
|
|
115
137
|
taskManager: ShellTaskManager;
|
|
138
|
+
turnUndoManager?: TurnUndoManager;
|
|
116
139
|
dispose(reason?: SessionDisposeReason["type"]): Promise<void>;
|
|
117
140
|
};
|
|
118
141
|
|
|
@@ -134,8 +157,20 @@ export function createDefaultTooling(options: {
|
|
|
134
157
|
skillCoordinator?: SkillActivationCoordinator;
|
|
135
158
|
toolingConfig?: PublicToolingConfig;
|
|
136
159
|
memorySearch?: ToolExecutor;
|
|
160
|
+
enableTurnUndo?: boolean;
|
|
161
|
+
bashGuard?: {
|
|
162
|
+
readonly surface: "tui" | "one-shot";
|
|
163
|
+
confirm(
|
|
164
|
+
call: ToolCall,
|
|
165
|
+
request: { command: string; reason: string },
|
|
166
|
+
signal: AbortSignal,
|
|
167
|
+
): Promise<"allow" | "deny">;
|
|
168
|
+
};
|
|
137
169
|
}): DefaultTooling {
|
|
138
170
|
const snapshots: FileSnapshotStore = new Map();
|
|
171
|
+
const turnUndoManager = options.enableTurnUndo
|
|
172
|
+
? new TurnUndoManager({ snapshots })
|
|
173
|
+
: undefined;
|
|
139
174
|
const registry = new ToolRegistry();
|
|
140
175
|
const runtimeSession = options.runtimeSession;
|
|
141
176
|
const toolingConfig = options.toolingConfig ?? DEFAULT_PUBLIC_TOOLING_CONFIG;
|
|
@@ -194,12 +229,21 @@ export function createDefaultTooling(options: {
|
|
|
194
229
|
createWriteToolExecutor({
|
|
195
230
|
workspaceRoot: options.workspaceRoot,
|
|
196
231
|
snapshots,
|
|
232
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
197
233
|
}),
|
|
198
234
|
);
|
|
199
235
|
registry.register(
|
|
200
236
|
createEditToolExecutor({
|
|
201
237
|
workspaceRoot: options.workspaceRoot,
|
|
202
238
|
snapshots,
|
|
239
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
registry.register(
|
|
243
|
+
createDeleteToolExecutor({
|
|
244
|
+
workspaceRoot: options.workspaceRoot,
|
|
245
|
+
snapshots,
|
|
246
|
+
...(turnUndoManager === undefined ? {} : { undoManager: turnUndoManager }),
|
|
203
247
|
}),
|
|
204
248
|
);
|
|
205
249
|
registry.register(
|
|
@@ -213,6 +257,7 @@ export function createDefaultTooling(options: {
|
|
|
213
257
|
);
|
|
214
258
|
registry.register(createTaskListToolExecutor({ taskManager }));
|
|
215
259
|
registry.register(createTaskOutputToolExecutor({ taskManager }));
|
|
260
|
+
registry.register(createTaskInputToolExecutor({ taskManager }));
|
|
216
261
|
registry.register(createTaskStopToolExecutor({ taskManager }));
|
|
217
262
|
|
|
218
263
|
const exaApiKey = options.exaApiKey ?? toolingConfig.exaApiKey;
|
|
@@ -232,9 +277,10 @@ export function createDefaultTooling(options: {
|
|
|
232
277
|
|
|
233
278
|
return {
|
|
234
279
|
registry,
|
|
235
|
-
runtime: new ToolRuntime(registry),
|
|
280
|
+
runtime: new ToolRuntime(registry, options.bashGuard),
|
|
236
281
|
snapshots,
|
|
237
282
|
taskManager,
|
|
283
|
+
...(turnUndoManager === undefined ? {} : { turnUndoManager }),
|
|
238
284
|
bashState: {
|
|
239
285
|
get cwd() {
|
|
240
286
|
return cwdState.cwd;
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { TERMINAL_SCREEN_COLUMNS, TERMINAL_SCREEN_ROWS } from "./terminal-screen";
|
|
3
|
+
|
|
4
|
+
export type ShellProcessMode = "pipe" | "pty";
|
|
5
|
+
|
|
6
|
+
export type ProcessExitResult = {
|
|
7
|
+
code: number | null;
|
|
8
|
+
signal: NodeJS.Signals | null;
|
|
9
|
+
error?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ShellProcessHandle = {
|
|
13
|
+
readonly pid: number;
|
|
14
|
+
readonly mode: ShellProcessMode;
|
|
15
|
+
readonly exitCode: number | null;
|
|
16
|
+
readonly signalCode: NodeJS.Signals | null;
|
|
17
|
+
wait(): Promise<ProcessExitResult>;
|
|
18
|
+
waitForOutputClose(): Promise<void>;
|
|
19
|
+
write?(chars: string): Promise<number>;
|
|
20
|
+
close(): void;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export class ShellProcessWriteError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
message: string,
|
|
26
|
+
readonly writtenBytes: number,
|
|
27
|
+
) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "ShellProcessWriteError";
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function spawnShellProcess(input: {
|
|
34
|
+
mode: ShellProcessMode;
|
|
35
|
+
command: string;
|
|
36
|
+
cwd: string;
|
|
37
|
+
cwdFilePath: string;
|
|
38
|
+
onOutput(bytes: Uint8Array): void;
|
|
39
|
+
}): Promise<ShellProcessHandle> {
|
|
40
|
+
const env = {
|
|
41
|
+
...process.env,
|
|
42
|
+
NO_COLOR: "1",
|
|
43
|
+
TINKER_BASH_COMMAND: input.command,
|
|
44
|
+
TINKER_BASH_CWD_FILE: input.cwdFilePath,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
if (input.mode === "pty") {
|
|
48
|
+
return spawnPtyShellProcess({ ...input, env });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return spawnPipeShellProcess({ ...input, env });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function spawnPipeShellProcess(input: {
|
|
55
|
+
cwd: string;
|
|
56
|
+
env: NodeJS.ProcessEnv;
|
|
57
|
+
onOutput(bytes: Uint8Array): void;
|
|
58
|
+
}): Promise<ShellProcessHandle> {
|
|
59
|
+
const child = spawn("bash", ["-lc", bashWrapperScript], {
|
|
60
|
+
cwd: input.cwd,
|
|
61
|
+
detached: true,
|
|
62
|
+
env: input.env,
|
|
63
|
+
});
|
|
64
|
+
pipeOutput(child.stdout, (bytes) => input.onOutput(bytes));
|
|
65
|
+
pipeOutput(child.stderr, (bytes) => input.onOutput(bytes));
|
|
66
|
+
|
|
67
|
+
const exit = waitForNodeProcessExit(child);
|
|
68
|
+
const close = waitForNodeProcessClose(child);
|
|
69
|
+
if (child.pid === undefined) {
|
|
70
|
+
const result = await exit;
|
|
71
|
+
throw new Error(result.error ?? "Bash process failed to start.");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
pid: child.pid,
|
|
76
|
+
mode: "pipe",
|
|
77
|
+
get exitCode() {
|
|
78
|
+
return child.exitCode;
|
|
79
|
+
},
|
|
80
|
+
get signalCode() {
|
|
81
|
+
return child.signalCode;
|
|
82
|
+
},
|
|
83
|
+
wait: () => exit,
|
|
84
|
+
waitForOutputClose: () => close,
|
|
85
|
+
close() {},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function spawnPtyShellProcess(input: {
|
|
90
|
+
cwd: string;
|
|
91
|
+
env: NodeJS.ProcessEnv;
|
|
92
|
+
onOutput(bytes: Uint8Array): void;
|
|
93
|
+
}): ShellProcessHandle {
|
|
94
|
+
let terminalEnded = false;
|
|
95
|
+
let resolveTerminalExit: (() => void) | undefined;
|
|
96
|
+
let drainGeneration = 0;
|
|
97
|
+
const drainWaiters = new Set<() => void>();
|
|
98
|
+
const terminalExit = new Promise<void>((resolve) => {
|
|
99
|
+
resolveTerminalExit = resolve;
|
|
100
|
+
});
|
|
101
|
+
const notifyDrain = () => {
|
|
102
|
+
drainGeneration += 1;
|
|
103
|
+
for (const resolve of drainWaiters) {
|
|
104
|
+
resolve();
|
|
105
|
+
}
|
|
106
|
+
drainWaiters.clear();
|
|
107
|
+
};
|
|
108
|
+
const settleTerminalExit = () => {
|
|
109
|
+
if (terminalEnded) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
terminalEnded = true;
|
|
113
|
+
notifyDrain();
|
|
114
|
+
resolveTerminalExit?.();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const subprocess = Bun.spawn(["bash", "-lc", bashWrapperScript], {
|
|
118
|
+
cwd: input.cwd,
|
|
119
|
+
detached: true,
|
|
120
|
+
env: {
|
|
121
|
+
...input.env,
|
|
122
|
+
TERM: "xterm-256color",
|
|
123
|
+
NO_COLOR: "1",
|
|
124
|
+
PAGER: "cat",
|
|
125
|
+
GIT_PAGER: "cat",
|
|
126
|
+
},
|
|
127
|
+
terminal: {
|
|
128
|
+
cols: TERMINAL_SCREEN_COLUMNS,
|
|
129
|
+
rows: TERMINAL_SCREEN_ROWS,
|
|
130
|
+
name: "xterm-256color",
|
|
131
|
+
data(_terminal, bytes) {
|
|
132
|
+
input.onOutput(new Uint8Array(bytes));
|
|
133
|
+
},
|
|
134
|
+
exit() {
|
|
135
|
+
// Linux reports slave closure as EIO; subprocess.exited owns task status.
|
|
136
|
+
settleTerminalExit();
|
|
137
|
+
},
|
|
138
|
+
drain() {
|
|
139
|
+
notifyDrain();
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
const terminal = subprocess.terminal!;
|
|
144
|
+
|
|
145
|
+
let writeQueue = Promise.resolve();
|
|
146
|
+
const write = (chars: string): Promise<number> => {
|
|
147
|
+
const bytes = new TextEncoder().encode(chars);
|
|
148
|
+
const operation = writeQueue.then(async () => {
|
|
149
|
+
let writtenBytes = 0;
|
|
150
|
+
try {
|
|
151
|
+
while (writtenBytes < bytes.byteLength) {
|
|
152
|
+
if (terminalEnded || terminal.closed) {
|
|
153
|
+
throw new Error("PTY is closed.");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const generationBeforeWrite = drainGeneration;
|
|
157
|
+
const accepted = terminal.write(bytes.subarray(writtenBytes));
|
|
158
|
+
if (accepted < 0 || accepted > bytes.byteLength - writtenBytes) {
|
|
159
|
+
throw new Error(`PTY accepted an invalid byte count: ${accepted}.`);
|
|
160
|
+
}
|
|
161
|
+
writtenBytes += accepted;
|
|
162
|
+
|
|
163
|
+
if (writtenBytes < bytes.byteLength) {
|
|
164
|
+
await waitForDrainOrExit({
|
|
165
|
+
generationBeforeWrite,
|
|
166
|
+
currentGeneration: () => drainGeneration,
|
|
167
|
+
terminalEnded: () => terminalEnded || terminal.closed,
|
|
168
|
+
drainWaiters,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return writtenBytes;
|
|
173
|
+
} catch (error) {
|
|
174
|
+
throw new ShellProcessWriteError(
|
|
175
|
+
error instanceof Error ? error.message : String(error),
|
|
176
|
+
writtenBytes,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
writeQueue = operation.then(
|
|
181
|
+
() => undefined,
|
|
182
|
+
() => undefined,
|
|
183
|
+
);
|
|
184
|
+
return operation;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const exit = subprocess.exited.then(
|
|
188
|
+
(code): ProcessExitResult => ({
|
|
189
|
+
code: subprocess.signalCode === null ? code : null,
|
|
190
|
+
signal: subprocess.signalCode,
|
|
191
|
+
}),
|
|
192
|
+
(error): ProcessExitResult => ({
|
|
193
|
+
code: null,
|
|
194
|
+
signal: null,
|
|
195
|
+
error: error instanceof Error ? error.message : String(error),
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
pid: subprocess.pid,
|
|
201
|
+
mode: "pty",
|
|
202
|
+
get exitCode() {
|
|
203
|
+
return subprocess.exitCode;
|
|
204
|
+
},
|
|
205
|
+
get signalCode() {
|
|
206
|
+
return subprocess.signalCode;
|
|
207
|
+
},
|
|
208
|
+
wait: () => exit,
|
|
209
|
+
waitForOutputClose: () => terminalExit,
|
|
210
|
+
write,
|
|
211
|
+
close() {
|
|
212
|
+
if (!terminal.closed) {
|
|
213
|
+
terminal.close();
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function waitForDrainOrExit(input: {
|
|
220
|
+
generationBeforeWrite: number;
|
|
221
|
+
currentGeneration(): number;
|
|
222
|
+
terminalEnded(): boolean;
|
|
223
|
+
drainWaiters: Set<() => void>;
|
|
224
|
+
}): Promise<void> {
|
|
225
|
+
if (
|
|
226
|
+
input.currentGeneration() !== input.generationBeforeWrite ||
|
|
227
|
+
input.terminalEnded()
|
|
228
|
+
) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
await new Promise<void>((resolve) => {
|
|
233
|
+
input.drainWaiters.add(resolve);
|
|
234
|
+
if (
|
|
235
|
+
input.currentGeneration() !== input.generationBeforeWrite ||
|
|
236
|
+
input.terminalEnded()
|
|
237
|
+
) {
|
|
238
|
+
input.drainWaiters.delete(resolve);
|
|
239
|
+
resolve();
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function waitForNodeProcessExit(
|
|
245
|
+
process: ChildProcessWithoutNullStreams,
|
|
246
|
+
): Promise<ProcessExitResult> {
|
|
247
|
+
return new Promise((resolve) => {
|
|
248
|
+
let settled = false;
|
|
249
|
+
const finish = (result: ProcessExitResult) => {
|
|
250
|
+
if (!settled) {
|
|
251
|
+
settled = true;
|
|
252
|
+
resolve(result);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
process.once("error", (error) => {
|
|
257
|
+
finish({ code: null, signal: null, error: error.message });
|
|
258
|
+
});
|
|
259
|
+
process.once("exit", (code, signal) => {
|
|
260
|
+
finish({ code, signal });
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function waitForNodeProcessClose(
|
|
266
|
+
process: ChildProcessWithoutNullStreams,
|
|
267
|
+
): Promise<void> {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
let settled = false;
|
|
270
|
+
const finish = () => {
|
|
271
|
+
if (!settled) {
|
|
272
|
+
settled = true;
|
|
273
|
+
resolve();
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
process.once("error", finish);
|
|
278
|
+
process.once("close", finish);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function pipeOutput(
|
|
283
|
+
stream: NodeJS.ReadableStream,
|
|
284
|
+
onOutput: (bytes: Uint8Array) => void,
|
|
285
|
+
): void {
|
|
286
|
+
stream.on("data", (chunk: Buffer | string) => {
|
|
287
|
+
onOutput(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const bashWrapperScript = `
|
|
292
|
+
eval "$TINKER_BASH_COMMAND"
|
|
293
|
+
exit_code=$?
|
|
294
|
+
pwd -P > "$TINKER_BASH_CWD_FILE"
|
|
295
|
+
exit "$exit_code"
|
|
296
|
+
`;
|