tinker-agent 2.12.0 → 3.0.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 +66 -1
  2. package/README.md +25 -4
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +505 -81
  5. package/src/agent/runtime-session-contracts.ts +9 -0
  6. package/src/agent/runtime-session.ts +29 -7
  7. package/src/cli/command-line.ts +159 -11
  8. package/src/cli/connect-runner.tsx +63 -0
  9. package/src/cli/connect-workspace.ts +33 -0
  10. package/src/cli/default-service-config.ts +163 -0
  11. package/src/cli/default-tui-runner.ts +23 -0
  12. package/src/cli/interactive-runtime.ts +87 -0
  13. package/src/cli/local-service-start.ts +131 -0
  14. package/src/cli/local-tui-client.ts +234 -0
  15. package/src/cli/main.ts +46 -3
  16. package/src/cli/public-cli-contract.ts +65 -2
  17. package/src/cli/public-config-contract.ts +1 -1
  18. package/src/cli/runner-dependencies.ts +35 -35
  19. package/src/cli/serve-runner.ts +155 -21
  20. package/src/cli/serve-runtime.ts +47 -46
  21. package/src/cli/service-control.ts +27 -0
  22. package/src/cli/service-supervisor.ts +170 -0
  23. package/src/cli/service-upgrade.ts +106 -0
  24. package/src/cli/tui-runner.tsx +9 -261
  25. package/src/cli/update-runner.ts +4 -0
  26. package/src/client/local-workspace-client.ts +238 -0
  27. package/src/client/model-catalog.ts +15 -0
  28. package/src/client/remote-images.ts +80 -0
  29. package/src/client/remote-prompt-history.ts +22 -0
  30. package/src/client/remote-tasks.ts +231 -0
  31. package/src/client/remote-tui-view.ts +69 -0
  32. package/src/client/remote-workspace-client.ts +528 -0
  33. package/src/client/session-client.ts +109 -0
  34. package/src/image/image-asset-store.ts +17 -0
  35. package/src/remote/client.ts +27 -5
  36. package/src/remote/config.ts +19 -4
  37. package/src/remote/failures.ts +68 -0
  38. package/src/remote/http-server.ts +120 -3
  39. package/src/remote/local-service-discovery.ts +361 -0
  40. package/src/remote/prompt-history.ts +36 -0
  41. package/src/remote/protocol.ts +93 -2
  42. package/src/remote/resident-policy.ts +96 -0
  43. package/src/remote/service-store.ts +28 -6
  44. package/src/remote/service.ts +479 -60
  45. package/src/remote/session-operations.ts +66 -0
  46. package/src/remote/session-reads.ts +126 -0
  47. package/src/remote/sync-hub.ts +2 -0
  48. package/src/remote/tui-protocol.ts +32 -0
  49. package/src/remote/workspace-resolution.ts +82 -0
  50. package/src/session/resume-projection.ts +19 -2
  51. package/src/session/session-lock.ts +32 -0
  52. package/src/session/session-store-contracts.ts +2 -0
  53. package/src/session/session-store.ts +7 -4
  54. package/src/tools/bash.ts +9 -1
  55. package/src/tools/bounded-output-preview.ts +2 -2
  56. package/src/tui/app.tsx +67 -26
  57. package/src/tui/components/model-picker.tsx +5 -5
  58. package/src/tui/components/prompt-input.tsx +16 -1
  59. package/src/tui/components/resume-session-picker.tsx +6 -2
  60. package/src/tui/tui-projection-store.ts +25 -0
  61. package/src/tui/tui-session-controller.ts +12 -282
@@ -1,3 +1,15 @@
1
+ import { SessionStore } from "../session/session-store";
2
+ import type { SessionLease } from "../session/session-lock";
3
+ import { runtimeIdFactory } from "../ids/runtime-id";
4
+ import { encodeFailure } from "../remote/failures";
5
+ import type { ClientModelCatalog } from "../client/model-catalog";
6
+ import {
7
+ isSessionOperation,
8
+ type SessionOperationResult,
9
+ } from "../remote/session-operations";
10
+ import { TuiProjectionStore } from "../tui/tui-projection-store";
11
+ import { ResumeProjectionReader } from "../session/resume-projection";
12
+ import type { RemoteTuiSnapshot } from "../remote/tui-protocol";
1
13
  import { randomUUID } from "node:crypto";
