tinker-agent 1.5.1 → 1.6.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 (42) hide show
  1. package/CHANGELOG.md +31 -1
  2. package/README.md +13 -5
  3. package/package.json +6 -5
  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/tui-runner.tsx +17 -2
  14. package/src/events/observation-text-log.ts +21 -0
  15. package/src/events/stdout-event-printer.ts +11 -0
  16. package/src/events/types.ts +14 -2
  17. package/src/model/fake-model-client.ts +93 -0
  18. package/src/model/model-client.ts +3 -0
  19. package/src/model/openai-chat-model-client.ts +54 -15
  20. package/src/model/openai-chat-stream.ts +95 -72
  21. package/src/observation/observation-builder.ts +11 -0
  22. package/src/session/session-store.ts +1 -0
  23. package/src/tools/bash-guard.ts +131 -0
  24. package/src/tools/bash.ts +31 -0
  25. package/src/tools/delete.ts +182 -0
  26. package/src/tools/edit.ts +68 -9
  27. package/src/tools/registry.ts +47 -3
  28. package/src/tools/turn-undo-manager.ts +794 -0
  29. package/src/tools/types.ts +13 -0
  30. package/src/tools/write.ts +65 -14
  31. package/src/tui/app.tsx +161 -45
  32. package/src/tui/assistant-markdown-section-framer.ts +135 -0
  33. package/src/tui/components/bash-confirmation.tsx +27 -0
  34. package/src/tui/components/context-status.tsx +11 -1
  35. package/src/tui/components/footer.tsx +8 -5
  36. package/src/tui/components/prompt-input.tsx +13 -1
  37. package/src/tui/components/timeline.tsx +10 -0
  38. package/src/tui/context-format.ts +17 -0
  39. package/src/tui/event-store.ts +62 -3
  40. package/src/tui/slash-commands.ts +28 -0
  41. package/src/tui/tui-projection-store.ts +246 -7
  42. package/src/tui/tui-session-controller.ts +18 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [1.6.0] - 2026-08-01
9
+
10
+ ### Added
11
+
12
+ - Add an `/undo` slash command that restores the files changed by the most recent
13
+ file-mutation turn from in-memory snapshots, with restore notices shown in the
14
+ TUI timeline.
15
+ - Give the agent a Delete tool for removing files, guarded by the same permission
16
+ flow as the other file-mutation tools.
17
+ - Guard destructive Bash commands behind an interactive confirmation prompt, with
18
+ a `/yolo` opt-out for sessions where unattended execution is intended.
19
+ - Show the latest provider cache hit rate in the TUI prompt status line.
20
+ - Stream assistant output in the TUI as sealed Markdown sections, so settled
21
+ content no longer repaints while the turn is still running.
22
+
23
+ ### Changed
24
+
25
+ - Retry transient provider failures automatically with backoff instead of
26
+ surfacing them as immediate turn errors.
27
+ - Update runtime dependencies, including `commander` 15, `openai` 7, and the
28
+ latest `@assistant-ui/react-ink` packages.
29
+
30
+ ### Fixed
31
+
32
+ - Floor the displayed cache hit rate so append-heavy turns never show a false
33
+ 100%.
34
+ - Bound the TUI live region below the viewport height to protect the prompt
35
+ frame from being pushed out of view.
36
+
8
37
  ## [1.5.1] - 2026-07-29
9
38
 
10
39
  ### Changed
@@ -100,7 +129,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
100
129
  - First formal npm release under the `tinker-agent` package name with the `tinker`
101
130
  executable.
102
131
 
103
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.5.1...HEAD
132
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v1.6.0...HEAD
133
+ [1.6.0]: https://github.com/ishowshao/tinker/releases/tag/v1.6.0
104
134
  [1.5.1]: https://github.com/ishowshao/tinker/releases/tag/v1.5.1
105
135
  [1.5.0]: https://github.com/ishowshao/tinker/releases/tag/v1.5.0
106
136
  [1.4.0]: https://github.com/ishowshao/tinker/releases/tag/v1.4.0
