@loomcycle/client 1.112.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.
@@ -19,7 +19,7 @@
19
19
  * to raise typed errors via `raiseFromResponse`.
20
20
  */
21
21
  Object.defineProperty(exports, "__esModule", { value: true });
22
- exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.PromptTooLargeError = exports.RequestTooLargeError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.TokenLimitExceededError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
22
+ exports.SubstrateToolRefusedError = exports.ChannelCursorRegressionError = exports.SnapshotVersionError = exports.PromptTooLargeError = exports.RequestTooLargeError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.NotPausedError = exports.AlreadyPausingError = exports.PauseNotConfiguredError = exports.InvalidArgumentError = exports.UnavailableError = exports.InsufficientScopeError = exports.AuthError = exports.PerUserQuotaExhaustedError = exports.TokenLimitExceededError = exports.RateLimitedError = exports.BackpressureError = exports.AgentIDInUseError = exports.SessionBusyError = exports.SessionNotFoundError = exports.AgentNotFoundError = exports.NotFoundError = exports.LoomcycleError = void 0;
23
23
  class LoomcycleError extends Error {
24
24
  status;
25
25
  bodyText;
@@ -36,8 +36,43 @@ class LoomcycleError extends Error {
36
36
  this.bodyText = opts?.bodyText;
37
37
  this.code = opts?.code;
38
38
  }
39
+ /** Whether the same call, sent again unchanged, can succeed once time has
40
+ * passed: true for a refusal that clears on its own (backpressure, a rate
41
+ * limit, a per-user run quota, a paused runtime, a decision-model call
42
+ * that failed or timed out), false for everything else, including a hard
43
+ * token budget, a cap the caller must make room under, a bad request and
44
+ * a missing resource. A retry loop can test this instead of a list of
45
+ * classes.
46
+ *
47
+ * It is a getter rather than a constructor field so that a subclass states
48
+ * its answer by overriding it, without a constructor option any caller
49
+ * could set. False is the default because a wrong `true` turns a refusal
50
+ * that never clears into a loop that never ends, while a wrong `false`
51
+ * only surfaces an error the caller could have waited out. The client
52
+ * itself never retries; bound your own retries even when this is true. */
53
+ get retryable() {
54
+ return this.code !== undefined && TRANSIENT_CODES.has(this.code);
55
+ }
39
56
  }
40
57
  exports.LoomcycleError = LoomcycleError;
58
+ /** Codes that clear on their own although the class their status maps to
59
+ * does not: a 503 is mostly a feature the deployment lacks, and a 502 / 504
60
+ * is any upstream failure, so the class alone says false. The server
61
+ * classifies each of these as transient:
62
+ * - `runtime_paused` (503): new runs are refused until the operator resumes.
63
+ * - `call_failed` (502): a decision-model call did not complete.
64
+ * - `timeout` (504): a decision-model call did not answer in time. */
65
+ const TRANSIENT_CODES = new Set([
66
+ "runtime_paused",
67
+ "call_failed",
68
+ "timeout",
69
+ ]);
70
+ /** 429 codes that do NOT clear by waiting: the caller has to act first, so
71
+ * a retry loop on them never ends. `configured_run_cap`: the user already
72
+ * holds as many configured (not yet started) runs as the operator allows,
73
+ * and only starting or discarding one makes room. (A hard token budget,
74
+ * `token_limit_exceeded`, has a class of its own outside BackpressureError.) */
75
+ const CALLER_ACTION_429_CODES = new Set(["configured_run_cap"]);
41
76
  /** Base class for every HTTP 404 the client surfaces. Lets callers
42
77
  * catch any not-found case with a single `instanceof NotFoundError`
43
78
  * check, regardless of which specific resource was missing
@@ -78,17 +113,48 @@ class AgentIDInUseError extends LoomcycleError {
78
113
  }
79
114
  }
80
115
  exports.AgentIDInUseError = AgentIDInUseError;
81
- /** Every HTTP 429 that is not a per-user quota. A plain BackpressureError is
82
- * the transient case (the server is busy; back off and retry). It has one
83
- * subclass that is NOT transient, TokenLimitExceededError — check for that
84
- * first when deciding whether to retry. */
116
+ /** An HTTP 429 that clears on its own: the server is busy (`backpressure`),
117
+ * a provider's queue is full (`provider_concurrency_exhausted`), or the
118
+ * caller is starting runs too fast (`rate_limited`, raised as the
119
+ * RateLimitedError subclass). Back off and retry; `retryable` is true.
120
+ *
121
+ * One code under this class is the exception: `configured_run_cap` (the user
122
+ * holds as many configured runs as allowed; start or discard one first)
123
+ * needs the caller to act, so its `retryable` is false. It stays a
124
+ * BackpressureError, the class every other 429 lands in.
125
+ *
126
+ * Two 429s are not this class: a per-user run quota
127
+ * (PerUserQuotaExhaustedError, also retryable) and a hard token budget
128
+ * (TokenLimitExceededError, not retryable). */
85
129
  class BackpressureError extends LoomcycleError {
86
130
  constructor(message, opts) {
87
131
  super(message, opts);
88
132
  this.name = "BackpressureError";
89
133
  }
134
+ get retryable() {
135
+ return this.code === undefined || !CALLER_ACTION_429_CODES.has(this.code);
136
+ }
90
137
  }
