@standardagents/builder 0.29.1 → 0.30.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/index.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, defineProvider, defineTool, isPacked, isVisibleInNamespace, mapReasoningLevel, providerConst, providerEnv, providerValue } from '@standardagents/spec';
5
5
  import 'virtual:@standardagents-providers';
6
6
  import { z } from 'zod';
@@ -13545,7 +13545,10 @@ var init_ThreadStateImpl = __esm({
13545
13545
  data: { deferred: true, toolCallId }
13546
13546
  };
13547
13547
  }
13548
- const local = await this._threadInstance.callClientTool(request);
13548
+ const local = await this._threadInstance.callClientTool(
13549
+ request,
13550
+ canPark && toolCallId ? { toolCallId, side } : void 0
13551
+ );
13549
13552
  if (local.ok || !this.isNoClient(local)) return local;
13550
13553
  const env = this._threadInstance.env;
13551
13554
  if (env?.AGENT_BUILDER_THREAD) {
@@ -14095,10 +14098,35 @@ var init_ThreadStateImpl = __esm({
14095
14098
  return { parentThreadId, parentStub };
14096
14099
  }
14097
14100
  _getAgentBuilderStub() {
14098
- const env = this._threadInstance.env;
14101
+ const env = this._getRuntimeEnv();
14099
14102
  const agentBuilderId = env.AGENT_BUILDER.idFromName("singleton");
14100
14103
  return env.AGENT_BUILDER.get(agentBuilderId);
14101
14104
  }
14105
+ /**
14106
+ * Environment bindings for this state. In-DO construction uses the thread
14107
+ * instance's own env; stub-backed states (thread endpoints, the user
14108
+ * scope's `thread()`) have no `instance.env` over RPC and use the env the
14109
+ * dispatcher recorded in the runtime context instead.
14110
+ */
14111
+ _getRuntimeEnv() {
14112
+ const instanceEnv = this._threadInstance.env;
14113
+ const env = instanceEnv?.AGENT_BUILDER ? instanceEnv : this._runtimeContext.env;
14114
+ if (!env?.AGENT_BUILDER) {
14115
+ throw new Error(
14116
+ "No runtime environment available on this ThreadState. Stub-backed states must be constructed with runtimeContext.env."
14117
+ );
14118
+ }
14119
+ return env;
14120
+ }
14121
+ /** The per-user hub stub for this thread's user (user-scope surfaces). */
14122
+ _getUserHubStub(userId) {
14123
+ const env = this._getRuntimeEnv();
14124
+ const namespace = env.AGENT_BUILDER_USER_HUB;
14125
+ if (!namespace) {
14126
+ throw new Error("User scope unavailable: AGENT_BUILDER_USER_HUB binding is missing.");
14127
+ }
14128
+ return namespace.get(namespace.idFromName(userId));
14129
+ }
14102
14130
  // ─────────────────────────────────────────────────────────────────────────
14103
14131
  // Skills — instance-global Agent Skills library, backed by the
14104
14132
  // DurableAgentBuilder singleton (skills are never stored per-thread or on
@@ -14115,7 +14143,67 @@ var init_ThreadStateImpl = __esm({
14115
14143
  const stub = this._getAgentBuilderStub();
14116
14144
  const user = await stub.getUserById(userId).catch(() => null);
14117
14145
  if (!user) return null;
14146
+ const inUserScope = this._runtimeContext.inUserScope === true;
14147
+ const agentId = this._metadata.agent_id;
14148
+ const hub = () => this._getUserHubStub(userId);
14149
+ const capabilities = {
14150
+ call: async (endpoint, payload) => {
14151
+ if (inUserScope) {
14152
+ throw new Error(
14153
+ "Reentrant user-scope call: this ThreadState was raised from user-scope code; state.user().call() is unavailable here."
14154
+ );
14155
+ }
14156
+ const path20 = endpoint.replace(/^\/+/, "");
14157
+ return hub().fetch(
14158
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/endpoints/${path20}`, {
14159
+ method: "POST",
14160
+ headers: {
14161
+ "Content-Type": "application/json",
14162
+ // Runtime-internal caller channel — the public routes overwrite
14163
+ // this header, so only thread-scope code can present "thread".
14164
+ "X-Standard-Agents-Caller": "thread"
14165
+ },
14166
+ body: payload === void 0 ? void 0 : JSON.stringify(payload)
14167
+ })
14168
+ );
14169
+ },
14170
+ getValue: async (key) => {
14171
+ return await stub.getUserValue?.(userId, key);
14172
+ },
14173
+ setValue: async (key, value) => {
14174
+ await stub.setUserValue?.(userId, key, value);
14175
+ },
14176
+ emit: (event, data) => {
14177
+ const delivery = hub().broadcastUserEvent(userId, event, data).catch(() => {
14178
+ });
14179
+ this._pendingEventDeliveries.add(delivery);
14180
+ void delivery.finally(
14181
+ () => this._pendingEventDeliveries.delete(delivery)
14182
+ );
14183
+ },
14184
+ fetch: async (grant, input, init) => {
14185
+ const body = init?.body;
14186
+ if (body !== void 0 && body !== null && typeof body !== "string") {
14187
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
14188
+ }
14189
+ return hub().fetch(
14190
+ new Request(`https://user-hub/users/${encodeURIComponent(userId)}/grant-fetch`, {
14191
+ method: "POST",
14192
+ headers: { "Content-Type": "application/json" },
14193
+ body: JSON.stringify({
14194
+ grant,
14195
+ url: String(input),
14196
+ method: init?.method ?? "GET",
14197
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
14198
+ body: body ?? null,
14199
+ caller: { kind: "agent", name: agentId }
14200
+ })
14201
+ })
14202
+ );
14203
+ }
14204
+ };
14118
14205
  return {
14206
+ ...capabilities,
14119
14207
  id: user.id,
14120
14208
  username: user.username,
14121
14209
  role: user.role,
@@ -15473,8 +15561,9 @@ function createHmrReloader() {
15473
15561
  const isPromptFile = file.startsWith(dirs.promptsDir) && file.endsWith(".ts");
15474
15562
  const isAgentFile = file.startsWith(dirs.agentsDir) && file.endsWith(".ts");
15475
15563
  const isEffectFile = file.startsWith(dirs.effectsDir) && file.endsWith(".ts");
15564
+ const isUserEndpointFile = file.startsWith(dirs.usersDir) && file.endsWith(".ts");
15476
15565
  const isConfigFile = isModelFile || isProviderFile || isPromptFile || isAgentFile || isEffectFile;
15477
- if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile) return;
15566
+ if (!isToolFile && !isThreadApiFile && !isHookFile && !isConfigFile && !isUserEndpointFile) return;
15478
15567
  reloader.invalidateSourceFileModule(server, file);
15479
15568
  if (isConfigFile || isToolFile) regenerateTypes();
15480
15569
  if (isToolFile) await reloader.reloadToolsModule(server);
@@ -17500,6 +17589,35 @@ function parseRequestBody(req) {
17500
17589
  req.on("error", reject);
17501
17590
  });
17502
17591
  }
17592
+ function scanUsersDirectory(dir) {
17593
+ const results = [];
17594
+ if (!fs8__default.existsSync(dir)) return results;
17595
+ const walk = (current, prefix) => {
17596
+ let entries;
17597
+ try {
17598
+ entries = fs8__default.readdirSync(current, { withFileTypes: true });
17599
+ } catch (error) {
17600
+ console.error(`Error scanning users directory ${current}:`, error);
17601
+ return;
17602
+ }
17603
+ for (const entry of entries) {
17604
+ const fullPath = path8__default.join(current, entry.name);
17605
+ if (entry.isDirectory()) {
17606
+ walk(fullPath, prefix ? `${prefix}/${entry.name}` : entry.name);
17607
+ continue;
17608
+ }
17609
+ if (!entry.name.endsWith(".ts") || entry.name.endsWith(".d.ts")) continue;
17610
+ const base = entry.name.slice(0, -3);
17611
+ const key = prefix ? `${prefix}/${base}` : base;
17612
+ results.push({
17613
+ key,
17614
+ importPath: "./" + path8__default.relative(process.cwd(), fullPath).replace(/\\/g, "/")
17615
+ });
17616
+ }
17617
+ };
17618
+ walk(dir, "");
17619
+ return results;
17620
+ }
17503
17621
 
17504
17622
  // src/plugin/agent-api-middleware.ts
17505
17623
  async function handleAgentApiRequest(context) {
@@ -21097,9 +21215,10 @@ function createDevMiddleware(server, context) {
21097
21215
  });
21098
21216
  }
