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.
- package/CHANGELOG.md +66 -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/runner-dependencies.ts +35 -35
- 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/tools/bash.ts +9 -1
- package/src/tools/bounded-output-preview.ts +2 -2
- 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
|
@@ -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
|
+
}
|
package/src/remote/protocol.ts
CHANGED
|
@@ -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
|
-
| {
|
|
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
|
+
}
|