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.
Files changed (61) hide show
  1. package/CHANGELOG.md +69 -1
  2. package/README.md +30 -1
  3. package/package.json +5 -3
  4. package/src/agent/loop.ts +50 -13
  5. package/src/agent/runtime-hosted-session.ts +443 -0
  6. package/src/agent/runtime-provider-retry.ts +115 -0
  7. package/src/agent/runtime-session-contracts.ts +11 -0
  8. package/src/agent/runtime-session.ts +29 -0
  9. package/src/cli/command-line.ts +26 -2
  10. package/src/cli/connect-runner.tsx +26 -0
  11. package/src/cli/main.ts +26 -0
  12. package/src/cli/output.ts +1 -1
  13. package/src/cli/public-cli-contract.ts +18 -0
  14. package/src/cli/public-config-contract.ts +1 -1
  15. package/src/cli/serve-runner.ts +45 -0
  16. package/src/cli/serve-runtime.ts +100 -0
  17. package/src/cli/tui-runner.tsx +1 -0
  18. package/src/context/context-swap-renderer.ts +14 -0
  19. package/src/events/types.ts +8 -0
  20. package/src/image/abortable-file-open.ts +54 -0
  21. package/src/image/image-asset-store.ts +7 -1
  22. package/src/model/fake-model-client.ts +20 -1
  23. package/src/model/openai-model-utils.ts +45 -1
  24. package/src/model/openai-responses-mapping.ts +11 -0
  25. package/src/model/openai-responses-stream.ts +14 -0
  26. package/src/observation/observation-builder.ts +87 -37
  27. package/src/remote/client.ts +350 -0
  28. package/src/remote/config.ts +95 -0
  29. package/src/remote/http-server.ts +240 -0
  30. package/src/remote/protocol.ts +228 -0
  31. package/src/remote/service-store.ts +175 -0
  32. package/src/remote/service.ts +219 -0
  33. package/src/remote/sync-hub.ts +95 -0
  34. package/src/session/remote-history-reader.ts +143 -0
  35. package/src/session/scoped-query-database.ts +27 -0
  36. package/src/session/session-history-access.ts +4 -3
  37. package/src/session/session-store.ts +7 -3
  38. package/src/tools/bash-task.ts +26 -16
  39. package/src/tools/bash.ts +44 -2
  40. package/src/tools/glob.ts +107 -19
  41. package/src/tools/grep-output.ts +130 -0
  42. package/src/tools/grep-pagination.ts +73 -0
  43. package/src/tools/grep-path.ts +11 -0
  44. package/src/tools/grep-snippets.ts +111 -0
  45. package/src/tools/grep.ts +148 -155
  46. package/src/tools/read.ts +0 -9
  47. package/src/tools/ripgrep.ts +19 -26
  48. package/src/tools/shell-process.ts +30 -4
  49. package/src/tools/task-stop.ts +2 -1
  50. package/src/tools/terminal-screen.ts +11 -2
  51. package/src/tools/types.ts +30 -2
  52. package/src/tui/app.tsx +45 -3
  53. package/src/tui/components/ask-user.tsx +15 -8
  54. package/src/tui/components/prompt-input.tsx +14 -6
  55. package/src/tui/components/timeline.tsx +17 -9
  56. package/src/tui/event-store.ts +25 -2
  57. package/src/tui/file-mention.ts +29 -5
  58. package/src/tui/remote-app.tsx +210 -0
  59. package/src/tui/tui-projection-store.ts +5 -2
  60. package/src/tui/tui-session-controller.ts +8 -0
  61. 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
- return `Glob failed for pattern=${JSON.stringify(raw.pattern)}: ${raw.error ?? "Unknown error."}`;
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
- `matchCount=${raw.matchCount ?? matches.length}`,
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 === 0 ? "(no matches)" : matches.join("\n"),
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 (raw.mode === "files_with_matches") {
207
- sections.push(
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
- `Found ${raw.numMatches ?? 0} total occurrence${(raw.numMatches ?? 0) === 1 ? "" : "s"} across ${raw.numFiles} file${raw.numFiles === 1 ? "" : "s"}.`,
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
- raw.content === undefined || raw.content === ""
227
- ? "No matches found"
228
- : raw.content,
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 (raw.truncated === true && raw.error !== undefined) {
238
- sections.push(`Warning: results are incomplete. ${raw.error}`);
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 renderGrepPagination(raw: GrepRawResult): string | undefined {
245
- const parts: string[] = [];
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
- if (raw.appliedLimit !== undefined) {
248
- parts.push(`limit: ${raw.appliedLimit}`);
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 !== undefined) {
252
- parts.push(`offset: ${raw.appliedOffset}`);
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 parts.length === 0
256
- ? undefined
257
- : `[Showing results with pagination = ${parts.join(", ")}]`;
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
+ }