@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.
- package/dist/agent-launch.d.ts +78 -0
- package/dist/agent-launch.js +247 -0
- package/dist/agent-session-id.d.ts +10 -0
- package/dist/agent-session-id.js +36 -0
- package/dist/agent-session-index-client.d.ts +7 -0
- package/dist/agent-session-index-client.js +86 -0
- package/dist/agent-session-index-worker.d.ts +1 -0
- package/dist/agent-session-index-worker.js +20 -0
- package/dist/agent-session-index.d.ts +34 -0
- package/dist/agent-session-index.js +527 -0
- package/dist/agent-session-tail.d.ts +33 -0
- package/dist/agent-session-tail.js +184 -0
- package/dist/agent-session-watcher.d.ts +36 -0
- package/dist/agent-session-watcher.js +194 -0
- package/dist/agent-session.d.ts +380 -0
- package/dist/agent-session.js +1688 -0
- package/dist/background-runner.d.ts +3 -0
- package/dist/background-runner.js +55 -0
- package/dist/boot-queue.d.ts +35 -0
- package/dist/boot-queue.js +66 -0
- package/dist/build-info.d.ts +5 -0
- package/dist/build-info.js +38 -0
- package/dist/collab/canvas-doc.d.ts +47 -0
- package/dist/collab/canvas-doc.js +83 -0
- package/dist/contracts/auth.d.ts +77 -0
- package/dist/contracts/auth.js +1 -0
- package/dist/contracts/canvas.d.ts +34 -0
- package/dist/contracts/canvas.js +76 -0
- package/dist/contracts/console-layout.d.ts +39 -0
- package/dist/contracts/console-layout.js +135 -0
- package/dist/contracts/files.d.ts +38 -0
- package/dist/contracts/files.js +37 -0
- package/dist/contracts/provider-url.d.ts +3 -0
- package/dist/contracts/provider-url.js +49 -0
- package/dist/contracts/registry.d.ts +58 -0
- package/dist/contracts/registry.js +285 -0
- package/dist/launch-trace.d.ts +6 -0
- package/dist/launch-trace.js +33 -0
- package/dist/lib/errors.d.ts +1 -0
- package/dist/lib/errors.js +5 -0
- package/dist/lib/exec.d.ts +13 -0
- package/dist/lib/exec.js +134 -0
- package/dist/local-providers.d.ts +32 -0
- package/dist/local-providers.js +184 -0
- package/dist/local-tunnel.d.ts +6 -0
- package/dist/local-tunnel.js +258 -0
- package/dist/provider-access-token.d.ts +11 -0
- package/dist/provider-access-token.js +77 -0
- package/dist/provider-client.d.ts +152 -0
- package/dist/provider-client.js +666 -0
- package/dist/provider-url-resolver.d.ts +16 -0
- package/dist/provider-url-resolver.js +37 -0
- package/dist/registry-client.d.ts +93 -0
- package/dist/registry-client.js +170 -0
- package/dist/registry.d.ts +56 -0
- package/dist/registry.js +406 -0
- package/dist/session-attention.d.ts +24 -0
- package/dist/session-attention.js +54 -0
- package/dist/session-lifecycle.d.ts +83 -0
- package/dist/session-lifecycle.js +658 -0
- package/dist/session-window.d.ts +3 -0
- package/dist/session-window.js +20 -0
- package/dist/terminal-client.d.ts +49 -0
- package/dist/terminal-client.js +89 -0
- package/dist/types.d.ts +155 -0
- package/dist/types.js +21 -0
- 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;
|