tinker-agent 2.8.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.
- package/CHANGELOG.md +79 -1
- package/README.md +81 -11
- package/package.json +5 -3
- package/src/agent/runtime-context-capabilities.ts +19 -0
- package/src/agent/runtime-context-events.ts +127 -0
- package/src/agent/runtime-context-maintenance.ts +780 -0
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-interactions.ts +291 -0
- package/src/agent/runtime-prompt-scheduler.ts +182 -0
- package/src/agent/runtime-session-contracts.ts +317 -0
- package/src/agent/runtime-session.ts +250 -2130
- package/src/agent/runtime-skills.ts +544 -0
- package/src/cli/command-line.ts +26 -2
- package/src/cli/connect-runner.tsx +26 -0
- package/src/cli/main.ts +26 -0
- package/src/cli/output.ts +1 -1
- package/src/cli/public-cli-contract.ts +18 -0
- package/src/cli/public-config-contract.ts +1 -1
- package/src/cli/runner-dependencies.ts +6 -5
- package/src/cli/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/context/context-automation-policy.ts +12 -118
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +12 -0
- package/src/memory/memory-get-tool.ts +1 -1
- package/src/observation/observation-builder.ts +128 -48
- package/src/remote/client.ts +350 -0
- package/src/remote/config.ts +95 -0
- package/src/remote/http-server.ts +240 -0
- package/src/remote/protocol.ts +228 -0
- package/src/remote/service-store.ts +175 -0
- package/src/remote/service.ts +219 -0
- package/src/remote/sync-hub.ts +95 -0
- package/src/session/remote-history-reader.ts +143 -0
- package/src/session/resume-projection.ts +47 -21
- package/src/session/session-history-access.ts +238 -0
- package/src/session/session-store-context-readers.ts +183 -0
- package/src/session/session-store-ledger-writer.ts +315 -0
- package/src/session/session-store-record-writer.ts +318 -0
- package/src/session/session-store-recovery.ts +225 -0
- package/src/session/session-store-revisions.ts +1004 -0
- package/src/session/session-store-sql.ts +40 -0
- package/src/session/session-store-validation.ts +657 -0
- package/src/session/session-store.ts +756 -3186
- package/src/tools/bash-task.ts +46 -18
- package/src/tools/bash.ts +44 -2
- package/src/tools/glob.ts +107 -19
- package/src/tools/grep-output.ts +130 -0
- package/src/tools/grep-pagination.ts +73 -0
- package/src/tools/grep-path.ts +11 -0
- package/src/tools/grep-snippets.ts +111 -0
- package/src/tools/grep.ts +139 -154
- package/src/tools/read.ts +0 -9
- package/src/tools/recall.ts +106 -50
- package/src/tools/registry.ts +4 -6
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-output-range.ts +146 -0
- package/src/tools/task-output-tool.ts +35 -5
- package/src/tools/task-output.ts +35 -0
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/task-tool-args.ts +34 -0
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +39 -2
- package/src/tui/event-store.ts +23 -5
- package/src/tui/remote-app.tsx +210 -0
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { readFile, realpath, stat } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { requireId, requireObject, requireText } from "./protocol";
|
|
5
|
+
|
|
6
|
+
export type RemoteWorkspaceConfig = {
|
|
7
|
+
id: string;
|
|
8
|
+
name: string;
|
|
9
|
+
path: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
};
|
|
12
|
+
export type RemoteServiceConfig = {
|
|
13
|
+
stateDirectory: string;
|
|
14
|
+
hostname: string;
|
|
15
|
+
port: number;
|
|
16
|
+
tls: { certFile: string; keyFile: string };
|
|
17
|
+
devices: { id: string; name: string; tokenSha256: string }[];
|
|
18
|
+
workspaces: RemoteWorkspaceConfig[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export async function loadRemoteConfig(file: string): Promise<RemoteServiceConfig> {
|
|
22
|
+
const absolute = path.resolve(file);
|
|
23
|
+
const raw = requireObject(JSON.parse(await readFile(absolute, "utf8")));
|
|
24
|
+
if (raw.version !== 1)
|
|
25
|
+
throw new Error("Remote service configuration version must be 1.");
|
|
26
|
+
const resolve = (value: unknown, name: string) =>
|
|
27
|
+
path.resolve(path.dirname(absolute), requireText(value, name, 4096));
|
|
28
|
+
const tls = requireObject(raw.tls);
|
|
29
|
+
const port = raw.port ?? 9443;
|
|
30
|
+
if (typeof port !== "number" || !Number.isInteger(port) || port < 1 || port > 65535)
|
|
31
|
+
throw new Error("Invalid service port.");
|
|
32
|
+
if (!Array.isArray(raw.devices) || raw.devices.length === 0)
|
|
33
|
+
throw new Error("At least one paired device is required.");
|
|
34
|
+
if (!Array.isArray(raw.workspaces) || raw.workspaces.length === 0)
|
|
35
|
+
throw new Error("At least one workspace is required.");
|
|
36
|
+
const devices = raw.devices.map((entry) => {
|
|
37
|
+
const device = requireObject(entry);
|
|
38
|
+
const tokenSha256 = requireText(device.tokenSha256, "tokenSha256", 64);
|
|
39
|
+
if (!/^[0-9a-f]{64}$/.test(tokenSha256))
|
|
40
|
+
throw new Error("Device tokenSha256 must be a lowercase SHA-256 digest.");
|
|
41
|
+
return {
|
|
42
|
+
id: requireId(device.id, "device.id"),
|
|
43
|
+
name: requireText(device.name, "device.name", 240),
|
|
44
|
+
tokenSha256,
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
const workspaces: RemoteWorkspaceConfig[] = [];
|
|
48
|
+
for (const entry of raw.workspaces) {
|
|
49
|
+
const workspace = requireObject(entry);
|
|
50
|
+
const root = await realpath(resolve(workspace.path, "workspace.path"));
|
|
51
|
+
if (!(await stat(root)).isDirectory())
|
|
52
|
+
throw new Error("Workspace must be a directory.");
|
|
53
|
+
workspaces.push({
|
|
54
|
+
id: requireId(workspace.id, "workspace.id"),
|
|
55
|
+
name: requireText(workspace.name, "workspace.name", 240),
|
|
56
|
+
path: root,
|
|
57
|
+
...(workspace.profile === undefined
|
|
58
|
+
? {}
|
|
59
|
+
: { profile: requireText(workspace.profile, "workspace.profile", 240) }),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
new Set(devices.map((d) => d.id)).size !== devices.length ||
|
|
64
|
+
new Set(workspaces.map((w) => w.id)).size !== workspaces.length ||
|
|
65
|
+
new Set(workspaces.map((w) => w.path)).size !== workspaces.length
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Duplicate device/workspace identity.");
|
|
68
|
+
const hostname = raw.hostname ?? "127.0.0.1";
|
|
69
|
+
if (hostname !== "127.0.0.1" && hostname !== "::1")
|
|
70
|
+
throw new Error("Bind the service to loopback; expose only the relay TCP port.");
|
|
71
|
+
return {
|
|
72
|
+
stateDirectory: resolve(raw.stateDirectory, "stateDirectory"),
|
|
73
|
+
hostname,
|
|
74
|
+
port,
|
|
75
|
+
tls: {
|
|
76
|
+
certFile: resolve(tls.certFile, "tls.certFile"),
|
|
77
|
+
keyFile: resolve(tls.keyFile, "tls.keyFile"),
|
|
78
|
+
},
|
|
79
|
+
devices,
|
|
80
|
+
workspaces,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function authenticateDevice(
|
|
85
|
+
header: string | null,
|
|
86
|
+
devices: RemoteServiceConfig["devices"],
|
|
87
|
+
): string | undefined {
|
|
88
|
+
if (!header?.startsWith("Bearer ") || header.length > 256) return undefined;
|
|
89
|
+
const token = header.slice(7);
|
|
90
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(token)) return undefined;
|
|
91
|
+
const digest = createHash("sha256").update(token).digest();
|
|
92
|
+
return devices.find((device) =>
|
|
93
|
+
timingSafeEqual(digest, Buffer.from(device.tokenSha256, "hex")),
|
|
94
|
+
)?.id;
|
|
95
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import type { ServerWebSocket } from "bun";
|
|
2
|
+
import { authenticateDevice, type RemoteServiceConfig } from "./config";
|
|
3
|
+
import {
|
|
4
|
+
RemoteError,
|
|
5
|
+
parseOperation,
|
|
6
|
+
requireId,
|
|
7
|
+
type RemoteCursor,
|
|
8
|
+
type RemoteFrame,
|
|
9
|
+
} from "./protocol";
|
|
10
|
+
import { RemoteService } from "./service";
|
|
11
|
+
import type { HostedSession } from "../agent/runtime-hosted-session";
|
|
12
|
+
|
|
13
|
+
type SocketData = {
|
|
14
|
+
session: HostedSession;
|
|
15
|
+
cursor?: RemoteCursor;
|
|
16
|
+
unsubscribe?: () => void;
|
|
17
|
+
device: string;
|
|
18
|
+
};
|
|
19
|
+
const SOCKET_BUFFER_LIMIT = 2 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
export function startRemoteHttpServer(
|
|
22
|
+
service: RemoteService,
|
|
23
|
+
config: RemoteServiceConfig,
|
|
24
|
+
) {
|
|
25
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
26
|
+
const server = Bun.serve<SocketData>({
|
|
27
|
+
hostname: config.hostname,
|
|
28
|
+
port: config.port,
|
|
29
|
+
tls: {
|
|
30
|
+
cert: Bun.file(config.tls.certFile),
|
|
31
|
+
key: Bun.file(config.tls.keyFile),
|
|
32
|
+
},
|
|
33
|
+
maxRequestBodySize: 72 * 1024,
|
|
34
|
+
idleTimeout: 30,
|
|
35
|
+
async fetch(request, server) {
|
|
36
|
+
try {
|
|
37
|
+
// Native clients authenticate in headers. Browser-origin requests are not supported.
|
|
38
|
+
if (request.headers.has("origin"))
|
|
39
|
+
throw new RemoteError(
|
|
40
|
+
403,
|
|
41
|
+
"ORIGIN_REJECTED",
|
|
42
|
+
"Browser origins are not enabled.",
|
|
43
|
+
);
|
|
44
|
+
const device = authenticateDevice(
|
|
45
|
+
request.headers.get("authorization"),
|
|
46
|
+
config.devices,
|
|
47
|
+
);
|
|
48
|
+
if (!device)
|
|
49
|
+
throw new RemoteError(
|
|
50
|
+
401,
|
|
51
|
+
"AUTH_REQUIRED",
|
|
52
|
+
"Pair this device with the Mac before connecting.",
|
|
53
|
+
);
|
|
54
|
+
const url = new URL(request.url);
|
|
55
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
56
|
+
if (parts[0] !== "v1")
|
|
57
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API version or route.");
|
|
58
|
+
if (request.method === "GET" && url.pathname === "/v1/workspaces") {
|
|
59
|
+
return json({
|
|
60
|
+
version: 1,
|
|
61
|
+
workspaces: service.workspaces.map(({ id, name }) => ({
|
|
62
|
+
id,
|
|
63
|
+
name,
|
|
64
|
+
})),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
if (
|
|
68
|
+
request.method === "GET" &&
|
|
69
|
+
parts.length === 4 &&
|
|
70
|
+
parts[1] === "workspaces" &&
|
|
71
|
+
parts[3] === "sessions"
|
|
72
|
+
) {
|
|
73
|
+
return json({
|
|
74
|
+
sessions: await service.listSessions(requireId(parts[2], "workspaceId")),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (parts[1] === "operations") {
|
|
78
|
+
if (request.method === "POST" && parts.length === 2) {
|
|
79
|
+
if (!request.headers.get("content-type")?.startsWith("application/json"))
|
|
80
|
+
throw new RemoteError(415, "JSON_REQUIRED", "Send application/json.");
|
|
81
|
+
const input = parseOperation(await request.json());
|
|
82
|
+
// Never use request.signal as a runtime cancellation signal.
|
|
83
|
+
return json(await service.submit(input, device), 202);
|
|
84
|
+
}
|
|
85
|
+
if (request.method === "GET" && parts.length === 3)
|
|
86
|
+
return json(service.store.get(requireId(parts[2], "requestId", true)));
|
|
87
|
+
}
|
|
88
|
+
if (request.method === "GET" && parts.length === 4 && parts[1] === "sessions") {
|
|
89
|
+
const session = service.session(requireId(parts[2], "sessionId", true));
|
|
90
|
+
await session.open();
|
|
91
|
+
if (parts[3] === "snapshot") return json(session.hub.snapshot());
|
|
92
|
+
if (parts[3] === "history") {
|
|
93
|
+
return json(
|
|
94
|
+
session.history(
|
|
95
|
+
optionalInteger(
|
|
96
|
+
url.searchParams.get("before"),
|
|
97
|
+
1,
|
|
98
|
+
Number.MAX_SAFE_INTEGER,
|
|
99
|
+
),
|
|
100
|
+
optionalInteger(url.searchParams.get("limit"), 1, 100),
|
|
101
|
+
),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (parts[3] === "events") {
|
|
105
|
+
if (sockets.size >= 64)
|
|
106
|
+
throw new RemoteError(
|
|
107
|
+
429,
|
|
108
|
+
"CONNECTION_LIMIT",
|
|
109
|
+
"Too many connected clients.",
|
|
110
|
+
);
|
|
111
|
+
const epoch = url.searchParams.get("epoch");
|
|
112
|
+
const sequence = optionalInteger(
|
|
113
|
+
url.searchParams.get("after"),
|
|
114
|
+
0,
|
|
115
|
+
Number.MAX_SAFE_INTEGER,
|
|
116
|
+
);
|
|
117
|
+
const cursor =
|
|
118
|
+
epoch && sequence !== undefined ? { epoch, sequence } : undefined;
|
|
119
|
+
if (server.upgrade(request, { data: { session, cursor, device } })) return;
|
|
120
|
+
throw new RemoteError(
|
|
121
|
+
400,
|
|
122
|
+
"WEBSOCKET_REQUIRED",
|
|
123
|
+
"Upgrade this request to WebSocket.",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
throw new RemoteError(404, "NOT_FOUND", "Unknown API route.");
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (error instanceof RemoteError)
|
|
130
|
+
return json(
|
|
131
|
+
{ error: { code: error.code, message: error.message } },
|
|
132
|
+
error.status,
|
|
133
|
+
);
|
|
134
|
+
if (error instanceof SyntaxError)
|
|
135
|
+
return json(
|
|
136
|
+
{
|
|
137
|
+
error: {
|
|
138
|
+
code: "INVALID_JSON",
|
|
139
|
+
message: "Request body is not valid JSON.",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
400,
|
|
143
|
+
);
|
|
144
|
+
return json(
|
|
145
|
+
{
|
|
146
|
+
error: {
|
|
147
|
+
code: "LOCAL_SERVICE_ERROR",
|
|
148
|
+
message:
|
|
149
|
+
error instanceof Error ? error.message : "The local operation failed.",
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
500,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
websocket: {
|
|
157
|
+
maxPayloadLength: 1024,
|
|
158
|
+
backpressureLimit: SOCKET_BUFFER_LIMIT,
|
|
159
|
+
closeOnBackpressureLimit: true,
|
|
160
|
+
idleTimeout: 60,
|
|
161
|
+
sendPings: true,
|
|
162
|
+
open(socket) {
|
|
163
|
+
sockets.add(socket);
|
|
164
|
+
const send = (frame: RemoteFrame) => {
|
|
165
|
+
try {
|
|
166
|
+
if (socket.getBufferedAmount() > SOCKET_BUFFER_LIMIT) {
|
|
167
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const text = JSON.stringify(frame);
|
|
171
|
+
if (socket.send(text) === -1)
|
|
172
|
+
socket.close(1013, "Reconnect to resynchronize");
|
|
173
|
+
} catch {
|
|
174
|
+
socket.close(1011, "Reconnect to resynchronize");
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
socket.data.unsubscribe = socket.data.session.hub.subscribe(
|
|
178
|
+
socket.data.cursor,
|
|
179
|
+
send,
|
|
180
|
+
);
|
|
181
|
+
},
|
|
182
|
+
message(socket) {
|
|
183
|
+
socket.close(1008, "Submit operations over HTTPS");
|
|
184
|
+
},
|
|
185
|
+
close(socket) {
|
|
186
|
+
sockets.delete(socket);
|
|
187
|
+
socket.data.unsubscribe?.();
|
|
188
|
+
// Detach only. The hosted runtime and its AbortController stay alive.
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
return {
|
|
193
|
+
port: server.port!,
|
|
194
|
+
async stopTransport() {
|
|
195
|
+
for (const socket of sockets) {
|
|
196
|
+
socket.data.unsubscribe?.();
|
|
197
|
+
socket.terminate();
|
|
198
|
+
}
|
|
199
|
+
// Bun 1.3.14 can leave stop's promise pending after a closing TLS socket
|
|
200
|
+
// is terminated. stop(true) synchronously closes the listener/connections;
|
|
201
|
+
// bound its completion wait so runtime disposal can always proceed.
|
|
202
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
203
|
+
try {
|
|
204
|
+
await Promise.race([
|
|
205
|
+
server.stop(true),
|
|
206
|
+
new Promise<void>((resolve) => {
|
|
207
|
+
timer = setTimeout(resolve, 250);
|
|
208
|
+
}),
|
|
209
|
+
]);
|
|
210
|
+
} finally {
|
|
211
|
+
if (timer) clearTimeout(timer);
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function json(value: unknown, status = 200): Response {
|
|
218
|
+
return Response.json(value, {
|
|
219
|
+
status,
|
|
220
|
+
headers: {
|
|
221
|
+
"Cache-Control": "no-store",
|
|
222
|
+
"X-Content-Type-Options": "nosniff",
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function optionalInteger(
|
|
227
|
+
value: string | null,
|
|
228
|
+
minimum: number,
|
|
229
|
+
maximum: number,
|
|
230
|
+
): number | undefined {
|
|
231
|
+
if (value === null) return undefined;
|
|
232
|
+
if (
|
|
233
|
+
!/^\d+$/.test(value) ||
|
|
234
|
+
!Number.isSafeInteger(Number(value)) ||
|
|
235
|
+
Number(value) < minimum ||
|
|
236
|
+
Number(value) > maximum
|
|
237
|
+
)
|
|
238
|
+
throw new RemoteError(400, "INVALID_CURSOR", "Invalid history or event cursor.");
|
|
239
|
+
return Number(value);
|
|
240
|
+
}
|
|
@@ -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
|
+
}
|