@termfleet/core 0.1.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 (67) hide show
  1. package/dist/agent-launch.d.ts +78 -0
  2. package/dist/agent-launch.js +247 -0
  3. package/dist/agent-session-id.d.ts +10 -0
  4. package/dist/agent-session-id.js +36 -0
  5. package/dist/agent-session-index-client.d.ts +7 -0
  6. package/dist/agent-session-index-client.js +86 -0
  7. package/dist/agent-session-index-worker.d.ts +1 -0
  8. package/dist/agent-session-index-worker.js +20 -0
  9. package/dist/agent-session-index.d.ts +34 -0
  10. package/dist/agent-session-index.js +527 -0
  11. package/dist/agent-session-tail.d.ts +33 -0
  12. package/dist/agent-session-tail.js +184 -0
  13. package/dist/agent-session-watcher.d.ts +36 -0
  14. package/dist/agent-session-watcher.js +194 -0
  15. package/dist/agent-session.d.ts +380 -0
  16. package/dist/agent-session.js +1688 -0
  17. package/dist/background-runner.d.ts +3 -0
  18. package/dist/background-runner.js +55 -0
  19. package/dist/boot-queue.d.ts +35 -0
  20. package/dist/boot-queue.js +66 -0
  21. package/dist/build-info.d.ts +5 -0
  22. package/dist/build-info.js +38 -0
  23. package/dist/collab/canvas-doc.d.ts +47 -0
  24. package/dist/collab/canvas-doc.js +83 -0
  25. package/dist/contracts/auth.d.ts +77 -0
  26. package/dist/contracts/auth.js +1 -0
  27. package/dist/contracts/canvas.d.ts +34 -0
  28. package/dist/contracts/canvas.js +76 -0
  29. package/dist/contracts/console-layout.d.ts +39 -0
  30. package/dist/contracts/console-layout.js +135 -0
  31. package/dist/contracts/files.d.ts +38 -0
  32. package/dist/contracts/files.js +37 -0
  33. package/dist/contracts/provider-url.d.ts +3 -0
  34. package/dist/contracts/provider-url.js +49 -0
  35. package/dist/contracts/registry.d.ts +58 -0
  36. package/dist/contracts/registry.js +285 -0
  37. package/dist/launch-trace.d.ts +6 -0
  38. package/dist/launch-trace.js +33 -0
  39. package/dist/lib/errors.d.ts +1 -0
  40. package/dist/lib/errors.js +5 -0
  41. package/dist/lib/exec.d.ts +13 -0
  42. package/dist/lib/exec.js +134 -0
  43. package/dist/local-providers.d.ts +32 -0
  44. package/dist/local-providers.js +184 -0
  45. package/dist/local-tunnel.d.ts +6 -0
  46. package/dist/local-tunnel.js +258 -0
  47. package/dist/provider-access-token.d.ts +11 -0
  48. package/dist/provider-access-token.js +77 -0
  49. package/dist/provider-client.d.ts +152 -0
  50. package/dist/provider-client.js +666 -0
  51. package/dist/provider-url-resolver.d.ts +16 -0
  52. package/dist/provider-url-resolver.js +37 -0
  53. package/dist/registry-client.d.ts +93 -0
  54. package/dist/registry-client.js +170 -0
  55. package/dist/registry.d.ts +56 -0
  56. package/dist/registry.js +406 -0
  57. package/dist/session-attention.d.ts +24 -0
  58. package/dist/session-attention.js +54 -0
  59. package/dist/session-lifecycle.d.ts +83 -0
  60. package/dist/session-lifecycle.js +658 -0
  61. package/dist/session-window.d.ts +3 -0
  62. package/dist/session-window.js +20 -0
  63. package/dist/terminal-client.d.ts +49 -0
  64. package/dist/terminal-client.js +89 -0
  65. package/dist/types.d.ts +155 -0
  66. package/dist/types.js +21 -0
  67. package/package.json +26 -0
