@loomcycle/client 1.111.0 → 1.113.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -135,6 +135,10 @@ function runBody(opts) {
135
135
  body.idempotency_key = opts.idempotencyKey;
136
136
  if (opts.maxWallSeconds !== undefined)
137
137
  body.max_wall_seconds = opts.maxWallSeconds;
138
+ if (opts.admission !== undefined)
139
+ body.admission = opts.admission;
140
+ if (opts.retention !== undefined)
141
+ body.retention = opts.retention;
138
142
  if (opts.metadata !== undefined)
139
143
  body.metadata = opts.metadata;
140
144
  if (opts.runTimeoutSeconds !== undefined)
@@ -436,6 +440,23 @@ export class LoomcycleClient {
436
440
  async getEffectiveConfig(runId, opts) {
437
441
  return jsonFetch(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/effective-config`, opts);
438
442
  }
443
+ /** Which per-run overrides a run of `name` may set, for this caller — what a
444
+ * run form needs before it sends anything. Mirrors
445
+ * `GET /v1/_agentdef/{name}/overridable`.
446
+ *
447
+ * Folds the agent's `allow_overrides` with the caller's token overrides
448
+ * policy, through the same checks the run path refuses with; a refused field
449
+ * names which (`reason`). Overridable fields carry what the run path then
450
+ * accepts: `narrow_only`, `max`, and `values` for a closed set.
451
+ *
452
+ * `substrate:tenant` (a non-isolated member too). A token limited to certain
453
+ * agents may ask about those agents only. Raises {@link AgentNotFoundError}
454
+ * (404) for an unknown or another tenant's agent, and a 403
455
+ * `agent_not_allowed` for an agent outside the token's allowlist. */
456
+ async getAgentOverridable(name, opts) {
457
+ const q = opts?.tenant ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
458
+ return jsonFetch(this.ctx, `/v1/_agentdef/${encodeURIComponent(name)}/overridable${q}`, opts);
459
+ }
439
460
  /** Re-attach to a run's event stream by `run_id` (RFC AI), replaying from
440
461
  * `fromSeq` (default 0) then live-tailing — the gRPC-free twin of the Web
441
462
  * UI's resume-in-terminal. Mirrors `GET /v1/runs/{run_id}/stream`. The
@@ -598,6 +619,35 @@ export class LoomcycleClient {
598
619
  body.reason = opts.reason;
599
620
  return postJSON(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/compact`, body, opts);
600
621
  }
622
+ /** Permanently delete a run and every run it spawned, with what is stored
623
+ * about them: transcripts, their sessions when no other run is left in
624
+ * them, interrupts, evaluations of them, queued memory rows and indexed
625
+ * turns. The usage ledger (tokens, cost) is kept. Mirrors
626
+ * `DELETE /v1/runs/{run_id}/tree`.
627
+ *
628
+ * Needs the `runs:delete` scope (an admin and a tenant operator have it;
629
+ * any other token only if it was granted at mint) — a token without it
630
+ * raises `LoomcycleError` with status 403. A run that does not exist, was
631
+ * already deleted, or is not yours to read resolves with `deleted_runs: 0`
632
+ * rather than raising, so repeating the call is safe. A tree holding a run
633
+ * that is still running, awaiting input or paused deletes nothing and
634
+ * raises `LoomcycleError` with `code: "run_tree_live"` (status 409): cancel
635
+ * it first. `not_reached` names where a copy may remain that this call
636
+ * cannot remove. To discard a configured run that never started, use
637
+ * {@link LoomcycleClient.deleteConfiguredRun}. */
638
+ async deleteRunTree(runId, opts) {
639
+ return deleteJSON(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/tree`, opts);
640
+ }
641
+ /** Permanently delete every run of a session, each with the runs it
642
+ * spawned — {@link LoomcycleClient.deleteRunTree} for all of them at once.
643
+ * `cascade: "children"` is required and is the only mode: there is no
644
+ * delete that leaves sub-agent runs behind. Mirrors
645
+ * `DELETE /v1/sessions/{session_id}?cascade=children`. Same scope, same
646
+ * zero-count answer for a session you cannot read, same `run_tree_live`
647
+ * refusal. */
648
+ async deleteSession(sessionId, opts) {
649
+ return deleteJSON(this.ctx, `/v1/sessions/${encodeURIComponent(sessionId)}?cascade=${encodeURIComponent(opts.cascade)}`, opts);
650
+ }
601
651
  /**
602
652
  * Replay a session's transcript into a NEW session bound to a (possibly
603
653
  * different) target agent, so that agent continues from the same context
@@ -675,8 +725,14 @@ export class LoomcycleClient {
675
725
  const resp = await postJSON(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}/cancel`, { reason: opts?.reason ?? "" }, opts);
676
726
  return { cancelledCount: resp.cancelled_count };
677
727
  }
678
- /** List a user's recent agent runs, optionally filtered by status. With no
679
- * `status` only running runs are returned; `"all"` returns every status.
728
+ /** List a user's recent agent runs (at most 100), optionally filtered by
729
+ * status. With no `status` only running runs are returned; `"all"` returns
730
+ * every status; several statuses list a run in any of them.
731
+ *
732
+ * `awaitedState` lists only running runs blocked on one of the named waits
733
+ * (`"review"`, `"input"`, …). The state is derived per run, so the server
734
+ * reads at most 1,000 running runs; the returned array carries
735
+ * `truncated: true` when it stopped with some unread.
680
736
  *
681
737
  * v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
682
738
  * whose `parent_agent_id` matches. The server still returns the
@@ -685,17 +741,21 @@ export class LoomcycleClient {
685
741
  * n8n trigger pattern "show me all sub-runs spawned by parent X." */
686
742
  async listUserAgents(userId, opts) {
687
743
  const params = new URLSearchParams();
688
- if (opts?.status)
689
- params.set("status", opts.status);
744
+ for (const s of [].concat(opts?.status ?? []))
745
+ params.append("status", s);
746
+ for (const s of [].concat(opts?.awaitedState ?? []))
747
+ params.append("awaited_state", s);
690
748
  if (opts?.tenant)
691
749
  params.set("tenant", opts.tenant);
692
750
  const q = params.toString() ? `?${params.toString()}` : "";
693
751
  const resp = await jsonFetch(this.ctx, `/v1/users/${encodeURIComponent(userId)}/agents${q}`, opts);
694
- const all = resp.agents ?? [];
752
+ let out = resp.agents ?? [];
695
753
  if (opts?.parentAgentId !== undefined && opts.parentAgentId !== "") {
696
- return all.filter((a) => a.parent_agent_id === opts.parentAgentId);
754
+ out = out.filter((a) => a.parent_agent_id === opts.parentAgentId);
697
755
  }
698
- return all;
756
+ if (resp.truncated)
757
+ out.truncated = true;
758
+ return out;
699
759
  }
700
760
  /** One page of a team walk's runs — the walk's own run (its run id IS the
701
761
  * walk id) and every member it spawned, oldest first. The walk's own row
@@ -787,6 +847,36 @@ export class LoomcycleClient {
787
847
  params.set("tenant", opts.tenant);
788
848
  return deleteRequest(this.ctx, `/v1/_limits?${params.toString()}`, opts);
789
849
  }
850
+ /** The timed no-trace overrides in force: agents whose runs are being
851
+ * stored although they normally store none. An admin sees every tenant's
852
+ * (or one with `tenant`); a tenant operator sees its own. Mirrors
853
+ * `GET /v1/_retention/overrides`. */
854
+ async listRetentionOverrides(opts) {
855
+ const q = opts?.tenant ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
856
+ return jsonFetch(this.ctx, `/v1/_retention/overrides${q}`, opts);
857
+ }
858
+ /** Store the runs of one no-trace agent until `until`, to debug it. At
859
+ * most 24 hours ahead; setting one replaces the last. It lifts a mode
860
+ * that came from the agent's definition or the run request, never one a
861
+ * token requires. Audited. `tenant` is admin-only: a tenant operator's
862
+ * override is always for its own tenant. Mirrors
863
+ * `PUT /v1/_retention/overrides`. */
864
+ async setRetentionOverride(body, opts) {
865
+ const until = body.until instanceof Date ? body.until.toISOString() : body.until;
866
+ const wire = { agent: body.agent, until };
867
+ if (body.tenant !== undefined)
868
+ wire.tenant = body.tenant;
869
+ return putJSON(this.ctx, "/v1/_retention/overrides", wire, opts);
870
+ }
871
+ /** End an agent's no-trace override before it runs out. Clearing one that
872
+ * is not there is not an error. Mirrors
873
+ * `DELETE /v1/_retention/overrides`. */
874
+ async clearRetentionOverride(agent, opts) {
875
+ const params = new URLSearchParams({ agent });
876
+ if (opts?.tenant)
877
+ params.set("tenant", opts.tenant);
878
+ return deleteRequest(this.ctx, `/v1/_retention/overrides?${params.toString()}`, opts);
879
+ }
790
880
  /** Ask a decision model typed questions (`choice` / `noul` / `score`) about
791
881
  * `state` and get each answer with its probabilities. A decision model does
792
882
  * not write: there is no reply to parse and the call costs a few output
@@ -866,9 +956,51 @@ export class LoomcycleClient {
866
956
  * scopes are DERIVED server-side from the member's access_mode (never sent),
867
957
  * and the plaintext `token` is returned exactly ONCE — store it now, it is
868
958
  * never retrievable again. 404 for an unknown/cross-tenant subject; 409 for
869
- * a disabled user. */
959
+ * a disabled user.
960
+ *
961
+ * `ttlSeconds` gives the token a fixed lifetime: it stops authenticating
962
+ * when that runs out, and the answer carries `expires_at`. It cannot run
963
+ * past the end of the token making the call. `name` labels it (stored as
964
+ * `u-<name>`).
965
+ * `maxActive` keeps at most that many of the user's tokens alive — the
966
+ * oldest beyond it are retired at once and listed in `retired`.
967
+ *
968
+ * `agents` limits the token to starting those agents as top-level runs, and
969
+ * to the run endpoints: it is refused everywhere else (403
970
+ * `restricted_token`), and refused any other agent (403
971
+ * `agent_not_allowed`). An agent that a listed one spawns is not limited.
972
+ * An empty list is refused; omit the option for no limit.
973
+ *
974
+ * `overrides` locks the per-run settings for runs the token starts, and
975
+ * for retunes it makes: `"none"` locks them all (model, provider, tier,
976
+ * effort, sampling, limits, output format and the rest), a list names the
977
+ * ones it may still set. A request that sets a locked one is refused with
978
+ * 403 `override_not_allowed`.
979
+ *
980
+ * `rate` bounds how often the token may start runs: `perMinute` runs a
981
+ * minute, with `burst` allowed at once from idle (default: `perMinute`).
982
+ * A start past the rate is refused with 429 `rate_limited` and a
983
+ * `Retry-After` header.
984
+ *
985
+ * With none of the options the request has no body, as before. */
870
986
  async mintUserToken(subject, opts) {
871
- return postJSON(this.ctx, `/v1/_users/${encodeURIComponent(subject)}/tokens`, undefined, opts);
987
+ const body = {};
988
+ if (opts?.ttlSeconds !== undefined)
989
+ body.ttl_seconds = opts.ttlSeconds;
990
+ if (opts?.name !== undefined)
991
+ body.name = opts.name;
992
+ if (opts?.maxActive !== undefined)
993
+ body.max_active = opts.maxActive;
994
+ if (opts?.agents !== undefined)
995
+ body.agents = opts.agents;
996
+ if (opts?.overrides !== undefined)
997
+ body.overrides = opts.overrides;
998
+ if (opts?.rate !== undefined) {
999
+ body.rate = { per_minute: opts.rate.perMinute, burst: opts.rate.burst };
1000
+ }
1001
+ if (opts?.retention !== undefined)
1002
+ body.retention = opts.retention;
1003
+ return postJSON(this.ctx, `/v1/_users/${encodeURIComponent(subject)}/tokens`, Object.keys(body).length > 0 ? body : undefined, { signal: opts?.signal });
872
1004
  }
873
1005
  /** List the caller's own user's delegated tokens — METADATA ONLY (never the
874
1006
  * plaintext or hash). `active` applies the auth-layer validity rule so a
@@ -928,6 +1060,38 @@ export class LoomcycleClient {
928
1060
  async resolveProbe(opts) {
929
1061
  return postJSON(this.ctx, "/v1/_resolve/probe", undefined, opts);
930
1062
  }
1063
+ /** The configured model aliases — the operator's top-level `models:` map,
1064
+ * alias name → `{provider, model, kind?}` (`GET /v1/_models`). Non-secret and
1065
+ * global (the same set for every tenant); tenant-readable. Store an alias on
1066
+ * an agent or fork rather than the concrete model it names, and the agent
1067
+ * follows when the operator retargets the alias. */
1068
+ async listModels(opts) {
1069
+ return jsonFetch(this.ctx, "/v1/_models", opts);
1070
+ }
1071
+ /** For each user tier × tier, the ordered provider/model cascade a run
1072
+ * resolves to now, top first, with live availability and the candidate
1073
+ * `selected` (`GET /v1/_routing`). Tenant-readable. A non-admin caller gets
1074
+ * `admin: false`, no raw `last_error`, and — when the operator-key gate
1075
+ * restricts it — only the providers it can key itself
1076
+ * (`operator_key_restricted: true`). */
1077
+ async getRouting(opts) {
1078
+ return jsonFetch(this.ctx, "/v1/_routing", opts);
1079
+ }
1080
+ /** List the models a provider serves right now, by a live round-trip to it
1081
+ * (`GET /v1/_providers/{id}/models`) rather than the cached probe matrix.
1082
+ *
1083
+ * **Requires an admin token.** A `substrate:tenant` token is refused with a
1084
+ * {@link LoomcycleError} whose `status` is 403 ("insufficient scope"); a
1085
+ * model picker built on a tenant token should fall back to
1086
+ * {@link LoomcycleClient.listModels} plus free-text model entry.
1087
+ *
1088
+ * Errors carry the server's code on `e.code`: 404 `provider_unknown` (no such
1089
+ * driver), 503 `provider_unavailable` (known, but not configured — e.g. no
1090
+ * key), 502 `provider_list_failed` (the round-trip failed; bounded at 10 s
1091
+ * server-side). */
1092
+ async listProviderModels(providerId, opts) {
1093
+ return jsonFetch(this.ctx, `/v1/_providers/${encodeURIComponent(providerId)}/models`, opts);
1094
+ }
931
1095
  // ---- Snapshot lifecycle ----
932
1096
  /** Capture running-state into a per-section-semver JSON envelope.
933
1097
  * Raises SnapshotTooLargeError on 413 when the envelope exceeds
@@ -1381,10 +1545,33 @@ export class LoomcycleClient {
1381
1545
  * Raises {@link SubstrateToolRefusedError} on tool-level refusals
1382
1546
  * (static-name collision, missing required credential, invalid
1383
1547
  * cron, etc.); {@link InvalidArgumentError} on 400 (malformed
1384
- * JSON); {@link AuthError} on 401. */
1548
+ * JSON); {@link AuthError} on 401.
1549
+ *
1550
+ * Annotate the input as {@link ScheduleDefInput} to have the overlay and
1551
+ * each op's fields checked (including `add_hook` / `remove_hook`, which the
1552
+ * loose {@link SubstrateToolInput} does not list); a loose input is sent
1553
+ * as it is. For create / fork, {@link LoomcycleClient.createScheduleDef} /
1554
+ * {@link LoomcycleClient.forkScheduleDef} check a bare literal too. */
1385
1555
  async scheduleDef(input, opts) {
1386
1556
  return postJSON(this.ctx, "/v1/_scheduledef", input, opts);
1387
1557
  }
