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.
- package/CHANGELOG.md +53 -1
- package/README.md +25 -4
- package/package.json +2 -1
- package/src/agent/runtime-hosted-session.ts +505 -81
- package/src/agent/runtime-session-contracts.ts +9 -0
- package/src/agent/runtime-session.ts +29 -7
- package/src/cli/command-line.ts +159 -11
- package/src/cli/connect-runner.tsx +63 -0
- package/src/cli/connect-workspace.ts +33 -0
- package/src/cli/default-service-config.ts +163 -0
- package/src/cli/default-tui-runner.ts +23 -0
- package/src/cli/interactive-runtime.ts +87 -0
- package/src/cli/local-service-start.ts +131 -0
- package/src/cli/local-tui-client.ts +234 -0
- package/src/cli/main.ts +46 -3
- package/src/cli/public-cli-contract.ts +65 -2
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/serve-runner.ts +155 -21
- package/src/cli/serve-runtime.ts +47 -46
- package/src/cli/service-control.ts +27 -0
- package/src/cli/service-supervisor.ts +170 -0
- package/src/cli/service-upgrade.ts +106 -0
- package/src/cli/tui-runner.tsx +9 -261
- package/src/cli/update-runner.ts +4 -0
- package/src/client/local-workspace-client.ts +238 -0
- package/src/client/model-catalog.ts +15 -0
- package/src/client/remote-images.ts +80 -0
- package/src/client/remote-prompt-history.ts +22 -0
- package/src/client/remote-tasks.ts +231 -0
- package/src/client/remote-tui-view.ts +69 -0
- package/src/client/remote-workspace-client.ts +528 -0
- package/src/client/session-client.ts +109 -0
- package/src/image/image-asset-store.ts +17 -0
- package/src/remote/client.ts +27 -5
- package/src/remote/config.ts +19 -4
- package/src/remote/failures.ts +68 -0
- package/src/remote/http-server.ts +120 -3
- package/src/remote/local-service-discovery.ts +361 -0
- package/src/remote/prompt-history.ts +36 -0
- package/src/remote/protocol.ts +93 -2
- package/src/remote/resident-policy.ts +96 -0
- package/src/remote/service-store.ts +28 -6
- package/src/remote/service.ts +479 -60
- package/src/remote/session-operations.ts +66 -0
- package/src/remote/session-reads.ts +126 -0
- package/src/remote/sync-hub.ts +2 -0
- package/src/remote/tui-protocol.ts +32 -0
- package/src/remote/workspace-resolution.ts +82 -0
- package/src/session/resume-projection.ts +19 -2
- package/src/session/session-lock.ts +32 -0
- package/src/session/session-store-contracts.ts +2 -0
- package/src/session/session-store.ts +7 -4
- package/src/tui/app.tsx +67 -26
- package/src/tui/components/model-picker.tsx +5 -5
- package/src/tui/components/prompt-input.tsx +16 -1
- package/src/tui/components/resume-session-picker.tsx +6 -2
- package/src/tui/tui-projection-store.ts +25 -0
- package/src/tui/tui-session-controller.ts +12 -282
package/src/remote/client.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { decodeFailure, type RemoteFailure } from "./failures";
|
|
1
2
|
import { readFile, chmod, writeFile, rename, mkdir } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import { randomUUID } from "node:crypto";
|
|
@@ -56,6 +57,7 @@ type ClientState = {
|
|
|
56
57
|
failures: { requestId: string; message: string }[];
|
|
57
58
|
};
|
|
58
59
|
export type ClientSnapshot = {
|
|
60
|
+
cursor?: { epoch: string; sequence: number };
|
|
59
61
|
connection: "connecting" | "online" | "offline" | "closed";
|
|
60
62
|
view?: RemoteView;
|
|
61
63
|
pending: number;
|
|
@@ -112,8 +114,12 @@ export class RemoteClient {
|
|
|
112
114
|
private diskTail: Promise<void> = Promise.resolve();
|
|
113
115
|
private snapshot: ClientSnapshot = { connection: "connecting", pending: 0 };
|
|
114
116
|
private readonly listeners = new Set<() => void>();
|
|
115
|
-
constructor(
|
|
117
|
+
constructor(
|
|
118
|
+
readonly config: RemoteClientConfig,
|
|
119
|
+
private readonly persistent = true,
|
|
120
|
+
) {}
|
|
116
121
|
async initialize(): Promise<void> {
|
|
122
|
+
if (!this.persistent) return;
|
|
117
123
|
try {
|
|
118
124
|
this.state = JSON.parse(
|
|
119
125
|
await readFile(this.config.statePath, "utf8"),
|
|
@@ -139,6 +145,14 @@ export class RemoteClient {
|
|
|
139
145
|
workspaces(): Promise<{ workspaces: { id: string; name: string }[] }> {
|
|
140
146
|
return this.request("/v1/workspaces");
|
|
141
147
|
}
|
|
148
|
+
async resolveWorkspace(
|
|
149
|
+
directory: string,
|
|
150
|
+
): Promise<{ id: string; name: string; path: string }> {
|
|
151
|
+
const result = await this.request<{
|
|
152
|
+
workspace: { id: string; name: string; path: string };
|
|
153
|
+
}>(`/v1/workspaces/resolve?${new URLSearchParams({ directory })}`);
|
|
154
|
+
return result.workspace;
|
|
155
|
+
}
|
|
142
156
|
sessions(workspaceId: string): Promise<{ sessions: RemoteSessionInfo[] }> {
|
|
143
157
|
return this.request(`/v1/workspaces/${workspaceId}/sessions`);
|
|
144
158
|
}
|
|
@@ -259,6 +273,7 @@ export class RemoteClient {
|
|
|
259
273
|
);
|
|
260
274
|
this.retryDelay = 500;
|
|
261
275
|
this.emit({
|
|
276
|
+
cursor: { epoch: this.frame.epoch, sequence: this.frame.sequence },
|
|
262
277
|
connection: "online",
|
|
263
278
|
view: this.frame.type === "snapshot" ? this.frame.view : undefined,
|
|
264
279
|
error: undefined,
|
|
@@ -292,7 +307,7 @@ export class RemoteClient {
|
|
|
292
307
|
}, this.retryDelay);
|
|
293
308
|
this.retryDelay = Math.min(this.retryDelay * 2, 10000);
|
|
294
309
|
}
|
|
295
|
-
async request<T>(route: string, input?: unknown): Promise<T> {
|
|
310
|
+
async request<T>(route: string, input?: unknown, signal?: AbortSignal): Promise<T> {
|
|
296
311
|
const response = await fetch(new URL(route, this.config.url), {
|
|
297
312
|
method: input === undefined ? "GET" : "POST",
|
|
298
313
|
redirect: "error",
|
|
@@ -304,9 +319,15 @@ export class RemoteClient {
|
|
|
304
319
|
...(this.config.ca
|
|
305
320
|
? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
|
|
306
321
|
: {}),
|
|
307
|
-
signal:
|
|
322
|
+
signal: signal
|
|
323
|
+
? AbortSignal.any([signal, AbortSignal.timeout(15000)])
|
|
324
|
+
: AbortSignal.timeout(15000),
|
|
308
325
|
});
|
|
309
|
-
const result = (await response.json()) as T & {
|
|
326
|
+
const result = (await response.json()) as T & {
|
|
327
|
+
error?: { message: string };
|
|
328
|
+
failure?: RemoteFailure;
|
|
329
|
+
};
|
|
330
|
+
if (!response.ok && result.failure) throw decodeFailure(result.failure);
|
|
310
331
|
if (!response.ok)
|
|
311
332
|
throw new ClientHttpError(
|
|
312
333
|
response.status,
|
|
@@ -315,6 +336,7 @@ export class RemoteClient {
|
|
|
315
336
|
return result;
|
|
316
337
|
}
|
|
317
338
|
private persist(): Promise<void> {
|
|
339
|
+
if (!this.persistent) return Promise.resolve();
|
|
318
340
|
const data = JSON.stringify(this.state);
|
|
319
341
|
this.diskTail = this.diskTail.then(async () => {
|
|
320
342
|
await mkdir(path.dirname(this.config.statePath), {
|
|
@@ -340,7 +362,7 @@ export class RemoteClient {
|
|
|
340
362
|
this.emit({ connection: "closed" });
|
|
341
363
|
}
|
|
342
364
|
}
|
|
343
|
-
class ClientHttpError extends Error {
|
|
365
|
+
export class ClientHttpError extends Error {
|
|
344
366
|
constructor(
|
|
345
367
|
readonly status: number,
|
|
346
368
|
message: string,
|
package/src/remote/config.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { parseResidentPolicy, type ResidentPolicy } from "./resident-policy";
|
|
1
2
|
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
3
|
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
@@ -10,6 +11,7 @@ export type RemoteWorkspaceConfig = {
|
|
|
10
11
|
profile?: string;
|
|
11
12
|
};
|
|
12
13
|
export type RemoteServiceConfig = {
|
|
14
|
+
resident?: ResidentPolicy;
|
|
13
15
|
stateDirectory: string;
|
|
14
16
|
hostname: string;
|
|
15
17
|
port: number;
|
|
@@ -27,12 +29,11 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
|
|
|
27
29
|
path.resolve(path.dirname(absolute), requireText(value, name, 4096));
|
|
28
30
|
const tls = requireObject(raw.tls);
|
|
29
31
|
const port = raw.port ?? 9443;
|
|
30
|
-
if (typeof port !== "number" || !Number.isInteger(port) || port <
|
|
32
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 0 || port > 65535)
|
|
31
33
|
throw new Error("Invalid service port.");
|
|
32
34
|
if (!Array.isArray(raw.devices) || raw.devices.length === 0)
|
|
33
35
|
throw new Error("At least one paired device is required.");
|
|
34
|
-
if (!Array.isArray(raw.workspaces)
|
|
35
|
-
throw new Error("At least one workspace is required.");
|
|
36
|
+
if (!Array.isArray(raw.workspaces)) throw new Error("Workspaces must be an array.");
|
|
36
37
|
const devices = raw.devices.map((entry) => {
|
|
37
38
|
const device = requireObject(entry);
|
|
38
39
|
const tokenSha256 = requireText(device.tokenSha256, "tokenSha256", 64);
|
|
@@ -69,7 +70,10 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
|
|
|
69
70
|
if (hostname !== "127.0.0.1" && hostname !== "::1")
|
|
70
71
|
throw new Error("Bind the service to loopback; expose only the relay TCP port.");
|
|
71
72
|
return {
|
|
72
|
-
|
|
73
|
+
resident: parseResidentPolicy(raw.resident),
|
|
74
|
+
stateDirectory: await canonicalDirectory(
|
|
75
|
+
resolve(raw.stateDirectory ?? "./state", "stateDirectory"),
|
|
76
|
+
),
|
|
73
77
|
hostname,
|
|
74
78
|
port,
|
|
75
79
|
tls: {
|
|
@@ -81,6 +85,17 @@ export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfi
|
|
|
81
85
|
};
|
|
82
86
|
}
|
|
83
87
|
|
|
88
|
+
async function canonicalDirectory(directory: string): Promise<string> {
|
|
89
|
+
try {
|
|
90
|
+
return await realpath(directory);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
93
|
+
const parent = path.dirname(directory);
|
|
94
|
+
if (parent === directory) throw error;
|
|
95
|
+
return path.join(await canonicalDirectory(parent), path.basename(directory));
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
84
99
|
export function authenticateDevice(
|
|
85
100
|
header: string | null,
|
|
86
101
|
devices: RemoteServiceConfig["devices"],
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ContextManagerError } from "../context/context-manager";
|
|
2
|
+
import { ContextBudgetExceededError } from "../model/model-request-preflight";
|
|
3
|
+
import { ModelRequestMediaAggregateError } from "../model/model-client";
|
|
4
|
+
import { ImageNotRecognizedError } from "../image/image-probe";
|
|
5
|
+
|
|
6
|
+
export type RemoteFailure = {
|
|
7
|
+
name: string;
|
|
8
|
+
message: string;
|
|
9
|
+
maintenance?: Pick<ContextManagerError, "stage" | "code" | "fatal" | "committed">;
|
|
10
|
+
context?: Pick<
|
|
11
|
+
ContextBudgetExceededError,
|
|
12
|
+
"projectedInputTokens" | "inputBudgetTokens" | "triggerTokens" | "source"
|
|
13
|
+
>;
|
|
14
|
+
};
|
|
15
|
+
export function encodeFailure(error: unknown): RemoteFailure {
|
|
16
|
+
return {
|
|
17
|
+
name: error instanceof Error ? error.name : "Error",
|
|
18
|
+
message: error instanceof Error ? error.message : String(error),
|
|
19
|
+
...(error instanceof ContextManagerError
|
|
20
|
+
? {
|
|
21
|
+
maintenance: {
|
|
22
|
+
stage: error.stage,
|
|
23
|
+
code: error.code,
|
|
24
|
+
fatal: error.fatal,
|
|
25
|
+
committed: error.committed,
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
: {}),
|
|
29
|
+
...(error instanceof ContextBudgetExceededError
|
|
30
|
+
? {
|
|
31
|
+
context: {
|
|
32
|
+
projectedInputTokens: error.projectedInputTokens,
|
|
33
|
+
inputBudgetTokens: error.inputBudgetTokens,
|
|
34
|
+
triggerTokens: error.triggerTokens,
|
|
35
|
+
source: error.source,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
: {}),
|
|
39
|
+
...(error instanceof ModelRequestMediaAggregateError
|
|
40
|
+
? { name: "ModelRequestMediaAggregateError" }
|
|
41
|
+
: {}),
|
|
42
|
+
...(error instanceof ImageNotRecognizedError
|
|
43
|
+
? { name: "ImageNotRecognizedError" }
|
|
44
|
+
: {}),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function decodeFailure(failure: RemoteFailure): Error {
|
|
48
|
+
if (failure.name === "ContextManagerError" && failure.maintenance) {
|
|
49
|
+
const m = failure.maintenance;
|
|
50
|
+
return new ContextManagerError(
|
|
51
|
+
m.stage,
|
|
52
|
+
m.code,
|
|
53
|
+
m.fatal,
|
|
54
|
+
m.committed,
|
|
55
|
+
failure.message,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (failure.name === "ContextBudgetExceededError" && failure.context) {
|
|
59
|
+
const error = new Error(failure.message);
|
|
60
|
+
Object.setPrototypeOf(error, ContextBudgetExceededError.prototype);
|
|
61
|
+
return Object.assign(error, { name: failure.name }, failure.context);
|
|
62
|
+
}
|
|
63
|
+
if (failure.name === "ModelRequestMediaAggregateError")
|
|
64
|
+
return new ModelRequestMediaAggregateError(failure.message);
|
|
65
|
+
if (failure.name === "ImageNotRecognizedError")
|
|
66
|
+
return new ImageNotRecognizedError(failure.message);
|
|
67
|
+
return new Error(failure.message);
|
|
68
|
+
}
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { sessionPost, sessionRead } from "./session-reads";
|
|
2
|
+
import { resolveServiceWorkspace } from "./workspace-resolution";
|
|
3
|
+
import { IMAGE_INPUT_POLICY } from "../image/image-input-policy";
|
|
1
4
|
import type { ServerWebSocket } from "bun";
|
|
2
5
|
import { authenticateDevice, type RemoteServiceConfig } from "./config";
|
|
3
6
|
import {
|
|
@@ -14,6 +17,7 @@ type SocketData = {
|
|
|
14
17
|
session: HostedSession;
|
|
15
18
|
cursor?: RemoteCursor;
|
|
16
19
|
unsubscribe?: () => void;
|
|
20
|
+
release?: () => void;
|
|
17
21
|
device: string;
|
|
18
22
|
};
|
|
19
23
|
const SOCKET_BUFFER_LIMIT = 2 * 1024 * 1024;
|
|
@@ -30,9 +34,10 @@ export function startRemoteHttpServer(
|
|
|
30
34
|
cert: Bun.file(config.tls.certFile),
|
|
31
35
|
key: Bun.file(config.tls.keyFile),
|
|
32
36
|
},
|
|
33
|
-
maxRequestBodySize:
|
|
37
|
+
maxRequestBodySize: Math.ceil(IMAGE_INPUT_POLICY.maxBytesPerImage / 3) * 4 + 4096,
|
|
34
38
|
idleTimeout: 30,
|
|
35
39
|
async fetch(request, server) {
|
|
40
|
+
let releaseRequest: (() => void) | undefined;
|
|
36
41
|
try {
|
|
37
42
|
// Native clients authenticate in headers. Browser-origin requests are not supported.
|
|
38
43
|
if (request.headers.has("origin"))
|
|
@@ -55,6 +60,28 @@ export function startRemoteHttpServer(
|
|
|
55
60
|
const parts = url.pathname.split("/").filter(Boolean);
|
|
56
61
|
if (parts[0] !== "v1")
|
|
57
62
|
throw new RemoteError(404, "NOT_FOUND", "Unknown API version or route.");
|
|
63
|
+
if (request.method === "GET" && url.pathname === "/v1/service")
|
|
64
|
+
return json({
|
|
65
|
+
version: 1,
|
|
66
|
+
instanceId: service.epoch,
|
|
67
|
+
...service.residentStatus(),
|
|
68
|
+
});
|
|
69
|
+
if (request.method === "GET" && url.pathname === "/v1/workspaces/resolve") {
|
|
70
|
+
const directory = url.searchParams.get("directory");
|
|
71
|
+
if (!directory || directory.length > 4096)
|
|
72
|
+
throw new RemoteError(
|
|
73
|
+
400,
|
|
74
|
+
"INVALID_DIRECTORY",
|
|
75
|
+
"Provide a workspace directory of at most 4096 characters.",
|
|
76
|
+
);
|
|
77
|
+
const workspace = await resolveServiceWorkspace(
|
|
78
|
+
service.workspaces,
|
|
79
|
+
directory,
|
|
80
|
+
);
|
|
81
|
+
return json({
|
|
82
|
+
workspace: { id: workspace.id, name: workspace.name, path: workspace.path },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
58
85
|
if (request.method === "GET" && url.pathname === "/v1/workspaces") {
|
|
59
86
|
return json({
|
|
60
87
|
version: 1,
|
|
@@ -64,6 +91,19 @@ export function startRemoteHttpServer(
|
|
|
64
91
|
})),
|
|
65
92
|
});
|
|
66
93
|
}
|
|
94
|
+
if (
|
|
95
|
+
request.method === "GET" &&
|
|
96
|
+
parts.length === 5 &&
|
|
97
|
+
parts[1] === "workspaces" &&
|
|
98
|
+
parts[3] === "tui-sessions"
|
|
99
|
+
) {
|
|
100
|
+
return json({
|
|
101
|
+
session: await service.getTuiSession(
|
|
102
|
+
requireId(parts[2], "workspaceId"),
|
|
103
|
+
requireId(parts[4], "sessionId", true),
|
|
104
|
+
),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
67
107
|
if (
|
|
68
108
|
request.method === "GET" &&
|
|
69
109
|
parts.length === 4 &&
|
|
@@ -74,20 +114,83 @@ export function startRemoteHttpServer(
|
|
|
74
114
|
sessions: await service.listSessions(requireId(parts[2], "workspaceId")),
|
|
75
115
|
});
|
|
76
116
|
}
|
|
117
|
+
if (
|
|
118
|
+
request.method === "GET" &&
|
|
119
|
+
parts.length === 4 &&
|
|
120
|
+
parts[1] === "workspaces" &&
|
|
121
|
+
parts[3] === "tui-sessions"
|
|
122
|
+
) {
|
|
123
|
+
return json({
|
|
124
|
+
sessions: await service.listTuiSessions(requireId(parts[2], "workspaceId")),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
77
127
|
if (parts[1] === "operations") {
|
|
78
128
|
if (request.method === "POST" && parts.length === 2) {
|
|
79
129
|
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
80
130
|
throw new RemoteError(415, "JSON_REQUIRED", "Send application/json.");
|
|
81
|
-
const
|
|
131
|
+
const body = await request.text();
|
|
132
|
+
if (Buffer.byteLength(body) > 72 * 1024)
|
|
133
|
+
throw new RemoteError(
|
|
134
|
+
413,
|
|
135
|
+
"BODY_TOO_LARGE",
|
|
136
|
+
"Operation body is too large.",
|
|
137
|
+
);
|
|
138
|
+
const input = parseOperation(JSON.parse(body));
|
|
82
139
|
// Never use request.signal as a runtime cancellation signal.
|
|
83
140
|
return json(await service.submit(input, device), 202);
|
|
84
141
|
}
|
|
85
142
|
if (request.method === "GET" && parts.length === 3)
|
|
86
143
|
return json(service.store.get(requireId(parts[2], "requestId", true)));
|
|
87
144
|
}
|
|
145
|
+
if (
|
|
146
|
+
request.method === "POST" &&
|
|
147
|
+
parts.length === 4 &&
|
|
148
|
+
parts[1] === "sessions"
|
|
149
|
+
) {
|
|
150
|
+
if (service.residentStatus().phase !== "ready")
|
|
151
|
+
throw new RemoteError(
|
|
152
|
+
503,
|
|
153
|
+
"SERVICE_DRAINING",
|
|
154
|
+
"Service is draining; new mutations are not accepted.",
|
|
155
|
+
);
|
|
156
|
+
const session = service.session(requireId(parts[2], "sessionId", true));
|
|
157
|
+
releaseRequest = session.attach(true);
|
|
158
|
+
await session.open();
|
|
159
|
+
session.assertAvailable();
|
|
160
|
+
const response = await sessionPost(
|
|
161
|
+
session,
|
|
162
|
+
parts[3],
|
|
163
|
+
request,
|
|
164
|
+
service.homeRoot,
|
|
165
|
+
);
|
|
166
|
+
if (response) return response;
|
|
167
|
+
}
|
|
88
168
|
if (request.method === "GET" && parts.length === 4 && parts[1] === "sessions") {
|
|
89
169
|
const session = service.session(requireId(parts[2], "sessionId", true));
|
|
170
|
+
releaseRequest = session.attach(true);
|
|
90
171
|
await session.open();
|
|
172
|
+
session.assertAvailable();
|
|
173
|
+
if (
|
|
174
|
+
[
|
|
175
|
+
"files",
|
|
176
|
+
"last-response",
|
|
177
|
+
"view-file",
|
|
178
|
+
"memories",
|
|
179
|
+
"prompt-history",
|
|
180
|
+
"project-commands",
|
|
181
|
+
"git-branch",
|
|
182
|
+
].includes(parts[3])
|
|
183
|
+
)
|
|
184
|
+
return json(
|
|
185
|
+
await sessionRead(
|
|
186
|
+
session,
|
|
187
|
+
parts[3],
|
|
188
|
+
url,
|
|
189
|
+
request.signal,
|
|
190
|
+
service.homeRoot,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
if (parts[3] === "tui-snapshot") return json(session.tuiSnapshot());
|
|
91
194
|
if (parts[3] === "snapshot") return json(session.hub.snapshot());
|
|
92
195
|
if (parts[3] === "history") {
|
|
93
196
|
return json(
|
|
@@ -116,7 +219,17 @@ export function startRemoteHttpServer(
|
|
|
116
219
|
);
|
|
117
220
|
const cursor =
|
|
118
221
|
epoch && sequence !== undefined ? { epoch, sequence } : undefined;
|
|
119
|
-
|
|
222
|
+
const release = session.attach();
|
|
223
|
+
try {
|
|
224
|
+
if (
|
|
225
|
+
server.upgrade(request, { data: { session, cursor, device, release } })
|
|
226
|
+
)
|
|
227
|
+
return;
|
|
228
|
+
} catch (error) {
|
|
229
|
+
release();
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
release();
|
|
120
233
|
throw new RemoteError(
|
|
121
234
|
400,
|
|
122
235
|
"WEBSOCKET_REQUIRED",
|
|
@@ -151,6 +264,8 @@ export function startRemoteHttpServer(
|
|
|
151
264
|
},
|
|
152
265
|
500,
|
|
153
266
|
);
|
|
267
|
+
} finally {
|
|
268
|
+
releaseRequest?.();
|
|
154
269
|
}
|
|
155
270
|
},
|
|
156
271
|
websocket: {
|
|
@@ -185,6 +300,7 @@ export function startRemoteHttpServer(
|
|
|
185
300
|
close(socket) {
|
|
186
301
|
sockets.delete(socket);
|
|
187
302
|
socket.data.unsubscribe?.();
|
|
303
|
+
socket.data.release?.();
|
|
188
304
|
// Detach only. The hosted runtime and its AbortController stay alive.
|
|
189
305
|
},
|
|
190
306
|
},
|
|
@@ -194,6 +310,7 @@ export function startRemoteHttpServer(
|
|
|
194
310
|
async stopTransport() {
|
|
195
311
|
for (const socket of sockets) {
|
|
196
312
|
socket.data.unsubscribe?.();
|
|
313
|
+
socket.data.release?.();
|
|
197
314
|
socket.terminate();
|
|
198
315
|
}
|
|
199
316
|
// Bun 1.3.14 can leave stop's promise pending after a closing TLS socket
|