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.
Files changed (52) hide show
  1. package/CHANGELOG.md +52 -1
  2. package/README.md +15 -7
  3. package/package.json +8 -7
  4. package/src/agent/assistant-text-delta.ts +10 -0
  5. package/src/agent/loop.ts +116 -22
  6. package/src/agent/runtime-session.ts +248 -1
  7. package/src/cli/command-line.ts +9 -1
  8. package/src/cli/config.ts +17 -4
  9. package/src/cli/main.ts +1 -0
  10. package/src/cli/public-cli-contract.ts +4 -0
  11. package/src/cli/public-config-contract.ts +25 -1
  12. package/src/cli/run-runner.ts +5 -0
  13. package/src/cli/runner-dependencies.ts +4 -1
  14. package/src/cli/tui-runner.tsx +17 -2
  15. package/src/events/bash-result-detail.ts +13 -6
  16. package/src/events/observation-text-log.ts +26 -1
  17. package/src/events/stdout-event-printer.ts +18 -2
  18. package/src/events/types.ts +14 -2
  19. package/src/model/fake-model-client.ts +177 -0
  20. package/src/model/model-client.ts +3 -0
  21. package/src/model/openai-chat-model-client.ts +54 -15
  22. package/src/model/openai-chat-stream.ts +95 -72
  23. package/src/observation/observation-builder.ts +54 -6
  24. package/src/session/session-catalog.ts +17 -11
  25. package/src/session/session-store.ts +2 -0
  26. package/src/tools/bash-guard.ts +131 -0
  27. package/src/tools/bash-task.ts +129 -90
  28. package/src/tools/bash.ts +75 -13
  29. package/src/tools/delete.ts +182 -0
  30. package/src/tools/edit.ts +68 -9
  31. package/src/tools/registry.ts +49 -3
  32. package/src/tools/shell-process.ts +296 -0
  33. package/src/tools/task-input.ts +229 -0
  34. package/src/tools/task-output-tool.ts +4 -1
  35. package/src/tools/terminal-screen.ts +105 -0
  36. package/src/tools/turn-undo-manager.ts +794 -0
  37. package/src/tools/types.ts +45 -0
  38. package/src/tools/write.ts +65 -14
  39. package/src/tui/app.tsx +161 -45
  40. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  41. package/src/tui/components/background-tasks.tsx +3 -2
  42. package/src/tui/components/bash-confirmation.tsx +27 -0
  43. package/src/tui/components/context-status.tsx +11 -1
  44. package/src/tui/components/footer.tsx +8 -5
  45. package/src/tui/components/prompt-input.tsx +13 -1
  46. package/src/tui/components/resume-session-picker.tsx +292 -46
  47. package/src/tui/components/timeline.tsx +10 -0
  48. package/src/tui/context-format.ts +17 -0
  49. package/src/tui/event-store.ts +76 -4
  50. package/src/tui/slash-commands.ts +28 -0
  51. package/src/tui/tui-projection-store.ts +246 -7
  52. 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
- _call,
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
- await writeFile(input.absolutePath, input.newContent, "utf8");
316
- const newSha256 = sha256Text(input.newContent);
317
- const writtenInfo = await stat(input.absolutePath);
318
- input.snapshots.set(input.absolutePath, {
319
- sha256: newSha256,
320
- mtimeMs: writtenInfo.mtimeMs,
321
- source: "edit",
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
  };
@@ -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(private readonly registry: ToolRegistry) {}
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, context);
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
+ `;