1558
+ /** `scheduleDef({op: "create"})` with the overlay typed and no loose
1559
+ * fallback. {@link ScheduleDefOverlay} stays OPEN (the server drops keys it
1560
+ * does not know), so a misspelt key still compiles here; what fails is a
1561
+ * wrong type or value on a key it declares (`concurrency_policy: "skip"`,
1562
+ * an `on_complete` entry missing its `tool`). New versions are promoted
1563
+ * unless `promote: false`. */
1564
+ async createScheduleDef(name, overlay, opts) {
1565
+ return (await this.scheduleDef(defWriteInput("create", name, overlay, opts), signalOpts(opts)));
1566
+ }
1567
+ /** `scheduleDef({op: "fork"})`, typed like
1568
+ * {@link LoomcycleClient.createScheduleDef}. A field the overlay leaves out
1569
+ * keeps the parent's; the credential maps merge key by key. Forks the
1570
+ * active version of `name` (or its yaml template) unless `parentDefId` is
1571
+ * given; promoted unless `promote: false`. */
1572
+ async forkScheduleDef(name, overlay, opts) {
1573
+ return (await this.scheduleDef(defWriteInput("fork", name, overlay, opts), signalOpts(opts)));
1574
+ }
1388
1575
  /** Invoke the v1.x RFC G A2AServerCardDef substrate tool over HTTP.
1389
1576
  * Author + fork + retire A2A server-card definitions at runtime.
1390
1577
  * Mirror of {@link LoomcycleClient.scheduleDef} for A2A server cards.
@@ -1426,10 +1613,33 @@ export class LoomcycleClient {
1426
1613
  *
1427
1614
  * Raises {@link SubstrateToolRefusedError} on tool-level refusals;
1428
1615
  * {@link InvalidArgumentError} on 400 (malformed JSON);
1429
- * {@link AuthError} on 401. */
1616
+ * {@link AuthError} on 401.
1617
+ *
1618
+ * Annotate the input as {@link WebhookDefInput} to have the overlay and
1619
+ * each op's fields checked; a loose input is sent as it is. For create /
1620
+ * fork, {@link LoomcycleClient.createWebhookDef} /
1621
+ * {@link LoomcycleClient.forkWebhookDef} check a bare literal too. */
1430
1622
  async webhookDef(input, opts) {
1431
1623
  return postJSON(this.ctx, "/v1/_webhookdef", input, opts);
1432
1624
  }
