tinker-agent 1.6.0 → 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.
@@ -1,4 +1,3 @@
1
- import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
2
1
  import { mkdir, open, readFile, unlink } from "node:fs/promises";
3
2
  import path from "node:path";
4
3
  import type {
@@ -8,7 +7,19 @@ import type {
8
7
  import type { ToolCallIdentity } from "../agent/types";
9
8
  import { createUuidV7 } from "../ids/uuid-v7";
10
9
  import { isWorkspaceLocalCwd, type CwdState } from "./cwd-state";
10
+ import {
11
+ type ProcessExitResult,
12
+ type ShellProcessHandle,
13
+ type ShellProcessMode,
14
+ spawnShellProcess,
15
+ } from "./shell-process";
11
16
  import { TaskOutput, type TaskOutputSnapshot } from "./task-output";
17
+ import {
18
+ createTerminalScreen,
19
+ TERMINAL_SCREEN_COLUMNS,
20
+ TERMINAL_SCREEN_ROWS,
21
+ type TerminalScreen,
22
+ } from "./terminal-screen";
12
23
 
13
24
  export type ShellTaskStatus =
14
25
  | "running"
@@ -38,11 +49,15 @@ export type ShellTaskSnapshot = {
38
49
  outputBytes: number;
39
50
  outputLines: number;
40
51
  cwd: string;
52
+ tty: boolean;
41
53
  };
42
54
 
43
55
  export type ShellTaskInspection = {
44
56
  task: ShellTaskSnapshot;
45
57
  output: TaskOutputSnapshot;
58
+ screenRows?: number;
59
+ screenColumns?: number;
60
+ screen?: string;
46
61
  };
47
62
 
48
63
  export type ShellTaskHandle = {
@@ -81,9 +96,12 @@ type ManagedShellTask = {
81
96
  outputFilePath: string;
82
97
  cwdFilePath: string;
83
98
  cwd: string;
84
- process: ChildProcessWithoutNullStreams;
99
+ mode: ShellProcessMode;
100
+ process: ShellProcessHandle;
85
101
  processGroupId: number;
86
102
  output: TaskOutput;
103
+ terminalScreen?: TerminalScreen;
104
+ finalScreen?: string;
87
105
  completion: Promise<ShellTaskSnapshot>;
88
106
  stopPromise?: Promise<StopTaskResult>;
89
107
  terminalEventEmitted: boolean;
@@ -115,6 +133,7 @@ export class ShellTaskManager {
115
133
  command: string;
116
134
  description: string;
117
135
  origin: ShellTaskOrigin;
136
+ tty: boolean;
118
137
  }): Promise<ShellTaskHandle> {
119
138
  if (!this.acceptingTasks) {
120
139
  throw new Error("Cannot start a Bash task after task manager shutdown.");
@@ -142,22 +161,26 @@ export class ShellTaskManager {
142
161
  throw new Error("Cannot start a Bash task after task manager shutdown.");
143
162
  }
144
163
 
145
- const child = spawn("bash", ["-lc", bashWrapperScript], {
146
- cwd: this.options.cwdState.cwd,
147
- detached: true,
148
- env: {
149
- ...process.env,
150
- NO_COLOR: "1",
151
- TINKER_BASH_COMMAND: input.command,
152
- TINKER_BASH_CWD_FILE: cwdFilePath,
153
- },
154
- });
155
-
156
- if (child.pid === undefined) {
157
- const spawnError = await waitForProcessError(child);
164
+ const terminalScreen = input.tty ? createTerminalScreen() : undefined;
165
+ let shellProcess: ShellProcessHandle;
166
+ try {
167
+ shellProcess = await spawnShellProcess({
168
+ mode: input.tty ? "pty" : "pipe",
169
+ command: input.command,
170
+ cwd: this.options.cwdState.cwd,
171
+ cwdFilePath,
172
+ onOutput(bytes) {
173
+ output.write(Buffer.from(bytes));
174
+ if (terminalScreen !== undefined) {
175
+ void terminalScreen.write(bytes).catch(() => undefined);
176
+ }
177
+ },
178
+ });
179
+ } catch (error) {
180
+ terminalScreen?.dispose();
158
181
  await output.end();
159
182
  await unlinkIfExists(cwdFilePath);
160
- throw spawnError;
183
+ throw error;
161
184
  }
162
185
 
163
186
  const task: ManagedShellTask = {
@@ -170,15 +193,15 @@ export class ShellTaskManager {
170
193
  outputFilePath,
171
194
  cwdFilePath,
172
195
  cwd: this.options.cwdState.cwd,
173
- process: child,
174
- processGroupId: child.pid,
196
+ mode: shellProcess.mode,
197
+ process: shellProcess,
198
+ processGroupId: shellProcess.pid,
175
199
  output,
200
+ terminalScreen,
176
201
  completion: Promise.resolve(undefined as never),
177
202
  terminalEventEmitted: false,
178
203
  };
179
204
 
180
- pipeTaskOutput(task.process.stdout, task.output);
181
- pipeTaskOutput(task.process.stderr, task.output);
182
205
  task.completion = this.monitorTaskSafely(task);
183
206
  this.tasks.set(id, task);
184
207
 
@@ -231,10 +254,45 @@ export class ShellTaskManager {
231
254
  }
232
255
 
233
256
  this.synchronizeTerminalState(task);
234
- return {
235
- task: this.snapshot(task),
236
- output: task.output.snapshot(),
237
- };
257
+ return this.inspection(task);
258
+ }
259
+
260
+ async inspectTaskOutput(taskId: string): Promise<ShellTaskInspection | undefined> {
261
+ const task = this.tasks.get(taskId);
262
+ if (task === undefined) {
263
+ return undefined;
264
+ }
265
+
266
+ this.synchronizeTerminalState(task);
267
+ if (
268
+ task.mode === "pty" &&
269
+ isTerminalStatus(task.status) &&
270
+ task.finalScreen === undefined
271
+ ) {
272
+ await task.completion;
273
+ } else {
274
+ await task.terminalScreen?.flush();
275
+ }
276
+ return this.inspection(task);
277
+ }
278
+
279
+ taskCompletion(taskId: string): Promise<ShellTaskSnapshot> {
280
+ return this.requireTask(taskId).completion;
281
+ }
282
+
283
+ async writeTaskInput(taskId: string, chars: string): Promise<number> {
284
+ const task = this.requireTask(taskId);
285
+ this.synchronizeTerminalState(task);
286
+ if (task.mode !== "pty" || task.process.write === undefined) {
287
+ throw new Error(
288
+ `Task ${taskId} does not accept terminal input; start it with Bash tty=true.`,
289
+ );
290
+ }
291
+ if (task.status !== "running") {
292
+ throw new Error(`Task ${taskId} is not running (status=${task.status}).`);
293
+ }
294
+
295
+ return task.process.write(chars);
238
296
  }
239
297
 
240
298
  async stopTask(taskId: string, reason: StopTaskReason): Promise<StopTaskResult> {
@@ -372,20 +430,43 @@ export class ShellTaskManager {
372
430
  outputError instanceof Error ? outputError.message : String(outputError)
373
431
  }`;
374
432
  }
433
+ if (task.terminalScreen !== undefined) {
434
+ try {
435
+ await task.terminalScreen.flush();
436
+ task.finalScreen = task.terminalScreen.text();
437
+ } catch {
438
+ // The original monitor error remains the primary task failure.
439
+ }
440
+ task.terminalScreen.dispose();
441
+ }
442
+ task.process.close();
375
443
  await unlinkIfExists(task.cwdFilePath);
376
444
 
377
- return this.snapshot(task);
445
+ const snapshot = this.snapshot(task);
446
+ if (task.backgroundedAt !== undefined && !task.terminalEventEmitted) {
447
+ task.terminalEventEmitted = true;
448
+ await this.options.runtimeSession.append({
449
+ type: "bash.task.finished",
450
+ ...task.origin,
451
+ data: { task: snapshot },
452
+ });
453
+ }
454
+ return snapshot;
378
455
  }
379
456
  }
380
457
 
381
458
  private async monitorTask(task: ManagedShellTask): Promise<ShellTaskSnapshot> {
382
- const exit = waitForProcessExit(task.process);
383
- const close = waitForProcessClose(task.process);
384
- const result = await exit;
459
+ const result = await task.process.wait();
385
460
 
386
461
  this.applyTermination(task, result);
387
- await close;
462
+ await task.process.waitForOutputClose();
388
463
  await task.output.end();
464
+ if (task.terminalScreen !== undefined) {
465
+ await task.terminalScreen.flush();
466
+ task.finalScreen = task.terminalScreen.text();
467
+ task.terminalScreen.dispose();
468
+ }
469
+ task.process.close();
389
470
  await this.updateCwdFromFile(task);
390
471
  await unlinkIfExists(task.cwdFilePath);
391
472
 
@@ -464,6 +545,25 @@ export class ShellTaskManager {
464
545
  outputBytes: output.outputBytes,
465
546
  outputLines: output.outputLines,
466
547
  cwd: task.cwd,
548
+ tty: task.mode === "pty",
549
+ };
550
+ }
551
+
552
+ private inspection(task: ManagedShellTask): ShellTaskInspection {
553
+ const screen =
554
+ task.mode === "pty"
555
+ ? (task.finalScreen ?? task.terminalScreen?.text() ?? "")
556
+ : undefined;
557
+ return {
558
+ task: this.snapshot(task),
559
+ output: task.output.snapshot(),
560
+ ...(screen === undefined
561
+ ? {}
562
+ : {
563
+ screenRows: TERMINAL_SCREEN_ROWS,
564
+ screenColumns: TERMINAL_SCREEN_COLUMNS,
565
+ screen,
566
+ }),
467
567
  };
468
568
  }
469
569
 
@@ -488,54 +588,6 @@ export class ShellTaskManager {
488
588
  }
489
589
  }
490
590
 
491
- type ProcessExitResult = {
492
- code: number | null;
493
- signal: NodeJS.Signals | null;
494
- error?: string;
495
- };
496
-
497
- function waitForProcessExit(
498
- process: ChildProcessWithoutNullStreams,
499
- ): Promise<ProcessExitResult> {
500
- return new Promise((resolve) => {
501
- let settled = false;
502
- const finish = (result: ProcessExitResult) => {
503
- if (!settled) {
504
- settled = true;
505
- resolve(result);
506
- }
507
- };
508
-
509
- process.once("error", (error) => {
510
- finish({ code: null, signal: null, error: error.message });
511
- });
512
- process.once("exit", (code, signal) => {
513
- finish({ code, signal });
514
- });
515
- });
516
- }
517
-
518
- function waitForProcessError(process: ChildProcessWithoutNullStreams): Promise<Error> {
519
- return new Promise((resolve) => {
520
- process.once("error", resolve);
521
- });
522
- }
523
-
524
- function waitForProcessClose(process: ChildProcessWithoutNullStreams): Promise<void> {
525
- return new Promise((resolve) => {
526
- let settled = false;
527
- const finish = () => {
528
- if (!settled) {
529
- settled = true;
530
- resolve();
531
- }
532
- };
533
-
534
- process.once("error", finish);
535
- process.once("close", finish);
536
- });
537
- }
538
-
539
591
  function signalProcessGroup(
540
592
  task: ManagedShellTask,
541
593
  signal: "SIGTERM" | "SIGKILL",
@@ -573,12 +625,6 @@ function isTerminalStatus(status: ShellTaskStatus): boolean {
573
625
  return status === "completed" || status === "failed" || status === "killed";
574
626
  }
575
627
 
576
- function pipeTaskOutput(stream: NodeJS.ReadableStream, output: TaskOutput): void {
577
- stream.on("data", (chunk: Buffer | string) => {
578
- output.write(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
579
- });
580
- }
581
-
582
628
  async function ensureEmptyFile(filePath: string): Promise<void> {
583
629
  await mkdir(path.dirname(filePath), { recursive: true });
584
630
  const file = await open(filePath, "w");
@@ -608,10 +654,3 @@ function errorCode(error: unknown): unknown {
608
654
  ? error.code
609
655
  : undefined;
610
656
  }
611
-
612
- const bashWrapperScript = `
613
- eval "$TINKER_BASH_COMMAND"
614
- exit_code=$?
615
- pwd -P > "$TINKER_BASH_CWD_FILE"
616
- exit "$exit_code"
617
- `;
package/src/tools/bash.ts CHANGED
@@ -19,6 +19,7 @@ type BashArgs = {
19
19
  timeout?: number;
20
20
  description?: string;
21
21
  run_in_background?: boolean;
22
+ tty?: boolean;
22
23
  };
23
24
 
24
25
  export type BashToolOptions = {
@@ -66,6 +67,11 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
66
67
  type: "boolean",
67
68
  description: "Run the command in the background and return immediately.",
68
69
  },
70
+ tty: {
71
+ type: "boolean",
72
+ description:
73
+ "Run the command in a pseudo-terminal so it can receive interactive input.",
74
+ },
69
75
  },
70
76
  required: ["command"],
71
77
  },
@@ -87,6 +93,7 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
87
93
  outputLines: 0,
88
94
  preview: "",
89
95
  truncated: false,
96
+ tty: false,
90
97
  error: parsed.error,
91
98
  };
92
99
  }
@@ -118,6 +125,7 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
118
125
  outputLines: 0,
119
126
  preview: "",
120
127
  truncated: false,
128
+ tty: input.tty === true,
121
129
  error: `Command denied: ${risk.reason}. ${suffix}`,
122
130
  };
123
131
  }
@@ -128,15 +136,19 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
128
136
  command: input.command,
129
137
  description: input.description ?? input.command,
130
138
  origin: call,
139
+ tty: input.tty === true,
131
140
  });
132
141
 
133
142
  if (input.run_in_background === true) {
134
143
  // Starting and publishing an explicit background task is one commit
135
144
  // boundary. Cancellation is observed after its result is recorded.
136
145
  await options.taskManager.markBackgrounded(task.taskId, "requested");
137
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
146
+ const inspection = await requireTaskOutputInspection(
147
+ options.taskManager,
148
+ task.taskId,
149
+ );
138
150
  if (inspection.task.status !== "running") {
139
- return buildCompletedResult(inspection.task, inspection.output);
151
+ return buildCompletedResult(inspection);
140
152
  }
141
153
 
142
154
  return buildRunningResult({
@@ -155,9 +167,12 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
155
167
  // Timeout wins ownership. Marking the task backgrounded and returning
156
168
  // its task ID is an uninterrupted commit boundary.
157
169
  await options.taskManager.markBackgrounded(task.taskId, "foreground_timeout");
158
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
170
+ const inspection = await requireTaskOutputInspection(
171
+ options.taskManager,
172
+ task.taskId,
173
+ );
159
174
  if (inspection.task.status !== "running") {
160
- return buildCompletedResult(inspection.task, inspection.output);
175
+ return buildCompletedResult(inspection);
161
176
  }
162
177
 
163
178
  return buildRunningResult({
@@ -169,11 +184,14 @@ export function createBashToolExecutor(options: BashToolOptions): ToolExecutor {
169
184
  });
170
185
  }
171
186
 
172
- const inspection = requireTaskInspection(options.taskManager, task.taskId);
173
- const raw = await buildCompletedResult(waitResult.task, inspection.output);
187
+ const inspection = await requireTaskOutputInspection(
188
+ options.taskManager,
189
+ task.taskId,
190
+ );
191
+ const raw = await buildCompletedResult(inspection);
174
192
  updateCwdStateAfterForegroundCommand({
175
193
  raw,
176
- task: waitResult.task,
194
+ task: inspection.task,
177
195
  cwdState: options.cwdState,
178
196
  workspaceRoot: options.workspaceRoot,
179
197
  });
@@ -210,6 +228,10 @@ export function parseBashArgs(
210
228
  return { ok: false, error: "Bash.run_in_background must be a boolean." };
211
229
  }
212
230
 
231
+ if (args.tty !== undefined && typeof args.tty !== "boolean") {
232
+ return { ok: false, error: "Bash.tty must be a boolean." };
233
+ }
234
+
213
235
  return {
214
236
  ok: true,
215
237
  value: {
@@ -220,6 +242,7 @@ export function parseBashArgs(
220
242
  ? undefined
221
243
  : args.description,
222
244
  run_in_background: args.run_in_background,
245
+ tty: args.tty,
223
246
  },
224
247
  };
225
248
  }
@@ -325,18 +348,22 @@ function buildRunningResult(input: {
325
348
  timeoutMs: input.timeoutMs,
326
349
  backgrounded: input.backgrounded,
327
350
  backgroundedDueToTimeout: input.backgroundedDueToTimeout,
351
+ tty: task.tty,
352
+ screenRows: input.inspection.screenRows,
353
+ screenColumns: input.inspection.screenColumns,
354
+ screen: input.inspection.screen,
328
355
  };
329
356
  }
330
357
 
331
358
  async function buildCompletedResult(
332
- task: ShellTaskSnapshot,
333
- fallbackOutput: TaskOutputSnapshot,
359
+ inspection: ShellTaskInspection,
334
360
  ): Promise<BashRawResult> {
361
+ const { task } = inspection;
335
362
  if (task.status === "running" || task.status === "stopping") {
336
363
  throw new Error(`Bash task ${task.taskId} completed with status=${task.status}.`);
337
364
  }
338
365
 
339
- const snapshot = await snapshotCompletedOutput(task, fallbackOutput);
366
+ const snapshot = await snapshotCompletedOutput(task, inspection.output);
340
367
  const interpretation = interpretCommandResult({
341
368
  command: task.command,
342
369
  exitCode: task.exitCode,
@@ -359,6 +386,10 @@ async function buildCompletedResult(
359
386
  truncated: snapshot.truncated,
360
387
  omittedLines: snapshot.omittedLines,
361
388
  returnCodeInterpretation: interpretation.interpretation,
389
+ tty: task.tty,
390
+ screenRows: inspection.screenRows,
391
+ screenColumns: inspection.screenColumns,
392
+ screen: inspection.screen,
362
393
  error: task.error,
363
394
  };
364
395
  }
@@ -390,11 +421,11 @@ function updateCwdStateAfterForegroundCommand(input: {
390
421
  }
391
422
  }
392
423
 
393
- function requireTaskInspection(
424
+ async function requireTaskOutputInspection(
394
425
  taskManager: ShellTaskManager,
395
426
  taskId: string,
396
- ): ShellTaskInspection {
397
- const inspection = taskManager.inspectTask(taskId);
427
+ ): Promise<ShellTaskInspection> {
428
+ const inspection = await taskManager.inspectTaskOutput(taskId);
398
429
  if (inspection === undefined) {
399
430
  throw new Error(`Bash task disappeared from task manager: ${taskId}`);
400
431
  }
@@ -8,6 +8,7 @@ import { createGrepToolExecutor } from "./grep";
8
8
  import { createReadToolExecutor } from "./read";
9
9
  import { createRecallToolExecutor } from "./recall";
10
10
  import { createTaskListToolExecutor } from "./task-list";
11
+ import { createTaskInputToolExecutor } from "./task-input";
11
12
  import { createTaskOutputToolExecutor } from "./task-output-tool";
12
13
  import { createTaskStopToolExecutor } from "./task-stop";
13
14
  import { createWebFetchToolExecutor } from "./web-fetch";
@@ -256,6 +257,7 @@ export function createDefaultTooling(options: {
256
257
  );
257
258
  registry.register(createTaskListToolExecutor({ taskManager }));
258
259
  registry.register(createTaskOutputToolExecutor({ taskManager }));
260
+ registry.register(createTaskInputToolExecutor({ taskManager }));
259
261
  registry.register(createTaskStopToolExecutor({ taskManager }));
260
262
 
261
263
  const exaApiKey = options.exaApiKey ?? toolingConfig.exaApiKey;