@workerdeck/client 0.23.0 → 1.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/build/index.d.mts +40 -324
- package/build/index.mjs +109 -333
- package/build/index.mjs.map +1 -1
- package/package.json +8 -8
package/build/index.mjs
CHANGED
|
@@ -1,94 +1,39 @@
|
|
|
1
|
-
//#region src/
|
|
2
|
-
/** Normalized REST base for `WorkerDeckClient`, or undefined if unparseable. */
|
|
3
|
-
function apiUrl(host) {
|
|
4
|
-
let text = host.baseUrl.trim();
|
|
5
|
-
while (text.endsWith("/")) text = text.slice(0, -1);
|
|
6
|
-
if (text === "") return void 0;
|
|
7
|
-
if (!text.includes("://")) text = "http://" + text;
|
|
8
|
-
if (!text.endsWith("/v1")) text += "/v1";
|
|
9
|
-
try {
|
|
10
|
-
new URL(text);
|
|
11
|
-
} catch {
|
|
12
|
-
return;
|
|
13
|
-
}
|
|
14
|
-
return text;
|
|
15
|
-
}
|
|
1
|
+
//#region src/lib/emitter.ts
|
|
16
2
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* lie. In a remote development window the caller runs on the remote box, so
|
|
20
|
-
* "loopback" correctly means *that* machine and its paths are real files there.
|
|
3
|
+
* The handles' listener registry. A throwing listener must never stop the ones queued behind it:
|
|
4
|
+
* these fire from a socket callback, where an escaping error would take the connection with it.
|
|
21
5
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
6
|
+
var Emitter = class {
|
|
7
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
8
|
+
on(kind, listener) {
|
|
9
|
+
let set = this.#listeners.get(kind);
|
|
10
|
+
if (!set) {
|
|
11
|
+
set = /* @__PURE__ */ new Set();
|
|
12
|
+
this.#listeners.set(kind, set);
|
|
13
|
+
}
|
|
14
|
+
set.add(listener);
|
|
15
|
+
return () => set.delete(listener);
|
|
30
16
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
*
|
|
38
|
-
* Here, beside `apiUrl`, for the same reason that is here: every host that lets
|
|
39
|
-
* someone type a gateway address and a key has to present them identically, or
|
|
40
|
-
* the same gateway works in one client and not another. It is browser-shaped on
|
|
41
|
-
* purpose — a Node host (the VS Code extension) sends the key as a header on
|
|
42
|
-
* both transports and needs none of this.
|
|
43
|
-
*
|
|
44
|
-
* Two transports, because a browser has no choice:
|
|
45
|
-
*
|
|
46
|
-
* - **REST** takes `Authorization: Bearer <key>`, like any service client.
|
|
47
|
-
* - **WebSocket** takes `?key=<key>`, because a tab cannot put a header on an
|
|
48
|
-
* upgrade handshake and the gateway's cookie belongs to another origin. The
|
|
49
|
-
* CLI's auth accepts the key this way on upgrades *only*.
|
|
50
|
-
*
|
|
51
|
-
* The query-string transport is the weaker one and is worth naming: unlike a
|
|
52
|
-
* header it is a permanent credential that lands in reverse-proxy access logs.
|
|
53
|
-
* It is confined to the upgrade so what a leaked URL buys is one attach. If a
|
|
54
|
-
* gateway later mints short-lived tickets, only the body of `buildWsUrl`
|
|
55
|
-
* changes — callers of this function do not.
|
|
56
|
-
*/
|
|
57
|
-
function hostAuth(options) {
|
|
58
|
-
const { baseUrl, key } = options;
|
|
59
|
-
if (key === "") return {};
|
|
60
|
-
const wsRoot = baseUrl.replace(/^http/, "ws");
|
|
61
|
-
const withKey = (url) => `${url}${url.includes("?") ? "&" : "?"}key=${encodeURIComponent(key)}`;
|
|
62
|
-
return {
|
|
63
|
-
headers: { authorization: `Bearer ${key}` },
|
|
64
|
-
buildWsUrl: (sessionId, afterSeq) => withKey(`${wsRoot}/sessions/${encodeURIComponent(sessionId)}/ws?afterSeq=${afterSeq}`),
|
|
65
|
-
buildQueueWsUrl: () => withKey(`${wsRoot}/queue/ws`)
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
//#endregion
|
|
69
|
-
//#region src/index.ts
|
|
70
|
-
/**
|
|
71
|
-
* A REST call the gateway refused, carrying the status alongside the message.
|
|
72
|
-
*
|
|
73
|
-
* An `Error` subclass on purpose: every existing `e instanceof Error` check and
|
|
74
|
-
* every `e.message` read keeps working unchanged. The status is what lets a
|
|
75
|
-
* caller tell "this server doesn't have that route" (404 — stop asking) from
|
|
76
|
-
* "that file was too big" (413 — tell the user), which a message string can't.
|
|
77
|
-
*/
|
|
78
|
-
var WorkerDeckError = class extends Error {
|
|
79
|
-
status;
|
|
80
|
-
constructor(message, status) {
|
|
81
|
-
super(message);
|
|
82
|
-
this.name = "WorkerDeckError";
|
|
83
|
-
this.status = status;
|
|
17
|
+
emit(kind, payload) {
|
|
18
|
+
const set = this.#listeners.get(kind);
|
|
19
|
+
if (!set) return;
|
|
20
|
+
for (const listener of set) try {
|
|
21
|
+
listener(payload);
|
|
22
|
+
} catch {}
|
|
84
23
|
}
|
|
85
24
|
};
|
|
25
|
+
/** 500ms doubling from a zero-based attempt count, capped at 10s. */
|
|
26
|
+
function reconnectDelay(retries) {
|
|
27
|
+
return Math.min(500 * 2 ** retries, 1e4);
|
|
28
|
+
}
|
|
29
|
+
//#endregion
|
|
30
|
+
//#region src/session-handle.ts
|
|
86
31
|
var SessionHandle = class {
|
|
87
32
|
sessionId;
|
|
88
33
|
#client;
|
|
89
34
|
#options;
|
|
90
35
|
#ws;
|
|
91
|
-
#
|
|
36
|
+
#events = new Emitter();
|
|
92
37
|
#lastSeq;
|
|
93
38
|
#closed = false;
|
|
94
39
|
#retries = 0;
|
|
@@ -108,18 +53,8 @@ var SessionHandle = class {
|
|
|
108
53
|
return this.#lastSeq;
|
|
109
54
|
}
|
|
110
55
|
on(kind, listener) {
|
|
111
|
-
|
|
112
|
-
if (!set) {
|
|
113
|
-
set = /* @__PURE__ */ new Set();
|
|
114
|
-
this.#listeners.set(kind, set);
|
|
115
|
-
}
|
|
116
|
-
set.add(listener);
|
|
117
|
-
return () => set.delete(listener);
|
|
56
|
+
return this.#events.on(kind, listener);
|
|
118
57
|
}
|
|
119
|
-
/** Send a message, optionally naming attachments uploaded ahead of it with
|
|
120
|
-
* {@link WorkerDeckClient.uploadAttachment} (ids in the order they should reach
|
|
121
|
-
* the model). An unknown id fails the whole command — the server will not send a
|
|
122
|
-
* message that quietly lost its picture. */
|
|
123
58
|
send(text, attachmentIds) {
|
|
124
59
|
this.#sendFrame({
|
|
125
60
|
type: "user_message",
|
|
@@ -147,14 +82,6 @@ var SessionHandle = class {
|
|
|
147
82
|
interrupt() {
|
|
148
83
|
this.#sendFrame({ type: "interrupt" });
|
|
149
84
|
}
|
|
150
|
-
/**
|
|
151
|
-
* Reset the conversation in place: same session, empty context. The server
|
|
152
|
-
* answers with a `conversation_reset` event.
|
|
153
|
-
*
|
|
154
|
-
* Gate the affordance on `EngineCapabilities.clearContext` (absent = false)
|
|
155
|
-
* rather than calling this blindly — an engine or a server that cannot do it
|
|
156
|
-
* answers with an error frame, which is the wrong way for a user to find out.
|
|
157
|
-
*/
|
|
158
85
|
clearContext() {
|
|
159
86
|
this.#sendFrame({ type: "clear_context" });
|
|
160
87
|
}
|
|
@@ -164,14 +91,12 @@ var SessionHandle = class {
|
|
|
164
91
|
mode
|
|
165
92
|
});
|
|
166
93
|
}
|
|
167
|
-
/** Switch the model for subsequent responses; omit `model` for the default. */
|
|
168
94
|
setModel(model) {
|
|
169
95
|
this.#sendFrame({
|
|
170
96
|
type: "set_model",
|
|
171
97
|
model
|
|
172
98
|
});
|
|
173
99
|
}
|
|
174
|
-
/** Answer a bridged tool call (see the `toolCallRequest` event). */
|
|
175
100
|
sendToolCallResult(executionId, output, logs) {
|
|
176
101
|
this.#sendFrame({
|
|
177
102
|
type: "tool_call_result",
|
|
@@ -180,8 +105,6 @@ var SessionHandle = class {
|
|
|
180
105
|
logs
|
|
181
106
|
});
|
|
182
107
|
}
|
|
183
|
-
/** Report that a bridged tool call could not be executed. The failure is fed
|
|
184
|
-
* to the model as tool output, so the agent can adapt rather than stall. */
|
|
185
108
|
sendToolCallError(executionId, reason, error, logs) {
|
|
186
109
|
this.#sendFrame({
|
|
187
110
|
type: "tool_call_error",
|
|
@@ -191,34 +114,22 @@ var SessionHandle = class {
|
|
|
191
114
|
logs
|
|
192
115
|
});
|
|
193
116
|
}
|
|
194
|
-
/** Ask the server to terminate the session (the handle disconnects too). */
|
|
195
117
|
closeSession() {
|
|
196
118
|
this.#sendFrame({ type: "close" });
|
|
197
119
|
this.detach();
|
|
198
120
|
}
|
|
199
|
-
/** Skip the reconnect backoff and try again now — what a tab returning to the
|
|
200
|
-
* foreground should do, rather than sitting out the remaining delay. No-op
|
|
201
|
-
* while connected or after {@link SessionHandle.detach}. */
|
|
202
121
|
reconnectNow() {
|
|
203
122
|
if (this.#closed || this.#ws && this.#ws.readyState === 1) return;
|
|
204
123
|
clearTimeout(this.#connectTimer);
|
|
205
124
|
this.#retries = 0;
|
|
206
125
|
this.#connect();
|
|
207
126
|
}
|
|
208
|
-
/** Disconnect this handle without touching the session. */
|
|
209
127
|
detach() {
|
|
210
128
|
this.#closed = true;
|
|
211
129
|
clearTimeout(this.#connectTimer);
|
|
212
130
|
this.#ws?.close();
|
|
213
131
|
this.#ws = void 0;
|
|
214
132
|
}
|
|
215
|
-
#emit(kind, payload) {
|
|
216
|
-
const set = this.#listeners.get(kind);
|
|
217
|
-
if (!set) return;
|
|
218
|
-
for (const listener of set) try {
|
|
219
|
-
listener(payload);
|
|
220
|
-
} catch {}
|
|
221
|
-
}
|
|
222
133
|
#sendFrame(frame) {
|
|
223
134
|
const payload = JSON.stringify(frame);
|
|
224
135
|
if (this.#ws && this.#ws.readyState === 1) this.#ws.send(payload);
|
|
@@ -230,43 +141,40 @@ var SessionHandle = class {
|
|
|
230
141
|
this.#ws = ws;
|
|
231
142
|
ws.onopen = () => {
|
|
232
143
|
this.#retries = 0;
|
|
233
|
-
this.#emit("connectionChange", true);
|
|
144
|
+
this.#events.emit("connectionChange", true);
|
|
234
145
|
for (const payload of this.#outbox.splice(0)) ws.send(payload);
|
|
235
146
|
};
|
|
236
147
|
ws.onmessage = (msg) => {
|
|
237
148
|
const frame = JSON.parse(String(msg.data));
|
|
238
|
-
if (frame.type === "attached") this.#emit("attached", frame);
|
|
149
|
+
if (frame.type === "attached") this.#events.emit("attached", frame);
|
|
239
150
|
else if (frame.type === "event") {
|
|
240
151
|
if (frame.event.seq <= this.#lastSeq) return;
|
|
241
152
|
this.#lastSeq = frame.event.seq;
|
|
242
|
-
this.#emit("event", frame.event);
|
|
243
|
-
} else if (frame.type === "tool_call_request") this.#emit("toolCallRequest", frame);
|
|
244
|
-
else if (frame.type === "tool_call_canceled") this.#emit("toolCallCanceled", {
|
|
153
|
+
this.#events.emit("event", frame.event);
|
|
154
|
+
} else if (frame.type === "tool_call_request") this.#events.emit("toolCallRequest", frame);
|
|
155
|
+
else if (frame.type === "tool_call_canceled") this.#events.emit("toolCallCanceled", {
|
|
245
156
|
executionId: frame.executionId,
|
|
246
157
|
reason: frame.reason
|
|
247
158
|
});
|
|
248
|
-
else if (frame.type === "protocol_error") this.#emit("protocolError", frame.message);
|
|
159
|
+
else if (frame.type === "protocol_error") this.#events.emit("protocolError", frame.message);
|
|
249
160
|
};
|
|
250
161
|
ws.onclose = () => {
|
|
251
|
-
this.#emit("connectionChange", false);
|
|
162
|
+
this.#events.emit("connectionChange", false);
|
|
252
163
|
if (this.#closed || !this.#options.reconnect) return;
|
|
253
|
-
const delay =
|
|
254
|
-
this.#emit("reconnectAttempt", this.#retries);
|
|
164
|
+
const delay = reconnectDelay(this.#retries++);
|
|
165
|
+
this.#events.emit("reconnectAttempt", this.#retries);
|
|
255
166
|
this.#connectTimer = setTimeout(() => this.#connect(), delay);
|
|
256
167
|
};
|
|
257
168
|
ws.onerror = () => {};
|
|
258
169
|
}
|
|
259
170
|
};
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
* read-only — submit/cancel stay on the REST methods. There is no replay: on
|
|
263
|
-
* (re)connect, re-list jobs and treat the stream as updates from there.
|
|
264
|
-
*/
|
|
171
|
+
//#endregion
|
|
172
|
+
//#region src/queue-handle.ts
|
|
265
173
|
var QueueHandle = class {
|
|
266
174
|
#client;
|
|
267
175
|
#reconnect;
|
|
268
176
|
#ws;
|
|
269
|
-
#
|
|
177
|
+
#events = new Emitter();
|
|
270
178
|
#closed = false;
|
|
271
179
|
#retries = 0;
|
|
272
180
|
#connectTimer;
|
|
@@ -276,13 +184,7 @@ var QueueHandle = class {
|
|
|
276
184
|
this.#connectTimer = setTimeout(() => this.#connect(), 0);
|
|
277
185
|
}
|
|
278
186
|
on(kind, listener) {
|
|
279
|
-
|
|
280
|
-
if (!set) {
|
|
281
|
-
set = /* @__PURE__ */ new Set();
|
|
282
|
-
this.#listeners.set(kind, set);
|
|
283
|
-
}
|
|
284
|
-
set.add(listener);
|
|
285
|
-
return () => set.delete(listener);
|
|
187
|
+
return this.#events.on(kind, listener);
|
|
286
188
|
}
|
|
287
189
|
detach() {
|
|
288
190
|
this.#closed = true;
|
|
@@ -290,38 +192,79 @@ var QueueHandle = class {
|
|
|
290
192
|
this.#ws?.close();
|
|
291
193
|
this.#ws = void 0;
|
|
292
194
|
}
|
|
293
|
-
#emit(kind, payload) {
|
|
294
|
-
const set = this.#listeners.get(kind);
|
|
295
|
-
if (!set) return;
|
|
296
|
-
for (const listener of set) try {
|
|
297
|
-
listener(payload);
|
|
298
|
-
} catch {}
|
|
299
|
-
}
|
|
300
195
|
#connect() {
|
|
301
196
|
if (this.#closed) return;
|
|
302
197
|
const ws = this.#client.openQueueSocket();
|
|
303
198
|
this.#ws = ws;
|
|
304
199
|
ws.onopen = () => {
|
|
305
200
|
this.#retries = 0;
|
|
306
|
-
this.#emit("connectionChange", true);
|
|
201
|
+
this.#events.emit("connectionChange", true);
|
|
307
202
|
};
|
|
308
203
|
ws.onmessage = (msg) => {
|
|
309
204
|
const frame = JSON.parse(String(msg.data));
|
|
310
205
|
if (frame.type === "queue_attached") {
|
|
311
|
-
this.#emit("attached", frame.stats);
|
|
312
|
-
this.#emit("stats", frame.stats);
|
|
313
|
-
} else if (frame.type === "job_event") this.#emit("event", frame.event);
|
|
314
|
-
else if (frame.type === "queue_stats") this.#emit("stats", frame.stats);
|
|
206
|
+
this.#events.emit("attached", frame.stats);
|
|
207
|
+
this.#events.emit("stats", frame.stats);
|
|
208
|
+
} else if (frame.type === "job_event") this.#events.emit("event", frame.event);
|
|
209
|
+
else if (frame.type === "queue_stats") this.#events.emit("stats", frame.stats);
|
|
315
210
|
};
|
|
316
211
|
ws.onclose = () => {
|
|
317
|
-
this.#emit("connectionChange", false);
|
|
212
|
+
this.#events.emit("connectionChange", false);
|
|
318
213
|
if (this.#closed || !this.#reconnect) return;
|
|
319
|
-
const delay =
|
|
214
|
+
const delay = reconnectDelay(this.#retries++);
|
|
320
215
|
this.#connectTimer = setTimeout(() => this.#connect(), delay);
|
|
321
216
|
};
|
|
322
217
|
ws.onerror = () => {};
|
|
323
218
|
}
|
|
324
219
|
};
|
|
220
|
+
//#endregion
|
|
221
|
+
//#region src/host-url.ts
|
|
222
|
+
function apiUrl(host) {
|
|
223
|
+
let text = host.baseUrl.trim();
|
|
224
|
+
while (text.endsWith("/")) text = text.slice(0, -1);
|
|
225
|
+
if (text === "") return;
|
|
226
|
+
if (!text.includes("://")) text = "http://" + text;
|
|
227
|
+
if (!text.endsWith("/v1")) text += "/v1";
|
|
228
|
+
try {
|
|
229
|
+
new URL(text);
|
|
230
|
+
} catch {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
return text;
|
|
234
|
+
}
|
|
235
|
+
function isLoopbackHost(host) {
|
|
236
|
+
const api = apiUrl(host);
|
|
237
|
+
if (!api) return false;
|
|
238
|
+
try {
|
|
239
|
+
const { hostname } = new URL(api);
|
|
240
|
+
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1" || hostname === "[::1]";
|
|
241
|
+
} catch {
|
|
242
|
+
return false;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/host-auth.ts
|
|
247
|
+
function hostAuth(options) {
|
|
248
|
+
const { baseUrl, key } = options;
|
|
249
|
+
if (key === "") return {};
|
|
250
|
+
const wsRoot = baseUrl.replace(/^http/, "ws");
|
|
251
|
+
const withKey = (url) => `${url}${url.includes("?") ? "&" : "?"}key=${encodeURIComponent(key)}`;
|
|
252
|
+
return {
|
|
253
|
+
headers: { authorization: `Bearer ${key}` },
|
|
254
|
+
buildWsUrl: (sessionId, afterSeq) => withKey(`${wsRoot}/sessions/${encodeURIComponent(sessionId)}/ws?afterSeq=${afterSeq}`),
|
|
255
|
+
buildQueueWsUrl: () => withKey(`${wsRoot}/queue/ws`)
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
//#endregion
|
|
259
|
+
//#region src/index.ts
|
|
260
|
+
var WorkerDeckError = class extends Error {
|
|
261
|
+
status;
|
|
262
|
+
constructor(message, status) {
|
|
263
|
+
super(message);
|
|
264
|
+
this.name = "WorkerDeckError";
|
|
265
|
+
this.status = status;
|
|
266
|
+
}
|
|
267
|
+
};
|
|
325
268
|
var WorkerDeckClient = class {
|
|
326
269
|
#options;
|
|
327
270
|
#fetch;
|
|
@@ -331,19 +274,6 @@ var WorkerDeckClient = class {
|
|
|
331
274
|
this.#fetch = options.fetchImpl ?? fetch.bind(globalThis);
|
|
332
275
|
this.#WebSocketImpl = options.WebSocketImpl ?? WebSocket;
|
|
333
276
|
}
|
|
334
|
-
/**
|
|
335
|
-
* Stable identity of the (gateway, principal) pair this client speaks as:
|
|
336
|
-
* the base URL plus the auth headers it sends, order-insensitively.
|
|
337
|
-
*
|
|
338
|
-
* Exists for client-side caches that must survive the client *instance*
|
|
339
|
-
* being rebuilt (a `useMemo` recreating it when a view switches gateways)
|
|
340
|
-
* without ever sharing an entry across gateways — a session id is unique
|
|
341
|
-
* only within one — or across credentials. Auth that rides outside
|
|
342
|
-
* `headers` (a same-origin cookie, a fetch shim adding the key host-side)
|
|
343
|
-
* is chosen per origin in every such host, so the base URL still separates
|
|
344
|
-
* principals there; an embedder whose principal varies some other way on
|
|
345
|
-
* one base URL should not key anything on this.
|
|
346
|
-
*/
|
|
347
277
|
get identityKey() {
|
|
348
278
|
const headers = Object.entries(this.#options.headers ?? {}).map(([name, value]) => [name.toLowerCase(), value]);
|
|
349
279
|
headers.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
@@ -358,176 +288,75 @@ var WorkerDeckClient = class {
|
|
|
358
288
|
async getSession(id) {
|
|
359
289
|
return (await this.#call("GET", `/sessions/${encodeURIComponent(id)}`)).session;
|
|
360
290
|
}
|
|
361
|
-
/** Rename a session (or clear the name with `null`, restoring the derived
|
|
362
|
-
* title). 409 when the session is parked. */
|
|
363
291
|
async updateSession(id, patch) {
|
|
364
292
|
return (await this.#call("PATCH", `/sessions/${encodeURIComponent(id)}`, patch)).session;
|
|
365
293
|
}
|
|
366
294
|
async deleteSession(id) {
|
|
367
295
|
return (await this.#call("DELETE", `/sessions/${encodeURIComponent(id)}`)).session;
|
|
368
296
|
}
|
|
369
|
-
/** List the files currently in a session's scratch filesystem (deliverables the
|
|
370
|
-
* agent wrote; see the `file_delivered` event). 404s when the session's engine
|
|
371
|
-
* has no file store (Claude-engine sessions). */
|
|
372
297
|
async listSessionFiles(sessionId) {
|
|
373
298
|
return (await this.#call("GET", `/sessions/${encodeURIComponent(sessionId)}/files`)).files;
|
|
374
299
|
}
|
|
375
|
-
/** Download one session file as text. */
|
|
376
300
|
async fetchSessionFile(sessionId, path) {
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
return await res.text();
|
|
380
|
-
}
|
|
381
|
-
/**
|
|
382
|
-
* Upload one file for the session, ahead of the message that will carry it.
|
|
383
|
-
* The returned `id` goes to {@link SessionHandle.send}.
|
|
384
|
-
*
|
|
385
|
-
* The body is the raw bytes — no multipart — so anything `fetch` accepts as a
|
|
386
|
-
* body works: a `File`/`Blob` from a picker, a `Uint8Array`, a string.
|
|
387
|
-
*/
|
|
301
|
+
return await (await this.#callRaw(this.sessionFileUrl(sessionId, path), { headers: this.#options.headers }, "GET file failed")).text();
|
|
302
|
+
}
|
|
388
303
|
async uploadAttachment(sessionId, file) {
|
|
389
304
|
const url = `${this.#options.baseUrl}/sessions/${encodeURIComponent(sessionId)}/attachments?name=${encodeURIComponent(file.name)}`;
|
|
390
|
-
|
|
305
|
+
return (await (await this.#callRaw(url, {
|
|
391
306
|
method: "POST",
|
|
392
307
|
headers: {
|
|
393
308
|
...this.#options.headers,
|
|
394
309
|
"content-type": file.mediaType
|
|
395
310
|
},
|
|
396
311
|
body: file.data
|
|
397
|
-
});
|
|
398
|
-
if (!res.ok) throw new WorkerDeckError((await res.json().catch(() => ({}))).error ?? `upload failed with ${res.status}`, res.status);
|
|
399
|
-
return (await res.json()).attachment;
|
|
312
|
+
}, "upload failed")).json()).attachment;
|
|
400
313
|
}
|
|
401
|
-
/** Direct URL for an uploaded attachment — an `<img src>` on a cookie-authenticated
|
|
402
|
-
* same-origin server. Header-authenticated clients must fetch it themselves. */
|
|
403
314
|
attachmentUrl(sessionId, attachmentId) {
|
|
404
315
|
return `${this.#options.baseUrl}/sessions/${encodeURIComponent(sessionId)}/attachments/${encodeURIComponent(attachmentId)}`;
|
|
405
316
|
}
|
|
406
|
-
/**
|
|
407
|
-
* Direct URL for a file the session's ENGINE produced on the host — the
|
|
408
|
-
* `fileId` of a `file_produced` event. Same caveat as `attachmentUrl`: usable
|
|
409
|
-
* as an `<img src>` only where the credential is a same-origin cookie; a
|
|
410
|
-
* header-authenticated client (the phone) fetches it and makes its own blob.
|
|
411
|
-
*
|
|
412
|
-
* Unlike `/fs/read`, this needs no host-file roots and no raised byte cap —
|
|
413
|
-
* see the `file_produced` note in the protocol for why that is sound.
|
|
414
|
-
*/
|
|
415
317
|
producedFileUrl(sessionId, fileId) {
|
|
416
318
|
return `${this.#options.baseUrl}/sessions/${encodeURIComponent(sessionId)}/produced/${encodeURIComponent(fileId)}`;
|
|
417
319
|
}
|
|
418
|
-
/** Fetch a produced file's bytes. For clients that cannot put a credential on
|
|
419
|
-
* an `<img src>`. Throws {@link WorkerDeckError} with the response status —
|
|
420
|
-
* a 404 means the file is gone from disk, not that the route is missing. */
|
|
421
320
|
async readProducedFile(sessionId, fileId) {
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
return await res.blob();
|
|
425
|
-
}
|
|
426
|
-
/**
|
|
427
|
-
* The URL behind a `ProjectIcon.image`. Session-scoped, like
|
|
428
|
-
* {@link producedFileUrl}: the fetch rides the same `canSee` gate as every
|
|
429
|
-
* other `/sessions/:id/*` route, and it takes **no path** — the gateway
|
|
430
|
-
* serves whatever its own discovery resolved for this session's cwd.
|
|
431
|
-
*/
|
|
321
|
+
return await (await this.#callRaw(this.producedFileUrl(sessionId, fileId), { headers: { ...this.#options.headers } }, "produced file request failed")).blob();
|
|
322
|
+
}
|
|
432
323
|
projectIconUrl(sessionId) {
|
|
433
324
|
return `${this.#options.baseUrl}/sessions/${encodeURIComponent(sessionId)}/project/icon`;
|
|
434
325
|
}
|
|
435
|
-
/**
|
|
436
|
-
* Fetch a project icon's bytes.
|
|
437
|
-
*
|
|
438
|
-
* Here rather than left to each client for the reason `readProducedFile`
|
|
439
|
-
* exists, plus one this route makes sharper: a VS Code webview has **no
|
|
440
|
-
* external `connect-src` at all**, so it cannot point an `<img src>` at a
|
|
441
|
-
* gateway even in principle — the bytes have to come back through a bridged
|
|
442
|
-
* fetch, which is exactly what this wraps. Three clients building the same
|
|
443
|
-
* URL from `baseUrl` was the other half of the argument.
|
|
444
|
-
*
|
|
445
|
-
* Cache the result by `ProjectIcon.image.hash`, never by session: two
|
|
446
|
-
* sessions in one project serve identical bytes, and the hash is on the wire
|
|
447
|
-
* precisely so a client fetches once per project.
|
|
448
|
-
*
|
|
449
|
-
* A 404 is the uniform "no icon" — no project, a glyph-only project, or an
|
|
450
|
-
* icon the gateway refused. It is deliberately not distinguishable, so treat
|
|
451
|
-
* it as "draw no image", never as an error worth reporting.
|
|
452
|
-
*/
|
|
453
326
|
async projectIcon(sessionId) {
|
|
454
|
-
|
|
455
|
-
if (!res.ok) throw new WorkerDeckError((await res.json().catch(() => ({}))).error ?? `project icon request failed with ${res.status}`, res.status);
|
|
456
|
-
return await res.blob();
|
|
327
|
+
return await (await this.#callRaw(this.projectIconUrl(sessionId), { headers: { ...this.#options.headers } }, "project icon request failed")).blob();
|
|
457
328
|
}
|
|
458
|
-
/** The session's MCP servers and their tools, live from the engine. 501 when the
|
|
459
|
-
* session's engine has no MCP surface; 409 while the session is parked. */
|
|
460
329
|
async listMcpServers(sessionId) {
|
|
461
330
|
return (await this.#call("GET", `/sessions/${encodeURIComponent(sessionId)}/mcp`)).servers;
|
|
462
331
|
}
|
|
463
|
-
/** Reconnect, enable or disable one MCP server; answers with the refreshed list. */
|
|
464
332
|
async mcpServerAction(sessionId, serverName, action) {
|
|
465
333
|
return (await this.#call("POST", `/sessions/${encodeURIComponent(sessionId)}/mcp/${encodeURIComponent(serverName)}`, { action })).servers;
|
|
466
334
|
}
|
|
467
|
-
/** Direct download URL for a session file (e.g. an <a download> href). Carries
|
|
468
|
-
* no headers — on authenticated servers, use fetchSessionFile instead. */
|
|
469
335
|
sessionFileUrl(sessionId, path) {
|
|
470
336
|
const encoded = path.split("/").filter(Boolean).map(encodeURIComponent).join("/");
|
|
471
337
|
return `${this.#options.baseUrl}/sessions/${encodeURIComponent(sessionId)}/files/${encoded}`;
|
|
472
338
|
}
|
|
473
|
-
/** Resolve a pending permission over REST — the remote-controller counterpart of the
|
|
474
|
-
* WS `permission_decision` command (e.g. answering a job's AskUserQuestion from a
|
|
475
|
-
* webhook consumer; the request rides on job_progress deliveries). Throws if the
|
|
476
|
-
* request is unknown, already resolved, or expired. */
|
|
477
339
|
async resolvePermission(sessionId, requestId, decision) {
|
|
478
340
|
await this.#call("POST", `/sessions/${encodeURIComponent(sessionId)}/permissions/${encodeURIComponent(requestId)}`, decision);
|
|
479
341
|
}
|
|
480
|
-
/**
|
|
481
|
-
* Deliver the result of a deferred tool execution — the callback a remote
|
|
482
|
-
* worker (or a human) makes when the work a session parked on is done. The
|
|
483
|
-
* session is rehydrated if its runner was torn down, and the agent loop
|
|
484
|
-
* continues with this as the tool's output.
|
|
485
|
-
*
|
|
486
|
-
* Applied idempotently by `executionId`: a duplicate, or one racing the
|
|
487
|
-
* execution watchdog, resolves with `applied: false` instead of applying twice.
|
|
488
|
-
* Throws (404) when no session is waiting on that id.
|
|
489
|
-
*/
|
|
490
342
|
async submitExecutionResult(executionId, result) {
|
|
491
343
|
return await this.#call("POST", `/executions/${encodeURIComponent(executionId)}/result`, result);
|
|
492
344
|
}
|
|
493
|
-
/** List the profiles (named Claude Code config dirs) this server declares, filtered
|
|
494
|
-
* to what the caller may use. Feed a result's `name` to createSession({ profile }).
|
|
495
|
-
* Servers predating profiles 404 here — catch and treat as none declared. */
|
|
496
|
-
/** The profiles this caller may use, plus whether it may create new ones.
|
|
497
|
-
* Each profile carries `managed: true` when it is store-backed and therefore
|
|
498
|
-
* editable; profiles declared in server options are not. */
|
|
499
345
|
async listProfiles() {
|
|
500
346
|
return await this.#call("GET", "/profiles");
|
|
501
347
|
}
|
|
502
|
-
/** One profile plus a fresh, view-only snapshot of its config directory (settings,
|
|
503
|
-
* skills, agents, commands — env var names only, never values). */
|
|
504
348
|
async getProfile(name) {
|
|
505
349
|
return await this.#call("GET", `/profiles/${encodeURIComponent(name)}`);
|
|
506
350
|
}
|
|
507
|
-
/**
|
|
508
|
-
* Create a managed profile. Requires a server with a profile store and a
|
|
509
|
-
* principal allowed to manage profiles; 409 if the name is already taken by a
|
|
510
|
-
* managed or a startup-declared profile.
|
|
511
|
-
*/
|
|
512
351
|
async createProfile(profile) {
|
|
513
352
|
return (await this.#call("POST", "/profiles", profile)).profile;
|
|
514
353
|
}
|
|
515
|
-
/** Merge into a managed profile. The name is the route: profiles cannot be
|
|
516
|
-
* renamed, since sessions and jobs are already pinned to the old one. */
|
|
517
354
|
async updateProfile(name, patch) {
|
|
518
355
|
return (await this.#call("PATCH", `/profiles/${encodeURIComponent(name)}`, patch)).profile;
|
|
519
356
|
}
|
|
520
|
-
/** Delete a managed profile. Startup-declared profiles are refused (403) —
|
|
521
|
-
* they live in the server's options. */
|
|
522
357
|
async deleteProfile(name) {
|
|
523
358
|
await this.#call("DELETE", `/profiles/${encodeURIComponent(name)}`);
|
|
524
359
|
}
|
|
525
|
-
/** List an engine's on-disk sessions (for resume across server restarts).
|
|
526
|
-
* Feed a result's `sessionId` to createSession({ resume }) — under a profile
|
|
527
|
-
* of the same engine. `profile` names whose store to list (claude profiles →
|
|
528
|
-
* the Agent SDK store, codex profiles → CODEX_HOME threads); absent, the
|
|
529
|
-
* server resolves it implicitly when it declares exactly one profile, else
|
|
530
|
-
* lists the Claude engine's store. */
|
|
531
360
|
async listSdkSessions(params) {
|
|
532
361
|
const search = new URLSearchParams();
|
|
533
362
|
if (params?.dir) search.set("dir", params.dir);
|
|
@@ -537,28 +366,13 @@ var WorkerDeckClient = class {
|
|
|
537
366
|
const qs = search.size > 0 ? `?${search.toString()}` : "";
|
|
538
367
|
return (await this.#call("GET", `/sdk-sessions${qs}`)).sdkSessions;
|
|
539
368
|
}
|
|
540
|
-
/**
|
|
541
|
-
* The host directories this server will let a client browse, and whether it
|
|
542
|
-
* accepts writes. Servers without host-file access configured 404 here — catch
|
|
543
|
-
* and treat as "no file browser", the same way `listProfiles` handles an older
|
|
544
|
-
* server.
|
|
545
|
-
*
|
|
546
|
-
* These are operator-privileged routes: the auth key is the whole authorization
|
|
547
|
-
* story, and they bypass the agent permission flow entirely. See the protocol
|
|
548
|
-
* package's `HostFileRoot` for why that framing is deliberate.
|
|
549
|
-
*/
|
|
550
369
|
async listHostRoots() {
|
|
551
370
|
return await this.#call("GET", "/fs/roots");
|
|
552
371
|
}
|
|
553
|
-
/** One host directory, not recursive. Symlinks are reported as symlinks, never
|
|
554
|
-
* followed here — read one to find out whether it resolves somewhere allowed. */
|
|
555
372
|
async listHostDir(path) {
|
|
556
373
|
const qs = `?path=${encodeURIComponent(path)}`;
|
|
557
374
|
return await this.#call("GET", `/fs/list${qs}`);
|
|
558
375
|
}
|
|
559
|
-
/** Recursive fuzzy file search under one host directory — the `@file` picker's
|
|
560
|
-
* query. Cheap enough to call per keystroke: build directories are skipped and
|
|
561
|
-
* the walk is bounded, truncating rather than erroring. */
|
|
562
376
|
async findHostFiles(path, query = "", limit) {
|
|
563
377
|
const search = new URLSearchParams({
|
|
564
378
|
path,
|
|
@@ -567,23 +381,13 @@ var WorkerDeckClient = class {
|
|
|
567
381
|
if (limit !== void 0) search.set("limit", String(limit));
|
|
568
382
|
return await this.#call("GET", `/fs/find?${search.toString()}`);
|
|
569
383
|
}
|
|
570
|
-
/** Read one host file. Binary content comes back base64-encoded; the returned
|
|
571
|
-
* `hash` is what a later `writeHostFile` needs as its `expectedHash`. */
|
|
572
384
|
async readHostFile(path) {
|
|
573
385
|
const qs = `?path=${encodeURIComponent(path)}`;
|
|
574
386
|
return await this.#call("GET", `/fs/read${qs}`);
|
|
575
387
|
}
|
|
576
|
-
/**
|
|
577
|
-
* Write one host file, conditionally — always. Pass the `hash` from the read this
|
|
578
|
-
* edit is based on; a 409 means the agent (or anything else) changed the file
|
|
579
|
-
* underneath you, and the edit must be rebased rather than forced. Omit
|
|
580
|
-
* `expectedHash` only to create a file that does not exist yet.
|
|
581
|
-
*/
|
|
582
388
|
async writeHostFile(request) {
|
|
583
389
|
return await this.#call("PUT", "/fs/write", request);
|
|
584
390
|
}
|
|
585
|
-
/** Schedule a one-shot run. The returned job's `sessionId` (once running) can be
|
|
586
|
-
* fed to `attach()` to watch the run live. */
|
|
587
391
|
async createJob(request) {
|
|
588
392
|
return (await this.#call("POST", "/jobs", request)).job;
|
|
589
393
|
}
|
|
@@ -593,7 +397,6 @@ var WorkerDeckClient = class {
|
|
|
593
397
|
async getJob(id) {
|
|
594
398
|
return (await this.#call("GET", `/jobs/${encodeURIComponent(id)}`)).job;
|
|
595
399
|
}
|
|
596
|
-
/** Cancel a queued or running job. */
|
|
597
400
|
async cancelJob(id) {
|
|
598
401
|
return (await this.#call("DELETE", `/jobs/${encodeURIComponent(id)}`)).job;
|
|
599
402
|
}
|
|
@@ -603,58 +406,31 @@ var WorkerDeckClient = class {
|
|
|
603
406
|
attach(sessionId, options) {
|
|
604
407
|
return new SessionHandle(this, sessionId, options);
|
|
605
408
|
}
|
|
606
|
-
/** Stream the job queue live (requires the server to be configured with `queue`).
|
|
607
|
-
* Servers without a queue refuse the socket — check REST first or expect retries. */
|
|
608
409
|
attachQueue(options) {
|
|
609
410
|
return new QueueHandle(this, options);
|
|
610
411
|
}
|
|
611
|
-
/** @internal used by SessionHandle */
|
|
612
412
|
openSocket(sessionId, afterSeq, truncateResults = false, imageRefs = false) {
|
|
613
413
|
const query = `afterSeq=${afterSeq}` + (truncateResults ? "&truncateResults=1" : "") + (imageRefs ? "&imageRefs=1" : "");
|
|
614
414
|
const url = this.#options.buildWsUrl?.(sessionId, afterSeq, truncateResults, imageRefs) ?? `${this.#options.baseUrl.replace(/^http/, "ws")}/sessions/${encodeURIComponent(sessionId)}/ws?${query}`;
|
|
615
415
|
return new this.#WebSocketImpl(url);
|
|
616
416
|
}
|
|
617
|
-
/**
|
|
618
|
-
* The whole of a tool result whose replay delivered only its head.
|
|
619
|
-
*
|
|
620
|
-
* `toolUseId` is required and the gateway verifies it against the block: a
|
|
621
|
-
* woken dormant session has a fresh log with fresh seqs, so a `sourceSeq`
|
|
622
|
-
* cached across a gateway restart can name a different event, and being handed
|
|
623
|
-
* another tool's output under the row you pressed is the exact failure this
|
|
624
|
-
* feature exists to remove. A 404 here means "ask again with a fresh attach",
|
|
625
|
-
* not "empty".
|
|
626
|
-
*/
|
|
627
417
|
async toolResult(sessionId, seq, toolUseId, options) {
|
|
628
418
|
return await this.#call("GET", `/sessions/${encodeURIComponent(sessionId)}/events/${seq}/result?toolUseId=${encodeURIComponent(toolUseId)}` + (options?.imageRefs ? "&imageRefs=1" : ""));
|
|
629
419
|
}
|
|
630
|
-
/**
|
|
631
|
-
* One image part's bytes, addressed by the `image_ref` a replay delivered in
|
|
632
|
-
* its place.
|
|
633
|
-
*
|
|
634
|
-
* A `Blob` and not a URL, and that is the whole reason this method exists: an
|
|
635
|
-
* `<img src>` pointing at the gateway carries a credential in exactly one of
|
|
636
|
-
* this project's four clients (the dashboard's same-origin implicit host,
|
|
637
|
-
* where the cookie rides along). Everywhere else — an added cross-origin
|
|
638
|
-
* gateway on a Bearer header, the VS Code webview whose every byte crosses a
|
|
639
|
-
* postMessage bridge, iOS — the URL is unauthenticated and the picture is a
|
|
640
|
-
* broken icon. Fetched rather than pointed at, then handed to
|
|
641
|
-
* `URL.createObjectURL`; `readProducedFile` is the shipped precedent.
|
|
642
|
-
*
|
|
643
|
-
* A 404 means "ask again with a fresh attach": a woken dormant session has a
|
|
644
|
-
* fresh log with fresh seqs, and the gateway refuses a stale address rather
|
|
645
|
-
* than serving another call's pixels under the row you are looking at.
|
|
646
|
-
*/
|
|
647
420
|
async toolResultImage(sessionId, seq, toolUseId, partIndex) {
|
|
648
421
|
const path = `/sessions/${encodeURIComponent(sessionId)}/events/${seq}/result?toolUseId=${encodeURIComponent(toolUseId)}&part=${partIndex}`;
|
|
649
|
-
|
|
650
|
-
if (!res.ok) throw new WorkerDeckError((await res.json().catch(() => ({}))).error ?? `image part request failed with ${res.status}`, res.status);
|
|
651
|
-
return await res.blob();
|
|
422
|
+
return await (await this.#callRaw(`${this.#options.baseUrl}${path}`, { headers: { ...this.#options.headers } }, "image part request failed")).blob();
|
|
652
423
|
}
|
|
653
|
-
/** @internal used by QueueHandle */
|
|
654
424
|
openQueueSocket() {
|
|
655
425
|
const url = this.#options.buildQueueWsUrl?.() ?? `${this.#options.baseUrl.replace(/^http/, "ws")}/queue/ws`;
|
|
656
426
|
return new this.#WebSocketImpl(url);
|
|
657
427
|
}
|
|
428
|
+
/** The byte-serving routes' shared failure arm: `#call` owns the same rule for JSON routes. */
|
|
429
|
+
async #callRaw(url, init, failure) {
|
|
430
|
+
const res = await this.#fetch(url, init);
|
|
431
|
+
if (!res.ok) throw new WorkerDeckError((await res.json().catch(() => ({}))).error ?? `${failure} with ${res.status}`, res.status);
|
|
432
|
+
return res;
|
|
433
|
+
}
|
|
658
434
|
async #call(method, path, body) {
|
|
659
435
|
const res = await this.#fetch(`${this.#options.baseUrl}${path}`, {
|
|
660
436
|
method,
|