21099
21217
  async function loadBuilderVirtualModule(context) {
21100
- const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, hmr } = context;
21218
+ const { toolsDir, hooksDir, modelsDir, promptsDir, agentsDir, effectsDir, usersDir, hmr } = context;
21101
21219
  const tools = await scanToolsDirectory(toolsDir);
21102
21220
  const hooks = await scanHooksDirectory(hooksDir);
21221
+ const userEndpoints = scanUsersDirectory(usersDir);
21103
21222
  const models = await scanModelsDirectory(modelsDir);
21104
21223
  const prompts = await scanPromptsDirectory(promptsDir);
21105
21224
  const agents = await scanAgentsDirectory(agentsDir);
@@ -21201,6 +21320,17 @@ async function loadBuilderVirtualModule(context) {
21201
21320
  },`;
21202
21321
  }
21203
21322
  }).join("\n");
21323
+ const userEndpointsCode = userEndpoints.map(({ key, importPath }) => {
21324
+ const absPath = toAbsolutePath(importPath);
21325
+ return ` "${key}": async () => {
21326
+ try {
21327
+ return (await import("${absPath}")).default;
21328
+ } catch (error) {
21329
+ console.error('Failed to import user endpoint ${key}:', error);
21330
+ throw error;
21331
+ }
21332
+ },`;
21333
+ }).join("\n");
21204
21334
  return `// Consolidated virtual module: virtual:@standardagents/builder /* v${hmr.versions.builder} */
21205
21335
  // Provides DurableThread, DurableAgentBuilder, ThreadWatchdog, and router
21206
21336
  import { DurableThread as _BaseDurableThread } from '@standardagents/builder/runtime';
@@ -21246,6 +21376,10 @@ const _effects = {
21246
21376
  ${effectsCode}
21247
21377
  };
21248
21378
 
21379
+ const _userEndpoints = {
21380
+ ${userEndpointsCode}
21381
+ };
21382
+
21249
21383
  /**
21250
21384
  * DurableThread with all virtual module methods already implemented.
21251
21385
  * Exported by the generated AgentBuilder Worker entry; user projects do not
@@ -21335,10 +21469,21 @@ export class CodeExecutionBridge extends _BaseCodeExecutionBridge {}
21335
21469
  export class ThreadWatchdog extends _BaseThreadWatchdog {}
21336
21470
 
21337
21471
  /**
21338
- * Per-user hub companion. Coordination only (persistent daemon socket, wake
21339
- * signal, sharded account KV); needs no virtual-module registries.
21472
+ * Per-user hub companion: persistent daemon socket, wake signal, sharded
21473
+ * account KV \u2014 and the USER SCOPE (spec: Users): packaged user endpoints from
21474
+ * the users/ directory executed under per-user serialization, the user event
21475
+ * stream, and user lifecycle hooks (filtered from the shared hooks registry
21476
+ * by hook type at dispatch).
21340
21477
  */