1625
+ /** `webhookDef({op: "create"})` with the overlay typed and no loose
1626
+ * fallback. {@link WebhookDefOverlay} stays OPEN for now (the server drops
1627
+ * keys it does not know), so a misspelt key still compiles here; what
1628
+ * fails is a wrong type or value on a key it declares (`delivery: "run"`,
1629
+ * `auth.kind: "basic"`, a misspelt key inside `rate_limit`). Promoted
1630
+ * unless `promote: false`. */
1631
+ async createWebhookDef(name, overlay, opts) {
1632
+ return (await this.webhookDef(defWriteInput("create", name, overlay, opts), signalOpts(opts)));
1633
+ }
1634
+ /** `webhookDef({op: "fork"})`, typed like
1635
+ * {@link LoomcycleClient.createWebhookDef}. A field the overlay leaves out
1636
+ * keeps the parent's; naming exactly one of `agent` / `channel` / `team`
1637
+ * clears the other two. Forks the active version of `name` (or its yaml
1638
+ * template) unless `parentDefId` is given; promoted unless
1639
+ * `promote: false`. */
1640
+ async forkWebhookDef(name, overlay, opts) {
1641
+ return (await this.webhookDef(defWriteInput("fork", name, overlay, opts), signalOpts(opts)));
1642
+ }
1433
1643
  /** Invoke the RFC I MR-3a MemoryBackendDef substrate tool over HTTP.
1434
1644
  * Author + fork + retire named memory backend definitions at runtime.
1435
1645
  * Mirror of {@link LoomcycleClient.webhookDef} for memory backends.
@@ -1458,10 +1668,71 @@ export class LoomcycleClient {
1458
1668
  * matches, its body (code-js or a webhook URL), fail mode and timeout.
1459
1669
  * Tenant-confined like agentDef. A HookDef fires on nothing by itself; an
1460
1670
  * agent definition, a team definition or a run names it. Mirrors
1461
- * POST /v1/_hookdef. */
1671
+ * POST /v1/_hookdef.
1672
+ *
1673
+ * Annotate the input as {@link HookDefInput} to have the overlay checked
1674
+ * against the definition the server decodes STRICTLY — an unknown overlay
1675
+ * key is then a compile error instead of a refusal. A loose
1676
+ * {@link SubstrateToolInput} is sent as it is — including a bare literal
1677
+ * with a misspelt key, which falls through to that loose shape. To have a
1678
+ * bare literal checked, use {@link LoomcycleClient.createHookDef} /
1679
+ * {@link LoomcycleClient.forkHookDef}. */
1462
1680
  async hookDef(input, opts) {
1463
1681
  return postJSON(this.ctx, "/v1/_hookdef", input, opts);
1464
1682
  }
