tinker-agent 2.10.0 → 2.11.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,33 @@ All notable user-facing changes to Tinker are documented here. The project follo
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [2.11.0] - 2026-09-08
9
+
10
+ ### Added
11
+
12
+ - Offer directories in the TUI `@` file-mention popup. Ancestor directories of
13
+ workspace files now appear alongside files, ranked after matching files, and
14
+ selecting a directory inserts its path directly instead of attempting an
15
+ image import.
16
+ - Offer a retry selection in the TUI after provider retries are exhausted, so a
17
+ failed model request can be retried without re-entering the prompt.
18
+
19
+ ### Changed
20
+
21
+ - Cache ESLint results for `check:fast` and split the fast test tier into
22
+ twelve independent shards, shortening local iteration while keeping the
23
+ serial full test run as the final gate.
24
+
25
+ ### Fixed
26
+
27
+ - Align `Grep` context-line precedence and remove an unsupported regex hint
28
+ from its documentation.
29
+ - Warm up the fake ripgrep binary in tests to absorb the macOS first-execution
30
+ validation delay.
31
+ - Cancel pending image file opens without leaking file handles.
32
+ - Emphasize TUI timeline labels and add spacing between messages.
33
+ - Finalize scoped SQLite queries before closing the session database.
34
+
8
35
  ## [2.10.0] - 2026-09-06
9
36
 
10
37
  ### Added
@@ -437,7 +464,8 @@ All notable user-facing changes to Tinker are documented here. The project follo
437
464
  - First formal npm release under the `tinker-agent` package name with the `tinker`
438
465
  executable.
439
466
 
440
- [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.10.0...HEAD
467
+ [Unreleased]: https://github.com/ishowshao/tinker/compare/v2.11.0...HEAD
468
+ [2.11.0]: https://github.com/ishowshao/tinker/releases/tag/v2.11.0
441
469
  [2.10.0]: https://github.com/ishowshao/tinker/releases/tag/v2.10.0
442
470
  [2.9.0]: https://github.com/ishowshao/tinker/releases/tag/v2.9.0
443
471
  [2.8.0]: https://github.com/ishowshao/tinker/releases/tag/v2.8.0
package/README.md CHANGED
@@ -34,6 +34,19 @@ This does not pretend that any model has infinite tokens or guarantee that it wi
34
34
  explicit model and context limits. Actual provider support must be established
35
35
  by a qualification matrix; transport compatibility alone is not a guarantee.
36
36
 
37
+ ### Provider retry selection
38
+
39
+ When automatic retries for a provider request are exhausted, the TUI offers
40
+ **Retry again** or **End this turn**. Retrying reuses the same request within the
41
+ same turn and iteration, with a fresh automatic retry allowance. Ending the turn
42
+ (or pressing Esc in the selection) records the original failure. The selection
43
+ adds no conversation message and applies only to the current running process.
44
+ Retryable failures include HTTP 429/500/502/503/504, structured `server_error`
45
+ and `rate_limit_exceeded` errors inside provider streams or failed Responses,
46
+ and transient connection failures, including socket resets during streaming.
47
+ Tool failures and non-retryable errors keep their existing behavior. One-shot
48
+ and remote service runs do not wait for this TUI selection.
49
+
37
50
  ### PTY screen size
38
51
 
39
52
  `Bash` accepts optional `cols` and `rows` for the initial PTY screen size:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tinker-agent",
3
- "version": "2.10.0",
3
+ "version": "2.11.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",
@@ -66,14 +66,15 @@
66
66
  "docs:check": "bun scripts/render-public-contract-docs.ts --check",
67
67
  "tinker": "bun src/cli/index.ts",
68
68
  "check": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint && bun run docs:check && bun run test && bun run bench:smoke",
69
- "check:fast": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint && bun run test:fast",
69
+ "check:fast": "bun run check:source-lines && bun run typecheck && bun run format:check && bun run lint:fast && bun run test:fast",
70
70
  "check:source-lines": "bun scripts/check-source-lines.ts",
71
71
  "format": "biome format --write .",
72
72
  "format:check": "biome format .",
73
73
  "lint": "eslint \"bin/**/*.js\" \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" \"packages/**/*.ts\" --max-warnings=0",
74
+ "lint:fast": "bun run lint --cache --cache-strategy content --cache-location .cache/eslint-fast/",
74
75
  "lint:fix": "eslint \"bin/**/*.js\" \"src/**/*.{ts,tsx}\" \"scripts/**/*.ts\" \"packages/**/*.ts\" --fix",
75
76
  "test": "FORCE_COLOR=0 bun test",
76
- "test:fast": "FORCE_COLOR=0 bun test --path-ignore-patterns='**/*pty*.test.ts' --path-ignore-patterns='**/native-host-integration.test.ts'",
77
+ "test:fast": "bun scripts/run-test-shards.ts --path-ignore-patterns='**/*pty*.test.ts' --path-ignore-patterns='**/native-host-integration.test.ts'",
77
78
  "test:e2e": "FORCE_COLOR=0 bun test src/__tests__/cli-pty src/__tests__/pty-tui-harness packages/tinker-chrome/__tests__/native-host-integration",
78
79
  "typecheck": "tsc --noEmit"
79
80
  },
package/src/agent/loop.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import type { ModelRequestFailedData } from "../events/types";
2
+ import type { ProviderRetryDecision } from "./runtime-provider-retry";
1
3
  import {
2
4
  materializeModelRequest,
3
5
  type MaterializedModelRequest,
@@ -74,6 +76,11 @@ export type RunAgentInput = {
74
76
  usage: ContextUsageSnapshot;
75
77
  };
76
78
  transientRetryDelaysMs?: readonly number[];
79
+ requestProviderRetry?: (
80
+ iteration: IterationIdentity,
81
+ failure: ModelRequestFailedData,
82
+ signal: AbortSignal,
83
+ ) => Promise<ProviderRetryDecision>;
77
84
  };
78
85
 
79
86
  export class FatalAgentTurnError extends Error {
@@ -196,12 +203,13 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
196
203
  return failedResult(error, iteration);
197
204
  }
198
205
 
206
+ let maxAttempts = MODEL_REQUEST_MAX_ATTEMPTS;
199
207
  await input.runtimeSession.append({
200
208
  type: "model.request.started",
201
209
  ...iteration,
202
210
  data: {
203
211
  attemptNumber: 1,
204
- maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
212
+ maxAttempts,
205
213
  },
206
214
  });
207
215
 
@@ -240,7 +248,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
240
248
  ...iteration,
241
249
  data: {
242
250
  attemptNumber,
243
- maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
251
+ maxAttempts,
244
252
  },
245
253
  });
246
254
  }
@@ -289,19 +297,47 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
289
297
  transientRetries,
290
298
  transientRetryDelaysMs,
291
299
  });