package/README.md CHANGED
@@ -11,6 +11,7 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
11
11
  - **Built-in tools**:
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
+ - `Delete` — Delete one existing regular file without directory or symlink support
14
15
  - `Bash` — Run shell commands (foreground and background) with per-task working directories
15
16
  - `TaskList` / `TaskOutput` / `TaskStop` — Manage long-running background shell tasks
16
17
  - `WebSearch` — Search the web via Exa API
@@ -28,7 +29,7 @@ Built with [Bun](https://bun.sh) + TypeScript ESM, powered by [Ink](https://gith
28
29
  - **Infinite Context architecture**: Immutable canonical history, deterministic
29
30
  context revisions, Recall-addressable cold state, and qualified prefix retirement
30
31
  keep long-running sessions recoverable without pretending the model has infinite
31
- tokens. See the [technical design](docs/infinite-context-technical-design-a.md).
32
+ tokens. See the [technical design](docs/infinite-context-technical-design.md).
32
33
  - **Choice of models**: Uses an OpenAI-compatible Chat Completions transport with
33
34
  explicit model and context limits. Actual provider support must be established
34
35
  by a qualification matrix; transport compatibility alone is not a guarantee.
@@ -76,9 +77,9 @@ The installed package exposes this public CLI:
76
77
  | --- | --- |
77
78
  | `tinker` | Start the interactive terminal interface. |
78
79
  | `tinker --profile <profile-name>` | Start the TUI with a selected model profile. |
79
- | `tinker run [--profile <profile-name>] <prompt>` | Submit one shell-quoted prompt argument. |
80
- | `tinker run [--profile <profile-name>] --stdin` | Read the prompt from standard input until EOF. |
81
- | `tinker run [--profile <profile-name>] --file <path>` | Read the prompt from a UTF-8 text file. |
80
+ | `tinker run [--profile <profile-name>] [--yolo] <prompt>` | Submit one shell-quoted prompt argument. |
81
+ | `tinker run [--profile <profile-name>] [--yolo] --stdin` | Read the prompt from standard input until EOF. |
82
+ | `tinker run [--profile <profile-name>] [--yolo] --file <path>` | Read the prompt from a UTF-8 text file. |
82
83
  | `tinker --help` | Show top-level CLI help. |
83
84
  | `tinker help run` | Show one-shot command help. |
84
85
  | `tinker --version` | Print the installed package version. |
@@ -146,6 +147,7 @@ are required. Boolean environment values accept case-insensitive `true/false`,
146
147
  | `TINKER_MCP_MAX_OBSERVATION_CHARS` | Tooling | All modes | No | Positive integer | `40000` | No | Maximum model-visible characters in one MCP result. |
147
148
  | `TINKER_BASH_DEFAULT_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `5000` | No | Default Bash foreground timeout in milliseconds. |
148
149
  | `TINKER_BASH_MAX_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `600000` | No | Maximum Bash foreground timeout in milliseconds. |
150
+ | `TINKER_YOLO` | Tooling | All modes | No | Boolean | `false` | No | Allow high-confidence destructive Bash commands without confirmation. |
149
151
  | `TINKER_GREP_TIMEOUT_MS` | Tooling | All modes | No | Positive integer | `20000` | No | Bundled ripgrep invocation timeout in milliseconds. |
150
152
  | `TINKER_GREP_MAX_BUFFER_BYTES` | Tooling | All modes | No | Positive integer | `20000000` | No | Maximum buffered output from one ripgrep invocation. |
151
153
  | `TINKER_WEBFETCH_REFINE_THRESHOLD` | Tooling | All modes | No | Positive integer | `2000` | No | Content-length threshold that enables WebFetch refinement. |
@@ -335,8 +337,10 @@ complete fixed policy and persistence contract.
335
337
  | `/status` | Show session and context details |
336
338
  | `/skills` | Show available and active Agent Skills |
337
339
  | `/mcp` | Show MCP servers and runtime tools |
340
+ | `/yolo [on\|off]` | Show or change destructive Bash confirmation |
338
341
  | `/memory` | Browse stored global memories |
339
342
  | `/compact [retire]` | Swap tool output or retire a cold history prefix |
343
+ | `/undo` | Undo the latest Write/Edit/Delete turn |
340
344
  | `/clear` | Start a new session and clear conversation |
341
345
  | `/fork` | Clone the current session |
342
346
  | `/view <path>` | View a local UTF-8 text file |
@@ -394,6 +398,10 @@ call always returns the complete requested line range. Use `offset` and `limit`
394
398
  to page through larger files; oversized requests fail instead of returning
395
399
  truncated content.
396
400
 
401
+ `Delete` removes one existing regular file by workspace-relative or absolute path.
402
+ It rejects directories and symbolic links, and clears any in-memory file snapshot
403
+ only after the removal succeeds.
404
+
397
405
  ## Project Structure
398
406
 
399
407
  ```
@@ -401,7 +409,7 @@ tinker/
401
409
  ├── src/
402
410
  │ ├── cli/ # Entry points (tui, run), config
403
411
  │ ├── agent/ # Agent loop, session ledger, turn cancellation, context metering
404
- │ ├── tools/ # Tool executors (bash, glob, grep, read, write, edit, recall, etc.)
412
+ │ ├── tools/ # Tool executors (bash, glob, grep, read, write, edit, delete, recall, etc.)
405
413
  │ ├── model/ # Model clients (OpenAI-compatible, fake), chat mapping, preflight
406
414
  │ ├── mcp/ # MCP server management, tool executor adapter
407
415
  │ ├── observation/ # Tool result → model-visible text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "1.5.1",
3
+ "version": "1.6.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",
@@ -74,8 +74,8 @@
74
74
  "typecheck": "tsc --noEmit"
75
75
  },
76
76
  "dependencies": {
77
- "@assistant-ui/react-ink": "^0.0.31",
78
- "@assistant-ui/react-ink-markdown": "^0.0.30",
77
+ "@assistant-ui/react-ink": "^0.0.35",
78
+ "@assistant-ui/react-ink-markdown": "^0.0.34",
79
79
  "@inkjs/ui": "^2.0.0",
80
80
  "@modelcontextprotocol/sdk": "^1.29.0",
81
81
  "@mozilla/readability": "^0.6.0",
@@ -83,13 +83,14 @@
83
83
  "ansi-escapes": "^7.3.0",
84
84
  "bun": "1.3.14",
85
85
  "clipboardy": "^5.3.1",
86
- "commander": "^14.0.3",
86
+ "commander": "^15.0.0",
87
87
  "diff": "^9.0.0",
88
88
  "glob": "^13.0.6",
89
89
  "ink": "^7.1.0",
90
90
  "linkedom": "^0.18.13",
91
91
  "markdansi": "0.3.2",
92
- "openai": "^6.45.0",
92
+ "marked": "^18.0.5",
93
+ "openai": "^7.1.0",
93
94
  "react": "^19.2.7",
94
95
  "sharp": "^0.35.3",
95
96
  "shiki": "^4.3.1",
@@ -0,0 +1,10 @@
1
+ import type { IterationIdentity } from "./types";
2
+
3
+ export type AssistantTextDeltaUpdate = IterationIdentity & {
4
+ attemptNumber: number;
5
+ content: string;
6
+ };
7
+
8
+ export interface AssistantTextDeltaSink {
9
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void;
10
+ }
package/src/agent/loop.ts CHANGED
@@ -73,6 +73,7 @@ export type RunAgentInput = {
73
73
  prepared: MaterializedModelRequest;
74
74
  usage: ContextUsageSnapshot;
75
75
  };
76
+ transientRetryDelaysMs?: readonly number[];
76
77
  };
77
78
 
78
79
  export class FatalAgentTurnError extends Error {
@@ -85,7 +86,10 @@ export class FatalAgentTurnError extends Error {
85
86
  }
86
87
  }
87
88
 
88
- const MODEL_REQUEST_MAX_ATTEMPTS = 2 as const;
89
+ const REASONING_ONLY_RETRY_LIMIT = 1 as const;
90
+ const TRANSIENT_RETRY_DELAYS_MS = [2_000, 4_000, 8_000, 16_000] as const;
91
+ const MODEL_REQUEST_MAX_ATTEMPTS =
92
+ 1 + REASONING_ONLY_RETRY_LIMIT + TRANSIENT_RETRY_DELAYS_MS.length;
89
93
 
90
94
  export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
91
95
  let lastIteration: IterationIdentity | undefined;
@@ -226,23 +230,22 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
226
230
  return failedResult(error, iteration);
227
231
  }
228
232
 
229
- const requestOptions = {
230
- signal: input.signal,
231
- identity: {
232
- iteration,
233
- runtimeSession: input.runtimeSession,
234
- },
235
- };
233
+ const transientRetryDelaysMs =
234
+ input.transientRetryDelaysMs ?? TRANSIENT_RETRY_DELAYS_MS;
236
235
  let modelOutput: ModelRequestOutput | undefined;
237
- let successfulAttempt: 1 | 2 | undefined;
238
- for (const attemptNumber of [1, 2] as const) {
236
+ let successfulAttempt: number | undefined;
237
+ let attemptNumber = 0;
238
+ let reasoningOnlyRetries = 0;
239
+ let transientRetries = 0;
240
+ while (true) {
239
241
  if (input.signal.aborted) {
240
242
  return cancelledResult(
241
243
  cancellation(input.signal, iteration, "model_request"),
242
244
  iteration,
243
245
  );
244
246
  }
245
- if (attemptNumber === 2) {
247
+ attemptNumber += 1;
248
+ if (attemptNumber > 1) {
246
249
  await input.runtimeSession.append({
247
250
  type: "model.request.started",
248
251
  ...iteration,
@@ -255,7 +258,32 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
255
258
 
256
259
  try {
257
260
  throwIfTurnCancelled(input.signal);
258
- modelOutput = await input.model.request(request, requestOptions);
261
+ let acceptingTextDeltas = true;
262
+ const onTextDelta =
263
+ input.runtimeSession.updateAssistantTextDelta === undefined
264
+ ? undefined
265
+ : (content: string) => {
266
+ if (!acceptingTextDeltas || input.signal.aborted) {
267
+ return;
268
+ }
269
+ input.runtimeSession.updateAssistantTextDelta?.({
270
+ ...iteration,
271
+ attemptNumber,
272
+ content,
273
+ });
274
+ };
275
+ try {
276
+ modelOutput = await input.model.request(request, {
277
+ signal: input.signal,
278
+ identity: {
279
+ iteration,
280
+ runtimeSession: input.runtimeSession,
281
+ },
282
+ ...(onTextDelta === undefined ? {} : { onTextDelta }),
283
+ });
284
+ } finally {
285
+ acceptingTextDeltas = false;
286
+ }
259
287
  throwIfTurnCancelled(input.signal);
260
288
  successfulAttempt = attemptNumber;
261
289
  break;
@@ -267,12 +295,11 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
267
295
  );
268
296
  }
269
297
 
270
- const reasoningOnly = isReasoningOnlyProviderError(error);
271
- const retryDisposition = reasoningOnly
272
- ? attemptNumber === 1
273
- ? "scheduled"
274
- : "exhausted"
275
- : "not_retryable";
298
+ const decision = modelRequestRetryDecision(error, {
299
+ reasoningOnlyRetries,
300
+ transientRetries,
301
+ transientRetryDelaysMs,
302
+ });
276
303
  await input.runtimeSession.append({
277
304
  type: "model.request.failed",
278
305
  ...iteration,
@@ -280,14 +307,26 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
280
307
  error,
281
308
  request,
282
309
  attemptNumber,
283
- retryDisposition,
310
+ retryDisposition: decision.disposition,
311
+ ...(decision.disposition === "scheduled" && decision.delayMs > 0
312
+ ? { retryDelayMs: decision.delayMs }
313
+ : {}),
284
314
  }),
285
315
  });
286
316
 
287
- if (retryDisposition === "scheduled") {
317
+ if (decision.disposition === "scheduled") {
318
+ if (decision.kind === "reasoning_only") {
319
+ reasoningOnlyRetries += 1;
320
+ } else {
321
+ transientRetries += 1;
322
+ await waitForRetryDelay(decision.delayMs, input.signal);
323
+ }
288
324
  continue;
289
325
  }
290
- if (retryDisposition === "exhausted") {
326
+ if (
327
+ decision.disposition === "exhausted" &&
328
+ decision.kind === "reasoning_only"
329
+ ) {
291
330
  return failedResult(
292
331
  new Error(
293
332
  `Provider returned reasoning without final text or tool calls in both attempts (provider=${request.provider}, model=${request.model}).`,
@@ -701,11 +740,65 @@ function isReasoningOnlyProviderError(error: unknown): error is ProviderResponse
701
740
  );
702
741
  }
703
742
 
743
+ function isTransientProviderError(error: unknown): error is ProviderResponseError {
744
+ return (
745
+ error instanceof ProviderResponseError &&
746
+ (error.code === "provider_rate_limited" || error.code === "provider_unavailable")
747
+ );
748
+ }
749
+
750
+ type ModelRequestRetryDecision =
751
+ | {
752
+ disposition: "scheduled";
753
+ kind: "reasoning_only" | "transient";
754
+ delayMs: number;
755
+ }
756
+ | { disposition: "exhausted"; kind: "reasoning_only" | "transient" }
757
+ | { disposition: "not_retryable" };
758
+
759
+ function modelRequestRetryDecision(
760
+ error: unknown,
761
+ state: {
762
+ reasoningOnlyRetries: number;
763
+ transientRetries: number;
764
+ transientRetryDelaysMs: readonly number[];
765
+ },
766
+ ): ModelRequestRetryDecision {
767
+ if (isReasoningOnlyProviderError(error)) {
768
+ return state.reasoningOnlyRetries < REASONING_ONLY_RETRY_LIMIT
769
+ ? { disposition: "scheduled", kind: "reasoning_only", delayMs: 0 }
770
+ : { disposition: "exhausted", kind: "reasoning_only" };
771
+ }
772
+ if (isTransientProviderError(error)) {
773
+ const delayMs = state.transientRetryDelaysMs[state.transientRetries];
774
+ return delayMs === undefined
775
+ ? { disposition: "exhausted", kind: "transient" }
776
+ : { disposition: "scheduled", kind: "transient", delayMs };
777
+ }
778
+ return { disposition: "not_retryable" };
779
+ }
780
+
781
+ async function waitForRetryDelay(delayMs: number, signal: AbortSignal): Promise<void> {
782
+ if (delayMs <= 0 || signal.aborted) {
783
+ return;
784
+ }
785
+ await new Promise<void>((resolve) => {
786
+ const timer = setTimeout(cleanup, delayMs);
787
+ signal.addEventListener("abort", cleanup, { once: true });
788
+ function cleanup() {
789
+ clearTimeout(timer);
790
+ signal.removeEventListener("abort", cleanup);
791
+ resolve();
792
+ }
793
+ });
794
+ }
795
+
704
796
  function modelRequestFailureData(input: {
705
797
  error: unknown;
706
798
  request: PreparedModelRequest;
707
- attemptNumber: 1 | 2;
799
+ attemptNumber: number;
708
800
  retryDisposition: "scheduled" | "not_retryable" | "exhausted";
801
+ retryDelayMs?: number;
709
802
  }) {
710
803
  const providerError =
711
804
  input.error instanceof ProviderResponseError ? input.error : undefined;
@@ -714,6 +807,7 @@ function modelRequestFailureData(input: {
714
807
  maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
715
808
  code: providerError?.code ?? ("provider_request_error" as const),
716
809
  retryDisposition: input.retryDisposition,
810
+ ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }),
717
811
  provider: input.request.provider,
718
812
  model: input.request.model,
719
813
  error: errorMessage(input.error),