@standardagents/builder 0.29.2 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/runtime.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { getProviderModelPricing } from '@standardagents/provider-pricing';
2
2
  import { jsonPathReader } from '@formkit/jsonreader';
3
- import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM } from '@standardagents/spec';
3
+ import { ProviderError, mapReasoningLevel, THREAD_STREAM_REASONING_PARAM, isUserEndpoint } from '@standardagents/spec';
4
4
  export { ProviderError, belongsToPackage, defineAgent, defineHook, defineModel, definePrompt, defineTool, isPacked, isVisibleInNamespace, mapReasoningLevel } from '@standardagents/spec';
5
5
  import 'virtual:@standardagents-providers';
6
6
  import { z } from 'zod';
@@ -14089,10 +14089,35 @@ var init_ThreadStateImpl = __esm({
14089
14089
  return { parentThreadId, parentStub };
14090
14090
  }
14091
14091
  _getAgentBuilderStub() {
14092
- const env = this._threadInstance.env;
14092
+ const env = this._getRuntimeEnv();
14093
14093
  const agentBuilderId = env.AGENT_BUILDER.idFromName("singleton");
14094
14094
  return env.AGENT_BUILDER.get(agentBuilderId);
14095
14095
  }
14096
+ /**
14097
+ * Environment bindings for this state. In-DO construction uses the thread
14098
+ * instance's own env; stub-backed states (thread endpoints, the user
14099
+ * scope's `thread()`) have no `instance.env` over RPC and use the env the
14100
+ * dispatcher recorded in the runtime context instead.
14101
+ */
14102
+ _getRuntimeEnv() {
14103
+ const instanceEnv = this._threadInstance.env;
14104
+ const env = instanceEnv?.AGENT_BUILDER ? instanceEnv : this._runtimeContext.env;
14105
+ if (!env?.AGENT_BUILDER) {
14106
+ throw new Error(
14107
+ "No runtime environment available on this ThreadState. Stub-backed states must be constructed with runtimeContext.env."
14108
+ );
14109
+ }
14110
+ return env;
14111
+ }
14112
+ /** The per-user hub stub for this thread's user (user-scope surfaces). */
14113
+ _getUserHubStub(userId) {
14114
+ const env = this._getRuntimeEnv();
14115
+ const namespace = env.AGENT_BUILDER_USER_HUB;
14116
+ if (!namespace) {
14117
+ throw new Error("User scope unavailable: AGENT_BUILDER_USER_HUB binding is missing.");
14118
+ }
14119
+ return namespace.get(namespace.idFromName(userId));
14120
+ }
14096
14121
  // ─────────────────────────────────────────────────────────────────────────
14097
14122
  // Skills — instance-global Agent Skills library, backed by the
14098
14123
  // DurableAgentBuilder singleton (skills are never stored per-thread or on
@@ -14109,7 +14134,67 @@ var init_ThreadStateImpl = __esm({
14109
14134
  const stub = this._getAgentBuilderStub();
14110
14135
  const user = await stub.getUserById(userId).catch(() => null);
14111
14136
  if (!user) return null;
14137
+ const inUserScope = this._runtimeContext.inUserScope === true;
14138
+ const agentId = this._metadata.agent_id;
14139
+ const hub = () => this._getUserHubStub(userId);
14140
+ const capabilities = {
14141
+ call: async (endpoint, payload) => {
14142
+ if (inUserScope) {
14143
+ throw new Error(
14144
+ "Reentrant user-scope call: this ThreadState was raised from user-scope code; state.user().call() is unavailable here."
14145
+ );
14146
+ }
14147
+ const path = endpoint.replace(/^\/+/, "");
14148
+ return hub().fetch(
14149
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/endpoints/${path}`, {
14150
+ method: "POST",
14151
+ headers: {
14152
+ "Content-Type": "application/json",
14153
+ // Runtime-internal caller channel — the public routes overwrite
14154
+ // this header, so only thread-scope code can present "thread".
14155
+ "X-Standard-Agents-Caller": "thread"
14156
+ },
14157
+ body: payload === void 0 ? void 0 : JSON.stringify(payload)
14158
+ })
14159
+ );
14160
+ },
14161
+ getValue: async (key) => {
14162
+ return await stub.getUserValue?.(userId, key);
14163
+ },
14164
+ setValue: async (key, value) => {
14165
+ await stub.setUserValue?.(userId, key, value);
14166
+ },
14167
+ emit: (event, data) => {
14168
+ const delivery = hub().broadcastUserEvent(userId, event, data).catch(() => {
14169
+ });
14170
+ this._pendingEventDeliveries.add(delivery);
14171
+ void delivery.finally(
14172
+ () => this._pendingEventDeliveries.delete(delivery)
14173
+ );
14174
+ },
14175
+ fetch: async (grant, input, init) => {
14176
+ const body = init?.body;
14177
+ if (body !== void 0 && body !== null && typeof body !== "string") {
14178
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
14179
+ }
14180
+ return hub().fetch(
14181
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/grant-fetch`, {
14182
+ method: "POST",
14183
+ headers: { "Content-Type": "application/json" },
14184
+ body: JSON.stringify({
14185
+ grant,
14186
+ url: String(input),
14187
+ method: init?.method ?? "GET",
14188
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
14189
+ body: body ?? null,
14190
+ caller: { kind: "agent", name: agentId }
14191
+ })
14192
+ })
14193
+ );
14194
+ }
14195
+ };
14112
14196
  return {
14197
+ ...capabilities,
14113
14198
  id: user.id,
14114
14199
  username: user.username,
14115
14200
  role: user.role,
@@ -14319,7 +14404,9 @@ function createThreadEndpointHandler(handler) {
14319
14404
  const durableId = env.AGENT_BUILDER_THREAD.idFromName(threadId);
14320
14405
  const stub = env.AGENT_BUILDER_THREAD.get(durableId);
14321
14406
  const instance = stub;
14322
- const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata);
14407
+ const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata, {
14408
+ env
14409
+ });
14323
14410
  const endpointParams = {};
14324
14411
  for (const [key, value] of Object.entries(params)) {
14325
14412
  if (key === "threadId" || key === "id" && !params.threadId) {
@@ -26199,6 +26286,232 @@ function formatMs(ms) {
26199
26286
  const minutes = Math.floor(seconds / 60);
26200
26287
  return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
26201
26288
  }
26289
+
26290
+ // src/users/route-match.ts
26291
+ var METHOD_SUFFIXES = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete"]);
26292
+ function parseUserRouteKey(key) {
26293
+ const parts = key.split("/");
26294
+ let file = parts.pop() ?? "";
26295
+ let method = "GET";
26296
+ const dot = file.lastIndexOf(".");
26297
+ if (dot >= 0) {
26298
+ const suffix = file.slice(dot + 1).toLowerCase();
26299
+ if (METHOD_SUFFIXES.has(suffix)) {
26300
+ method = suffix.toUpperCase();
26301
+ file = file.slice(0, dot);
26302
+ }
26303
+ }
26304
+ const segments = [...parts];
26305
+ if (file !== "index") segments.push(file);
26306
+ let dynamism = 0;
26307
+ for (const segment of segments) {
26308
+ if (segment === "[*]") dynamism = 2;
26309
+ else if (dynamism < 2 && segment.startsWith("[") && segment.endsWith("]")) dynamism = 1;
26310
+ }
26311
+ return { key, method, segments, dynamism };
26312
+ }
26313
+ function matchSegments(route, path) {
26314
+ const params = {};
26315
+ for (let i = 0; i < route.length; i++) {
26316
+ const seg = route[i];
26317
+ if (seg === "[*]") {
26318
+ if (i !== route.length - 1) return null;
26319
+ params["*"] = path.slice(i).join("/");
26320
+ return params;
26321
+ }
26322
+ const value = path[i];
26323
+ if (value === void 0) return null;
26324
+ if (seg.startsWith("[") && seg.endsWith("]")) {
26325
+ params[seg.slice(1, -1)] = value;
26326
+ } else if (seg !== value) {
26327
+ return null;
26328
+ }
26329
+ }
26330
+ return path.length === route.length ? params : null;
26331
+ }
26332
+ function matchUserRoute(keys, method, endpointPath) {
26333
+ const path = endpointPath.split("/").filter((s) => s.length > 0);
26334
+ const wanted = method.toUpperCase();
26335
+ let best = null;
26336
+ for (const key of keys) {
26337
+ const route = parseUserRouteKey(key);
26338
+ if (route.method !== wanted) continue;
26339
+ const params = matchSegments(route.segments, path);
26340
+ if (!params) continue;
26341
+ if (!best || route.dynamism < best.route.dynamism) {
26342
+ best = { route, params };
26343
+ }
26344
+ }
26345
+ return best ? { key: best.route.key, params: best.params } : null;
26346
+ }
26347
+
26348
+ // src/users/UserStateImpl.ts
26349
+ init_ThreadStateImpl();
26350
+ var UserStateImpl = class {
26351
+ userId;
26352
+ host;
26353
+ /** Caller descriptor for grant `allow` policy checks. */
26354
+ caller;
26355
+ constructor(userId, host, caller) {
26356
+ this.userId = userId;
26357
+ this.host = host;
26358
+ this.caller = caller;
26359
+ }
26360
+ singleton() {
26361
+ const ns = this.host.env.AGENT_BUILDER;
26362
+ if (!ns) throw new Error("AGENT_BUILDER binding unavailable in the user scope.");
26363
+ return ns.get(ns.idFromName("singleton"));
26364
+ }
26365
+ async identity() {
26366
+ const user = await this.singleton().getUserById(this.userId);
26367
+ if (!user) throw new Error(`Unknown user: ${this.userId}`);
26368
+ return {
26369
+ id: user.id,
26370
+ username: user.username,
26371
+ role: user.role,
26372
+ permissions: user.permissions ?? null,
26373
+ email: user.email ?? null,
26374
+ display_name: user.display_name ?? null,
26375
+ avatar_url: user.avatar_url ?? null
26376
+ };
26377
+ }
26378
+ async getValue(key) {
26379
+ return await this.host.getUserValue(this.userId, key);
26380
+ }
26381
+ async setValue(key, value) {
26382
+ await this.host.setUserValue(this.userId, key, value);
26383
+ }
26384
+ async listValues(options) {
26385
+ const { entries, total } = await this.host.listUserValues(this.userId, options);
26386
+ return { entries, total };
26387
+ }
26388
+ async env(name) {
26389
+ const values = await this.singleton().getUserEnv(this.userId);
26390
+ const value = values[name];
26391
+ if (value === void 0) throw new Error(`Environment variable not found: ${name}`);
26392
+ return value;
26393
+ }
26394
+ emit(event, data) {
26395
+ this.host.broadcastUserEvent(this.userId, event, data);
26396
+ }
26397
+ async listThreads(options) {
26398
+ const { threads, total } = await this.singleton().listThreads({
26399
+ user_id: this.userId,
26400
+ agent_name: options?.agentId,
26401
+ limit: options?.limit,
26402
+ offset: options?.offset
26403
+ });
26404
+ return {
26405
+ threads: threads.map((t) => ({
26406
+ id: t.id,
26407
+ agent_id: t.agent_id ?? t.agent_name ?? "",
26408
+ tags: t.tags ?? [],
26409
+ created_at: t.created_at
26410
+ })),
26411
+ total
26412
+ };
26413
+ }
26414
+ async fetch(grant, input, init) {
26415
+ const body = init?.body;
26416
+ if (body !== void 0 && body !== null && typeof body !== "string") {
26417
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
26418
+ }
26419
+ return this.host.grantFetch(this.userId, grant, {
26420
+ url: String(input),
26421
+ method: init?.method ?? "GET",
26422
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
26423
+ body: body ?? null,
26424
+ caller: this.caller
26425
+ });
26426
+ }
26427
+ /**
26428
+ * Raise one of this user's threads at rest — the same stub-backed
26429
+ * materialization thread endpoints receive. `inUserScope` marks the state
26430
+ * so a reentrant `state.user().call()` fails instead of deadlocking the
26431
+ * serialized user execution (spec: Users §2.2.3).
26432
+ */
26433
+ async thread(threadId) {
26434
+ const metadata = await this.singleton().getThread(threadId);
26435
+ if (!metadata) throw new Error(`Thread not found: ${threadId}`);
26436
+ if (metadata.user_id !== this.userId) {
26437
+ throw new Error(`Thread ${threadId} does not belong to user ${this.userId}.`);
26438
+ }
26439
+ const meta = metadata;
26440
+ if (!meta.agent_id && meta.agent_name) meta.agent_id = meta.agent_name;
26441
+ const ns = this.host.env.AGENT_BUILDER_THREAD;
26442
+ if (!ns) throw new Error("AGENT_BUILDER_THREAD binding unavailable in the user scope.");
26443
+ const instance = ns.get(ns.idFromName(threadId));
26444
+ return ThreadStateImpl.fromThreadInstance(instance, meta, {
26445
+ env: this.host.env,
26446
+ inUserScope: true
26447
+ });
26448
+ }
26449
+ };
26450
+
26451
+ // src/users/serialize.ts
26452
+ function createExecutionChain() {
26453
+ let tail2 = Promise.resolve();
26454
+ return {
26455
+ run(fn) {
26456
+ const next = tail2.then(fn, fn);
26457
+ tail2 = next.catch(() => {
26458
+ });
26459
+ return next;
26460
+ }
26461
+ };
26462
+ }
26463
+
26464
+ // src/users/grants.ts
26465
+ function originAllowed(origins, target) {
26466
+ if (!Array.isArray(origins)) return false;
26467
+ for (const allowed of origins) {
26468
+ if (allowed.startsWith("*.")) {
26469
+ const suffix = allowed.slice(1);
26470
+ if (target.protocol === "https:" && target.hostname.endsWith(suffix)) return true;
26471
+ } else {
26472
+ try {
26473
+ if (new URL(allowed).origin === target.origin) return true;
26474
+ } catch {
26475
+ }
26476
+ }
26477
+ }
26478
+ return false;
26479
+ }
26480
+ function callerAllowed(allow, caller) {
26481
+ if (!allow) return false;
26482
+ const lists = {
26483
+ tool: allow.tools,
26484
+ endpoint: allow.endpoints,
26485
+ userEndpoint: allow.userEndpoints,
26486
+ userHook: allow.userEndpoints,
26487
+ agent: allow.agents
26488
+ };
26489
+ const list = lists[caller.kind];
26490
+ if (!list) return false;
26491
+ const bare = caller.name.replace(/\.(get|post|put|patch|delete)$/i, "");
26492
+ return list.includes(caller.name) || list.includes(bare);
26493
+ }
26494
+ function injectGrantCredential(config, secret, target, headers) {
26495
+ const format = config.inject?.format ?? "SECRET";
26496
+ switch (config.kind) {
26497
+ case "bearer":
26498
+ headers.set("Authorization", `Bearer ${secret}`);
26499
+ return true;
26500
+ case "header":
26501
+ headers.set(config.inject?.header ?? "Authorization", format.replace(/SECRET/g, secret));
26502
+ return true;
26503
+ case "query":
26504
+ target.searchParams.set(config.inject?.query ?? "key", secret);
26505
+ return true;
26506
+ case "basic":
26507
+ headers.set("Authorization", `Basic ${btoa(secret)}`);
26508
+ return true;
26509
+ default:
26510
+ return false;
26511
+ }
26512
+ }
26513
+
26514
+ // src/durable-objects/DurableUserHub.ts
26202
26515
  var DurableUserHub = class extends DurableObject {
26203
26516
  /** Persistent client (daemon) sockets, restored across hibernation. */
26204
26517
  clientSockets = /* @__PURE__ */ new Set();
@@ -26206,11 +26519,9 @@ var DurableUserHub = class extends DurableObject {
26206
26519
  migrated = false;
26207
26520
  constructor(ctx, env) {
26208
26521
  super(ctx, env);
26209
- if (typeof WebSocketRequestResponsePair !== "undefined") {
26210
- ctx.setWebSocketAutoResponse(
26211
- new WebSocketRequestResponsePair("stream_ping", "stream_pong")
26212
- );
26213
- }
26522
+ ctx.blockConcurrencyWhile(async () => {
26523
+ this.ensureMigrated();
26524
+ });
26214
26525
  for (const ws of ctx.getWebSockets()) {
26215
26526
  const attachment = ws.deserializeAttachment();
26216
26527
  if (attachment?.type === "hub_client") {
@@ -26238,8 +26549,38 @@ var DurableUserHub = class extends DurableObject {
26238
26549
  this.ctx.storage.sql.exec(
26239
26550
  `CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)`
26240
26551
  );
26552
+ this.ctx.storage.sql.exec(`
26553
+ CREATE TABLE IF NOT EXISTS user_grants (
26554
+ user_id TEXT NOT NULL,
26555
+ name TEXT NOT NULL,
26556
+ secret TEXT NOT NULL,
26557
+ config TEXT NOT NULL,
26558
+ created_at INTEGER NOT NULL,
26559
+ updated_at INTEGER NOT NULL,
26560
+ PRIMARY KEY (user_id, name)
26561
+ )
26562
+ `);
26241
26563
  this.migrated = true;
26242
26564
  }
26565
+ // ─── User scope: registries (overridden by the generated virtual module) ──
26566
+ /**
26567
+ * Returns the user endpoints registry for lazy-loading `users/` files.
26568
+ * Keys are endpoint file paths relative to `users/` with the extension
26569
+ * stripped except the method suffix (e.g. `standardcode/lease_acquire.post`).
26570
+ * Implemented when DurableUserHub is imported from
26571
+ * 'virtual:@standardagents/builder'.
26572
+ */
26573
+ userEndpoints() {
26574
+ return {};
26575
+ }
26576
+ /**
26577
+ * Returns the hooks registry (same registry the thread runtime uses). The
26578
+ * hub filters it to user-scope hook types at dispatch time. Implemented
26579
+ * when DurableUserHub is imported from 'virtual:@standardagents/builder'.
26580
+ */
26581
+ userHooks() {
26582
+ return {};
26583
+ }
26243
26584
  // ─── Persistent client socket ──────────────────────────────────────────────
26244
26585
  /**
26245
26586
  * Accept the user's persistent client (daemon) WebSocket. The daemon holds
@@ -26248,6 +26589,16 @@ var DurableUserHub = class extends DurableObject {
26248
26589
  */
26249
26590
  async fetch(request) {
26250
26591
  const url = new URL(request.url);
26592
+ const scopeMatch = /^\/users\/([^/]+)\/(stream|endpoints|grant-fetch)(?:\/(.*))?$/.exec(
26593
+ url.pathname
26594
+ );
26595
+ if (scopeMatch) {
26596
+ const userId = decodeURIComponent(scopeMatch[1]);
26597
+ const kind = scopeMatch[2];
26598
+ if (kind === "stream") return this.acceptUserStream(request, url, userId);
26599
+ if (kind === "grant-fetch") return this.handleGrantFetch(request, userId);
26600
+ return this.runUserEndpoint(request, userId, scopeMatch[3] ?? "");
26601
+ }
26251
26602
  if (request.headers.get("Upgrade") === "websocket") {
26252
26603
  const pair = new WebSocketPair();
26253
26604
  const [client, server] = [pair[0], pair[1]];
@@ -26256,6 +26607,7 @@ var DurableUserHub = class extends DurableObject {
26256
26607
  clientName: url.searchParams.get("client_name"),
26257
26608
  clientKind: url.searchParams.get("client_kind")
26258
26609
  };
26610
+ this.ensureHeartbeatAutoResponse();
26259
26611
  this.ctx.acceptWebSocket(server);
26260
26612
  server.serializeAttachment({ type: "hub_client", ...info });
26261
26613
  this.clientSockets.add(server);
@@ -26268,16 +26620,265 @@ var DurableUserHub = class extends DurableObject {
26268
26620
  }
26269
26621
  return new Response("Not found", { status: 404 });
26270
26622
  }
26271
- async webSocketMessage(ws, _message) {
26272
- if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
26623
+ // ─── User scope: event stream (spec: Users §4) ─────────────────────────────
26624
+ /** Idle sockets answer heartbeats at the edge without waking the object.
26625
+ * Registered at accept time rather than in the constructor so a hub that
26626
+ * never accepts a socket stays fully evictable (this also keeps
26627
+ * vitest-pool-workers' isolated storage able to pop the DO's frame). */
26628
+ ensureHeartbeatAutoResponse() {
26629
+ if (typeof WebSocketRequestResponsePair !== "undefined") {
26630
+ this.ctx.setWebSocketAutoResponse(
26631
+ new WebSocketRequestResponsePair("stream_ping", "stream_pong")
26632
+ );
26633
+ }
26634
+ }
26635
+ /** Sockets attached to the user event stream, restored after hibernation. */
26636
+ streamSockets() {
26637
+ return this.ctx.getWebSockets().filter((ws) => ws.deserializeAttachment()?.type === "user_stream");
26638
+ }
26639
+ acceptUserStream(request, url, userId) {
26640
+ if (request.headers.get("Upgrade") !== "websocket") {
26641
+ return Response.json({ error: "WebSocket upgrade required" }, { status: 426 });
26642
+ }
26643
+ const pair = new WebSocketPair();
26644
+ const [client, server] = [pair[0], pair[1]];
26645
+ const clientId = url.searchParams.get("client_id") ?? crypto.randomUUID();
26646
+ const clientKind = url.searchParams.get("client_kind");
26647
+ this.ensureHeartbeatAutoResponse();
26648
+ this.ctx.acceptWebSocket(server);
26649
+ server.serializeAttachment({ type: "user_stream", userId, clientId, clientKind });
26650
+ try {
26651
+ server.send(JSON.stringify({ event: "stream_ready", data: { client_id: clientId } }));
26652
+ } catch {
26653
+ }
26654
+ this.dispatchUserHooks(userId, "user_client_attached", [
26655
+ { id: clientId, kind: clientKind ?? void 0 }
26656
+ ]);
26657
+ return new Response(null, { status: 101, webSocket: client });
26658
+ }
26659
+ /**
26660
+ * Deliver an event frame to every socket attached to this user's stream.
26661
+ * At-most-once, live-only (spec: Users §4) — no history, no acks.
26662
+ */
26663
+ broadcastUserEvent(userId, event, data) {
26664
+ const frame = JSON.stringify({ event, data });
26665
+ for (const ws of this.streamSockets()) {
26666
+ const attachment = ws.deserializeAttachment();
26667
+ if (attachment?.userId !== userId) continue;
26668
+ try {
26669
+ ws.send(frame);
26670
+ } catch {
26671
+ }
26672
+ }
26673
+ }
26674
+ // ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
26675
+ /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
26676
+ executionChain = createExecutionChain();
26677
+ scopeHost() {
26678
+ return {
26679
+ getUserValue: (userId, key) => this.getUserValue(userId, key),
26680
+ setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
26681
+ listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
26682
+ broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
26683
+ grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
26684
+ env: this.env
26685
+ };
26686
+ }
26687
+ async runUserEndpoint(request, userId, endpointPath) {
26688
+ const registry = this.userEndpoints();
26689
+ const match = matchUserRoute(Object.keys(registry), request.method, endpointPath);
26690
+ if (!match) return Response.json({ error: "Not found" }, { status: 404 });
26691
+ let loaded;
26692
+ try {
26693
+ loaded = await registry[match.key]();
26694
+ } catch (error) {
26695
+ return Response.json(
26696
+ { error: `Failed to load user endpoint: ${match.key}` },
26697
+ { status: 500 }
26698
+ );
26699
+ }
26700
+ const module = loaded;
26701
+ const handler = module.default ?? loaded;
26702
+ if (!isUserEndpoint(handler)) {
26703
+ return Response.json(
26704
+ { error: `${match.key} is not a defineUserEndpoint export` },
26705
+ { status: 500 }
26706
+ );
26707
+ }
26708
+ return this.executionChain.run(async () => {
26709
+ const user = new UserStateImpl(userId, this.scopeHost(), {
26710
+ kind: "userEndpoint",
26711
+ name: match.key
26712
+ });
26713
+ try {
26714
+ return await handler.__handler(request, user, match.params);
26715
+ } catch (error) {
26716
+ return Response.json(
26717
+ { error: error instanceof Error ? error.message : "User endpoint failed" },
26718
+ { status: 500 }
26719
+ );
26720
+ }
26721
+ });
26722
+ }
26723
+ // ─── User scope: lifecycle hooks (spec: Users §7) ──────────────────────────
26724
+ /**
26725
+ * Fire every definition of a user hook type. Observational and best-effort:
26726
+ * failures are swallowed and dispatch never blocks the triggering
26727
+ * operation. Nested dispatch is suppressed so a `user_value_changed` hook
26728
+ * writing values cannot recurse.
26729
+ */
26730
+ inUserHookDispatch = false;
26731
+ dispatchUserHooks(userId, hookType, args) {
26732
+ if (this.inUserHookDispatch) return;
26733
+ const registry = this.userHooks();
26734
+ const names = Object.keys(registry);
26735
+ if (names.length === 0) return;
26736
+ const run = async () => {
26737
+ const user = new UserStateImpl(userId, this.scopeHost(), {
26738
+ kind: "userHook",
26739
+ name: hookType
26740
+ });
26741
+ for (const name of names) {
26742
+ try {
26743
+ const loaded = await registry[name]();
26744
+ const definition = loaded.default ?? loaded;
26745
+ if (definition?.hook !== hookType || typeof definition.execute !== "function") {
26746
+ continue;
26747
+ }
26748
+ this.inUserHookDispatch = true;
26749
+ try {
26750
+ await definition.execute(user, ...args);
26751
+ } finally {
26752
+ this.inUserHookDispatch = false;
26753
+ }
26754
+ } catch {
26755
+ }
26756
+ }
26757
+ };
26758
+ this.ctx.waitUntil(run());
26273
26759
  }
26274
26760
  async webSocketClose(ws) {
26761
+ const attachment = ws.deserializeAttachment();
26762
+ if (attachment?.type === "user_stream") {
26763
+ this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
26764
+ {
26765
+ id: typeof attachment.clientId === "string" ? attachment.clientId : "unknown",
26766
+ kind: typeof attachment.clientKind === "string" ? attachment.clientKind : void 0
26767
+ }
26768
+ ]);
26769
+ return;
26770
+ }
26275
26771
  this.clientSockets.delete(ws);
26276
26772
  this.clientInfo.delete(ws);
26277
26773
  }
26774
+ // ─── User scope: credential grants (spec: Users §6) ───────────────────────
26775
+ /**
26776
+ * Store or replace a grant. `secret` is write-only from this point on.
26777
+ * Policy (`CredentialGrant` minus the secret) is kept as JSON config.
26778
+ */
26779
+ async setGrant(userId, grant, secret) {
26780
+ this.ensureMigrated();
26781
+ const { name, ...config } = grant;
26782
+ const now = Date.now();
26783
+ await this.ctx.storage.sql.exec(
26784
+ `INSERT INTO user_grants (user_id, name, secret, config, created_at, updated_at)
26785
+ VALUES (?1, ?2, ?3, ?4, ?5, ?5)
26786
+ ON CONFLICT(user_id, name) DO UPDATE SET
26787
+ secret = excluded.secret, config = excluded.config, updated_at = excluded.updated_at`,
26788
+ userId,
26789
+ name,
26790
+ secret,
26791
+ JSON.stringify(config),
26792
+ now
26793
+ );
26794
+ }
26795
+ async deleteGrant(userId, name) {
26796
+ this.ensureMigrated();
26797
+ await this.ctx.storage.sql.exec(
26798
+ `DELETE FROM user_grants WHERE user_id = ? AND name = ?`,
26799
+ userId,
26800
+ name
26801
+ );
26802
+ }
26803
+ /** Metadata-only listing — never returns secret material. */
26804
+ async listGrants(userId) {
26805
+ this.ensureMigrated();
26806
+ const cursor = await this.ctx.storage.sql.exec(
26807
+ `SELECT name, config FROM user_grants WHERE user_id = ? ORDER BY name ASC`,
26808
+ userId
26809
+ );
26810
+ return cursor.toArray().map((row) => {
26811
+ let config = {};
26812
+ try {
26813
+ config = JSON.parse(row.config);
26814
+ } catch {
26815
+ }
26816
+ return { name: row.name, ...config };
26817
+ });
26818
+ }
26819
+ /** Perform a privileged fetch with the grant's credential injected. */
26820
+ async grantFetch(userId, grantName, req) {
26821
+ this.ensureMigrated();
26822
+ const cursor = await this.ctx.storage.sql.exec(
26823
+ `SELECT secret, config FROM user_grants WHERE user_id = ? AND name = ? LIMIT 1`,
26824
+ userId,
26825
+ grantName
26826
+ );
26827
+ const row = cursor.toArray()[0];
26828
+ if (!row) {
26829
+ return Response.json({ error: "grant_not_found", grant: grantName }, { status: 404 });
26830
+ }
26831
+ let config;
26832
+ try {
26833
+ config = JSON.parse(row.config);
26834
+ } catch {
26835
+ return Response.json({ error: "grant_config_invalid" }, { status: 500 });
26836
+ }
26837
+ let target;
26838
+ try {
26839
+ target = new URL(req.url);
26840
+ } catch {
26841
+ return Response.json({ error: "grant_target_invalid" }, { status: 400 });
26842
+ }
26843
+ if (!originAllowed(config.origins, target)) {
26844
+ return Response.json({ error: "grant_origin_denied" }, { status: 403 });
26845
+ }
26846
+ if (!callerAllowed(config.allow, req.caller)) {
26847
+ return Response.json({ error: "grant_caller_denied" }, { status: 403 });
26848
+ }
26849
+ const headers = new Headers(req.headers);
26850
+ if (!injectGrantCredential(config, row.secret, target, headers)) {
26851
+ return Response.json({ error: "grant_kind_unsupported" }, { status: 501 });
26852
+ }
26853
+ try {
26854
+ const response = await fetch(target.toString(), {
26855
+ method: req.method,
26856
+ headers,
26857
+ body: req.body ?? void 0
26858
+ });
26859
+ return new Response(response.body, { status: response.status, headers: response.headers });
26860
+ } catch {
26861
+ return Response.json({ error: "grant_fetch_failed" }, { status: 502 });
26862
+ }
26863
+ }
26864
+ async handleGrantFetch(request, userId) {
26865
+ const body = await request.json().catch(() => null);
26866
+ if (!body?.grant || !body.url) {
26867
+ return Response.json({ error: "grant and url required" }, { status: 400 });
26868
+ }
26869
+ return this.grantFetch(userId, body.grant, {
26870
+ url: body.url,
26871
+ method: body.method ?? "GET",
26872
+ headers: body.headers ?? {},
26873
+ body: body.body ?? null,
26874
+ caller: body.caller ?? { kind: "unknown", name: "unknown" }
26875
+ });
26876
+ }
26877
+ async webSocketMessage(ws, _message) {
26878
+ if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
26879
+ }
26278
26880
  async webSocketError(ws) {
26279
- this.clientSockets.delete(ws);
26280
- this.clientInfo.delete(ws);
26881
+ await this.webSocketClose(ws);
26281
26882
  }
26282
26883
  /** Whether a persistent client (daemon) is connected right now. */
26283
26884
  async hasClient() {
@@ -26311,6 +26912,17 @@ var DurableUserHub = class extends DurableObject {
26311
26912
  this.clientInfo.delete(ws);
26312
26913
  }
26313
26914
  }
26915
+ const wakeEvent = JSON.stringify({
26916
+ event: "standardagents.wake",
26917
+ data: { thread_id: threadId }
26918
+ });
26919
+ for (const ws of this.streamSockets()) {
26920
+ try {
26921
+ ws.send(wakeEvent);
26922
+ delivered = true;
26923
+ } catch {
26924
+ }
26925
+ }
26314
26926
  return { delivered };
26315
26927
  }
26316
26928
  // ─── Account-wide KV (sharded off the singleton) ───────────────────────────
@@ -26337,6 +26949,7 @@ var DurableUserHub = class extends DurableObject {
26337
26949
  userId,
26338
26950
  key
26339
26951
  );
26952
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
26340
26953
  return;
26341
26954
  }
26342
26955
  const serialized = JSON.stringify(value);
@@ -26352,6 +26965,7 @@ var DurableUserHub = class extends DurableObject {
26352
26965
  serialized,
26353
26966
  now
26354
26967
  );
26968
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
26355
26969
  }
26356
26970
  async listUserValues(userId, options = {}) {
26357
26971
  this.ensureMigrated();