2
14
  import type { RuntimeSession } from "./runtime-session";
3
15
  import type {
@@ -21,20 +33,42 @@ import {
21
33
  import { type ManagedSessionRecord, RemoteServiceStore } from "../remote/service-store";
22
34
  import { RemoteSyncHub } from "../remote/sync-hub";
23
35
 
24
- export type HostedRuntimeFactory = (input: {
36
+ export type HostedRuntimeFactory = ((input: {
25
37
  record: ManagedSessionRecord;
38
+ lease?: SessionLease;
26
39
  sink: EventSink & AssistantTextDeltaSink;
27
40
  }) => Promise<{
28
41
  runtime: RuntimeSession;
29
42
  databasePath: string;
30
43
  modelName: string;
31
- }>;
44
+ profileName?: string;
45
+ modelCatalog?: ClientModelCatalog;
46
+ }>) & {
47
+ profiles?: (workspaceId: string) => Promise<ClientModelCatalog | undefined>;
48
+ persistDefaultProfile?: (workspaceId: string, profileName: string) => Promise<void>;
49
+ };
50
+
51
+ export type HostedResources = {
52
+ load(): Promise<void>;
53
+ unload(): void;
54
+ acquireTurn(signal: AbortSignal): Promise<() => void>;
55
+ };
32
56
 
33
57
  export class HostedSession implements EventSink, AssistantTextDeltaSink {
34
58
  readonly name = "remote-view";
35
59
  readonly hub: RemoteSyncHub;
36
60
  private runtime?: RuntimeSession;
61
+ private lease?: SessionLease;
62
+ private suspending?: Promise<void>;
63
+ private lastAccess = Date.now();
64
+ private controls = 0;
65
+ private requests = 0;
66
+ private closePromise?: Promise<void>;
67
+ private maintenance?: Promise<unknown>;
68
+ private modelCatalog?: ClientModelCatalog;
69
+ private interactionSource?: object;
37
70
  private reader?: RemoteHistoryReader;
71
+ private projection?: TuiProjectionStore;
38
72
  private opening?: Promise<void>;
39
73
  private readonly queue: OperationReceipt[] = [];
40
74
  private active?: {
@@ -49,6 +83,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
49
83
  private lastOrdinal = 0;
50
84
  private streamTimer?: ReturnType<typeof setTimeout>;
51
85
  private stopping = false;
86
+ private connections = 0;
52
87
  private unsubscribers: (() => void)[] = [];
53
88
  private controlTail: Promise<void> = Promise.resolve();
54
89
 
@@ -57,17 +92,80 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
57
92
  private readonly store: RemoteServiceStore,
58
93
  epoch: string,
59
94
  private readonly factory: HostedRuntimeFactory,
95
+ private readonly resources?: HostedResources,
60
96
  ) {
61
97
  this.hub = new RemoteSyncHub(epoch, () => this.view());
62
98
  }
63
99
 
100
+ assertAvailable(): void {
101
+ if (this.stopping)
102
+ throw new RemoteError(409, "SESSION_CLOSING", "Session is closing.");
103
+ }
104
+ get connectedClients(): number {
105
+ return this.connections;
106
+ }
107
+ attach(request = false): () => void {
108
+ this.assertAvailable();
109
+ this.lastAccess = Date.now();
110
+ this.connections += 1;
111
+ if (request) this.requests++;
112
+ let released = false;
113
+ return () => {
114
+ if (!released) {
115
+ released = true;
116
+ this.connections -= 1;
117
+ if (request) this.requests--;
118
+ this.lastAccess = Date.now();
119
+ }
120
+ };
121
+ }
122
+ beginDelete(): void {
123
+ this.assertIdle();
124
+ if (this.connections > 0)
125
+ throw new RemoteError(
126
+ 409,
127
+ "SESSION_CONNECTED",
128
+ "Cannot delete a session while a client is connected. Disconnect it first.",
129
+ );
130
+ this.stopping = true;
131
+ }
64
132
  open(): Promise<void> {
65
- return (this.opening ??= this.initialize());
133
+ this.assertAvailable();
134
+ this.lastAccess = Date.now();
135
+ if (this.suspending) return this.suspending.then(() => this.open());
136
+ return (this.opening ??= this.initialize().catch((error: unknown) => {
137
+ this.opening = undefined;
138
+ throw error;
139
+ }));
66
140
  }
67
141
  private async initialize(): Promise<void> {
68
142
  try {
69
- const opened = await this.factory({ record: this.record, sink: this });
143
+ await this.resources?.load();
144
+ const opened = await this.factory({
145
+ record: this.record,
146
+ sink: this,
147
+ lease: this.lease,
148
+ });
70
149
  this.runtime = opened.runtime;
150
+ this.lease = this.runtime.retainSessionLease();
151
+ if (this.record.residentState?.reasoning)
152
+ this.runtime.setReasoningEffort(this.record.residentState.reasoning);
153
+ if (this.record.residentState?.yolo !== undefined)
154
+ this.runtime.setYoloMode(this.record.residentState.yolo);
155
+ this.modelCatalog = opened.modelCatalog;
156
+ this.projection = new TuiProjectionStore({
157
+ sessionId: this.record.id,
158
+ workspaceRoot: this.record.workspacePath,
159
+ modelName: opened.modelName,
160
+ committedItemLimit: 512,
161
+ initialSnapshot: ResumeProjectionReader.readDatabase({
162
+ databasePath: opened.databasePath,
163
+ workspaceRoot: this.record.workspacePath,
164
+ sessionId: parseSessionId(this.record.id),
165
+ modelName: opened.modelName,
166
+ closedTurnsOnly: true,
167
+ }),
168
+ });
71
169
  this.reader = new RemoteHistoryReader(
72
170
  opened.databasePath,
73
171
  parseSessionId(this.record.id),
@@ -77,36 +175,14 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
77
175
  ...this.record,
78
176
  initialized: true,
79
177
  modelName: opened.modelName,
178
+ profileName: opened.profileName,
80
179
  status: "idle",
81
180
  };
82
181
  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
- }
182
+ this.restoreActivity(this.reader);
109
183
  this.unsubscribers = [
184
+ this.runtime.subscribePromptScheduler(() => this.publish()),
185
+ this.runtime.subscribeProviderRetry(() => this.updateInteraction()),
110
186
  this.runtime.subscribeAskUser(() => this.updateInteraction()),
111
187
  this.runtime.subscribeBashGuard(() => this.updateInteraction()),
112
188
  ];
@@ -118,14 +194,95 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
118
194
  await this.runtime
119
195
  .dispose({ type: "runner_failed", error: errorMessage(error) })
120
196
  .catch(() => undefined);
197
+ this.reader?.close();
198
+ this.reader = undefined;
199
+ this.projection = undefined;
200
+ this.runtime = undefined;
201
+ this.resources?.unload();
121
202
  this.publish();
122
203
  throw error;
123
204
  }
124
205
  }
125
206
 
207
+ private restoreActivity(reader: RemoteHistoryReader): void {
208
+ this.activity = { status: "idle", tools: [] };
209
+ const latest = reader.latestTurn();
210
+ if (latest && latest.status !== "open") {
211
+ this.activity.status = latest.status as RemoteActivity["status"];
212
+ this.activity.error = latest.error;
213
+ }
214
+ // Canonical terminal status wins over a crash between turn commit and receipt update.
215
+ for (const receipt of this.store.operations(this.record.id)) {
216
+ const status = receipt.turnId ? reader.turnStatus(receipt.turnId) : undefined;
217
+ // A terminal turn cannot prove that an extended execution chain finished.
218
+ // Without its final result, a crash may have lost unconsumed follow-ups.
219
+ if (receipt.hasFollowUps && !receipt.result && receipt.status === "interrupted")
220
+ continue;
221
+ if (
222
+ receipt.kind === "prompt" &&
223
+ !receipt.shutdownInterrupted &&
224
+ status &&
225
+ status !== "open"
226
+ ) {
227
+ this.store.update({
228
+ ...receipt,
229
+ status: status as OperationReceipt["status"],
230
+ error: status === "interrupted" ? receipt.error : undefined,
231
+ });
232
+ }
233
+ }
234
+ const last = this.store
235
+ .operations(this.record.id)
236
+ .filter((op) => op.kind === "prompt")
237
+ .at(-1);
238
+ if (last?.status === "interrupted") {
239
+ this.activity.status = "interrupted";
240
+ this.activity.error = last.error;
241
+ }
242
+ }
243
+
126
244
  view(): RemoteView {
127
245
  return { ...this.readActivity(), history: this.history() };
128
246
  }
247
+ tuiSnapshot(): RemoteTuiSnapshot {
248
+ if (!this.projection || !this.runtime) throw new Error("Session is not ready.");
249
+ const frame = this.hub.snapshot();
250
+ return {
251
+ version: 1,
252
+ cursor: { epoch: frame.epoch, sequence: frame.sequence },
253
+ activity: this.readActivity(),
254
+ history: this.projection.getSnapshot(),
255
+ timeline: this.projection.getLogSnapshot(),
256
+ promptScheduler: this.runtime.promptScheduler(),
257
+ profileName: this.record.profileName,
258
+ modelCatalog: this.modelCatalog,
259
+ reasoningEffort: this.runtime.reasoningEffort(),
260
+ supportsImageInput: this.runtime.supportsImageInput(),
261
+ bashGuard: {
262
+ ...this.runtime.bashGuard(),
263
+ ...(this.runtime.bashGuard().pending
264
+ ? { interactionId: this.activity.interaction?.id }
265
+ : {}),
266
+ },
267
+ askUser: {
268
+ ...this.runtime.askUser(),
269
+ ...(this.runtime.askUser().pending
270
+ ? { interactionId: this.activity.interaction?.id }
271
+ : {}),
272
+ },
273
+ providerRetry:
274
+ this.runtime.providerRetry().pending && this.activity.interaction
275
+ ? {
276
+ pending: {
277
+ ...this.runtime.providerRetry().pending!,
278
+ requestId: this.activity.interaction.id,
279
+ },
280
+ }
281
+ : {},
282
+ skills: this.runtime.skills(),
283
+ mcp: this.runtime.mcp(),
284
+ };
285
+ }
129
286
  history(before?: number, limit?: number): RemoteHistoryPage {
130
287
  return this.reader?.page(before, limit) ?? { messages: [], hasMore: false };
131
288
  }
@@ -135,11 +292,78 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
135
292
  return structuredClone({
136
293
  ...this.activity,
137
294
  session: { ...session, status: this.activity.status },
138
- operations: this.store.operations(this.record.id),
295
+ // Final results are fetched by operation ID, not repeated in every live frame.
296
+ operations: this.store.operations(this.record.id, false),
139
297
  });
140
298
  }
141
299
 
300
+ updateModelCatalog(catalog: ClientModelCatalog | undefined): void {
301
+ this.modelCatalog = catalog;
302
+ this.publish();
303
+ }
304
+
305
+ assertIdle(): void {
306
+ if (
307
+ this.stopping ||
308
+ this.suspending ||
309
+ this.maintenance ||
310
+ this.active ||
311
+ this.queue.length ||
312
+ !this.runtime?.canSwitchSession()
313
+ )
314
+ throw new RemoteError(
315
+ 409,
316
+ "SESSION_BUSY",
317
+ "A turn, context operation or background task is active.",
318
+ );
319
+ }
320
+ get id() {
321
+ return parseSessionId(this.record.id);
322
+ }
323
+ get hasTurns(): boolean {
324
+ return this.reader?.latestTurn() !== undefined;
325
+ }
326
+ exclusive<T>(operation: (runtime: RuntimeSession) => Promise<T>): Promise<T> {
327
+ this.assertIdle();
328
+ const pending = Promise.resolve()
329
+ .then(() => operation(this.runtime!))
330
+ .finally(() => {
331
+ if (this.maintenance === pending) this.maintenance = undefined;
332
+ this.publish();
333
+ });
334
+ this.maintenance = pending;
335
+ return pending;
336
+ }
337
+ runMaintenance(
338
+ receipt: OperationReceipt,
339
+ operation: (runtime: RuntimeSession) => Promise<SessionOperationResult>,
340
+ ): void {
341
+ const running = this.updateReceipt(receipt, { status: "running" });
342
+ void this.exclusive(operation).then(
343
+ (sessionResult) => {
344
+ this.saveResidentState();
345
+ this.updateReceipt(running, { status: "completed", sessionResult });
346
+ this.publish();
347
+ },
348
+ (error: unknown) => {
349
+ this.updateReceipt(running, {
350
+ status: "failed",
351
+ error: errorMessage(error),
352
+ failure: encodeFailure(error),
353
+ });
354
+ this.publish();
355
+ },
356
+ );
357
+ }
358
+
142
359
  validate(input: RemoteOperationInput): void {
360
+ if (isSessionOperation(input)) this.assertIdle();
361
+ if (input.kind === "prompt" && this.maintenance)
362
+ throw new RemoteError(
363
+ 409,
364
+ "SESSION_BUSY",
365
+ "A session maintenance operation is active.",
366
+ );
143
367
  if (this.stopping)
144
368
  throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
145
369
  if (input.kind === "prompt" && this.queue.length >= 8)
@@ -148,12 +372,17 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
148
372
  "QUEUE_FULL",
149
373
  "This session already has eight queued requests.",
150
374
  );
151
- if (input.kind === "answer" || input.kind === "confirm") {
375
+ if (
376
+ input.kind === "answer" ||
377
+ input.kind === "confirm" ||
378
+ input.kind === "provider_retry"
379
+ ) {
152
380
  const pending = this.activity.interaction;
153
381
  if (
154
382
  !pending ||
155
383
  pending.id !== input.interactionId ||
156
- pending.kind !== (input.kind === "answer" ? "question" : "confirmation")
384
+ pending.kind !== (input.kind === "confirm" ? "confirmation" : "question") ||
385
+ (input.kind === "provider_retry" && !this.runtime?.providerRetry().pending)
157
386
  )
158
387
  throw new RemoteError(
159
388
  409,
@@ -168,6 +397,16 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
168
397
  )
169
398
  throw new RemoteError(400, "INVALID_ANSWER", "Answer index is out of range.");
170
399
  }
400
+ if (
401
+ input.kind === "follow_up" &&
402
+ (this.active?.receipt.requestId !== input.targetRequestId ||
403
+ this.runtime?.promptScheduler().state !== "running")
404
+ )
405
+ throw new RemoteError(
406
+ 409,
407
+ "STALE_TURN",
408
+ "The targeted execution is no longer running.",
409
+ );
171
410
  if (input.kind === "stop") {
172
411
  const target = this.store.get(input.targetRequestId);
173
412
  if (target.sessionId !== this.record.id || target.kind !== "prompt")
@@ -205,7 +444,9 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
205
444
  active.completion = this.execute(active);
206
445
  }
207
446
  private async execute(active: NonNullable<HostedSession["active"]>): Promise<void> {
447
+ let release: (() => void) | undefined;
208
448
  try {
449
+ release = await this.resources?.acquireTurn(active.controller.signal);
209
450
  await this.open();
210
451
  if (active.controller.signal.aborted) {
211
452
  this.updateReceipt(active.receipt, { status: "cancelled" });
@@ -219,7 +460,13 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
219
460
  this.updateReceipt(active.receipt, { status: "running" });
220
461
  this.publish();
221
462
  const accepted = await this.runtime!.admitTurn({
222
- userMessage: { role: "user", content: active.receipt.prompt! },
463
+ userMessage: {
464
+ role: "user",
465
+ content: active.receipt.prompt!,
466
+ ...(active.receipt.attachments
467
+ ? { attachments: active.receipt.attachments }
468
+ : {}),
469
+ },
223
470
  signal: active.controller.signal,
224
471
  });
225
472
  active.receipt = this.updateReceipt(active.receipt, {
@@ -231,6 +478,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
231
478
  const result = await accepted.completion;
232
479
  this.updateReceipt(active.receipt, {
233
480
  status: result.status,
481
+ result,
234
482
  ...(result.status === "failed" ? { error: result.error } : {}),
235
483
  });
236
484
  this.activity.status = result.status;
@@ -240,6 +488,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
240
488
  this.updateReceipt(active.receipt, {
241
489
  status,
242
490
  error: errorMessage(error),
491
+ failure: encodeFailure(error),
243
492
  });
244
493
  this.activity.status = status;
245
494
  this.activity.error = errorMessage(error);
@@ -253,6 +502,8 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
253
502
  tool.status = this.activity.status === "cancelled" ? "cancelled" : "failed";
254
503
  }
255
504
  this.active = undefined;
505
+ this.lastAccess = Date.now();
506
+ release?.();
256
507
  this.publish(true);
257
508
  this.pump();
258
509
  }
@@ -260,38 +511,66 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
260
511
 
261
512
  control(input: RemoteOperationInput, receipt: OperationReceipt): void {
262
513
  // 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",
514
+ this.controls++;
515
+ this.controlTail = this.controlTail
516
+ .then(async () => {
517
+ try {
518
+ await this.open();
519
+ this.validate(input);
520
+ if (input.kind === "stop") {
521
+ if (this.active?.receipt.requestId === input.targetRequestId)
522
+ this.active.controller.abort();
523
+ const index = this.queue.findIndex(
524
+ (op) => op.requestId === input.targetRequestId,
525
+ );
526
+ if (index !== -1)
527
+ this.updateReceipt(this.queue.splice(index, 1)[0], {
528
+ status: "cancelled",
529
+ });
530
+ } else if (input.kind === "follow_up") {
531
+ const followUp = this.runtime!.queueFollowUp({
532
+ role: "user",
533
+ content: input.prompt,
276
534
  });
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);
535
+ this.updateReceipt(this.active!.receipt, { hasFollowUps: true });
536
+ this.updateReceipt(receipt, { status: "completed", followUp });
537
+ this.publish();
538
+ return;
539
+ } else if (
540
+ input.kind === "provider_retry" ||
541
+ (input.kind === "answer" && this.runtime!.providerRetry().pending)
542
+ ) {
543
+ const retry = this.runtime!.providerRetry().pending!;
544
+ await this.runtime!.resolveProviderRetry(
545
+ retry.requestId,
546
+ input.kind === "provider_retry"
547
+ ? input.decision
548
+ : input.selectedIndex === 0
549
+ ? "retry"
550
+ : "stop",
551
+ );
552
+ } else if (input.kind === "answer") {
553
+ await this.runtime!.resolveAskUser(
554
+ input.selectedIndex === null
555
+ ? { outcome: "dismissed" }
556
+ : { outcome: "selected", selectedIndex: input.selectedIndex },
557
+ );
558
+ } else if (input.kind === "confirm") {
559
+ await this.runtime!.resolveBashConfirmation(input.decision);
560
+ }
561
+ this.updateReceipt(receipt, { status: "completed" });
562
+ } catch (error) {
563
+ this.updateReceipt(receipt, {
564
+ status: "failed",
565
+ error: errorMessage(error),
566
+ });
285
567
  }
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
- });
568
+ this.publish();
569
+ })
570
+ .finally(() => {
571
+ this.controls--;
572
+ this.lastAccess = Date.now();
573
+ });
295
574
  }
296
575
 
297
576
  private updateReceipt(
@@ -312,14 +591,28 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
312
591
  private updateInteraction(): void {
313
592
  const question = this.runtime?.askUser().pending;
314
593
  const confirmation = this.runtime?.bashGuard().pending;
315
- if (question || confirmation) {
316
- if (!this.activity.interaction) {
594
+ const retry = this.runtime?.providerRetry().pending;
595
+ const source = question ?? confirmation ?? retry;
596
+ if (source) {
597
+ if (source !== this.interactionSource) {
598
+ this.interactionSource = source;
317
599
  this.activity.interaction = question
318
600
  ? { id: randomUUID(), kind: "question", ...question }
319
- : { id: randomUUID(), kind: "confirmation", ...confirmation! };
601
+ : confirmation
602
+ ? { id: randomUUID(), kind: "confirmation", ...confirmation }
603
+ : {
604
+ id: randomUUID(),
605
+ kind: "question",
606
+ question: `Provider request failed. Automatic retries exhausted. ${retry!.failure.error.slice(0, 500)}`,
607
+ options: [
608
+ { description: "Retry again" },
609
+ { description: "End this turn" },
610
+ ],
611
+ };
320
612
  }
321
613
  this.activity.status = "waiting_input";
322
614
  } else {
615
+ this.interactionSource = undefined;
323
616
  this.activity.interaction = undefined;
324
617
  if (this.active) this.activity.status = "running";
325
618
  }
@@ -332,6 +625,7 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
332
625
 
333
626
  async append(event: AgentEvent): Promise<void> {
334
627
  if (!this.reader) return;
628
+ await this.projection?.append(event);
335
629
  switch (event.type) {
336
630
  case "turn.started":
337
631
  this.activity.activeTurnId = event.turnId;
@@ -370,11 +664,12 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
370
664
  case "turn.steering.applied":
371
665
  break;
372
666
  default:
373
- return;
667
+ break;
374
668
  }
375
669
  this.publish();
376
670
  }
377
671
  updateAssistantTextDelta(update: AssistantTextDeltaUpdate): void {
672
+ this.projection?.updateAssistantTextDelta(update);
378
673
  const previous = this.activity.streaming;
379
674
  this.activity.streaming = {
380
675
  iterationId: update.iterationId,
@@ -407,25 +702,154 @@ export class HostedSession implements EventSink, AssistantTextDeltaSink {
407
702
  this.hub.publish({ activity: this.readActivity(), messages });
408
703
  }
409
704
 
410
- async close(): Promise<void> {
705
+ get lastUsed(): number {
706
+ return this.lastAccess;
707
+ }
708
+ get reclaimable(): boolean {
709
+ return (
710
+ !!this.runtime &&
711
+ !this.stopping &&
712
+ !this.suspending &&
713
+ !this.connections &&
714
+ !this.active &&
715
+ !this.queue.length &&
716
+ !this.maintenance &&
717
+ !this.controls &&
718
+ this.runtime.canSwitchSession()
719
+ );
720
+ }
721
+ get busy(): boolean {
722
+ return (
723
+ !!this.active ||
724
+ !!this.queue.length ||
725
+ !!this.maintenance ||
726
+ !!this.controls ||
727
+ !!this.requests ||
728
+ (!!this.opening && !this.runtime) ||
729
+ (!!this.runtime && !this.runtime.canSwitchSession())
730
+ );
731
+ }
732
+ async prepareDormant(homeRoot?: string): Promise<void> {
733
+ try {
734
+ await this.recoverDormant(homeRoot);
735
+ } catch (error) {
736
+ this.activity = { status: "failed", tools: [], error: errorMessage(error) };
737
+ throw error;
738
+ }
739
+ }
740
+ private async recoverDormant(homeRoot?: string): Promise<void> {
741
+ if (!this.record.initialized) return;
742
+ const store = await SessionStore.openExisting({
743
+ workspaceRoot: this.record.workspacePath,
744
+ homeRoot,
745
+ sessionId: this.id,
746
+ });
747
+ this.lease = store.retainLease();
748
+ try {
749
+ store.recoverInterruptedState(runtimeIdFactory);
750
+ } finally {
751
+ await store.close("session_switch");
752
+ }
753
+ const reader = new RemoteHistoryReader(
754
+ store.databasePath,
755
+ this.id,
756
+ this.record.workspacePath,
757
+ );
758
+ try {
759
+ this.restoreActivity(reader);
760
+ } finally {
761
+ reader.close();
762
+ }
763
+ this.record = { ...this.record, status: this.activity.status };
764
+ this.store.saveSession(this.record);
765
+ }
766
+ suspend(): Promise<void> {
767
+ if (this.suspending) return this.suspending;
768
+ if (!this.reclaimable) return Promise.resolve();
769
+ this.suspending = this.unload().finally(() => {
770
+ this.suspending = undefined;
771
+ });
772
+ return this.suspending;
773
+ }
774
+ private saveResidentState(): void {
775
+ if (!this.runtime) return;
776
+ const reasoning = this.runtime.reasoningEffort();
777
+ const guard = this.runtime.bashGuard();
778
+ this.record = {
779
+ ...this.record,
780
+ status: this.activity.status,
781
+ residentState: {
782
+ ...(reasoning?.source === "session_override"
783
+ ? { reasoning: reasoning.effort }
784
+ : {}),
785
+ ...(guard.source === "session" ? { yolo: guard.mode === "yolo" } : {}),
786
+ },
787
+ };
788
+ this.store.saveSession(this.record);
789
+ }
790
+ private async unload(): Promise<void> {
791
+ const runtime = this.runtime!;
792
+ this.saveResidentState();
793
+ try {
794
+ await runtime.dispose({ type: "session_switch" });
795
+ } finally {
796
+ for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
797
+ this.reader?.close();
798
+ this.reader = undefined;
799
+ this.projection = undefined;
800
+ this.modelCatalog = undefined;
801
+ this.interactionSource = undefined;
802
+ this.activity = {
803
+ status: this.activity.status,
804
+ error: this.activity.error,
805
+ tools: [],
806
+ };
807
+ this.runtime = undefined;
808
+ this.opening = undefined;
809
+ this.hub.close();
810
+ this.resources?.unload();
811
+ }
812
+ }
813
+ close(): Promise<void> {
814
+ return (this.closePromise ??= this.performClose());
815
+ }
816
+ private async performClose(): Promise<void> {
411
817
  this.stopping = true;
818
+ this.saveResidentState();
819
+ const interrupted = this.active?.receipt;
820
+ this.active?.controller.abort();
412
821
  for (const receipt of this.queue.splice(0))
413
822
  this.updateReceipt(receipt, {
414
823
  status: "interrupted",
415
- error: "The service was stopped before this queued request began.",
824
+ error: "The service stopped before this queued request began.",
416
825
  });
826
+ await this.suspending?.catch(() => undefined);
417
827
  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();
828
+ try {
829
+ if (this.runtime)
830
+ await this.runtime.dispose({
831
+ type: "runner_failed",
832
+ error: "Remote service shutdown.",
833
+ });
834
+ await this.maintenance?.catch(() => undefined);
835
+ await this.active?.completion;
836
+ if (interrupted) {
837
+ this.updateReceipt(interrupted, {
838
+ status: "interrupted",
839
+ shutdownInterrupted: true,
840
+ error: "Service shutdown interrupted this request; it will not be replayed.",
841
+ });
842
+ this.activity.status = "interrupted";
843
+ }
844
+ await this.controlTail;
845
+ } finally {
846
+ for (const unsubscribe of this.unsubscribers.splice(0)) unsubscribe();
847
+ if (this.streamTimer) clearTimeout(this.streamTimer);
848
+ this.hub.close();
849
+ this.reader?.close();
850
+ this.resources?.unload();
851
+ await this.lease?.releaseOwnership();
852
+ }
429
853
  }
430
854
  get pendingCount(): number {
431
855
  return this.queue.length + (this.active ? 1 : 0);