@zoowork-ai/sdk 0.4.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/client.js ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * ZooWork Managed Agents SDK — core client. Developer Preview.
3
+ *
4
+ * Authenticate with your organization API key (`zct_…`). It carries full tenant
5
+ * authority, so it is SERVER-SIDE ONLY: never ship it in a browser or mobile bundle.
6
+ *
7
+ * IDs are opaque strings and unknown response fields must be ignored — both are
8
+ * forward-compatibility rules, not suggestions. Errors surface an `error.type`; match on
9
+ * that, never on the message text.
10
+ */
11
+ import { parseSSE, isObj } from './sse.js';
12
+ import { normalizeEvent } from './events.js';
13
+ /**
14
+ * The production API base URL — the default. You should not need to set this:
15
+ * `ZOOWORK_BASE_URL` overrides it, and so does the `baseUrl` option, only when you
16
+ * need to point at a different deployment.
17
+ */
18
+ export const DEFAULT_BASE_URL = 'https://clawapi.ecap.gsmo.ai/service/v1';
19
+ /**
20
+ * Read an environment variable without assuming a Node runtime.
21
+ *
22
+ * The SDK runs in Workers, Deno and browsers too, where `process` does not exist —
23
+ * touching it unguarded is a ReferenceError, not `undefined`.
24
+ */
25
+ function readEnv(name) {
26
+ const proc = globalThis.process;
27
+ const value = proc?.env?.[name];
28
+ return value === undefined || value === '' ? undefined : value;
29
+ }
30
+ export class ZooworkError extends Error {
31
+ status;
32
+ /**
33
+ * The machine-readable error code. Match on this, never on the message.
34
+ *
35
+ * TWO VOCABULARIES, because there are two error envelopes — staging-verified 2026-08-07. The
36
+ * sessions/schedules/environments family answers `{ error: { type, message } }` with a bare code
37
+ * (`agent_not_running`, `session_archived`, `environment_not_ready`); the agents family answers
38
+ * `{ code, detail }` with a DOTTED one (`service_api.not_found`). Both land here, so a caller
39
+ * always gets something — but do not assume one spelling covers both, and prefer `status` when
40
+ * you only need the class of failure.
41
+ */
42
+ type;
43
+ constructor(status, message, type) {
44
+ super(message);
45
+ this.name = 'ZooworkError';
46
+ this.status = status;
47
+ if (type)
48
+ this.type = type;
49
+ }
50
+ }
51
+ /**
52
+ * Create a client.
53
+ *
54
+ * ```ts
55
+ * const zc = createZooworkClient({ apiKey: 'zct_...' })
56
+ * const zc = createZooworkClient() // reads ZOOWORK_API_KEY
57
+ * ```
58
+ *
59
+ * Resolution order for both settings is the same: explicit argument, then environment
60
+ * variable, then (for `baseUrl` only) the built-in default.
61
+ *
62
+ * @throws if no API key can be resolved — a missing key is a setup mistake worth failing
63
+ * loudly at construction rather than as a 401 on the first call.
64
+ */
65
+ export function createZooworkClient(cfg = {}) {
66
+ const doFetch = cfg.fetch ?? ((input, init) => fetch(input, init));
67
+ const base = (cfg.baseUrl ?? readEnv('ZOOWORK_BASE_URL') ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
68
+ const auth = cfg.auth ??
69
+ (cfg.apiKey !== undefined
70
+ ? { apiKey: cfg.apiKey }
71
+ : (() => {
72
+ const fromEnv = readEnv('ZOOWORK_API_KEY');
73
+ return fromEnv ? { apiKey: fromEnv } : undefined;
74
+ })());
75
+ if (!auth) {
76
+ throw new Error('No ZooWork API key. Pass createZooworkClient({ apiKey }) or set ZOOWORK_API_KEY. ' +
77
+ 'Keys look like zct_… and are issued by an organization administrator.');
78
+ }
79
+ const bearer = 'serviceToken' in auth ? auth.serviceToken : auth.apiKey;
80
+ /**
81
+ * TWO error envelopes, one ZooworkError shape, for every helper below.
82
+ *
83
+ * The API does not answer failures the same way everywhere — staging-verified 2026-08-07. Most
84
+ * families send `{ error: { type, message } }`; the agents family sends `{ code, detail }`.
85
+ * Reading only the first left every agent 404 with `type: undefined` and the message `HTTP 404`,
86
+ * so both are unpacked here. The codes stay verbatim (`not_found` vs `service_api.not_found`) —
87
+ * inventing a shared vocabulary would be this SDK guessing, which is what it exists not to do.
88
+ */
89
+ const readResponse = async (res, path) => {
90
+ const text = await res.text();
91
+ if (!res.ok) {
92
+ let msg = `HTTP ${res.status}`;
93
+ let type;
94
+ try {
95
+ const j = JSON.parse(text);
96
+ msg = j?.error?.message || j?.message || j?.detail || msg;
97
+ type = j?.error?.type ?? j?.code;
98
+ }
99
+ catch {
100
+ /* non-JSON error body → keep clean status */
101
+ }
102
+ throw new ZooworkError(res.status, msg, type);
103
+ }
104
+ if (!text)
105
+ return {};
106
+ try {
107
+ return JSON.parse(text);
108
+ }
109
+ catch {
110
+ throw new ZooworkError(res.status, `non-JSON response: ${path}`);
111
+ }
112
+ };
113
+ /**
114
+ * `signal` is forwarded to `fetch`, so a request can be cancelled WHILE IT IS IN FLIGHT.
115
+ * That matters because neither Node's `fetch` nor the Workers one has a default timeout:
116
+ * a gateway that accepts the connection and then stalls hangs the promise forever unless
117
+ * somebody hands it a signal. See `waitUntilRunning`, which bounds every poll with one.
118
+ */
119
+ const json = async (path, init = {}) => {
120
+ const headers = { ...init.headers, Authorization: `Bearer ${bearer}` };
121
+ if (init.body && !('Content-Type' in headers))
122
+ headers['Content-Type'] = 'application/json';
123
+ const res = await doFetch(`${base}${path}`, {
124
+ method: init.method,
125
+ body: init.body,
126
+ headers,
127
+ ...(init.signal ? { signal: init.signal } : {}),
128
+ });
129
+ return readResponse(res, path);
130
+ };
131
+ /**
132
+ * Multipart sibling of `json()`: same auth, same error envelope, but the body is a `FormData`
133
+ * and the SDK deliberately does NOT set `Content-Type`. The runtime has to set it, because
134
+ * only the runtime knows the boundary it generated — hand-writing
135
+ * `multipart/form-data` yourself produces a body the server cannot parse.
136
+ */
137
+ const multipart = async (path, form, init = {}) => {
138
+ const headers = { ...init.headers, Authorization: `Bearer ${bearer}` };
139
+ const res = await doFetch(`${base}${path}`, { method: init.method ?? 'POST', body: form, headers });
140
+ return readResponse(res, path);
141
+ };
142
+ const agents = (id) => `/agents/${encodeURIComponent(id)}`;
143
+ const sessions = (id) => `${agents(id)}/sessions`;
144
+ const schedules = (id) => `${agents(id)}/schedules`;
145
+ const environments = (id) => `/environments/${encodeURIComponent(id)}`;
146
+ const query = (params) => {
147
+ const q = new URLSearchParams();
148
+ for (const [k, v] of Object.entries(params))
149
+ if (v !== undefined)
150
+ q.set(k, String(v));
151
+ const qs = q.toString();
152
+ return qs ? `?${qs}` : '';
153
+ };
154
+ /**
155
+ * A zip from any of the three shapes callers actually hold. `Blob` exists in every runtime we
156
+ * target, so it is the one currency `FormData` always accepts.
157
+ *
158
+ * The cast is a typings artefact, not a runtime risk: lib.dom narrows `BlobPart`'s view branch
159
+ * to `ArrayBufferView<ArrayBuffer>`, which excludes a plain `Uint8Array` (whose buffer is
160
+ * `ArrayBufferLike`) — i.e. exactly what `fs.readFile` hands you.
161
+ */
162
+ const zipBlob = (zip) => zip instanceof Blob ? zip : new Blob([zip], { type: 'application/zip' });
163
+ const skillForm = (zip, opts) => {
164
+ const form = new FormData();
165
+ // `files[]` is the field name the Skills API expects — it is isomorphic to the Claude
166
+ // Skills API, and exactly one zip goes in it.
167
+ form.append('files[]', zipBlob(zip), opts.fileName ?? 'skill.zip');
168
+ if (opts.description !== undefined)
169
+ form.append('description', opts.description);
170
+ return form;
171
+ };
172
+ /** Interruptible sleep, so an aborted wait does not linger for the rest of its interval. */
173
+ const sleep = (ms, signal) => new Promise((resolve) => {
174
+ // Check first: `addEventListener('abort')` never fires on an ALREADY-aborted signal, so
175
+ // without this an abort that landed during the preceding request would sleep the whole
176
+ // interval before anyone noticed it.
177
+ if (signal?.aborted) {
178
+ resolve();
179
+ return;
180
+ }
181
+ let timer;
182
+ const done = () => {
183
+ if (timer !== undefined)
184
+ clearTimeout(timer);
185
+ signal?.removeEventListener('abort', done);
186
+ resolve();
187
+ };
188
+ timer = setTimeout(done, ms);
189
+ signal?.addEventListener('abort', done, { once: true });
190
+ });
191
+ /**
192
+ * `owner_uid` + `org_id` for the artifacts family, derived once per agent from its own
193
+ * projection. The engine requires both selectors on every artifact route and re-checks them
194
+ * against the agent's ownership; the gateway forwards the caller's query verbatim there
195
+ * (it injects selectors only on `GET /agents`), so the SDK has to supply them — and the
196
+ * projection's `ownership` is the one source a key-holder always has. Ownership is
197
+ * immutable for the life of an agent, so the cache never needs invalidating.
198
+ */
199
+ const ownershipByAgent = new Map();
200
+ const artifactSelectors = async (agentId) => {
201
+ const hit = ownershipByAgent.get(agentId);
202
+ if (hit)
203
+ return hit;
204
+ const projection = await json(agents(agentId));
205
+ const own = projection.ownership;
206
+ if (!own?.owner_uid || !own.org_id) {
207
+ throw new ZooworkError(500, `agent ${agentId} projection carries no ownership — cannot derive the owner_uid/org_id ` +
208
+ 'selectors the artifact routes require', 'ownership_unavailable');
209
+ }
210
+ const sel = { owner_uid: own.owner_uid, org_id: own.org_id };
211
+ ownershipByAgent.set(agentId, sel);
212
+ return sel;
213
+ };
214
+ const client = {
215
+ listModels: async () => {
216
+ const data = await json('/models');
217
+ return Array.isArray(data) ? data : (data.models ?? []);
218
+ },
219
+ createAgent: (input, idempotencyKey) => {
220
+ // Runtime strip, not just type-level: `warm` races the platform credential seeding
221
+ // (sandbox born without creds, never heals — zooclaw-engine#791), and the BOOTSTRAP
222
+ // interview is never what an API caller wants. JS callers bypass the types, so both
223
+ // are enforced here.
224
+ const { warm: _warm, onboarding: _onboarding, ...resource } = input.resource;
225
+ return json('/agents', {
226
+ method: 'POST',
227
+ body: JSON.stringify({
228
+ resource: { ...resource, onboarding: false },
229
+ ...(input.ownership ? { ownership: input.ownership } : {}),
230
+ }),
231
+ ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
232
+ });
233
+ },
234
+ listAgents: async (opts = {}) => {
235
+ const params = { page: opts.page };
236
+ for (const [k, v] of Object.entries(opts.labels ?? {}))
237
+ params[`label.${k}`] = v;
238
+ const data = await json(`/agents${query(params)}`);
239
+ return data.agents ?? [];
240
+ },
241
+ getAgent: (agentId) => json(agents(agentId)),
242
+ updateAgent: (agentId, sections) => json(agents(agentId), { method: 'PUT', body: JSON.stringify(sections) }),
243
+ deleteAgent: async (agentId) => {
244
+ await json(agents(agentId), { method: 'DELETE' });
245
+ },
246
+ startAgent: async (agentId) => {
247
+ const data = await json(`${agents(agentId)}/start`, { method: 'POST' });
248
+ return { warnings: data.warnings ?? [] };
249
+ },
250
+ stopAgent: async (agentId) => {
251
+ const data = await json(`${agents(agentId)}/stop`, { method: 'POST' });
252
+ return { warnings: data.warnings ?? [] };
253
+ },
254
+ waitUntilRunning: async (agentId, opts = {}) => {
255
+ const timeoutMs = opts.timeoutMs ?? 30_000;
256
+ const intervalMs = opts.intervalMs ?? 500;
257
+ const deadline = Date.now() + timeoutMs;
258
+ let lastSeen = 'unknown';
259
+ const abortedError = () => new ZooworkError(0, `waitUntilRunning(${agentId}) aborted`, 'aborted');
260
+ const timeoutError = () => new ZooworkError(408, `agent ${agentId} did not reach status.desired_state=running within ${timeoutMs}ms ` +
261
+ `(last seen: ${lastSeen})`, 'timeout');
262
+ for (;;) {
263
+ if (opts.signal?.aborted)
264
+ throw abortedError();
265
+ const remaining = deadline - Date.now();
266
+ if (remaining <= 0)
267
+ throw timeoutError();
268
+ // THE POLL ITSELF IS BOUNDED, not just the gap between polls. `fetch` has no default
269
+ // timeout anywhere we run, so a gateway that accepts the connection and then stalls
270
+ // would otherwise park this promise forever — outliving both `timeoutMs` and the
271
+ // caller's `signal`, which is the exact never-returning readiness loop this helper
272
+ // exists to prevent. The per-request signal fires on whichever comes first.
273
+ const poll = new AbortController();
274
+ const cancelPoll = () => poll.abort();
275
+ opts.signal?.addEventListener('abort', cancelPoll, { once: true });
276
+ const budget = setTimeout(cancelPoll, remaining);
277
+ let agent;
278
+ try {
279
+ agent = await json(agents(agentId), { signal: poll.signal });
280
+ }
281
+ catch (e) {
282
+ // Our own cancellation surfaces as a fetch AbortError; translate it into the two
283
+ // outcomes this method documents instead of leaking a DOMException.
284
+ if (poll.signal.aborted)
285
+ throw opts.signal?.aborted ? abortedError() : timeoutError();
286
+ throw e;
287
+ }
288
+ finally {
289
+ clearTimeout(budget);
290
+ opts.signal?.removeEventListener('abort', cancelPoll);
291
+ }
292
+ // desired_state, never actual_state — see the doc comment on this method.
293
+ lastSeen = agent.status?.desired_state ?? 'unknown';
294
+ if (lastSeen === 'running')
295
+ return agent;
296
+ if (Date.now() + intervalMs > deadline)
297
+ throw timeoutError();
298
+ await sleep(intervalMs, opts.signal);
299
+ }
300
+ },
301
+ listAgentSkills: async (agentId, opts = {}) => {
302
+ const data = await json(`${agents(agentId)}/skills${opts.verbose ? '?verbose=true' : ''}`);
303
+ return data.skills ?? [];
304
+ },
305
+ putAgentSkill: (agentId, skillId, opts = {}) => json(`${agents(agentId)}/skills/${encodeURIComponent(skillId)}`, {
306
+ method: 'PUT',
307
+ body: JSON.stringify({ enabled: opts.enabled ?? true, version_pin: opts.versionPin ?? null }),
308
+ }),
309
+ deleteAgentSkill: async (agentId, skillId) => {
310
+ await json(`${agents(agentId)}/skills/${encodeURIComponent(skillId)}`, { method: 'DELETE' });
311
+ },
312
+ listChannels: async (agentId) => {
313
+ const data = await json(`${agents(agentId)}/channels`);
314
+ return data.channels ?? [];
315
+ },
316
+ addChannel: (agentId, input) => json(`${agents(agentId)}/channels`, { method: 'POST', body: JSON.stringify(input) }),
317
+ updateChannel: (agentId, platform, input = {}) => json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/update`, {
318
+ method: 'POST',
319
+ body: JSON.stringify(input),
320
+ }),
321
+ removeChannel: async (agentId, platform, opts = {}) => {
322
+ await json(`${agents(agentId)}/channels/${encodeURIComponent(platform)}/remove`, {
323
+ method: 'POST',
324
+ body: JSON.stringify({ account: opts.account ?? 'default' }),
325
+ });
326
+ },
327
+ startFeishuSetup: (agentId, input = {}) => json(`${agents(agentId)}/channels/feishu/setup`, { method: 'POST', body: JSON.stringify(input) }),
328
+ pollFeishuSetup: (agentId, sessionId) => json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`),
329
+ cancelFeishuSetup: async (agentId, sessionId) => {
330
+ await json(`${agents(agentId)}/channels/feishu/setup/cancel${query({ session_id: sessionId })}`, {
331
+ method: 'POST',
332
+ });
333
+ },
334
+ waitForFeishuSetup: async (agentId, sessionId, opts = {}) => {
335
+ const timeoutMs = opts.timeoutMs ?? 600_000;
336
+ const deadline = Date.now() + timeoutMs;
337
+ let lastStatus = 'unknown';
338
+ const abortedError = () => new ZooworkError(0, `waitForFeishuSetup(${agentId}, ${sessionId}) aborted`, 'aborted');
339
+ const timeoutError = () => new ZooworkError(408, `Feishu setup session ${sessionId} still '${lastStatus}' after ${timeoutMs}ms — ` +
340
+ 'the QR may simply not have been scanned yet; the session itself expires server-side', 'timeout');
341
+ for (;;) {
342
+ if (opts.signal?.aborted)
343
+ throw abortedError();
344
+ const remaining = deadline - Date.now();
345
+ if (remaining <= 0)
346
+ throw timeoutError();
347
+ // Same in-flight bound as waitUntilRunning: without it, a stalled gateway would park
348
+ // this promise past both the budget and the caller's signal.
349
+ const poll = new AbortController();
350
+ const cancelPoll = () => poll.abort();
351
+ opts.signal?.addEventListener('abort', cancelPoll, { once: true });
352
+ const budget = setTimeout(cancelPoll, remaining);
353
+ let result;
354
+ try {
355
+ result = await json(`${agents(agentId)}/channels/feishu/poll${query({ session_id: sessionId })}`, { signal: poll.signal });
356
+ }
357
+ catch (e) {
358
+ if (poll.signal.aborted)
359
+ throw opts.signal?.aborted ? abortedError() : timeoutError();
360
+ throw e;
361
+ }
362
+ finally {
363
+ clearTimeout(budget);
364
+ opts.signal?.removeEventListener('abort', cancelPoll);
365
+ }
366
+ opts.onPoll?.(result);
367
+ lastStatus = result.status ?? 'unknown';
368
+ // Only a literal 'pending' keeps the loop alive… except that an UNKNOWN status is
369
+ // treated as still-in-flight too (see FeishuPollResult): a new intermediate state on
370
+ // the server should stretch the wait, not end it with a fake terminal result.
371
+ const terminal = ['success', 'expired', 'denied', 'error'].includes(lastStatus);
372
+ if (terminal)
373
+ return result;
374
+ // Server semantics: poll_interval is in SECONDS. The floor only guards a 0/negative
375
+ // value from ever busy-looping the gateway.
376
+ const intervalMs = Math.max(250, (result.poll_interval ?? 5) * 1000);
377
+ if (Date.now() + intervalMs > deadline)
378
+ throw timeoutError();
379
+ await sleep(intervalMs, opts.signal);
380
+ }
381
+ },
382
+ uploadSkill: (zip, opts) => {
383
+ const form = skillForm(zip, opts);
384
+ form.append('scope', opts.scope);
385
+ return multipart('/skills', form, {
386
+ ...(opts.idempotencyKey ? { headers: { 'Idempotency-Key': opts.idempotencyKey } } : {}),
387
+ });
388
+ },
389
+ uploadSkillVersion: (skillId, zip, opts = {}) => multipart(`/skills/${encodeURIComponent(skillId)}/versions`, skillForm(zip, opts), {
390
+ ...(opts.idempotencyKey ? { headers: { 'Idempotency-Key': opts.idempotencyKey } } : {}),
391
+ }),
392
+ listSkills: async (opts = {}) => {
393
+ const data = await json(`/skills${query({ scope: opts.scope, q: opts.q, page: opts.page })}`);
394
+ return data.skills ?? [];
395
+ },
396
+ deleteSkill: async (skillId) => {
397
+ await json(`/skills/${encodeURIComponent(skillId)}`, { method: 'DELETE' });
398
+ },
399
+ createSession: (agentId, input, idempotencyKey) => json(sessions(agentId), {
400
+ method: 'POST',
401
+ body: JSON.stringify(input),
402
+ ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
403
+ }),
404
+ getSession: (agentId, sessionId, opts = {}) => {
405
+ const q = new URLSearchParams();
406
+ if (opts.history)
407
+ q.set('history', 'true');
408
+ if (opts.limit !== undefined)
409
+ q.set('limit', String(opts.limit));
410
+ const qs = q.toString();
411
+ return json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}${qs ? `?${qs}` : ''}`);
412
+ },
413
+ listSessions: async (agentId, opts = {}) => {
414
+ const data = await json(`${sessions(agentId)}${query({ page: opts.page })}`);
415
+ return data.sessions ?? [];
416
+ },
417
+ archiveSession: async (agentId, sessionId) => {
418
+ const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/archive`, { method: 'POST' });
419
+ return { ...data, archived: data.archived ?? false };
420
+ },
421
+ deleteSession: async (agentId, sessionId) => {
422
+ await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
423
+ },
424
+ postEvents: async (agentId, sessionId, events) => {
425
+ const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/events`, { method: 'POST', body: JSON.stringify({ events }) });
426
+ return { events: data.events ?? [] };
427
+ },
428
+ listEvents: async (agentId, sessionId, opts = {}) => (await client.listEventsPage(agentId, sessionId, opts)).events,
429
+ listEventsPage: async (agentId, sessionId, opts = {}) => {
430
+ const data = await json(`${sessions(agentId)}/${encodeURIComponent(sessionId)}/events${query({
431
+ after: opts.after,
432
+ cursor: opts.cursor,
433
+ types: opts.types?.join(','),
434
+ limit: opts.limit,
435
+ })}`);
436
+ return {
437
+ events: (data.events ?? []).map((e) => normalizeEvent(e)),
438
+ ...(data.has_more !== undefined ? { hasMore: data.has_more } : {}),
439
+ ...(data.next_cursor !== undefined ? { nextCursor: data.next_cursor } : {}),
440
+ };
441
+ },
442
+ listAllEvents: async (agentId, sessionId, opts = {}) => {
443
+ const pageSize = Math.min(Math.max(opts.pageSize ?? 500, 1), 500);
444
+ const out = [];
445
+ // Unified lane (default): follow the server's cursor until has_more is false.
446
+ if (opts.after === undefined) {
447
+ let pageCursor;
448
+ for (;;) {
449
+ const page = await client.listEventsPage(agentId, sessionId, {
450
+ ...(pageCursor !== undefined ? { cursor: pageCursor } : {}),
451
+ ...(opts.types ? { types: opts.types } : {}),
452
+ limit: pageSize,
453
+ });
454
+ // A cursor that fails to advance means the same page again — stop before
455
+ // re-appending it rather than refetching it forever.
456
+ if (page.hasMore !== undefined && page.nextCursor === pageCursor)
457
+ return out;
458
+ out.push(...page.events);
459
+ if (page.hasMore === undefined) {
460
+ // Before the first cursor this is a server without cursor pagination: a short
461
+ // page already ends the history, a full one continues on the `after` walk below.
462
+ // Mid-walk it is a protocol violation — return what we have rather than silently
463
+ // switching to the engine-only lane and dropping input events.
464
+ if (pageCursor !== undefined || page.events.length < pageSize)
465
+ return out;
466
+ break;
467
+ }
468
+ if (!page.hasMore || !page.nextCursor || page.events.length === 0)
469
+ return out;
470
+ pageCursor = page.nextCursor;
471
+ }
472
+ }
473
+ let cursor = opts.after ?? out.reduce((max, e) => (e.seq > max ? e.seq : max), 0);
474
+ for (;;) {
475
+ const page = await client.listEvents(agentId, sessionId, {
476
+ after: cursor,
477
+ ...(opts.types ? { types: opts.types } : {}),
478
+ limit: pageSize,
479
+ });
480
+ // Anything at or below the cursor is a boundary replay — or a server that ignored
481
+ // `after`. Dropping it keeps the result deduplicated AND the walk finite.
482
+ const fresh = cursor > 0 ? page.filter((e) => e.seq > cursor) : page;
483
+ out.push(...fresh);
484
+ const highest = fresh.reduce((max, e) => (e.seq > max ? e.seq : max), cursor);
485
+ if (page.length < pageSize || highest <= cursor)
486
+ return out;
487
+ cursor = highest;
488
+ }
489
+ },
490
+ async *streamEvents(agentId, sessionId, opts = {}) {
491
+ const after = opts.after ?? 0;
492
+ let lastSeq = after;
493
+ const qs = query(opts.cursor !== undefined ? { cursor: opts.cursor } : { after: after > 0 ? after : undefined });
494
+ const path = `${sessions(agentId)}/${encodeURIComponent(sessionId)}/events/stream${qs}`;
495
+ try {
496
+ const res = await doFetch(`${base}${path}`, {
497
+ headers: { Authorization: `Bearer ${bearer}`, Accept: 'text/event-stream' },
498
+ ...(opts.signal ? { signal: opts.signal } : {}),
499
+ });
500
+ if (!res.ok)
501
+ throw new ZooworkError(res.status, `events stream HTTP ${res.status}`);
502
+ if (!res.body)
503
+ return;
504
+ for await (const msg of parseSSE(res.body)) {
505
+ if (msg.event === 'event_delta')
506
+ continue;
507
+ if (!isObj(msg.data))
508
+ continue;
509
+ const ev = normalizeEvent(msg.data, msg.id);
510
+ // The server already resumes from `after`; this guards the boundary event being
511
+ // replayed when a dropped connection is re-established.
512
+ if (ev.seq >= 0 && ev.seq <= lastSeq)
513
+ continue;
514
+ if (ev.seq > lastSeq)
515
+ lastSeq = ev.seq;
516
+ yield ev;
517
+ }
518
+ }
519
+ catch (e) {
520
+ if (opts.signal?.aborted)
521
+ return;
522
+ throw e;
523
+ }
524
+ },
525
+ listApprovals: async (agentId, opts = {}) => {
526
+ const data = await json(`${agents(agentId)}/approvals${query({ status: opts.status })}`);
527
+ return data.approvals ?? [];
528
+ },
529
+ resolveApproval: (agentId, approvalId, input) => json(`${agents(agentId)}/approvals/${encodeURIComponent(approvalId)}/resolve`, {
530
+ method: 'POST',
531
+ body: JSON.stringify(input),
532
+ }),
533
+ getSystemPrompt: (agentId) => json(`${agents(agentId)}/system-prompt`),
534
+ // The colon goes RAW here: this family matches the literal `system-prompt:preview` segment,
535
+ // and both spellings pass the gateway (verified 2026-08-14) — so raw also serves the
536
+ // deployment-internal direct mode, whose HTTP server never percent-decodes the path.
537
+ previewSystemPrompt: (agentId, input) => json(`${agents(agentId)}/system-prompt:preview`, { method: 'POST', body: JSON.stringify(input) }),
538
+ upgradeSystemPrompt: (agentId, input) => json(`${agents(agentId)}:upgrade-system-prompt`, { method: 'POST', body: JSON.stringify(input) }),
539
+ listArtifacts: async (agentId, opts = {}) => {
540
+ const sel = await artifactSelectors(agentId);
541
+ const data = await json(`${agents(agentId)}/artifacts${query({
542
+ ...sel,
543
+ page: opts.page,
544
+ limit: opts.limit,
545
+ session_id: opts.sessionId,
546
+ source_path: opts.sourcePath,
547
+ created_before: opts.createdBefore,
548
+ })}`);
549
+ return { ...data, artifacts: data.artifacts ?? [] };
550
+ },
551
+ getArtifact: async (agentId, artifactId) => {
552
+ const sel = await artifactSelectors(agentId);
553
+ return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}${query({ ...sel })}`);
554
+ },
555
+ downloadArtifact: async (agentId, artifactId) => {
556
+ const sel = await artifactSelectors(agentId);
557
+ return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}:download${query({ ...sel })}`, {
558
+ method: 'POST',
559
+ });
560
+ },
561
+ deleteArtifact: async (agentId, artifactId) => {
562
+ const sel = await artifactSelectors(agentId);
563
+ return json(`${agents(agentId)}/artifacts/${encodeURIComponent(artifactId)}${query({ ...sel })}`, {
564
+ method: 'DELETE',
565
+ });
566
+ },
567
+ listSchedules: async (agentId) => {
568
+ const data = await json(schedules(agentId));
569
+ return data.schedules ?? [];
570
+ },
571
+ createSchedule: (agentId, input, idempotencyKey) => json(schedules(agentId), {
572
+ method: 'POST',
573
+ body: JSON.stringify(input),
574
+ ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
575
+ }),
576
+ getSchedule: (agentId, scheduleId) => json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`),
577
+ updateSchedule: (agentId, scheduleId, update) => {
578
+ // Stripped rather than forwarded. Every one of these is a field `getSchedule()` hands you
579
+ // and the PUT refuses: four are `server-derived` 400s, `sessionTarget` is an `immutable`
580
+ // 400, and `scheduleSpec` is worse than a 400 — it is accepted and ignored, so echoing a
581
+ // read back would answer 200 while quietly dropping the cadence change. The types already
582
+ // refuse all six; this is what makes the same round trip work from JavaScript.
583
+ const { sessionTarget: _immutable, scheduleSpec: _readShape, execution: _derived1, originMetadata: _derived2, contextSnapshot: _derived3, creatorPrincipalRef: _derived4, ...body } = update;
584
+ return json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`, {
585
+ method: 'PUT',
586
+ body: JSON.stringify(body),
587
+ });
588
+ },
589
+ deleteSchedule: async (agentId, scheduleId) => {
590
+ await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}`, { method: 'DELETE' });
591
+ },
592
+ triggerSchedule: async (agentId, scheduleId) => {
593
+ const data = await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}/trigger`, { method: 'POST' });
594
+ return { ...data, triggered: data.triggered ?? false };
595
+ },
596
+ listScheduleRuns: async (agentId, scheduleId, opts = {}) => {
597
+ const data = await json(`${schedules(agentId)}/${encodeURIComponent(scheduleId)}/runs${query({ limit: opts.limit })}`);
598
+ return data.runs ?? [];
599
+ },
600
+ wake: (agentId, input) => json(`${agents(agentId)}/wake`, { method: 'POST', body: JSON.stringify(input) }),
601
+ exec: (agentId, args) => json(`${agents(agentId)}/exec`, { method: 'POST', body: JSON.stringify({ args }) }),
602
+ listEnvironments: async (opts = {}) => {
603
+ const data = await json(`/environments${query({ page: opts.page })}`);
604
+ return data.environments ?? [];
605
+ },
606
+ getEnvironment: (environmentId) => json(environments(environmentId)),
607
+ createEnvironment: (input, idempotencyKey) => json('/environments', {
608
+ method: 'POST',
609
+ body: JSON.stringify(input),
610
+ ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
611
+ }),
612
+ // `%3A`, never a literal ':' — the engine misses the route on a raw colon and answers 404.
613
+ archiveEnvironment: (environmentId) => json(`/environments/${encodeURIComponent(environmentId)}%3Aarchive`, { method: 'POST' }),
614
+ createEnvironmentVersion: (environmentId, config, idempotencyKey) => json(`${environments(environmentId)}/versions`, {
615
+ method: 'POST',
616
+ body: JSON.stringify({ resource: { config } }),
617
+ ...(idempotencyKey ? { headers: { 'Idempotency-Key': idempotencyKey } } : {}),
618
+ }),
619
+ getEnvironmentVersion: (environmentId, version) => json(`${environments(environmentId)}/versions/${encodeURIComponent(String(version))}`),
620
+ };
621
+ return client;
622
+ }