300
+ const failure = modelRequestFailureData({
301
+ error,
302
+ request,
303
+ attemptNumber,
304
+ maxAttempts,
305
+ retryDisposition: decision.disposition,
306
+ ...(decision.disposition === "scheduled" && decision.delayMs > 0
307
+ ? { retryDelayMs: decision.delayMs }
308
+ : {}),
309
+ });
292
310
  await input.runtimeSession.append({
293
311
  type: "model.request.failed",
294
312
  ...iteration,
295
- data: modelRequestFailureData({
296
- error,
297
- request,
298
- attemptNumber,
299
- retryDisposition: decision.disposition,
300
- ...(decision.disposition === "scheduled" && decision.delayMs > 0
301
- ? { retryDelayMs: decision.delayMs }
302
- : {}),
303
- }),
313
+ data: failure,
304
314
  });
315
+ if (decision.disposition === "exhausted" && input.requestProviderRetry) {
316
+ let retry: ProviderRetryDecision;
317
+ try {
318
+ retry = await input.requestProviderRetry(iteration, failure, input.signal);
319
+ } catch (retryError) {
320
+ if (input.signal.aborted) {
321
+ return cancelledResult(
322
+ cancellation(input.signal, iteration, "model_request"),
323
+ iteration,
324
+ );
325
+ }
326
+ throw retryError;
327
+ }
328
+ if (input.signal.aborted) {
329
+ return cancelledResult(
330
+ cancellation(input.signal, iteration, "model_request"),
331
+ iteration,
332
+ );
333
+ }
334
+ if (retry === "retry") {
335
+ reasoningOnlyRetries = 0;
336
+ transientRetries = 0;
337
+ maxAttempts = attemptNumber + MODEL_REQUEST_MAX_ATTEMPTS;
338
+ continue;
339
+ }
340
+ }
305
341
 