1683
+ /** `hookDef({op: "create"})` with no loose fallback: `overlay` is the
1684
+ * CLOSED {@link HookDefOverlay}, so any key the server would refuse is a
1685
+ * compile error, even in a bare literal. Every create mints a new version
1686
+ * (use {@link LoomcycleClient.ensureHookDef} to register only on change);
1687
+ * promoted unless `promote: false`. */
1688
+ async createHookDef(name, overlay, opts) {
1689
+ return (await this.hookDef(defWriteInput("create", name, overlay, opts), signalOpts(opts)));
1690
+ }
1691
+ /** `hookDef({op: "fork"})`, closed like
1692
+ * {@link LoomcycleClient.createHookDef}. Each top-level field given replaces
1693
+ * the parent's; `null` clears one. Forks the active version of `name` in
1694
+ * your tenant unless `parentDefId` is given. NOT promoted unless
1695
+ * `promote: true` — authoring and putting in force are two steps. */
1696
+ async forkHookDef(name, overlay, opts) {
1697
+ return (await this.hookDef(defWriteInput("fork", name, overlay, opts), signalOpts(opts)));
1698
+ }
1699
+ /** Register a HookDef idempotently — the register-on-every-boot
1700
+ * convenience, sibling of {@link LoomcycleClient.ensureMcpServer}.
1701
+ *
1702
+ * Unlike MCPServerDef and AgentDef, a HookDef `create` is NOT
1703
+ * content-addressed: every create mints a new version. And the hash
1704
+ * `verify` compares is computed over the server's own JSON encoding of the
1705
+ * definition, which this client cannot reproduce byte for byte. So instead
1706
+ * this reads the active version (`verify` for its def_id — it answers
1707
+ * `deployed: false` rather than raising — then `get`) and compares its
1708
+ * stored definition with `overlay`, both normalized the way the server
1709
+ * normalizes (`fail_mode` defaults to `"open"`, an empty `match.tools`,
1710
+ * description, header map or timeout is dropped). Equal → no write,
1711
+ * `changed: false`. Otherwise `create` with the default promote, which
1712
+ * makes the new version active.
1713
+ *
1714
+ * The normalization only collapses what the server collapses, so a real
1715
+ * change is never skipped; should the server come to normalize something
1716
+ * more, the cost is a redundant version, not a lost one. The rationale
1717
+ * in {@link EnsureHookDefOptions.description} is not part of the
1718
+ * definition and does not count as a change. */
1719
+ async ensureHookDef(name, overlay, opts) {
1720
+ const callOpts = signalOpts(opts);
1721
+ const verified = (await this.hookDef({ op: "verify", name }, callOpts));
1722
+ if (verified.deployed && verified.current_def_id) {
1723
+ // By def_id, not name: the version verify reported, even if another
1724
+ // writer promotes a different one in between.
1725
+ const active = (await this.hookDef({ op: "get", def_id: verified.current_def_id }, callOpts));
1726
+ if (sameJSON(normalizeHookDef(active.definition), normalizeHookDef(overlay))) {
1727
+ return { name, defId: active.def_id, version: active.version, changed: false };
1728
+ }
1729
+ }
1730
+ const input = { op: "create", name, overlay };
1731
+ if (opts?.description)
1732
+ input.description = opts.description;
1733
+ const row = (await this.hookDef(input, callOpts));
1734
+ return { name, defId: row.def_id, version: row.version, changed: true };
1735
+ }
1465
1736
  /** OperatorTokenDef substrate ops (create/rotate/retire/get/list) —
1466
1737
  * RFC L OSS multi-tenant authorization. Operator-admin only. The
1467
1738
  * token plaintext is returned ONCE in the create/rotate response.
@@ -1492,6 +1763,109 @@ export class LoomcycleClient {
1492
1763
  async volumeDef(input, opts) {
1493
1764
  return postJSON(this.ctx, "/v1/_volumedef", input, opts);
1494
1765
  }
1766
+ // ---- Def name enumeration (GET /v1/_<family>def/names) ----
1767
+ //
1768
+ // Every name declared in one substrate family, each with its version
1769
+ // roll-up — what the op-dispatched `list` op cannot give (it lists the
1770
+ // versions of ONE name). Tenant-scoped server-side: a substrate:tenant token
1771
+ // sees only its own tenant's names; an admin sees all, or focuses one tenant
1772
+ // with `tenant` (ignored for a tenant token). The TeamDef family is
1773
+ // {@link LoomcycleClient.listTeams}.
1774
+ listDefNames(family, opts) {
1775
+ const q = opts?.tenant ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
1776
+ return jsonFetch(this.ctx, `/v1/_${family}/names${q}`, opts);
1777
+ }
1778
+ /** Every AgentDef name (`GET /v1/_agentdef/names`). */
1779
+ async listAgentDefNames(opts) {
1780
+ return this.listDefNames("agentdef", opts);
1781
+ }
1782
+ /** Every SkillDef name (`GET /v1/_skilldef/names`). */
1783
+ async listSkillDefNames(opts) {
1784
+ return this.listDefNames("skilldef", opts);
1785
+ }
1786
+ /** Every HookDef name (`GET /v1/_hookdef/names`). */
1787
+ async listHookDefNames(opts) {
1788
+ return this.listDefNames("hookdef", opts);
1789
+ }
1790
+ /** Every MCPServerDef name (`GET /v1/_mcpserverdef/names`). */
1791
+ async listMcpServerDefNames(opts) {
1792
+ return this.listDefNames("mcpserverdef", opts);
1793
+ }
1794
+ /** Every ScheduleDef name (`GET /v1/_scheduledef/names`). Substrate only —
1795
+ * {@link LoomcycleClient.listAllSchedules} merges in the static yaml ones. */
1796
+ async listScheduleDefNames(opts) {
1797
+ return this.listDefNames("scheduledef", opts);
1798
+ }
1799
+ /** Every A2AServerCardDef name (`GET /v1/_a2aservercarddef/names`). */
1800
+ async listA2AServerCardDefNames(opts) {
1801
+ return this.listDefNames("a2aservercarddef", opts);
1802
+ }
1803
+ /** Every A2AAgentDef name (`GET /v1/_a2aagentdef/names`). */
1804
+ async listA2AAgentDefNames(opts) {
1805
+ return this.listDefNames("a2aagentdef", opts);
1806
+ }
1807
+ /** Every WebhookDef name (`GET /v1/_webhookdef/names`). */
1808
+ async listWebhookDefNames(opts) {
1809
+ return this.listDefNames("webhookdef", opts);
1810
+ }
1811
+ /** Every MemoryBackendDef name (`GET /v1/_memorybackenddef/names`). */
1812
+ async listMemoryBackendDefNames(opts) {
1813
+ return this.listDefNames("memorybackenddef", opts);
1814
+ }
1815
+ /** Every DocumentSourceDef name (`GET /v1/_documentsourcedef/names`). */
1816
+ async listDocumentSourceDefNames(opts) {
1817
+ return this.listDefNames("documentsourcedef", opts);
1818
+ }
1819
+ /** Every OperatorTokenDef name (`GET /v1/_operatortokendef/names`), with its
1820
+ * subject and token counts — never a token. **Admin token only**: token
1821
+ * minting stays operator-only, so a `substrate:tenant` token is refused
1822
+ * with 403. */
1823
+ async listOperatorTokenDefNames(opts) {
1824
+ return this.listDefNames("operatortokendef", opts);
1825
+ }
1826
+ // ---- Schedules admin (GET/POST /v1/_schedules/...) ----
1827
+ //
1828
+ // What the Web UI's schedules page drives: the merged list, one schedule's
1829
+ // runtime state, and run-now / pause / resume. Authoring stays on
1830
+ // {@link LoomcycleClient.scheduleDef}. The per-schedule methods take the
1831
+ // ScheduleDef's `active_def_id`; a static yaml schedule has none to give, and
1832
+ // a tenant token reaching another tenant's def gets the same 404 as an
1833
+ // unknown one ({@link NotFoundError}).
1834
+ /** Every schedule: static yaml `scheduled_runs` and ScheduleDefs, merged by
1835
+ * name (`GET /v1/_schedules/list-all`), sorted by name. A tenant token sees
1836
+ * only its own tenant's ScheduleDefs — static schedules are operator
1837
+ * automation and appear only for an admin, who may focus one tenant with
1838
+ * `tenant`. */
1839
+ async listAllSchedules(opts) {
1840
+ const q = opts?.tenant ? `?tenant=${encodeURIComponent(opts.tenant)}` : "";
1841
+ return jsonFetch(this.ctx, `/v1/_schedules/list-all${q}`, opts);
1842
+ }
1843
+ /** One schedule's runtime state (`GET /v1/_schedules/{def_id}/state`): last
1844
+ * and next fire, pause, the last slot claim, the catch-up backlog and the
1845
+ * runs it has in flight. 404 when the def has no state row (never promoted,
1846
+ * retired, or not yours). */
1847
+ async getScheduleState(defId, opts) {
1848
+ return jsonFetch(this.ctx, `/v1/_schedules/${encodeURIComponent(defId)}/state`, opts);
1849
+ }
1850
+ /** Fire a schedule now (`POST /v1/_schedules/{def_id}/run-now`). The run
1851
+ * starts on the scheduler's next tick, not during this call, and the regular
1852
+ * cadence continues after it. A run already in flight is handled by the
1853
+ * schedule's `concurrency_policy`. 404 for an unknown def. */
1854
+ async runScheduleNow(defId, opts) {
1855
+ return postJSON(this.ctx, `/v1/_schedules/${encodeURIComponent(defId)}/run-now`, undefined, opts);
1856
+ }
1857
+ /** Pause a schedule indefinitely (`POST /v1/_schedules/{def_id}/pause`) until
1858
+ * {@link LoomcycleClient.resumeSchedule}. Takes no duration: the server sets
1859
+ * `paused_until` about 100 years ahead. Distinct from
1860
+ * {@link LoomcycleClient.pauseRuntime}, which stops the whole runtime. */
1861
+ async pauseSchedule(defId, opts) {
1862
+ return postJSON(this.ctx, `/v1/_schedules/${encodeURIComponent(defId)}/pause`, undefined, opts);
1863
+ }
1864
+ /** Clear a schedule's pause (`POST /v1/_schedules/{def_id}/resume`), so the
1865
+ * scheduler considers it due again; `next_run_at` is left as it was. */
1866
+ async resumeSchedule(defId, opts) {
1867
+ return postJSON(this.ctx, `/v1/_schedules/${encodeURIComponent(defId)}/resume`, undefined, opts);
1868
+ }
1495
1869
  // ---- RFC AP Agent Teams (TeamDef substrate) ----