91
138
  exports.BackpressureError = BackpressureError;
139
+ /**
140
+ * RateLimitedError signals that the caller is starting runs faster than its
141
+ * per-caller run-start rate allows: HTTP 429 with body code `rate_limited`.
142
+ * The server says when the next start will be admitted in `Retry-After`,
143
+ * surfaced here as `retryAfterMs`; wait that long rather than backing off on
144
+ * a guess. Retryable, like every BackpressureError.
145
+ */
146
+ class RateLimitedError extends BackpressureError {
147
+ /** Server-suggested retry window in milliseconds, from the Retry-After
148
+ * header. Null when the header is absent or not a whole number of
149
+ * seconds. */
150
+ retryAfterMs;
151
+ constructor(message, opts) {
152
+ super(message, opts);
153
+ this.name = "RateLimitedError";
154
+ this.retryAfterMs = opts?.retryAfterMs ?? null;
155
+ }
156
+ }
157
+ exports.RateLimitedError = RateLimitedError;
92
158
  /**
93
159
  * TokenLimitExceededError signals that the caller is at a hard token budget:
94
160
  * HTTP 429 with body code `token_limit_exceeded`, from run creation and from
@@ -98,20 +164,18 @@ exports.BackpressureError = BackpressureError;
98
164
  * month's tokens for an operator, tenant or user; the same call is refused
99
165
  * until an operator raises the budget or the window rolls over.
100
166
  *
101
- * It extends BackpressureError only so that code written before this class
102
- * existed, which caught this refusal with `instanceof BackpressureError`,
103
- * keeps catching it. Test for this class BEFORE BackpressureError:
104
- *
105
- * if (e instanceof TokenLimitExceededError) {
106
- * // not retryable now: report e.scope / e.used / e.limit
107
- * } else if (e instanceof BackpressureError) {
108
- * // transient: back off and retry
109
- * }
167
+ * It is not a BackpressureError, and `retryable` is false. It used to
168
+ * extend BackpressureError so that an older `instanceof BackpressureError`
169
+ * check kept catching it, but such a check is almost always a retry loop, and
170
+ * that loop then retried a refusal that cannot clear for the rest of the
171
+ * month. The relation was dropped on purpose: a retry loop keyed on
172
+ * BackpressureError or on `retryable` now stops here, and code that wants to
173
+ * report the budget tests for this class.
110
174
  *
111
175
  * The fields come from the response body and are null when the server did
112
176
  * not send them.
113
177
  */