306
342
  if (decision.disposition === "scheduled") {
307
343
  if (decision.kind === "reasoning_only") {
@@ -349,7 +385,7 @@ export async function runAgent(input: RunAgentInput): Promise<RunAgentResult> {
349
385
  ...iteration,
350
386
  data: {
351
387
  attemptNumber: successfulAttempt,
352
- maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
388
+ maxAttempts,
353
389
  output: modelOutput,
354
390
  },
355
391
  });
@@ -823,6 +859,7 @@ function modelRequestFailureData(input: {
823
859
  error: unknown;
824
860
  request: PreparedModelRequest;
825
861
  attemptNumber: number;
862
+ maxAttempts: number;
826
863
  retryDisposition: "scheduled" | "not_retryable" | "exhausted";
827
864
  retryDelayMs?: number;
828
865
  }) {
@@ -830,7 +867,7 @@ function modelRequestFailureData(input: {
830
867
  input.error instanceof ProviderResponseError ? input.error : undefined;
831
868
  return {
832
869
  attemptNumber: input.attemptNumber,
833
- maxAttempts: MODEL_REQUEST_MAX_ATTEMPTS,
870
+ maxAttempts: input.maxAttempts,
834
871
  code: providerError?.code ?? ("provider_request_error" as const),
835
872
  retryDisposition: input.retryDisposition,
836
873
  ...(input.retryDelayMs === undefined ? {} : { retryDelayMs: input.retryDelayMs }),
@@ -0,0 +1,115 @@
1
+ import type { AgentEventInput, ModelRequestFailedData } from "../events/types";
2
+ import type { IterationIdentity } from "./types";
3
+ import { cancellationError } from "./turn-cancellation";
4
+
5
+ export type ProviderRetryDecision = "retry" | "stop";
6
+ export type ProviderRetryRequest = {
7
+ readonly requestId: string;
8
+ readonly failure: ModelRequestFailedData;
9
+ };
10
+ export type ProviderRetrySnapshot = { readonly pending?: ProviderRetryRequest };
11
+ export const EMPTY_PROVIDER_RETRY: ProviderRetrySnapshot = Object.freeze({});
12
+
13
+ type PendingRetry = {
14
+ request: ProviderRetryRequest;
15
+ iteration: IterationIdentity;
16
+ startedAt: number;
17
+ signal: AbortSignal;
18
+ resolve: (decision: ProviderRetryDecision) => void;
19
+ reject: (error: unknown) => void;
20
+ removeAbortListener: () => void;
21
+ };
22
+
23
+ /** Process-local interaction; the original turn and model request remain open. */
24
+ export class RuntimeProviderRetry {
25
+ private snapshot: ProviderRetrySnapshot = EMPTY_PROVIDER_RETRY;
26
+ private pending?: PendingRetry;
27
+ private readonly listeners = new Set<() => void>();
28
+
29
+ constructor(private readonly append: (event: AgentEventInput) => Promise<void>) {}
30
+
31
+ read(): ProviderRetrySnapshot {
32
+ return this.snapshot;
33
+ }
34
+
35
+ subscribe(listener: () => void): () => void {
36
+ this.listeners.add(listener);
37
+ return () => this.listeners.delete(listener);
38
+ }
39
+
40
+ async request(
41
+ iteration: IterationIdentity,
42
+ failure: ModelRequestFailedData,
43
+ signal: AbortSignal,
44
+ ): Promise<ProviderRetryDecision> {
45
+ if (this.pending !== undefined) throw new Error("Provider retry already pending.");
46
+ if (signal.aborted) throw cancellationError(signal);
47
+ await this.append({ type: "model.retry.requested", ...iteration, data: failure });
48
+ return new Promise((resolve, reject) => {
49
+ const request = Object.freeze({
50
+ requestId: `${iteration.iterationId}:${failure.attemptNumber}`,
51
+ failure: Object.freeze({ ...failure }),
52
+ });
53
+ const pending: PendingRetry = {
54
+ request,
55
+ iteration,
56
+ startedAt: Date.now(),
57
+ signal,
58
+ resolve,
59
+ reject,
60
+ removeAbortListener: () => signal.removeEventListener("abort", onAbort),
61
+ };
62
+ const onAbort = () => {
63
+ // settle rejects the waiting loop too; do not leave a detached rejection.
64
+ void this.settle(pending, "cancelled").catch(() => undefined);
65
+ };
66
+ this.pending = pending;
67
+ this.snapshot = Object.freeze({ pending: request });
68
+ signal.addEventListener("abort", onAbort, { once: true });
69
+ if (signal.aborted) onAbort();
70
+ else this.notify();
71
+ });
72
+ }
73
+
74
+ async resolve(requestId: string, decision: ProviderRetryDecision): Promise<void> {
75
+ const pending = this.pending;
76
+ if (pending === undefined || pending.request.requestId !== requestId) {
77
+ throw new Error("Provider retry question is no longer pending.");
78
+ }
79
+ if (decision !== "retry" && decision !== "stop") {
80
+ throw new Error("Invalid provider retry decision.");
81
+ }
82
+ await this.settle(pending, decision);
83
+ }
84
+
85
+ private async settle(
86
+ pending: PendingRetry,
87
+ decision: ProviderRetryDecision | "cancelled",
88
+ ): Promise<void> {
89
+ if (this.pending !== pending) return;
90
+ this.pending = undefined;
91
+ this.snapshot = EMPTY_PROVIDER_RETRY;
92
+ pending.removeAbortListener();
93
+ this.notify();
94
+ try {
95
+ await this.append({
96
+ type: "model.retry.resolved",
97
+ ...pending.iteration,
98
+ data: {
99
+ attemptNumber: pending.request.failure.attemptNumber,
100
+ decision,
101
+ durationMs: Date.now() - pending.startedAt,
102
+ },
103
+ });
104
+ if (decision === "cancelled") pending.reject(cancellationError(pending.signal));
105
+ else pending.resolve(decision);
106
+ } catch (error) {
107
+ pending.reject(error);
108
+ throw error;
109
+ }
110
+ }
111
+
112
+ private notify(): void {
113
+ for (const listener of this.listeners) listener();
114
+ }
115
+ }
@@ -1,3 +1,7 @@
1
+ import type {
2
+ ProviderRetryDecision,
3
+ ProviderRetrySnapshot,
4
+ } from "./runtime-provider-retry";
1
5
  import type { PublicToolingConfig } from "../cli/public-config-contract";
2
6
  import type { ContextAutomationPolicy } from "../context/context-automation-policy";
3
7
  import type {
@@ -118,6 +122,12 @@ export type RuntimeSession = {
118
122
  subscribeBashGuard(listener: () => void): () => void;
119
123
  setYoloMode(enabled: boolean): void;
120
124
  resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
125
+ providerRetry(): ProviderRetrySnapshot;
126
+ subscribeProviderRetry(listener: () => void): () => void;
127
+ resolveProviderRetry(
128
+ requestId: string,
129
+ decision: ProviderRetryDecision,
130
+ ): Promise<void>;
121
131
  askUser(): AskUserSnapshot;
122
132
  subscribeAskUser(listener: () => void): () => void;
123
133
  resolveAskUser(response: AskUserResolution): Promise<void>;
@@ -251,6 +261,7 @@ export type CommonRuntimeSessionInput = {
251
261
  completedTurnHook?: CompletedTurnHook;
252
262
  enableTurnUndo?: boolean;
253
263
  enableAskUser?: boolean;
264
+ enableProviderRetryPrompt?: boolean;
254
265
  bashGuard?: {
255
266
  readonly mode: "guard" | "yolo";
256
267
  readonly source: Exclude<BashGuardSource, "session">;
@@ -1,3 +1,7 @@
1
+ import {
2
+ RuntimeProviderRetry,
3
+ type ProviderRetryDecision,
4
+ } from "./runtime-provider-retry";
1
5
  import path from "node:path";
2
6
  import { assertContextMaintenanceCapabilities } from "./runtime-context-capabilities";
3
7
  import { CompiledContextError } from "../context/compiled-context-validator";
@@ -221,6 +225,7 @@ class DefaultRuntimeSession implements RuntimeSession {
221
225
 
222
226
  private readonly skillCatalog: SkillCatalogSnapshot;
223
227
  private readonly interactions: RuntimeInteractions;
228
+ private readonly providerRetryInteraction: RuntimeProviderRetry;
224
229
  private readonly scheduler: RuntimePromptScheduler;
225
230
  private readonly contextMaintenance: RuntimeContextMaintenance;
226
231
  private readonly runtimeSkills: RuntimeSkills;
@@ -234,6 +239,9 @@ class DefaultRuntimeSession implements RuntimeSession {
234
239
  private readonly store: SessionStore,
235
240
  private readonly assetStore: ImageAssetStore,
236
241
  ) {
242
+ this.providerRetryInteraction = new RuntimeProviderRetry((event) =>
243
+ this.append(event),
244
+ );
237
245
  this.sessionId = input.selection.sessionId;
238
246
  this.resumed = input.selection.mode === "resume";
239
247
  this.scheduler = new RuntimePromptScheduler(
@@ -694,6 +702,21 @@ class DefaultRuntimeSession implements RuntimeSession {
694
702
  return this.interactions.resolveBashConfirmation(decision);
695
703
  }
696
704
 
705
+ providerRetry() {
706
+ return this.providerRetryInteraction.read();
707
+ }
708
+
709
+ subscribeProviderRetry(listener: () => void): () => void {
710
+ return this.providerRetryInteraction.subscribe(listener);
711
+ }
712
+
713
+ resolveProviderRetry(
714
+ requestId: string,
715
+ decision: ProviderRetryDecision,
716
+ ): Promise<void> {
717
+ return this.providerRetryInteraction.resolve(requestId, decision);
718
+ }
719
+
697
720
  askUser(): AskUserSnapshot {
698
721
  return this.interactions.askUser();
699
722
  }
@@ -1184,6 +1207,12 @@ class DefaultRuntimeSession implements RuntimeSession {
1184
1207
  signal,
1185
1208
  assetStore: this.assetStore,
1186
1209
  initialRequest,
1210
+ ...(this.input.enableProviderRetryPrompt === true
1211
+ ? {
1212
+ requestProviderRetry: (iteration, failure, signal) =>
1213
+ this.providerRetryInteraction.request(iteration, failure, signal),
1214
+ }
1215
+ : {}),
1187
1216
  });
1188
1217
  } catch (error) {
1189
1218
  if (error instanceof RuntimeEventAppendError) {
@@ -119,6 +119,7 @@ export async function runTui(options: RunTuiOptions): Promise<void> {
119
119
  toolingConfig: options.publicConfig.tooling,
120
120
  enableTurnUndo: true,
121
121
  enableAskUser: true,
122
+ enableProviderRetryPrompt: true,
122
123
  bashGuard: {
123
124
  mode: sessionConfig.bashGuardMode,
124
125
  source: sessionConfig.bashGuardSource,
@@ -337,6 +337,12 @@ export type AgentEventDataMap = {
337
337
  "agent.iteration.started": { iterationNumber: number };
338
338
  "model.request.started": ModelRequestAttemptData;
339
339
  "model.request.failed": ModelRequestFailedData;
340
+ "model.retry.requested": ModelRequestFailedData;
341
+ "model.retry.resolved": {
342
+ attemptNumber: number;
343
+ decision: "retry" | "stop" | "cancelled";
344
+ durationMs: number;
345
+ };
340
346
  "model.request.finished": ModelRequestAttemptData & {
341
347
  output: ModelRequestOutput;
342
348
  };
@@ -457,6 +463,8 @@ export type AgentEventInput =
457
463
  | "agent.iteration.started"
458
464
  | "model.request.started"
459
465
  | "model.request.failed"
466
+ | "model.retry.requested"
467
+ | "model.retry.resolved"
460
468
  | "model.request.finished"
461
469
  | "context.usage.updated"
462
470
  | "context.shadow.planned"
@@ -0,0 +1,54 @@
1
+ import type { FileHandle } from "node:fs/promises";
2
+
3
+ // An OS permission prompt can leave open() pending without accepting a signal.
4
+ // Cancellation releases the caller; ownership of a late handle stays here.
5
+ export function abortableFileOpen(
6
+ openFile: () => Promise<FileHandle>,
7
+ signal?: AbortSignal,
8
+ onWarning?: (message: string) => void,
9
+ ): Promise<FileHandle> {
10
+ signal?.throwIfAborted();
11
+ if (signal === undefined) return openFile();
12
+
13
+ return new Promise<FileHandle>((resolve, reject) => {
14
+ let cancelled = false;
15
+ const onAbort = () => {
16
+ cancelled = true;
17
+ signal.removeEventListener("abort", onAbort);
18
+ reject(asError(signal.reason));
19
+ };
20
+ signal.addEventListener("abort", onAbort, { once: true });
21
+ // Also handle a synchronous failure from the opener without leaking a listener.
22
+ Promise.resolve()
23
+ .then(() => {
24
+ signal.throwIfAborted();
25
+ return openFile();
26
+ })
27
+ .then(async (handle) => {
28
+ signal.removeEventListener("abort", onAbort);
29
+ if (cancelled) {
30
+ try {
31
+ await handle.close();
32
+ } catch (error) {
33
+ onWarning?.(
34
+ `Failed to close cancelled image file: ${error instanceof Error ? error.message : String(error)}.`,
35
+ );
36
+ }
37
+ } else {
38
+ resolve(handle);
39
+ }
40
+ })
41
+ .catch((error: unknown) => {
42
+ signal.removeEventListener("abort", onAbort);
43
+ if (cancelled) {
44
+ // Late open failures must not become unhandled rejections.
45
+ return;
46
+ }
47
+ reject(asError(error));
48
+ });
49
+ });
50
+ }
51
+
52
+ function asError(error: unknown): Error {
53
+ return error instanceof Error ? error : new Error(String(error), { cause: error });
54
+ }
@@ -13,6 +13,7 @@ import {
13
13
  import path from "node:path";
14
14
  import { createUuidV7 } from "../ids/uuid-v7";
15
15
  import { IMAGE_INPUT_POLICY } from "./image-input-policy";
16
+ import { abortableFileOpen } from "./abortable-file-open";
16
17
  import { probeImageBytes } from "./image-probe";
17
18
  import {
18
19
  normalizeOriginalImageName,
@@ -116,9 +117,14 @@ export class ImageAssetStore {
116
117
  assertContained(this.workspaceRoot, canonicalSource, "Image source realpath");
117
118
  }
118
119
 
119
- const handle = await open(canonicalSource, constants.O_RDONLY | noFollowFlag());
120
+ const handle = await abortableFileOpen(
121
+ () => open(canonicalSource, constants.O_RDONLY | noFollowFlag()),
122
+ options.signal,
123
+ this.onWarning,
124
+ );
120
125
  let bytes: Buffer;
121
126
  try {
127
+ throwIfAborted(options.signal);
122
128
  const handleStat = await handle.stat();
123
129
  if (
124
130
  !handleStat.isFile() ||
@@ -25,7 +25,7 @@ import type {
25
25
  PreparedModelRequest,
26
26
  PreparedPromptSegment,
27
27
  } from "./model-client";
28
- import { validateModelModalities } from "./model-client";
28
+ import { ProviderResponseError, validateModelModalities } from "./model-client";
29
29
  import { sha256, stableJsonStringify } from "./model-request-preflight";
30
30
  import { estimatePromptSegments } from "./token-estimator";
31
31
 
@@ -212,6 +212,25 @@ export class FakeModelClient implements ModelClient {
212
212
  );
213
213
  }
214
214
 
215
+ if (this.mode === "pty-provider-retry") {
216
+ if (lastUserMessage(input.messages) === "PTY_RETRY_NEXT") {
217
+ return textOutput(prepared, "PTY_RETRY_NEXT_DONE");
218
+ }
219
+ if (this.steps <= 4) {
220
+ options.onTextDelta?.(
221
+ `## Attempt ${this.steps}\nPartial response\n\n## Unfinished\nDRAFT_ONLY`,
222
+ );
223
+ throw new ProviderResponseError(
224
+ "reasoning_only_assistant",
225
+ "PTY_PROVIDER_FAILURE",
226
+ {
227
+ provider: prepared.provider,
228
+ model: prepared.model,
229
+ },
230
+ );
231
+ }
232
+ return textOutput(prepared, "PTY_RETRY_DONE");
233
+ }
215
234
  if (this.mode === "write-notes") {
216
235
  return this.writeNotes(input, prepared, options);
217
236
  }
@@ -358,12 +358,56 @@ function providerErrorCode(error: unknown): ProviderResponseErrorCode {
358
358
  if (status === 500 || status === 502 || status === 503 || status === 504) {
359
359
  return "provider_unavailable";
360
360
  }
361
- if (status === undefined && error instanceof OpenAI.APIConnectionError) {
361
+ // Explicit HTTP failures take precedence over payload or transport hints.
362
+ if (status !== undefined) return "provider_request_error";
363
+ const code = errorField(error, "code");
364
+ if (code === "server_error") return "provider_unavailable";
365
+ if (code === "rate_limit_exceeded") return "provider_rate_limited";
366
+ if (error instanceof OpenAI.APIConnectionError || isTransportFailure(error)) {
362
367
  return "provider_unavailable";
363
368
  }
364
369
  return "provider_request_error";
365
370
  }
366
371
 
372
+ const TRANSIENT_TRANSPORT_CODES = new Set([
373
+ "ECONNRESET",
374
+ "EPIPE",
375
+ "ETIMEDOUT",
376
+ "ERR_STREAM_PREMATURE_CLOSE",
377
+ "UND_ERR_SOCKET",
378
+ "UND_ERR_CONNECT_TIMEOUT",
379
+ "UND_ERR_HEADERS_TIMEOUT",
380
+ "UND_ERR_BODY_TIMEOUT",
381
+ ]);
382
+
383
+ function isTransportFailure(error: unknown): boolean {
384
+ const seen = new Set<unknown>();
385
+ // Fetch may wrap a socket error in TypeError.cause after headers arrive.
386
+ for (let depth = 0; error !== undefined && depth < 8; depth += 1) {
387
+ if (seen.has(error)) return false;
388
+ seen.add(error);
389
+ if (
390
+ errorField(error, "name") === "AbortError" ||
391
+ error instanceof OpenAI.APIUserAbortError ||
392
+ providerErrorStatus(error) !== undefined
393
+ ) {
394
+ return false;
395
+ }
396
+ const code = errorField(error, "code");
397
+ if (code !== undefined) {
398
+ return typeof code === "string" && TRANSIENT_TRANSPORT_CODES.has(code);
399
+ }
400
+ error = errorField(error, "cause");
401
+ }
402
+ return false;
403
+ }
404
+
405
+ function errorField(error: unknown, key: string): unknown {
406
+ return typeof error === "object" && error !== null
407
+ ? (error as Record<string, unknown>)[key]
408
+ : undefined;
409
+ }
410
+
367
411
  function providerErrorStatus(error: unknown): number | undefined {
368
412
  if (typeof error !== "object" || error === null || !("status" in error)) {
369
413
  return undefined;
@@ -28,6 +28,7 @@ import {
28
28
  type ProviderResponseDiagnostics,
29
29
  } from "./model-client";
30
30
  import { imageAssetUrlMarker } from "./openai-image-mapping";
31
+ import { sanitizedProviderError } from "./openai-model-utils";
31
32
 
32
33
  export type OpenAIResponsesMappingOptions = {
33
34
  materializedImages?: ReadonlyMap<ImageAssetId, string>;
@@ -160,6 +161,16 @@ export function fromOpenAIResponse(
160
161
  ): ModelRequestOutput {
161
162
  const root = requireRecord(response, "response", options);
162
163
  const status = requireString(root.status, "status", options);
164
+ if (status === "failed") {
165
+ const error = requireRecord(root.error, "error", options);
166
+ const code = requireString(error.code, "error.code", options);
167
+ const message = requireString(error.message, "error.message", options);
168
+ throw sanitizedProviderError(
169
+ Object.assign(new Error(message), { code }),
170
+ options.provider,
171
+ options.model,
172
+ );
173
+ }
163
174
  if (status !== "completed" && status !== "incomplete") {
164
175
  throw providerResponseError(
165
176
  options,
@@ -2,6 +2,7 @@ import {
2
2
  ProviderResponseError,
3
3
  type ProviderResponseDiagnostics,
4
4
  } from "./model-client";
5
+ import { sanitizedProviderError } from "./openai-model-utils";
5
6
 
6
7
  export class OpenAIResponsesStreamAccumulator {
7
8
  private eventCount = 0;
@@ -20,6 +21,19 @@ export class OpenAIResponsesStreamAccumulator {
20
21
  const type = requireString(record.type, `${path}.type`, this.options);
21
22
  this.eventCount += 1;
22
23
 
24
+ if (type === "error") {
25
+ const message = requireString(record.message, `${path}.message`, this.options);
26
+ const code =
27
+ record.code === null
28
+ ? null
29
+ : requireString(record.code, `${path}.code`, this.options);
30
+ throw sanitizedProviderError(
31
+ Object.assign(new Error(message), { code }),
32
+ this.options.provider,
33
+ this.options.model,
34
+ );
35
+ }
36
+
23
37
  if (type === "response.output_text.delta") {
24
38
  return requireString(record.delta, `${path}.delta`, this.options);
25
39
  }
@@ -0,0 +1,27 @@
1
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
2
+
3
+ /**
4
+ * Own every query statement until a short-lived connection closes. Bun only
5
+ * caches its first 20 queries; later statements otherwise survive close() until
6
+ * GC, leaving a zombie SQLite connection behind. Do not use this for resident
7
+ * stores: retaining every query is intentionally bounded by the operation.
8
+ */
9
+ export class ScopedQueryDatabase extends Database {
10
+ private readonly queries = new Set<{ finalize(): void }>();
11
+
12
+ override query<Result, Params extends SQLQueryBindings | SQLQueryBindings[]>(
13
+ sql: string,
14
+ ) {
15
+ const statement = super.query<Result, Params>(sql);
16
+ this.queries.add(statement);
17
+ return statement;
18
+ }
19
+
20
+ override close(): void {
21
+ for (const statement of this.queries) statement.finalize();
22
+ this.queries.clear();
23
+ // Also finalizes Bun's transaction statements and rejects any other live
24
+ // resources instead of silently deferring the underlying connection close.
25
+ super.close(true);
26
+ }
27
+ }
@@ -1,4 +1,5 @@
1
- import { Database } from "bun:sqlite";
1
+ import type { Database } from "bun:sqlite";
2
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
3
  import { lstat, readdir, realpath } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { throwIfTurnCancelled } from "../agent/turn-cancellation";
@@ -67,7 +68,7 @@ export function createSessionHistoryAccess(input: {
67
68
  throwIfTurnCancelled(signal);
68
69
  await validateHistoryFiles(location.databasePath, sessionId);
69
70
  throwIfTurnCancelled(signal);
70
- const database = new Database(location.databasePath, {
71
+ const database = new ScopedQueryDatabase(location.databasePath, {
71
72
  readonly: true,
72
73
  strict: true,
73
74
  safeIntegers: true,
@@ -100,7 +101,7 @@ export function createSessionHistoryAccess(input: {
100
101
  throwIfTurnCancelled(signal);
101
102
  return result;
102
103
  } finally {
103
- database.close(true);
104
+ database.close();
104
105
  }
105
106
  } catch (error) {
106
107
  throwIfTurnCancelled(signal);
@@ -1,4 +1,5 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { ScopedQueryDatabase } from "./scoped-query-database";
2
3
  import { randomUUID } from "node:crypto";
3
4
  import { chmod, mkdir, open, readdir, rename, rmdir } from "node:fs/promises";
4
5
  import path from "node:path";
@@ -1327,7 +1328,7 @@ export class SessionStore implements SessionLedgerCommitter {
1327
1328
  await chmod(stagingDatabasePath, 0o600);
1328
1329
  input.faultInjector?.("after_snapshot");
1329
1330
 
1330
- stagingDatabase = openWritableDatabase(stagingDatabasePath);
1331
+ stagingDatabase = openWritableDatabase(stagingDatabasePath, ScopedQueryDatabase);
1331
1332
  verifySessionSchema(stagingDatabase, this.sessionId);
1332
1333
  dropSessionCloneTriggers(stagingDatabase);
1333
1334
  input.faultInjector?.("after_trigger_drop");
@@ -1542,8 +1543,11 @@ export async function resolveSessionDatabasePath(
1542
1543
  );
1543
1544
  }
1544
1545
 
1545
- function openWritableDatabase(databasePath: string): Database {
1546
- const database = new Database(databasePath, {
1546
+ function openWritableDatabase(
1547
+ databasePath: string,
1548
+ DatabaseType: typeof Database = Database,
1549
+ ): Database {
1550
+ const database = new DatabaseType(databasePath, {
1547
1551
  create: false,
1548
1552
  readwrite: true,
1549
1553
  strict: true,
package/src/tools/grep.ts CHANGED
@@ -72,7 +72,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
72
72
  path: {
73
73
  type: "string",
74
74
  description:
75
- "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely.",
75
+ "Optional workspace-relative or absolute file or directory to search in. Defaults to the current workspace-local cwd. For directories, ripgrep runs in that directory with . as its search path; explicitly selecting an excluded directory allows searching inside it. Files are passed as absolute paths: explicit files bypass ignore/glob/type filtering, and explicit symlink files are followed. Explicit binary files may yield matches but are not guaranteed to be searched completely, and reported line numbers may be inaccurate.",
76
76
  },
77
77
  glob: {
78
78
  type: "string",
@@ -202,7 +202,7 @@ export function createGrepToolExecutor(options: GrepToolOptions): ToolExecutor {
202
202
  absoluteSearchPath,
203
203
  mode,
204
204
  truncated: rg.truncated ? true : undefined,
205
- error: rg.error ?? "ripgrep failed.",
205
+ error: omitUnsupportedRegexHint(rg.error ?? "ripgrep failed."),
206
206
  });
207
207
  }
208
208
 
@@ -368,11 +368,19 @@ export function buildRipgrepArgs(
368
368
  function resolveGrepContext(input: GrepArgs) {
369
369
  const both = input.context ?? input.contextAlias;
370
370
  return {
371
- before: both ?? input.before ?? 0,
372
- after: both ?? input.after ?? 0,
371
+ before: input.before ?? both ?? 0,
372
+ after: input.after ?? both ?? 0,
373
373
  };
374
374
  }
375
375
 
376
+ function omitUnsupportedRegexHint(error: string): string {
377
+ // Grep does not expose rg's PCRE2 flag; preserve the diagnostic itself.
378
+ return error.replace(
379
+ /\n+Consider enabling PCRE2 with the --pcre2 flag, which can handle backreferences\s+and look-around\.\s*$/,
380
+ "",
381
+ );
382
+ }
383
+
376
384
  type ParsedGrepArgs =
377
385
  | { ok: true; value: GrepArgs }
378
386
  | { ok: false; error: string; pattern?: string; mode?: GrepOutputMode };
package/src/tui/app.tsx CHANGED
@@ -1,3 +1,4 @@
1
+ import { EMPTY_PROVIDER_RETRY } from "../agent/runtime-provider-retry";
1
2
  import { Box, Static, Text, useApp, useInput, useStdout, useWindowSize } from "ink";
2
3
  import {
3
4
  useCallback,
@@ -143,6 +144,12 @@ export function App(props: AppProps) {
143
144
  () => binding.bashGuard(),
144
145
  () => binding.bashGuard(),
145
146
  );
147
+ const providerRetry = useSyncExternalStore(
148
+ (listener) => binding.subscribeProviderRetry?.(listener) ?? (() => undefined),
149
+ () => binding.providerRetry?.() ?? EMPTY_PROVIDER_RETRY,
150
+ () => EMPTY_PROVIDER_RETRY,
151
+ );
152
+ const pendingProviderRetry = providerRetry.pending;
146
153
  const askUser = useSyncExternalStore(
147
154
  (listener) => binding.subscribeAskUser(listener),
148
155
  () => binding.askUser(),
@@ -282,7 +289,12 @@ export function App(props: AppProps) {
282
289
  setIsCancelling(true);
283
290
  setNotice("Cancelling current turn...");
284
291
  },
285
- { isActive: executionRunning && askUser.pending === undefined },
292
+ {
293
+ isActive:
294
+ executionRunning &&
295
+ askUser.pending === undefined &&
296
+ pendingProviderRetry === undefined,
297
+ },
286
298
  );
287
299
 
288
300
  const closeResumePicker = () => {
@@ -912,7 +924,8 @@ export function App(props: AppProps) {
912
924
  status={
913
925
  isCancelling
914
926
  ? "cancelling"
915
- : askUser.pending !== undefined
927
+ : askUser.pending !== undefined ||
928
+ pendingProviderRetry !== undefined
916
929
  ? "waiting_for_answer"
917
930
  : executionRunning
918
931
  ? "running"
@@ -924,7 +937,35 @@ export function App(props: AppProps) {
924
937
  />
925
938
  </Box>
926
939
  <Box marginTop={1} flexDirection="column" flexShrink={0}>
927
- {askUser.pending !== undefined ? (
940
+ {pendingProviderRetry !== undefined ? (
941
+ <AskUser
942
+ key={pendingProviderRetry.requestId}
943
+ title="Provider request failed"
944
+ question={`Automatic retries exhausted. ${pendingProviderRetry.failure.error.slice(0, 500)}`}
945
+ options={[
946
+ { description: "Retry again" },
947
+ { description: "End this turn" },
948
+ ]}
949
+ dismissLabel="end this turn"
950
+ onSelect={(index) => {
951
+ void binding
952
+ .resolveProviderRetry?.(
953
+ pendingProviderRetry.requestId,
954
+ index === 0 ? "retry" : "stop",
955
+ )
956
+ .catch((error: unknown) =>
957
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
958
+ );
959
+ }}
960
+ onDismiss={() => {
961
+ void binding
962
+ .resolveProviderRetry?.(pendingProviderRetry.requestId, "stop")
963
+ .catch((error: unknown) =>
964
+ setNotice(`Retry selection failed: ${errorMessage(error)}`),
965
+ );
966
+ }}
967
+ />
968
+ ) : askUser.pending !== undefined ? (
928
969
  <AskUser
929
970
  question={askUser.pending.question}
930
971
  options={askUser.pending.options}
@@ -977,6 +1018,7 @@ export function App(props: AppProps) {
977
1018
  isCopying ||
978
1019
  isCancelling ||
979
1020
  askUser.pending !== undefined ||
1021
+ pendingProviderRetry !== undefined ||
980
1022
  bashGuard.pending !== undefined
981
1023
  }
982
1024
  history={props.history}
@@ -1,8 +1,10 @@
1
1
  import { Box, Text, useInput } from "ink";
2
- import { useState } from "react";
2
+ import { useRef, useState } from "react";
3
3
 
4
4
  export type AskUserProps = {
5
5
  question: string;
6
+ title?: string;
7
+ dismissLabel?: string;
6
8
  options: readonly { readonly description: string }[];
7
9
  onSelect(selectedIndex: number): void;
8
10
  onDismiss(): void;
@@ -10,6 +12,12 @@ export type AskUserProps = {
10
12
 
11
13
  export function AskUser(props: AskUserProps) {
12
14
  const [selectedIndex, setSelectedIndex] = useState(0);
15
+ const selection = useRef(0);
16
+ const moveSelection = (offset: number) => {
17
+ selection.current =
18
+ (selection.current + offset + props.options.length) % props.options.length;
19
+ setSelectedIndex(selection.current);
20
+ };
13
21
 
14
22
  useInput((input, key) => {
15
23
  if (key.escape) {
@@ -17,17 +25,15 @@ export function AskUser(props: AskUserProps) {
17
25
  return;
18
26
  }
19
27
  if (key.upArrow) {
20
- setSelectedIndex((current) =>
21
- current === 0 ? props.options.length - 1 : current - 1,
22
- );
28
+ moveSelection(-1);
23
29
  return;
24
30
  }
25
31
  if (key.downArrow) {
26
- setSelectedIndex((current) => (current + 1) % props.options.length);
32
+ moveSelection(1);
27
33
  return;
28
34
  }
29
35
  if (key.return) {
30
- props.onSelect(selectedIndex);
36
+ props.onSelect(selection.current);
31
37
  return;
32
38
  }
33
39
  if (/^[1-6]$/.test(input)) {
@@ -41,7 +47,7 @@ export function AskUser(props: AskUserProps) {
41
47
  return (
42
48
  <Box flexDirection="column" borderStyle="round" borderColor="cyan" paddingX={1}>
43
49
  <Text color="cyan" bold>
44
- Tinker asks
50
+ {props.title ?? "Tinker asks"}
45
51
  </Text>
46
52
  <Text>{props.question}</Text>
47
53
  <Box flexDirection="column" marginTop={1}>
@@ -54,7 +60,8 @@ export function AskUser(props: AskUserProps) {
54
60
  ))}
55
61
  </Box>
56
62
  <Text dimColor>
57
- ↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc skip
63
+ ↑/↓ select · 1-{props.options.length} choose · Enter confirm · Esc{" "}
64
+ {props.dismissLabel ?? "skip"}
58
65
  </Text>
59
66
  </Box>
60
67
  );
@@ -41,7 +41,11 @@ import {
41
41
  type PromptDraft,
42
42
  } from "../prompt-draft";
43
43
  import { matchSlashCommands, type SlashCommand } from "../slash-commands";
44
- import { listWorkspaceFiles, type WorkspaceFileLister } from "../workspace-file-search";
44
+ import {
45
+ listWorkspaceFiles,
46
+ listWorkspaceFilesAndDirectories,
47
+ type WorkspaceFileLister,
48
+ } from "../workspace-file-search";
45
49
 
46
50
  export type PromptSubmission = {
47
51
  readonly draft: PromptDraft;
@@ -183,7 +187,10 @@ export function PromptInput(props: PromptInputProps) {
183
187
  () =>
184
188
  fileQuery === undefined || fileCatalog.status !== "ready"
185
189
  ? []
186
- : rankWorkspaceFiles(fileCatalog.files, fileQuery),
190
+ : rankWorkspaceFiles(
191
+ listWorkspaceFilesAndDirectories(fileCatalog.files),
192
+ fileQuery,
193
+ ),
187
194
  [fileCatalog, fileQuery],
188
195
  );
189
196
  const suggestions =
@@ -223,13 +230,14 @@ export function PromptInput(props: PromptInputProps) {
223
230
  });
224
231
  };
225
232
 
226
- const selectFile = (filePath: string) => {
233
+ const selectFile = (match: FileMentionMatch) => {
234
+ const filePath = match.path;
227
235
  const mention = findFileMention(state.draft.editor);
228
236
  if (mention === undefined || locked) {
229
237
  return;
230
238
  }
231
239
  const importImage = props.importImage;
232
- if (importImage === undefined) {
240
+ if (importImage === undefined || match.kind === "directory") {
233
241
  insertFilePath(filePath);
234
242
  return;
235
243
  }
@@ -616,7 +624,7 @@ export function PromptInput(props: PromptInputProps) {
616
624
  const selectedCommand = suggestions[selectedIndex];
617
625
  if (key.return) {
618
626
  if (filePopupActive && selectedFile !== undefined) {
619
- selectFile(selectedFile.path);
627
+ selectFile(selectedFile);
620
628
  } else if (selectedCommand !== undefined) {
621
629
  submitDraft(createPromptDraft(`/${selectedCommand.name}`));
622
630
  } else {
@@ -629,7 +637,7 @@ export function PromptInput(props: PromptInputProps) {
629
637
  if (selectedFile === undefined) {
630
638
  setState((current) => ({ ...current, suggestionsDismissed: true }));
631
639
  } else {
632
- selectFile(selectedFile.path);
640
+ selectFile(selectedFile);
633
641
  }
634
642
  } else if (selectedCommand !== undefined) {
635
643
  setState(createPromptInputState(`/${selectedCommand.name} `));
@@ -26,16 +26,16 @@ export function TimelineRow(props: { item: TimelineItem }) {
26
26
  if (item.label !== undefined) {
27
27
  if (item.label === "assistant") {
28
28
  return (
29
- <Fragment>
30
- <Text color="gray">- {item.label}</Text>
29
+ <Box flexDirection="column" marginTop={1}>
30
+ <TimelineLabel label={item.label} />
31
31
  <AssistantMarkdown text={item.text} />
32
- </Fragment>
32
+ </Box>
33
33
  );
34
34
  }
35
35
 
36
36
  return (
37
- <Fragment>
38
- <Text color="gray">- {item.label}</Text>
37
+ <Box flexDirection="column" marginY={1}>
38
+ <TimelineLabel label={item.label} />
39
39
  {item.userPrompt === undefined ? (
40
40
  <Text color={colorForStatus(item.status)}>{formatTimelineItem(item)}</Text>
41
41
  ) : (
@@ -44,7 +44,7 @@ export function TimelineRow(props: { item: TimelineItem }) {
44
44
  {renderItemBash(item)}
45
45
  {renderItemDiff(item)}
46
46
  {renderItemPlan(item)}
47
- </Fragment>
47
+ </Box>
48
48
  );
49
49
  }
50
50
 
@@ -60,10 +60,18 @@ export function TimelineRow(props: { item: TimelineItem }) {
60
60
 
61
61
  export function AssistantStreamSectionRow(props: { item: AssistantStreamSectionItem }) {
62
62
  return (
63
- <Fragment>
64
- {props.item.showAssistantLabel ? <Text color="gray">- assistant</Text> : null}
63
+ <Box flexDirection="column" marginTop={props.item.showAssistantLabel ? 1 : 0}>
64
+ {props.item.showAssistantLabel ? <TimelineLabel label="assistant" /> : null}
65
65
  <AssistantMarkdown text={props.item.markdown} />
66
- </Fragment>
66
+ </Box>
67
+ );
68
+ }
69
+
70
+ function TimelineLabel(props: { label: string }) {
71
+ return (
72
+ <Text color="cyan" bold>
73
+ - {props.label}
74
+ </Text>
67
75
  );
68
76
  }
69
77
 
@@ -205,6 +205,16 @@ export function reduceTuiProjection(
205
205
  status: "running",
206
206
  })),
207
207
  );
208
+ case "model.retry.requested":
209
+ return updateActiveTurn(state, event, policy, (turn) =>
210
+ updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
211
+ ...item,
212
+ text: `model iteration ${event.iterationNumber} · waiting for retry selection`,
213
+ status: "running",
214
+ })),
215
+ );
216
+ case "model.retry.resolved":
217
+ return state;
208
218
  case "model.request.finished":
209
219
  return updateActiveTurn(state, event, policy, (turn) =>
210
220
  updateTurnItem(turn, modelRequestRef(event.iterationId), (item) => ({
@@ -12,6 +12,7 @@ export type FileMentionMatch = {
12
12
  path: string;
13
13
  indices: readonly number[];
14
14
  score: number;
15
+ kind: "file" | "directory";
15
16
  };
16
17
 
17
18
  export function findFileMention(editor: LineEditorState): FileMention | undefined {
@@ -76,8 +77,13 @@ export function rankWorkspaceFiles(
76
77
  ): FileMentionMatch[] {
77
78
  if (query === "") {
78
79
  return files
79
- .map((filePath) => ({ path: filePath, indices: [], score: 0 }))
80
- .sort((left, right) => compareShallowPaths(left.path, right.path))
80
+ .map((filePath) => ({
81
+ path: filePath,
82
+ indices: [],
83
+ score: 0,
84
+ kind: fileMentionKind(filePath),
85
+ }))
86
+ .sort((left, right) => compareShallowPaths(left, right))
81
87
  .slice(0, limit);
82
88
  }
83
89
 
@@ -104,6 +110,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
104
110
  path: filePath,
105
111
  indices: basenameMatch.indices,
106
112
  score: basenameMatch.score + 200,
113
+ kind: fileMentionKind(filePath),
107
114
  };
108
115
  }
109
116
 
@@ -115,6 +122,7 @@ function fuzzyMatchPath(filePath: string, query: string): FileMentionMatch | und
115
122
  path: filePath,
116
123
  indices: fullPathMatch.indices,
117
124
  score: fullPathMatch.score,
125
+ kind: fileMentionKind(filePath),
118
126
  };
119
127
  }
120
128
 
@@ -167,6 +175,11 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
167
175
  return right.score - left.score;
168
176
  }
169
177
 
178
+ const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
179
+ if (kindDifference !== 0) {
180
+ return kindDifference;
181
+ }
182
+
170
183
  const depthDifference = pathDepth(left.path) - pathDepth(right.path);
171
184
  if (depthDifference !== 0) {
172
185
  return depthDifference;
@@ -178,9 +191,16 @@ function compareFileMatches(left: FileMentionMatch, right: FileMentionMatch): nu
178
191
  : lengthDifference;
179
192
  }
180
193
 
181
- function compareShallowPaths(left: string, right: string): number {
182
- const depthDifference = pathDepth(left) - pathDepth(right);
183
- return depthDifference === 0 ? comparePathText(left, right) : depthDifference;
194
+ function compareShallowPaths(left: FileMentionMatch, right: FileMentionMatch): number {
195
+ const kindDifference = left.kind === right.kind ? 0 : left.kind === "file" ? -1 : 1;
196
+ if (kindDifference !== 0) {
197
+ return kindDifference;
198
+ }
199
+
200
+ const depthDifference = pathDepth(left.path) - pathDepth(right.path);
201
+ return depthDifference === 0
202
+ ? comparePathText(left.path, right.path)
203
+ : depthDifference;
184
204
  }
185
205
 
186
206
  function comparePathText(left: string, right: string): number {
@@ -196,6 +216,10 @@ function comparePathText(left: string, right: string): number {
196
216
  return left < right ? -1 : left > right ? 1 : 0;
197
217
  }
198
218
 
219
+ function fileMentionKind(filePath: string): FileMentionMatch["kind"] {
220
+ return filePath.endsWith("/") || filePath.endsWith("\\") ? "directory" : "file";
221
+ }
222
+
199
223
  function pathDepth(filePath: string): number {
200
224
  return [...filePath].filter((char) => char === "/" || char === "\\").length;
201
225
  }
@@ -205,12 +205,15 @@ export class TuiProjectionStore implements EventSink, AssistantTextDeltaSink {
205
205
  return false;
206
206
  }
207
207
  this.assistantStreamAttempt = undefined;
208
- if (event.data.retryDisposition !== "scheduled" || attempt.sectionCount === 0) {
208
+ if (attempt.sectionCount === 0) {
209
209
  return false;
210
210
  }
211
211
  this.appendCommitted({
212
212
  id: `assistant-stream-retry-${attempt.iterationId}-${attempt.attemptNumber}`,
213
- text: "assistant response interrupted · retrying",
213
+ text:
214
+ event.data.retryDisposition === "scheduled"
215
+ ? "assistant response interrupted · retrying"
216
+ : "assistant response interrupted",
214
217
  status: "info",
215
218
  });
216
219
  return true;
@@ -54,6 +54,9 @@ export type TuiSessionBinding = {
54
54
  subscribeBashGuard(listener: () => void): () => void;
55
55
  setYoloMode(enabled: boolean): void;
56
56
  resolveBashConfirmation(decision: "allow" | "deny"): Promise<void>;
57
+ providerRetry?: RuntimeSession["providerRetry"];
58
+ subscribeProviderRetry?: RuntimeSession["subscribeProviderRetry"];
59
+ resolveProviderRetry?: RuntimeSession["resolveProviderRetry"];
57
60
  askUser(): AskUserSnapshot;
58
61
  subscribeAskUser(listener: () => void): () => void;
59
62
  resolveAskUser(response: AskUserResolution): Promise<void>;
@@ -267,6 +270,11 @@ export function managedTuiBinding(input: {
267
270
  setYoloMode: (enabled) => input.runtimeSession.setYoloMode(enabled),
268
271
  resolveBashConfirmation: (decision) =>
269
272
  input.runtimeSession.resolveBashConfirmation(decision),
273
+ providerRetry: () => input.runtimeSession.providerRetry(),
274
+ subscribeProviderRetry: (listener) =>
275
+ input.runtimeSession.subscribeProviderRetry(listener),
276
+ resolveProviderRetry: (requestId, decision) =>
277
+ input.runtimeSession.resolveProviderRetry(requestId, decision),
270
278
  askUser: () => input.runtimeSession.askUser(),
271
279
  subscribeAskUser: (listener) => input.runtimeSession.subscribeAskUser(listener),
272
280
  resolveAskUser: (response) => input.runtimeSession.resolveAskUser(response),
@@ -93,6 +93,27 @@ export function createWorkspaceFileLister(
93
93
 
94
94
  export const listWorkspaceFiles = createWorkspaceFileLister();
95
95
 
96
+ export function deriveWorkspaceDirectories(files: readonly string[]): string[] {
97
+ const directories = new Set<string>();
98
+
99
+ for (const filePath of files) {
100
+ for (let index = 0; index < filePath.length; index += 1) {
101
+ const char = filePath[index];
102
+ if ((char === "/" || char === "\\") && index > 0) {
103
+ directories.add(filePath.slice(0, index + 1));
104
+ }
105
+ }
106
+ }
107
+
108
+ return [...directories];
109
+ }
110
+
111
+ export function listWorkspaceFilesAndDirectories(
112
+ files: readonly string[],
113
+ ): readonly string[] {
114
+ return [...files, ...deriveWorkspaceDirectories(files)];
115
+ }
116
+
96
117
  function splitPaths(stdout: string): string[] {
97
118
  return stdout
98
119
  .split("\n")