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
@@ -0,0 +1,361 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { createConnection, createServer } from "node:net";
3
+ import {
4
+ chmod,
5
+ lstat,
6
+ mkdir,
7
+ readFile,
8
+ realpath,
9
+ rename,
10
+ unlink,
11
+ writeFile,
12
+ } from "node:fs/promises";
13
+ import path from "node:path";
14
+ import { defaultHomeRoot } from "../session/workspace-storage";
15
+ import { loadRemoteConfig, type RemoteServiceConfig } from "./config";
16
+
17
+ export type LocalServiceTarget = {
18
+ configPath: string;
19
+ config: RemoteServiceConfig;
20
+ fingerprint: string;
21
+ homeRoot: string;
22
+ };
23
+ export type LocalServiceInstance = {
24
+ version: 1;
25
+ appVersion?: string;
26
+ packageRoot?: string;
27
+ instanceId: string;
28
+ pid: number;
29
+ startedAt: string;
30
+ configPath: string;
31
+ stateDirectory: string;
32
+ fingerprint: string;
33
+ url: string;
34
+ };
35
+
36
+ export function defaultServiceConfigPath(env: NodeJS.ProcessEnv): string {
37
+ return path.resolve(defaultHomeRoot(env), ".tinker/service/service.json");
38
+ }
39
+
40
+ export async function loadLocalServiceTarget(
41
+ configPath: string,
42
+ env: NodeJS.ProcessEnv,
43
+ ): Promise<LocalServiceTarget> {
44
+ const canonicalPath = await realpath(configPath).catch((error: unknown) => {
45
+ if ((error as NodeJS.ErrnoException).code === "ENOENT")
46
+ throw new Error(
47
+ `Service configuration not found: ${configPath}. Create it or use --config <path>.`,
48
+ );
49
+ throw error;
50
+ });
51
+ const config = await loadRemoteConfig(canonicalPath);
52
+ const homeRoot = await realpath(defaultHomeRoot(env));
53
+ const fingerprint = createHash("sha256")
54
+ .update(JSON.stringify({ config, homeRoot }))
55
+ .update(await readFile(config.tls.certFile))
56
+ .update(await readFile(config.tls.keyFile))
57
+ .digest("hex");
58
+ return { configPath: canonicalPath, config, fingerprint, homeRoot };
59
+ }
60
+
61
+ export function localServicePaths(directory: string) {
62
+ // Keep Unix paths short even when workspaces/state live deep in macOS temp roots.
63
+ const socketRoot = `/tmp/tinker-service-${process.getuid?.() ?? "unknown"}`;
64
+ const name = createHash("sha256").update(directory).digest("hex").slice(0, 32);
65
+ return {
66
+ socket: path.join(socketRoot, `${name}.sock`),
67
+ instance: path.join(directory, "service-instance.json"),
68
+ log: path.join(directory, "service.log"),
69
+ startup: path.join(directory, "startup"),
70
+ };
71
+ }
72
+
73
+ /** Live, read-only local probe. A PID or a leftover descriptor never proves readiness. */
74
+ export async function discoverLocalService(
75
+ target: LocalServiceTarget,
76
+ allowDifferentConfig = false,
77
+ ): Promise<LocalServiceInstance | undefined> {
78
+ return (await requestLocalService(target, undefined, undefined, allowDifferentConfig))
79
+ ?.instance;
80
+ }
81
+
82
+ export async function shutdownLocalService(
83
+ target: LocalServiceTarget,
84
+ force: boolean,
85
+ ): Promise<LocalServiceInstance | undefined> {
86
+ return (await requestLocalService(target, undefined, { force }))?.instance;
87
+ }
88
+
89
+ export async function registerLocalWorkspace(
90
+ target: LocalServiceTarget,
91
+ directory: string,
92
+ ): Promise<RemoteServiceConfig["workspaces"][number]> {
93
+ const response = await requestLocalService(target, directory);
94
+ if (!response?.workspace)
95
+ throw new Error("Local service is not ready for workspace registration.");
96
+ return response.workspace;
97
+ }
98
+
99
+ type LocalServiceReply = {
100
+ instance: LocalServiceInstance;
101
+ workspace?: RemoteServiceConfig["workspaces"][number];
102
+ error?: string;
103
+ };
104
+
105
+ async function requestLocalService(
106
+ target: LocalServiceTarget,
107
+ directory?: string,
108
+ shutdown?: { force: boolean },
109
+ allowDifferentConfig = false,
110
+ ): Promise<LocalServiceReply | undefined> {
111
+ if (process.platform === "win32")
112
+ throw new Error("Local service discovery currently requires macOS/Linux.");
113
+ const socketPath = localServicePaths(target.config.stateDirectory).socket;
114
+ try {
115
+ const root = await lstat(path.dirname(socketPath));
116
+ if (
117
+ !root.isDirectory() ||
118
+ root.isSymbolicLink() ||
119
+ root.uid !== process.getuid?.() ||
120
+ (root.mode & 0o077) !== 0
121
+ )
122
+ throw new Error(
123
+ "Local discovery socket directory has unsafe ownership or permissions.",
124
+ );
125
+ } catch (error) {
126
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
127
+ throw error;
128
+ }
129
+ const nonce = randomUUID();
130
+ const response = await new Promise<LocalServiceReply | undefined>(
131
+ (resolve, reject) => {
132
+ const socket = createConnection(socketPath);
133
+ socket.setEncoding("utf8");
134
+ let text = "";
135
+ let settled = false;
136
+ const finish = (value?: LocalServiceReply, error?: Error) => {
137
+ if (settled) return;
138
+ settled = true;
139
+ socket.destroy();
140
+ if (error) reject(error);
141
+ else resolve(value);
142
+ };
143
+ socket.setTimeout(
144
+ shutdown
145
+ ? (target.config.resident?.shutdownGraceMs ?? 30000) + 15000
146
+ : directory === undefined
147
+ ? 1000
148
+ : 10000,
149
+ () => finish(),
150
+ );
151
+ socket.once("error", (error: NodeJS.ErrnoException) => {
152
+ if (["ENOENT", "ECONNREFUSED", "ECONNRESET"].includes(error.code ?? ""))
153
+ finish();
154
+ else finish(undefined, error);
155
+ });
156
+ socket.once("connect", () =>
157
+ socket.write(
158
+ `${shutdown ? JSON.stringify({ nonce, command: "shutdown", force: shutdown.force }) : directory === undefined ? nonce : JSON.stringify({ nonce, command: "register-workspace", directory, fingerprint: target.fingerprint })}\n`,
159
+ ),
160
+ );
161
+ socket.on("data", (chunk: string) => {
162
+ text += chunk;
163
+ if (text.length > 16384)
164
+ return finish(undefined, new Error("Invalid local service response."));
165
+ if (!text.includes("\n")) return;
166
+ try {
167
+ const response = JSON.parse(text.trim()) as LocalServiceReply & {
168
+ nonce?: unknown;
169
+ };
170
+ const value = response.instance;
171
+ if (
172
+ response.nonce !== nonce ||
173
+ value?.version !== 1 ||
174
+ typeof value.instanceId !== "string" ||
175
+ !Number.isInteger(value.pid) ||
176
+ typeof value.url !== "string" ||
177
+ typeof value.fingerprint !== "string"
178
+ )
179
+ throw new Error("Invalid local service response.");
180
+ finish(response);
181
+ } catch (error) {
182
+ finish(
183
+ undefined,
184
+ error instanceof Error
185
+ ? error
186
+ : new Error("Invalid local service response."),
187
+ );
188
+ }
189
+ });
190
+ socket.once("end", () => finish());
191
+ },
192
+ );
193
+ if (
194
+ !shutdown &&
195
+ !allowDifferentConfig &&
196
+ response &&
197
+ response.instance.fingerprint !== target.fingerprint
198
+ )
199
+ throw new Error(
200
+ "The running service uses different configuration or TINKER_HOME. Stop it before changing configuration.",
201
+ );
202
+ if (shutdown && response?.error?.startsWith("Invalid local workspace registration"))
203
+ throw new Error(
204
+ `This older service cannot drain through the control endpoint. Stop its verified live pid ${response.instance.pid} explicitly, then retry.`,
205
+ );
206
+ if (response?.error) throw new Error(response.error);
207
+ return response;
208
+ }
209
+
210
+ /** Called only while the canonical service lease is held, after HTTPS is listening. */
211
+ export async function publishLocalService(
212
+ target: LocalServiceTarget,
213
+ instanceId: string,
214
+ port: number,
215
+ registerWorkspace?: (
216
+ directory: string,
217
+ ) => Promise<RemoteServiceConfig["workspaces"][number]>,
218
+ administration?: {
219
+ appVersion: string;
220
+ status(): Record<string, unknown>;
221
+ shutdown(force: boolean): Promise<() => void>;
222
+ },
223
+ ): Promise<() => Promise<void>> {
224
+ const files = localServicePaths(target.config.stateDirectory);
225
+ const socketRoot = path.dirname(files.socket);
226
+ await mkdir(socketRoot, { recursive: true, mode: 0o700 });
227
+ const rootStat = await lstat(socketRoot);
228
+ if (
229
+ !rootStat.isDirectory() ||
230
+ rootStat.isSymbolicLink() ||
231
+ rootStat.uid !== process.getuid?.() ||
232
+ (rootStat.mode & 0o077) !== 0
233
+ )
234
+ throw new Error(
235
+ "Local discovery socket directory has unsafe ownership or permissions.",
236
+ );
237
+ const instance: LocalServiceInstance = {
238
+ version: 1,
239
+ appVersion: administration?.appVersion,
240
+ packageRoot: await realpath(new URL("../../", import.meta.url)),
241
+ instanceId,
242
+ pid: process.pid,
243
+ startedAt: new Date().toISOString(),
244
+ configPath: target.configPath,
245
+ stateDirectory: target.config.stateDirectory,
246
+ fingerprint: target.fingerprint,
247
+ url: `https://${target.config.hostname === "::1" ? "[::1]" : target.config.hostname}:${port}`,
248
+ };
249
+ await removeIfMissing(files.socket);
250
+ const server = createServer((socket) => {
251
+ socket.setEncoding("utf8");
252
+ let input = "";
253
+ let handling = false;
254
+ socket.setTimeout(1000, () => socket.destroy());
255
+ socket.on("error", () => socket.destroy());
256
+ socket.on("data", (chunk: string) => {
257
+ if (handling) return;
258
+ input += chunk;
259
+ if (input.length > 8192) {
260
+ socket.destroy();
261
+ return;
262
+ }
263
+ if (!input.includes("\n")) return;
264
+ handling = true;
265
+ socket.setTimeout(
266
+ (target.config.resident?.shutdownGraceMs ?? 30000) + 15000,
267
+ () => socket.destroy(),
268
+ );
269
+ const reply = async () => {
270
+ let nonce = input.trim();
271
+ try {
272
+ if (!input.startsWith("{")) {
273
+ socket.end(
274
+ `${JSON.stringify({ nonce, instance: { ...instance, ...administration?.status() } })}\n`,
275
+ );
276
+ return;
277
+ }
278
+ const command = JSON.parse(input.trim()) as {
279
+ nonce: string;
280
+ command: string;
281
+ directory: string;
282
+ fingerprint: string;
283
+ force?: boolean;
284
+ };
285
+ nonce = command.nonce;
286
+ if (
287
+ command.command === "shutdown" &&
288
+ administration &&
289
+ typeof nonce === "string" &&
290
+ nonce.length <= 100 &&
291
+ typeof command.force === "boolean"
292
+ ) {
293
+ const stop = await administration.shutdown(command.force);
294
+ socket.end(`${JSON.stringify({ nonce, instance })}\n`);
295
+ setTimeout(stop, 100);
296
+ return;
297
+ }
298
+ if (
299
+ typeof nonce !== "string" ||
300
+ nonce.length > 100 ||
301
+ command.command !== "register-workspace" ||
302
+ typeof command.directory !== "string" ||
303
+ command.fingerprint !== target.fingerprint ||
304
+ !registerWorkspace
305
+ )
306
+ throw new Error(
307
+ "Invalid local workspace registration request or service configuration changed.",
308
+ );
309
+ const workspace = await registerWorkspace(command.directory);
310
+ socket.end(`${JSON.stringify({ nonce, instance, workspace })}\n`);
311
+ } catch (error) {
312
+ socket.end(
313
+ `${JSON.stringify({ nonce, instance, error: error instanceof Error ? error.message : String(error) })}\n`,
314
+ );
315
+ }
316
+ };
317
+ void reply();
318
+ });
319
+ });
320
+ let listening = false;
321
+ const temp = `${files.instance}.${instanceId}.tmp`;
322
+ const discoveryTemp = `${files.socket}.json.${instanceId}.tmp`;
323
+ try {
324
+ await new Promise<void>((resolve, reject) => {
325
+ server.once("error", reject);
326
+ server.listen(files.socket, () => {
327
+ listening = true;
328
+ resolve();
329
+ });
330
+ });
331
+ await chmod(files.socket, 0o600);
332
+ await writeFile(temp, `${JSON.stringify(instance)}\n`, { mode: 0o600 });
333
+ await rename(temp, files.instance);
334
+ await writeFile(discoveryTemp, `${JSON.stringify(instance)}\n`, {
335
+ mode: 0o600,
336
+ });
337
+ await rename(discoveryTemp, `${files.socket}.json`);
338
+ } catch (error) {
339
+ await removeIfMissing(discoveryTemp);
340
+ if (listening) await new Promise<void>((resolve) => server.close(() => resolve()));
341
+ await removeIfMissing(temp);
342
+ throw error;
343
+ }
344
+ return async () => {
345
+ await new Promise<void>((resolve) => server.close(() => resolve()));
346
+ await removeIfMissing(files.instance);
347
+ // Keep the installation guard while launchd may restart this process.
348
+ if (
349
+ !(await Bun.file(
350
+ path.join(target.config.stateDirectory, "supervisor.enabled"),
351
+ ).exists())
352
+ )
353
+ await removeIfMissing(`${files.socket}.json`);
354
+ };
355
+ }
356
+
357
+ async function removeIfMissing(filename: string): Promise<void> {
358
+ await unlink(filename).catch((error: unknown) => {
359
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
360
+ });
361
+ }
@@ -0,0 +1,36 @@
1
+ import { PromptHistory } from "../tui/prompt-history";
2
+ import { validatePromptDraft, type PromptDraft } from "../tui/prompt-draft";
3
+ import { promptHistoryPath } from "../cli/config";
4
+ import { RemoteError, requireObject } from "./protocol";
5
+
6
+ const pending = new Map<string, Promise<void>>();
7
+ export async function readPromptHistory(workspaceRoot: string, homeRoot?: string) {
8
+ const filename = await promptHistoryPath(workspaceRoot, homeRoot);
9
+ await pending.get(filename);
10
+ return (await PromptHistory.load(filename)).records;
11
+ }
12
+ export async function appendPromptHistory(
13
+ request: Request,
14
+ workspaceRoot: string,
15
+ homeRoot?: string,
16
+ ): Promise<void> {
17
+ const body = await request.text();
18
+ if (Buffer.byteLength(body) > 96 * 1024)
19
+ throw new RemoteError(413, "BODY_TOO_LARGE", "Prompt history entry is too large.");
20
+ const input = requireObject(JSON.parse(body));
21
+ const prompt = input.prompt as string | PromptDraft;
22
+ if (typeof prompt !== "string") validatePromptDraft(prompt);
23
+ const filename = await promptHistoryPath(workspaceRoot, homeRoot);
24
+ const next = (pending.get(filename) ?? Promise.resolve())
25
+ .catch(() => undefined)
26
+ .then(async () => {
27
+ const history = await PromptHistory.load(filename);
28
+ await history.append(prompt);
29
+ });
30
+ pending.set(filename, next);
31
+ try {
32
+ await next;
33
+ } finally {
34
+ if (pending.get(filename) === next) pending.delete(filename);
35
+ }
36
+ }
@@ -1,3 +1,12 @@
1
+ import type { RemoteFailure } from "./failures";
2
+ import {
3
+ parseSessionOperation,
4
+ type SessionOperation,
5
+ type SessionOperationResult,
6
+ } from "./session-operations";
7
+ import { validateUserMessage, type UserImageAttachment } from "../image/image-types";
8
+ import type { RunAgentResult } from "../agent/types";
9
+ import type { QueueFollowUpResult } from "../agent/runtime-session-contracts";
1
10
  import type {
2
11
  RemoteHistoryPage,
3
12
  RemoteMessage,
@@ -14,9 +23,23 @@ export type OperationStatus =
14
23
  | "interrupted";
15
24
 
16
25
  export type RemoteOperationInput = { requestId: string } & (
17
- | { kind: "create"; workspaceId: string; title?: string }
26
+ | { kind: "create"; workspaceId: string; title?: string; profileName?: string }
27
+ | { kind: "delete_session"; sessionId: string; targetSessionId: string }
28
+ | SessionOperation
18
29
  | { kind: "adopt"; workspaceId: string; sessionId: string }
19
- | { kind: "prompt"; sessionId: string; prompt: string }
30
+ | {
31
+ kind: "prompt";
32
+ sessionId: string;
33
+ prompt: string;
34
+ attachments?: readonly UserImageAttachment[];
35
+ }
36
+ | {
37
+ kind: "provider_retry";
38
+ sessionId: string;
39
+ interactionId: string;
40
+ decision: "retry" | "stop";
41
+ }
42
+ | { kind: "follow_up"; sessionId: string; targetRequestId: string; prompt: string }
20
43
  | { kind: "stop"; sessionId: string; targetRequestId: string }
21
44
  | {
22
45
  kind: "answer";
@@ -33,6 +56,7 @@ export type RemoteOperationInput = { requestId: string } & (
33
56
  );
34
57
 
35
58
  export type OperationReceipt = {
59
+ shutdownInterrupted?: boolean;
36
60
  requestId: string;
37
61
  sessionId: string;
38
62
  kind: RemoteOperationInput["kind"];
@@ -41,7 +65,13 @@ export type OperationReceipt = {
41
65
  updatedAt: string;
42
66
  turnId?: string;
43
67
  prompt?: string;
68
+ attachments?: readonly UserImageAttachment[];
69
+ sessionResult?: SessionOperationResult;
70
+ failure?: RemoteFailure;
44
71
  error?: string;
72
+ result?: RunAgentResult;
73
+ followUp?: QueueFollowUpResult;
74
+ hasFollowUps?: boolean;
45
75
  };
46
76
 
47
77
  export type RemoteSessionInfo = {
@@ -123,16 +153,37 @@ export function parseOperation(value: unknown): RemoteOperationInput {
123
153
  const sessionId = () => requireId(object.sessionId, "sessionId", true);
124
154
  let result: RemoteOperationInput;
125
155
  switch (kind) {
156
+ case "compact":
157
+ case "retire":
158
+ case "undo":
159
+ case "fork":
160
+ case "reasoning":
161
+ case "yolo":
162
+ case "switch_model":
163
+ case "default_profile":
164
+ result = { requestId, ...parseSessionOperation(object) };
165
+ break;
126
166
  case "create":
127
167
  result = {
128
168
  requestId,
129
169
  kind,
130
170
  workspaceId: requireId(object.workspaceId, "workspaceId"),
171
+ ...(object.profileName === undefined
172
+ ? {}
173
+ : { profileName: requireText(object.profileName, "profileName", 240) }),
131
174
  ...(object.title === undefined
132
175
  ? {}
133
176
  : { title: requireText(object.title, "title", 240) }),
134
177
  };
135
178
  break;
179
+ case "delete_session":
180
+ result = {
181
+ kind,
182
+ requestId,
183
+ sessionId: sessionId(),
184
+ targetSessionId: requireId(object.targetSessionId, "targetSessionId", true),
185
+ };
186
+ break;
136
187
  case "adopt":
137
188
  result = {
138
189
  requestId,
@@ -147,6 +198,31 @@ export function parseOperation(value: unknown): RemoteOperationInput {
147
198
  kind,
148
199
  sessionId: sessionId(),
149
200
  prompt: requireText(object.prompt, "prompt", 64 * 1024),
201
+ ...(object.attachments === undefined
202
+ ? {}
203
+ : { attachments: object.attachments as readonly UserImageAttachment[] }),
204
+ };
205
+ try {
206
+ validateUserMessage({
207
+ role: "user",
208
+ content: result.prompt,
209
+ attachments: result.attachments,
210
+ });
211
+ } catch (error) {
212
+ throw new RemoteError(
213
+ 400,
214
+ "INVALID_IMAGE_MESSAGE",
215
+ error instanceof Error ? error.message : String(error),
216
+ );
217
+ }
218
+ break;
219
+ case "follow_up":
220
+ result = {
221
+ requestId,
222
+ kind,
223
+ sessionId: sessionId(),
224
+ targetRequestId: requireId(object.targetRequestId, "targetRequestId", true),
225
+ prompt: requireText(object.prompt, "prompt", 64 * 1024),
150
226
  };
151
227
  break;
152
228
  case "stop":
@@ -157,6 +233,21 @@ export function parseOperation(value: unknown): RemoteOperationInput {
157
233
  targetRequestId: requireId(object.targetRequestId, "targetRequestId", true),
158
234
  };
159
235
  break;
236
+ case "provider_retry":
237
+ if (object.decision !== "retry" && object.decision !== "stop")
238
+ throw new RemoteError(
239
+ 400,
240
+ "INVALID_REQUEST",
241
+ "decision must be retry or stop.",
242
+ );
243
+ result = {
244
+ requestId,
245
+ kind,
246
+ sessionId: sessionId(),
247
+ interactionId: requireId(object.interactionId, "interactionId", true),
248
+ decision: object.decision,
249
+ };
250
+ break;
160
251
  case "confirm":
161
252
  if (object.decision !== "allow" && object.decision !== "deny") {
162
253
  throw new RemoteError(
@@ -0,0 +1,96 @@
1
+ import { RemoteError } from "./protocol";
2
+
3
+ export type ResidentPolicy = {
4
+ maxLoadedSessions: number;
5
+ maxConcurrentTurns: number;
6
+ maxPendingTurns: number;
7
+ idleTimeoutMs: number;
8
+ shutdownGraceMs: number;
9
+ };
10
+ export const DEFAULT_RESIDENT_POLICY: Readonly<ResidentPolicy> = Object.freeze({
11
+ maxLoadedSessions: 16,
12
+ maxConcurrentTurns: 4,
13
+ maxPendingTurns: 32,
14
+ idleTimeoutMs: 5 * 60 * 1000,
15
+ shutdownGraceMs: 30 * 1000,
16
+ });
17
+ export function parseResidentPolicy(value: unknown): ResidentPolicy {
18
+ if (value === undefined) return { ...DEFAULT_RESIDENT_POLICY };
19
+ if (!value || typeof value !== "object" || Array.isArray(value))
20
+ throw new Error("resident must be an object.");
21
+ const entries = value as Record<string, unknown>;
22
+ for (const key of Object.keys(entries))
23
+ if (!(key in DEFAULT_RESIDENT_POLICY))
24
+ throw new Error(`Unknown resident setting: ${key}`);
25
+ const result = { ...DEFAULT_RESIDENT_POLICY };
26
+ for (const key of Object.keys(result) as (keyof ResidentPolicy)[]) {
27
+ const candidate = entries[key] ?? result[key];
28
+ const maximum = key.endsWith("Ms") ? 86400000 : 4096;
29
+ if (
30
+ typeof candidate !== "number" ||
31
+ !Number.isInteger(candidate) ||
32
+ candidate < 1 ||
33
+ candidate > maximum
34
+ )
35
+ throw new Error(
36
+ `Invalid resident.${key}; expected an integer from 1 to ${maximum}.`,
37
+ );
38
+ result[key] = candidate;
39
+ }
40
+ if (
41
+ result.maxConcurrentTurns > result.maxLoadedSessions ||
42
+ result.maxPendingTurns < result.maxConcurrentTurns
43
+ )
44
+ throw new Error(
45
+ "resident requires maxConcurrentTurns <= maxLoadedSessions and maxPendingTurns >= maxConcurrentTurns.",
46
+ );
47
+ return result;
48
+ }
49
+
50
+ /** FIFO execution slots. Queued cancellation never enters the runtime. */
51
+ export class TurnSlots {
52
+ private active = 0;
53
+ private readonly waiting: (() => void)[] = [];
54
+ constructor(private readonly maximum: number) {}
55
+ get running() {
56
+ return this.active;
57
+ }
58
+ get queued() {
59
+ return this.waiting.length;
60
+ }
61
+ acquire(signal: AbortSignal): Promise<() => void> {
62
+ return new Promise((resolve, reject) => {
63
+ const abort = () => {
64
+ const index = this.waiting.indexOf(start);
65
+ if (index >= 0) this.waiting.splice(index, 1);
66
+ reject(
67
+ new RemoteError(409, "TURN_CANCELLED", "Queued execution was cancelled."),
68
+ );
69
+ };
70
+ const start = () => {
71
+ signal.removeEventListener("abort", abort);
72
+ if (signal.aborted) {
73
+ abort();
74
+ return;
75
+ }
76
+ this.active++;
77
+ let released = false;
78
+ resolve(() => {
79
+ if (released) return;
80
+ released = true;
81
+ this.active--;
82
+ this.waiting.shift()?.();
83
+ });
84
+ };
85
+ if (signal.aborted) {
86
+ abort();
87
+ return;
88
+ }
89
+ if (this.active < this.maximum) start();
90
+ else {
91
+ this.waiting.push(start);
92
+ signal.addEventListener("abort", abort, { once: true });
93
+ }
94
+ });
95
+ }
96
+ }