tinker-agent 2.9.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +30 -1
  3. package/package.json +5 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-hosted-session.ts +443 -0
  6. package/src/agent/runtime-provider-retry.ts +115 -0
  7. package/src/agent/runtime-session-contracts.ts +11 -0
  8. package/src/agent/runtime-session.ts +29 -0
  9. package/src/cli/command-line.ts +26 -2
  10. package/src/cli/connect-runner.tsx +26 -0
  11. package/src/cli/main.ts +26 -0
  12. package/src/cli/output.ts +1 -1
  13. package/src/cli/public-cli-contract.ts +18 -0
  14. package/src/cli/public-config-contract.ts +1 -1
  15. package/src/cli/serve-runner.ts +45 -0
  16. package/src/cli/serve-runtime.ts +100 -0
  17. package/src/cli/tui-runner.tsx +1 -0
  18. package/src/context/context-swap-renderer.ts +14 -0
  19. package/src/events/types.ts +8 -0
  20. package/src/image/abortable-file-open.ts +54 -0
  21. package/src/image/image-asset-store.ts +7 -1
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +87 -37
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/scoped-query-database.ts +27 -0
  36. package/src/session/session-history-access.ts +4 -3
  37. package/src/session/session-store.ts +7 -3
  38. package/src/tools/bash-task.ts +26 -16
  39. package/src/tools/bash.ts +44 -2
  40. package/src/tools/glob.ts +107 -19
  41. package/src/tools/grep-output.ts +130 -0
  42. package/src/tools/grep-pagination.ts +73 -0
  43. package/src/tools/grep-path.ts +11 -0
  44. package/src/tools/grep-snippets.ts +111 -0
  45. package/src/tools/grep.ts +148 -155
  46. package/src/tools/read.ts +0 -9
  47. package/src/tools/ripgrep.ts +19 -26
  48. package/src/tools/shell-process.ts +30 -4
  49. package/src/tools/task-stop.ts +2 -1
  50. package/src/tools/terminal-screen.ts +11 -2
  51. package/src/tools/types.ts +30 -2
  52. package/src/tui/app.tsx +45 -3
  53. package/src/tui/components/ask-user.tsx +15 -8
  54. package/src/tui/components/prompt-input.tsx +14 -6
  55. package/src/tui/components/timeline.tsx +17 -9
  56. package/src/tui/event-store.ts +25 -2
  57. package/src/tui/file-mention.ts +29 -5
  58. package/src/tui/remote-app.tsx +210 -0
  59. package/src/tui/tui-projection-store.ts +5 -2
  60. package/src/tui/tui-session-controller.ts +8 -0
  61. package/src/tui/workspace-file-search.ts +21 -0