1496
1870
  //
1497
1871
  // TeamDefs are agent-team workflow graphs (states + transitions + a per-state
@@ -1520,15 +1894,37 @@ export class LoomcycleClient {
1520
1894
  }
1521
1895
  /** Author a new TeamDef (op=create). `overlay` is the workflow graph
1522
1896
  * (`{entry, states, transitions, max_iterations?, colors?}`), validated
1523
- * server-side — an invalid graph raises {@link SubstrateToolRefusedError}. */
1897
+ * server-side — an invalid graph raises {@link SubstrateToolRefusedError}.
1898
+ *
1899
+ * `opts.promote` and `opts.description` ride beside `signal`, as on
1900
+ * {@link LoomcycleClient.forkTeam}; each is sent only when set. Omitted,
1901
+ * the server promotes a create (unlike a fork); `promote: false` authors the
1902
+ * version without putting it in force. */
1524
1903
  async createTeam(name, overlay, opts) {
1525
- return postJSON(this.ctx, "/v1/_teamdef", { op: "create", name, overlay }, opts);
1904
+ const body = { op: "create", name, overlay };
1905
+ if (opts?.promote !== undefined)
1906
+ body.promote = opts.promote;
1907
+ if (opts?.description !== undefined)
1908
+ body.description = opts.description;
1909
+ return postJSON(this.ctx, "/v1/_teamdef", body, { signal: opts?.signal });
1526
1910
  }
