tinker-agent 2.13.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 (58) hide show
  1. package/CHANGELOG.md +53 -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/serve-runner.ts +155 -21
  19. package/src/cli/serve-runtime.ts +47 -46
  20. package/src/cli/service-control.ts +27 -0
  21. package/src/cli/service-supervisor.ts +170 -0
  22. package/src/cli/service-upgrade.ts +106 -0
  23. package/src/cli/tui-runner.tsx +9 -261
  24. package/src/cli/update-runner.ts +4 -0
  25. package/src/client/local-workspace-client.ts +238 -0
  26. package/src/client/model-catalog.ts +15 -0
  27. package/src/client/remote-images.ts +80 -0
  28. package/src/client/remote-prompt-history.ts +22 -0
  29. package/src/client/remote-tasks.ts +231 -0
  30. package/src/client/remote-tui-view.ts +69 -0
  31. package/src/client/remote-workspace-client.ts +528 -0
  32. package/src/client/session-client.ts +109 -0
  33. package/src/image/image-asset-store.ts +17 -0
  34. package/src/remote/client.ts +27 -5
  35. package/src/remote/config.ts +19 -4
  36. package/src/remote/failures.ts +68 -0
  37. package/src/remote/http-server.ts +120 -3
  38. package/src/remote/local-service-discovery.ts +361 -0
  39. package/src/remote/prompt-history.ts +36 -0
  40. package/src/remote/protocol.ts +93 -2
  41. package/src/remote/resident-policy.ts +96 -0
  42. package/src/remote/service-store.ts +28 -6
  43. package/src/remote/service.ts +479 -60
  44. package/src/remote/session-operations.ts +66 -0
  45. package/src/remote/session-reads.ts +126 -0
  46. package/src/remote/sync-hub.ts +2 -0
  47. package/src/remote/tui-protocol.ts +32 -0
  48. package/src/remote/workspace-resolution.ts +82 -0
  49. package/src/session/resume-projection.ts +19 -2
  50. package/src/session/session-lock.ts +32 -0
  51. package/src/session/session-store-contracts.ts +2 -0
  52. package/src/session/session-store.ts +7 -4
  53. package/src/tui/app.tsx +67 -26
  54. package/src/tui/components/model-picker.tsx +5 -5
  55. package/src/tui/components/prompt-input.tsx +16 -1
  56. package/src/tui/components/resume-session-picker.tsx +6 -2
  57. package/src/tui/tui-projection-store.ts +25 -0
  58. package/src/tui/tui-session-controller.ts +12 -282
@@ -1,7 +1,26 @@
1
+ import {
2
+ DEFAULT_RESIDENT_POLICY,
3
+ TurnSlots,
4
+ type ResidentPolicy,
5
+ } from "./resident-policy";
6
+ import {
7
+ isSessionOperation,
8
+ type SessionOperation,
9
+ type SessionOperationResult,
10
+ } from "./session-operations";
11
+ import type { RuntimeSession } from "../agent/runtime-session";
12
+ import type { ClientSessionSummary } from "../client/session-client";
1
13
  import { randomUUID } from "node:crypto";
2
14
  import { createUuidV7 } from "../ids/uuid-v7";
3
15
  import { parseSessionId } from "../ids/runtime-id";
4
16
  import { SessionCatalog } from "../session/session-catalog";