21341
- export class DurableUserHub extends _BaseDurableUserHub {}
21478
+ export class DurableUserHub extends _BaseDurableUserHub {
21479
+ userEndpoints() {
21480
+ return _userEndpoints;
21481
+ }
21482
+
21483
+ userHooks() {
21484
+ return _hooks;
21485
+ }
21486
+ }
21342
21487
  `;
21343
21488
  }
21344
21489
  async function loadRegistryVirtualModule(context) {
@@ -22394,6 +22539,7 @@ function agentbuilder(options = {}) {
22394
22539
  const promptsDir = options.promptsDir ? path8__default.resolve(process.cwd(), options.promptsDir) : path8__default.resolve(process.cwd(), "agents/prompts");
22395
22540
  const agentsDir = options.agentsDir ? path8__default.resolve(process.cwd(), options.agentsDir) : path8__default.resolve(process.cwd(), "agents/agents");
22396
22541
  const effectsDir = options.effectsDir ? path8__default.resolve(process.cwd(), options.effectsDir) : path8__default.resolve(process.cwd(), "agents/effects");
22542
+ const usersDir = options.usersDir ? path8__default.resolve(process.cwd(), options.usersDir) : path8__default.resolve(process.cwd(), "agents/users");
22397
22543
  const outputDir = path8__default.resolve(process.cwd(), ".agents");
22398
22544
  const generatedWorkerEntry = path8__default.join(outputDir, "worker.ts");
22399
22545
  const isProviderPackageInstalled = (packageName) => {
@@ -22456,6 +22602,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22456
22602
  toolsDir,
22457
22603
  hooksDir,
22458
22604
  threadApiDir,
22605
+ usersDir,
22459
22606
  modelsDir,
22460
22607
  providersDir,
22461
22608
  promptsDir,
@@ -22466,6 +22613,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22466
22613
  toolsDir,
22467
22614
  hooksDir,
22468
22615
  threadApiDir,
22616
+ usersDir,
22469
22617
  modelsDir,
22470
22618
  providersDir,
22471
22619
  promptsDir,
@@ -22553,6 +22701,7 @@ export { CodeExecutionBridge, DurableAgentBuilder, DurableThread, ThreadWatchdog
22553
22701
  this.addWatchFile(promptsDir);
22554
22702
  this.addWatchFile(agentsDir);
22555
22703
  this.addWatchFile(effectsDir);
22704
+ this.addWatchFile(usersDir);
22556
22705
  },
22557
22706
  configureServer(server) {
22558
22707
  createDevMiddleware(server, devMiddlewareContext);
@@ -22657,7 +22806,9 @@ function createThreadEndpointHandler(handler) {
22657
22806
  const durableId = env.AGENT_BUILDER_THREAD.idFromName(threadId);
22658
22807
  const stub = env.AGENT_BUILDER_THREAD.get(durableId);
22659
22808
  const instance = stub;
22660
- const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata);
22809
+ const threadState = ThreadStateImpl.fromThreadInstance(instance, metadata, {
22810
+ env
22811
+ });
22661
22812
  const endpointParams = {};
22662
22813
  for (const [key, value] of Object.entries(params)) {
22663
22814
  if (key === "threadId" || key === "id" && !params.threadId) {
@@ -28279,7 +28430,7 @@ ${resultContent}${attachmentSummary}`;
28279
28430
  }
28280
28431
  /** Whether any forwarded call is mid-flight (blocking await or parked). */
28281
28432
  async hasInFlightClientCalls() {
28282
- if (this.pendingClientCalls.size > 0) return true;
28433
+ if ((this.pendingClientCalls?.size ?? 0) > 0) return true;
28283
28434
  await this.ensureMigrated();
28284
28435
  await this.ensurePendingClientTable();
28285
28436
  const cursor = await this.ctx.storage.sql.exec(
@@ -28338,7 +28489,7 @@ ${resultContent}${attachmentSummary}`;
28338
28489
  * always produce a normal tool error (no client connected, timeout, or user
28339
28490
  * denial) for the model to reason about.
28340
28491
  */
28341
- async callClientTool(request) {
28492
+ async callClientTool(request, durability) {
28342
28493
  const owner = await this.getExecutionOwner();
28343
28494
  const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
28344
28495
  if (!socket) {
@@ -28348,7 +28499,33 @@ ${resultContent}${attachmentSummary}`;
28348
28499
  data: { noClient: true }
28349
28500
  };
28350
28501
  }
28351
- const id = crypto.randomUUID();
28502
+ if (durability) {
28503
+ try {
28504
+ await this.ensureMigrated();
28505
+ await this.ensurePendingClientTable();
28506
+ const ownerId = owner?.client_id ?? null;
28507
+ const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
28508
+ const fenced = owner && socketClientId === owner.client_id;
28509
+ await this.ctx.storage.sql.exec(
28510
+ `INSERT OR REPLACE INTO pending_client_calls
28511
+ (tool_call_id, tool, args, request_permission, risk, side, status, created_at,
28512
+ dispatched_client_id, dispatched_generation)
28513
+ VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)`,
28514
+ durability.toolCallId,
28515
+ request.tool,
28516
+ JSON.stringify(request.args ?? {}),
28517
+ request.requestPermission ?? null,
28518
+ typeof request.risk === "number" ? request.risk : null,
28519
+ durability.side,
28520
+ Date.now(),
28521
+ fenced ? ownerId : null,
28522
+ fenced ? owner.generation : 0
28523
+ );
28524
+ } catch (error) {
28525
+ console.error("[DurableThread] Failed to persist fast-path call (continuing):", error);
28526
+ }
28527
+ }
28528
+ const id = durability?.toolCallId ?? crypto.randomUUID();
28352
28529
  const timeoutMs = typeof request.timeoutMs === "number" && request.timeoutMs > 0 ? request.timeoutMs : DEFAULT_CLIENT_TOOL_TIMEOUT_MS;
28353
28530
  this.bridgeLastSeen.set(socket, Date.now());