1527
1911
  /** Save an edited graph as a NEW version of an existing team (op=fork). With
1528
- * no parent the server forks the name's active def; `overlay` (a full graph)
1529
- * replaces it. Validated server-side ({@link SubstrateToolRefusedError}). */
1912
+ * no `parentDefId` the server forks the name's active def; `overlay` (a full
1913
+ * graph) replaces it. Validated server-side ({@link SubstrateToolRefusedError}).
1914
+ *
1915
+ * The fork's own fields ride in `opts` beside `signal` — the pattern
1916
+ * {@link LoomcycleClient.setRunBreakpoints} uses — so existing two- and
1917
+ * three-argument calls are unchanged. `promote: true` puts the new version
1918
+ * in force in the same call; omitted, it is authored but not active. */
1530
1919
  async forkTeam(name, overlay, opts) {
1531
- return postJSON(this.ctx, "/v1/_teamdef", { op: "fork", name, overlay }, opts);
1920
+ const body = { op: "fork", name, overlay };
1921
+ if (opts?.promote !== undefined)
1922
+ body.promote = opts.promote;
1923
+ if (opts?.parentDefId !== undefined)
1924
+ body.parent_def_id = opts.parentDefId;
1925
+ if (opts?.description !== undefined)
1926
+ body.description = opts.description;
1927
+ return postJSON(this.ctx, "/v1/_teamdef", body, { signal: opts?.signal });
1532
1928
  }
