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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,26 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.7.0] - 2026-08-01
9
+
10
+ ### Added
11
+
12
+ - Run interactive terminal programs through PTY-backed Bash tasks, send exact
13
+ keystrokes with `TaskInput`, and inspect their current terminal screen with
14
+ `TaskOutput`.
15
+ - Search all resumable sessions from the `/resume` picker by text from their
16
+ first user prompt, including older sessions beyond the 20 most recent.
17
+
18
+ ### Changed
19
+
20
+ - Keep the live background-task panel compact by showing at most two tasks and
21
+ label PTY-backed tasks explicitly.
22
+
23
+ ### Fixed
24
+
25
+ - Complete PTY tasks reliably after subprocess exit on Linux, including the EIO
26
+ signal reported when the terminal closes.
27
+
8
28
  ## [1.6.0] - 2026-08-01
9
29
 
10
30
  ### Added
@@ -129,7 +149,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
129
149
  - First formal npm release under the `tinker-agent` package name with the `tinker`
130
150
  executable.
131
151
 
132
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.6.0...HEAD
152
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.7.0...HEAD
153
+ [1.7.0]: https://github.com/ishowshao/tinker/releases/tag/v1.7.0
133
154
  [1.6.0]: https://github.com/ishowshao/tinker/releases/tag/v1.6.0
134
155
  [1.5.1]: https://github.com/ishowshao/tinker/releases/tag/v1.5.1
135
156
  [1.5.0]: https://github.com/ishowshao/tinker/releases/tag/v1.5.0
package/README.md CHANGED
@@ -12,8 +12,8 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
12
12
  - `Glob` / `Grep` — Find and search files by pattern or content
13
13
  - `Read` / `Write` / `Edit` — File I/O with content hashing and concurrent-modification protection
14
14
  - `Delete` — Delete one existing regular file without directory or symlink support
15
- - `Bash` — Run shell commands (foreground and background) with per-task working directories
16
- - `TaskList` / `TaskOutput` / `TaskStop` — Manage long-running background shell tasks
15
+ - `Bash` — Run foreground, background, and PTY shell commands with per-task working directories
16
+ - `TaskList` / `TaskOutput` / `TaskInput` / `TaskStop` — Inspect, interact with, and stop long-running shell tasks
17
17
  - `WebSearch` — Search the web via Exa API
18
18
  - `WebFetch` — Fetch and refine web page content (local, browser, or Exa backend)
19
19
  - `Recall` — Search or retrieve model-visible history from the current session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "A personal coding agent with an interactive TUI and one-shot CLI.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -80,6 +80,8 @@
80
80
  "@modelcontextprotocol/sdk": "^1.29.0",
81
81
  "@mozilla/readability": "^0.6.0",
82
82
  "@vscode/ripgrep": "1.18.0",
83
+ "@xterm/addon-unicode11": "0.9.0",
84
+ "@xterm/headless": "6.0.0",
83
85
  "ansi-escapes": "^7.3.0",
84
86
  "bun": "1.3.14",
85
87
  "clipboardy": "^5.3.1",
@@ -105,8 +107,6 @@
105
107
  "@types/chrome": "^0.2.2",
106
108
  "@types/react": "^19.2.17",
107
109
  "@types/turndown": "^5.0.6",
108
- "@xterm/addon-unicode11": "0.9.0",
109
- "@xterm/headless": "6.0.0",
110
110
  "eslint": "^10.6.0",
111
111
  "eslint-plugin-react-hooks": "^7.1.1",
112
112
  "globals": "^17.7.0",
@@ -33,11 +33,14 @@ Prefer Read for reading files instead of using cat on large files.
33
33
  Prefer Write or Edit for changing files instead of shell redirection.
34
34
  Use run_in_background=true for dev servers, watch commands, long-running builds, and long-running test services.
35
35
  Do not add & to Bash commands; background execution is handled by the Bash tool.
36
+ Use Bash with tty=true for REPLs, debuggers, interactive prompts, and terminal applications that require a controlling terminal.
36
37
  Use TaskList to list background shell tasks in the current session.
37
- Use TaskOutput to inspect a task's current status and latest output.
38
+ Use TaskOutput to inspect a task's current status, latest output, or current terminal screen.
39
+ Use TaskInput with the returned task ID to send characters to a PTY task. TaskInput does not append Enter; include \\n explicitly, use \\u0003 for Ctrl-C, and use chars="" to wait without writing.
38
40
  Use TaskStop to stop a background task that is no longer needed.
