tinker-agent 2.9.0 → 2.10.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 (38) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/README.md +17 -1
  3. package/package.json +2 -1
  4. package/src/agent/runtime-hosted-session.ts +443 -0
  5. package/src/cli/command-line.ts +26 -2
  6. package/src/cli/connect-runner.tsx +26 -0
  7. package/src/cli/main.ts +26 -0
  8. package/src/cli/output.ts +1 -1
  9. package/src/cli/public-cli-contract.ts +18 -0
  10. package/src/cli/public-config-contract.ts +1 -1
  11. package/src/cli/serve-runner.ts +45 -0
  12. package/src/cli/serve-runtime.ts +100 -0
  13. package/src/context/context-swap-renderer.ts +14 -0
  14. package/src/observation/observation-builder.ts +87 -37
  15. package/src/remote/client.ts +350 -0
  16. package/src/remote/config.ts +95 -0
  17. package/src/remote/http-server.ts +240 -0
  18. package/src/remote/protocol.ts +228 -0
  19. package/src/remote/service-store.ts +175 -0
  20. package/src/remote/service.ts +219 -0
  21. package/src/remote/sync-hub.ts +95 -0
  22. package/src/session/remote-history-reader.ts +143 -0
  23. package/src/tools/bash-task.ts +26 -16
  24. package/src/tools/bash.ts +44 -2
  25. package/src/tools/glob.ts +107 -19
  26. package/src/tools/grep-output.ts +130 -0
  27. package/src/tools/grep-pagination.ts +73 -0
  28. package/src/tools/grep-path.ts +11 -0
  29. package/src/tools/grep-snippets.ts +111 -0
  30. package/src/tools/grep.ts +139 -154
  31. package/src/tools/read.ts +0 -9
  32. package/src/tools/ripgrep.ts +19 -26
  33. package/src/tools/shell-process.ts +30 -4
  34. package/src/tools/task-stop.ts +2 -1
  35. package/src/tools/terminal-screen.ts +11 -2
  36. package/src/tools/types.ts +30 -2
  37. package/src/tui/event-store.ts +15 -2
  38. package/src/tui/remote-app.tsx +210 -0