1533
1929
  /** List every version of ONE team, newest first (op=list) — the lineage
1534
1930
  * behind {@link LoomcycleClient.listTeams}' roll-up. Tenant-scoped
@@ -1600,6 +1996,8 @@ export class LoomcycleClient {
1600
1996
  body.review_ttl_seconds = target.reviewTtlSeconds;
1601
1997
  if (target.idempotencyKey !== undefined)
1602
1998
  body.idempotency_key = target.idempotencyKey;
1999
+ if (target.interruptOnCap !== undefined)
2000
+ body.interrupt_on_cap = target.interruptOnCap;
1603
2001
  return postJSON(this.ctx, "/v1/_teamdef", body, opts);
1604
2002
  }
1605
2003
  /** Read the debug breakpoints armed on a live team walk
@@ -1751,6 +2149,48 @@ export class LoomcycleClient {
1751
2149
  async history(input, opts) {
1752
2150
  return postJSON(this.ctx, "/v1/_history", input, opts);
1753
2151
  }
2152
+ async evaluation(input, opts) {
2153
+ return postJSON(this.ctx, "/v1/_evaluation", input, opts);
2154
+ }
2155
+ /** Record a score against a run — `evaluation({op:"submit"})`. Scores are
2156
+ * additive: a second submit records another evaluation. */
2157
+ async submitEvaluation(runId, score, opts) {
2158
+ const input = { op: "submit", run_id: runId, score };
2159
+ if (opts?.dimensions !== undefined)
2160
+ input.dimensions = opts.dimensions;
2161
+ if (opts?.judgement !== undefined)
2162
+ input.judgement = opts.judgement;
2163
+ if (opts?.rationale !== undefined)
2164
+ input.rationale = opts.rationale;
2165
+ return this.evaluation(input, { signal: opts?.signal });
2166
+ }
2167
+ /** One evaluation by id — `evaluation({op:"get"})`. */
2168
+ async getEvaluation(evalId, opts) {
2169
+ return this.evaluation({ op: "get", eval_id: evalId }, opts);
2170
+ }
2171
+ /** Every evaluation of one run — `evaluation({op:"list_for_run"})`. */
2172
+ async listEvaluationsForRun(runId, opts) {
2173
+ const input = { op: "list_for_run", run_id: runId };
2174
+ if (opts?.limit !== undefined)
2175
+ input.limit = opts.limit;
2176
+ return this.evaluation(input, { signal: opts?.signal });
2177
+ }
2178
+ /** Every evaluation of one definition version, in your tenant —
2179
+ * `evaluation({op:"list_for_def"})`. */
2180
+ async listEvaluationsForDef(defId, opts) {
2181
+ const input = { op: "list_for_def", def_id: defId };
2182
+ if (opts?.limit !== undefined)
2183
+ input.limit = opts.limit;
2184
+ return this.evaluation(input, { signal: opts?.signal });
2185
+ }
2186
+ /** A definition's scores rolled up — `evaluation({op:"aggregate"})`.
2187
+ * `includeLineage` adds the ancestors' evaluations along parent_def_id. */
2188
+ async aggregateEvaluations(defId, opts) {
2189
+ const input = { op: "aggregate", def_id: defId };
2190
+ if (opts?.includeLineage !== undefined)
2191
+ input.include_lineage = opts.includeLineage;
2192
+ return this.evaluation(input, { signal: opts?.signal });
2193
+ }
1754
2194
  /** List the PERSISTENT volume universe for the caller's tenant — every
1755
2195
  * static volume (the shared bind floor, `source: "static"`, read-only)
1756
2196
  * plus the tenant's own dynamic VolumeDefs (`source: "dynamic"`). Host
@@ -2307,11 +2747,11 @@ export class LoomcycleClient {
2307
2747
  *
2308
2748
  * v1.78.0 — `walkId` is a SERVER-side filter: only the runs one team
2309
2749
  * walk spawned. A walk's own run_id IS its walk id, so a caller that
2310
- * started a team with `detach: true` passes back the handle it already
2750
+ * started a team with `mode: "detach"` passes back the handle it already
2311
2751
  * holds and gets a live view of that workflow's agents:
2312
2752
  *
2313
2753
  * ```ts
2314
- * const { run_id } = await client.runTeam({ team: "triage", detach: true });
2754
+ * const { run_id } = await client.runTeam({ name: "triage", mode: "detach" });
2315
2755
  * for await (const item of client.streamUserRunStates(userId, { walkId: run_id })) {
2316
2756
  * if (item.kind === "event") place(item.payload.parent_context?.wave_index);
2317
2757
  * }
@@ -2393,6 +2833,61 @@ export class LoomcycleClient {
2393
2833
  yield { kind: "close", payload: { reason: closeReason } };
2394
2834
  }
2395
2835
  }
2836
+ /** Tail the caller's tenant's MEMORY change feed (RFC CD Part C) — one
2837
+ * value-free coordinate per `memory.set` / `memory.delete` /
2838
+ * `memory.scope_deleted`. Mirrors `GET /v1/_memory/changes?since=<seq>`.
2839
+ *
2840
+ * The first item is always `{ kind: "feed", payload: ChangeFeedStatus }`,
2841
+ * then one `{ kind: "change", payload: MemoryChange }` per change, oldest
2842
+ * first. Pull the current value through the data API; the feed never
2843
+ * carries it.
2844
+ *
2845
+ * - The feed is EMPTY unless the server runs with
2846
+ * `LOOMCYCLE_MEMORY_CHANGES_ENABLED`. The stream still opens and idles,
2847
+ * so read `payload.enabled` on the feed frame instead of waiting.
2848
+ * - Scope is the caller's OWN tenant; there is no cross-tenant feed.
2849
+ * - Needs `substrate:tenant` (or admin). A tenant MEMBER's token is refused
2850
+ * — the feed exposes the tenant's write pattern, which is operator
2851
+ * observability, not member data.
2852
+ * - To resume after a disconnect, persist the last `payload.seq` you
2853
+ * processed and pass it back as `since`. A cursor older than the
2854
+ * server's change retention resumes past the pruned gap without saying
2855
+ * so.
2856
+ * - A `{ kind: "error", payload: ChangeFeedError }` item means the server
2857
+ * could not read its change log. It is NOT fatal and iteration
2858
+ * continues: the server retries with backoff without advancing its
2859
+ * cursor, so changes resume after recovery with no gap. One item per
2860
+ * failure streak.
2861
+ * - The stream has no end of its own: iteration ends when the server
2862
+ * closes it (shutdown — reconnect with `since`) or throws an
2863
+ * `AbortError` when `signal` aborts. */
2864
+ async *streamMemoryChanges(opts) {
2865
+ yield* this.streamChangeFeed("/v1/_memory/changes", opts);
2866
+ }
2867
+ /** Tail the caller's tenant's DOCUMENT change feed (RFC CD Part C) — one
2868
+ * coordinate per `document.chunk.updated` / `document.chunk.deleted`,
2869
+ * naming the `chunk_id`. Mirrors `GET /v1/_document/changes?since=<seq>`.
2870
+ * Same frames, scope, auth and resume contract as
2871
+ * {@link LoomcycleClient.streamMemoryChanges}; a reader wanting both
2872
+ * families opens both. */
2873
+ async *streamDocumentChanges(opts) {
2874
+ yield* this.streamChangeFeed("/v1/_document/changes", opts);
2875
+ }
2876
+ async *streamChangeFeed(path, opts) {
2877
+ const since = opts?.since;
2878
+ // The server reads an unparseable or negative `since` as 0 rather than
2879
+ // refusing it, which replays every retained change — a resume bug that
2880
+ // would look like a burst of activity. Refuse it here instead.
2881
+ if (since !== undefined && !(Number.isSafeInteger(since) && since >= 0)) {
2882
+ throw new Error(`loomcycle: change feed "since" must be a non-negative integer, got ${String(since)}`);
2883
+ }
2884
+ const q = since ? `?since=${since}` : "";
2885
+ for await (const ev of this.streamSSEGet(path + q, opts?.signal)) {
2886
+ const frame = toChangeFeedFrame(ev);
2887
+ if (frame)
2888
+ yield frame;
2889
+ }
2890
+ }
2396
2891
  /** List credential METADATA for a scope (RFC AR) — never a secret value.
2397
2892
  * `user` = your own subject's tokens (per-user, e.g. a personal Telegram bot
2398
2893
  * token); `tenant` = shared across the tenant (operator authority). RFC CN: a
@@ -2403,10 +2898,35 @@ export class LoomcycleClient {
2403
2898
  }
2404
2899
  /** Store or rotate a credential (RFC AR). The plaintext `value` is write-only:
2405
2900
  * the server encrypts it, masks it from the transcript, and never returns it.
2406
- * Returns the stored row's metadata. Mirrors `POST /v1/_credentialdef {op:create}`. */
2901
+ * Returns the stored row's metadata. Mirrors `POST /v1/_credentialdef {op:create}`.
2902
+ *
2903
+ * `allowed_urls` restricts where the value may be sent: https URLs, each
2904
+ * covering itself and everything under it. `[]` stores a value that is
2905
+ * sent nowhere. Leave it out for no restriction, or, when the credential
2906
+ * already exists, to keep the list it has. A restricted credential is used
2907
+ * only where the destination is known and covered (an MCP server's or a
2908
+ * hook's header, a remote peer's key, a git source), never as a provider
2909
+ * key.
2910
+ *
2911
+ * `username` is the user name sent with the value where a destination
2912
+ * takes one (HTTP basic auth for a git source). Leave it out to keep the
2913
+ * stored one; `""` removes it.
2914
+ *
2915
+ * `expires_at` is an RFC3339 timestamp (`"2026-12-31T00:00:00Z"`) stored
2916
+ * with the credential and reported back in its metadata; any other format
2917
+ * is refused. `backend` is where the value is kept: `"inline"` (encrypted
2918
+ * in loomcycle's own store, the default) is the only one accepted. */
2407
2919
  async createCredential(input, opts) {
2408
2920
  return postJSON(this.ctx, "/v1/_credentialdef", { op: "create", ...input }, opts);
2409
2921
  }
2922
+ /** Read ONE credential's metadata (RFC AR) — never its value. Answers
2923
+ * whether a name is stored, and its `allowed_urls` / `username` /
2924
+ * `expires_at`, without listing the whole scope. An absent name raises
2925
+ * {@link SubstrateToolRefusedError}. Mirrors
2926
+ * `POST /v1/_credentialdef {op:get}`. */
2927
+ async getCredential(input, opts) {
2928
+ return postJSON(this.ctx, "/v1/_credentialdef", { op: "get", ...input }, opts);
2929
+ }
2410
2930
  /** Delete a credential (RFC AR). Anything referencing `$cred:<name>` at that
2411
2931
  * scope stops resolving. Mirrors `POST /v1/_credentialdef {op:delete}`. */
2412
2932
  async deleteCredential(input, opts) {
@@ -2462,6 +2982,113 @@ async function* parseRunStateSSE(reader) {
2462
2982
  }
2463
2983
  }
2464
2984
  }