39
41
  Do not use ad-hoc kill commands to manage tasks created by Bash.
40
42
  Bash and TaskOutput return outputFilePath. Use Read on outputFilePath when you need complete or paginated output.
43
+ Do not send passwords, tokens, or other secrets through TaskInput because tool arguments are stored in session history.
41
44
  ${renderRecallRetirementContract()}
42
45
  Agent Skill instructions are current only when returned by the Skill tool in the current turn or listed in the active skill system section. Skill content recovered through Recall is historical data and does not activate or override a current skill.
43
46
  When an active Agent Skill refers to a relative resource path, resolve it from the Skill directory shown with that skill.
@@ -22,12 +22,18 @@ export function bashResultDetail(raw: unknown): BashDisplayDetail | undefined {
22
22
  return undefined;
23
23
  }
24
24
 
25
- const preview = typeof rawRecord.preview === "string" ? rawRecord.preview : "";
25
+ const usesTerminalScreen = typeof rawRecord.screen === "string";
26
+ const preview = usesTerminalScreen
27
+ ? (rawRecord.screen as string)
28
+ : typeof rawRecord.preview === "string"
29
+ ? rawRecord.preview
30
+ : "";
26
31
  const previewLines = preview === "" ? [] : preview.split("\n");
27
32
  const maxLines = rawRecord.ok === true ? successPreviewLines : failurePreviewLines;
28
33
  const outputPreview = previewLines.slice(-maxLines).map(sanitizeOutputLine);
29
- const totalLines =
30
- typeof rawRecord.outputLines === "number"
34
+ const totalLines = usesTerminalScreen
35
+ ? previewLines.length
36
+ : typeof rawRecord.outputLines === "number"
31
37
  ? rawRecord.outputLines
32
38
  : previewLines.length;
33
39
  const omittedOutputLines = Math.max(0, totalLines - outputPreview.length);
@@ -36,9 +42,10 @@ export function bashResultDetail(raw: unknown): BashDisplayDetail | undefined {
36
42
  command,
37
43
  outputPreview,
38
44
  omittedOutputLines,
39
- outputFilePath:
40
- nonEmptyString(rawRecord.outputFilePath) ??
41
- nonEmptyString(taskRecord.outputFilePath),
45
+ outputFilePath: usesTerminalScreen
46
+ ? undefined
47
+ : (nonEmptyString(rawRecord.outputFilePath) ??
48
+ nonEmptyString(taskRecord.outputFilePath)),
42
49
  };
43
50
  }
44
51
 
@@ -179,7 +179,11 @@ function toolCallSummary(call: ToolCall): string {
179
179
  .join("\n");
180
180
  }
181
181
 
