tinker-agent 2.9.0 → 2.11.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 +69 -1
- package/README.md +30 -1
- package/package.json +5 -3
- package/src/agent/loop.ts +50 -13
- package/src/agent/runtime-hosted-session.ts +443 -0
- package/src/agent/runtime-provider-retry.ts +115 -0
- package/src/agent/runtime-session-contracts.ts +11 -0
- package/src/agent/runtime-session.ts +29 -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/serve-runner.ts +45 -0
- package/src/cli/serve-runtime.ts +100 -0
- package/src/cli/tui-runner.tsx +1 -0
- package/src/context/context-swap-renderer.ts +14 -0
- package/src/events/types.ts +8 -0
- package/src/image/abortable-file-open.ts +54 -0
- package/src/image/image-asset-store.ts +7 -1
- package/src/model/fake-model-client.ts +20 -1
- package/src/model/openai-model-utils.ts +45 -1
- package/src/model/openai-responses-mapping.ts +11 -0
- package/src/model/openai-responses-stream.ts +14 -0
- package/src/observation/observation-builder.ts +87 -37
- 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/scoped-query-database.ts +27 -0
- package/src/session/session-history-access.ts +4 -3
- package/src/session/session-store.ts +7 -3
- package/src/tools/bash-task.ts +26 -16
- 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 +148 -155
- package/src/tools/read.ts +0 -9
- package/src/tools/ripgrep.ts +19 -26
- package/src/tools/shell-process.ts +30 -4
- package/src/tools/task-stop.ts +2 -1
- package/src/tools/terminal-screen.ts +11 -2
- package/src/tools/types.ts +30 -2
- package/src/tui/app.tsx +45 -3
- package/src/tui/components/ask-user.tsx +15 -8
- package/src/tui/components/prompt-input.tsx +14 -6
- package/src/tui/components/timeline.tsx +17 -9
- package/src/tui/event-store.ts +25 -2
- package/src/tui/file-mention.ts +29 -5
- package/src/tui/remote-app.tsx +210 -0
- package/src/tui/tui-projection-store.ts +5 -2
- package/src/tui/tui-session-controller.ts +8 -0
- package/src/tui/workspace-file-search.ts +21 -0
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
WriteFileRawResult,
|
|
36
36
|
} from "../tools/types";
|
|
37
37
|
import { MAX_MEMORY_TEXT_BYTES, truncateUtf8 } from "../memory/contracts";
|
|
38
|
+
import { formatGrepPath } from "../tools/grep-path";
|
|
38
39
|
|
|
39
40
|
export type ToolObservation = {
|
|
40
41
|
readonly content: readonly ToolResultContent[];
|
|
@@ -181,18 +182,30 @@ function assertNever(value: never): never {
|
|
|
181
182
|
|
|
182
183
|
function renderGlobObservation(raw: GlobRawResult): string {
|
|
183
184
|
if (!raw.ok) {
|
|
184
|
-
|
|
185
|
+
const pattern =
|
|
186
|
+
raw.pattern === undefined ? "(missing or invalid)" : JSON.stringify(raw.pattern);
|
|
187
|
+
return `Glob failed for pattern=${pattern}, searchPath=${JSON.stringify(raw.searchPath)}: ${raw.error ?? "Unknown error."}`;
|
|
185
188
|
}
|
|
186
189
|
|
|
187
190
|
const matches = raw.matches ?? [];
|
|
191
|
+
const totalMatches = raw.totalMatches ?? raw.matchCount ?? matches.length;
|
|
188
192
|
|
|
189
193
|
return [
|
|
190
194
|
`Glob succeeded for pattern=${JSON.stringify(raw.pattern)}.`,
|
|
191
195
|
`searchPath=${raw.searchPath}`,
|
|
192
|
-
`
|
|
196
|
+
`totalMatches=${totalMatches}`,
|
|
197
|
+
`returnedCount=${raw.returnedCount ?? matches.length}`,
|
|
198
|
+
`hasMore=${raw.hasMore ?? false}`,
|
|
199
|
+
...(raw.hasMore && raw.nextOffset !== undefined
|
|
200
|
+
? [`nextOffset=${raw.nextOffset}`]
|
|
201
|
+
: []),
|
|
193
202
|
`ignored=${(raw.ignored ?? []).join(",")}`,
|
|
194
203
|
"matches:",
|
|
195
|
-
matches.length
|
|
204
|
+
matches.length > 0
|
|
205
|
+
? matches.join("\n")
|
|
206
|
+
: totalMatches === 0
|
|
207
|
+
? "(no matches)"
|
|
208
|
+
: `(no results on this page at offset ${raw.appliedOffset ?? 0})`,
|
|
196
209
|
].join("\n");
|
|
197
210
|
}
|
|
198
211
|
|
|
@@ -202,31 +215,51 @@ function renderGrepObservation(raw: GrepRawResult): string {
|
|
|
202
215
|
}
|
|
203
216
|
|
|
204
217
|
const sections: string[] = [];
|
|
218
|
+
const paginated = raw.appliedLimit !== undefined || (raw.appliedOffset ?? 0) > 0;
|
|
219
|
+
const incomplete = grepSearchIncomplete(raw);
|
|
220
|
+
const empty = raw.mode === "content" ? !raw.content : raw.numFiles === 0;
|
|
205
221
|
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
raw.numFiles === 0
|
|
209
|
-
? "No files found"
|
|
210
|
-
: [
|
|
211
|
-
`Found ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}`,
|
|
212
|
-
...raw.filenames,
|
|
213
|
-
].join("\n"),
|
|
214
|
-
);
|
|
215
|
-
} else if (raw.mode === "count") {
|
|
216
|
-
if (raw.numFiles === 0) {
|
|
222
|
+
if (empty) {
|
|
223
|
+
if (raw.totalResults === 0 && !incomplete) {
|
|
217
224
|
sections.push("No matches found");
|
|
225
|
+
} else if ((raw.appliedOffset ?? 0) > 0) {
|
|
226
|
+
sections.push(`No results on this page at offset ${raw.appliedOffset}.`);
|
|
218
227
|
} else {
|
|
219
|
-
sections.push(raw.content ?? "");
|
|
220
228
|
sections.push(
|
|
221
|
-
|
|
229
|
+
incomplete
|
|
230
|
+
? "No results available in this partial output."
|
|
231
|
+
: "No matches found",
|
|
222
232
|
);
|
|
223
233
|
}
|
|
224
|
-
} else {
|
|
234
|
+
} else if (raw.mode === "files_with_matches") {
|
|
225
235
|
sections.push(
|
|
226
|
-
|
|
227
|
-
? "
|
|
228
|
-
|
|
236
|
+
[
|
|
237
|
+
`${paginated || incomplete ? "Showing" : "Found"} ${raw.numFiles} matching file${raw.numFiles === 1 ? "" : "s"}${paginated ? " on this page" : ""}`,
|
|
238
|
+
...raw.filenames.map(formatGrepPath),
|
|
239
|
+
].join("\n"),
|
|
229
240
|
);
|
|
241
|
+
} else if (raw.mode === "count" || raw.mode === "count-matches") {
|
|
242
|
+
const mode = raw.mode;
|
|
243
|
+
sections.push(
|
|
244
|
+
raw.counts !== undefined
|
|
245
|
+
? raw.counts
|
|
246
|
+
.map(
|
|
247
|
+
(entry) =>
|
|
248
|
+
`${formatGrepPath(entry.filePath)}: ${grepCountLabel(mode, entry.count)}`,
|
|
249
|
+
)
|
|
250
|
+
.join("\n")
|
|
251
|
+
: // Legacy stored results have only display text. Never use it to compute totals.
|
|
252
|
+
(raw.content ?? "").replace(
|
|
253
|
+
/:(\d+)$/gm,
|
|
254
|
+
(_suffix, count: string) => `: ${grepCountLabel(mode, Number(count))}`,
|
|
255
|
+
),
|
|
256
|
+
);
|
|
257
|
+
const scope = paginated ? "This page" : incomplete ? "Results shown" : "Total";
|
|
258
|
+
sections.push(
|
|
259
|
+
`${scope}: ${grepCountLabel(mode, raw.numMatches ?? 0)} across ${raw.numFiles} matching file${raw.numFiles === 1 ? "" : "s"}.`,
|
|
260
|
+
);
|
|
261
|
+
} else {
|
|
262
|
+
sections.push(raw.content ?? "");
|
|
230
263
|
}
|
|
231
264
|
|
|
232
265
|
const pagination = renderGrepPagination(raw);
|
|
@@ -234,27 +267,49 @@ function renderGrepObservation(raw: GrepRawResult): string {
|
|
|
234
267
|
sections.push(pagination);
|
|
235
268
|
}
|
|
236
269
|
|
|
237
|
-
if (
|
|
238
|
-
sections.push(
|
|
270
|
+
if (incomplete) {
|
|
271
|
+
sections.push(
|
|
272
|
+
`Warning: results are incomplete. ${raw.error ?? "Search did not finish."}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
if (raw.contextMayBeIncomplete === true) {
|
|
276
|
+
sections.push(
|
|
277
|
+
"Warning: requested context may be incomplete because the search stopped early. Narrow the search and retry.",
|
|
278
|
+
);
|
|
239
279
|
}
|
|
240
280
|
|
|
241
281
|
return sections.join("\n\n");
|
|
242
282
|
}
|
|
243
283
|
|
|
244
|
-
function
|
|
245
|
-
|
|
284
|
+
function grepCountLabel(mode: "count" | "count-matches", count: number): string {
|
|
285
|
+
return mode === "count"
|
|
286
|
+
? `${count} matching line${count === 1 ? "" : "s"}`
|
|
287
|
+
: `${count} match${count === 1 ? "" : "es"}`;
|
|
288
|
+
}
|
|
246
289
|
|
|
247
|
-
|
|
248
|
-
|
|
290
|
+
function renderGrepPagination(raw: GrepRawResult): string | undefined {
|
|
291
|
+
const incomplete = grepSearchIncomplete(raw);
|
|
292
|
+
const hasMore = raw.hasMore ?? raw.appliedLimit !== undefined;
|
|
293
|
+
const nextOffset =
|
|
294
|
+
raw.nextOffset ??
|
|
295
|
+
(raw.appliedLimit === undefined
|
|
296
|
+
? undefined
|
|
297
|
+
: (raw.appliedOffset ?? 0) + raw.appliedLimit);
|
|
298
|
+
if (hasMore && nextOffset !== undefined) {
|
|
299
|
+
return `More ${incomplete ? "collected " : ""}results available; nextOffset=${nextOffset}.`;
|
|
249
300
|
}
|
|
250
301
|
|
|
251
|
-
if (raw.appliedOffset !==
|
|
252
|
-
|
|
302
|
+
if ((raw.appliedOffset ?? 0) > 0 && raw.totalResults !== 0) {
|
|
303
|
+
return incomplete
|
|
304
|
+
? "End of collected results; search is incomplete."
|
|
305
|
+
: "End of results.";
|
|
253
306
|
}
|
|
254
307
|
|
|
255
|
-
return
|
|
256
|
-
|
|
257
|
-
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function grepSearchIncomplete(raw: GrepRawResult): boolean {
|
|
312
|
+
return raw.searchIncomplete ?? (raw.truncated === true && raw.error !== undefined);
|
|
258
313
|
}
|
|
259
314
|
|
|
260
315
|
function renderReadObservation(raw: ReadFileRawResult): string {
|
|
@@ -269,7 +324,6 @@ function renderReadObservation(raw: ReadFileRawResult): string {
|
|
|
269
324
|
|
|
270
325
|
return [
|
|
271
326
|
`Read succeeded for ${raw.filePath}.`,
|
|
272
|
-
`sha256=${raw.sha256}`,
|
|
273
327
|
`sizeBytes=${raw.sizeBytes ?? 0}`,
|
|
274
328
|
`contentBytes=${raw.contentBytes ?? 0}`,
|
|
275
329
|
`totalLines=${raw.totalLines ?? 0}`,
|
|
@@ -489,8 +543,6 @@ function renderWriteObservation(raw: WriteFileRawResult): string {
|
|
|
489
543
|
return [
|
|
490
544
|
`Write succeeded for ${raw.filePath}.`,
|
|
491
545
|
`bytesWritten=${raw.bytesWritten ?? 0}`,
|
|
492
|
-
`oldSha256=${raw.oldSha256 ?? "null"}`,
|
|
493
|
-
`newSha256=${raw.newSha256}`,
|
|
494
546
|
].join("\n");
|
|
495
547
|
}
|
|
496
548
|
|
|
@@ -508,8 +560,6 @@ function renderEditObservation(raw: EditFileRawResult): string {
|
|
|
508
560
|
`replacementCount=${raw.replacementCount ?? 0}`,
|
|
509
561
|
`replaceAll=${raw.replaceAll ?? false}`,
|
|
510
562
|
`created=${raw.created ?? false}`,
|
|
511
|
-
`oldSha256=${raw.oldSha256 ?? "null"}`,
|
|
512
|
-
`newSha256=${raw.newSha256}`,
|
|
513
563
|
].join("\n");
|
|
514
564
|
}
|
|
515
565
|
|
|
@@ -644,7 +694,7 @@ function renderTaskInputObservation(raw: TaskInputRawResult): string {
|
|
|
644
694
|
}
|
|
645
695
|
|
|
646
696
|
return [
|
|
647
|
-
"Terminal input sent.",
|
|
697
|
+
raw.writtenBytes === 0 ? "Terminal screen polled." : "Terminal input sent.",
|
|
648
698
|
`taskId=${raw.taskId}`,
|
|
649
699
|
`status=${raw.status}`,
|
|
650
700
|
`writtenBytes=${raw.writtenBytes}`,
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import { readFile, chmod, writeFile, rename, mkdir } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { WebSocketOptions } from "bun";
|
|
5
|
+
import type {
|
|
6
|
+
RemoteHistoryPage,
|
|
7
|
+
RemoteMessage,
|
|
8
|
+
} from "../session/remote-history-reader";
|
|
9
|
+
import {
|
|
10
|
+
requireObject,
|
|
11
|
+
requireText,
|
|
12
|
+
type RemoteOperationInput,
|
|
13
|
+
type RemoteView,
|
|
14
|
+
type RemoteFrame,
|
|
15
|
+
type OperationReceipt,
|
|
16
|
+
type RemoteSessionInfo,
|
|
17
|
+
} from "./protocol";
|
|
18
|
+
|
|
19
|
+
export type RemoteClientConfig = {
|
|
20
|
+
url: string;
|
|
21
|
+
token: string;
|
|
22
|
+
ca?: string;
|
|
23
|
+
statePath: string;
|
|
24
|
+
};
|
|
25
|
+
export async function loadRemoteClientConfig(
|
|
26
|
+
filename: string,
|
|
27
|
+
): Promise<RemoteClientConfig> {
|
|
28
|
+
const raw = requireObject(JSON.parse(await readFile(filename, "utf8")));
|
|
29
|
+
const url = new URL(requireText(raw.url, "url", 4096));
|
|
30
|
+
if (
|
|
31
|
+
url.protocol !== "https:" ||
|
|
32
|
+
url.username ||
|
|
33
|
+
url.password ||
|
|
34
|
+
url.search ||
|
|
35
|
+
url.hash ||
|
|
36
|
+
url.pathname !== "/"
|
|
37
|
+
)
|
|
38
|
+
throw new Error("Pairing URL must be an HTTPS origin.");
|
|
39
|
+
const token = requireText(raw.token, "token", 128);
|
|
40
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(token))
|
|
41
|
+
throw new Error("Pairing token is invalid.");
|
|
42
|
+
const ca =
|
|
43
|
+
raw.caFile === undefined
|
|
44
|
+
? undefined
|
|
45
|
+
: await readFile(
|
|
46
|
+
path.resolve(path.dirname(filename), requireText(raw.caFile, "caFile", 4096)),
|
|
47
|
+
"utf8",
|
|
48
|
+
);
|
|
49
|
+
return { url: url.origin, token, ca, statePath: `${filename}.state.json` };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ClientState = {
|
|
53
|
+
sessionId?: string;
|
|
54
|
+
workspaceId?: string;
|
|
55
|
+
outbox: RemoteOperationInput[];
|
|
56
|
+
failures: { requestId: string; message: string }[];
|
|
57
|
+
};
|
|
58
|
+
export type ClientSnapshot = {
|
|
59
|
+
connection: "connecting" | "online" | "offline" | "closed";
|
|
60
|
+
view?: RemoteView;
|
|
61
|
+
pending: number;
|
|
62
|
+
error?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function mergeRemoteMessages(
|
|
66
|
+
before: readonly RemoteMessage[],
|
|
67
|
+
after: readonly RemoteMessage[],
|
|
68
|
+
): RemoteMessage[] {
|
|
69
|
+
const all = new Map(before.map((message) => [message.id, message]));
|
|
70
|
+
for (const message of after) all.set(message.id, message);
|
|
71
|
+
return [...all.values()].sort((a, b) => a.ordinal - b.ordinal);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyRemoteFrame(
|
|
75
|
+
current: RemoteFrame | undefined,
|
|
76
|
+
frame: RemoteFrame,
|
|
77
|
+
): RemoteFrame {
|
|
78
|
+
if (frame.version !== 1) throw new Error("Unsupported remote protocol version.");
|
|
79
|
+
if (frame.type === "snapshot") return frame;
|
|
80
|
+
if (!current || current.type !== "snapshot" || frame.epoch !== current.epoch)
|
|
81
|
+
throw new Error("A full snapshot is required.");
|
|
82
|
+
if (frame.sequence <= current.sequence) return current;
|
|
83
|
+
if (frame.sequence !== current.sequence + 1)
|
|
84
|
+
throw new Error("Missing event; a full snapshot is required.");
|
|
85
|
+
return {
|
|
86
|
+
version: 1,
|
|
87
|
+
type: "snapshot",
|
|
88
|
+
epoch: frame.epoch,
|
|
89
|
+
sequence: frame.sequence,
|
|
90
|
+
view: {
|
|
91
|
+
...frame.change.activity,
|
|
92
|
+
history: {
|
|
93
|
+
...current.view.history,
|
|
94
|
+
messages: mergeRemoteMessages(
|
|
95
|
+
current.view.history.messages,
|
|
96
|
+
frame.change.messages,
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Optional terminal transport. Disk outbox IDs survive a lost HTTP response/restart. */
|
|
104
|
+
export class RemoteClient {
|
|
105
|
+
private state: ClientState = { outbox: [], failures: [] };
|
|
106
|
+
private frame?: RemoteFrame;
|
|
107
|
+
private socket?: WebSocket;
|
|
108
|
+
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
|
109
|
+
private retryDelay = 500;
|
|
110
|
+
private closed = false;
|
|
111
|
+
private flushing = false;
|
|
112
|
+
private diskTail: Promise<void> = Promise.resolve();
|
|
113
|
+
private snapshot: ClientSnapshot = { connection: "connecting", pending: 0 };
|
|
114
|
+
private readonly listeners = new Set<() => void>();
|
|
115
|
+
constructor(readonly config: RemoteClientConfig) {}
|
|
116
|
+
async initialize(): Promise<void> {
|
|
117
|
+
try {
|
|
118
|
+
this.state = JSON.parse(
|
|
119
|
+
await readFile(this.config.statePath, "utf8"),
|
|
120
|
+
) as ClientState;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
123
|
+
}
|
|
124
|
+
this.emit({ pending: this.state.outbox.length });
|
|
125
|
+
if (this.state.sessionId) this.watch(this.state.sessionId);
|
|
126
|
+
void this.flush();
|
|
127
|
+
}
|
|
128
|
+
getSnapshot = (): ClientSnapshot => this.snapshot;
|
|
129
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
130
|
+
this.listeners.add(listener);
|
|
131
|
+
return () => this.listeners.delete(listener);
|
|
132
|
+
};
|
|
133
|
+
get sessionId(): string | undefined {
|
|
134
|
+
return this.state.sessionId;
|
|
135
|
+
}
|
|
136
|
+
get workspaceId(): string | undefined {
|
|
137
|
+
return this.state.workspaceId;
|
|
138
|
+
}
|
|
139
|
+
workspaces(): Promise<{ workspaces: { id: string; name: string }[] }> {
|
|
140
|
+
return this.request("/v1/workspaces");
|
|
141
|
+
}
|
|
142
|
+
sessions(workspaceId: string): Promise<{ sessions: RemoteSessionInfo[] }> {
|
|
143
|
+
return this.request(`/v1/workspaces/${workspaceId}/sessions`);
|
|
144
|
+
}
|
|
145
|
+
operation(id: string): Promise<OperationReceipt> {
|
|
146
|
+
return this.request(`/v1/operations/${id}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async select(sessionId: string, workspaceId: string): Promise<void> {
|
|
150
|
+
this.state.sessionId = sessionId;
|
|
151
|
+
this.state.workspaceId = workspaceId;
|
|
152
|
+
await this.persist();
|
|
153
|
+
this.frame = undefined;
|
|
154
|
+
this.emit({ view: undefined });
|
|
155
|
+
this.watch(sessionId);
|
|
156
|
+
}
|
|
157
|
+
async submit(
|
|
158
|
+
input: Omit<RemoteOperationInput, "requestId"> & Record<string, unknown>,
|
|
159
|
+
): Promise<string> {
|
|
160
|
+
const request = { ...input, requestId: randomUUID() } as RemoteOperationInput;
|
|
161
|
+
this.state.outbox.push(request);
|
|
162
|
+
await this.persist();
|
|
163
|
+
this.emit({ pending: this.state.outbox.length });
|
|
164
|
+
void this.flush();
|
|
165
|
+
return request.requestId;
|
|
166
|
+
}
|
|
167
|
+
async loadOlderHistory(): Promise<void> {
|
|
168
|
+
if (this.frame?.type !== "snapshot") return;
|
|
169
|
+
const id = this.state.sessionId;
|
|
170
|
+
const before = this.frame.view.history.beforeOrdinal;
|
|
171
|
+
if (!id || !before) return;
|
|
172
|
+
const page = await this.request<RemoteHistoryPage>(
|
|
173
|
+
`/v1/sessions/${id}/history?before=${before}`,
|
|
174
|
+
);
|
|
175
|
+
if (id !== this.state.sessionId || this.frame?.type !== "snapshot") return;
|
|
176
|
+
this.frame = {
|
|
177
|
+
...this.frame,
|
|
178
|
+
view: {
|
|
179
|
+
...this.frame.view,
|
|
180
|
+
history: {
|
|
181
|
+
...page,
|
|
182
|
+
messages: mergeRemoteMessages(
|
|
183
|
+
page.messages,
|
|
184
|
+
this.frame.view.history.messages,
|
|
185
|
+
),
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
this.emit({ view: this.frame.view });
|
|
190
|
+
}
|
|
191
|
+
private async flush(): Promise<void> {
|
|
192
|
+
if (this.flushing || this.closed) return;
|
|
193
|
+
this.flushing = true;
|
|
194
|
+
try {
|
|
195
|
+
while (this.state.outbox.length && !this.closed) {
|
|
196
|
+
const input = this.state.outbox[0];
|
|
197
|
+
try {
|
|
198
|
+
const receipt = await this.request<OperationReceipt>("/v1/operations", input);
|
|
199
|
+
this.state.outbox.shift();
|
|
200
|
+
await this.persist();
|
|
201
|
+
if (input.kind === "create" || input.kind === "adopt")
|
|
202
|
+
await this.select(receipt.sessionId, input.workspaceId);
|
|
203
|
+
this.emit({ pending: this.state.outbox.length, error: undefined });
|
|
204
|
+
} catch (error) {
|
|
205
|
+
this.emit({
|
|
206
|
+
error: error instanceof Error ? error.message : String(error),
|
|
207
|
+
connection: "offline",
|
|
208
|
+
});
|
|
209
|
+
if (
|
|
210
|
+
error instanceof ClientHttpError &&
|
|
211
|
+
error.status >= 400 &&
|
|
212
|
+
error.status < 500 &&
|
|
213
|
+
error.status !== 429
|
|
214
|
+
) {
|
|
215
|
+
this.state.outbox.shift();
|
|
216
|
+
this.state.failures.push({
|
|
217
|
+
requestId: input.requestId,
|
|
218
|
+
message: error.message,
|
|
219
|
+
});
|
|
220
|
+
this.state.failures = this.state.failures.slice(-20);
|
|
221
|
+
await this.persist();
|
|
222
|
+
this.emit({ pending: this.state.outbox.length });
|
|
223
|
+
} else {
|
|
224
|
+
this.reconnect();
|
|
225
|
+
break;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
} finally {
|
|
230
|
+
this.flushing = false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
private watch(id: string): void {
|
|
234
|
+
this.socket?.close();
|
|
235
|
+
if (this.closed) return;
|
|
236
|
+
const url = new URL(`/v1/sessions/${id}/events`, this.config.url);
|
|
237
|
+
url.protocol = "wss:";
|
|
238
|
+
if (this.frame) {
|
|
239
|
+
url.searchParams.set("epoch", this.frame.epoch);
|
|
240
|
+
url.searchParams.set("after", String(this.frame.sequence));
|
|
241
|
+
}
|
|
242
|
+
this.emit({ connection: "connecting" });
|
|
243
|
+
const BunWebSocket = WebSocket as unknown as {
|
|
244
|
+
new (url: URL, options: WebSocketOptions): WebSocket;
|
|
245
|
+
};
|
|
246
|
+
const socket = new BunWebSocket(url, {
|
|
247
|
+
headers: { Authorization: `Bearer ${this.config.token}` },
|
|
248
|
+
...(this.config.ca
|
|
249
|
+
? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
|
|
250
|
+
: {}),
|
|
251
|
+
});
|
|
252
|
+
this.socket = socket;
|
|
253
|
+
socket.onmessage = (event) => {
|
|
254
|
+
if (socket !== this.socket || this.closed) return;
|
|
255
|
+
try {
|
|
256
|
+
this.frame = applyRemoteFrame(
|
|
257
|
+
this.frame,
|
|
258
|
+
JSON.parse(String(event.data)) as RemoteFrame,
|
|
259
|
+
);
|
|
260
|
+
this.retryDelay = 500;
|
|
261
|
+
this.emit({
|
|
262
|
+
connection: "online",
|
|
263
|
+
view: this.frame.type === "snapshot" ? this.frame.view : undefined,
|
|
264
|
+
error: undefined,
|
|
265
|
+
});
|
|
266
|
+
void this.flush();
|
|
267
|
+
} catch {
|
|
268
|
+
this.frame = undefined;
|
|
269
|
+
socket.close();
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
socket.onclose = () => {
|
|
273
|
+
if (socket === this.socket && !this.closed) {
|
|
274
|
+
this.emit({ connection: "offline" });
|
|
275
|
+
this.reconnect();
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
socket.onerror = () => {
|
|
279
|
+
if (socket === this.socket && !this.closed) {
|
|
280
|
+
this.emit({ connection: "offline" });
|
|
281
|
+
socket.close();
|
|
282
|
+
this.reconnect();
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
private reconnect(): void {
|
|
287
|
+
if (this.closed || this.reconnectTimer) return;
|
|
288
|
+
this.reconnectTimer = setTimeout(() => {
|
|
289
|
+
this.reconnectTimer = undefined;
|
|
290
|
+
if (this.state.sessionId) this.watch(this.state.sessionId);
|
|
291
|
+
void this.flush();
|
|
292
|
+
}, this.retryDelay);
|
|
293
|
+
this.retryDelay = Math.min(this.retryDelay * 2, 10000);
|
|
294
|
+
}
|
|
295
|
+
async request<T>(route: string, input?: unknown): Promise<T> {
|
|
296
|
+
const response = await fetch(new URL(route, this.config.url), {
|
|
297
|
+
method: input === undefined ? "GET" : "POST",
|
|
298
|
+
redirect: "error",
|
|
299
|
+
headers: {
|
|
300
|
+
Authorization: `Bearer ${this.config.token}`,
|
|
301
|
+
"Content-Type": "application/json",
|
|
302
|
+
},
|
|
303
|
+
...(input === undefined ? {} : { body: JSON.stringify(input) }),
|
|
304
|
+
...(this.config.ca
|
|
305
|
+
? { tls: { ca: this.config.ca, rejectUnauthorized: true } }
|
|
306
|
+
: {}),
|
|
307
|
+
signal: AbortSignal.timeout(15000),
|
|
308
|
+
});
|
|
309
|
+
const result = (await response.json()) as T & { error?: { message: string } };
|
|
310
|
+
if (!response.ok)
|
|
311
|
+
throw new ClientHttpError(
|
|
312
|
+
response.status,
|
|
313
|
+
result.error?.message ?? `HTTP ${response.status}`,
|
|
314
|
+
);
|
|
315
|
+
return result;
|
|
316
|
+
}
|
|
317
|
+
private persist(): Promise<void> {
|
|
318
|
+
const data = JSON.stringify(this.state);
|
|
319
|
+
this.diskTail = this.diskTail.then(async () => {
|
|
320
|
+
await mkdir(path.dirname(this.config.statePath), {
|
|
321
|
+
recursive: true,
|
|
322
|
+
mode: 0o700,
|
|
323
|
+
});
|
|
324
|
+
const temp = `${this.config.statePath}.${process.pid}.tmp`;
|
|
325
|
+
await writeFile(temp, data, { mode: 0o600 });
|
|
326
|
+
await chmod(temp, 0o600);
|
|
327
|
+
await rename(temp, this.config.statePath);
|
|
328
|
+
});
|
|
329
|
+
return this.diskTail;
|
|
330
|
+
}
|
|
331
|
+
private emit(patch: Partial<ClientSnapshot>): void {
|
|
332
|
+
this.snapshot = { ...this.snapshot, ...patch };
|
|
333
|
+
for (const listener of this.listeners) listener();
|
|
334
|
+
}
|
|
335
|
+
async close(): Promise<void> {
|
|
336
|
+
this.closed = true;
|
|
337
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
338
|
+
this.socket?.close();
|
|
339
|
+
await this.diskTail;
|
|
340
|
+
this.emit({ connection: "closed" });
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
class ClientHttpError extends Error {
|
|
344
|
+
constructor(
|
|
345
|
+
readonly status: number,
|
|
346
|
+
message: string,
|
|
347
|
+
) {
|
|
348
|
+
super(message);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -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
|
+
}
|