@@ -0,0 +1,666 @@
1
+ import { asError } from "@termfleet/core/lib/errors.js";
2
+ import { io } from "socket.io-client";
3
+ import { directProviderUrlResolver } from "./provider-url-resolver.js";
4
+ const controlSocketTimeoutMs = 10_000;
5
+ export class ProviderClient {
6
+ ref;
7
+ authToken;
8
+ urls;
9
+ apiBase;
10
+ socket;
11
+ // One fan-out per socket dispatches agent-session events to every subscription,
12
+ // keyed by sessionId — instead of one set of socket listeners per subscription.
13
+ agentSubscriptions = new Map();
14
+ agentListenersSocket;
15
+ constructor(ref, options = {}) {
16
+ this.ref = ref;
17
+ this.authToken = options.authToken;
18
+ this.urls = options.urlResolver ?? directProviderUrlResolver;
19
+ this.apiBase = this.urls.httpBase(ref.baseUrl);
20
+ }
21
+ async health() {
22
+ const response = await this.fetchProviderUrl(`${this.apiBase}/healthz`);
23
+ if (!response.ok) {
24
+ throw new Error(await response.text());
25
+ }
26
+ const health = await response.json();
27
+ if (health.ok !== true || !health.provider) {
28
+ throw new Error(`${this.ref.baseUrl} did not return a valid provider health response.`);
29
+ }
30
+ return health;
31
+ }
32
+ async snapshot() {
33
+ const response = await this.fetchProviderUrl(`${this.apiBase}/api/mirror/snapshot`);
34
+ if (!response.ok) {
35
+ throw new Error(await response.text());
36
+ }
37
+ return parseProviderSnapshot(await response.json(), this.ref.baseUrl);
38
+ }
39
+ async updateProviderSettings(settings) {
40
+ return await this.patchJson("/api/provider-settings", settings);
41
+ }
42
+ async lifecycle() {
43
+ const response = await this.fetchProviderUrl(`${this.apiBase}/api/lifecycle`);
44
+ if (!response.ok) {
45
+ throw new Error(await response.text());
46
+ }
47
+ return parseLifecycleSnapshot(await response.json(), this.ref.baseUrl);
48
+ }
49
+ // Kill orphan `prefix-*` tmux sessions the provider no longer tracks (issue
50
+ // #14) — bulk recovery for a worker stuck at "capacity full" after a crash.
51
+ async pruneOrphanSessions() {
52
+ return await this.postJson("/api/lifecycle/prune", undefined);
53
+ }
54
+ async registryProviders(options = {}) {
55
+ const response = await this.fetchProviderUrl(`${this.apiBase}/api/registry/providers`, {
56
+ headers: options.authToken ? { authorization: `Bearer ${options.authToken}` } : undefined
57
+ });
58
+ if (!response.ok) {
59
+ throw new Error(await response.text());
60
+ }
61
+ return await response.json();
62
+ }
63
+ async registerLocalProvider(provider) {
64
+ return await this.postJson("/api/registry/local-providers", provider);
65
+ }
66
+ async unregisterLocalProvider(baseUrl) {
67
+ const url = new URL(`${this.apiBase}/api/registry/local-providers`);
68
+ url.searchParams.set("baseUrl", baseUrl);
69
+ const response = await this.fetchProviderUrl(url, { method: "DELETE" });
70
+ if (!response.ok) {
71
+ throw new Error(await response.text());
72
+ }
73
+ return await response.json();
74
+ }
75
+ async registerSharedProvider(provider, options) {
76
+ return await this.postJson("/api/registry/remote-providers", provider, {
77
+ authorization: `Bearer ${options.authToken}`
78
+ });
79
+ }
80
+ async unregisterSharedProvider(baseUrl, options) {
81
+ const url = new URL(`${this.apiBase}/api/registry/remote-providers`);
82
+ url.searchParams.set("baseUrl", baseUrl);
83
+ const response = await this.fetchProviderUrl(url, {
84
+ headers: { authorization: `Bearer ${options.authToken}` },
85
+ method: "DELETE"
86
+ });
87
+ if (!response.ok) {
88
+ throw new Error(await response.text());
89
+ }
90
+ return await response.json();
91
+ }
92
+ async startDockerWorker(options = {}) {
93
+ return await this.postJson("/api/docker-worker/start", options);
94
+ }
95
+ async startLocalProvider(options) {
96
+ return await this.postJson("/api/local-provider/start", options);
97
+ }
98
+ async signInToRegistry(identifier, password) {
99
+ return await this.postJson("/api/auth/sign-in", { identifier, password });
100
+ }
101
+ async registrySession(authToken) {
102
+ const response = await this.fetchProviderUrl(`${this.apiBase}/api/auth/session`, {
103
+ headers: { authorization: `Bearer ${authToken}` }
104
+ });
105
+ if (!response.ok) {
106
+ throw new Error(await response.text());
107
+ }
108
+ return await response.json();
109
+ }
110
+ async signOutOfRegistry(authToken) {
111
+ return await this.postJson("/api/auth/sign-out", undefined, {
112
+ authorization: `Bearer ${authToken}`
113
+ });
114
+ }
115
+ async switchRegistryOrganization(organizationId, options) {
116
+ return await this.postJson("/api/auth/active-organization", { organizationId }, {
117
+ authorization: `Bearer ${options.authToken}`
118
+ });
119
+ }
120
+ async captureTerminal(terminalId, lines, options = {}) {
121
+ const url = new URL(`${this.apiBase}/api/terminals/${encodeURIComponent(terminalId)}/capture`);
122
+ if (lines !== undefined) {
123
+ url.searchParams.set("lines", String(lines));
124
+ }
125
+ if (options.preserveEscapes) {
126
+ url.searchParams.set("preserveEscapes", "1");
127
+ }
128
+ const response = await this.fetchProviderUrl(url);
129
+ if (!response.ok) {
130
+ throw new Error(await response.text());
131
+ }
132
+ const result = await response.json();
133
+ if (typeof result.content !== "string") {
134
+ throw new Error(`Provider ${this.ref.baseUrl} did not return terminal capture content.`);
135
+ }
136
+ return { content: result.content };
137
+ }
138
+ async getAgentSession(agent, sessionId, options = {}) {
139
+ const url = new URL(`${this.apiBase}/api/agents/${agent}/sessions/${encodeURIComponent(sessionId)}`);
140
+ if (options.cwd) {
141
+ url.searchParams.set("cwd", options.cwd);
142
+ }
143
+ const response = await this.fetchProviderUrl(url);
144
+ if (!response.ok) {
145
+ throw new Error(await response.text());
146
+ }
147
+ const result = await response.json();
148
+ if (typeof result.sessionId !== "string" || typeof result.lastAssistantText !== "string") {
149
+ throw new Error(`Provider ${this.ref.baseUrl} did not return valid agent session details.`);
150
+ }
151
+ if (result.provider !== agent) {
152
+ throw new Error(`Provider ${this.ref.baseUrl} returned ${result.provider} details for ${agent} session request.`);
153
+ }
154
+ return result;
155
+ }
156
+ async getAgentSubagentSession(sessionId, agentId) {
157
+ const url = new URL(`${this.apiBase}/api/agents/claude/sessions/${encodeURIComponent(sessionId)}/subagents/${encodeURIComponent(agentId)}`);
158
+ const response = await this.fetchProviderUrl(url);
159
+ if (!response.ok) {
160
+ throw new Error(await response.text());
161
+ }
162
+ const result = await response.json();
163
+ if (typeof result.sessionId !== "string" || typeof result.lastAssistantText !== "string") {
164
+ throw new Error(`Provider ${this.ref.baseUrl} did not return valid subagent session details.`);
165
+ }
166
+ return result;
167
+ }
168
+ async listAgentSessions(options = {}) {
169
+ const url = new URL(`${this.apiBase}/api/agents/sessions`);
170
+ if (options.cursor) {
171
+ url.searchParams.set("cursor", options.cursor);
172
+ }
173
+ if (options.limit !== undefined) {
174
+ url.searchParams.set("limit", String(options.limit));
175
+ }
176
+ if (options.query) {
177
+ url.searchParams.set("query", options.query);
178
+ }
179
+ const response = await this.fetchProviderUrl(url);
180
+ if (!response.ok) {
181
+ throw new Error(await response.text());
182
+ }
183
+ const result = await response.json();
184
+ if (!Array.isArray(result.rows)) {
185
+ throw new Error(`Provider ${this.ref.baseUrl} did not return a valid agent session index.`);
186
+ }
187
+ return result;
188
+ }
189
+ connect() {
190
+ if (this.socket) {
191
+ if (!this.socket.connected) {
192
+ this.socket.connect();
193
+ }
194
+ return this.socket;
195
+ }
196
+ const socketTarget = this.urls.socketTarget(this.ref.baseUrl);
197
+ this.socket = io(socketTarget.origin, {
198
+ auth: (callback) => callback({ token: this.readAuthToken() }),
199
+ autoConnect: true,
200
+ path: socketTarget.path,
201
+ query: this.urls.wsAuthQuery(),
202
+ reconnection: true,
203
+ reconnectionAttempts: Infinity,
204
+ reconnectionDelay: 250,
205
+ reconnectionDelayMax: 5000,
206
+ timeout: controlSocketTimeoutMs,
207
+ transports: ["websocket"]
208
+ });
209
+ return this.socket;
210
+ }
211
+ disconnect() {
212
+ this.socket?.disconnect();
213
+ this.socket = undefined;
214
+ this.agentListenersSocket = undefined;
215
+ }
216
+ onSnapshot(handler) {
217
+ const socket = this.connect();
218
+ socket.on("provider:snapshot", handler);
219
+ return () => socket.off("provider:snapshot", handler);
220
+ }
221
+ onAgentSession(sessionId, handlers, options = {}) {
222
+ const socket = this.connect();
223
+ this.wireAgentSessionListeners(socket);
224
+ const subscription = {
225
+ handlers,
226
+ request: { sessionId, ...(options.cwd ? { cwd: options.cwd } : {}) }
227
+ };
228
+ let set = this.agentSubscriptions.get(sessionId);
229
+ if (!set) {
230
+ set = new Set();
231
+ this.agentSubscriptions.set(sessionId, set);
232
+ }
233
+ set.add(subscription);
234
+ if (socket.connected) {
235
+ this.subscribeAgentSession(socket, subscription.request);
236
+ }
237
+ return () => {
238
+ const current = this.agentSubscriptions.get(sessionId);
239
+ if (!current)
240
+ return;
241
+ current.delete(subscription);
242
+ if (current.size === 0) {
243
+ this.agentSubscriptions.delete(sessionId);
244
+ if (socket.connected)
245
+ socket.emit("agent-session:unsubscribe", subscription.request);
246
+ }
247
+ };
248
+ }
249
+ // Attach the shared agent-session listeners once per socket. A reconnect fires
250
+ // the single `connect` handler, which re-subscribes each active sessionId once
251
+ // (not once per open chat), and update/error fan out to all subscriptions.
252
+ wireAgentSessionListeners(socket) {
253
+ if (this.agentListenersSocket === socket)
254
+ return;
255
+ this.agentListenersSocket = socket;
256
+ socket.on("connect", () => {
257
+ for (const set of this.agentSubscriptions.values()) {
258
+ const first = set.values().next().value;
259
+ if (first)
260
+ this.subscribeAgentSession(socket, first.request);
261
+ }
262
+ });
263
+ socket.on("agent-session:update", (payload) => {
264
+ const update = parseAgentSessionUpdate(payload);
265
+ if (!update)
266
+ return;
267
+ const set = this.agentSubscriptions.get(update.sessionId);
268
+ if (set)
269
+ for (const entry of set)
270
+ entry.handlers.update(update.details);
271
+ });
272
+ socket.on("agent-session:error", (payload) => {
273
+ const error = parseAgentSessionError(payload);
274
+ if (!error)
275
+ return;
276
+ const set = this.agentSubscriptions.get(error.sessionId);
277
+ if (set)
278
+ for (const entry of set)
279
+ entry.handlers.error?.(error.error);
280
+ });
281
+ }
282
+ subscribeAgentSession(socket, request) {
283
+ socket.timeout(controlSocketTimeoutMs).emit("agent-session:subscribe", request, (timeoutError, ack) => {
284
+ const set = this.agentSubscriptions.get(request.sessionId);
285
+ if (!set)
286
+ return;
287
+ if (timeoutError) {
288
+ for (const entry of set)
289
+ entry.handlers.error?.(timeoutError.message);
290
+ return;
291
+ }
292
+ if (ack?.ok !== true) {
293
+ for (const entry of set)
294
+ entry.handlers.error?.(ack?.error ?? "Agent session subscription failed.");
295
+ return;
296
+ }
297
+ if (ack.result?.details && ack.result.sessionId === request.sessionId) {
298
+ for (const entry of set)
299
+ entry.handlers.update(ack.result.details);
300
+ }
301
+ });
302
+ }
303
+ async createWindow(options = {}) {
304
+ return await this.command("window:create", options);
305
+ }
306
+ async createAgentWindow(options, clientOptions = {}) {
307
+ // Backward/forward compat: the create-body field was renamed sessionId -> agentSessionId
308
+ // (951862f). A long-running provider may predate or postdate this CLI, so send BOTH names —
309
+ // otherwise a version skew silently drops the requested --session-id and the agent generates
310
+ // its own, breaking deterministic session tracking. Providers read whichever they know.
311
+ const body = options.agentSessionId
312
+ ? { ...options, sessionId: options.agentSessionId }
313
+ : options;
314
+ return await this.command("agent:create", body, { timeoutMs: clientOptions.timeoutMs ?? 30_000 });
315
+ }
316
+ async moveWindow(id, bounds) {
317
+ return await this.command("window:move", {
318
+ height: bounds.height,
319
+ id,
320
+ left: bounds.left,
321
+ top: bounds.top,
322
+ width: bounds.width
323
+ });
324
+ }
325
+ async closeWindow(id) {
326
+ return await this.command("window:close", { id });
327
+ }
328
+ // Act on a session by id — the provider resolves window→terminal internally, so
329
+ // an SDK caller never has to descend to a terminalId to reply to or close a
330
+ // session (the session-first primary abstraction).
331
+ async sendToSession(agentSessionId, data, options = {}) {
332
+ return await this.command("agent-session:input", {
333
+ agentSessionId,
334
+ data,
335
+ ...(options.submitMode ? { submitMode: options.submitMode } : {})
336
+ });
337
+ }
338
+ async closeSession(agentSessionId) {
339
+ return await this.command("agent-session:close", { agentSessionId });
340
+ }
341
+ async resizeDisplay(bounds) {
342
+ return await this.command("display:resize", {
343
+ height: bounds.height,
344
+ width: bounds.width
345
+ });
346
+ }
347
+ async sendTerminalInput(terminalId, data, options = {}) {
348
+ return await this.command("terminal:input", {
349
+ data,
350
+ terminalId,
351
+ ...(options.breakGlass ? { breakGlass: true } : {}),
352
+ ...(options.submitMode ? { submitMode: options.submitMode } : {})
353
+ });
354
+ }
355
+ async sendLine(terminalId, line, options = {}) {
356
+ return await this.sendTerminalInput(terminalId, `${line}\n`, options);
357
+ }
358
+ // Apply a transient visual effect (flash/dim/highlight) to a terminal — e.g.
359
+ // to surface a terminal you lost. Shows on every tmux client of the pane and
360
+ // in the browser mirror; self-expires after the hold duration.
361
+ async setTerminalEffect(terminalId, kind, options = {}) {
362
+ return await this.command("terminal:effect", {
363
+ kind,
364
+ terminalId,
365
+ ...(options.durationMs ? { durationMs: options.durationMs } : {})
366
+ });
367
+ }
368
+ async listDirectory(terminalId, path) {
369
+ const url = new URL(`${this.apiBase}/api/files/list`);
370
+ url.searchParams.set("terminalId", terminalId);
371
+ url.searchParams.set("path", path);
372
+ const response = await this.fetchProviderUrl(url);
373
+ if (!response.ok) {
374
+ throw new Error(await response.text());
375
+ }
376
+ return await response.json();
377
+ }
378
+ async listDirectoryByFilesystem(filesystemId, path) {
379
+ const url = new URL(`${this.apiBase}/api/files/list`);
380
+ url.searchParams.set("filesystemId", filesystemId);
381
+ url.searchParams.set("path", path);
382
+ const response = await this.fetchProviderUrl(url);
383
+ if (!response.ok) {
384
+ throw new Error(await response.text());
385
+ }
386
+ return await response.json();
387
+ }
388
+ async readFile(terminalId, path) {
389
+ const url = new URL(`${this.apiBase}/api/files`);
390
+ url.searchParams.set("terminalId", terminalId);
391
+ url.searchParams.set("path", path);
392
+ const response = await this.fetchProviderUrl(url);
393
+ if (!response.ok) {
394
+ throw new Error(await response.text());
395
+ }
396
+ return new Uint8Array(await response.arrayBuffer());
397
+ }
398
+ async readFileByFilesystem(filesystemId, path) {
399
+ const url = new URL(`${this.apiBase}/api/files`);
400
+ url.searchParams.set("filesystemId", filesystemId);
401
+ url.searchParams.set("path", path);
402
+ const response = await this.fetchProviderUrl(url);
403
+ if (!response.ok) {
404
+ throw new Error(await response.text());
405
+ }
406
+ return new Uint8Array(await response.arrayBuffer());
407
+ }
408
+ async statFile(terminalId, path) {
409
+ const url = new URL(`${this.apiBase}/api/files/stat`);
410
+ url.searchParams.set("terminalId", terminalId);
411
+ url.searchParams.set("path", path);
412
+ const response = await this.fetchProviderUrl(url);
413
+ if (!response.ok) {
414
+ throw new Error(await response.text());
415
+ }
416
+ return await response.json();
417
+ }
418
+ async statFileByFilesystem(filesystemId, path) {
419
+ const url = new URL(`${this.apiBase}/api/files/stat`);
420
+ url.searchParams.set("filesystemId", filesystemId);
421
+ url.searchParams.set("path", path);
422
+ const response = await this.fetchProviderUrl(url);
423
+ if (!response.ok) {
424
+ throw new Error(await response.text());
425
+ }
426
+ return await response.json();
427
+ }
428
+ async writeFile(terminalId, path, data, options = {}) {
429
+ const url = new URL(`${this.apiBase}/api/files`);
430
+ url.searchParams.set("terminalId", terminalId);
431
+ url.searchParams.set("path", path);
432
+ if (options.mkdirs) {
433
+ url.searchParams.set("mkdirs", "1");
434
+ }
435
+ if (options.mode) {
436
+ url.searchParams.set("mode", options.mode);
437
+ }
438
+ const response = await this.fetchProviderUrl(url, {
439
+ body: new Uint8Array(data),
440
+ method: "PUT"
441
+ });
442
+ if (!response.ok) {
443
+ throw new Error(await response.text());
444
+ }
445
+ return await response.json();
446
+ }
447
+ async writeFileByFilesystem(filesystemId, path, data, options = {}) {
448
+ const url = new URL(`${this.apiBase}/api/files`);
449
+ url.searchParams.set("filesystemId", filesystemId);
450
+ url.searchParams.set("path", path);
451
+ if (options.mkdirs) {
452
+ url.searchParams.set("mkdirs", "1");
453
+ }
454
+ if (options.mode) {
455
+ url.searchParams.set("mode", options.mode);
456
+ }
457
+ const response = await this.fetchProviderUrl(url, {
458
+ body: new Uint8Array(data),
459
+ method: "PUT"
460
+ });
461
+ if (!response.ok) {
462
+ throw new Error(await response.text());
463
+ }
464
+ return await response.json();
465
+ }
466
+ async postJson(path, body, headers = {}) {
467
+ return await this.writeJson("POST", path, body, headers);
468
+ }
469
+ async patchJson(path, body, headers = {}) {
470
+ return await this.writeJson("PATCH", path, body, headers);
471
+ }
472
+ async writeJson(method, path, body, headers = {}) {
473
+ const response = await this.fetchProviderUrl(`${this.apiBase}${path}`, {
474
+ body: body === undefined ? undefined : JSON.stringify(body),
475
+ headers: {
476
+ ...headers,
477
+ ...(body === undefined ? {} : { "content-type": "application/json" })
478
+ },
479
+ method
480
+ });
481
+ if (!response.ok) {
482
+ throw new Error(await response.text());
483
+ }
484
+ return await response.json();
485
+ }
486
+ async command(event, payload, options = {}) {
487
+ const timeoutMs = options.timeoutMs ?? controlSocketTimeoutMs;
488
+ const socket = this.connect();
489
+ await waitForConnected(socket, timeoutMs);
490
+ const response = await new Promise((resolve, reject) => {
491
+ socket.timeout(timeoutMs).emit(event, payload, (timeoutError, ack) => {
492
+ if (timeoutError) {
493
+ reject(timeoutError);
494
+ return;
495
+ }
496
+ if (!ack) {
497
+ reject(new Error(`Provider command ${event} did not return an acknowledgement.`));
498
+ return;
499
+ }
500
+ resolve(ack);
501
+ });
502
+ });
503
+ if (response.ok !== true) {
504
+ throw new Error(response.error ?? `Provider command ${event} failed.`);
505
+ }
506
+ return response;
507
+ }
508
+ async fetchProviderUrl(input, init = {}) {
509
+ return await fetchProviderUrl(input, {
510
+ ...init,
511
+ headers: withBearerHeaders(init.headers, this.readAuthToken())
512
+ });
513
+ }
514
+ readAuthToken() {
515
+ return this.authToken?.();
516
+ }
517
+ }
518
+ async function fetchProviderUrl(input, init) {
519
+ const url = input instanceof URL ? input : new URL(input);
520
+ try {
521
+ return await fetch(url, init);
522
+ }
523
+ catch (error) {
524
+ throw new Error(`Provider request failed: ${url.href}: ${asError(error).message}`);
525
+ }
526
+ }
527
+ function withBearerHeaders(headers, token) {
528
+ if (!token) {
529
+ return headers;
530
+ }
531
+ const next = new Headers(headers);
532
+ if (!next.has("authorization")) {
533
+ next.set("authorization", `Bearer ${token}`);
534
+ }
535
+ return next;
536
+ }
537
+ function parseProviderSnapshot(value, baseUrl) {
538
+ const snapshot = value;
539
+ if (!snapshot || typeof snapshot !== "object") {
540
+ throw new Error(`${baseUrl} did not return a mirror snapshot object.`);
541
+ }
542
+ if (typeof snapshot.epoch !== "string" || snapshot.epoch.length === 0) {
543
+ throw new Error(`${baseUrl} mirror snapshot is missing epoch.`);
544
+ }
545
+ if (typeof snapshot.revision !== "number" || !Number.isInteger(snapshot.revision) || snapshot.revision < 0) {
546
+ throw new Error(`${baseUrl} mirror snapshot is missing revision.`);
547
+ }
548
+ if (typeof snapshot.observedAt !== "string" || snapshot.observedAt.length === 0) {
549
+ throw new Error(`${baseUrl} mirror snapshot is missing observedAt.`);
550
+ }
551
+ if (snapshot.provider !== "iterm" && snapshot.provider !== "virtual-tmux" && snapshot.provider !== "wezterm") {
552
+ throw new Error(`${baseUrl} mirror snapshot has unsupported provider.`);
553
+ }
554
+ if (!snapshot.displayBounds || !Array.isArray(snapshot.windows)) {
555
+ throw new Error(`${baseUrl} mirror snapshot is missing display bounds or windows.`);
556
+ }
557
+ if (!snapshot.lifecycle || !Array.isArray(snapshot.lifecycle.panes) || !Array.isArray(snapshot.lifecycle.sessions)) {
558
+ throw new Error(`${baseUrl} mirror snapshot is missing lifecycle.`);
559
+ }
560
+ return snapshot;
561
+ }
562
+ function parseLifecycleSnapshot(value, baseUrl) {
563
+ const lifecycle = value;
564
+ if (!lifecycle || typeof lifecycle !== "object") {
565
+ throw new Error(`${baseUrl} did not return a lifecycle object.`);
566
+ }
567
+ if (typeof lifecycle.observedAt !== "string" || !Array.isArray(lifecycle.panes) || !Array.isArray(lifecycle.sessions)) {
568
+ throw new Error(`${baseUrl} lifecycle response is missing observedAt, panes, or sessions.`);
569
+ }
570
+ return lifecycle;
571
+ }
572
+ function parseAgentSessionUpdate(value) {
573
+ if (!value || typeof value !== "object") {
574
+ return undefined;
575
+ }
576
+ const payload = value;
577
+ if (typeof payload.sessionId !== "string" || !payload.details || typeof payload.details !== "object") {
578
+ return undefined;
579
+ }
580
+ return {
581
+ details: payload.details,
582
+ sessionId: payload.sessionId
583
+ };
584
+ }
585
+ function parseAgentSessionError(value) {
586
+ if (!value || typeof value !== "object") {
587
+ return undefined;
588
+ }
589
+ const payload = value;
590
+ if (typeof payload.sessionId !== "string" || typeof payload.error !== "string") {
591
+ return undefined;
592
+ }
593
+ return {
594
+ error: payload.error,
595
+ sessionId: payload.sessionId
596
+ };
597
+ }
598
+ export function providerRefFromUrl(value, label = "Provider") {
599
+ const url = new URL(value);
600
+ return {
601
+ baseUrl: url.origin,
602
+ id: `provider-${machineIdSlug(url.origin)}`,
603
+ label
604
+ };
605
+ }
606
+ // The single place a machine's durable board/layout id is resolved. Identity is
607
+ // the provider-minted instanceId once the console has learned it; a machine the
608
+ // console has never reached falls back to the legacy address-derived id, and
609
+ // only until its first probe stamps an instanceId. One named resolver — never a
610
+ // scattered `record.instanceId ?? …` at use sites (the no-fallback convention).
611
+ export function resolveMachineId(record) {
612
+ return record.instanceId
613
+ ? `provider-${machineIdSlug(record.instanceId)}`
614
+ : providerRefFromUrl(record.baseUrl).id;
615
+ }
616
+ // The `provider-` prefix that turns an already-resolved machine id into its
617
+ // console-layout / board-node key. The one place the prefixing lives: the
618
+ // frontend board read seam (`readMachinePlacement`) holds a resolved
619
+ // `provider.id` and derives the key here, while server-side reconciliation goes
620
+ // through `machineLayoutKey` below — both land on the identical key.
621
+ export function machineLayoutKeyForId(machineId) {
622
+ return `provider-${machineId}`;
623
+ }
624
+ // The console-layout / board-node key for a machine record. The board builds its
625
+ // node id as `provider-${provider.id}` (graph-model) where `provider.id` is
626
+ // resolveMachineId(record); the layout is keyed by that node id. Server-side
627
+ // reconciliation must target the SAME key, so it derives it here rather than
628
+ // re-deriving the `provider-` prefixing by hand.
629
+ export function machineLayoutKey(record) {
630
+ return machineLayoutKeyForId(resolveMachineId(record));
631
+ }
632
+ function machineIdSlug(value) {
633
+ return value.replace(/[^a-z0-9]/gi, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").toLowerCase();
634
+ }
635
+ function waitForConnected(socket, timeoutMs) {
636
+ if (socket.connected) {
637
+ return Promise.resolve();
638
+ }
639
+ return new Promise((resolve, reject) => {
640
+ // The manager retries forever (reconnection: Infinity), so connect_error
641
+ // and disconnect are transient per-attempt failures, not verdicts. Failing
642
+ // the command on the first unlucky attempt forfeits the rest of its
643
+ // timeout budget; instead keep waiting and report the last error if the
644
+ // deadline expires.
645
+ let lastError;
646
+ const timer = setTimeout(() => {
647
+ cleanup();
648
+ const detail = lastError ? ` Last error: ${lastError.message}` : "";
649
+ reject(new Error(`Timed out waiting for provider control socket to connect.${detail}`));
650
+ }, timeoutMs);
651
+ const onConnect = () => {
652
+ cleanup();
653
+ resolve();
654
+ };
655
+ const onConnectError = (error) => {
656
+ lastError = error;
657
+ };
658
+ const cleanup = () => {
659
+ clearTimeout(timer);
660
+ socket.off("connect", onConnect);
661
+ socket.off("connect_error", onConnectError);
662
+ };
663
+ socket.on("connect", onConnect);
664
+ socket.on("connect_error", onConnectError);
665
+ });
666
+ }
@@ -0,0 +1,16 @@
1
+ export type ProviderUrlResolver = {
2
+ httpBase(providerBaseUrl: string): string;
3
+ socketTarget(providerBaseUrl: string): {
4
+ origin: string;
5
+ path: string;
6
+ };
7
+ wsBase(providerBaseUrl: string): string;
8
+ wsAuthQuery(): Record<string, string>;
9
+ };
10
+ export declare const providerProxyPathPrefix = "/providers/";
11
+ export declare function providerProxyKey(providerBaseUrl: string): string;
12
+ export declare function providerProxyPath(providerBaseUrl: string): string;
13
+ export declare const directProviderUrlResolver: ProviderUrlResolver;
14
+ export declare function consoleProxyProviderUrlResolver(consoleOrigin: string, options?: {
15
+ sessionToken?: () => string | undefined;
16
+ }): ProviderUrlResolver;