182
- if (call.name === "TaskOutput" || call.name === "TaskStop") {
182
+ if (
183
+ call.name === "TaskOutput" ||
184
+ call.name === "TaskInput" ||
185
+ call.name === "TaskStop"
186
+ ) {
183
187
  const taskId = stringProperty(args, "task_id");
184
188
  return [
185
189
  `Call ID: ${call.toolCallId}`,
@@ -202,6 +202,7 @@ function formatToolRawResult(call: ToolCall, raw: ToolRawResult): string[] {
202
202
  return optionalLine(formatDiff(call, raw));
203
203
  case "bash":
204
204
  case "task_output":
205
+ case "task_input":
205
206
  return optionalLine(formatBashResult(call, raw));
206
207
  case "task_list":
207
208
  case "task_stop":
@@ -278,7 +279,7 @@ function formatDiff(call: ToolCall, raw: ToolRawResult): string | undefined {
278
279
  }
279
280
 
280
281
  function formatBashResult(_call: ToolCall, raw: ToolRawResult): string | undefined {
281
- if (raw.kind !== "bash" && raw.kind !== "task_output") {
282
+ if (raw.kind !== "bash" && raw.kind !== "task_output" && raw.kind !== "task_input") {
282
283
  return undefined;
283
284
  }
284
285
 
@@ -348,7 +349,11 @@ function formatToolLine(prefix: string, call: ToolCall): string {
348
349
  return `${prefix} name=${call.name}\n`;
349
350
  }
350
351
 
351
- if (call.name === "TaskOutput" || call.name === "TaskStop") {
352
+ if (
353
+ call.name === "TaskOutput" ||
354
+ call.name === "TaskInput" ||
355
+ call.name === "TaskStop"
356
+ ) {
352
357
  const taskId = toolTaskId(call);
353
358
  return `${prefix} name=${call.name}${taskId === undefined ? "" : ` task=${taskId}`}\n`;
354
359
  }
@@ -247,6 +247,9 @@ export class FakeModelClient implements ModelClient {
247
247
  if (this.mode === "pty-background-task") {
248
248
  return this.ptyBackgroundTask(input, prepared, options);
249
249
  }
250
+ if (this.mode === "pty-interactive-terminal") {
251
+ return this.ptyInteractiveTerminal(input, prepared, options);
252
+ }
250
253
  if (this.mode === "pty-resume") {
251
254
  return this.ptyResume(input, prepared, options);
252
255
  }
@@ -658,6 +661,87 @@ export class FakeModelClient implements ModelClient {
658
661
  return textOutput(prepared, "PTY_BACKGROUND_STOPPED");
659
662
  }
660
663
 
664
+ private ptyInteractiveTerminal(
665
+ input: ModelRequestInput,
666
+ prepared: PreparedModelRequest,
667
+ options: ModelRequestOptions,
668
+ ): ModelRequestOutput {
669
+ requireTools(input, ["Bash", "TaskOutput", "TaskInput"]);
670
+ const prompt = lastUserMessage(input.messages);
671
+ if (prompt === "PTY_INTERACTIVE_FOLLOWUP") {
672
+ requireMessage(input.messages, "assistant", "PTY_INTERACTIVE_DONE");
673
+ return textOutput(prepared, "PTY_INTERACTIVE_FOLLOWUP_DONE");
674
+ }
675
+ if (prompt !== "PTY_INTERACTIVE_TERMINAL" && prompt !== "PTY_INTERACTIVE_QUIT") {
676
+ throw new Error(
677
+ `Unexpected pty-interactive-terminal prompt: ${JSON.stringify(prompt)}.`,
678
+ );
679
+ }
680
+
681
+ const tools = toolMessagesAfterLastUser(input.messages);
682
+ const bash = tools.find((message) => message.name === "Bash");
683
+ if (bash === undefined) {
684
+ return toolCallOutput(prepared, options, "Bash", {
685
+ command: "python3 -q",
686
+ description: "Start interactive Python fixture",
687
+ tty: true,
688
+ timeout: 25,
689
+ });
690
+ }
691
+ if (!bash.content.includes("taskId=") || !bash.content.includes("tty=true")) {
692
+ throw new Error("PTY Bash task did not return an interactive task ID.");
693
+ }
694
+ const taskId = requireObservationValue(bash.content, "taskId");
695
+
696
+ const outputs = tools.filter((message) => message.name === "TaskOutput");
697
+ const output = outputs.at(-1);
698
+ if (output === undefined || !output.content.includes(">>>")) {
699
+ if (outputs.length >= 20) {
700
+ throw new Error("Interactive Python fixture did not show its prompt.");
701
+ }
702
+ return toolCallOutput(prepared, options, "TaskOutput", {
703
+ task_id: taskId,
704
+ });
705
+ }
706
+
707
+ const inputs = tools.filter((message) => message.name === "TaskInput");
708
+ if (inputs.length === 0) {
709
+ return toolCallOutput(prepared, options, "TaskInput", {
710
+ task_id: taskId,
711
+ chars:
712
+ prompt === "PTY_INTERACTIVE_QUIT"
713
+ ? "import os; print('PTY_INTERACTIVE_PID=' + str(os.getpid()))\n"
714
+ : "print(6 * 7)\n",
715
+ wait_ms: 250,
716
+ });
717
+ }
718
+
719
+ const latestInput = inputs.at(-1);
720
+ const expected = prompt === "PTY_INTERACTIVE_QUIT" ? "PTY_INTERACTIVE_PID=" : "42";
721
+ if (!latestInput?.content.includes(expected)) {
722
+ if (inputs.length >= 20) {
723
+ throw new Error(`Interactive Python fixture did not show ${expected}.`);
724
+ }
725
+ return toolCallOutput(prepared, options, "TaskInput", {
726
+ task_id: taskId,
727
+ chars: "",
728
+ wait_ms: 250,
729
+ });
730
+ }
731
+
732
+ if (prompt === "PTY_INTERACTIVE_QUIT") {
733
+ return textOutput(prepared, "PTY_INTERACTIVE_RUNNING");
734
+ }
735
+ if (!inputs.some((message) => message.content.includes("status=completed"))) {
736
+ return toolCallOutput(prepared, options, "TaskInput", {
737
+ task_id: taskId,
738
+ chars: "exit()\n",
739
+ wait_ms: 500,
740
+ });
741
+ }
742
+ return textOutput(prepared, "PTY_INTERACTIVE_DONE");
743
+ }
744
+
661
745
  private ptyResume(
662
746
  input: ModelRequestInput,
663
747
  prepared: PreparedModelRequest,
@@ -11,6 +11,7 @@ import type {
11
11
  ReadFileRawResult,
12
12
  RecallRawResult,
13
13
  SkillRawResult,
14
+ TaskInputRawResult,
14
15
  TaskListRawResult,
15
16
  TaskOutputRawResult,
16
17
  TaskStopRawResult,
@@ -51,6 +52,8 @@ export class ObservationBuilder {
51
52
  return { content: renderTaskListObservation(input.raw) };
52
53
  case "task_output":
53
54
  return { content: renderTaskOutputObservation(input.raw) };
55
+ case "task_input":
56
+ return { content: renderTaskInputObservation(input.raw) };
54
57
  case "task_stop":
55
58
  return { content: renderTaskStopObservation(input.raw) };
56
59
  case "web_search":
@@ -331,8 +334,11 @@ function renderBashObservation(raw: BashRawResult): string {
331
334
  `timeoutMs=${raw.timeoutMs ?? 0}`,
332
335
  `command=${raw.command}`,
333
336
  `cwd=${raw.cwd}`,
337
+ `tty=${raw.tty}`,
334
338
  `outputFilePath=${raw.outputFilePath}`,
335
- "Use Read on outputFilePath to inspect current output.",
339
+ raw.tty
340
+ ? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
341
+ : "Use TaskOutput to inspect current output.",
336
342
  ].join("\n");
337
343
  }
338
344
 
@@ -342,8 +348,11 @@ function renderBashObservation(raw: BashRawResult): string {
342
348
  `taskId=${raw.taskId}`,
343
349
  `command=${raw.command}`,
344
350
  `cwd=${raw.cwd}`,
351
+ `tty=${raw.tty}`,
345
352
  `outputFilePath=${raw.outputFilePath}`,
346
- "Use Read on outputFilePath to inspect current output.",
353
+ raw.tty
354
+ ? "Use TaskOutput to inspect the current terminal screen and TaskInput to interact."
355
+ : "Use TaskOutput to inspect current output.",
347
356
  ].join("\n");
348
357
  }
349
358
 
@@ -353,6 +362,7 @@ function renderBashObservation(raw: BashRawResult): string {
353
362
  `exitCode=${raw.exitCode ?? "null"}`,
354
363
  `status=${raw.status}`,
355
364
  `cwd=${raw.cwd}`,
365
+ `tty=${raw.tty}`,
356
366
  `outputFilePath=${raw.outputFilePath}`,
357
367
  `outputBytes=${raw.outputBytes}`,
358
368
  `outputLines=${raw.outputLines}`,
@@ -362,8 +372,9 @@ function renderBashObservation(raw: BashRawResult): string {
362
372
  ? undefined
363
373
  : `returnCodeInterpretation=${raw.returnCodeInterpretation}`,
364
374
  raw.error === undefined ? undefined : `error=${raw.error}`,
365
- "preview:",
366
- raw.preview,
375
+ raw.tty ? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}` : undefined,
376
+ raw.tty ? "current screen:" : "preview:",
377
+ raw.tty ? (raw.screen ?? "") : raw.preview,
367
378
  ]
368
379
  .filter((line): line is string => line !== undefined)
369
380
  .join("\n");
@@ -387,23 +398,48 @@ function renderTaskOutputObservation(raw: TaskOutputRawResult): string {
387
398
  return `TaskOutput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
388
399
  }
389
400
 
401
+ const terminalScreen = raw.task.tty;
390
402
  return [
391
403
  "Task output retrieved.",
392
404
  `taskId=${raw.taskId}`,
393
405
  `status=${raw.task.status}`,
394
406
  `command=${raw.task.command}`,
407
+ `tty=${terminalScreen}`,
395
408
  `outputFilePath=${raw.outputFilePath}`,
396
409
  `outputBytes=${raw.outputBytes ?? 0}`,
397
410
  `outputLines=${raw.outputLines ?? 0}`,
398
411
  `truncated=${raw.truncated ?? false}`,
399
412
  raw.omittedLines === undefined ? undefined : `omittedLines=${raw.omittedLines}`,
400
- "preview:",
401
- raw.preview ?? "",
413
+ terminalScreen
414
+ ? `screen=${raw.screenColumns ?? 80}x${raw.screenRows ?? 24}`
415
+ : undefined,
416
+ terminalScreen ? "current screen:" : "preview:",
417
+ terminalScreen ? (raw.screen ?? "") : (raw.preview ?? ""),
402
418
  ]
403
419
  .filter((line): line is string => line !== undefined)
404
420
  .join("\n");
405
421
  }
406
422
 
423
+ function renderTaskInputObservation(raw: TaskInputRawResult): string {
424
+ if (!raw.ok) {
425
+ return `TaskInput failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error}`;
426
+ }
427
+
428
+ return [
429
+ "Terminal input sent.",
430
+ `taskId=${raw.taskId}`,
431
+ `status=${raw.status}`,
432
+ `writtenBytes=${raw.writtenBytes}`,
433
+ `waitedMs=${raw.waitedMs}`,
434
+ `screen=${raw.screenColumns}x${raw.screenRows}`,
435
+ `outputFilePath=${raw.outputFilePath}`,
436
+ `outputBytes=${raw.outputBytes}`,
437
+ `outputLines=${raw.outputLines}`,
438
+ "current screen:",
439
+ raw.screen,
440
+ ].join("\n");
441
+ }
442
+
407
443
  function renderTaskStopObservation(raw: TaskStopRawResult): string {
408
444
  if (!raw.ok || raw.task === undefined) {
409
445
  return `TaskStop failed for ${raw.taskId || "(unknown task ID)"}: ${raw.error ?? "Unknown error."}`;
@@ -428,6 +464,7 @@ function renderTaskSummary(task: TaskListRawResult["tasks"][number]): string {
428
464
  `taskId=${task.taskId}`,
429
465
  `description=${task.description}`,
430
466
  `status=${task.status}`,
467
+ `tty=${task.tty}`,
431
468
  `startedAt=${task.startedAt}`,
432
469
  task.endedAt === undefined ? undefined : `endedAt=${task.endedAt}`,
433
470
  task.exitCode === undefined ? undefined : `exitCode=${task.exitCode}`,
@@ -34,6 +34,15 @@ export class SessionCatalog {
34
34
  }
35
35
 
36
36
  async list(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
37
+ const summaries = await this.scan(currentSessionId);
38
+ return Object.freeze(summaries.slice(0, this.input.limit ?? 20));
39
+ }
40
+
41
+ async listAll(currentSessionId?: SessionId): Promise<readonly SessionSummary[]> {
42
+ return Object.freeze(await this.scan(currentSessionId));
43
+ }
44
+
45
+ private async scan(currentSessionId?: SessionId): Promise<SessionSummary[]> {
37
46
  const workspaceRoot = await this.workspaceRootPromise;
38
47
  const sessionsRoot = path.join(workspaceRoot, ".tinker", "sessions");
39
48
  let entries;
@@ -63,17 +72,14 @@ export class SessionCatalog {
63
72
  );
64
73
  }
65
74
 
66
- return Object.freeze(
67
- summaries
68
- .filter(
69
- (summary) =>
70
- summary.turnCount > 0 ||
71
- summary.status === "incomplete" ||
72
- summary.status === "unavailable",
73
- )
74
- .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
75
- .slice(0, this.input.limit ?? 20),
76
- );
75
+ return summaries
76
+ .filter(
77
+ (summary) =>
78
+ summary.turnCount > 0 ||
79
+ summary.status === "incomplete" ||
80
+ summary.status === "unavailable",
81
+ )
82
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
77
83
  }
78
84
 
79
85
  async get(
@@ -4844,6 +4844,7 @@ export function decodeStoredToolRawResult(value: unknown): ToolRawResult {
4844
4844
  "bash",
4845
4845
  "task_list",
4846
4846
  "task_output",
4847
+ "task_input",
4847
4848
  "task_stop",
4848
4849
  "web_search",
4849
4850
  "web_fetch",