17
+ import { realpath, stat } from "node:fs/promises";
18
+ import path from "node:path";
19
+ import {
20
+ findContainingWorkspace,
21
+ localWorkspaceRecord,
22
+ mergeWorkspaces,
23
+ } from "./workspace-resolution";
5
24
  import {
6
25
  HostedSession,
7
26
  type HostedRuntimeFactory,
@@ -16,27 +35,141 @@ import {
16
35
  } from "./protocol";
17
36
 
18
37
  export class RemoteService {
38
+ private readonly workspaceEntries: RemoteWorkspaceConfig[];
39
+ get workspaces(): readonly RemoteWorkspaceConfig[] {
40
+ return this.workspaceEntries;
41
+ }
19
42
  readonly epoch = randomUUID();
20
43
  private readonly hosted = new Map<string, HostedSession>();
21
44
  private submitting: Promise<void> = Promise.resolve();
22
45
  private stopping = false;
46
+ private draining = false;
47
+ private admissions = 0;
48
+ private sweepTimer?: ReturnType<typeof setInterval>;
49
+ private readonly loaded = new Set<string>();
50
+ private loadTail: Promise<void> = Promise.resolve();
51
+ private readonly slots: TurnSlots;
23
52
  constructor(
24
53
  readonly store: RemoteServiceStore,
25
- readonly workspaces: readonly RemoteWorkspaceConfig[],
54
+ workspaces: readonly RemoteWorkspaceConfig[],
26
55
  private readonly factory: HostedRuntimeFactory,
27
- private readonly homeRoot?: string,
28
- ) {}
56
+ readonly homeRoot?: string,
57
+ readonly policy: ResidentPolicy = { ...DEFAULT_RESIDENT_POLICY },
58
+ ) {
59
+ this.slots = new TurnSlots(policy.maxConcurrentTurns);
60
+ this.workspaceEntries = mergeWorkspaces(workspaces, store.workspaces());
61
+ }
62
+
63
+ /** Local Unix-socket entry only; never exposed through the paired-device HTTP API. */
64
+ async registerLocalWorkspace(directory: string): Promise<RemoteWorkspaceConfig> {
65
+ if (!path.isAbsolute(directory) || directory.length > 4096)
66
+ throw new Error("Local workspace requires an absolute directory path.");
67
+ const canonical = await realpath(directory);
68
+ if (!(await stat(canonical)).isDirectory())
69
+ throw new Error("Workspace must be a directory.");
70
+ if (this.stopping || this.draining)
71
+ throw new Error("Service is stopping or draining.");
72
+ const existing = findContainingWorkspace(this.workspaces, canonical);
73
+ if (existing) return existing;
74
+ const record = localWorkspaceRecord(canonical);
75
+ // No await between checking identity, the durable insert and publishing the list.
76
+ this.store.registerWorkspace(record);
77
+ this.workspaceEntries.push(record);
78
+ return record;
79
+ }
29
80
 
30
81
  async initialize(): Promise<void> {
31
- // Reacquire every managed canonical lease; no prompt is resubmitted on boot.
82
+ // Recover canonical state and retain lightweight ownership without constructing runtimes.
32
83
  for (const record of this.store.sessions()) {
33
84
  if (!record.initialized) continue;
34
85
  try {
35
- await this.session(record.id).open();
86
+ await this.session(record.id).prepareDormant(this.homeRoot);
36
87
  } catch {
37
88
  /* A failed workspace/session remains visible with its error. */
38
89
  }
39
90
  }
91
+ this.sweepTimer = setInterval(
92
+ () => {
93
+ void this.sweepIdle().catch(() => undefined);
94
+ },
95
+ Math.min(this.policy.idleTimeoutMs, 30000),
96
+ );
97
+ this.sweepTimer.unref();
98
+ }
99
+ async sweepIdle(now = Date.now()): Promise<void> {
100
+ for (const session of this.hosted.values())
101
+ if (session.reclaimable && now - session.lastUsed >= this.policy.idleTimeoutMs)
102
+ await session.suspend();
103
+ }
104
+ private makeSession(record: ManagedSessionRecord): HostedSession {
105
+ return new HostedSession(record, this.store, this.epoch, this.factory, {
106
+ load: () => this.reserveRuntime(record.id),
107
+ unload: () => {
108
+ this.loaded.delete(record.id);
109
+ },
110
+ acquireTurn: (signal) => this.slots.acquire(signal),
111
+ });
112
+ }
113
+ private reserveRuntime(id: string): Promise<void> {
114
+ const reservation = this.loadTail.then(async () => {
115
+ if (this.loaded.has(id)) return;
116
+ if (this.stopping)
117
+ throw new RemoteError(503, "SERVICE_STOPPING", "Service is stopping.");
118
+ if (this.loaded.size >= this.policy.maxLoadedSessions) {
119
+ const candidate = [...this.hosted.values()]
120
+ .filter((session) => session.reclaimable)
121
+ .sort((a, b) => a.lastUsed - b.lastUsed)[0];
122
+ if (candidate) await candidate.suspend();
123
+ }
124
+ if (this.loaded.size >= this.policy.maxLoadedSessions)
125
+ throw new RemoteError(
126
+ 429,
127
+ "RUNTIME_LIMIT",
128
+ "All runtime slots are active or connected. Disconnect an idle client or wait for running work.",
129
+ );
130
+ this.loaded.add(id);
131
+ });
132
+ this.loadTail = reservation.catch(() => undefined);
133
+ return reservation;
134
+ }
135
+ residentStatus() {
136
+ return {
137
+ phase: this.stopping ? "stopping" : this.draining ? "draining" : "ready",
138
+ loadedSessions: this.loaded.size,
139
+ managedSessions: this.store.sessions().length,
140
+ runningTurns: this.slots.running,
141
+ waitingTurns: this.slots.queued,
142
+ pendingTurns: [...this.hosted.values()].reduce(
143
+ (sum, session) => sum + session.pendingCount,
144
+ 0,
145
+ ),
146
+ policy: this.policy,
147
+ };
148
+ }
149
+ async drain(force = false): Promise<void> {
150
+ if (this.draining || this.stopping)
151
+ throw new RemoteError(
152
+ 409,
153
+ "SERVICE_DRAINING",
154
+ "Service shutdown is already in progress.",
155
+ );
156
+ this.draining = true;
157
+ const deadline = Date.now() + this.policy.shutdownGraceMs;
158
+ while (
159
+ this.admissions > 0 ||
160
+ [...this.hosted.values()].some((session) => session.busy)
161
+ ) {
162
+ if (Date.now() >= deadline) {
163
+ if (force) return;
164
+ this.draining = false;
165
+ throw new RemoteError(
166
+ 409,
167
+ "SERVICE_BUSY",
168
+ "Work is still active; shutdown was cancelled. Retry after completion or use --force to interrupt.",
169
+ );
170
+ }
171
+ await Bun.sleep(25);
172
+ }
40
173
  }
41
174
  workspace(id: string): RemoteWorkspaceConfig {
42
175
  const workspace = this.workspaces.find((workspace) => workspace.id === id);
@@ -65,7 +198,7 @@ export class RemoteService {
65
198
  "WORKSPACE_CHANGED",
66
199
  "The managed session belongs to a different workspace path.",
67
200
  );
68
- const hosted = new HostedSession(record, this.store, this.epoch, this.factory);
201
+ const hosted = this.makeSession(record);
69
202
  this.hosted.set(id, hosted);
70
203
  return hosted;
71
204
  }
@@ -88,7 +221,7 @@ export class RemoteService {
88
221
  title: record.title,
89
222
  modelName: record.modelName,
90
223
  owner: "service" as const,
91
- status: record.initialized ? "idle" : "interrupted",
224
+ status: record.initialized ? record.status : "interrupted",
92
225
  updatedAt: record.updatedAt,
93
226
  }
94
227
  );