@@ -0,0 +1,443 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RuntimeSession } from "./runtime-session";
3
+ import type {
4
+ AssistantTextDeltaSink,
5
+ AssistantTextDeltaUpdate,
6
+ } from "./assistant-text-delta";
7
+ import type { AgentEvent } from "../events/types";
8
+ import type { EventSink } from "../events/event-sink";
9
+ import {
10
+ RemoteHistoryReader,
11
+ type RemoteHistoryPage,
12
+ } from "../session/remote-history-reader";
13
+ import { parseSessionId } from "../ids/runtime-id";
14
+ import {
15
+ RemoteError,
16
+ type RemoteActivity,
17
+ type RemoteOperationInput,
18
+ type OperationReceipt,
19
+ type RemoteView,
20
+ } from "../remote/protocol";
21
+ import { type ManagedSessionRecord, RemoteServiceStore } from "../remote/service-store";
22
+ import { RemoteSyncHub } from "../remote/sync-hub";
23
+
24
+ export type HostedRuntimeFactory = (input: {
25
+ record: ManagedSessionRecord;
26
+ sink: EventSink & AssistantTextDeltaSink;
27
+ }) => Promise<{
28
+ runtime: RuntimeSession;
29
+ databasePath: string;
30
+ modelName: string;
31
+ }>;
32
+
33
+ export class HostedSession implements EventSink, AssistantTextDeltaSink {
34
+ readonly name = "remote-view";
35
+ readonly hub: RemoteSyncHub;
36
+ private runtime?: RuntimeSession;
37
+ private reader?: RemoteHistoryReader;
38
+ private opening?: Promise<void>;
39
+ private readonly queue: OperationReceipt[] = [];
40
+ private active?: {
41
+ receipt: OperationReceipt;
42
+ controller: AbortController;
43
+ completion?: Promise<void>;
44
+ };
45
+ private activity: Omit<RemoteActivity, "session" | "operations"> = {
46
+ status: "idle",
47
+ tools: [],
48
+ };
49
+ private lastOrdinal = 0;
50
+ private streamTimer?: ReturnType<typeof setTimeout>;
51
+ private stopping = false;
52
+ private unsubscribers: (() => void)[] = [];
53
+ private controlTail: Promise<void> = Promise.resolve();
54
+
55
+ constructor(
56
+ private record: ManagedSessionRecord,
57
+ private readonly store: RemoteServiceStore,
58
+ epoch: string,
59
+ private readonly factory: HostedRuntimeFactory,
60
+ ) {
61
+ this.hub = new RemoteSyncHub(epoch, () => this.view());
62
+ }
63
+
64
+ open(): Promise<void> {
65
+ return (this.opening ??= this.initialize());
66
+ }
67
+ private async initialize(): Promise<void> {
68
+ try {
69
+ const opened = await this.factory({ record: this.record, sink: this });
70
+ this.runtime = opened.runtime;
71
+ this.reader = new RemoteHistoryReader(
72
+ opened.databasePath,
73
+ parseSessionId(this.record.id),
74
+ this.record.workspacePath,
75
+ );
76
+ this.record = {
77
+ ...this.record,
78
+ initialized: true,
79
+ modelName: opened.modelName,
80
+ status: "idle",
81
+ };
82
+ this.store.saveSession(this.record);
83
+ const latest = this.reader.latestTurn();
84
+ if (latest && latest.status !== "open") {
85
+ this.activity.status = latest.status as RemoteActivity["status"];
86
+ this.activity.error = latest.error;
87
+ }
88
+ // Canonical terminal status wins over a crash between turn commit and receipt update.
89
+ for (const receipt of this.store.operations(this.record.id)) {
90
+ const status = receipt.turnId
91
+ ? this.reader.turnStatus(receipt.turnId)
92
+ : undefined;
93
+ if (receipt.kind === "prompt" && status && status !== "open") {
94
+ this.store.update({
95
+ ...receipt,
96
+ status: status as OperationReceipt["status"],
97
+ error: status === "interrupted" ? receipt.error : undefined,
98
+ });
99
+ }
100
+ }
101
+ const last = this.store
102
+ .operations(this.record.id)
103
+ .filter((op) => op.kind === "prompt")
104
+ .at(-1);
105
+ if (last?.status === "interrupted") {
106
+ this.activity.status = "interrupted";
107
+ this.activity.error = last.error;
108
+ }
109
+ this.unsubscribers = [
110
+ this.runtime.subscribeAskUser(() => this.updateInteraction()),
111
+ this.runtime.subscribeBashGuard(() => this.updateInteraction()),
112
+ ];
113
+ this.publish();
114
+ } catch (error) {
115
+ this.activity.status = "failed";
116
+ this.activity.error = errorMessage(error);
117
+ if (this.runtime)
118
+ await this.runtime
119
+ .dispose({ type: "runner_failed", error: errorMessage(error) })
120
+ .catch(() => undefined);
121
+ this.publish();
122
+ throw error;
123
+ }
124
+ }
125
+
126
+ view(): RemoteView {
127
+ return { ...this.readActivity(), history: this.history() };
128
+ }
129
+ history(before?: number, limit?: number): RemoteHistoryPage {
130
+ return this.reader?.page(before, limit) ?? { messages: [], hasMore: false };
131
+ }
132
+ private readActivity(): RemoteActivity {
133
+ const { id, workspaceId, title, modelName, owner, updatedAt } = this.record;
134
+ const session = { id, workspaceId, title, modelName, owner, updatedAt };
135
+ return structuredClone({
136
+ ...this.activity,
137
+ session: { ...session, status: this.activity.status },
138
+ operations: this.store.operations(this.record.id),
139
+ });
140
+ }
141
+
142
+ validate(input: RemoteOperationInput): void {
143
+ if (this.stopping)
144
+ throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
145
+ if (input.kind === "prompt" && this.queue.length >= 8)
146
+ throw new RemoteError(
147
+ 409,
148
+ "QUEUE_FULL",
149
+ "This session already has eight queued requests.",
150
+ );
151
+ if (input.kind === "answer" || input.kind === "confirm") {
152
+ const pending = this.activity.interaction;
153
+ if (
154
+ !pending ||
155
+ pending.id !== input.interactionId ||
156
+ pending.kind !== (input.kind === "answer" ? "question" : "confirmation")
157
+ )
158
+ throw new RemoteError(
159
+ 409,
160
+ "STALE_INTERACTION",
161
+ "This question or confirmation is no longer pending.",
162
+ );
163
+ if (
164
+ input.kind === "answer" &&
165
+ input.selectedIndex !== null &&
166
+ pending.kind === "question" &&
167
+ !pending.options[input.selectedIndex]
168
+ )
169
+ throw new RemoteError(400, "INVALID_ANSWER", "Answer index is out of range.");
170
+ }
171
+ if (input.kind === "stop") {
172
+ const target = this.store.get(input.targetRequestId);
173
+ if (target.sessionId !== this.record.id || target.kind !== "prompt")
174
+ throw new RemoteError(
175
+ 409,
176
+ "INVALID_STOP_TARGET",
177
+ "Stop must target a prompt in this session.",
178
+ );
179
+ }
180
+ }
181
+
182
+ enqueue(receipt: OperationReceipt): void {
183
+ this.queue.push(receipt);
184
+ if (!this.active) this.activity.status = "accepted";
185
+ if (this.record.title === "New session" && receipt.prompt) {
186
+ this.record = {
187
+ ...this.record,
188
+ title: receipt.prompt.replace(/\s+/g, " ").slice(0, 100),
189
+ };
190
+ this.store.saveSession(this.record);
191
+ }
192
+ this.publish();
193
+ this.pump();
194
+ }
195
+ private pump(): void {
196
+ if (this.active || this.stopping) return;
197
+ const receipt = this.queue.shift();
198
+ if (!receipt) return;
199
+ const active = {
200
+ receipt,
201
+ controller: new AbortController(),
202
+ completion: undefined as Promise<void> | undefined,
203
+ };
204
+ this.active = active;
205
+ active.completion = this.execute(active);
206
+ }
207
+ private async execute(active: NonNullable<HostedSession["active"]>): Promise<void> {
208
+ try {
209
+ await this.open();
210
+ if (active.controller.signal.aborted) {
211
+ this.updateReceipt(active.receipt, { status: "cancelled" });
212
+ return;
213
+ }
214
+ this.activity = {
215
+ status: "running",
216
+ activeRequestId: active.receipt.requestId,
217
+ tools: [],
218
+ };
219
+ this.updateReceipt(active.receipt, { status: "running" });
220
+ this.publish();
221
+ const accepted = await this.runtime!.admitTurn({
222
+ userMessage: { role: "user", content: active.receipt.prompt! },
223
+ signal: active.controller.signal,
224
+ });
225
+ active.receipt = this.updateReceipt(active.receipt, {
226
+ status: "running",
227
+ turnId: accepted.turnId,
228
+ });
229
+ this.activity.activeTurnId = accepted.turnId;
230
+ this.publish();
231
+ const result = await accepted.completion;
232
+ this.updateReceipt(active.receipt, {
233
+ status: result.status,
234
+ ...(result.status === "failed" ? { error: result.error } : {}),
235
+ });
236
+ this.activity.status = result.status;
237
+ if (result.status === "failed") this.activity.error = result.error;
238
+ } catch (error) {
239
+ const status = active.controller.signal.aborted ? "cancelled" : "failed";
240
+ this.updateReceipt(active.receipt, {
241
+ status,
242
+ error: errorMessage(error),
243
+ });
244
+ this.activity.status = status;
245
+ this.activity.error = errorMessage(error);
246
+ } finally {
247
+ this.activity.streaming = undefined;
248
+ this.activity.interaction = undefined;
249
+ this.activity.activeRequestId = undefined;
250
+ this.activity.activeTurnId = undefined;
251
+ for (const tool of this.activity.tools) {
252
+ if (tool.status === "running")
253
+ tool.status = this.activity.status === "cancelled" ? "cancelled" : "failed";
254
+ }
255
+ this.active = undefined;
256
+ this.publish(true);
257
+ this.pump();
258
+ }
259
+ }
260
+
261
+ control(input: RemoteOperationInput, receipt: OperationReceipt): void {
262
+ // Separate from prompt execution: answering a wait cannot wait for that turn.
263
+ this.controlTail = this.controlTail.then(async () => {
264
+ try {
265
+ await this.open();
266
+ this.validate(input);
267
+ if (input.kind === "stop") {
268
+ if (this.active?.receipt.requestId === input.targetRequestId)
269
+ this.active.controller.abort();
270
+ const index = this.queue.findIndex(
271
+ (op) => op.requestId === input.targetRequestId,
272
+ );
273
+ if (index !== -1)
274
+ this.updateReceipt(this.queue.splice(index, 1)[0], {
275
+ status: "cancelled",
276
+ });
277
+ } else if (input.kind === "answer") {
278
+ await this.runtime!.resolveAskUser(
279
+ input.selectedIndex === null
280
+ ? { outcome: "dismissed" }
281
+ : { outcome: "selected", selectedIndex: input.selectedIndex },
282
+ );
283
+ } else if (input.kind === "confirm") {
284
+ await this.runtime!.resolveBashConfirmation(input.decision);
285
+ }
286
+ this.updateReceipt(receipt, { status: "completed" });
287
+ } catch (error) {
288
+ this.updateReceipt(receipt, {
289
+ status: "failed",
290
+ error: errorMessage(error),
291
+ });
292
+ }
293
+ this.publish();
294
+ });
295
+ }
296
+
297
+ private updateReceipt(
298
+ receipt: OperationReceipt,
299
+ patch: Partial<OperationReceipt>,
300
+ ): OperationReceipt {
301
+ const next = this.store.update({
302
+ ...this.store.get(receipt.requestId),
303
+ ...patch,
304
+ });
305
+ this.record = { ...this.record, updatedAt: next.updatedAt };
306
+ this.store.saveSession(this.record);
307
+ if (this.active?.receipt.requestId === receipt.requestId)
308
+ this.active.receipt = next;
309
+ return next;
310
+ }
311
+
312
+ private updateInteraction(): void {
313
+ const question = this.runtime?.askUser().pending;
314
+ const confirmation = this.runtime?.bashGuard().pending;
315
+ if (question || confirmation) {
316
+ if (!this.activity.interaction) {
317
+ this.activity.interaction = question
318
+ ? { id: randomUUID(), kind: "question", ...question }
319
+ : { id: randomUUID(), kind: "confirmation", ...confirmation! };
320
+ }
321
+ this.activity.status = "waiting_input";
322
+ } else {
323
+ this.activity.interaction = undefined;
324
+ if (this.active) this.activity.status = "running";
325
+ }
326
+ if (this.active)
327
+ this.updateReceipt(this.active.receipt, {
328
+ status: this.activity.status as OperationReceipt["status"],
329
+ });
330
+ this.publish();
331
+ }
332
+
333
+ async append(event: AgentEvent): Promise<void> {
334
+ if (!this.reader) return;
335
+ switch (event.type) {
336
+ case "turn.started":
337
+ this.activity.activeTurnId = event.turnId;
338
+ if (this.active)
339
+ this.updateReceipt(this.active.receipt, { turnId: event.turnId });
340
+ break;
341
+ case "model.request.started":
342
+ this.activity.streaming = undefined;
343
+ break;
344
+ case "model.request.finished":
345
+ this.activity.streaming = undefined;
346
+ break;
347
+ case "tool.started":
348
+ this.activity.tools.push({
349
+ id: event.data.call.toolCallId,
350
+ name: event.data.call.name,
351
+ arguments: JSON.stringify(event.data.call.args),
352
+ status: "running",
353
+ });
354
+ this.activity.tools = this.activity.tools.slice(-100);
355
+ break;
356
+ case "tool.finished": {
357
+ const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
358
+ if (tool) tool.status = event.data.ok ? "completed" : "failed";
359
+ break;
360
+ }
361
+ case "tool.observation": {
362
+ const tool = this.activity.tools.find((tool) => tool.id === event.toolCallId);
363
+ if (tool) tool.detail = event.data.observation.displayText;
364
+ break;
365
+ }
366
+ case "turn.finished":
367
+ case "turn.failed":
368
+ case "turn.cancelled":
369
+ case "agent.iteration.finished":
370
+ case "turn.steering.applied":
371
+ break;
372
+ default:
373
+ return;
374
+ }
375
+ this.publish();
376
+ }
377
+ updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
378
+ const previous = this.activity.streaming;
379
+ this.activity.streaming = {
380
+ iterationId: update.iterationId,
381
+ attempt: update.attemptNumber,
382
+ text:
383
+ previous?.iterationId === update.iterationId &&
384
+ previous.attempt === update.attemptNumber
385
+ ? previous.text + update.content
386
+ : update.content,
387
+ };
388
+ if (!this.streamTimer)
389
+ this.streamTimer = setTimeout(() => {
390
+ this.streamTimer = undefined;
391
+ this.publish();
392
+ }, 50);
393
+ }
394
+ private publish(refreshTurn = false): void {
395
+ if (this.streamTimer) {
396
+ clearTimeout(this.streamTimer);
397
+ this.streamTimer = undefined;
398
+ }
399
+ const messages = this.reader?.after(this.lastOrdinal) ?? [];
400
+ if (messages.length) this.lastOrdinal = messages.at(-1)!.ordinal;
401
+ if (refreshTurn) {
402
+ const latest = this.reader?.latestTurn();
403
+ messages.push(
404
+ ...(this.reader?.page().messages.filter((m) => m.turnId === latest?.id) ?? []),
405
+ );
406
+ }
407
+ this.hub.publish({ activity: this.readActivity(), messages });
408
+ }
409
+
410
+ async close(): Promise<void> {
411
+ this.stopping = true;
412
+ for (const receipt of this.queue.splice(0))
413
+ this.updateReceipt(receipt, {
414
+ status: "interrupted",
415
+ error: "The service was stopped before this queued request began.",
416
+ });
417
+ if (this.opening) await this.opening.catch(() => undefined);
418
+ if (this.runtime)
419
+ await this.runtime.dispose({
420
+ type: "runner_failed",
421
+ error: "Remote service shutdown.",
422
+ });
423
+ await this.active?.completion;
424
+ await this.controlTail;
425
+ for (const unsubscribe of this.unsubscribers) unsubscribe();
426
+ if (this.streamTimer) clearTimeout(this.streamTimer);
427
+ this.hub.close();
428
+ this.reader?.close();
429
+ }
430
+ get pendingCount(): number {
431
+ return this.queue.length + (this.active ? 1 : 0);
432
+ }
433
+ receiptChanged(): void {
434
+ this.publish();
435
+ }
436
+ get initialized(): boolean {
437
+ return this.runtime !== undefined;
438
+ }
439
+ }
440
+
441
+ function errorMessage(error: unknown): string {
442
+ return error instanceof Error ? error.message : String(error);
443
+ }
@@ -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) {
@@ -6,6 +6,8 @@ import { CliUsageError, type CliCommandScope } from "./output";
6
6
  export type CliCommand =
7
7
  | { readonly type: "tui"; readonly profileName?: string }
8
8
  | { readonly type: "update" }
9
+ | { readonly type: "serve"; readonly configPath: string }
10
+ | { readonly type: "connect"; readonly configPath: string }
9
11
  | {
10
12
  readonly type: "run";
11
13
  readonly profileName?: string;
@@ -130,6 +132,21 @@ export async function parseCommandLine(
130
132
  },
131
133
  );
132
134
 
135
+ for (const type of ["serve", "connect"] as const) {
136
+ const command = contract[type];
137
+ program
138
+ .command(command.command)
139
+ .description(command.description)
140
+ .requiredOption(command.configOption.flags, command.configOption.description)
141
+ .allowExcessArguments(false)
142
+ .exitOverride()
143
+ .action((options: { config: string }) => {
144
+ if (!options.config.trim())
145
+ throw new CliUsageError("--config requires a non-empty path.", type);
146
+ selectedCommand = Object.freeze({ type, configPath: options.config });
147
+ });
148
+ }
149
+
133
150
  program
134
151
  .command(contract.update.command)
135
152
  .description(contract.update.description)
@@ -166,10 +183,13 @@ export async function parseCommandLine(
166
183
  "run",
167
184
  );
168
185
  }
169
- if (selectedCommand.type === "update" && topLevelProfile !== undefined) {
186
+ if (
187
+ ["update", "serve", "connect"].includes(selectedCommand.type) &&
188
+ topLevelProfile !== undefined
189
+ ) {
170
190
  throw new CliUsageError(
171
191
  "The top-level --profile option only applies to the TUI.",
172
- "update",
192
+ selectedCommand.type as CliCommandScope,
173
193
  );
174
194
  }
175
195
  return Object.freeze({ type: "command", command: selectedCommand });
@@ -224,6 +244,10 @@ function preflightArgv(args: readonly string[]): CliCommandScope {
224
244
  scope = "update";
225
245
  continue;
226
246
  }
247
+ if (token === "serve" || token === "connect") {
248
+ scope = token;
249
+ continue;
250
+ }
227
251
  if (token === "help") {
228
252
  return "root";
229
253
  }