@@ -0,0 +1,228 @@
1
+ import type {
2
+ RemoteHistoryPage,
3
+ RemoteMessage,
4
+ } from "../session/remote-history-reader";
5
+
6
+ export const REMOTE_PROTOCOL_VERSION = 1;
7
+ export type OperationStatus =
8
+ | "accepted"
9
+ | "running"
10
+ | "waiting_input"
11
+ | "completed"
12
+ | "failed"
13
+ | "cancelled"
14
+ | "interrupted";
15
+
16
+ export type RemoteOperationInput = { requestId: string } & (
17
+ | { kind: "create"; workspaceId: string; title?: string }
18
+ | { kind: "adopt"; workspaceId: string; sessionId: string }
19
+ | { kind: "prompt"; sessionId: string; prompt: string }
20
+ | { kind: "stop"; sessionId: string; targetRequestId: string }
21
+ | {
22
+ kind: "answer";
23
+ sessionId: string;
24
+ interactionId: string;
25
+ selectedIndex: number | null;
26
+ }
27
+ | {
28
+ kind: "confirm";
29
+ sessionId: string;
30
+ interactionId: string;
31
+ decision: "allow" | "deny";
32
+ }
33
+ );
34
+
35
+ export type OperationReceipt = {
36
+ requestId: string;
37
+ sessionId: string;
38
+ kind: RemoteOperationInput["kind"];
39
+ status: OperationStatus;
40
+ createdAt: string;
41
+ updatedAt: string;
42
+ turnId?: string;
43
+ prompt?: string;
44
+ error?: string;
45
+ };
46
+
47
+ export type RemoteSessionInfo = {
48
+ id: string;
49
+ workspaceId: string;
50
+ title: string;
51
+ modelName: string;
52
+ owner: "service" | "local";
53
+ status: string;
54
+ updatedAt: string;
55
+ };
56
+
57
+ export type RemoteInteraction =
58
+ | {
59
+ id: string;
60
+ kind: "question";
61
+ question: string;
62
+ options: readonly { description: string }[];
63
+ }
64
+ | { id: string; kind: "confirmation"; command: string; reason: string };
65
+
66
+ export type RemoteTool = {
67
+ id: string;
68
+ name: string;
69
+ arguments: string;
70
+ status: "running" | "completed" | "failed" | "cancelled";
71
+ detail?: string;
72
+ };
73
+
74
+ export type RemoteActivity = {
75
+ session: RemoteSessionInfo;
76
+ status: "idle" | OperationStatus;
77
+ activeRequestId?: string;
78
+ activeTurnId?: string;
79
+ streaming?: { iterationId: string; attempt: number; text: string };
80
+ tools: RemoteTool[];
81
+ interaction?: RemoteInteraction;
82
+ operations: OperationReceipt[];
83
+ error?: string;
84
+ };
85
+
86
+ export type RemoteView = RemoteActivity & { history: RemoteHistoryPage };
87
+ export type RemoteChange = { activity: RemoteActivity; messages: RemoteMessage[] };
88
+ export type RemoteCursor = { epoch: string; sequence: number };
89
+ export type RemoteFrame = { version: 1; epoch: string; sequence: number } & (
90
+ | { type: "snapshot"; view: RemoteView }
91
+ | { type: "event"; change: RemoteChange }
92
+ );
93
+
94
+ export class RemoteError extends Error {
95
+ constructor(
96
+ readonly status: number,
97
+ readonly code: string,
98
+ message: string,
99
+ ) {
100
+ super(message);
101
+ this.name = "RemoteError";
102
+ }
103
+ }
104
+
105
+ export function isTerminal(status: OperationStatus): boolean {
106
+ return ["completed", "failed", "cancelled", "interrupted"].includes(status);
107
+ }
108
+
109
+ const ID = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,100}$/;
110
+ const UUID =
111
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
112
+ export function requireId(value: unknown, name: string, uuid = false): string {
113
+ if (typeof value !== "string" || !(uuid ? UUID : ID).test(value)) {
114
+ throw new RemoteError(400, "INVALID_REQUEST", `${name} is invalid.`);
115
+ }
116
+ return value;
117
+ }
118
+
119
+ export function parseOperation(value: unknown): RemoteOperationInput {
120
+ const object = requireObject(value);
121
+ const requestId = requireId(object.requestId, "requestId", true);
122
+ const kind = object.kind;
123
+ const sessionId = () => requireId(object.sessionId, "sessionId", true);
124
+ let result: RemoteOperationInput;
125
+ switch (kind) {
126
+ case "create":
127
+ result = {
128
+ requestId,
129
+ kind,
130
+ workspaceId: requireId(object.workspaceId, "workspaceId"),
131
+ ...(object.title === undefined
132
+ ? {}
133
+ : { title: requireText(object.title, "title", 240) }),
134
+ };
135
+ break;
136
+ case "adopt":
137
+ result = {
138
+ requestId,
139
+ kind,
140
+ workspaceId: requireId(object.workspaceId, "workspaceId"),
141
+ sessionId: sessionId(),
142
+ };
143
+ break;
144
+ case "prompt":
145
+ result = {
146
+ requestId,
147
+ kind,
148
+ sessionId: sessionId(),
149
+ prompt: requireText(object.prompt, "prompt", 64 * 1024),
150
+ };
151
+ break;
152
+ case "stop":
153
+ result = {
154
+ requestId,
155
+ kind,
156
+ sessionId: sessionId(),
157
+ targetRequestId: requireId(object.targetRequestId, "targetRequestId", true),
158
+ };
159
+ break;
160
+ case "confirm":
161
+ if (object.decision !== "allow" && object.decision !== "deny") {
162
+ throw new RemoteError(
163
+ 400,
164
+ "INVALID_REQUEST",
165
+ "decision must be allow or deny.",
166
+ );
167
+ }
168
+ result = {
169
+ requestId,
170
+ kind,
171
+ sessionId: sessionId(),
172
+ interactionId: requireId(object.interactionId, "interactionId", true),
173
+ decision: object.decision,
174
+ };
175
+ break;
176
+ case "answer": {
177
+ const selectedIndex = object.selectedIndex;
178
+ if (
179
+ selectedIndex !== null &&
180
+ (typeof selectedIndex !== "number" ||
181
+ !Number.isSafeInteger(selectedIndex) ||
182
+ selectedIndex < 0)
183
+ ) {
184
+ throw new RemoteError(
185
+ 400,
186
+ "INVALID_REQUEST",
187
+ "selectedIndex must be a nonnegative integer or null.",
188
+ );
189
+ }
190
+ result = {
191
+ requestId,
192
+ kind,
193
+ sessionId: sessionId(),
194
+ interactionId: requireId(object.interactionId, "interactionId", true),
195
+ selectedIndex,
196
+ };
197
+ break;
198
+ }
199
+ default:
200
+ throw new RemoteError(400, "INVALID_REQUEST", "Unknown operation kind.");
201
+ }
202
+ if (Object.keys(object).some((key) => !(key in result))) {
203
+ throw new RemoteError(400, "INVALID_REQUEST", "Unexpected operation field.");
204
+ }
205
+ return result;
206
+ }
207
+
208
+ export function requireObject(value: unknown): Record<string, unknown> {
209
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
210
+ throw new RemoteError(400, "INVALID_REQUEST", "Expected an object.");
211
+ }
212
+ return value as Record<string, unknown>;
213
+ }
214
+
215
+ export function requireText(value: unknown, name: string, maxBytes: number): string {
216
+ if (
217
+ typeof value !== "string" ||
218
+ !value.trim() ||
219
+ Buffer.byteLength(value) > maxBytes
220
+ ) {
221
+ throw new RemoteError(
222
+ 400,
223
+ "INVALID_REQUEST",
224
+ `${name} must contain 1–${maxBytes} bytes.`,
225
+ );
226
+ }
227
+ return value;
228
+ }
@@ -0,0 +1,175 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { chmod, mkdir } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { stableJsonStringify, sha256 } from "../model/model-request-preflight";
5
+ import { parseSessionId } from "../ids/runtime-id";
6
+ import { SessionLease } from "../session/session-lock";
7
+ import {
8
+ RemoteError,
9
+ type RemoteOperationInput,
10
+ type OperationReceipt,
11
+ type RemoteSessionInfo,
12
+ } from "./protocol";
13
+
14
+ export type ManagedSessionRecord = RemoteSessionInfo & {
15
+ workspacePath: string;
16
+ initialized: boolean;
17
+ };
18
+ type ReceiptRow = {
19
+ device: string;
20
+ fingerprint: string;
21
+ receipt: string;
22
+ input: string;
23
+ };
24
+ const SERVICE_LEASE_ID = parseSessionId("00000000-0000-7000-8000-000000000001");
25
+
26
+ /** Durable acceptance receipts, separate from the canonical conversation databases. */
27
+ export class RemoteServiceStore {
28
+ private constructor(
29
+ private readonly db: Database,
30
+ private readonly lease: SessionLease,
31
+ ) {}
32
+
33
+ static async open(directory: string): Promise<RemoteServiceStore> {
34
+ await mkdir(directory, { recursive: true, mode: 0o700 });
35
+ await chmod(directory, 0o700);
36
+ const lease = await SessionLease.acquire({
37
+ sessionDirectory: directory,
38
+ sessionId: SERVICE_LEASE_ID,
39
+ });
40
+ let db: Database | undefined;
41
+ try {
42
+ const filename = path.join(directory, "remote.sqlite");
43
+ db = new Database(filename, { create: true, strict: true });
44
+ await chmod(filename, 0o600);
45
+ db.exec(
46
+ "PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL; PRAGMA busy_timeout=5000;",
47
+ );
48
+ const version = (
49
+ db.query("PRAGMA user_version").get() as { user_version: number }
50
+ ).user_version;
51
+ if (version !== 0 && version !== 1)
52
+ throw new Error("Unsupported remote state schema.");
53
+ db.exec(`CREATE TABLE IF NOT EXISTS managed_sessions (id TEXT PRIMARY KEY, record TEXT NOT NULL) STRICT;
54
+ CREATE TABLE IF NOT EXISTS operations (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, device TEXT NOT NULL, fingerprint TEXT NOT NULL, input TEXT NOT NULL, receipt TEXT NOT NULL) STRICT;
55
+ CREATE INDEX IF NOT EXISTS operations_session ON operations(session_id);
56
+ PRAGMA user_version=1;`);
57
+ const store = new RemoteServiceStore(db, lease);
58
+ for (const row of db.query("SELECT receipt FROM operations").all() as {
59
+ receipt: string;
60
+ }[]) {
61
+ const receipt = JSON.parse(row.receipt) as OperationReceipt;
62
+ if (["accepted", "running", "waiting_input"].includes(receipt.status)) {
63
+ store.update({
64
+ ...receipt,
65
+ status: "interrupted",
66
+ error:
67
+ "The local service process stopped. This request will not be replayed automatically.",
68
+ });
69
+ }
70
+ }
71
+ return store;
72
+ } catch (error) {
73
+ db?.close();
74
+ await lease.release();
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ sessions(): ManagedSessionRecord[] {
80
+ return (
81
+ this.db.query("SELECT record FROM managed_sessions").all() as { record: string }[]
82
+ ).map((row) => JSON.parse(row.record) as ManagedSessionRecord);
83
+ }
84
+ session(id: string): ManagedSessionRecord | undefined {
85
+ const row = this.db
86
+ .query("SELECT record FROM managed_sessions WHERE id = ?")
87
+ .get(id) as { record: string } | null;
88
+ return row ? (JSON.parse(row.record) as ManagedSessionRecord) : undefined;
89
+ }
90
+ saveSession(record: ManagedSessionRecord): void {
91
+ this.db
92
+ .query(
93
+ "INSERT INTO managed_sessions VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record=excluded.record",
94
+ )
95
+ .run(record.id, JSON.stringify(record));
96
+ }
97
+ existing(input: RemoteOperationInput, device: string): OperationReceipt | undefined {
98
+ const row = this.db
99
+ .query("SELECT * FROM operations WHERE id = ?")
100
+ .get(input.requestId) as ReceiptRow | null;
101
+ if (!row) return undefined;
102
+ if (row.device !== device || row.fingerprint !== sha256(stableJsonStringify(input)))
103
+ throw new RemoteError(
104
+ 409,
105
+ "REQUEST_ID_REUSED",
106
+ "This request ID was already used with different data or by another device.",
107
+ );
108
+ return JSON.parse(row.receipt) as OperationReceipt;
109
+ }
110
+ accept(
111
+ input: RemoteOperationInput,
112
+ device: string,
113
+ sessionId: string,
114
+ session?: ManagedSessionRecord,
115
+ ): OperationReceipt {
116
+ const now = new Date().toISOString();
117
+ const receipt: OperationReceipt = {
118
+ requestId: input.requestId,
119
+ kind: input.kind,
120
+ sessionId,
121
+ status: "accepted",
122
+ createdAt: now,
123
+ updatedAt: now,
124
+ ...(input.kind === "prompt" ? { prompt: input.prompt } : {}),
125
+ };
126
+ this.db.transaction(() => {
127
+ this.db
128
+ .query("INSERT INTO operations VALUES (?, ?, ?, ?, ?, ?)")
129
+ .run(
130
+ input.requestId,
131
+ sessionId,
132
+ device,
133
+ sha256(stableJsonStringify(input)),
134
+ JSON.stringify(input),
135
+ JSON.stringify(receipt),
136
+ );
137
+ if (session) this.saveSession(session);
138
+ })();
139
+ return receipt;
140
+ }
141
+ get(id: string): OperationReceipt {
142
+ const row = this.db
143
+ .query("SELECT receipt FROM operations WHERE id = ?")
144
+ .get(id) as { receipt: string } | null;
145
+ if (!row)
146
+ throw new RemoteError(
147
+ 404,
148
+ "REQUEST_NOT_FOUND",
149
+ "Request was not accepted by this service.",
150
+ );
151
+ return JSON.parse(row.receipt) as OperationReceipt;
152
+ }
153
+ operations(sessionId: string): OperationReceipt[] {
154
+ return (
155
+ this.db
156
+ .query(
157
+ "SELECT receipt FROM operations WHERE session_id = ? ORDER BY rowid DESC LIMIT 100",
158
+ )
159
+ .all(sessionId) as { receipt: string }[]
160
+ )
161
+ .reverse()
162
+ .map((row) => JSON.parse(row.receipt) as OperationReceipt);
163
+ }
164
+ update(receipt: OperationReceipt): OperationReceipt {
165
+ const next = { ...receipt, updatedAt: new Date().toISOString() };
166
+ this.db
167
+ .query("UPDATE operations SET receipt = ? WHERE id = ?")
168
+ .run(JSON.stringify(next), receipt.requestId);
169
+ return next;
170
+ }
171
+ async close(): Promise<void> {
172
+ this.db.close();
173
+ await this.lease.release();
174
+ }
175
+ }
@@ -0,0 +1,219 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createUuidV7 } from "../ids/uuid-v7";
3
+ import { parseSessionId } from "../ids/runtime-id";
4
+ import { SessionCatalog } from "../session/session-catalog";
5
+ import {
6
+ HostedSession,
7
+ type HostedRuntimeFactory,
8
+ } from "../agent/runtime-hosted-session";
9
+ import type { RemoteWorkspaceConfig } from "./config";
10
+ import { RemoteServiceStore, type ManagedSessionRecord } from "./service-store";
11
+ import {
12
+ RemoteError,
13
+ type RemoteOperationInput,
14
+ type OperationReceipt,
15
+ type RemoteSessionInfo,
16
+ } from "./protocol";
17
+
18
+ export class RemoteService {
19
+ readonly epoch = randomUUID();
20
+ private readonly hosted = new Map<string, HostedSession>();
21
+ private submitting: Promise<void> = Promise.resolve();
22
+ private stopping = false;
23
+ constructor(
24
+ readonly store: RemoteServiceStore,
25
+ readonly workspaces: readonly RemoteWorkspaceConfig[],
26
+ private readonly factory: HostedRuntimeFactory,
27
+ private readonly homeRoot?: string,
28
+ ) {}
29
+
30
+ async initialize(): Promise<void> {
31
+ // Reacquire every managed canonical lease; no prompt is resubmitted on boot.
32
+ for (const record of this.store.sessions()) {
33
+ if (!record.initialized) continue;
34
+ try {
35
+ await this.session(record.id).open();
36
+ } catch {
37
+ /* A failed workspace/session remains visible with its error. */
38
+ }
39
+ }
40
+ }
41
+ workspace(id: string): RemoteWorkspaceConfig {
42
+ const workspace = this.workspaces.find((workspace) => workspace.id === id);
43
+ if (!workspace)
44
+ throw new RemoteError(
45
+ 404,
46
+ "WORKSPACE_NOT_FOUND",
47
+ "This workspace is not configured on the Mac.",
48
+ );
49
+ return workspace;
50
+ }
51
+ session(id: string): HostedSession {
52
+ const existing = this.hosted.get(id);
53
+ if (existing) return existing;
54
+ const record = this.store.session(id);
55
+ if (!record)
56
+ throw new RemoteError(
57
+ 404,
58
+ "SESSION_NOT_MANAGED",
59
+ "Attach this local session before connecting to it.",
60
+ );
61
+ const workspace = this.workspace(record.workspaceId);
62
+ if (record.workspacePath !== workspace.path)
63
+ throw new RemoteError(
64
+ 409,
65
+ "WORKSPACE_CHANGED",
66
+ "The managed session belongs to a different workspace path.",
67
+ );
68
+ const hosted = new HostedSession(record, this.store, this.epoch, this.factory);
69
+ this.hosted.set(id, hosted);
70
+ return hosted;
71
+ }
72
+ async listSessions(workspaceId: string): Promise<RemoteSessionInfo[]> {
73
+ const workspace = this.workspace(workspaceId);
74
+ const local = await new SessionCatalog({
75
+ workspaceRoot: workspace.path,
76
+ homeRoot: this.homeRoot,
77
+ }).listAll();
78
+ const managed = this.store
79
+ .sessions()
80
+ .filter((record) => record.workspaceId === workspaceId);
81
+ return [
82
+ ...managed.map((record) => {
83
+ const view = this.hosted.get(record.id)?.view();
84
+ return (
85
+ view?.session ?? {
86
+ id: record.id,
87
+ workspaceId,
88
+ title: record.title,
89
+ modelName: record.modelName,
90
+ owner: "service" as const,
91
+ status: record.initialized ? "idle" : "interrupted",
92
+ updatedAt: record.updatedAt,
93
+ }
94
+ );
95
+ }),
96
+ ...local
97
+ .filter((summary) => !managed.some((record) => record.id === summary.sessionId))
98
+ .map((summary) => ({
99
+ id: summary.sessionId,
100
+ workspaceId,
101
+ title: summary.firstUserPromptPreview ?? "Empty session",
102
+ modelName: summary.modelName,
103
+ owner: "local" as const,
104
+ status: summary.status,
105
+ updatedAt: summary.updatedAt,
106
+ })),
107
+ ].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
108
+ }
109
+
110
+ 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));
113
+ this.submitting = result.then(
114
+ () => undefined,
115
+ () => undefined,
116
+ );
117
+ return result;
118
+ }
119
+ private async accept(
120
+ input: RemoteOperationInput,
121
+ device: string,
122
+ ): Promise<OperationReceipt> {
123
+ const existing = this.store.existing(input, device);
124
+ if (existing) return existing;
125
+ if (this.stopping)
126
+ throw new RemoteError(503, "SERVICE_STOPPING", "The local service is stopping.");
127
+ if (input.kind === "create" || input.kind === "adopt") {
128
+ 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
+ }
178
+ const receipt = this.store.accept(input, device, id, record);
179
+ const hosted = this.session(id);
180
+ void hosted.open().then(
181
+ () => {
182
+ this.store.update({ ...receipt, status: "completed" });
183
+ hosted.receiptChanged();
184
+ },
185
+ (error: unknown) => {
186
+ this.store.update({
187
+ ...receipt,
188
+ status: "failed",
189
+ error: error instanceof Error ? error.message : String(error),
190
+ });
191
+ },
192
+ );
193
+ return receipt;
194
+ }
195
+ const session = this.session(input.sessionId);
196
+ // Attach/initialization must be complete before a new mutation can be accepted.
197
+ await session.open();
198
+ session.validate(input);
199
+ const receipt = this.store.accept(input, device, input.sessionId);
200
+ if (input.kind === "prompt") session.enqueue(receipt);
201
+ else session.control(input, receipt);
202
+ return receipt;
203
+ }
204
+
205
+ async close(): Promise<void> {
206
+ this.stopping = true;
207
+ await this.submitting;
208
+ const results = await Promise.allSettled(
209
+ [...this.hosted.values()].map((session) => session.close()),
210
+ );
211
+ await this.store.close();
212
+ const errors = results.filter((result) => result.status === "rejected");
213
+ if (errors.length)
214
+ throw new AggregateError(
215
+ errors.map((result) => result.reason as unknown),
216
+ "Remote service shutdown failed.",
217
+ );
218
+ }
219
+ }