@@ -107,9 +240,79 @@ export class RemoteService {
107
240
  ].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
108
241
  }
109
242
 
243
+ async listTuiSessions(workspaceId: string): Promise<ClientSessionSummary[]> {
244
+ const workspace = this.workspace(workspaceId);
245
+ const catalog = new SessionCatalog({
246
+ workspaceRoot: workspace.path,
247
+ homeRoot: this.homeRoot,
248
+ });
249
+ const summaries = [...(await catalog.listAll())];
250
+ // The local picker omits empty sessions; service-owned empty sessions remain connectable.
251
+ for (const record of this.store.sessions()) {
252
+ if (
253
+ record.workspaceId === workspaceId &&
254
+ record.initialized &&
255
+ !summaries.some((s) => s.sessionId === record.id)
256
+ ) {
257
+ summaries.push(await catalog.get(parseSessionId(record.id)));
258
+ }
259
+ }
260
+ return summaries.map((summary) => {
261
+ const managed = this.store.session(summary.sessionId);
262
+ return {
263
+ ...summary,
264
+ ...(managed?.workspaceId === workspaceId ? { canConnect: true } : {}),
265
+ };
266
+ });
267
+ }
268
+
269
+ async getTuiSession(workspaceId: string, id: string): Promise<ClientSessionSummary> {
270
+ const workspace = this.workspace(workspaceId);
271
+ const summary = await new SessionCatalog({
272
+ workspaceRoot: workspace.path,
273
+ homeRoot: this.homeRoot,
274
+ })
275
+ .get(parseSessionId(id))
276
+ .catch(() => {
277
+ throw new RemoteError(
278
+ 404,
279
+ "SESSION_NOT_FOUND",
280
+ "Session does not belong to this workspace.",
281
+ );
282
+ });
283
+ const managed = this.store.session(id);
284
+ return {
285
+ ...summary,
286
+ ...(managed?.workspaceId === workspaceId &&
287
+ managed.workspacePath === workspace.path
288
+ ? { canConnect: true }
289
+ : {}),
290
+ };
291
+ }
292
+
110
293
  submit(input: RemoteOperationInput, device: string): Promise<OperationReceipt> {
111
- // Serializes acceptance and any async catalog lookup, never task execution.
112
- const result = this.submitting.then(() => this.accept(input, device));
294
+ const controls = ["answer", "confirm", "provider_retry", "stop"].includes(
295
+ input.kind,
296
+ );
297
+ const admissionLimit =
298
+ this.policy.maxPendingTurns +
299
+ this.policy.maxLoadedSessions +
300
+ (controls ? 32 : 16);
301
+ if (this.admissions >= admissionLimit)
302
+ return Promise.reject(
303
+ new RemoteError(
304
+ 429,
305
+ "ADMISSION_LIMIT",
306
+ "Too many concurrent service operations; retry after current admissions finish.",
307
+ ),
308
+ );
309
+ this.admissions++;
310
+ // Serializes acceptance, catalog lookup and deletion; never model execution.
311
+ const result = this.submitting
312
+ .then(() => this.accept(input, device))
313
+ .finally(() => {
314
+ this.admissions--;
315
+ });
113
316
  this.submitting = result.then(
114
317
  () => undefined,
115
318
  () => undefined,
@@ -124,57 +327,41 @@ export class RemoteService {
124
327
  if (existing) return existing;
125
328
  if (this.stopping)
126
329
  throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
127
- if (input.kind === "create" || input.kind === "adopt") {
330
+ if (
331
+ this.draining &&
332
+ !["answer", "confirm", "provider_retry", "stop"].includes(input.kind)
333
+ )
334
+ throw new RemoteError(
335
+ 503,
336
+ "SERVICE_DRAINING",
337
+ "Service is draining; wait before submitting new work.",
338
+ );
339
+ if (
340
+ input.kind === "prompt" &&
341
+ this.residentStatus().pendingTurns >= this.policy.maxPendingTurns
342
+ )
343
+ throw new RemoteError(
344
+ 429,
345
+ "SERVICE_QUEUE_FULL",
346
+ "The service pending-turn limit has been reached.",
347
+ );
348
+ if (input.kind === "adopt") return this.adopt(input, device);
349
+ if (input.kind === "create") {
128
350
  const workspace = this.workspace(input.workspaceId);
129
- const id = input.kind === "create" ? createUuidV7() : input.sessionId;
130
- if (this.store.sessions().length >= 128)
131
- throw new RemoteError(
132
- 409,
133
- "SESSION_LIMIT",
134
- "The service has reached its 128 managed session limit.",
135
- );
136
- let record: ManagedSessionRecord;
137
- if (input.kind === "adopt") {
138
- if (this.store.session(id))
139
- throw new RemoteError(
140
- 409,
141
- "ALREADY_MANAGED",
142
- "This session is already owned by the service.",
143
- );
144
- const summary = await new SessionCatalog({
145
- workspaceRoot: workspace.path,
146
- homeRoot: this.homeRoot,
147
- }).get(parseSessionId(id));
148
- if (summary.status !== "resumable" && summary.status !== "interrupted")
149
- throw new RemoteError(
150
- 409,
151
- "SESSION_UNAVAILABLE",
152
- "Exit its local TUI before attaching this session; it must be resumable.",
153
- );
154
- record = {
155
- id,
156
- workspaceId: workspace.id,
157
- workspacePath: workspace.path,
158
- title: summary.firstUserPromptPreview ?? "Empty session",
159
- modelName: summary.modelName,
160
- owner: "service",
161
- status: "accepted",
162
- updatedAt: new Date().toISOString(),
163
- initialized: true,
164
- };
165
- } else {
166
- record = {
167
- id,
168
- workspaceId: workspace.id,
169
- workspacePath: workspace.path,
170
- title: input.title ?? "New session",
171
- modelName: "",
172
- owner: "service",
173
- status: "accepted",
174
- updatedAt: new Date().toISOString(),
175
- initialized: false,
176
- };
177
- }
351
+ const id = createUuidV7();
352
+ await this.reserveRuntime(id);
353
+ const record: ManagedSessionRecord = {
354
+ id,
355
+ workspaceId: workspace.id,
356
+ workspacePath: workspace.path,
357
+ title: input.title ?? "New session",
358
+ modelName: "",
359
+ owner: "service",
360
+ status: "accepted",
361
+ updatedAt: new Date().toISOString(),
362
+ initialized: false,
363
+ profileName: input.profileName,
364
+ };
178
365
  const receipt = this.store.accept(input, device, id, record);
179
366
  const hosted = this.session(id);
180
367
  void hosted.open().then(
@@ -192,19 +379,251 @@ export class RemoteService {
192
379
  );
193
380
  return receipt;
194
381
  }
382
+ if (input.kind === "delete_session") return this.deleteSession(input, device);
195
383
  const session = this.session(input.sessionId);
196
384
  // Attach/initialization must be complete before a new mutation can be accepted.
197
385
  await session.open();
198
386
  session.validate(input);
199
387
  const receipt = this.store.accept(input, device, input.sessionId);
200
- if (input.kind === "prompt") session.enqueue(receipt);
388
+ if (isSessionOperation(input))
389
+ session.runMaintenance(receipt, (runtime) => this.maintain(input, runtime));
390
+ else if (input.kind === "prompt") session.enqueue(receipt);
201
391
  else session.control(input, receipt);
202
392
  return receipt;
203
393
  }
204
394
 
395
+ private async adopt(
396
+ input: Extract<RemoteOperationInput, { kind: "adopt" }>,
397
+ device: string,
398
+ ): Promise<OperationReceipt> {
399
+ const workspace = this.workspace(input.workspaceId);
400
+ const id = input.sessionId;
401
+ const managed = this.store.session(id);
402
+ if (managed) {
403
+ if (
404
+ managed.workspaceId !== workspace.id ||
405
+ managed.workspacePath !== workspace.path
406
+ )
407
+ throw new RemoteError(
408
+ 409,
409
+ "WORKSPACE_MISMATCH",
410
+ "Session belongs to another workspace.",
411
+ );
412
+ await this.session(id).open();
413
+ const receipt = this.store.accept(input, device, id);
414
+ const completed = this.store.update({ ...receipt, status: "completed" });
415
+ this.session(id).receiptChanged();
416
+ return completed;
417
+ }
418
+
419
+ const summary = await new SessionCatalog({
420
+ workspaceRoot: workspace.path,
421
+ homeRoot: this.homeRoot,
422
+ })
423
+ .get(parseSessionId(id))
424
+ .catch(() => {
425
+ throw new RemoteError(
426
+ 404,
427
+ "SESSION_NOT_FOUND",
428
+ "Session is not in this workspace.",
429
+ );
430
+ });
431
+ if (summary.status !== "resumable" && summary.status !== "interrupted")
432
+ throw new RemoteError(
433
+ 409,
434
+ "SESSION_UNAVAILABLE",
435
+ "Exit its local TUI before attaching this session; it must be resumable.",
436
+ );
437
+ const record: ManagedSessionRecord = {
438
+ id,
439
+ workspaceId: workspace.id,
440
+ workspacePath: workspace.path,
441
+ title: summary.firstUserPromptPreview ?? "Empty session",
442
+ modelName: summary.modelName,
443
+ profileName: summary.profileName,
444
+ owner: "service",
445
+ status: "accepted",
446
+ updatedAt: new Date().toISOString(),
447
+ initialized: true,
448
+ };
449
+ // A receipt records intent, not ownership. HostedSession publishes the managed
450
+ // record only after the runtime has acquired the canonical session lease.
451
+ const receipt = this.store.accept(input, device, id);
452
+ const hosted = this.makeSession(record);
453
+ this.hosted.set(id, hosted);
454
+ try {
455
+ await hosted.open();
456
+ const completed = this.store.update({ ...receipt, status: "completed" });
457
+ hosted.receiptChanged();
458
+ return completed;
459
+ } catch (error) {
460
+ await hosted.close().catch(() => undefined);
461
+ this.hosted.delete(id);
462
+ this.store.releaseSession(id);
463
+ const failed = {
464
+ ...receipt,
465
+ status: "failed" as const,
466
+ error: error instanceof Error ? error.message : String(error),
467
+ };
468
+ return this.store.update(failed);
469
+ }
470
+ }
471
+
472
+ private async deleteSession(
473
+ input: Extract<RemoteOperationInput, { kind: "delete_session" }>,
474
+ device: string,
475
+ ): Promise<OperationReceipt> {
476
+ const source = this.store.session(input.sessionId);
477
+ if (!source)
478
+ throw new RemoteError(
479
+ 404,
480
+ "SESSION_NOT_MANAGED",
481
+ "Current session is not managed by this service.",
482
+ );
483
+ if (input.targetSessionId === input.sessionId)
484
+ throw new RemoteError(
485
+ 409,
486
+ "SESSION_CURRENT",
487
+ "Cannot delete the current session.",
488
+ );
489
+ const workspace = this.workspace(source.workspaceId);
490
+ const record = this.store.session(input.targetSessionId);
491
+ if (record && record.workspaceId !== workspace.id)
492
+ throw new RemoteError(
493
+ 403,
494
+ "WORKSPACE_MISMATCH",
495
+ "Session belongs to another workspace.",
496
+ );
497
+ const catalog = new SessionCatalog({
498
+ workspaceRoot: workspace.path,
499
+ homeRoot: this.homeRoot,
500
+ });
501
+ // Resolve through the workspace catalog even for unmanaged local sessions.
502
+ try {
503
+ await catalog.get(parseSessionId(input.targetSessionId));
504
+ } catch (error) {
505
+ throw new RemoteError(
506
+ 404,
507
+ "SESSION_NOT_FOUND",
508
+ error instanceof Error ? error.message : "Session is not in this workspace.",
509
+ );
510
+ }
511
+ const target = record ? this.session(record.id) : undefined;
512
+ if (target) {
513
+ try {
514
+ await target.open();
515
+ } catch (error) {
516
+ throw new RemoteError(
517
+ 409,
518
+ "SESSION_UNAVAILABLE",
519
+ error instanceof Error ? error.message : "Session is unavailable.",
520
+ );
521
+ }
522
+ target.beginDelete();
523
+ }
524
+ let receipt = this.store.accept(input, device, input.targetSessionId);
525
+ receipt = this.store.update({ ...receipt, status: "running" });
526
+ try {
527
+ if (target) {
528
+ await target.close();
529
+ this.hosted.delete(input.targetSessionId);
530
+ // Release ownership before removing canonical files. A crash can leave a local
531
+ // session, but never a managed record that reopens an already deleted database.
532
+ this.store.releaseSession(input.targetSessionId);
533
+ }
534
+ await catalog.delete(
535
+ parseSessionId(input.targetSessionId),
536
+ parseSessionId(input.sessionId),
537
+ );
538
+ return this.store.update({ ...receipt, status: "completed" });
539
+ } catch (error) {
540
+ if (record && !this.store.session(record.id)) {
541
+ try {
542
+ await catalog.get(parseSessionId(record.id));
543
+ this.store.saveSession(record);
544
+ await this.session(record.id).open();
545
+ } catch {
546
+ /* A removed catalog entry must never be resurrected. */
547
+ }
548
+ }
549
+ return this.store.update({
550
+ ...receipt,
551
+ status: "failed",
552
+ error: error instanceof Error ? error.message : String(error),
553
+ });
554
+ }
555
+ }
556
+
557
+ private async maintain(
558
+ input: SessionOperation,
559
+ runtime: RuntimeSession,
560
+ ): Promise<SessionOperationResult> {
561
+ const kind = input.kind;
562
+ if (kind === "compact") return { kind, value: await runtime.compactContext() };
563
+ if (kind === "retire") return { kind, value: await runtime.retireContext() };
564
+ if (kind === "undo")
565
+ return { kind, value: await runtime.undoLatestFileMutationTurn() };
566
+ if (kind === "reasoning")
567
+ return {
568
+ kind,
569
+ value:
570
+ input.effort === null
571
+ ? runtime.resetReasoningEffort()
572
+ : runtime.setReasoningEffort(input.effort),
573
+ };
574
+ if (kind === "yolo") {
575
+ runtime.setYoloMode(input.enabled);
576
+ return { kind, value: null };
577
+ }
578
+ const source = this.store.session(input.sessionId)!;
579
+ if (kind === "default_profile") {
580
+ if (!this.factory.persistDefaultProfile)
581
+ throw new Error("Model profiles are not configured.");
582
+ await this.factory.persistDefaultProfile(source.workspaceId, input.profileName);
583
+ const catalog = await this.factory.profiles?.(source.workspaceId);
584
+ for (const record of this.store.sessions()) {
585
+ if (record.workspaceId === source.workspaceId)
586
+ this.hosted.get(record.id)?.updateModelCatalog(catalog);
587
+ }
588
+ return { kind, value: null };
589
+ }
590
+ if (this.stopping) throw new Error("Service is stopping.");
591
+
592
+ if (kind === "switch_model") {
593
+ if (this.session(input.sessionId).hasTurns)
594
+ throw new Error("Cannot switch models after the session has turns.");
595
+ const catalog = await this.factory.profiles?.(source.workspaceId);
596
+ if (!catalog?.profiles.some((p) => p.name === input.profileName))
597
+ throw new Error("Unknown model profile.");
598
+ }
599
+ const id = createUuidV7();
600
+ await this.reserveRuntime(id);
601
+ try {
602
+ if (kind === "fork") await runtime.cloneSession(parseSessionId(id));
603
+ if (this.stopping) throw new Error("Service is stopping.");
604
+ this.store.saveSession({
605
+ ...source,
606
+ id,
607
+ initialized: kind === "fork",
608
+ status: "accepted",
609
+ profileName: kind === "switch_model" ? input.profileName : source.profileName,
610
+ residentState: kind === "switch_model" ? undefined : source.residentState,
611
+ updatedAt: new Date().toISOString(),
612
+ title: kind === "fork" ? source.title : "New session",
613
+ });
614
+ await this.session(id).open();
615
+ return { kind, value: id };
616
+ } catch (error) {
617
+ this.loaded.delete(id);
618
+ throw error;
619
+ }
620
+ }
621
+
205
622
  async close(): Promise<void> {
206
623
  this.stopping = true;
624
+ if (this.sweepTimer) clearInterval(this.sweepTimer);
207
625
  await this.submitting;
626
+ await this.loadTail;
208
627
  const results = await Promise.allSettled(
209
628
  [...this.hosted.values()].map((session) => session.close()),
210
629
  );
@@ -0,0 +1,66 @@
1
+ import type {
2
+ ContextCompactionResult,
3
+ ContextRetirementResult,
4
+ } from "../context/context-manager";
5
+ import type { TurnUndoResult } from "../tools/turn-undo-manager";
6
+ import type { ReasoningEffortSnapshot } from "../model/reasoning-effort";
7
+ import { requireId, requireText, RemoteError } from "./protocol";
8
+
9
+ export type SessionOperation = { sessionId: string } & (
10
+ | { kind: "compact" | "retire" | "undo" | "fork" }
11
+ | { kind: "reasoning"; effort: string | null }
12
+ | { kind: "yolo"; enabled: boolean }
13
+ | { kind: "switch_model" | "default_profile"; profileName: string }
14
+ );
15
+ export type SessionOperationResult =
16
+ | { kind: "compact"; value: ContextCompactionResult }
17
+ | { kind: "retire"; value: ContextRetirementResult }
18
+ | { kind: "undo"; value: TurnUndoResult }
19
+ | { kind: "fork" | "switch_model"; value: string }
20
+ | { kind: "reasoning"; value: ReasoningEffortSnapshot }
21
+ | { kind: "yolo" | "default_profile"; value: null };
22
+ export function isSessionOperation(input: { kind: string }): input is SessionOperation {
23
+ return [
24
+ "compact",
25
+ "retire",
26
+ "undo",
27
+ "fork",
28
+ "reasoning",
29
+ "yolo",
30
+ "switch_model",
31
+ "default_profile",
32
+ ].includes(input.kind);
33
+ }
34
+ export function parseSessionOperation(
35
+ object: Record<string, unknown>,
36
+ ): SessionOperation {
37
+ const kind = object.kind;
38
+ const sessionId = requireId(object.sessionId, "sessionId", true);
39
+ switch (kind) {
40
+ case "compact":
41
+ case "retire":
42
+ case "undo":
43
+ case "fork":
44
+ return { kind, sessionId };
45
+ case "reasoning":
46
+ return {
47
+ kind,
48
+ sessionId,
49
+ effort:
50
+ object.effort === null ? null : requireText(object.effort, "effort", 100),
51
+ };
52
+ case "yolo":
53
+ if (typeof object.enabled !== "boolean")
54
+ throw new RemoteError(400, "INVALID_REQUEST", "enabled must be boolean.");
55
+ return { kind, sessionId, enabled: object.enabled };
56
+ case "switch_model":
57
+ case "default_profile":
58
+ return {
59
+ kind,
60
+ sessionId,
61
+ profileName: requireText(object.profileName, "profileName", 240),
62
+ };
63
+ default:
64
+ throw new RemoteError(400, "INVALID_REQUEST", "Unknown session operation.");
65
+ }
66
+ }