@loomcycle/client 0.9.1 → 0.9.2

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/README.md CHANGED
@@ -6,10 +6,19 @@ TypeScript client for the [loomcycle](https://github.com/denn-gubsky/loomcycle)
6
6
 
7
7
  ## Status
8
8
 
9
- **v0.8.18** — full Python-adapter parity + hook management. 27 methods covering run streaming, agent metadata, transcript, pause/resume/state, snapshot lifecycle, memory admin, interruption resolve, hook registration, and health.
9
+ **v0.11.0** — 31 methods covering run streaming, agent metadata, transcript, pause/resume/state, snapshot lifecycle, memory admin, interruption resolve, hook registration, **v0.8.22 substrate admin (agentDef + skillDef)**, **v0.9.x n8n Phase 0 (listChannels + streamUserRunStates)**, **v0.9.x content_sha256** (the bundle-vs-deployed comparison workflow for Docker-bundled operators), and health.
10
10
 
11
11
  > Migrating from raw `fetch` against `/v1/*`? See **[docs/MIGRATING-FROM-HTTP.md](./docs/MIGRATING-FROM-HTTP.md)** for a side-by-side walkthrough.
12
12
 
13
+ ### What's new since v0.8.18
14
+
15
+ - **`agentDef` / `skillDef`** (v0.8.22) — runtime fork / promote / retire / get / list / `verify` on the substrate. Lets a containerised app push agent + skill definitions to a remote loomcycle at startup without restarting it.
16
+ - **`listChannels`** (v0.9.x) — list operator-declared channels with aggregate stats (message_count, oldest/newest visible_at). The substrate companion to the existing Channel tool; useful for credential pickers + dashboards.
17
+ - **`streamUserRunStates`** (v0.9.x) — SSE stream of run state transitions scoped to one `user_id`. Yields `{ kind: "open" | "event", payload }` items until the connection closes (30-min server cap). The primary substrate hook for orchestration UIs that need to react when an agent run completes / fails / cancels.
18
+ - **Content signatures** (v0.9.x) — every `agent_defs` / `skill_defs` row now carries a deterministic `content_sha256`. Combined with the `verify` op and the `loomcycle hash agent|skill` CLI subcommand, this gives Docker-bundled operators a one-call answer to *"is what I have in my image identical to what's deployed?"* — see [Content signatures](#content-signatures-v09x) below for the end-to-end workflow.
19
+ - **Transcript first-cycle types** (v0.9.1) — `UserInputPayload` + `SystemPromptPayload` typed interfaces for the two new transcript events that surface "what the agent actually received" (the resolved system prompt + the caller's segments) as the first frames of every run.
20
+ - **n8n polish — `debug` toggle + `parentAgentId` filter** (v0.13.0) — opt-in synthetic `stream_open` / `stream_close` frames on `runStreaming` / `continueSession` / `streamUserRunStates` plus a client-side `parentAgentId` filter on `listUserAgents` + `streamUserRunStates`. Default behaviour is unchanged for existing callers; both knobs are off until set. See [Patterns](#patterns) for when to reach for them.
21
+
13
22
  ## Install
14
23
 
15
24
  ```bash
@@ -207,6 +216,165 @@ export async function POST(req: Request) {
207
216
  - `fail_mode: "open"` (default) is right for telemetry hooks where a down receiver shouldn't break tool dispatch. `"closed"` is right for security hooks where a down receiver should fail the tool call (don't let bypassed payloads through).
208
217
  - `allow_hosts` in `PreHookResult` is a **trust-sensitive surface** — it widens the agent's outbound network policy for one tool call. Server enforces an operator-yaml allowlist (`hooks.permit_host_widen.owners`); your owner has to be on that list for `allow_hosts` to take effect. See the SECURITY note in `internal/hooks/types.go` before using.
209
218
 
219
+ ### Substrate admin: AgentDef + SkillDef (v0.8.22)
220
+
221
+ Two op-discriminated methods that mirror the in-process `AgentDef` / `SkillDef` built-in tools over HTTP. The same `op` values an agent's tool_use would invoke are reachable directly from your app code — useful for runtime fork / promote / retire / list, and for the `verify` op covered in [Content signatures](#content-signatures-v09x).
222
+
223
+ | Method | Returns | Notes |
224
+ |---|---|---|
225
+ | `agentDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_agentdef`. |
226
+ | `skillDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_skilldef`. |
227
+
228
+ The response type is intentionally `unknown` because the shape varies per op (`create`/`fork` return a row envelope; `list` returns `{name, versions: [...]}`; `verify` returns `AgentDefVerifyResult` / `SkillDefVerifyResult`). Cast / narrow as needed:
229
+
230
+ ```ts
231
+ import type { AgentDefRowResponse } from "@loomcycle/client";
232
+
233
+ const forked = (await client.agentDef({
234
+ op: "fork",
235
+ name: "researcher",
236
+ overlay: { system_prompt: "be very thorough", max_iterations: 32 },
237
+ promote: true,
238
+ })) as AgentDefRowResponse;
239
+
240
+ console.log(`forked def_id=${forked.def_id} hash=${forked.content_sha256}`);
241
+ ```
242
+
243
+ Operations on AgentDef: `create` / `fork` / `get` / `list` / `promote` / `retire` / **`verify`** (v0.9.x). SkillDef has the same set minus `retire`'s edge cases. See `internal/tools/builtin/agentdef.go` for the canonical input schema; each op enforces the agent's `agent_def_scopes` / `skill_def_scopes` capability gate from the operator yaml.
244
+
245
+ Refusals throw `SubstrateToolRefusedError` (a scope deny / empty body / allowed-tools widening); transport failures throw the usual typed errors (`AuthError`, `UnavailableError`, etc.).
246
+
247
+ ### Channels + run-state stream (v0.9.x n8n Phase 0)
248
+
249
+ Two substrate-side surfaces added in the n8n integration's Phase 0 wire-API work. Useful for any orchestrator (not just n8n) that needs to see channel state or subscribe to run-state transitions.
250
+
251
+ | Method | Returns | Notes |
252
+ |---|---|---|
253
+ | `listChannels()` | `Promise<ListChannelsResponse>` | Operator-declared channels + aggregate stats (`message_count`, `oldest_visible_at`, `newest_visible_at`). Mirrors `GET /v1/_channels`. |
254
+ | `streamUserRunStates(userId, opts?)` | `AsyncIterable<RunStateStreamItem>` | SSE stream of run state transitions for one user. Yields one `{ kind: "open", ... }` frame then one `{ kind: "event", payload: RunStateEvent }` per matching transition until close. |
255
+
256
+ **Streaming run-state events** — for orchestration UIs that want to react when an agent run completes / fails / cancels:
257
+
258
+ ```ts
259
+ import type { RunStateEvent } from "@loomcycle/client";
260
+
261
+ const ac = new AbortController();
262
+ const stream = client.streamUserRunStates(userId, {
263
+ statuses: ["completed", "failed", "cancelled"], // optional filter
264
+ agent: "researcher", // optional filter
265
+ signal: ac.signal,
266
+ });
267
+
268
+ for await (const item of stream) {
269
+ if (item.kind === "open") {
270
+ console.log(`stream open for user=${item.payload.user_id}`);
271
+ continue;
272
+ }
273
+ const evt: RunStateEvent = item.payload;
274
+ console.log(`${evt.agent}/${evt.run_id} -> ${evt.status} (stop_reason=${evt.stop_reason ?? "-"})`);
275
+ // ... persist to DB, push to UI websocket, fire webhook, etc.
276
+ }
277
+ ```
278
+
279
+ The stream stays open for up to 30 minutes (server-enforced); reconnect on close for long-running orchestrators. Filters apply server-side; an empty filter delivers all transitions.
280
+
281
+ ### Content signatures (v0.9.x)
282
+
283
+ **The bundle-vs-deployed comparison feature.** Every persisted `agent_defs` and `skill_defs` row carries a deterministic SHA-256 of its content-bearing fields (`content_sha256`). Combined with the CLI helper `loomcycle hash agent|skill <path>`, this lets Docker-bundled operators answer *"is what I have in my image identical to what's deployed?"* with one cheap call instead of fetching the full Definition JSONB and diffing it field by field.
284
+
285
+ **The workflow** — three steps, fully Dockerfile-friendly:
286
+
287
+ 1. **At image-build time** (in your Dockerfile or CI): run the CLI against each bundled MD to capture the expected hash.
288
+
289
+ ```dockerfile
290
+ # Dockerfile
291
+ COPY agents/ /bundle/agents/
292
+ COPY skills/ /bundle/skills/
293
+ RUN /usr/local/bin/loomcycle hash agent /bundle/agents/researcher.md > /bundle/agents/researcher.sha256
294
+ RUN /usr/local/bin/loomcycle hash skill /bundle/skills/summariser > /bundle/skills/summariser.sha256
295
+ ```
296
+
297
+ 2. **At container startup**: ask the deployed loomcycle whether each agent is in sync. Use `agentDef({op:"verify"})` / `skillDef({op:"verify"})` and narrow the response to `AgentDefVerifyResult` / `SkillDefVerifyResult`:
298
+
299
+ ```ts
300
+ import { readFile } from "node:fs/promises";
301
+ import type { AgentDefVerifyResult } from "@loomcycle/client";
302
+
303
+ const localHash = (await readFile("/bundle/agents/researcher.sha256", "utf-8")).trim();
304
+ const verify = (await client.agentDef({
305
+ op: "verify",
306
+ name: "researcher",
307
+ content_sha256: localHash,
308
+ })) as AgentDefVerifyResult;
309
+
310
+ if (verify.matches) {
311
+ console.log("researcher in sync");
312
+ } else if (!verify.deployed) {
313
+ console.log("researcher not deployed yet; pushing first version");
314
+ await pushAgent("/bundle/agents/researcher.md"); // your set-agent helper
315
+ } else {
316
+ console.log(`researcher drifted; deployed=${verify.current_sha256} local=${localHash}; pushing update`);
317
+ await pushAgent("/bundle/agents/researcher.md");
318
+ }
319
+ ```
320
+
321
+ 3. **Pushing on mismatch** is `agentDef({op:"set"|"fork", overlay: {...}})` with the same content the YAML expresses, parsed from your bundle.
322
+
323
+ | Method | Returns | Notes |
324
+ |---|---|---|
325
+ | `agentDef({op:"verify", name, content_sha256})` | `Promise<AgentDefVerifyResult>` | `{matches, current_sha256, current_def_id, version, name, deployed}`. |
326
+ | `skillDef({op:"verify", name, content_sha256})` | `Promise<SkillDefVerifyResult>` | Same shape. |
327
+
328
+ **Key invariants:**
329
+
330
+ - `matches: true` only when both hashes are non-empty AND equal. An empty caller hash NEVER matches (no false-positive when the deployed row's hash is also empty due to a not-yet-completed backfill).
331
+ - `deployed: false` ⇒ `matches: false`. Use this to distinguish "no active row" (first deploy) from "drift" (push update).
332
+ - The CLI hash and the substrate's hash are guaranteed identical for matching content — both compute through the same Go function in `internal/agents.Sign`.
333
+ - Agent hash covers `name + description + system_prompt + allowed_tools + skills + model + provider + tier + effort + max_tokens + max_iterations + providers + models + memory_scopes + memory_quota_bytes`. Explicitly excluded: `def_id`, `version`, `created_at`, `retired`, **plus** `channels` and `*_scopes` (operator-yaml-only ACL fields that don't round-trip through `set` / `fork`).
334
+ - Skill hash covers `name + description + body + allowed_tools`. Skill bodies are normalised before hashing (CRLF → LF; trailing whitespace stripped) so editor drift doesn't cause spurious mismatches.
335
+
336
+ See `help(topic="content-signatures")` from inside an agent run for the full operator narrative.
337
+
338
+ ### Transcript first-cycle types (v0.9.1)
339
+
340
+ Every run's persisted transcript now records two events that describe **what the agent actually received** before any model output:
341
+
342
+ - **`system_prompt`** — the resolved system prompt (AgentDef body + skill bodies, after overlay + merge), with provenance (`agent_def_id` + `skill_def_ids` map).
343
+ - **`user_input`** — the caller's `segments` from the original `POST /v1/runs`.
344
+
345
+ Surface them via `getTranscript(sessionId)` and narrow on `event.type`:
346
+
347
+ ```ts
348
+ import type {
349
+ SystemPromptPayload,
350
+ TranscriptEvent,
351
+ UserInputPayload,
352
+ } from "@loomcycle/client";
353
+
354
+ const { events } = await client.getTranscript(sessionId);
355
+
356
+ for (const ev of events as TranscriptEvent[]) {
357
+ if (ev.type === "system_prompt") {
358
+ const p = ev.payload as SystemPromptPayload;
359
+ console.log(`prompt (def_id=${p.agent_def_id ?? "-"}): ${p.system_prompt.slice(0, 80)}...`);
360
+ if (p.skill_def_ids) {
361
+ for (const [skill, defId] of Object.entries(p.skill_def_ids)) {
362
+ console.log(` skill ${skill} resolved to def_id=${defId}`);
363
+ }
364
+ }
365
+ } else if (ev.type === "user_input") {
366
+ const segs = ev.payload as UserInputPayload[];
367
+ console.log(`caller sent ${segs.length} segment(s):`);
368
+ for (const seg of segs) {
369
+ const firstText = seg.content.find((c) => c.type.endsWith("text"))?.text ?? "";
370
+ console.log(` [${seg.role}] ${firstText.slice(0, 80)}`);
371
+ }
372
+ }
373
+ }
374
+ ```
375
+
376
+ These events are part of the persisted transcript (not the live `runStreaming` event channel — they fire before the first model call, before the SSE stream consumer typically attaches). Existing transcript readers that don't know the new types see them as `event: unknown` with the typed body in `payload` and ignore them safely.
377
+
210
378
  ## Errors
211
379
 
212
380
  Non-2xx responses throw typed subclasses of `LoomcycleError`. The original HTTP status is on `e.status`; the truncated response body is on `e.bodyText` (≤1 KiB).
@@ -254,6 +422,80 @@ try {
254
422
  }
255
423
  ```
256
424
 
425
+ ## Patterns
426
+
427
+ A short field guide for the common consumer shapes — when to use which method, what each one costs, and how the v0.9.x polish hooks (`debug`, `parentAgentId`) fit in.
428
+
429
+ ### Sync vs async run consumption
430
+
431
+ `runStreaming` and `continueSession` are **sync**: the iterator stays alive for the FULL duration of the run. Use them when:
432
+
433
+ - You have a single agent run and want to render its output progressively (UI streaming, CLI tail-like display).
434
+ - The caller can hold a connection per active run without worker-thread starvation.
435
+
436
+ For async fire-and-forget patterns (the n8n trigger node's model), use `streamUserRunStates` instead:
437
+
438
+ ```ts
439
+ // Don't do this in an n8n worker — blocks the worker for the full run:
440
+ for await (const ev of client.runStreaming({ agent: "long-task", segments })) { ... }
441
+
442
+ // Do this instead — kick off the run, get back a tracking ID, and watch run-state transitions:
443
+ const seedRun = await runOnce(...); // your one-shot dispatch
444
+ for await (const item of client.streamUserRunStates(userId, {
445
+ statuses: ["completed", "failed", "cancelled"],
446
+ })) {
447
+ if (item.kind === "event" && item.payload.agent_id === seedRun.agentId) {
448
+ // fire downstream workflow, persist to DB, etc.
449
+ break;
450
+ }
451
+ }
452
+ ```
453
+
454
+ `streamUserRunStates` holds ONE connection per user regardless of how many concurrent runs that user has. Server-enforced 30-minute timeout; reconnect on close.
455
+
456
+ ### `debug: true` — synthetic open/close frames
457
+
458
+ All three streaming methods (`runStreaming`, `continueSession`, `streamUserRunStates`) accept `debug?: boolean`. Default off; behaviour is exactly the pre-v0.9.x shape.
459
+
460
+ When `debug: true`:
461
+ - `runStreaming` / `continueSession` brackets the real events with `{ type: "_meta", meta_subtype: "stream_open" | "stream_close", meta_reason }` frames. The leading-underscore type signals "client-synthesized; never on the wire." The `meta_reason` is `"eof"` on clean close or an error class name (e.g. `"AuthError"`) when the inner iterator threw mid-stream.
462
+ - `streamUserRunStates` yields an extra `{ kind: "close", payload: { reason } }` item on stream end (in addition to the existing `kind: "open" | "event"` frames).
463
+
464
+ ```ts
465
+ for await (const ev of client.runStreaming({ agent: "qa", segments, debug: true })) {
466
+ if (ev.type === "_meta") {
467
+ console.log(`[stream ${ev.meta_subtype}] reason=${ev.meta_reason}`);
468
+ continue;
469
+ }
470
+ // ... handle real events
471
+ }
472
+ ```
473
+
474
+ Use case: n8n trigger nodes that surface "stream re-opened / closed" log entries to the operator without inferring from event timing. Non-n8n consumers don't need to know the toggle exists.
475
+
476
+ ### `parentAgentId` — client-side narrowing
477
+
478
+ `listUserAgents(userId, { parentAgentId })` and `streamUserRunStates(userId, { parentAgentId })` apply a client-side filter on the run's `parent_agent_id`. The server still returns / streams the full set; the adapter trims before yielding.
479
+
480
+ ```ts
481
+ // All sub-runs spawned by a specific parent (one-shot snapshot)
482
+ const subRuns = await client.listUserAgents(userId, {
483
+ parentAgentId: "ag_parent_abc",
484
+ status: "running",
485
+ });
486
+
487
+ // Same shape, but as a live stream
488
+ for await (const item of client.streamUserRunStates(userId, {
489
+ parentAgentId: "ag_parent_abc",
490
+ statuses: ["completed", "failed"],
491
+ })) {
492
+ // Only events whose payload.parent_agent_id === "ag_parent_abc"
493
+ // (open and close frames always pass through).
494
+ }
495
+ ```
496
+
497
+ **Cost note:** because the filter is client-side, the server doesn't shed any load. If the result set is large enough that you care about server-side narrowing, raise an issue — server-side `?parent_agent_id=` is a planned addition.
498
+
257
499
  ## Why HTTP, not gRPC
258
500
 
259
501
  Loomcycle's HTTP+SSE surface is the canonical wire contract — every gRPC RPC has an HTTP equivalent (see `internal/api/http/server.go` for the route registrations). The Python adapter (gRPC) and this TS adapter (HTTP) cover the same surface; the choice between them is about ecosystem fit, not capability. HTTP+SSE works through every reverse proxy without special config; gRPC needs HTTP/2 + protoc round trips. For Node.js orchestrators that already have `fetch` in scope, HTTP is the simpler dependency.
package/dist/client.d.ts CHANGED
@@ -23,7 +23,7 @@
23
23
  * via fetch-helpers.ts:raiseFromResponse — see README.md for the
24
24
  * full mapping table.
25
25
  */
26
- import type { Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, CreateSnapshotOptions, HealthResponse, Hook, InterruptListResponse, InterruptStatus, ListUsersResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopesResponse, PauseResult, RegisterHookOptions, RegisterHookResponse, ResolveInterruptOptions, ResumeResult, RunOptions, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, SubstrateToolInput, SubstrateToolResponse, TranscriptResponse } from "./types.js";
26
+ import type { Agent, AgentEvent, AgentStatus, CancelAgentResult, ClientOptions, ContinueOptions, CreateSnapshotOptions, HealthResponse, Hook, InterruptListResponse, InterruptStatus, AckChannelOptions, ChannelAckResult, ChannelPeekResult, ChannelPublishResult, ChannelSubscribeResult, ListChannelsResponse, PeekChannelOptions, PublishChannelOptions, SubscribeChannelOptions, ListUsersResponse, MemoryEntriesResponse, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopesResponse, PauseResult, RegisterHookOptions, RegisterHookResponse, ResolveInterruptOptions, ResumeResult, RunOptions, RunStateStreamItem, RuntimeStateResponse, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotRestoreResponse, StreamUserRunStatesOptions, SubstrateToolInput, SubstrateToolResponse, TranscriptResponse } from "./types.js";
27
27
  export declare class LoomcycleClient {
28
28
  private ctx;
29
29
  constructor(opts?: ClientOptions);
@@ -34,6 +34,19 @@ export declare class LoomcycleClient {
34
34
  * Errors during the run surface as `{ type: "error", error }` events;
35
35
  * only transport / HTTP-level failures throw — and those throw typed
36
36
  * errors (e.g. AuthError for 401, BackpressureError for 429).
37
+ *
38
+ * **Blocking semantics.** This iterator is alive for the FULL
39
+ * duration of the run — typically seconds, occasionally minutes for
40
+ * long tool chains. Callers that need fire-and-forget completion
41
+ * notifications (n8n's worker model, dashboards that don't want to
42
+ * hold a connection per active run) should subscribe to
43
+ * {@link LoomcycleClient.streamUserRunStates} instead, which yields
44
+ * one terminal-state frame per completed run without holding the
45
+ * run's stream open.
46
+ *
47
+ * v0.9.x — pass `opts.debug = true` to emit synthetic
48
+ * `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
49
+ * events around the real frames. Silent (default) when omitted.
37
50
  */
38
51
  runStreaming(opts: RunOptions): AsyncIterable<AgentEvent>;
39
52
  /**
@@ -44,6 +57,14 @@ export declare class LoomcycleClient {
44
57
  * Raises SessionNotFoundError when sessionId is unknown,
45
58
  * SessionBusyError when another request is in flight on the same
46
59
  * session.
60
+ *
61
+ * **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
62
+ * the iterator stays alive for the duration of the new run. For
63
+ * async fire-and-forget completion patterns, see
64
+ * {@link LoomcycleClient.streamUserRunStates}.
65
+ *
66
+ * v0.9.x — pass `opts.debug = true` for synthetic
67
+ * `_meta` open/close events.
47
68
  */
48
69
  continueSession(opts: ContinueOptions): AsyncIterable<AgentEvent>;
49
70
  /** Read one agent's status + usage stats. Raises AgentNotFoundError
@@ -58,9 +79,16 @@ export declare class LoomcycleClient {
58
79
  reason?: string;
59
80
  signal?: AbortSignal;
60
81
  }): Promise<CancelAgentResult>;
61
- /** List a user's recent agent runs, optionally filtered by status. */
82
+ /** List a user's recent agent runs, optionally filtered by status.
83
+ *
84
+ * v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
85
+ * whose `parent_agent_id` matches. The server still returns the
86
+ * full set (server-side `?parent_agent_id=` filter is a future
87
+ * request); the adapter trims before returning. Useful for the
88
+ * n8n trigger pattern "show me all sub-runs spawned by parent X." */
62
89
  listUserAgents(userId: string, opts?: {
63
90
  status?: AgentStatus;
91
+ parentAgentId?: string;
64
92
  signal?: AbortSignal;
65
93
  }): Promise<Agent[]>;
66
94
  /** Read the full event log for a session. Each entry has seq,
@@ -233,7 +261,101 @@ export declare class LoomcycleClient {
233
261
  skillDef(input: SubstrateToolInput, opts?: {
234
262
  signal?: AbortSignal;
235
263
  }): Promise<SubstrateToolResponse>;
264
+ /** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
265
+ * Dynamic MCP server registration — register an HTTP /
266
+ * Streamable-HTTP MCP server at runtime so its tools become
267
+ * callable from any agent's `allowed_tools` list without a yaml
268
+ * edit + restart.
269
+ *
270
+ * Operator-admin-only: this endpoint requires the bearer token.
271
+ *
272
+ * Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
273
+ * | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
274
+ * shape varies — narrow with {@link MCPServerDefRowResponse} for
275
+ * create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
276
+ * verify responses.
277
+ *
278
+ * Hard constraints (substrate refuses these):
279
+ * - Transport must be `http` or `streamable-http` (stdio stays
280
+ * yaml-only — dynamic registration doesn't allow process spawn).
281
+ * - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
282
+ * defence at the registration boundary).
283
+ * - Name colliding with a static cfg.MCPServers entry is refused
284
+ * (yaml is ground truth; use a different name).
285
+ *
286
+ * Raises {@link SubstrateToolRefusedError} on tool-level refusals
287
+ * (transport/host/yaml-name); {@link InvalidArgumentError} on 400
288
+ * (malformed JSON); {@link AuthError} on 401. */
289
+ mcpServerDef(input: SubstrateToolInput, opts?: {
290
+ signal?: AbortSignal;
291
+ }): Promise<SubstrateToolResponse>;
236
292
  /** Shared SSE POST → stream-of-AgentEvent path. Used by
237
- * runStreaming + continueSession. */
293
+ * runStreaming + continueSession.
294
+ *
295
+ * When `debug` is true, the iterator yields a synthetic
296
+ * `{ type: "_meta", meta_subtype: "stream_open" }` before any real
297
+ * events AND a `{ type: "_meta", meta_subtype: "stream_close",
298
+ * meta_reason }` on EOF / abort / error. The default is silent
299
+ * (matches pre-v0.9.x behaviour). */
238
300
  private streamSSE;
301
+ /** List every operator-declared channel with aggregate stats
302
+ * (message_count, oldest_visible_at, newest_visible_at).
303
+ * Channels with no published messages still appear with
304
+ * message_count=0. Orphaned message rows for un-declared channels
305
+ * also appear (forensic visibility). Mirrors GET /v1/_channels. */
306
+ listChannels(opts?: {
307
+ signal?: AbortSignal;
308
+ }): Promise<ListChannelsResponse>;
309
+ /** Publish a JSON payload to an operator-declared channel. Mirrors
310
+ * the in-band Channel tool's publish op semantics — including
311
+ * deferred delivery via `deliverAt` (RFC3339Nano).
312
+ *
313
+ * Errors:
314
+ * - {@link NotFoundError} (404) when the channel isn't in operator
315
+ * yaml. The wire `code` is `channel_not_declared`.
316
+ * - {@link InvalidArgumentError} (400) on invalid scope / payload.
317
+ * - {@link AuthError} (401) on bearer mismatch. */
318
+ publishChannel(channel: string, opts: PublishChannelOptions): Promise<ChannelPublishResult>;
319
+ /** Read the next batch of messages from a channel. Single-round-
320
+ * trip long-poll: returns immediately if messages are present,
321
+ * otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
322
+ * cursor on a non-empty batch.
323
+ *
324
+ * For at-least-once delivery (crash safety between "loomcycle
325
+ * returned the batch" and "consumer finished processing"), use
326
+ * {@link LoomcycleClient.peekChannel} + an explicit
327
+ * {@link LoomcycleClient.ackChannel} after durable processing. */
328
+ subscribeChannel(channel: string, opts: SubscribeChannelOptions): Promise<ChannelSubscribeResult>;
329
+ /** Non-destructive read — never advances the committed cursor.
330
+ * Use for at-least-once consumption patterns: peek, process the
331
+ * batch durably, then `ackChannel` to advance. Multiple consumers
332
+ * can peek the same channel without disturbing each other. */
333
+ peekChannel(channel: string, opts: PeekChannelOptions): Promise<ChannelPeekResult>;
334
+ /** Advance the committed cursor for a (channel, scope, scope_id)
335
+ * tuple. Cursor must be monotonically forward — older cursors
336
+ * raise a {@link ConflictError} (HTTP 409, code
337
+ * `channel_cursor_regression`). */
338
+ ackChannel(channel: string, opts: AckChannelOptions): Promise<ChannelAckResult>;
339
+ /** Subscribe to run state transitions for one user_id via SSE.
340
+ * Yields one `{ kind: "open", ... }` item first (confirms the
341
+ * connection is live), then one `{ kind: "event", ... }` per
342
+ * matching state transition until the stream closes.
343
+ *
344
+ * The stream stays open for at most 30 minutes (server-enforced).
345
+ * Callers running indefinitely should reconnect on close.
346
+ *
347
+ * Errors during the stream throw — they do NOT surface as items.
348
+ * Pass an AbortSignal to terminate cleanly from the consumer side.
349
+ *
350
+ * v0.9.x options:
351
+ * - `parentAgentId` — client-side filter: only `kind: "event"`
352
+ * items whose payload's `parent_agent_id` matches are yielded.
353
+ * The server still streams every matching event; the adapter
354
+ * filters before yielding. Empty/omitted = no filter.
355
+ * - `debug` — when true, an additional `{ kind: "close", payload:
356
+ * { reason } }` item is yielded when the stream ends (EOF,
357
+ * abort, or pre-yield error). Useful for n8n nodes that surface
358
+ * "stream re-opened / closed" log entries without inferring
359
+ * from timing. Default false. */
360
+ streamUserRunStates(userId: string, opts?: StreamUserRunStatesOptions): AsyncIterable<RunStateStreamItem>;
239
361
  }
package/dist/client.js CHANGED
@@ -42,6 +42,19 @@ export class LoomcycleClient {
42
42
  * Errors during the run surface as `{ type: "error", error }` events;
43
43
  * only transport / HTTP-level failures throw — and those throw typed
44
44
  * errors (e.g. AuthError for 401, BackpressureError for 429).
45
+ *
46
+ * **Blocking semantics.** This iterator is alive for the FULL
47
+ * duration of the run — typically seconds, occasionally minutes for
48
+ * long tool chains. Callers that need fire-and-forget completion
49
+ * notifications (n8n's worker model, dashboards that don't want to
50
+ * hold a connection per active run) should subscribe to
51
+ * {@link LoomcycleClient.streamUserRunStates} instead, which yields
52
+ * one terminal-state frame per completed run without holding the
53
+ * run's stream open.
54
+ *
55
+ * v0.9.x — pass `opts.debug = true` to emit synthetic
56
+ * `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
57
+ * events around the real frames. Silent (default) when omitted.
45
58
  */
46
59
  async *runStreaming(opts) {
47
60
  // Build the body conditionally so omitted fields stay off the wire.
@@ -73,7 +86,7 @@ export class LoomcycleClient {
73
86
  body.user_tier = opts.userTier;
74
87
  if (opts.userBearer !== undefined)
75
88
  body.user_bearer = opts.userBearer;
76
- yield* this.streamSSE("/v1/runs", body, opts.signal);
89
+ yield* this.streamSSE("/v1/runs", body, opts.signal, opts.debug);
77
90
  }
78
91
  /**
79
92
  * Continue an existing session with a new run. The session's prior
@@ -83,6 +96,14 @@ export class LoomcycleClient {
83
96
  * Raises SessionNotFoundError when sessionId is unknown,
84
97
  * SessionBusyError when another request is in flight on the same
85
98
  * session.
99
+ *
100
+ * **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
101
+ * the iterator stays alive for the duration of the new run. For
102
+ * async fire-and-forget completion patterns, see
103
+ * {@link LoomcycleClient.streamUserRunStates}.
104
+ *
105
+ * v0.9.x — pass `opts.debug = true` for synthetic
106
+ * `_meta` open/close events.
86
107
  */
87
108
  async *continueSession(opts) {
88
109
  const body = {
@@ -101,7 +122,7 @@ export class LoomcycleClient {
101
122
  body.user_tier = opts.userTier;
102
123
  if (opts.userBearer !== undefined)
103
124
  body.user_bearer = opts.userBearer;
104
- yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal);
125
+ yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal, opts.debug);
105
126
  }
106
127
  // ---- Agent metadata ----
107
128
  /** Read one agent's status + usage stats. Raises AgentNotFoundError
@@ -116,11 +137,21 @@ export class LoomcycleClient {
116
137
  const resp = await postJSON(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}/cancel`, { reason: opts?.reason ?? "" }, opts);
117
138
  return { cancelledCount: resp.cancelled_count };
118
139
  }
119
- /** List a user's recent agent runs, optionally filtered by status. */
140
+ /** List a user's recent agent runs, optionally filtered by status.
141
+ *
142
+ * v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
143
+ * whose `parent_agent_id` matches. The server still returns the
144
+ * full set (server-side `?parent_agent_id=` filter is a future
145
+ * request); the adapter trims before returning. Useful for the
146
+ * n8n trigger pattern "show me all sub-runs spawned by parent X." */
120
147
  async listUserAgents(userId, opts) {
121
148
  const q = opts?.status ? `?status=${encodeURIComponent(opts.status)}` : "";
122
149
  const resp = await jsonFetch(this.ctx, `/v1/users/${encodeURIComponent(userId)}/agents${q}`, opts);
123
- return resp.agents ?? [];
150
+ const all = resp.agents ?? [];
151
+ if (opts?.parentAgentId !== undefined && opts.parentAgentId !== "") {
152
+ return all.filter((a) => a.parent_agent_id === opts.parentAgentId);
153
+ }
154
+ return all;
124
155
  }
125
156
  /** Read the full event log for a session. Each entry has seq,
126
157
  * run_id, ts_ns, type, event (the providers.Event payload). */
@@ -360,10 +391,44 @@ export class LoomcycleClient {
360
391
  async skillDef(input, opts) {
361
392
  return postJSON(this.ctx, "/v1/_skilldef", input, opts);
362
393
  }
394
+ /** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
395
+ * Dynamic MCP server registration — register an HTTP /
396
+ * Streamable-HTTP MCP server at runtime so its tools become
397
+ * callable from any agent's `allowed_tools` list without a yaml
398
+ * edit + restart.
399
+ *
400
+ * Operator-admin-only: this endpoint requires the bearer token.
401
+ *
402
+ * Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
403
+ * | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
404
+ * shape varies — narrow with {@link MCPServerDefRowResponse} for
405
+ * create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
406
+ * verify responses.
407
+ *
408
+ * Hard constraints (substrate refuses these):
409
+ * - Transport must be `http` or `streamable-http` (stdio stays
410
+ * yaml-only — dynamic registration doesn't allow process spawn).
411
+ * - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
412
+ * defence at the registration boundary).
413
+ * - Name colliding with a static cfg.MCPServers entry is refused
414
+ * (yaml is ground truth; use a different name).
415
+ *
416
+ * Raises {@link SubstrateToolRefusedError} on tool-level refusals
417
+ * (transport/host/yaml-name); {@link InvalidArgumentError} on 400
418
+ * (malformed JSON); {@link AuthError} on 401. */
419
+ async mcpServerDef(input, opts) {
420
+ return postJSON(this.ctx, "/v1/_mcpserverdef", input, opts);
421
+ }
363
422
  // ---- Internal helpers ----
364
423
  /** Shared SSE POST → stream-of-AgentEvent path. Used by
365
- * runStreaming + continueSession. */
366
- async *streamSSE(path, body, signal) {
424
+ * runStreaming + continueSession.
425
+ *
426
+ * When `debug` is true, the iterator yields a synthetic
427
+ * `{ type: "_meta", meta_subtype: "stream_open" }` before any real
428
+ * events AND a `{ type: "_meta", meta_subtype: "stream_close",
429
+ * meta_reason }` on EOF / abort / error. The default is silent
430
+ * (matches pre-v0.9.x behaviour). */
431
+ async *streamSSE(path, body, signal, debug) {
367
432
  const headers = {
368
433
  "Content-Type": "application/json",
369
434
  // Accept BOTH text/event-stream (the success path) AND
@@ -388,6 +453,278 @@ export class LoomcycleClient {
388
453
  if (!resp.body) {
389
454
  throw new Error("loomcycle: response has no body");
390
455
  }
391
- yield* parseSSE(resp.body.getReader());
456
+ if (!debug) {
457
+ // Silent default — pre-v0.9.x shape.
458
+ yield* parseSSE(resp.body.getReader());
459
+ return;
460
+ }
461
+ // Debug shape: synthetic open + close around the real stream.
462
+ // The open frame carries no meta_reason — the frame itself IS the
463
+ // signal. The close frame's meta_reason distinguishes normal EOF
464
+ // from caller-side abort or a typed-error throw mid-stream.
465
+ //
466
+ // Close is emitted on both paths via try/catch/throw: success path
467
+ // emits AFTER the try block; error path emits INSIDE the catch
468
+ // before re-throwing. NOT a try/finally — the duplication is
469
+ // intentional so the close-then-throw ordering is explicit and
470
+ // a refactor adding `finally` doesn't accidentally double-emit.
471
+ yield { type: "_meta", meta_subtype: "stream_open" };
472
+ let closeReason = "eof";
473
+ try {
474
+ yield* parseSSE(resp.body.getReader());
475
+ }
476
+ catch (e) {
477
+ // Capture the error type for the close frame, then re-throw so
478
+ // typed-error handling at the consumer site still works.
479
+ closeReason =
480
+ e && typeof e === "object" && "name" in e
481
+ ? String(e.name)
482
+ : "error";
483
+ yield {
484
+ type: "_meta",
485
+ meta_subtype: "stream_close",
486
+ meta_reason: closeReason,
487
+ };
488
+ throw e;
489
+ }
490
+ yield {
491
+ type: "_meta",
492
+ meta_subtype: "stream_close",
493
+ meta_reason: closeReason,
494
+ };
495
+ }
496
+ // ---- v0.9.x n8n RFC Phase 0 ----
497
+ /** List every operator-declared channel with aggregate stats
498
+ * (message_count, oldest_visible_at, newest_visible_at).
499
+ * Channels with no published messages still appear with
500
+ * message_count=0. Orphaned message rows for un-declared channels
501
+ * also appear (forensic visibility). Mirrors GET /v1/_channels. */
502
+ async listChannels(opts) {
503
+ return await jsonFetch(this.ctx, "/v1/_channels", opts);
504
+ }
505
+ // ---- v0.9.x Channel CRUD ----
506
+ //
507
+ // Four bearer-authed ops mirroring the in-band Channel tool's
508
+ // publish/subscribe/peek/ack. Two URL families behind the
509
+ // `scope` field:
510
+ // - scope: "global" → POST /v1/_channels/{name}/{op} (admin)
511
+ // - scope: "user" → POST /v1/users/{userId}/channels/{name}/{op}
512
+ //
513
+ // The same operator bearer token guards both surfaces; the per-user
514
+ // URL embeds the user_id in the path so a caller can't forge a
515
+ // different user_id by lying in the body.
516
+ //
517
+ // Subscribe is a SINGLE-ROUND-TRIP long-poll, not an open stream.
518
+ // For continuous delivery, call `subscribeChannel` in a loop (the
519
+ // n8n trigger node's pattern). Auto-commits the cursor on non-empty
520
+ // batches (at-most-once shape) — use `peekChannel` + explicit
521
+ // `ackChannel` for at-least-once semantics.
522
+ /** Publish a JSON payload to an operator-declared channel. Mirrors
523
+ * the in-band Channel tool's publish op semantics — including
524
+ * deferred delivery via `deliverAt` (RFC3339Nano).
525
+ *
526
+ * Errors:
527
+ * - {@link NotFoundError} (404) when the channel isn't in operator
528
+ * yaml. The wire `code` is `channel_not_declared`.
529
+ * - {@link InvalidArgumentError} (400) on invalid scope / payload.
530
+ * - {@link AuthError} (401) on bearer mismatch. */
531
+ async publishChannel(channel, opts) {
532
+ const path = channelOpPath(channel, opts.scope, opts.userId, "publish");
533
+ const body = { payload: opts.payload };
534
+ if (opts.deliverAt)
535
+ body.deliver_at = opts.deliverAt;
536
+ return postJSON(this.ctx, path, body, {
537
+ signal: opts.signal,
538
+ });
539
+ }
540
+ /** Read the next batch of messages from a channel. Single-round-
541
+ * trip long-poll: returns immediately if messages are present,
542
+ * otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
543
+ * cursor on a non-empty batch.
544
+ *
545
+ * For at-least-once delivery (crash safety between "loomcycle
546
+ * returned the batch" and "consumer finished processing"), use
547
+ * {@link LoomcycleClient.peekChannel} + an explicit
548
+ * {@link LoomcycleClient.ackChannel} after durable processing. */
549
+ async subscribeChannel(channel, opts) {
550
+ const path = channelOpPath(channel, opts.scope, opts.userId, "subscribe");
551
+ const body = {};
552
+ if (opts.fromCursor !== undefined)
553
+ body.from_cursor = opts.fromCursor;
554
+ if (opts.maxMessages !== undefined)
555
+ body.max_messages = opts.maxMessages;
556
+ if (opts.waitMs !== undefined)
557
+ body.wait_ms = opts.waitMs;
558
+ return postJSON(this.ctx, path, body, {
559
+ signal: opts.signal,
560
+ });
561
+ }
562
+ /** Non-destructive read — never advances the committed cursor.
563
+ * Use for at-least-once consumption patterns: peek, process the
564
+ * batch durably, then `ackChannel` to advance. Multiple consumers
565
+ * can peek the same channel without disturbing each other. */
566
+ async peekChannel(channel, opts) {
567
+ let path = channelOpPath(channel, opts.scope, opts.userId, "peek");
568
+ const params = [];
569
+ if (opts.fromCursor)
570
+ params.push(`from_cursor=${encodeURIComponent(opts.fromCursor)}`);
571
+ if (opts.maxMessages)
572
+ params.push(`max_messages=${opts.maxMessages}`);
573
+ if (params.length > 0)
574
+ path += `?${params.join("&")}`;
575
+ return jsonFetch(this.ctx, path, { signal: opts.signal });
576
+ }
577
+ /** Advance the committed cursor for a (channel, scope, scope_id)
578
+ * tuple. Cursor must be monotonically forward — older cursors
579
+ * raise a {@link ConflictError} (HTTP 409, code
580
+ * `channel_cursor_regression`). */
581
+ async ackChannel(channel, opts) {
582
+ const path = channelOpPath(channel, opts.scope, opts.userId, "ack");
583
+ return postJSON(this.ctx, path, { cursor: opts.cursor }, { signal: opts.signal });
584
+ }
585
+ /** Subscribe to run state transitions for one user_id via SSE.
586
+ * Yields one `{ kind: "open", ... }` item first (confirms the
587
+ * connection is live), then one `{ kind: "event", ... }` per
588
+ * matching state transition until the stream closes.
589
+ *
590
+ * The stream stays open for at most 30 minutes (server-enforced).
591
+ * Callers running indefinitely should reconnect on close.
592
+ *
593
+ * Errors during the stream throw — they do NOT surface as items.
594
+ * Pass an AbortSignal to terminate cleanly from the consumer side.
595
+ *
596
+ * v0.9.x options:
597
+ * - `parentAgentId` — client-side filter: only `kind: "event"`
598
+ * items whose payload's `parent_agent_id` matches are yielded.
599
+ * The server still streams every matching event; the adapter
600
+ * filters before yielding. Empty/omitted = no filter.
601
+ * - `debug` — when true, an additional `{ kind: "close", payload:
602
+ * { reason } }` item is yielded when the stream ends (EOF,
603
+ * abort, or pre-yield error). Useful for n8n nodes that surface
604
+ * "stream re-opened / closed" log entries without inferring
605
+ * from timing. Default false. */
606
+ async *streamUserRunStates(userId, opts) {
607
+ const params = new URLSearchParams();
608
+ if (opts?.statuses && opts.statuses.length > 0) {
609
+ params.set("status", opts.statuses.join(","));
610
+ }
611
+ if (opts?.agent) {
612
+ params.set("agent", opts.agent);
613
+ }
614
+ const qs = params.toString();
615
+ const path = `/v1/users/${encodeURIComponent(userId)}/agents/stream` +
616
+ (qs ? `?${qs}` : "");
617
+ const headers = {
618
+ Accept: "text/event-stream",
619
+ };
620
+ if (this.ctx.authToken) {
621
+ headers.Authorization = `Bearer ${this.ctx.authToken}`;
622
+ }
623
+ const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
624
+ method: "GET",
625
+ headers,
626
+ signal: opts?.signal,
627
+ });
628
+ if (!resp.ok) {
629
+ await raiseFromResponse(resp);
630
+ }
631
+ if (!resp.body) {
632
+ throw new Error("loomcycle: streamUserRunStates response has no body");
633
+ }
634
+ const parentFilter = opts?.parentAgentId ?? "";
635
+ const debug = opts?.debug === true;
636
+ let closeReason = "eof";
637
+ try {
638
+ for await (const item of parseRunStateSSE(resp.body.getReader())) {
639
+ // Client-side parent_agent_id filter. Pre-v1 the server has no
640
+ // ?parent_agent_id= query param; n8n-style consumers that need
641
+ // a narrow view get a smaller iterator at the cost of
642
+ // unchanged server load. See StreamUserRunStatesOptions for
643
+ // the trade-off note.
644
+ if (parentFilter !== "" &&
645
+ item.kind === "event" &&
646
+ item.payload.parent_agent_id !== parentFilter) {
647
+ continue;
648
+ }
649
+ yield item;
650
+ }
651
+ }
652
+ catch (e) {
653
+ closeReason =
654
+ e && typeof e === "object" && "name" in e
655
+ ? String(e.name)
656
+ : "error";
657
+ if (debug) {
658
+ yield { kind: "close", payload: { reason: closeReason } };
659
+ }
660
+ throw e;
661
+ }
662
+ if (debug) {
663
+ yield { kind: "close", payload: { reason: closeReason } };
664
+ }
665
+ }
666
+ }
667
+ /** Lightweight SSE parser tailored to the run-state stream. Each
668
+ * frame's event name distinguishes the two kinds; data is JSON.
669
+ * Comment lines (": keepalive") are ignored. */
670
+ async function* parseRunStateSSE(reader) {
671
+ const decoder = new TextDecoder("utf-8");
672
+ let buf = "";
673
+ let event = "";
674
+ let data = "";
675
+ while (true) {
676
+ const { value, done } = await reader.read();
677
+ if (done)
678
+ break;
679
+ buf += decoder.decode(value, { stream: true });
680
+ let idx;
681
+ while ((idx = buf.indexOf("\n")) !== -1) {
682
+ const line = buf.slice(0, idx).replace(/\r$/, "");
683
+ buf = buf.slice(idx + 1);
684
+ if (line === "") {
685
+ if (event && data) {
686
+ try {
687
+ const parsed = JSON.parse(data);
688
+ if (event === "stream_open") {
689
+ yield {
690
+ kind: "open",
691
+ payload: parsed,
692
+ };
693
+ }
694
+ else if (event === "run_state") {
695
+ yield {
696
+ kind: "event",
697
+ payload: parsed,
698
+ };
699
+ }
700
+ }
701
+ catch {
702
+ // Drop malformed frame silently — same posture as parseSSE.
703
+ }
704
+ }
705
+ event = "";
706
+ data = "";
707
+ continue;
708
+ }
709
+ if (line.startsWith("event:"))
710
+ event = line.slice("event:".length).trim();
711
+ else if (line.startsWith("data:"))
712
+ data = line.slice("data:".length).trim();
713
+ }
714
+ }
715
+ }
716
+ // channelOpPath builds the v0.9.x Channel CRUD URL. Two families:
717
+ // - scope === "global" → /v1/_channels/{channel}/{op}
718
+ // - scope === "user" → /v1/users/{userId}/channels/{channel}/{op}
719
+ // Channel name is URL-encoded so names containing slashes
720
+ // ("findings/alpha", "_system/foo") survive transport.
721
+ function channelOpPath(channel, scope, userId, op) {
722
+ const enc = encodeURIComponent(channel);
723
+ if (scope === "user") {
724
+ if (!userId) {
725
+ throw new Error(`loomcycle: scope="user" requires opts.userId for the channel ${op} call`);
726
+ }
727
+ return `/v1/users/${encodeURIComponent(userId)}/channels/${enc}/${op}`;
392
728
  }
729
+ return `/v1/_channels/${enc}/${op}`;
393
730
  }
package/dist/errors.d.ts CHANGED
@@ -133,6 +133,22 @@ export declare class HookNotFoundError extends NotFoundError {
133
133
  bodyText?: string;
134
134
  });
135
135
  }
136
+ /** ChannelCursorRegressionError — raised by `client.ackChannel()`
137
+ * when the caller-supplied cursor is older than the currently-
138
+ * committed cursor for the (channel, scope, scope_id) tuple. HTTP
139
+ * 409 with `{code: "channel_cursor_regression", ...}` body.
140
+ *
141
+ * Mirrors `store.ErrChannelCursorRegression` on the loomcycle
142
+ * side. Distinct from `SessionBusyError` etc. (which also map to
143
+ * 409) so the n8n adapter can distinguish "this cursor is stale,
144
+ * re-fetch and retry from the new committed position" from other
145
+ * 409 conditions. */
146
+ export declare class ChannelCursorRegressionError extends LoomcycleError {
147
+ constructor(message: string, opts?: {
148
+ status?: number;
149
+ bodyText?: string;
150
+ });
151
+ }
136
152
  /** SubstrateToolRefusedError — raised by `client.agentDef()` /
137
153
  * `client.skillDef()` when the in-process tool refused the call
138
154
  * (scope deny, empty body, allowed-tools widening, etc.). HTTP
package/dist/errors.js CHANGED
@@ -136,6 +136,22 @@ export class HookNotFoundError extends NotFoundError {
136
136
  this.name = "HookNotFoundError";
137
137
  }
138
138
  }
139
+ /** ChannelCursorRegressionError — raised by `client.ackChannel()`
140
+ * when the caller-supplied cursor is older than the currently-
141
+ * committed cursor for the (channel, scope, scope_id) tuple. HTTP
142
+ * 409 with `{code: "channel_cursor_regression", ...}` body.
143
+ *
144
+ * Mirrors `store.ErrChannelCursorRegression` on the loomcycle
145
+ * side. Distinct from `SessionBusyError` etc. (which also map to
146
+ * 409) so the n8n adapter can distinguish "this cursor is stale,
147
+ * re-fetch and retry from the new committed position" from other
148
+ * 409 conditions. */
149
+ export class ChannelCursorRegressionError extends LoomcycleError {
150
+ constructor(message, opts) {
151
+ super(message, opts);
152
+ this.name = "ChannelCursorRegressionError";
153
+ }
154
+ }
139
155
  /** SubstrateToolRefusedError — raised by `client.agentDef()` /
140
156
  * `client.skillDef()` when the in-process tool refused the call
141
157
  * (scope deny, empty body, allowed-tools widening, etc.). HTTP
@@ -9,7 +9,7 @@
9
9
  * Method-level code in client.ts stays focused on URL + body shape;
10
10
  * the boring fetch + error-translation machinery lives here.
11
11
  */
12
- import { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, InvalidArgumentError, LoomcycleError, NotFoundError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
12
+ import { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, ChannelCursorRegressionError, HookNotFoundError, InvalidArgumentError, LoomcycleError, NotFoundError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
13
13
  /** authHeaders builds the standard request header set: JSON Accept
14
14
  * + Bearer token when the client was constructed with one. The
15
15
  * caller adds Content-Type when posting a body. */
@@ -157,6 +157,10 @@ export async function raiseFromResponse(resp) {
157
157
  throw new AlreadyPausingError(msg, opts);
158
158
  if (bodyLower.includes("not_paused") || bodyLower.includes("not paused"))
159
159
  throw new NotPausedError(msg, opts);
160
+ // v0.9.x — Channel CRUD ack with a stale cursor. Distinct so
161
+ // the n8n adapter / consumer can branch on `instanceof`.
162
+ if (bodyLower.includes("channel_cursor_regression"))
163
+ throw new ChannelCursorRegressionError(msg, opts);
160
164
  if (bodyLower.includes("session"))
161
165
  throw new SessionBusyError(msg, opts);
162
166
  if (bodyLower.includes("agent_id"))
package/dist/index.d.ts CHANGED
@@ -64,5 +64,5 @@
64
64
  * See `adapters/ts/README.md` for usage examples.
65
65
  */
66
66
  export { LoomcycleClient } from "./client.js";
67
- export type { AgentEvent, ClientOptions, ContinueOptions, EventType, HostWidening, PromptContent, PromptSegment, RetryInfo, RunOptions, ToolUse, Usage, Agent, AgentStatus, AgentUsage, CancelAgentResult, ListAgentsResponse, TranscriptEvent, TranscriptResponse, HealthResponse, ListUsersResponse, UserSummary, PauseResult, ResumeResult, RuntimeStateResponse, RuntimeStateStatus, CreateSnapshotOptions, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotListResponse, SnapshotRestoreResponse, MemoryEntriesResponse, MemoryEntry, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopeIDSummary, MemoryScopeKind, MemoryScopesResponse, InterruptListResponse, InterruptRow, InterruptStatus, ResolveInterruptOptions, Hook, HookFailMode, HookPhase, HookToolCall, HookToolResult, ListHooksResponse, PostHookCall, PostHookResult, PreHookCall, PreHookResult, RegisterHookOptions, RegisterHookResponse, SubstrateToolInput, SubstrateToolResponse, } from "./types.js";
68
- export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
67
+ export type { AgentEvent, ClientOptions, ContinueOptions, EventType, HostWidening, PromptContent, PromptSegment, RetryInfo, RunOptions, ToolUse, Usage, Agent, AgentStatus, AgentUsage, CancelAgentResult, ListAgentsResponse, TranscriptEvent, TranscriptResponse, HealthResponse, ListUsersResponse, UserSummary, PauseResult, ResumeResult, RuntimeStateResponse, RuntimeStateStatus, CreateSnapshotOptions, SnapshotCreateResponse, SnapshotDescriptor, SnapshotEnvelope, SnapshotListResponse, SnapshotRestoreResponse, MemoryEntriesResponse, MemoryEntry, MemoryEntryResponse, MemoryScopeIDsResponse, MemoryScopeIDSummary, MemoryScopeKind, MemoryScopesResponse, InterruptListResponse, InterruptRow, InterruptStatus, ResolveInterruptOptions, Hook, HookFailMode, HookPhase, HookToolCall, HookToolResult, ListHooksResponse, PostHookCall, PostHookResult, PreHookCall, PreHookResult, RegisterHookOptions, RegisterHookResponse, SubstrateToolInput, SubstrateToolResponse, SystemPromptPayload, UserInputPayload, ChannelDescriptor, ListChannelsResponse, RunStateEvent, RunStateStreamClose, RunStateStreamItem, RunStateStreamOpen, StreamUserRunStatesOptions, AckChannelOptions, ChannelAckResult, ChannelMessageItem, ChannelPeekResult, ChannelPublishResult, ChannelScope, ChannelSubscribeResult, PeekChannelOptions, PublishChannelOptions, SubscribeChannelOptions, AgentDefRowResponse, AgentDefVerifyResult, SkillDefVerifyResult, MCPServerDefRowResponse, MCPServerDefVerifyResult, } from "./types.js";
68
+ export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
package/dist/index.js CHANGED
@@ -64,4 +64,4 @@
64
64
  * See `adapters/ts/README.md` for usage examples.
65
65
  */
66
66
  export { LoomcycleClient } from "./client.js";
67
- export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
67
+ export { AgentIDInUseError, AgentNotFoundError, AlreadyPausingError, AuthError, BackpressureError, HookNotFoundError, NotFoundError, InvalidArgumentError, ChannelCursorRegressionError, LoomcycleError, NotPausedError, PauseNotConfiguredError, SessionBusyError, SessionNotFoundError, SnapshotNotFoundError, SnapshotTooLargeError, SnapshotVersionError, SubstrateToolRefusedError, UnavailableError, } from "./errors.js";
package/dist/types.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  * `client.ts` for the input shapes (RunOptions, CreateSnapshotOptions,
8
8
  * etc.) — those are translated to snake_case in the request body.
9
9
  */
10
- export type EventType = "started" | "text" | "tool_call" | "tool_result" | "usage" | "done" | "error" | "retry" | "host_widened" | "session" | "agent";
10
+ export type EventType = "started" | "text" | "tool_call" | "tool_result" | "usage" | "done" | "error" | "retry" | "host_widened" | "session" | "agent" | "_meta";
11
11
  export interface ToolUse {
12
12
  id: string;
13
13
  name: string;
@@ -69,6 +69,8 @@ export interface AgentEvent {
69
69
  run_id?: string;
70
70
  session_id?: string;
71
71
  parent_agent_id?: string | null;
72
+ meta_subtype?: "stream_open" | "stream_close";
73
+ meta_reason?: string;
72
74
  }
73
75
  export type PromptContent = {
74
76
  type: "trusted-text";
@@ -128,6 +130,14 @@ export interface RunOptions {
128
130
  * (static-bearer setups unaffected). Sub-agents inherit identically.
129
131
  * Never persisted; never logged in full. */
130
132
  userBearer?: string;
133
+ /** Opt-in observability: when true, the iterator emits client-
134
+ * synthesized `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
135
+ * events around the real event stream. `meta_reason` carries the
136
+ * trigger ("eof", "abort", or an error class name). Default is
137
+ * false — existing consumers see no behaviour change. Useful for
138
+ * n8n nodes that want to surface "stream re-opened" / "stream
139
+ * closed" log entries without inferring from event timing. */
140
+ debug?: boolean;
131
141
  signal?: AbortSignal;
132
142
  }
133
143
  export interface ContinueOptions {
@@ -156,6 +166,8 @@ export interface ContinueOptions {
156
166
  * so different continuations in the same session may carry
157
167
  * different end-user tokens. */
158
168
  userBearer?: string;
169
+ /** Opt-in observability: see {@link RunOptions.debug}. Same shape. */
170
+ debug?: boolean;
159
171
  signal?: AbortSignal;
160
172
  }
161
173
  export interface ClientOptions {
@@ -208,13 +220,50 @@ export interface CancelAgentResult {
208
220
  }
209
221
  /** TranscriptEvent — one persisted store.Event from
210
222
  * GET /v1/sessions/{id}/transcript. The server wraps each
211
- * providers.Event in {seq, run_id, ts_ns, type, event:{...}}. */
223
+ * providers.Event in {seq, run_id, ts_ns, type, event:{...}}.
224
+ *
225
+ * `payload` is the v0.9.1 sidecar field carrying the typed body of
226
+ * events that don't fit the providers.Event union (the first-cycle
227
+ * `system_prompt` + `user_input` transcript events). Narrow on
228
+ * `type` to pick the right payload interface — see
229
+ * {@link SystemPromptPayload} and {@link UserInputPayload}. */
212
230
  export interface TranscriptEvent {
213
231
  seq: number;
214
232
  run_id: string;
215
233
  ts_ns: number;
216
234
  type: string;
217
235
  event: unknown;
236
+ /** v0.9.1+ sidecar for typed transcript events:
237
+ * type === "system_prompt" → SystemPromptPayload
238
+ * type === "user_input" → UserInputPayload[]
239
+ * Absent for events the server hands through via `event`. */
240
+ payload?: SystemPromptPayload | UserInputPayload[] | unknown;
241
+ }
242
+ /** UserInputPayload mirrors the JSON of one `loop.PromptSegment` —
243
+ * what the caller supplied as `segments` on POST /v1/runs +
244
+ * /v1/sessions/{id}/messages. The transcript event's `payload`
245
+ * field carries the FULL array (`UserInputPayload[]`) because one
246
+ * call may include multiple segments (system + user prepends, etc.). */
247
+ export interface UserInputPayload {
248
+ role: string;
249
+ content: Array<{
250
+ type: string;
251
+ text?: string;
252
+ cacheable?: boolean;
253
+ }>;
254
+ }
255
+ /** SystemPromptPayload mirrors the v0.9.1 system_prompt transcript
256
+ * event payload — the resolved system prompt + provenance metadata
257
+ * so operators can see WHICH AgentDef + WHICH SkillDef rows fed in. */
258
+ export interface SystemPromptPayload {
259
+ system_prompt: string;
260
+ /** Empty for yaml-only agents (no AgentDef row). Pinned for
261
+ * sub-runs spawned via the Agent tool with a def_id. */
262
+ agent_def_id?: string;
263
+ /** skillName → active SkillDef def_id. Only present for skills
264
+ * whose DB-active row supplied the body; static-fallback skills
265
+ * are absent. */
266
+ skill_def_ids?: Record<string, string>;
218
267
  }
219
268
  export interface TranscriptResponse {
220
269
  session: {
@@ -502,3 +551,263 @@ export type SubstrateToolResponse = unknown;
502
551
  export interface PostHookResult {
503
552
  result?: HookToolResult;
504
553
  }
554
+ /** Aggregate stats for one operator-declared channel. Returned by
555
+ * {@link LoomcycleClient.listChannels}. */
556
+ export interface ChannelDescriptor {
557
+ name: string;
558
+ scope?: string;
559
+ semantic?: string;
560
+ publisher?: string;
561
+ period?: string;
562
+ default_ttl?: number;
563
+ max_messages?: number;
564
+ message_count: number;
565
+ /** RFC3339 — empty when count == 0. */
566
+ oldest_visible_at?: string;
567
+ newest_visible_at?: string;
568
+ }
569
+ /** Response shape for {@link LoomcycleClient.listChannels}. */
570
+ export interface ListChannelsResponse {
571
+ channels: ChannelDescriptor[];
572
+ }
573
+ /** Scope selector for the Channel CRUD methods. `"global"` addresses
574
+ * the admin surface; `"user"` requires `userId` and addresses the
575
+ * per-end-user URL family. */
576
+ export type ChannelScope = "global" | "user";
577
+ /** Options for {@link LoomcycleClient.publishChannel}. `payload` is
578
+ * the raw JSON value (object, array, string, number) to publish.
579
+ * `deliverAt` (RFC3339Nano) defers the publish so long-poll
580
+ * subscribers wake at the visible_at time. */
581
+ export interface PublishChannelOptions {
582
+ scope: ChannelScope;
583
+ /** Required when scope === "user". The per-user URL is
584
+ * /v1/users/{userId}/channels/{channel}/publish. */
585
+ userId?: string;
586
+ payload: unknown;
587
+ /** RFC3339Nano deferred-publish time. Omit for "publish now". */
588
+ deliverAt?: string;
589
+ signal?: AbortSignal;
590
+ }
591
+ /** Response shape for {@link LoomcycleClient.publishChannel}. */
592
+ export interface ChannelPublishResult {
593
+ msg_id: string;
594
+ channel: string;
595
+ /** RFC3339Nano. */
596
+ created_at: string;
597
+ /** RFC3339Nano. Omitted when the publish was immediate. */
598
+ visible_at?: string;
599
+ }
600
+ /** Options for {@link LoomcycleClient.subscribeChannel}. The call is a
601
+ * single-round-trip long-poll, NOT an open SSE stream — returns
602
+ * immediately if messages are present, otherwise waits up to
603
+ * `waitMs` for a publish. Auto-commits the cursor on a non-empty
604
+ * batch (at-most-once shape). For at-least-once, use
605
+ * {@link LoomcycleClient.peekChannel} + explicit ack. */
606
+ export interface SubscribeChannelOptions {
607
+ scope: ChannelScope;
608
+ userId?: string;
609
+ /** Cursor to read forward from. Empty/omitted = the committed
610
+ * cursor. `"cur_0"` = replay from the oldest non-expired row. */
611
+ fromCursor?: string;
612
+ /** Defaults to 10; clamped at 100 by the server. */
613
+ maxMessages?: number;
614
+ /** Long-poll timeout in ms. 0 / omitted = poll once and return.
615
+ * Capped at the operator's `ChannelsLongPollCapMS` (default 30s). */
616
+ waitMs?: number;
617
+ signal?: AbortSignal;
618
+ }
619
+ /** One delivered message — same wire shape as the in-band Channel
620
+ * tool's subscribe response. */
621
+ export interface ChannelMessageItem {
622
+ id: string;
623
+ value: unknown;
624
+ /** RFC3339Nano. */
625
+ published_at: string;
626
+ }
627
+ /** Response shape for {@link LoomcycleClient.subscribeChannel}. */
628
+ export interface ChannelSubscribeResult {
629
+ channel: string;
630
+ messages: ChannelMessageItem[];
631
+ /** Cursor to pass on the next subscribe call to continue forward.
632
+ * Empty when the batch is empty. */
633
+ next_cursor: string;
634
+ }
635
+ /** Options for {@link LoomcycleClient.peekChannel}. */
636
+ export interface PeekChannelOptions {
637
+ scope: ChannelScope;
638
+ userId?: string;
639
+ fromCursor?: string;
640
+ maxMessages?: number;
641
+ signal?: AbortSignal;
642
+ }
643
+ /** Response shape for {@link LoomcycleClient.peekChannel}. */
644
+ export interface ChannelPeekResult {
645
+ channel: string;
646
+ messages: ChannelMessageItem[];
647
+ }
648
+ /** Options for {@link LoomcycleClient.ackChannel}. */
649
+ export interface AckChannelOptions {
650
+ scope: ChannelScope;
651
+ userId?: string;
652
+ cursor: string;
653
+ signal?: AbortSignal;
654
+ }
655
+ /** Response shape for {@link LoomcycleClient.ackChannel}. */
656
+ export interface ChannelAckResult {
657
+ ok: boolean;
658
+ }
659
+ /** One run state transition emitted by
660
+ * {@link LoomcycleClient.streamUserRunStates}. The TS field is RFC3339. */
661
+ export interface RunStateEvent {
662
+ run_id: string;
663
+ agent_id: string;
664
+ agent: string;
665
+ user_id: string;
666
+ parent_agent_id?: string;
667
+ status: string;
668
+ stop_reason?: string;
669
+ error?: string;
670
+ ts: string;
671
+ }
672
+ /** Initial stream_open frame emitted before the first run_state. */
673
+ export interface RunStateStreamOpen {
674
+ user_id: string;
675
+ filter_status: string[] | null;
676
+ filter_agent: string;
677
+ keepalive_interval: number;
678
+ }
679
+ /** Yielded by {@link LoomcycleClient.streamUserRunStates}.
680
+ *
681
+ * The first item is always `{ kind: "open", payload: RunStateStreamOpen }`.
682
+ * Subsequent items are `{ kind: "event", payload: RunStateEvent }`.
683
+ *
684
+ * Consumers branch on `kind`; the `open` frame is useful for confirming
685
+ * the connection before any real events flow. */
686
+ export type RunStateStreamItem = {
687
+ kind: "open";
688
+ payload: RunStateStreamOpen;
689
+ } | {
690
+ kind: "event";
691
+ payload: RunStateEvent;
692
+ } | {
693
+ kind: "close";
694
+ payload: RunStateStreamClose;
695
+ };
696
+ /** Optional filter for {@link LoomcycleClient.streamUserRunStates}. */
697
+ export interface StreamUserRunStatesOptions {
698
+ /** Subset of states to receive. Empty means all states. */
699
+ statuses?: string[];
700
+ /** Filter to one agent name. Empty means any. */
701
+ agent?: string;
702
+ /** v0.9.x — client-side filter on the run's parent_agent_id.
703
+ * Useful for "show me only the sub-runs spawned by agent X."
704
+ * The filter is applied AFTER the SSE frame is parsed, so this
705
+ * shrinks what your callback sees but doesn't reduce server-side
706
+ * load. Server-side filtering is a separate (future) request.
707
+ * Pass the empty string to opt out (default). */
708
+ parentAgentId?: string;
709
+ /** v0.9.x — opt-in observability: when true, the iterator yields a
710
+ * client-synthesized `{ kind: "close", payload: { reason } }` item
711
+ * when the stream ends (EOF, abort, or error). `reason` carries
712
+ * the cause ("eof" on clean close or an error class name like
713
+ * "AbortError" / "AuthError"). The opening `kind: "open"` frame
714
+ * that always appears first is server-emitted, not synthetic;
715
+ * `debug` has no effect on it. Default false leaves behaviour
716
+ * identical to v0.9.x earlier. */
717
+ debug?: boolean;
718
+ signal?: AbortSignal;
719
+ }
720
+ /** v0.9.x — close-event payload emitted only under
721
+ * {@link StreamUserRunStatesOptions.debug}. Synthetic; never on the
722
+ * wire. */
723
+ export interface RunStateStreamClose {
724
+ reason: string;
725
+ }
726
+ /** Response shape for `AgentDef set/fork/get/list` rows. Mirrors what
727
+ * the server-side rowResponseMap emits. The `content_sha256` field is
728
+ * the deterministic SHA-256 of the agent's content-bearing fields,
729
+ * prefixed `sha256:` (Docker image-digest convention). Empty on rows
730
+ * that pre-date v0.9.x and haven't been backfilled yet. */
731
+ export interface AgentDefRowResponse {
732
+ def_id: string;
733
+ name: string;
734
+ version: number;
735
+ parent_def_id?: string;
736
+ description?: string;
737
+ created_at: string;
738
+ created_by_agent_id?: string;
739
+ retired: boolean;
740
+ bootstrapped_from_static: boolean;
741
+ /** "sha256:" + 64 hex chars; empty for not-yet-backfilled rows. */
742
+ content_sha256?: string;
743
+ /** Only populated on `set` / `fork` responses (was the new row
744
+ * auto-promoted to active?). Absent on get/list. */
745
+ promoted?: boolean;
746
+ }
747
+ /** Response shape for `AgentDef verify`. Answers "is the supplied
748
+ * content_sha256 the active deployed version of this name?"
749
+ *
750
+ * - `matches: true` — caller's local hash matches the deployed
751
+ * active version; no push needed.
752
+ * - `matches: false` — bundle is out of sync; the operator should
753
+ * push a new version via `agentDef({op: "set",
754
+ * overlay: ...})`.
755
+ * - `deployed: false` — no active row exists for this name (no
756
+ * deployment yet). matches is always false. */
757
+ export interface AgentDefVerifyResult {
758
+ matches: boolean;
759
+ /** Deployed active row's hash; empty when not deployed. */
760
+ current_sha256: string;
761
+ /** Deployed active row's def_id; empty when not deployed. */
762
+ current_def_id: string;
763
+ /** Deployed active row's version; 0 when not deployed. */
764
+ version: number;
765
+ name: string;
766
+ /** True if an active row exists for this name. */
767
+ deployed: boolean;
768
+ }
769
+ /** Response shape for `SkillDef verify`. Same semantics as
770
+ * AgentDefVerifyResult; the per-skill content basis is just
771
+ * smaller (name + description + body + allowed_tools). */
772
+ export interface SkillDefVerifyResult {
773
+ matches: boolean;
774
+ current_sha256: string;
775
+ current_def_id: string;
776
+ version: number;
777
+ name: string;
778
+ deployed: boolean;
779
+ }
780
+ /** Response shape for `MCPServerDef set/fork/get/list` rows. Mirrors
781
+ * what the server-side rowResponseMap emits. `discovered_tools` is the
782
+ * cached tools/list snapshot — refreshed via the `rediscover` op; not
783
+ * part of the content_sha256 basis. */
784
+ export interface MCPServerDefRowResponse {
785
+ def_id: string;
786
+ name: string;
787
+ version: number;
788
+ parent_def_id?: string;
789
+ description?: string;
790
+ created_at: string;
791
+ created_by_agent_id?: string;
792
+ retired: boolean;
793
+ bootstrapped_from_static: boolean;
794
+ /** "sha256:" + 64 hex chars; empty for not-yet-backfilled rows. */
795
+ content_sha256?: string;
796
+ /** Only populated on `set` / `fork` responses (auto-promoted?). */
797
+ promoted?: boolean;
798
+ }
799
+ /** Response shape for `MCPServerDef verify`. Same semantics as
800
+ * AgentDefVerifyResult / SkillDefVerifyResult — answers "is the
801
+ * supplied content_sha256 the deployed active version of this name?" */
802
+ export interface MCPServerDefVerifyResult {
803
+ matches: boolean;
804
+ /** Deployed active row's hash; empty when not deployed. */
805
+ current_sha256: string;
806
+ /** Deployed active row's def_id; empty when not deployed. */
807
+ current_def_id: string;
808
+ /** Deployed active row's version; 0 when not deployed. */
809
+ version: number;
810
+ name: string;
811
+ /** True if an active row exists for this name. */
812
+ deployed: boolean;
813
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@loomcycle/client",
3
- "version": "0.9.1",
4
- "description": "TypeScript client for the loomcycle sidecar (HTTP+SSE). 29 methods covering run streaming, agent metadata, pause/resume/state, snapshot lifecycle, memory admin (incl. v0.9.0 Vector Memory embed_stats + reembed), interruption resolve, hook management, and v0.8.22 substrate admin (agentDef + skillDef). v0.9.1 adds typed UserInputPayload + SystemPromptPayload for the transcript event sidecar.",
3
+ "version": "0.9.2",
4
+ "description": "TypeScript client for the loomcycle sidecar (HTTP+SSE). 36 methods covering run streaming, agent metadata, pause/resume/state, snapshot lifecycle, memory admin (incl. v0.9.0 Vector Memory embed_stats + reembed), interruption resolve, hook management, v0.8.22 substrate admin (agentDef + skillDef), v0.9.x n8n Phase 0 (listChannels + streamUserRunStates — with debug-mode synthetic open/close meta-frames + client-side parentAgentId filter), v0.9.x Channel CRUD (publishChannel + subscribeChannel + peekChannel + ackChannel — admin scope=global + per-user scope=user surfaces), v0.9.x content_sha256 (AgentDefVerifyResult + SkillDefVerifyResult types for the bundle-vs-deployed comparison workflow), v0.9.1 transcript first-cycle (SystemPromptPayload + UserInputPayload), and v0.9.x dynamic MCP server registration (mcpServerDef + MCPServerDefVerifyResult — register HTTP/Streamable-HTTP MCP servers at runtime without yaml edits).",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
7
7
  "repository": {