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
@@ -0,0 +1,229 @@
1
+ import { throwIfTurnCancelled } from "../agent/turn-cancellation";
2
+ import type { ShellTaskManager, ShellTaskSnapshot } from "./bash-task";
3
+ import { ShellProcessWriteError } from "./shell-process";
4
+ import { defineToolExecutor } from "./types";
5
+ import type { TaskInputRawResult, ToolExecutionContext, ToolExecutor } from "./types";
6
+
7
+ type TaskInputArgs = {
8
+ taskId: string;
9
+ chars: string;
10
+ waitMs: number;
11
+ };
12
+
13
+ const defaultWaitMs = 250;
14
+ const maxWaitMs = 30_000;
15
+
16
+ export function createTaskInputToolExecutor(options: {
17
+ taskManager: ShellTaskManager;
18
+ }): ToolExecutor {
19
+ return defineToolExecutor("task_input", {
20
+ definition: {
21
+ name: "TaskInput",
22
+ description:
23
+ "Write characters to a PTY shell task and return its current terminal screen.",
24
+ parameters: {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ task_id: {
29
+ type: "string",
30
+ description: "The PTY task ID returned by Bash or TaskList.",
31
+ },
32
+ chars: {
33
+ type: "string",
34
+ description:
35
+ "Characters to write exactly as provided. Use an empty string to poll without writing.",
36
+ },
37
+ wait_ms: {
38
+ type: "integer",
39
+ minimum: 0,
40
+ maximum: maxWaitMs,
41
+ description:
42
+ "Milliseconds to wait before returning the current screen. Defaults to 250.",
43
+ },
44
+ },
45
+ required: ["task_id", "chars"],
46
+ },
47
+ },
48
+ async execute(
49
+ args,
50
+ _call,
51
+ context: ToolExecutionContext,
52
+ ): Promise<TaskInputRawResult> {
53
+ throwIfTurnCancelled(context.signal);
54
+ const parsed = parseTaskInputArgs(args);
55
+ if (!parsed.ok) {
56
+ return { ok: false, taskId: "", error: parsed.error };
57
+ }
58
+
59
+ const { taskId, chars, waitMs } = parsed.value;
60
+ const initial = options.taskManager.inspectTask(taskId);
61
+ if (initial === undefined) {
62
+ return { ok: false, taskId, error: `Unknown task ID: ${taskId}` };
63
+ }
64
+ if (!initial.task.tty) {
65
+ return taskInputFailure(
66
+ initial.task,
67
+ `Task ${taskId} does not accept terminal input; start it with Bash tty=true.`,
68
+ );
69
+ }
70
+ if (chars !== "" && initial.task.status !== "running") {
71
+ return taskInputFailure(
72
+ initial.task,
73
+ `Task ${taskId} is not running (status=${initial.task.status}).`,
74
+ );
75
+ }
76
+
77
+ let writtenBytes = 0;
78
+ if (chars !== "") {
79
+ try {
80
+ writtenBytes = await options.taskManager.writeTaskInput(taskId, chars);
81
+ } catch (error) {
82
+ const current = options.taskManager.inspectTask(taskId)?.task ?? initial.task;
83
+ return {
84
+ ...taskInputFailure(
85
+ current,
86
+ error instanceof Error ? error.message : String(error),
87
+ ),
88
+ writtenBytes:
89
+ error instanceof ShellProcessWriteError
90
+ ? error.writtenBytes
91
+ : writtenBytes,
92
+ };
93
+ }
94
+ }
95
+
96
+ throwIfTurnCancelled(context.signal);
97
+ const waitStartedAt = Date.now();
98
+ await waitForCollectionWindow({
99
+ waitMs,
100
+ completion: options.taskManager.taskCompletion(taskId),
101
+ signal: context.signal,
102
+ });
103
+ throwIfTurnCancelled(context.signal);
104
+
105
+ const inspection = await options.taskManager.inspectTaskOutput(taskId);
106
+ if (inspection === undefined) {
107
+ return {
108
+ ok: false,
109
+ taskId,
110
+ writtenBytes,
111
+ error: `Task disappeared while waiting for terminal output: ${taskId}`,
112
+ };
113
+ }
114
+ if (
115
+ inspection.screen === undefined ||
116
+ inspection.screenRows === undefined ||
117
+ inspection.screenColumns === undefined
118
+ ) {
119
+ throw new Error(`PTY task ${taskId} has no terminal screen.`);
120
+ }
121
+
122
+ return {
123
+ ok: true,
124
+ taskId,
125
+ task: inspection.task,
126
+ status: inspection.task.status,
127
+ writtenBytes,
128
+ waitedMs: Math.max(0, Date.now() - waitStartedAt),
129
+ screenRows: inspection.screenRows,
130
+ screenColumns: inspection.screenColumns,
131
+ screen: inspection.screen,
132
+ outputBytes: inspection.output.outputBytes,
133
+ outputLines: inspection.output.outputLines,
134
+ outputFilePath: inspection.task.outputFilePath,
135
+ };
136
+ },
137
+ });
138
+ }
139
+
140
+ export function parseTaskInputArgs(
141
+ args: unknown,
142
+ ): { ok: true; value: TaskInputArgs } | { ok: false; error: string } {
143
+ if (!isRecord(args)) {
144
+ return { ok: false, error: "TaskInput arguments must be an object." };
145
+ }
146
+
147
+ const allowed = new Set(["task_id", "chars", "wait_ms"]);
148
+ const unexpected = Object.keys(args).find((key) => !allowed.has(key));
149
+ if (unexpected !== undefined) {
150
+ return {
151
+ ok: false,
152
+ error: `TaskInput received unexpected argument: ${unexpected}.`,
153
+ };
154
+ }
155
+ if (typeof args.task_id !== "string" || args.task_id.trim() === "") {
156
+ return { ok: false, error: "TaskInput.task_id must be a non-empty string." };
157
+ }
158
+ if (typeof args.chars !== "string") {
159
+ return { ok: false, error: "TaskInput.chars must be a string." };
160
+ }
161
+ if (
162
+ args.wait_ms !== undefined &&
163
+ (!Number.isInteger(args.wait_ms) ||
164
+ typeof args.wait_ms !== "number" ||
165
+ args.wait_ms < 0 ||
166
+ args.wait_ms > maxWaitMs)
167
+ ) {
168
+ return {
169
+ ok: false,
170
+ error: `TaskInput.wait_ms must be an integer between 0 and ${maxWaitMs}.`,
171
+ };
172
+ }
173
+
174
+ return {
175
+ ok: true,
176
+ value: {
177
+ taskId: args.task_id,
178
+ chars: args.chars,
179
+ waitMs: args.wait_ms ?? defaultWaitMs,
180
+ },
181
+ };
182
+ }
183
+
184
+ function taskInputFailure(task: ShellTaskSnapshot, error: string): TaskInputRawResult {
185
+ return {
186
+ ok: false,
187
+ taskId: task.taskId,
188
+ task,
189
+ status: task.status,
190
+ error,
191
+ };
192
+ }
193
+
194
+ async function waitForCollectionWindow(input: {
195
+ waitMs: number;
196
+ completion: Promise<ShellTaskSnapshot>;
197
+ signal: AbortSignal;
198
+ }): Promise<void> {
199
+ let timeout: ReturnType<typeof setTimeout> | undefined;
200
+ let onAbort: (() => void) | undefined;
201
+
202
+ try {
203
+ await Promise.race([
204
+ input.completion.then(() => undefined),
205
+ new Promise<void>((resolve) => {
206
+ timeout = setTimeout(resolve, input.waitMs);
207
+ }),
208
+ new Promise<void>((resolve) => {
209
+ onAbort = () => resolve();
210
+ if (input.signal.aborted) {
211
+ onAbort();
212
+ return;
213
+ }
214
+ input.signal.addEventListener("abort", onAbort, { once: true });
215
+ }),
216
+ ]);
217
+ } finally {
218
+ if (timeout !== undefined) {
219
+ clearTimeout(timeout);
220
+ }
221
+ if (onAbort !== undefined) {
222
+ input.signal.removeEventListener("abort", onAbort);
223
+ }
224
+ }
225
+ }
226
+
227
+ function isRecord(value: unknown): value is Record<string, unknown> {
228
+ return typeof value === "object" && value !== null && !Array.isArray(value);
229
+ }
@@ -34,7 +34,7 @@ export function createTaskOutputToolExecutor(options: {
34
34
  return { ok: false, taskId: "", error: parsed.error };
35
35
  }
36
36
 
37
- const inspection = options.taskManager.inspectTask(parsed.taskId);
37
+ const inspection = await options.taskManager.inspectTaskOutput(parsed.taskId);
38
38
  throwIfTurnCancelled(context.signal);
39
39
  if (inspection === undefined) {
40
40
  return {
@@ -56,6 +56,9 @@ export function createTaskOutputToolExecutor(options: {
56
56
  truncated: inspection.output.truncated,
57
57
  omittedLines: inspection.output.omittedLines,
58
58
  outputFilePath: inspection.task.outputFilePath,
59
+ screenRows: inspection.screenRows,
60
+ screenColumns: inspection.screenColumns,
61
+ screen: inspection.screen,
59
62
  };
60
63
  },
61
64
  });
@@ -0,0 +1,105 @@
1
+ import { Unicode11Addon } from "@xterm/addon-unicode11";
2
+ import { Terminal } from "@xterm/headless";
3
+
4
+ export const TERMINAL_SCREEN_ROWS = 24;
5
+ export const TERMINAL_SCREEN_COLUMNS = 80;
6
+
7
+ export type TerminalScreen = {
8
+ write(bytes: Uint8Array): Promise<void>;
9
+ flush(): Promise<void>;
10
+ text(): string;
11
+ dispose(): void;
12
+ };
13
+
14
+ export function createTerminalScreen(): TerminalScreen {
15
+ return new HeadlessTerminalScreen(TERMINAL_SCREEN_ROWS, TERMINAL_SCREEN_COLUMNS);
16
+ }
17
+
18
+ export class HeadlessTerminalScreen implements TerminalScreen {
19
+ private readonly terminal: Terminal;
20
+ private readonly unicodeAddon: Unicode11Addon;
21
+ private pendingWrite = Promise.resolve();
22
+ private disposed = false;
23
+ private currentRows: number;
24
+ private currentColumns: number;
25
+
26
+ constructor(rows: number, columns: number) {
27
+ this.currentRows = rows;
28
+ this.currentColumns = columns;
29
+ this.terminal = new Terminal({
30
+ allowProposedApi: true,
31
+ cols: columns,
32
+ rows,
33
+ scrollback: 0,
34
+ });
35
+ this.unicodeAddon = new Unicode11Addon();
36
+ this.terminal.loadAddon(this.unicodeAddon);
37
+ this.terminal.unicode.activeVersion = "11";
38
+ }
39
+
40
+ get rows(): number {
41
+ return this.currentRows;
42
+ }
43
+
44
+ get columns(): number {
45
+ return this.currentColumns;
46
+ }
47
+
48
+ get bracketedPasteMode(): boolean {
49
+ return this.terminal.modes.bracketedPasteMode;
50
+ }
51
+
52
+ write(bytes: Uint8Array): Promise<void> {
53
+ const copy = new Uint8Array(bytes);
54
+ const pending = this.pendingWrite.then(
55
+ () =>
56
+ new Promise<void>((resolve, reject) => {
57
+ if (this.disposed) {
58
+ reject(new Error("Cannot write to a disposed terminal screen."));
59
+ return;
60
+ }
61
+
62
+ try {
63
+ this.terminal.write(copy, resolve);
64
+ } catch (error) {
65
+ reject(error instanceof Error ? error : new Error(String(error)));
66
+ }
67
+ }),
68
+ );
69
+ this.pendingWrite = pending;
70
+ return pending;
71
+ }
72
+
73
+ async resize(rows: number, columns: number): Promise<void> {
74
+ await this.pendingWrite;
75
+ this.terminal.resize(columns, rows);
76
+ this.currentRows = rows;
77
+ this.currentColumns = columns;
78
+ }
79
+
80
+ async flush(): Promise<void> {
81
+ await this.pendingWrite;
82
+ }
83
+
84
+ text(): string {
85
+ const buffer = this.terminal.buffer.active;
86
+ const lines: string[] = [];
87
+ for (let row = 0; row < this.rows; row += 1) {
88
+ lines.push(buffer.getLine(buffer.viewportY + row)?.translateToString(true) ?? "");
89
+ }
90
+ while (lines.at(-1) === "") {
91
+ lines.pop();
92
+ }
93
+ return lines.join("\n");
94
+ }
95
+
96
+ dispose(): void {
97
+ if (this.disposed) {
98
+ return;
99
+ }
100
+
101
+ this.disposed = true;
102
+ this.unicodeAddon.dispose();
103
+ this.terminal.dispose();
104
+ }
105
+ }