@loomcycle/client 0.10.0 → 0.10.1

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.
@@ -0,0 +1,734 @@
1
+ "use strict";
2
+ /**
3
+ * LoomcycleClient — the single public class exported by
4
+ * @loomcycle/client. Speaks HTTP+SSE to a running loomcycle sidecar.
5
+ *
6
+ * hooks-connector PR C: full Python-adapter parity + hook management.
7
+ * 27 methods total — 26 async (run streaming, continuation, agent
8
+ * metadata, transcript, health, users, pause/resume/state, snapshot
9
+ * lifecycle capture / list / get / restore / delete, memory admin,
10
+ * interruption listing + resolve, hook registration / list / delete)
11
+ * plus one synchronous helper (exportSnapshotURL builds a URL string
12
+ * without issuing a request).
13
+ *
14
+ * Construction:
15
+ *
16
+ * const client = new LoomcycleClient({
17
+ * baseUrl: "http://127.0.0.1:8787", // or process.env.LOOMCYCLE_BASE_URL
18
+ * authToken: "...", // or process.env.LOOMCYCLE_AUTH_TOKEN
19
+ * });
20
+ *
21
+ * Streaming methods (`runStreaming`, `continueSession`) return
22
+ * AsyncIterable<AgentEvent>; non-streaming methods return
23
+ * Promise<T>. Non-2xx responses throw typed errors from errors.ts
24
+ * via fetch-helpers.ts:raiseFromResponse — see README.md for the
25
+ * full mapping table.
26
+ */
27
+ Object.defineProperty(exports, "__esModule", { value: true });
28
+ exports.LoomcycleClient = void 0;
29
+ const fetch_helpers_js_1 = require("./fetch-helpers.js");
30
+ const stream_js_1 = require("./stream.js");
31
+ class LoomcycleClient {
32
+ ctx;
33
+ constructor(opts = {}) {
34
+ this.ctx = {
35
+ baseUrl: (opts.baseUrl ?? "http://127.0.0.1:8787").replace(/\/$/, ""),
36
+ authToken: opts.authToken,
37
+ fetchImpl: opts.fetch ?? fetch,
38
+ };
39
+ }
40
+ // ---- Run lifecycle ----
41
+ /**
42
+ * Run an agent and stream events. Returns AsyncIterable<AgentEvent>;
43
+ * the iterator completes when the server closes the SSE stream.
44
+ *
45
+ * Errors during the run surface as `{ type: "error", error }` events;
46
+ * only transport / HTTP-level failures throw — and those throw typed
47
+ * errors (e.g. AuthError for 401, BackpressureError for 429).
48
+ *
49
+ * **Blocking semantics.** This iterator is alive for the FULL
50
+ * duration of the run — typically seconds, occasionally minutes for
51
+ * long tool chains. Callers that need fire-and-forget completion
52
+ * notifications (n8n's worker model, dashboards that don't want to
53
+ * hold a connection per active run) should subscribe to
54
+ * {@link LoomcycleClient.streamUserRunStates} instead, which yields
55
+ * one terminal-state frame per completed run without holding the
56
+ * run's stream open.
57
+ *
58
+ * v0.9.x — pass `opts.debug = true` to emit synthetic
59
+ * `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
60
+ * events around the real frames. Silent (default) when omitted.
61
+ */
62
+ async *runStreaming(opts) {
63
+ // Build the body conditionally so omitted fields stay off the wire.
64
+ // The pointer-vs-empty distinction on allowed_hosts is preserved by
65
+ // treating `null` as "omit" — same as the server's nil semantics —
66
+ // so callers threading a possibly-unset slice don't accidentally
67
+ // send `allowed_hosts: null` (which JSON-decodes to a deny-all on
68
+ // some implementations).
69
+ const body = {
70
+ agent: opts.agent,
71
+ segments: opts.segments,
72
+ };
73
+ if (opts.allowedTools !== undefined)
74
+ body.allowed_tools = opts.allowedTools;
75
+ if (opts.allowedHosts !== undefined && opts.allowedHosts !== null) {
76
+ body.allowed_hosts = opts.allowedHosts;
77
+ }
78
+ if (opts.webSearchFilter !== undefined)
79
+ body.web_search_filter = opts.webSearchFilter;
80
+ if (opts.sessionId !== undefined)
81
+ body.session_id = opts.sessionId;
82
+ if (opts.tenantId !== undefined)
83
+ body.tenant_id = opts.tenantId;
84
+ if (opts.userId !== undefined)
85
+ body.user_id = opts.userId;
86
+ if (opts.agentId !== undefined)
87
+ body.agent_id = opts.agentId;
88
+ if (opts.userTier !== undefined)
89
+ body.user_tier = opts.userTier;
90
+ if (opts.userBearer !== undefined)
91
+ body.user_bearer = opts.userBearer;
92
+ yield* this.streamSSE("/v1/runs", body, opts.signal, opts.debug);
93
+ }
94
+ /**
95
+ * Continue an existing session with a new run. The session's prior
96
+ * transcript is replayed into the model's context server-side;
97
+ * this iterator yields only the NEW events from the continuation.
98
+ *
99
+ * Raises SessionNotFoundError when sessionId is unknown,
100
+ * SessionBusyError when another request is in flight on the same
101
+ * session.
102
+ *
103
+ * **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
104
+ * the iterator stays alive for the duration of the new run. For
105
+ * async fire-and-forget completion patterns, see
106
+ * {@link LoomcycleClient.streamUserRunStates}.
107
+ *
108
+ * v0.9.x — pass `opts.debug = true` for synthetic
109
+ * `_meta` open/close events.
110
+ */
111
+ async *continueSession(opts) {
112
+ const body = {
113
+ segments: opts.segments,
114
+ };
115
+ if (opts.allowedTools !== undefined)
116
+ body.allowed_tools = opts.allowedTools;
117
+ if (opts.allowedHosts !== undefined && opts.allowedHosts !== null) {
118
+ body.allowed_hosts = opts.allowedHosts;
119
+ }
120
+ if (opts.webSearchFilter !== undefined)
121
+ body.web_search_filter = opts.webSearchFilter;
122
+ if (opts.agentId !== undefined)
123
+ body.agent_id = opts.agentId;
124
+ if (opts.userTier !== undefined)
125
+ body.user_tier = opts.userTier;
126
+ if (opts.userBearer !== undefined)
127
+ body.user_bearer = opts.userBearer;
128
+ yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal, opts.debug);
129
+ }
130
+ // ---- Agent metadata ----
131
+ /** Read one agent's status + usage stats. Raises AgentNotFoundError
132
+ * when the agent_id is unknown. */
133
+ async getAgent(agentId, opts) {
134
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}`, opts);
135
+ }
136
+ /** Cancel a live agent (cascades to children via parent_agent_id).
137
+ * Returns count of agents cancelled. Idempotent — already-terminated
138
+ * agents return 0. */
139
+ async cancelAgent(agentId, opts) {
140
+ const resp = await (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}/cancel`, { reason: opts?.reason ?? "" }, opts);
141
+ return { cancelledCount: resp.cancelled_count };
142
+ }
143
+ /** List a user's recent agent runs, optionally filtered by status.
144
+ *
145
+ * v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
146
+ * whose `parent_agent_id` matches. The server still returns the
147
+ * full set (server-side `?parent_agent_id=` filter is a future
148
+ * request); the adapter trims before returning. Useful for the
149
+ * n8n trigger pattern "show me all sub-runs spawned by parent X." */
150
+ async listUserAgents(userId, opts) {
151
+ const q = opts?.status ? `?status=${encodeURIComponent(opts.status)}` : "";
152
+ const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/users/${encodeURIComponent(userId)}/agents${q}`, opts);
153
+ const all = resp.agents ?? [];
154
+ if (opts?.parentAgentId !== undefined && opts.parentAgentId !== "") {
155
+ return all.filter((a) => a.parent_agent_id === opts.parentAgentId);
156
+ }
157
+ return all;
158
+ }
159
+ /** Read the full event log for a session. Each entry has seq,
160
+ * run_id, ts_ns, type, event (the providers.Event payload). */
161
+ async getTranscript(sessionId, opts) {
162
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/sessions/${encodeURIComponent(sessionId)}/transcript`, opts);
163
+ }
164
+ /** Liveness probe. Unauthenticated. Returns build info + uptime.
165
+ * Hits /healthz, not /v1/. */
166
+ async health(opts) {
167
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/healthz", opts);
168
+ }
169
+ /** Admin: list known users with running-count summary. Drives the
170
+ * Web UI's user picker; operators with bearer auth can call too. */
171
+ async listUsers(opts) {
172
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_users", opts);
173
+ }
174
+ // ---- v0.8.17/8.18 Pause / Resume / State ----
175
+ /** Quiesce the runtime. Idempotent tools cancel immediately;
176
+ * non-idempotent + external tools get a grace window then
177
+ * force-cancel. Raises AlreadyPausingError on 409,
178
+ * PauseNotConfiguredError on 503. */
179
+ async pauseRuntime(opts) {
180
+ const body = opts?.timeoutMs && opts.timeoutMs > 0
181
+ ? { timeout_ms: opts.timeoutMs }
182
+ : undefined;
183
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_pause", body, opts);
184
+ }
185
+ /** Release the runtime quiesce. Raises NotPausedError on 409. */
186
+ async resumeRuntime(opts) {
187
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_resume", undefined, opts);
188
+ }
189
+ /** Current runtime state. Cheap query — atomic state + a
190
+ * bounded snapshots count. */
191
+ async getRuntimeState(opts) {
192
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_state", opts);
193
+ }
194
+ // ---- Snapshot lifecycle ----
195
+ /** Capture running-state into a per-section-semver JSON envelope.
196
+ * Raises SnapshotTooLargeError on 413 when the envelope exceeds
197
+ * LOOMCYCLE_SNAPSHOT_MAX_BYTES (default 512 MiB). */
198
+ async createSnapshot(opts) {
199
+ const body = {};
200
+ if (opts?.label)
201
+ body.label = opts.label;
202
+ if (opts?.includeHistory)
203
+ body.include_history = true;
204
+ if (opts?.includeHistorySince)
205
+ body.include_history_since = opts.includeHistorySince;
206
+ if (opts?.maxBytes && opts.maxBytes > 0)
207
+ body.max_bytes = opts.maxBytes;
208
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_snapshots", body, opts);
209
+ }
210
+ /** List captured snapshots (most-recent first). Capped at 200
211
+ * server-side; the limit param defaults to 200 too. */
212
+ async listSnapshots(opts) {
213
+ const params = new URLSearchParams();
214
+ if (opts?.limit && opts.limit > 0)
215
+ params.set("limit", String(opts.limit));
216
+ if (opts?.labelContains)
217
+ params.set("label_contains", opts.labelContains);
218
+ const qs = params.toString();
219
+ const path = qs ? `/v1/_snapshots?${qs}` : "/v1/_snapshots";
220
+ const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, opts);
221
+ return resp.entries ?? [];
222
+ }
223
+ /** Fetch the full snapshot envelope including JSON content.
224
+ * Distinct from exportSnapshot (which is operator-facing
225
+ * "where did this land on the host" semantics with a download
226
+ * URL). Raises SnapshotNotFoundError on 404. */
227
+ async getSnapshot(snapshotId, opts) {
228
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_snapshots/${encodeURIComponent(snapshotId)}`, opts);
229
+ }
230
+ /** Returns the URL of the snapshot's canonical envelope —
231
+ * synchronous and side-effect-free; does NOT issue an HTTP
232
+ * request. The endpoint is bearer-authed like every other
233
+ * `/v1/_snapshots/*` route, so callers must attach the same
234
+ * `Authorization: Bearer <token>` header when fetching this
235
+ * URL (e.g. `curl -H "Authorization: Bearer $TOKEN" ...`).
236
+ * There is no token query-param fallback. */
237
+ exportSnapshotURL(snapshotId) {
238
+ return `${this.ctx.baseUrl}/v1/_snapshots/${encodeURIComponent(snapshotId)}/export`;
239
+ }
240
+ /** Restore from a same-instance snapshot id OR an inline
241
+ * envelope JSON. Idempotent: ON CONFLICT DO NOTHING per row;
242
+ * the returned counters reflect rows actually written.
243
+ * Raises SnapshotVersionError on 422 when a section's
244
+ * declared version is newer than the reader supports. */
245
+ async restoreSnapshot(opts) {
246
+ if (!opts.snapshotId && opts.json === undefined) {
247
+ // Client-side validation — match Python adapter's
248
+ // InvalidArgumentError pattern but the typed-error layer
249
+ // lives in errors.ts; for a thrown plain error here the
250
+ // method's catchers just see the message.
251
+ throw new Error("restoreSnapshot: pass snapshotId or json (one is required)");
252
+ }
253
+ if (opts.snapshotId && opts.json !== undefined) {
254
+ throw new Error("restoreSnapshot: pass only one of snapshotId or json");
255
+ }
256
+ // When json is supplied the id path-segment is ignored
257
+ // server-side; we use a placeholder "inline" segment to keep
258
+ // the URL well-formed.
259
+ const id = opts.snapshotId ?? "inline";
260
+ const body = {};
261
+ if (opts.includeHistory)
262
+ body.include_history = true;
263
+ if (opts.json !== undefined)
264
+ body.json = opts.json;
265
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/_snapshots/${encodeURIComponent(id)}/restore`, body, opts);
266
+ }
267
+ /** Delete a snapshot. Idempotent — succeeds whether or not the
268
+ * row existed (server returns 204 in both cases). */
269
+ async deleteSnapshot(snapshotId, opts) {
270
+ await (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/_snapshots/${encodeURIComponent(snapshotId)}`, opts);
271
+ }
272
+ // ---- Memory admin ----
273
+ /** List the kinds of memory scopes the server knows about
274
+ * (agent, user — or whatever the operator yaml declares). */
275
+ async listMemoryScopes(opts) {
276
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_memory/scopes", opts);
277
+ }
278
+ /** List the scope_ids that have at least one memory row under
279
+ * a given scope. */
280
+ async listMemoryScopeIDs(scope, opts) {
281
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_memory/scopes/${encodeURIComponent(scope)}`, opts);
282
+ }
283
+ /** List memory entries under a (scope, scope_id) tuple.
284
+ * Optional prefix narrows by key prefix. */
285
+ async listMemoryEntries(scope, scopeID, opts) {
286
+ const params = new URLSearchParams();
287
+ if (opts?.prefix)
288
+ params.set("prefix", opts.prefix);
289
+ // Guard against `limit: 0` (falsy but valid-looking) and negatives —
290
+ // both would either send `limit=0` (server treats as default but the
291
+ // semantic is unclear) or `limit=-N` (server rejects). Only send the
292
+ // param when the caller passed a meaningful positive number.
293
+ if (opts?.limit && opts.limit > 0)
294
+ params.set("limit", String(opts.limit));
295
+ const qs = params.toString();
296
+ const path = `/v1/_memory/scopes/${encodeURIComponent(scope)}/${encodeURIComponent(scopeID)}/keys${qs ? "?" + qs : ""}`;
297
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, opts);
298
+ }
299
+ /** Read a single memory entry by (scope, scope_id, key). */
300
+ async getMemoryEntry(scope, scopeID, key, opts) {
301
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_memory/scopes/${encodeURIComponent(scope)}/${encodeURIComponent(scopeID)}/keys/${encodeURIComponent(key)}`, opts);
302
+ }
303
+ // ---- Interruption ----
304
+ /** List interrupts addressable to a user_id. Default filter is
305
+ * status=pending. */
306
+ async listUserInterrupts(userId, opts) {
307
+ const status = opts?.status ?? "pending";
308
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/users/${encodeURIComponent(userId)}/interrupts?status=${encodeURIComponent(status)}`, opts);
309
+ }
310
+ /** List interrupts emitted by a specific run. */
311
+ async listRunInterrupts(runId, opts) {
312
+ const status = opts?.status ?? "pending";
313
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/interrupts?status=${encodeURIComponent(status)}`, opts);
314
+ }
315
+ /** Resolve a pending Interruption.ask from outside the agent
316
+ * loop. Lets a TS-side dashboard or service act as the human
317
+ * answerer when operator yaml configures the consumer-MCP
318
+ * backend. */
319
+ async resolveInterrupt(runId, interruptId, opts) {
320
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(interruptId)}/resolve`, {
321
+ kind: opts.kind ?? "question",
322
+ answer: opts.answer,
323
+ resolved_by: opts.resolvedBy ?? "client",
324
+ }, opts);
325
+ }
326
+ // ---- Hook management (hooks-connector series, PR C) ----
327
+ /** Register a pre- or post-tool webhook. The callback_url must be
328
+ * an http:// or https:// endpoint the CONSUMER runs — loomcycle
329
+ * POSTs PreHookCall / PostHookCall payloads to it. This method
330
+ * manages registration only; the receiver is the consumer's own
331
+ * HTTP framework (Express, Next.js, etc.).
332
+ *
333
+ * Re-registering the same (owner, name) replaces the prior entry
334
+ * with a fresh id (idempotent app-restart contract).
335
+ *
336
+ * Raises InvalidArgumentError on 400 (bad URL / phase / missing
337
+ * required fields). */
338
+ async registerHook(opts) {
339
+ const body = {
340
+ owner: opts.owner,
341
+ name: opts.name,
342
+ phase: opts.phase,
343
+ callback_url: opts.callbackUrl,
344
+ };
345
+ if (opts.agents !== undefined)
346
+ body.agents = opts.agents;
347
+ if (opts.tools !== undefined)
348
+ body.tools = opts.tools;
349
+ if (opts.failMode !== undefined)
350
+ body.fail_mode = opts.failMode;
351
+ if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
352
+ body.timeout_ms = opts.timeoutMs;
353
+ }
354
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/hooks", body, opts);
355
+ }
356
+ /** List every currently-registered hook. Returns the array
357
+ * unwrapped (the wire envelope is `{hooks: [...]}` — we strip
358
+ * the envelope to match listUserAgents). In-memory only — empty
359
+ * after a loomcycle restart. */
360
+ async listHooks(opts) {
361
+ const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/hooks", opts);
362
+ return resp.hooks ?? [];
363
+ }
364
+ /** Delete a registered hook by id. Raises HookNotFoundError on
365
+ * 404. Returns void on success (the HTTP 200 body `{deleted: id}`
366
+ * is dropped — callers already know the id they passed). */
367
+ async deleteHook(id, opts) {
368
+ await (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/hooks/${encodeURIComponent(id)}`, opts);
369
+ }
370
+ // ---- v0.8.22 substrate admin (AgentDef + SkillDef) ----
371
+ /** Invoke the AgentDef substrate tool over HTTP. Mirrors the
372
+ * MCP `agentdef` meta-tool and the in-band agent tool_use of
373
+ * the same name — different transport, identical semantics.
374
+ *
375
+ * The `input.op` field discriminates create / fork / get /
376
+ * list / promote / retire. The remaining fields are op-specific;
377
+ * see the in-process tool's documentation.
378
+ *
379
+ * Raises {@link SubstrateToolRefusedError} when the tool itself
380
+ * refuses the call (scope deny, empty body, allowed-tools
381
+ * widening, etc.) — distinct from transport failures so callers
382
+ * can branch on the typed error class.
383
+ *
384
+ * Raises {@link InvalidArgumentError} on 400 (malformed JSON
385
+ * body); {@link AuthError} on 401; {@link UnavailableError} on
386
+ * 503 (store / connector unwired). */
387
+ async agentDef(input, opts) {
388
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_agentdef", input, opts);
389
+ }
390
+ /** Invoke the SkillDef substrate tool over HTTP. Mirror of
391
+ * {@link LoomcycleClient.agentDef} for skills (v0.8.22+). Same
392
+ * input grammar, same error class on refusal. See the
393
+ * agentDef() doc for the full shape and error contract. */
394
+ async skillDef(input, opts) {
395
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_skilldef", input, opts);
396
+ }
397
+ /** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
398
+ * Dynamic MCP server registration — register an HTTP /
399
+ * Streamable-HTTP MCP server at runtime so its tools become
400
+ * callable from any agent's `allowed_tools` list without a yaml
401
+ * edit + restart.
402
+ *
403
+ * Operator-admin-only: this endpoint requires the bearer token.
404
+ *
405
+ * Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
406
+ * | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
407
+ * shape varies — narrow with {@link MCPServerDefRowResponse} for
408
+ * create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
409
+ * verify responses.
410
+ *
411
+ * Hard constraints (substrate refuses these):
412
+ * - Transport must be `http` or `streamable-http` (stdio stays
413
+ * yaml-only — dynamic registration doesn't allow process spawn).
414
+ * - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
415
+ * defence at the registration boundary).
416
+ * - Name colliding with a static cfg.MCPServers entry is refused
417
+ * (yaml is ground truth; use a different name).
418
+ *
419
+ * Raises {@link SubstrateToolRefusedError} on tool-level refusals
420
+ * (transport/host/yaml-name); {@link InvalidArgumentError} on 400
421
+ * (malformed JSON); {@link AuthError} on 401. */
422
+ async mcpServerDef(input, opts) {
423
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_mcpserverdef", input, opts);
424
+ }
425
+ // ---- Internal helpers ----
426
+ /** Shared SSE POST → stream-of-AgentEvent path. Used by
427
+ * runStreaming + continueSession.
428
+ *
429
+ * When `debug` is true, the iterator yields a synthetic
430
+ * `{ type: "_meta", meta_subtype: "stream_open" }` before any real
431
+ * events AND a `{ type: "_meta", meta_subtype: "stream_close",
432
+ * meta_reason }` on EOF / abort / error. The default is silent
433
+ * (matches pre-v0.9.x behaviour). */
434
+ async *streamSSE(path, body, signal, debug) {
435
+ const headers = {
436
+ "Content-Type": "application/json",
437
+ // Accept BOTH text/event-stream (the success path) AND
438
+ // application/json (the error path — non-2xx responses come
439
+ // back as JSON so raiseFromResponse can extract typed errors).
440
+ // Per the Streamable HTTP spec; strict reverse proxies in
441
+ // front of the sidecar 406 otherwise. Same rationale as the
442
+ // v0.8.x MCP HTTP-transport hardening note in CLAUDE.md.
443
+ Accept: "text/event-stream, application/json",
444
+ };
445
+ if (this.ctx.authToken)
446
+ headers.Authorization = `Bearer ${this.ctx.authToken}`;
447
+ const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
448
+ method: "POST",
449
+ headers,
450
+ body: JSON.stringify(body),
451
+ signal,
452
+ });
453
+ if (!resp.ok) {
454
+ await (0, fetch_helpers_js_1.raiseFromResponse)(resp);
455
+ }
456
+ if (!resp.body) {
457
+ throw new Error("loomcycle: response has no body");
458
+ }
459
+ if (!debug) {
460
+ // Silent default — pre-v0.9.x shape.
461
+ yield* (0, stream_js_1.parseSSE)(resp.body.getReader());
462
+ return;
463
+ }
464
+ // Debug shape: synthetic open + close around the real stream.
465
+ // The open frame carries no meta_reason — the frame itself IS the
466
+ // signal. The close frame's meta_reason distinguishes normal EOF
467
+ // from caller-side abort or a typed-error throw mid-stream.
468
+ //
469
+ // Close is emitted on both paths via try/catch/throw: success path
470
+ // emits AFTER the try block; error path emits INSIDE the catch
471
+ // before re-throwing. NOT a try/finally — the duplication is
472
+ // intentional so the close-then-throw ordering is explicit and
473
+ // a refactor adding `finally` doesn't accidentally double-emit.
474
+ yield { type: "_meta", meta_subtype: "stream_open" };
475
+ let closeReason = "eof";
476
+ try {
477
+ yield* (0, stream_js_1.parseSSE)(resp.body.getReader());
478
+ }
479
+ catch (e) {
480
+ // Capture the error type for the close frame, then re-throw so
481
+ // typed-error handling at the consumer site still works.
482
+ closeReason =
483
+ e && typeof e === "object" && "name" in e
484
+ ? String(e.name)
485
+ : "error";
486
+ yield {
487
+ type: "_meta",
488
+ meta_subtype: "stream_close",
489
+ meta_reason: closeReason,
490
+ };
491
+ throw e;
492
+ }
493
+ yield {
494
+ type: "_meta",
495
+ meta_subtype: "stream_close",
496
+ meta_reason: closeReason,
497
+ };
498
+ }
499
+ // ---- v0.9.x n8n RFC Phase 0 ----
500
+ /** List every operator-declared channel with aggregate stats
501
+ * (message_count, oldest_visible_at, newest_visible_at).
502
+ * Channels with no published messages still appear with
503
+ * message_count=0. Orphaned message rows for un-declared channels
504
+ * also appear (forensic visibility). Mirrors GET /v1/_channels. */
505
+ async listChannels(opts) {
506
+ return await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_channels", opts);
507
+ }
508
+ // ---- v0.9.x Channel CRUD ----
509
+ //
510
+ // Four bearer-authed ops mirroring the in-band Channel tool's
511
+ // publish/subscribe/peek/ack. Two URL families behind the
512
+ // `scope` field:
513
+ // - scope: "global" → POST /v1/_channels/{name}/{op} (admin)
514
+ // - scope: "user" → POST /v1/users/{userId}/channels/{name}/{op}
515
+ //
516
+ // The same operator bearer token guards both surfaces; the per-user
517
+ // URL embeds the user_id in the path so a caller can't forge a
518
+ // different user_id by lying in the body.
519
+ //
520
+ // Subscribe is a SINGLE-ROUND-TRIP long-poll, not an open stream.
521
+ // For continuous delivery, call `subscribeChannel` in a loop (the
522
+ // n8n trigger node's pattern). Auto-commits the cursor on non-empty
523
+ // batches (at-most-once shape) — use `peekChannel` + explicit
524
+ // `ackChannel` for at-least-once semantics.
525
+ /** Publish a JSON payload to an operator-declared channel. Mirrors
526
+ * the in-band Channel tool's publish op semantics — including
527
+ * deferred delivery via `deliverAt` (RFC3339Nano).
528
+ *
529
+ * Errors:
530
+ * - {@link NotFoundError} (404) when the channel isn't in operator
531
+ * yaml. The wire `code` is `channel_not_declared`.
532
+ * - {@link InvalidArgumentError} (400) on invalid scope / payload.
533
+ * - {@link AuthError} (401) on bearer mismatch. */
534
+ async publishChannel(channel, opts) {
535
+ const path = channelOpPath(channel, opts.scope, opts.userId, "publish");
536
+ const body = { payload: opts.payload };
537
+ if (opts.deliverAt)
538
+ body.deliver_at = opts.deliverAt;
539
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, body, {
540
+ signal: opts.signal,
541
+ });
542
+ }
543
+ /** Read the next batch of messages from a channel. Single-round-
544
+ * trip long-poll: returns immediately if messages are present,
545
+ * otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
546
+ * cursor on a non-empty batch.
547
+ *
548
+ * For at-least-once delivery (crash safety between "loomcycle
549
+ * returned the batch" and "consumer finished processing"), use
550
+ * {@link LoomcycleClient.peekChannel} + an explicit
551
+ * {@link LoomcycleClient.ackChannel} after durable processing. */
552
+ async subscribeChannel(channel, opts) {
553
+ const path = channelOpPath(channel, opts.scope, opts.userId, "subscribe");
554
+ const body = {};
555
+ if (opts.fromCursor !== undefined)
556
+ body.from_cursor = opts.fromCursor;
557
+ if (opts.maxMessages !== undefined)
558
+ body.max_messages = opts.maxMessages;
559
+ if (opts.waitMs !== undefined)
560
+ body.wait_ms = opts.waitMs;
561
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, body, {
562
+ signal: opts.signal,
563
+ });
564
+ }
565
+ /** Non-destructive read — never advances the committed cursor.
566
+ * Use for at-least-once consumption patterns: peek, process the
567
+ * batch durably, then `ackChannel` to advance. Multiple consumers
568
+ * can peek the same channel without disturbing each other. */
569
+ async peekChannel(channel, opts) {
570
+ let path = channelOpPath(channel, opts.scope, opts.userId, "peek");
571
+ const params = [];
572
+ if (opts.fromCursor)
573
+ params.push(`from_cursor=${encodeURIComponent(opts.fromCursor)}`);
574
+ if (opts.maxMessages)
575
+ params.push(`max_messages=${opts.maxMessages}`);
576
+ if (params.length > 0)
577
+ path += `?${params.join("&")}`;
578
+ return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, { signal: opts.signal });
579
+ }
580
+ /** Advance the committed cursor for a (channel, scope, scope_id)
581
+ * tuple. Cursor must be monotonically forward — older cursors
582
+ * raise a {@link ConflictError} (HTTP 409, code
583
+ * `channel_cursor_regression`). */
584
+ async ackChannel(channel, opts) {
585
+ const path = channelOpPath(channel, opts.scope, opts.userId, "ack");
586
+ return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, { cursor: opts.cursor }, { signal: opts.signal });
587
+ }
588
+ /** Subscribe to run state transitions for one user_id via SSE.
589
+ * Yields one `{ kind: "open", ... }` item first (confirms the
590
+ * connection is live), then one `{ kind: "event", ... }` per
591
+ * matching state transition until the stream closes.
592
+ *
593
+ * The stream stays open for at most 30 minutes (server-enforced).
594
+ * Callers running indefinitely should reconnect on close.
595
+ *
596
+ * Errors during the stream throw — they do NOT surface as items.
597
+ * Pass an AbortSignal to terminate cleanly from the consumer side.
598
+ *
599
+ * v0.9.x options:
600
+ * - `parentAgentId` — client-side filter: only `kind: "event"`
601
+ * items whose payload's `parent_agent_id` matches are yielded.
602
+ * The server still streams every matching event; the adapter
603
+ * filters before yielding. Empty/omitted = no filter.
604
+ * - `debug` — when true, an additional `{ kind: "close", payload:
605
+ * { reason } }` item is yielded when the stream ends (EOF,
606
+ * abort, or pre-yield error). Useful for n8n nodes that surface
607
+ * "stream re-opened / closed" log entries without inferring
608
+ * from timing. Default false. */
609
+ async *streamUserRunStates(userId, opts) {
610
+ const params = new URLSearchParams();
611
+ if (opts?.statuses && opts.statuses.length > 0) {
612
+ params.set("status", opts.statuses.join(","));
613
+ }
614
+ if (opts?.agent) {
615
+ params.set("agent", opts.agent);
616
+ }
617
+ const qs = params.toString();
618
+ const path = `/v1/users/${encodeURIComponent(userId)}/agents/stream` +
619
+ (qs ? `?${qs}` : "");
620
+ const headers = {
621
+ Accept: "text/event-stream",
622
+ };
623
+ if (this.ctx.authToken) {
624
+ headers.Authorization = `Bearer ${this.ctx.authToken}`;
625
+ }
626
+ const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
627
+ method: "GET",
628
+ headers,
629
+ signal: opts?.signal,
630
+ });
631
+ if (!resp.ok) {
632
+ await (0, fetch_helpers_js_1.raiseFromResponse)(resp);
633
+ }
634
+ if (!resp.body) {
635
+ throw new Error("loomcycle: streamUserRunStates response has no body");
636
+ }
637
+ const parentFilter = opts?.parentAgentId ?? "";
638
+ const debug = opts?.debug === true;
639
+ let closeReason = "eof";
640
+ try {
641
+ for await (const item of parseRunStateSSE(resp.body.getReader())) {
642
+ // Client-side parent_agent_id filter. Pre-v1 the server has no
643
+ // ?parent_agent_id= query param; n8n-style consumers that need
644
+ // a narrow view get a smaller iterator at the cost of
645
+ // unchanged server load. See StreamUserRunStatesOptions for
646
+ // the trade-off note.
647
+ if (parentFilter !== "" &&
648
+ item.kind === "event" &&
649
+ item.payload.parent_agent_id !== parentFilter) {
650
+ continue;
651
+ }
652
+ yield item;
653
+ }
654
+ }
655
+ catch (e) {
656
+ closeReason =
657
+ e && typeof e === "object" && "name" in e
658
+ ? String(e.name)
659
+ : "error";
660
+ if (debug) {
661
+ yield { kind: "close", payload: { reason: closeReason } };
662
+ }
663
+ throw e;
664
+ }
665
+ if (debug) {
666
+ yield { kind: "close", payload: { reason: closeReason } };
667
+ }
668
+ }
669
+ }
670
+ exports.LoomcycleClient = LoomcycleClient;
671
+ /** Lightweight SSE parser tailored to the run-state stream. Each
672
+ * frame's event name distinguishes the two kinds; data is JSON.
673
+ * Comment lines (": keepalive") are ignored. */
674
+ async function* parseRunStateSSE(reader) {
675
+ const decoder = new TextDecoder("utf-8");
676
+ let buf = "";
677
+ let event = "";
678
+ let data = "";
679
+ while (true) {
680
+ const { value, done } = await reader.read();
681
+ if (done)
682
+ break;
683
+ buf += decoder.decode(value, { stream: true });
684
+ let idx;
685
+ while ((idx = buf.indexOf("\n")) !== -1) {
686
+ const line = buf.slice(0, idx).replace(/\r$/, "");
687
+ buf = buf.slice(idx + 1);
688
+ if (line === "") {
689
+ if (event && data) {
690
+ try {
691
+ const parsed = JSON.parse(data);
692
+ if (event === "stream_open") {
693
+ yield {
694
+ kind: "open",
695
+ payload: parsed,
696
+ };
697
+ }
698
+ else if (event === "run_state") {
699
+ yield {
700
+ kind: "event",
701
+ payload: parsed,
702
+ };
703
+ }
704
+ }
705
+ catch {
706
+ // Drop malformed frame silently — same posture as parseSSE.
707
+ }
708
+ }
709
+ event = "";
710
+ data = "";
711
+ continue;
712
+ }
713
+ if (line.startsWith("event:"))
714
+ event = line.slice("event:".length).trim();
715
+ else if (line.startsWith("data:"))
716
+ data = line.slice("data:".length).trim();
717
+ }
718
+ }
719
+ }
720
+ // channelOpPath builds the v0.9.x Channel CRUD URL. Two families:
721
+ // - scope === "global" → /v1/_channels/{channel}/{op}
722
+ // - scope === "user" → /v1/users/{userId}/channels/{channel}/{op}
723
+ // Channel name is URL-encoded so names containing slashes
724
+ // ("findings/alpha", "_system/foo") survive transport.
725
+ function channelOpPath(channel, scope, userId, op) {
726
+ const enc = encodeURIComponent(channel);
727
+ if (scope === "user") {
728
+ if (!userId) {
729
+ throw new Error(`loomcycle: scope="user" requires opts.userId for the channel ${op} call`);
730
+ }
731
+ return `/v1/users/${encodeURIComponent(userId)}/channels/${enc}/${op}`;
732
+ }
733
+ return `/v1/_channels/${enc}/${op}`;
734
+ }