28354
28531
  return new Promise((resolve2) => {
@@ -28399,6 +28576,13 @@ ${resultContent}${attachmentSummary}`;
28399
28576
  this.pendingClientCalls.delete(id);
28400
28577
  if (pending.timer) clearTimeout(pending.timer);
28401
28578
  if (pending.liveness) clearInterval(pending.liveness);
28579
+ try {
28580
+ void this.ctx.storage.sql.exec(
28581
+ `UPDATE pending_client_calls SET status = 'resolved' WHERE tool_call_id = ? AND status = 'pending'`,
28582
+ id
28583
+ );
28584
+ } catch {
28585
+ }
28402
28586
  pending.resolve(res);
28403
28587
  }
28404
28588
  /** Close and forget a bridge socket we've deemed dead, failing remaining calls. */
@@ -28650,6 +28834,26 @@ ${resultContent}${attachmentSummary}`;
28650
28834
  toolStatus,
28651
28835
  toolCallId
28652
28836
  );
28837
+ const updated = await this.ctx.storage.sql.exec(
28838
+ `SELECT COUNT(*) AS total FROM messages WHERE tool_call_id = ? AND role = 'tool'`,
28839
+ toolCallId
28840
+ );
28841
+ if (Number(updated.toArray()[0]?.total ?? 0) === 0) {
28842
+ const toolRow = await this.ctx.storage.sql.exec(
28843
+ `SELECT tool FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
28844
+ toolCallId
28845
+ );
28846
+ await this.ctx.storage.sql.exec(
28847
+ `INSERT INTO messages (id, role, content, name, tool_call_id, tool_status, created_at, status, prompt)
28848
+ VALUES (?, 'tool', ?, ?, ?, ?, ?, 'completed', NULL)`,
28849
+ crypto.randomUUID(),
28850
+ content,
28851
+ toolRow.toArray()[0]?.tool ?? null,
28852
+ toolCallId,
28853
+ toolStatus,
28854
+ Date.now() * TIMESTAMP_MULTIPLIER
28855
+ );
28856
+ }
28653
28857
  await this.ctx.storage.sql.exec(
28654
28858
  `UPDATE pending_client_calls SET status = 'resolved' WHERE tool_call_id = ?`,
28655
28859
  toolCallId
@@ -29587,6 +29791,31 @@ ${resultContent}${attachmentSummary}`;
29587
29791
  `SELECT COUNT(*) AS c FROM message_queue`
29588
29792
  )).toArray()[0]?.c ?? 0;
29589
29793
  if (stopped || finalizedCount === 0 && queued === 0) {
29794
+ let orphanedClientCalls = false;
29795
+ if (!stopped) {
29796
+ try {
29797
+ orphanedClientCalls = await this.hasInFlightClientCalls();
29798
+ } catch {
29799
+ orphanedClientCalls = false;
29800
+ }
29801
+ }
29802
+ if (orphanedClientCalls) {
29803
+ const owner = await this.getExecutionOwner();
29804
+ const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
29805
+ if (socket) {
29806
+ void this.resendPendingClientCalls(socket);
29807
+ } else if (threadId) {
29808
+ try {
29809
+ const meta = await this.getThreadMetadata(threadId);
29810
+ if (meta.user_id) await this.requestHubWake(meta.user_id, threadId);
29811
+ } catch {
29812
+ }
29813
+ }
29814
+ console.warn(
29815
+ `[DurableThread] Watchdog: pending forwarded call with no executor on ${threadId} \u2014 ${socket ? "re-dispatched to the connected bridge" : "requested owner wake"}`
29816
+ );
29817
+ return report("observing", "none", { queuedMessages: queued });
29818
+ }
29590
29819
  if (finalizedCount > 0) {
29591
29820
  return report("parked", "finalized_parked", {
29592
29821
  queuedMessages: queued,
@@ -34459,6 +34688,232 @@ function formatMs(ms) {
34459
34688
  const minutes = Math.floor(seconds / 60);
34460
34689
  return `${minutes}m${String(seconds % 60).padStart(2, "0")}s`;
34461
34690
  }
34691
+
34692
+ // src/users/route-match.ts
34693
+ var METHOD_SUFFIXES = /* @__PURE__ */ new Set(["get", "post", "put", "patch", "delete"]);
34694
+ function parseUserRouteKey(key) {
34695
+ const parts = key.split("/");
34696
+ let file = parts.pop() ?? "";
34697
+ let method = "GET";
34698
+ const dot = file.lastIndexOf(".");
34699
+ if (dot >= 0) {
34700
+ const suffix = file.slice(dot + 1).toLowerCase();
34701
+ if (METHOD_SUFFIXES.has(suffix)) {
34702
+ method = suffix.toUpperCase();
34703
+ file = file.slice(0, dot);
34704
+ }
34705
+ }
34706
+ const segments = [...parts];
34707
+ if (file !== "index") segments.push(file);
34708
+ let dynamism = 0;
34709
+ for (const segment of segments) {
34710
+ if (segment === "[*]") dynamism = 2;
34711
+ else if (dynamism < 2 && segment.startsWith("[") && segment.endsWith("]")) dynamism = 1;
34712
+ }
34713
+ return { key, method, segments, dynamism };
34714
+ }
34715
+ function matchSegments(route, path20) {
34716
+ const params = {};
34717
+ for (let i = 0; i < route.length; i++) {
34718
+ const seg = route[i];
34719
+ if (seg === "[*]") {
34720
+ if (i !== route.length - 1) return null;
34721
+ params["*"] = path20.slice(i).join("/");
34722
+ return params;
34723
+ }
34724
+ const value = path20[i];
34725
+ if (value === void 0) return null;
34726
+ if (seg.startsWith("[") && seg.endsWith("]")) {
34727
+ params[seg.slice(1, -1)] = value;
34728
+ } else if (seg !== value) {
34729
+ return null;
34730
+ }
34731
+ }
34732
+ return path20.length === route.length ? params : null;
34733
+ }
34734
+ function matchUserRoute(keys, method, endpointPath) {
34735
+ const path20 = endpointPath.split("/").filter((s) => s.length > 0);
34736
+ const wanted = method.toUpperCase();
34737
+ let best = null;
34738
+ for (const key of keys) {
34739
+ const route = parseUserRouteKey(key);
34740
+ if (route.method !== wanted) continue;
34741
+ const params = matchSegments(route.segments, path20);
34742
+ if (!params) continue;
34743
+ if (!best || route.dynamism < best.route.dynamism) {
34744
+ best = { route, params };
34745
+ }
34746
+ }
34747
+ return best ? { key: best.route.key, params: best.params } : null;
34748
+ }
34749
+
34750
+ // src/users/UserStateImpl.ts
34751
+ init_ThreadStateImpl();
34752
+ var UserStateImpl = class {
34753
+ userId;
34754
+ host;
34755
+ /** Caller descriptor for grant `allow` policy checks. */
34756
+ caller;
34757
+ constructor(userId, host, caller) {
34758
+ this.userId = userId;
34759
+ this.host = host;
34760
+ this.caller = caller;
34761
+ }
34762
+ singleton() {
34763
+ const ns = this.host.env.AGENT_BUILDER;
34764
+ if (!ns) throw new Error("AGENT_BUILDER binding unavailable in the user scope.");
34765
+ return ns.get(ns.idFromName("singleton"));
34766
+ }
34767
+ async identity() {
34768
+ const user = await this.singleton().getUserById(this.userId);
34769
+ if (!user) throw new Error(`Unknown user: ${this.userId}`);
34770
+ return {
34771
+ id: user.id,
34772
+ username: user.username,
34773
+ role: user.role,
34774
+ permissions: user.permissions ?? null,
34775
+ email: user.email ?? null,
34776
+ display_name: user.display_name ?? null,
34777
+ avatar_url: user.avatar_url ?? null
34778
+ };
34779
+ }
34780
+ async getValue(key) {
34781
+ return await this.host.getUserValue(this.userId, key);
34782
+ }
34783
+ async setValue(key, value) {
34784
+ await this.host.setUserValue(this.userId, key, value);
34785
+ }
34786
+ async listValues(options) {
34787
+ const { entries, total } = await this.host.listUserValues(this.userId, options);
34788
+ return { entries, total };
34789
+ }
34790
+ async env(name) {
34791
+ const values = await this.singleton().getUserEnv(this.userId);
34792
+ const value = values[name];
34793
+ if (value === void 0) throw new Error(`Environment variable not found: ${name}`);
34794
+ return value;
34795
+ }
34796
+ emit(event, data) {
34797
+ this.host.broadcastUserEvent(this.userId, event, data);
34798
+ }
34799
+ async listThreads(options) {
34800
+ const { threads, total } = await this.singleton().listThreads({
34801
+ user_id: this.userId,
34802
+ agent_name: options?.agentId,
34803
+ limit: options?.limit,
34804
+ offset: options?.offset
34805
+ });
34806
+ return {
34807
+ threads: threads.map((t) => ({
34808
+ id: t.id,
34809
+ agent_id: t.agent_id ?? t.agent_name ?? "",
34810
+ tags: t.tags ?? [],
34811
+ created_at: t.created_at
34812
+ })),
34813
+ total
34814
+ };
34815
+ }
34816
+ async fetch(grant, input, init) {
34817
+ const body = init?.body;
34818
+ if (body !== void 0 && body !== null && typeof body !== "string") {
34819
+ throw new Error("Grant fetch bodies must be strings in this runtime.");
34820
+ }
34821
+ return this.host.grantFetch(this.userId, grant, {
34822
+ url: String(input),
34823
+ method: init?.method ?? "GET",
34824
+ headers: init?.headers ? Object.fromEntries(new Headers(init.headers)) : {},
34825
+ body: body ?? null,
34826
+ caller: this.caller
34827
+ });
34828
+ }
34829
+ /**
34830
+ * Raise one of this user's threads at rest — the same stub-backed
34831
+ * materialization thread endpoints receive. `inUserScope` marks the state
34832
+ * so a reentrant `state.user().call()` fails instead of deadlocking the
34833
+ * serialized user execution (spec: Users §2.2.3).
34834
+ */
34835
+ async thread(threadId) {
34836
+ const metadata = await this.singleton().getThread(threadId);
34837
+ if (!metadata) throw new Error(`Thread not found: ${threadId}`);
34838
+ if (metadata.user_id !== this.userId) {
34839
+ throw new Error(`Thread ${threadId} does not belong to user ${this.userId}.`);
34840
+ }
34841
+ const meta = metadata;
34842
+ if (!meta.agent_id && meta.agent_name) meta.agent_id = meta.agent_name;
34843
+ const ns = this.host.env.AGENT_BUILDER_THREAD;
34844
+ if (!ns) throw new Error("AGENT_BUILDER_THREAD binding unavailable in the user scope.");
34845
+ const instance = ns.get(ns.idFromName(threadId));
34846
+ return ThreadStateImpl.fromThreadInstance(instance, meta, {
34847
+ env: this.host.env,
34848
+ inUserScope: true
34849
+ });
34850
+ }
34851
+ };
34852
+
34853
+ // src/users/serialize.ts
34854
+ function createExecutionChain() {
34855
+ let tail2 = Promise.resolve();
34856
+ return {
34857
+ run(fn) {
34858
+ const next = tail2.then(fn, fn);
34859
+ tail2 = next.catch(() => {
34860
+ });
34861
+ return next;
34862
+ }
34863
+ };
34864
+ }
34865
+
34866
+ // src/users/grants.ts
34867
+ function originAllowed(origins, target) {
34868
+ if (!Array.isArray(origins)) return false;
34869
+ for (const allowed of origins) {
34870
+ if (allowed.startsWith("*.")) {
34871
+ const suffix = allowed.slice(1);
34872
+ if (target.protocol === "https:" && target.hostname.endsWith(suffix)) return true;
34873
+ } else {
34874
+ try {
34875
+ if (new URL(allowed).origin === target.origin) return true;
34876
+ } catch {
34877
+ }
34878
+ }
34879
+ }
34880
+ return false;
34881
+ }
34882
+ function callerAllowed(allow, caller) {
34883
+ if (!allow) return false;
34884
+ const lists = {
34885
+ tool: allow.tools,
34886
+ endpoint: allow.endpoints,
34887
+ userEndpoint: allow.userEndpoints,
34888
+ userHook: allow.userEndpoints,
34889
+ agent: allow.agents
34890
+ };
34891
+ const list = lists[caller.kind];
34892
+ if (!list) return false;
34893
+ const bare = caller.name.replace(/\.(get|post|put|patch|delete)$/i, "");
34894
+ return list.includes(caller.name) || list.includes(bare);
34895
+ }
34896
+ function injectGrantCredential(config, secret, target, headers) {
34897
+ const format = config.inject?.format ?? "SECRET";
34898
+ switch (config.kind) {
34899
+ case "bearer":
34900
+ headers.set("Authorization", `Bearer ${secret}`);
34901
+ return true;
34902
+ case "header":
34903
+ headers.set(config.inject?.header ?? "Authorization", format.replace(/SECRET/g, secret));
34904
+ return true;
34905
+ case "query":
34906
+ target.searchParams.set(config.inject?.query ?? "key", secret);
34907
+ return true;
34908
+ case "basic":
34909
+ headers.set("Authorization", `Basic ${btoa(secret)}`);
34910
+ return true;
34911
+ default:
34912
+ return false;
34913
+ }
34914
+ }
34915
+
34916
+ // src/durable-objects/DurableUserHub.ts
34462
34917
  var DurableUserHub = class extends DurableObject {
34463
34918
  /** Persistent client (daemon) sockets, restored across hibernation. */
34464
34919
  clientSockets = /* @__PURE__ */ new Set();
@@ -34466,11 +34921,9 @@ var DurableUserHub = class extends DurableObject {
34466
34921
  migrated = false;
34467
34922
  constructor(ctx, env) {
34468
34923
  super(ctx, env);
34469
- if (typeof WebSocketRequestResponsePair !== "undefined") {
34470
- ctx.setWebSocketAutoResponse(
34471
- new WebSocketRequestResponsePair("stream_ping", "stream_pong")
34472
- );
34473
- }
34924
+ ctx.blockConcurrencyWhile(async () => {
34925
+ this.ensureMigrated();
34926
+ });
34474
34927
  for (const ws of ctx.getWebSockets()) {
34475
34928
  const attachment = ws.deserializeAttachment();
34476
34929
  if (attachment?.type === "hub_client") {
@@ -34498,8 +34951,38 @@ var DurableUserHub = class extends DurableObject {
34498
34951
  this.ctx.storage.sql.exec(
34499
34952
  `CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)`
34500
34953
  );
34954
+ this.ctx.storage.sql.exec(`
34955
+ CREATE TABLE IF NOT EXISTS user_grants (
34956
+ user_id TEXT NOT NULL,
34957
+ name TEXT NOT NULL,
34958
+ secret TEXT NOT NULL,
34959
+ config TEXT NOT NULL,
34960
+ created_at INTEGER NOT NULL,
34961
+ updated_at INTEGER NOT NULL,
34962
+ PRIMARY KEY (user_id, name)
34963
+ )
34964
+ `);
34501
34965
  this.migrated = true;
34502
34966
  }
34967
+ // ─── User scope: registries (overridden by the generated virtual module) ──
34968
+ /**
34969
+ * Returns the user endpoints registry for lazy-loading `users/` files.
34970
+ * Keys are endpoint file paths relative to `users/` with the extension
34971
+ * stripped except the method suffix (e.g. `standardcode/lease_acquire.post`).
34972
+ * Implemented when DurableUserHub is imported from
34973
+ * 'virtual:@standardagents/builder'.
34974
+ */
34975
+ userEndpoints() {
34976
+ return {};
34977
+ }
34978
+ /**
34979
+ * Returns the hooks registry (same registry the thread runtime uses). The
34980
+ * hub filters it to user-scope hook types at dispatch time. Implemented
34981
+ * when DurableUserHub is imported from 'virtual:@standardagents/builder'.
34982
+ */
34983
+ userHooks() {
34984
+ return {};
34985
+ }
34503
34986
  // ─── Persistent client socket ──────────────────────────────────────────────
34504
34987
  /**
34505
34988
  * Accept the user's persistent client (daemon) WebSocket. The daemon holds
@@ -34508,6 +34991,16 @@ var DurableUserHub = class extends DurableObject {
34508
34991
  */
34509
34992
  async fetch(request) {
34510
34993
  const url = new URL(request.url);
34994
+ const scopeMatch = /^\/users\/([^/]+)\/(stream|endpoints|grant-fetch)(?:\/(.*))?$/.exec(
34995
+ url.pathname
34996
+ );
34997
+ if (scopeMatch) {
34998
+ const userId = decodeURIComponent(scopeMatch[1]);
34999
+ const kind = scopeMatch[2];
35000
+ if (kind === "stream") return this.acceptUserStream(request, url, userId);
35001
+ if (kind === "grant-fetch") return this.handleGrantFetch(request, userId);
35002
+ return this.runUserEndpoint(request, userId, scopeMatch[3] ?? "");
35003
+ }
34511
35004
  if (request.headers.get("Upgrade") === "websocket") {
34512
35005
  const pair = new WebSocketPair();
34513
35006
  const [client, server] = [pair[0], pair[1]];
@@ -34516,6 +35009,7 @@ var DurableUserHub = class extends DurableObject {
34516
35009
  clientName: url.searchParams.get("client_name"),
34517
35010
  clientKind: url.searchParams.get("client_kind")
34518
35011
  };
35012
+ this.ensureHeartbeatAutoResponse();
34519
35013
  this.ctx.acceptWebSocket(server);
34520
35014
  server.serializeAttachment({ type: "hub_client", ...info });
34521
35015
  this.clientSockets.add(server);
@@ -34528,16 +35022,265 @@ var DurableUserHub = class extends DurableObject {
34528
35022
  }
34529
35023
  return new Response("Not found", { status: 404 });
34530
35024
  }
34531
- async webSocketMessage(ws, _message) {
34532
- if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
35025
+ // ─── User scope: event stream (spec: Users §4) ─────────────────────────────
35026
+ /** Idle sockets answer heartbeats at the edge without waking the object.
35027
+ * Registered at accept time rather than in the constructor so a hub that
35028
+ * never accepts a socket stays fully evictable (this also keeps
35029
+ * vitest-pool-workers' isolated storage able to pop the DO's frame). */
35030
+ ensureHeartbeatAutoResponse() {
35031
+ if (typeof WebSocketRequestResponsePair !== "undefined") {
35032
+ this.ctx.setWebSocketAutoResponse(
35033
+ new WebSocketRequestResponsePair("stream_ping", "stream_pong")
35034
+ );
35035
+ }
35036
+ }
35037
+ /** Sockets attached to the user event stream, restored after hibernation. */
35038
+ streamSockets() {
35039
+ return this.ctx.getWebSockets().filter((ws) => ws.deserializeAttachment()?.type === "user_stream");
35040
+ }
35041
+ acceptUserStream(request, url, userId) {
35042
+ if (request.headers.get("Upgrade") !== "websocket") {
35043
+ return Response.json({ error: "WebSocket upgrade required" }, { status: 426 });
35044
+ }
35045
+ const pair = new WebSocketPair();
35046
+ const [client, server] = [pair[0], pair[1]];
35047
+ const clientId = url.searchParams.get("client_id") ?? crypto.randomUUID();
35048
+ const clientKind = url.searchParams.get("client_kind");
35049
+ this.ensureHeartbeatAutoResponse();
35050
+ this.ctx.acceptWebSocket(server);
35051
+ server.serializeAttachment({ type: "user_stream", userId, clientId, clientKind });
35052
+ try {
35053
+ server.send(JSON.stringify({ event: "stream_ready", data: { client_id: clientId } }));
35054
+ } catch {
35055
+ }
35056
+ this.dispatchUserHooks(userId, "user_client_attached", [
35057
+ { id: clientId, kind: clientKind ?? void 0 }
35058
+ ]);
35059
+ return new Response(null, { status: 101, webSocket: client });
35060
+ }
35061
+ /**
35062
+ * Deliver an event frame to every socket attached to this user's stream.
35063
+ * At-most-once, live-only (spec: Users §4) — no history, no acks.
35064
+ */
35065
+ broadcastUserEvent(userId, event, data) {
35066
+ const frame = JSON.stringify({ event, data });
35067
+ for (const ws of this.streamSockets()) {
35068
+ const attachment = ws.deserializeAttachment();
35069
+ if (attachment?.userId !== userId) continue;
35070
+ try {
35071
+ ws.send(frame);
35072
+ } catch {
35073
+ }
35074
+ }
35075
+ }
35076
+ // ─── User scope: serialized endpoint execution (spec: Users §2.1, §3) ─────
35077
+ /** Per-user execution chain (spec: Users §2.1) — see users/serialize.ts. */
35078
+ executionChain = createExecutionChain();
35079
+ scopeHost() {
35080
+ return {
35081
+ getUserValue: (userId, key) => this.getUserValue(userId, key),
35082
+ setUserValue: (userId, key, value) => this.setUserValue(userId, key, value),
35083
+ listUserValues: (userId, options) => this.listUserValues(userId, options ?? {}),
35084
+ broadcastUserEvent: (userId, event, data) => this.broadcastUserEvent(userId, event, data),
35085
+ grantFetch: (userId, grant, req) => this.grantFetch(userId, grant, req),
35086
+ env: this.env
35087
+ };
35088
+ }
35089
+ async runUserEndpoint(request, userId, endpointPath) {
35090
+ const registry = this.userEndpoints();
35091
+ const match = matchUserRoute(Object.keys(registry), request.method, endpointPath);
35092
+ if (!match) return Response.json({ error: "Not found" }, { status: 404 });
35093
+ let loaded;
35094
+ try {
35095
+ loaded = await registry[match.key]();
35096
+ } catch (error) {
35097
+ return Response.json(
35098
+ { error: `Failed to load user endpoint: ${match.key}` },
35099
+ { status: 500 }
35100
+ );
35101
+ }
35102
+ const module = loaded;
35103
+ const handler = module.default ?? loaded;
35104
+ if (!isUserEndpoint(handler)) {
35105
+ return Response.json(
35106
+ { error: `${match.key} is not a defineUserEndpoint export` },
35107
+ { status: 500 }
35108
+ );
35109
+ }
35110
+ return this.executionChain.run(async () => {
35111
+ const user = new UserStateImpl(userId, this.scopeHost(), {
35112
+ kind: "userEndpoint",
35113
+ name: match.key
35114
+ });
35115
+ try {
35116
+ return await handler.__handler(request, user, match.params);
35117
+ } catch (error) {
35118
+ return Response.json(
35119
+ { error: error instanceof Error ? error.message : "User endpoint failed" },
35120
+ { status: 500 }
35121
+ );
35122
+ }
35123
+ });
35124
+ }
35125
+ // ─── User scope: lifecycle hooks (spec: Users §7) ──────────────────────────
35126
+ /**
35127
+ * Fire every definition of a user hook type. Observational and best-effort:
35128
+ * failures are swallowed and dispatch never blocks the triggering
35129
+ * operation. Nested dispatch is suppressed so a `user_value_changed` hook
35130
+ * writing values cannot recurse.
35131
+ */
35132
+ inUserHookDispatch = false;
35133
+ dispatchUserHooks(userId, hookType, args) {
35134
+ if (this.inUserHookDispatch) return;
35135
+ const registry = this.userHooks();
35136
+ const names = Object.keys(registry);
35137
+ if (names.length === 0) return;
35138
+ const run = async () => {
35139
+ const user = new UserStateImpl(userId, this.scopeHost(), {
35140
+ kind: "userHook",
35141
+ name: hookType
35142
+ });
35143
+ for (const name of names) {
35144
+ try {
35145
+ const loaded = await registry[name]();
35146
+ const definition = loaded.default ?? loaded;
35147
+ if (definition?.hook !== hookType || typeof definition.execute !== "function") {
35148
+ continue;
35149
+ }
35150
+ this.inUserHookDispatch = true;
35151
+ try {
35152
+ await definition.execute(user, ...args);
35153
+ } finally {
35154
+ this.inUserHookDispatch = false;
35155
+ }
35156
+ } catch {
35157
+ }
35158
+ }
35159
+ };
35160
+ this.ctx.waitUntil(run());
34533
35161
  }
34534
35162
  async webSocketClose(ws) {
35163
+ const attachment = ws.deserializeAttachment();
35164
+ if (attachment?.type === "user_stream") {
35165
+ this.dispatchUserHooks(String(attachment.userId), "user_client_detached", [
35166
+ {
35167
+ id: typeof attachment.clientId === "string" ? attachment.clientId : "unknown",
35168
+ kind: typeof attachment.clientKind === "string" ? attachment.clientKind : void 0
35169
+ }
35170
+ ]);
35171
+ return;
35172
+ }
34535
35173
  this.clientSockets.delete(ws);
34536
35174
  this.clientInfo.delete(ws);
34537
35175
  }
35176
+ // ─── User scope: credential grants (spec: Users §6) ───────────────────────
35177
+ /**
35178
+ * Store or replace a grant. `secret` is write-only from this point on.
35179
+ * Policy (`CredentialGrant` minus the secret) is kept as JSON config.
35180
+ */
35181
+ async setGrant(userId, grant, secret) {
35182
+ this.ensureMigrated();
35183
+ const { name, ...config } = grant;
35184
+ const now = Date.now();
35185
+ await this.ctx.storage.sql.exec(
35186
+ `INSERT INTO user_grants (user_id, name, secret, config, created_at, updated_at)
35187
+ VALUES (?1, ?2, ?3, ?4, ?5, ?5)
35188
+ ON CONFLICT(user_id, name) DO UPDATE SET
35189
+ secret = excluded.secret, config = excluded.config, updated_at = excluded.updated_at`,
35190
+ userId,
35191
+ name,
35192
+ secret,
35193
+ JSON.stringify(config),
35194
+ now
35195
+ );
35196
+ }
35197
+ async deleteGrant(userId, name) {
35198
+ this.ensureMigrated();
35199
+ await this.ctx.storage.sql.exec(
35200
+ `DELETE FROM user_grants WHERE user_id = ? AND name = ?`,
35201
+ userId,
35202
+ name
35203
+ );
35204
+ }
35205
+ /** Metadata-only listing — never returns secret material. */
35206
+ async listGrants(userId) {
35207
+ this.ensureMigrated();
35208
+ const cursor = await this.ctx.storage.sql.exec(
35209
+ `SELECT name, config FROM user_grants WHERE user_id = ? ORDER BY name ASC`,
35210
+ userId
35211
+ );
35212
+ return cursor.toArray().map((row) => {
35213
+ let config = {};
35214
+ try {
35215
+ config = JSON.parse(row.config);
35216
+ } catch {
35217
+ }
35218
+ return { name: row.name, ...config };
35219
+ });
35220
+ }
35221
+ /** Perform a privileged fetch with the grant's credential injected. */
35222
+ async grantFetch(userId, grantName, req) {
35223
+ this.ensureMigrated();
35224
+ const cursor = await this.ctx.storage.sql.exec(
35225
+ `SELECT secret, config FROM user_grants WHERE user_id = ? AND name = ? LIMIT 1`,
35226
+ userId,
35227
+ grantName
35228
+ );
35229
+ const row = cursor.toArray()[0];
35230
+ if (!row) {
35231
+ return Response.json({ error: "grant_not_found", grant: grantName }, { status: 404 });
35232
+ }
35233
+ let config;
35234
+ try {
35235
+ config = JSON.parse(row.config);
35236
+ } catch {
35237
+ return Response.json({ error: "grant_config_invalid" }, { status: 500 });
35238
+ }
35239
+ let target;
35240
+ try {
35241
+ target = new URL(req.url);
35242
+ } catch {
35243
+ return Response.json({ error: "grant_target_invalid" }, { status: 400 });
35244
+ }
35245
+ if (!originAllowed(config.origins, target)) {
35246
+ return Response.json({ error: "grant_origin_denied" }, { status: 403 });
35247
+ }
35248
+ if (!callerAllowed(config.allow, req.caller)) {
35249
+ return Response.json({ error: "grant_caller_denied" }, { status: 403 });
35250
+ }
35251
+ const headers = new Headers(req.headers);
35252
+ if (!injectGrantCredential(config, row.secret, target, headers)) {
35253
+ return Response.json({ error: "grant_kind_unsupported" }, { status: 501 });
35254
+ }
35255
+ try {
35256
+ const response = await fetch(target.toString(), {
35257
+ method: req.method,
35258
+ headers,
35259
+ body: req.body ?? void 0
35260
+ });
35261
+ return new Response(response.body, { status: response.status, headers: response.headers });
35262
+ } catch {
35263
+ return Response.json({ error: "grant_fetch_failed" }, { status: 502 });
35264
+ }
35265
+ }
35266
+ async handleGrantFetch(request, userId) {
35267
+ const body = await request.json().catch(() => null);
35268
+ if (!body?.grant || !body.url) {
35269
+ return Response.json({ error: "grant and url required" }, { status: 400 });
35270
+ }
35271
+ return this.grantFetch(userId, body.grant, {
35272
+ url: body.url,
35273
+ method: body.method ?? "GET",
35274
+ headers: body.headers ?? {},
35275
+ body: body.body ?? null,
35276
+ caller: body.caller ?? { kind: "unknown", name: "unknown" }
35277
+ });
35278
+ }
35279
+ async webSocketMessage(ws, _message) {
35280
+ if (!this.clientSockets.has(ws)) this.clientSockets.add(ws);
35281
+ }
34538
35282
  async webSocketError(ws) {
34539
- this.clientSockets.delete(ws);
34540
- this.clientInfo.delete(ws);
35283
+ await this.webSocketClose(ws);
34541
35284
  }
34542
35285
  /** Whether a persistent client (daemon) is connected right now. */
34543
35286
  async hasClient() {
@@ -34597,6 +35340,7 @@ var DurableUserHub = class extends DurableObject {
34597
35340
  userId,
34598
35341
  key
34599
35342
  );
35343
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
34600
35344
  return;
34601
35345
  }
34602
35346
  const serialized = JSON.stringify(value);
@@ -34612,6 +35356,7 @@ var DurableUserHub = class extends DurableObject {
34612
35356
  serialized,
34613
35357
  now
34614
35358
  );
35359
+ this.dispatchUserHooks(userId, "user_value_changed", [key]);
34615
35360
  }
34616
35361
  async listUserValues(userId, options = {}) {
34617
35362
  this.ensureMigrated();