114
- class TokenLimitExceededError extends BackpressureError {
178
+ class TokenLimitExceededError extends LoomcycleError {
115
179
  /** Whose budget was hit: "operator", "tenant" or "user". */
116
180
  scope;
117
181
  /** The id of that scope: the tenant id or the user subject. Null for the
@@ -183,6 +247,11 @@ class PerUserQuotaExhaustedError extends LoomcycleError {
183
247
  this.cap = opts?.cap ?? null;
184
248
  this.retryAfterMs = opts?.retryAfterMs ?? null;
185
249
  }
250
+ /** The quota counts the caller's own in-flight runs, so it frees up as
251
+ * they finish. */
252
+ get retryable() {
253
+ return true;
254
+ }
186
255
  }
187
256
  exports.PerUserQuotaExhaustedError = PerUserQuotaExhaustedError;
188
257
  class AuthError extends LoomcycleError {
@@ -192,6 +261,37 @@ class AuthError extends LoomcycleError {
192
261
  }
193
262
  }
194
263
  exports.AuthError = AuthError;
264
+ /** An HTTP 403 because the bearer token lacks a scope the call needs: body code
265
+ * `insufficient_scope`. The token is valid; it was minted without that
266
+ * grant. Ask an operator for a token that carries `requiredScope`; sending
267
+ * the same call again never succeeds, so `retryable` is false.
268
+ *
269
+ * Any other 403 (`restricted_token`, `operator_key_restricted`, …) stays a
270
+ * plain LoomcycleError: those refuse the caller for something a new scope
271
+ * does not fix. */
272
+ class InsufficientScopeError extends LoomcycleError {
273
+ /** The scope the token lacks, e.g. `runs:create` or `substrate:tenant`.
274
+ * Read from the body's `required_scope`, or, from a server that predates
275
+ * that field, from the `WWW-Authenticate: Bearer scope="…"` header. Null
276
+ * when neither names one (a browser does not see that header on a
277
+ * cross-origin response unless the server exposes it). */
278
+ requiredScope;
279
+ constructor(message, opts) {
280
+ super(message, opts);
281
+ this.name = "InsufficientScopeError";
282
+ this.requiredScope = opts?.requiredScope ?? null;
283
+ }
284
+ }
285
+ exports.InsufficientScopeError = InsufficientScopeError;
286
+ /** Every HTTP 503. The status is shared between a condition that clears on
287
+ * its own and features this deployment does not have
288
+ * (`decision_not_configured`, `store_unavailable`, `vector_unsupported`, …),
289
+ * which never will, and many 503 bodies carry no code at all. So
290
+ * `retryable` is true only for `runtime_paused` (new runs are refused while
291
+ * the operator has the runtime paused) and false otherwise. A code-less 503
292
+ * from run creation can also be a model tier with no provider available
293
+ * right now; retry that one on your own judgement, with a bound. (The
294
+ * inherited `retryable` reads `runtime_paused` from TRANSIENT_CODES.) */
195
295
  class UnavailableError extends LoomcycleError {
196
296
  constructor(message, opts) {
197
297
  super(message, opts);
@@ -215,6 +315,10 @@ class PauseNotConfiguredError extends UnavailableError {
215
315
  super(message, opts);
216
316
  this.name = "PauseNotConfiguredError";
217
317
  }
318
+ /** The server has no pause manager; asking again does not give it one. */
319
+ get retryable() {
320
+ return false;
321
+ }
218
322
  }
219
323
  exports.PauseNotConfiguredError = PauseNotConfiguredError;
220
324
  class AlreadyPausingError extends LoomcycleError {
@@ -167,6 +167,9 @@ async function deleteJSON(ctx, path, opts) {
167
167
  *
168
168
  * 400 → InvalidArgumentError
169
169
  * 401 → AuthError
170
+ * 403 + code "insufficient_scope" (or an older server's
171
+ * WWW-Authenticate scope hint) → InsufficientScopeError
172
+ * 403 (other) → LoomcycleError (base)
170
173
  * 404 + "snapshot" → SnapshotNotFoundError ────────┐
171
174
  * 404 + "session" → SessionNotFoundError │ All extend
172
175
  * 404 + "agent" → AgentNotFoundError │ callers can
@@ -226,6 +229,19 @@ async function raiseFromResponse(resp) {
226
229
  throw new errors_js_1.InvalidArgumentError(msg, opts);
227
230
  case 401:
228
231
  throw new errors_js_1.AuthError(msg, opts);
232
+ case 403: {
233
+ // A missing scope says so in its code. A server from before the JSON
234
+ // body sent "insufficient scope" as plain text; the scope hint in
235
+ // WWW-Authenticate, which no other 403 carries, still identifies it.
236
+ const hinted = scopeFromWWWAuthenticate(resp);
237
+ if (opts.code === "insufficient_scope" || (opts.code === undefined && hinted !== undefined)) {
238
+ throw new errors_js_1.InsufficientScopeError(msg, {
239
+ ...opts,
240
+ requiredScope: stringField(bodyObject(bodyText)?.required_scope) ?? hinted,
241
+ });
242
+ }
243
+ throw new errors_js_1.LoomcycleError(msg, opts);
244
+ }
229
245
  case 404:
230
246
  // Priority: most-specific keyword wins.
231
247
  // - "snapshot" → SnapshotNotFoundError
@@ -285,8 +301,8 @@ async function raiseFromResponse(resp) {
285
301
  }
286
302
  case 429: {
287
303
  // A hard token budget is not transient the way backpressure is, so
288
- // it gets its own class (a BackpressureError subclass, for callers
289
- // that already catch that).
304
+ // it gets its own class, deliberately outside BackpressureError: a
305
+ // retry loop keyed on that class must not spin on it all month.
290
306
  if (opts.code === "token_limit_exceeded") {
291
307
  const body = bodyObject(bodyText) ?? {};
292
308
  throw new errors_js_1.TokenLimitExceededError(msg, {
@@ -298,6 +314,9 @@ async function raiseFromResponse(resp) {
298
314
  window: stringField(body.window),
299
315
  });
300
316
  }
317
+ if (opts.code === "rate_limited") {
318
+ throw new errors_js_1.RateLimitedError(msg, { ...opts, retryAfterMs: retryAfterMs(resp) });
319
+ }
301
320
  // v0.10.1: distinguish per-user quota exhaustion from
302
321
  // operator-wide backpressure. The shapes share the 429 status
303
322
  // but the JSON body's `code` field discriminates. Consumers
@@ -305,18 +324,13 @@ async function raiseFromResponse(resp) {
305
324
  try {
306
325
  const parsed = JSON.parse(bodyText);
307
326
  if (parsed.code === "per_user_quota_exhausted") {
308
- // Retry-After is `<seconds>` per RFC; convert to ms.
309
- const retryAfterRaw = resp.headers.get("retry-after");
310
- const retryAfterMs = retryAfterRaw
311
- ? Number.parseInt(retryAfterRaw, 10) * 1000
312
- : undefined;
313
327
  throw new errors_js_1.PerUserQuotaExhaustedError(parsed.error ?? msg, {
314
328
  status,
315
329
  bodyText,
316
330
  code: parsed.code,
317
331
  userId: parsed.user_id,
318
332
  cap: parsed.cap,
319
- retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : undefined,
333
+ retryAfterMs: retryAfterMs(resp),
320
334
  });
321
335
  }
322
336
  }
@@ -335,6 +349,23 @@ async function raiseFromResponse(resp) {
335
349
  throw new errors_js_1.LoomcycleError(msg, opts);
336
350
  }
337
351
  }
352
+ /** retryAfterMs reads a `Retry-After` header given in seconds, as the server
353
+ * always sends it, and returns milliseconds; undefined when the header is
354
+ * absent or not a number (an HTTP-date form is not parsed). */
355
+ function retryAfterMs(resp) {
356
+ const raw = resp.headers.get("retry-after");
357
+ if (!raw)
358
+ return undefined;
359
+ const ms = Number.parseInt(raw, 10) * 1000;
360
+ return Number.isFinite(ms) ? ms : undefined;
361
+ }
362
+ /** scopeFromWWWAuthenticate reads the scope from the RFC 6750 hint the server
363
+ * sends with a scope refusal (`Bearer scope="runs:create"`); undefined when
364
+ * the header is absent or names no scope. */
365
+ function scopeFromWWWAuthenticate(resp) {
366
+ const m = /\bscope="([^"]*)"/.exec(resp.headers.get("www-authenticate") ?? "");
367
+ return stringField(m?.[1]);
368
+ }
338
369
  /** bodyCode returns the string `code` of a JSON error body, or undefined when
339
370
  * the body is not a JSON object or has none. Statuses are shared between
340
371
  * unrelated conditions (a 400 is any bad request; a 429 is backpressure, a
package/dist/cjs/index.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * getAgent(agentId): Promise<Agent>
16
16
  * getRun(runId): Promise<Agent>
17
17
  * cancelAgent(agentId, opts?): Promise<CancelAgentResult>
18
- * listUserAgents(userId, opts?): Promise<Agent[]>
18
+ * listUserAgents(userId, opts?): Promise<AgentList>
19
19
  * listWalkRuns(walkId, opts?): Promise<WalkRunsPage>
20
20
  * deleteRunTree(runId, opts?): Promise<RunTreeDeletion> // needs runs:delete
21
21
  * deleteSession(sessionId, {cascade: "children"}): Promise<RunTreeDeletion>
@@ -36,6 +36,9 @@
36
36
  * resumeRuntime(): Promise<ResumeResult>
37
37
  * getRuntimeState(): Promise<RuntimeStateResponse>
38
38
  * resolveProbe(opts?): Promise<ResolverMatrix>
39
+ * listModels(): Promise<ModelAliasesResponse> // configured model aliases
40
+ * getRouting(): Promise<RoutingResponse> // per user_tier × tier cascade
41
+ * listProviderModels(providerId): Promise<ProviderModelsResponse> // admin token only
39
42
  *
40
43
  * // Snapshot lifecycle (v0.8.17/8.18)
41
44
  * createSnapshot(opts?): Promise<SnapshotCreateResponse>
@@ -58,6 +61,10 @@
58
61
  * backfillEmbeddings(scope, scopeId, opts?): Promise<MemoryBackfillResponse> // embeds rows that have none
59
62
  * purgeStaleEmbeddings(scope, scopeId, opts?): Promise<MemoryPurgeResponse> // DELETES; dry_run defaults true
60
63
  *
64
+ * // Memory / document change feeds (RFC CD Part C; SSE; substrate:tenant)
65
+ * streamMemoryChanges(opts?): AsyncIterable<ChangeFeedFrame> // feed | change | error (non-fatal); resume with opts.since = last seq
66
+ * streamDocumentChanges(opts?): AsyncIterable<ChangeFeedFrame>
67
+ *
61
68
  * // Interruption (v0.8.16; decline RFC BH)
62
69
  * listUserInterrupts(userId, opts?): Promise<InterruptListResponse>
63
70
  * listRunInterrupts(runId, opts?): Promise<InterruptListResponse>
@@ -73,7 +80,27 @@
73
80
  * mcpServerDef(input): Promise<SubstrateToolResponse>
74
81
  * mcpServerDefVerify(name, sha): Promise<MCPServerDefVerifyResult> // v0.18.0
75
82
  * ensureMcpServer(opts): Promise<EnsureMcpServerResult> // v0.18.0 — idempotent register-if-changed
76
- * scheduleDef(input): Promise<SubstrateToolResponse>
83
+ * scheduleDef(input): Promise<SubstrateToolResponse> // input: ScheduleDefInput | SubstrateToolInput
84
+ * webhookDef(input): Promise<SubstrateToolResponse> // input: WebhookDefInput | SubstrateToolInput
85
+ * hookDef(input): Promise<SubstrateToolResponse> // input: HookDefInput | SubstrateToolInput
86
+ * ensureHookDef(name, overlay, opts?): Promise<EnsureHookDefResult> // idempotent register-if-changed
87
+ * createHookDef / forkHookDef(name, overlay, opts?) // overlay strictly typed, no loose fallback
88
+ * createScheduleDef / forkScheduleDef(name, overlay, opts?) // ditto (overlay open: types of known keys checked)
89
+ * createWebhookDef / forkWebhookDef(name, overlay, opts?) // ditto
90
+ *
91
+ * // Def name enumeration (tenant-scoped; opts.tenant = admin focus)
92
+ * listAgentDefNames / listSkillDefNames / listHookDefNames / listMcpServerDefNames /
93
+ * listScheduleDefNames / listA2AServerCardDefNames / listA2AAgentDefNames /
94
+ * listWebhookDefNames / listMemoryBackendDefNames / listDocumentSourceDefNames
95
+ * (opts?): Promise<DefNamesResponse>
96
+ * listOperatorTokenDefNames(opts?): Promise<OperatorTokenDefNamesResponse> // admin token only
97
+ *
98
+ * // Schedules admin
99
+ * listAllSchedules(opts?): Promise<ScheduleListResponse> // static yaml + ScheduleDefs, merged
100
+ * getScheduleState(defId): Promise<ScheduleStateView>
101
+ * runScheduleNow(defId): Promise<ScheduleRunNowResult> // fires on the next tick
102
+ * pauseSchedule(defId): Promise<SchedulePauseResult> // indefinite
103
+ * resumeSchedule(defId): Promise<SchedulePauseResult>
77
104
  *
78
105
  * // Dynamic filesystem volumes (v0.35.0 — RFC AH; tenant-confined)
79
106
  * volumeDef(input): Promise<SubstrateToolResponse> // create/get/list/delete/purge
@@ -84,8 +111,8 @@
84
111
  * listTeams(): Promise<ListTeamsResponse>
85
112
  * renderTeamDiagram(name, opts?): Promise<TeamDiagram>
86
113
  * getTeamDef(defId): Promise<TeamDefDetail>
87
- * createTeam(name, overlay): Promise<CreatedTeam>
88
- * forkTeam(name, overlay): Promise<CreatedTeam>
114
+ * createTeam(name, overlay, opts?): Promise<CreatedTeam> // opts: promote (default true) / description
115
+ * forkTeam(name, overlay, opts?): Promise<CreatedTeam> // opts: promote / parentDefId / description
89
116
  * deleteTeam(name): Promise<{name, deleted}>
90
117
  * runTeam({name|defId, input}): Promise<TeamRunResult>
91
118
  * runTeam({..., mode:"detach"}): Promise<TeamRunDetached> // the handle, now — the walk runs on
@@ -99,6 +126,14 @@
99
126
  * // History — browse/search/annotate past chats (RFC BE; tenant-confined)
100
127
  * history(input): Promise<HistoryToolResponse> // list/get/search/rename/annotate/pin/archive/recap/resume
101
128
  *
129
+ * // Evaluation — score runs and read the scores back, outside a run
130
+ * evaluation(input): Promise<…> // submit/get/list_for_run/list_for_def/aggregate
131
+ * submitEvaluation(runId, score, opts?): Promise<EvaluationSubmitResult>
132
+ * getEvaluation(evalId): Promise<EvaluationRecord>
133
+ * listEvaluationsForRun(runId, opts?): Promise<EvaluationListForRunResult>
134
+ * listEvaluationsForDef(defId, opts?): Promise<EvaluationListForDefResult>
135
+ * aggregateEvaluations(defId, opts?): Promise<EvaluationAggregate>
136
+ *
102
137
  * // Library v2 enumeration (v0.10.3 — yaml+substrate merged)
103
138
  * listLibraryAgents(): Promise<LibraryListResponse<LibraryAgentDefinition>>
104
139
  * listLibrarySkills(): Promise<LibraryListResponse<LibrarySkillDefinition>>
@@ -115,13 +150,18 @@
115
150
  * full HTTP-status → typed-error mapping table):
116
151
  * LoomcycleError, AgentNotFoundError, SessionNotFoundError,
117
152
  * SessionBusyError, AgentIDInUseError, BackpressureError,
118
- * AuthError, UnavailableError, InvalidArgumentError,
153
+ * RateLimitedError (subclass of BackpressureError),
154
+ * PerUserQuotaExhaustedError, AuthError, InsufficientScopeError (a 403
155
+ * naming the scope the token lacks), UnavailableError,
156
+ * InvalidArgumentError,
119
157
  * PauseNotConfiguredError (subclass of UnavailableError),
120
158
  * AlreadyPausingError, NotPausedError, SnapshotNotFoundError,
121
159
  * SnapshotTooLargeError, RequestTooLargeError + PromptTooLargeError
122
160
  * (both subclasses of SnapshotTooLargeError), TokenLimitExceededError
123
- * (subclass of BackpressureError), SnapshotVersionError,
124
- * SubstrateToolRefusedError (v0.8.22)
161
+ * (NOT a BackpressureError: a hard budget, not retryable),
162
+ * SnapshotVersionError, SubstrateToolRefusedError (v0.8.22).
163
+ * Every one carries `retryable` (true only for a refusal that clears
164
+ * on its own).
125
165
  *
126
166
  * Transport: HTTP+SSE. Auth: Bearer token via the Authorization
127
167
  * header. Designed for Node ≥18 (engines pinned); Bun/Deno likely
@@ -131,11 +171,14 @@
131
171
  * See `adapters/ts/README.md` for usage examples.
132
172
  */
133
173
  Object.defineProperty(exports, "__esModule", { value: true });
134
- exports.UnavailableError = exports.TokenLimitExceededError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.RequestTooLargeError = exports.PromptTooLargeError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.NotFoundError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.InteractiveSession = exports.LoomcycleClient = void 0;
174
+ exports.UnavailableError = exports.TokenLimitExceededError = exports.SubstrateToolRefusedError = exports.SnapshotVersionError = exports.SnapshotTooLargeError = exports.SnapshotNotFoundError = exports.SessionNotFoundError = exports.SessionBusyError = exports.RequestTooLargeError = exports.RateLimitedError = exports.PromptTooLargeError = exports.PerUserQuotaExhaustedError = exports.PauseNotConfiguredError = exports.NotPausedError = exports.LoomcycleError = exports.ChannelCursorRegressionError = exports.InvalidArgumentError = exports.InsufficientScopeError = exports.NotFoundError = exports.BackpressureError = exports.AuthError = exports.AlreadyPausingError = exports.AgentNotFoundError = exports.AgentIDInUseError = exports.CLIENT_TOOL_SUBPROTOCOL = exports.clientToolsURL = exports.ClientToolHost = exports.verifyLoomcycleDelivery = exports.verifyLoomcycleSignature = exports.InteractiveSession = exports.LoomcycleClient = void 0;
135
175
  var client_js_1 = require("./client.js");
136
176
  Object.defineProperty(exports, "LoomcycleClient", { enumerable: true, get: function () { return client_js_1.LoomcycleClient; } });
137
177
  var interactive_js_1 = require("./interactive.js");
138
178
  Object.defineProperty(exports, "InteractiveSession", { enumerable: true, get: function () { return interactive_js_1.InteractiveSession; } });
179
+ var signature_js_1 = require("./signature.js");
180
+ Object.defineProperty(exports, "verifyLoomcycleSignature", { enumerable: true, get: function () { return signature_js_1.verifyLoomcycleSignature; } });
181
+ Object.defineProperty(exports, "verifyLoomcycleDelivery", { enumerable: true, get: function () { return signature_js_1.verifyLoomcycleDelivery; } });
139
182
  var client_tools_js_1 = require("./client-tools.js");
140
183
  Object.defineProperty(exports, "ClientToolHost", { enumerable: true, get: function () { return client_tools_js_1.ClientToolHost; } });
141
184
  Object.defineProperty(exports, "clientToolsURL", { enumerable: true, get: function () { return client_tools_js_1.clientToolsURL; } });
@@ -147,6 +190,7 @@ Object.defineProperty(exports, "AlreadyPausingError", { enumerable: true, get: f
147
190
  Object.defineProperty(exports, "AuthError", { enumerable: true, get: function () { return errors_js_1.AuthError; } });
148
191
  Object.defineProperty(exports, "BackpressureError", { enumerable: true, get: function () { return errors_js_1.BackpressureError; } });
149
192
  Object.defineProperty(exports, "NotFoundError", { enumerable: true, get: function () { return errors_js_1.NotFoundError; } });
193
+ Object.defineProperty(exports, "InsufficientScopeError", { enumerable: true, get: function () { return errors_js_1.InsufficientScopeError; } });
150
194
  Object.defineProperty(exports, "InvalidArgumentError", { enumerable: true, get: function () { return errors_js_1.InvalidArgumentError; } });
151
195
  Object.defineProperty(exports, "ChannelCursorRegressionError", { enumerable: true, get: function () { return errors_js_1.ChannelCursorRegressionError; } });
152
196
  Object.defineProperty(exports, "LoomcycleError", { enumerable: true, get: function () { return errors_js_1.LoomcycleError; } });
@@ -154,6 +198,7 @@ Object.defineProperty(exports, "NotPausedError", { enumerable: true, get: functi
154
198
  Object.defineProperty(exports, "PauseNotConfiguredError", { enumerable: true, get: function () { return errors_js_1.PauseNotConfiguredError; } });
155
199
  Object.defineProperty(exports, "PerUserQuotaExhaustedError", { enumerable: true, get: function () { return errors_js_1.PerUserQuotaExhaustedError; } });
156
200
  Object.defineProperty(exports, "PromptTooLargeError", { enumerable: true, get: function () { return errors_js_1.PromptTooLargeError; } });
201
+ Object.defineProperty(exports, "RateLimitedError", { enumerable: true, get: function () { return errors_js_1.RateLimitedError; } });
157
202
  Object.defineProperty(exports, "RequestTooLargeError", { enumerable: true, get: function () { return errors_js_1.RequestTooLargeError; } });
158
203
  Object.defineProperty(exports, "SessionBusyError", { enumerable: true, get: function () { return errors_js_1.SessionBusyError; } });
159
204
  Object.defineProperty(exports, "SessionNotFoundError", { enumerable: true, get: function () { return errors_js_1.SessionNotFoundError; } });
@@ -49,7 +49,11 @@ class InteractiveSession {
49
49
  else if (ev.type === "session" && ev.session_id) {
50
50
  this.sessionId = ev.session_id;
51
51
  }
52
- this.awaitingInput = ev.type === "awaiting_input";
52
+ // A queued/admitted frame reports the run's admission, not activity in
53
+ // its conversation, so it leaves awaitingInput as it was.
54
+ if (ev.type !== "queued" && ev.type !== "admitted") {
55
+ this.awaitingInput = ev.type === "awaiting_input";
56
+ }
53
57
  yield ev;
54
58
  }
55
59
  }
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ /**
3
+ * Verifying a signed loomcycle hook delivery.
4
+ *
5
+ * A HookDef whose webhook body names a `signing_secret` signs every call it
6
+ * makes. The call carries
7
+ *
8
+ * X-Loomcycle-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256>
9
+ *
10
+ * where the MAC is keyed by the secret and computed over `<t>.<raw body>` —
11
+ * the exact bytes of the request body. Only the body is signed: the body's
12
+ * `delivery_id` (a retry keeps it) is what a receiver dedupes on, and its
13
+ * `hook_name` / `owner` say which hook sent it. `X-Loomcycle-Delivery` and
14
+ * `X-Loomcycle-Hook` are UNSIGNED copies of those two fields — a replayer can
15
+ * change them — so never dedupe or authorize on them.
16
+ * {@link verifyLoomcycleDelivery} verifies and returns the body's identity.
17
+ *
18
+ * Web Crypto only (`crypto.subtle`), so the same function runs in Node and in
19
+ * a browser or edge runtime. It is async because Web Crypto is.
20
+ */
21
+ Object.defineProperty(exports, "__esModule", { value: true });
22
+ exports.verifyLoomcycleSignature = verifyLoomcycleSignature;
23
+ exports.verifyLoomcycleDelivery = verifyLoomcycleDelivery;
24
+ const DEFAULT_TOLERANCE_SEC = 300;
25
+ const HEX_RE = /^(?:[0-9a-fA-F]{2})+$/;
26
+ // What Go's strconv.ParseInt accepts, so a header loomcycle's own receiver
27
+ // would accept is accepted here too.
28
+ const UNIX_RE = /^[+-]?\d+$/;
29
+ /**
30
+ * Reports whether `header` (the `X-Loomcycle-Signature` value) is a valid
31
+ * signature of `rawBody` under `secret`, made within the tolerance of now.
32
+ *
33
+ * `rawBody` must be the request body exactly as received — the string or the
34
+ * bytes, before any JSON parsing. A body parsed and re-serialized will not
35
+ * match: key order, spacing and escapes all change the bytes. A string is
36
+ * hashed as its UTF-8 encoding.
37
+ *
38
+ * Fails closed: a missing or repeated-garbage header, a `t` that is not an
39
+ * integer, a stale or future `t`, a `v1` that is not hex, or a wrong MAC all
40
+ * return `false`, with no reason given. The MAC is compared by Web Crypto's
41
+ * HMAC verify, which compares in constant time. Throws only when no Web Crypto
42
+ * implementation is available (a configuration problem, not a bad request).
43
+ */
44
+ async function verifyLoomcycleSignature(rawBody, header, secret, opts = {}) {
45
+ const subtle = opts.subtle ?? globalThis.crypto?.subtle;
46
+ if (!subtle) {
47
+ throw new TypeError("verifyLoomcycleSignature: no Web Crypto implementation; pass opts.subtle (on Node 18: require('node:crypto').webcrypto.subtle)");
48
+ }
49
+ if (typeof header !== "string" || typeof secret !== "string" || secret === "") {
50
+ return false;
51
+ }
52
+ let ts = "";
53
+ let macHex = "";
54
+ for (const raw of header.split(",")) {
55
+ const part = raw.trim();
56
+ if (part.startsWith("t="))
57
+ ts = part.slice(2);
58
+ else if (part.startsWith("v1="))
59
+ macHex = part.slice(3);
60
+ }
61
+ if (!UNIX_RE.test(ts) || !HEX_RE.test(macHex)) {
62
+ return false;
63
+ }
64
+ const t = Number(ts);
65
+ if (!Number.isSafeInteger(t)) {
66
+ return false;
67
+ }
68
+ const tolerance = opts.toleranceSec ?? DEFAULT_TOLERANCE_SEC;
69
+ const nowSec = (opts.now ?? Date.now()) / 1000;
70
+ if (!(tolerance >= 0) || Math.abs(nowSec - t) > tolerance) {
71
+ return false;
72
+ }
73
+ const enc = new TextEncoder();
74
+ const body = typeof rawBody === "string" ? enc.encode(rawBody) : rawBody;
75
+ // The MAC covers t exactly as written in the header, then ".", then the body.
76
+ const prefix = enc.encode(ts + ".");
77
+ const signed = new Uint8Array(prefix.length + body.length);
78
+ signed.set(prefix, 0);
79
+ signed.set(body, prefix.length);
80
+ const mac = new Uint8Array(macHex.length / 2);
81
+ for (let i = 0; i < mac.length; i++) {
82
+ mac[i] = parseInt(macHex.slice(i * 2, i * 2 + 2), 16);
83
+ }
84
+ try {
85
+ const key = await subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]);
86
+ return await subtle.verify("HMAC", key, mac, signed);
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ /**
93
+ * {@link verifyLoomcycleSignature}, then parse the verified body and return
94
+ * the identity a receiver should act on: its `delivery_id`, `hook_name` and
95
+ * `owner`. Resolves `null` when the signature does not verify, or the body is
96
+ * not a JSON object carrying a non-empty string `delivery_id` and `hook_name`
97
+ * — a signed delivery always has both. Deduplicating on `deliveryId` stays the
98
+ * caller's job.
99
+ */
100
+ async function verifyLoomcycleDelivery(rawBody, header, secret, opts = {}) {
101
+ if (!(await verifyLoomcycleSignature(rawBody, header, secret, opts))) {
102
+ return null;
103
+ }
104
+ let body;
105
+ try {
106
+ body = JSON.parse(typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody));
107
+ }
108
+ catch {
109
+ return null;
110
+ }
111
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
112
+ return null;
113
+ }
114
+ const { delivery_id: deliveryId, hook_name: hookName, owner } = body;
115
+ if (typeof deliveryId !== "string" || deliveryId === "" || typeof hookName !== "string" || hookName === "") {
116
+ return null;
117
+ }
118
+ return { deliveryId, hookName, owner: typeof owner === "string" ? owner : "", body: body };
119
+ }