2985
+ /** The wire input of a typed create / fork. `promote` goes out only when the
2986
+ * caller set it — false included — so the server's per-def default applies
2987
+ * otherwise. */
2988
+ function defWriteInput(op, name, overlay, opts) {
2989
+ const input = { op, name, overlay };
2990
+ if (opts?.parentDefId)
2991
+ input.parent_def_id = opts.parentDefId;
2992
+ if (opts?.description)
2993
+ input.description = opts.description;
2994
+ if (opts?.promote !== undefined)
2995
+ input.promote = opts.promote;
2996
+ return input;
2997
+ }
2998
+ function signalOpts(opts) {
2999
+ return opts?.signal ? { signal: opts.signal } : undefined;
3000
+ }
3001
+ /** The form a HookDef definition is stored in: hooks.Def.Normalize followed
3002
+ * by Go's omitempty encoding. Applied to both sides of ensureHookDef's
3003
+ * comparison; the stored side is already in this form, so what matters is
3004
+ * that the overlay lands exactly where the server would put it. */
3005
+ function normalizeHookDef(d) {
3006
+ const out = { event: d.event };
3007
+ const description = goTrimSpace(d.description ?? "");
3008
+ if (description)
3009
+ out.description = description;
3010
+ if (d.match?.tools && d.match.tools.length > 0)
3011
+ out.match = { tools: d.match.tools };
3012
+ const body = { kind: d.body.kind };
3013
+ if (d.body.kind === "code-js") {
3014
+ if (d.body.code)
3015
+ body.code = d.body.code;
3016
+ }
3017
+ else {
3018
+ if (d.body.url)
3019
+ body.url = d.body.url;
3020
+ if (d.body.headers && Object.keys(d.body.headers).length > 0)
3021
+ body.headers = d.body.headers;
3022
+ if (d.body.signing_secret)
3023
+ body.signing_secret = d.body.signing_secret;
3024
+ }
3025
+ out.body = body;
3026
+ out.fail_mode = d.fail_mode || "open";
3027
+ if (d.timeout_ms)
3028
+ out.timeout_ms = d.timeout_ms;
3029
+ return out;
3030
+ }
3031
+ /** strings.TrimSpace: Go's Unicode White_Space set, which is not
3032
+ * String.prototype.trim's (Go trims U+0085 and keeps U+FEFF; JavaScript does
3033
+ * the reverse). Trimming differently could make two descriptions the server
3034
+ * stores differently compare equal, and skip a real change. */
3035
+ const goTrimRe = /^[\t\n\v\f\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+|[\t\n\v\f\r \u0085\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]+$/g;
3036
+ function goTrimSpace(s) {
3037
+ return s.replace(goTrimRe, "");
3038
+ }
3039
+ /** Structural JSON equality, ignoring object key order (the server's
3040
+ * encoding orders keys by struct field, a caller's literal by whatever it
3041
+ * wrote). Arrays compare in order. */
3042
+ function sameJSON(a, b) {
3043
+ if (a === b)
3044
+ return true;
3045
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null)
3046
+ return false;
3047
+ if (Array.isArray(a) !== Array.isArray(b))
3048
+ return false;
3049
+ if (Array.isArray(a) && Array.isArray(b)) {
3050
+ return a.length === b.length && a.every((v, i) => sameJSON(v, b[i]));
3051
+ }
3052
+ const ao = a;
3053
+ const bo = b;
3054
+ const keys = Object.keys(ao);
3055
+ if (keys.length !== Object.keys(bo).length)
3056
+ return false;
3057
+ return keys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && sameJSON(ao[k], bo[k]));
3058
+ }
3059
+ /** toChangeFeedFrame types one frame of a change feed, or returns null for a
3060
+ * frame it does not recognise.
3061
+ *
3062
+ * The frames arrive through parseSSE, which backfills `type` from the SSE
3063
+ * event name ONLY when the payload has none. The `feed` and `error` frames
3064
+ * deliberately carry no `type`, so they surface as `type: "feed"` /
3065
+ * `type: "error"`; a `change` frame
3066
+ * carries its own change type (`memory.set`, …), so it is recognised by its
3067
+ * numeric `seq` instead. Anything else — an event name a newer server adds —
3068
+ * is skipped, the adapter's usual ignore-what-you-don't-know posture. */
3069
+ function toChangeFeedFrame(raw) {
3070
+ if (raw.type === "feed") {
3071
+ // An unreadable status must not default to enabled: the frame exists so a
3072
+ // disabled feed is not mistaken for a quiet one.
3073
+ if (typeof raw.enabled !== "boolean")
3074
+ return null;
3075
+ const { type: _type, ...status } = raw;
3076
+ return { kind: "feed", payload: status };
3077
+ }
3078
+ if (raw.type === "error") {
3079
+ // Like `feed`, the error frame carries no `type` of its own, so this one
3080
+ // was backfilled from the event name. Both fields are required: a
3081
+ // half-read error is skipped rather than surfaced as a blank one.
3082
+ if (typeof raw.code !== "string" || typeof raw.error !== "string")
3083
+ return null;
3084
+ const { type: _type, ...failure } = raw;
3085
+ return { kind: "error", payload: failure };
3086
+ }
3087
+ if (typeof raw.seq === "number" && typeof raw.type === "string") {
3088
+ return { kind: "change", payload: raw };
3089
+ }
3090
+ return null;
3091
+ }
2465
3092
  // channelOpPath builds the v0.9.x Channel CRUD URL. Two families:
2466
3093
  // - scope === "global" → /v1/_channels/{channel}/{op}
2467
3094
  // - scope === "user" → /v1/users/{userId}/channels/{channel}/{op}