@standardagents/builder 0.26.1 → 0.27.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
@@ -1088,6 +1088,25 @@ async function awaitBounded(work, budgetMs, label) {
1088
1088
  if (timer) clearTimeout(timer);
1089
1089
  }
1090
1090
  }
1091
+ function chunkCarriesPayload(chunk) {
1092
+ switch (chunk.type) {
1093
+ case "content-delta":
1094
+ case "reasoning-delta":
1095
+ return typeof chunk.delta === "string" && chunk.delta.length > 0;
1096
+ case "tool-call-delta":
1097
+ return typeof chunk.argumentsDelta === "string" && chunk.argumentsDelta.length > 0;
1098
+ case "tool-call-start":
1099
+ case "tool-call-done":
1100
+ case "finish":
1101
+ case "image-done":
1102
+ case "web-search-done":
1103
+ case "provider-tool-done":
1104
+ case "error":
1105
+ return true;
1106
+ default:
1107
+ return false;
1108
+ }
1109
+ }
1091
1110
  function modelSupportsImageInput(modelDef) {
1092
1111
  const supportsImages = modelDef.capabilities?.supportsImages;
1093
1112
  if (typeof supportsImages === "boolean") {
@@ -1899,7 +1918,7 @@ var init_LLMRequest = __esm({
1899
1918
  if (next.done) break;
1900
1919
  const chunk = next.value;
1901
1920
  state.thread.instance.noteExecutionProgress?.();
1902
- if (firstChunkAt === 0) {
1921
+ if (firstChunkAt === 0 && chunkCarriesPayload(chunk)) {
1903
1922
  firstChunkAt = Date.now();
1904
1923
  emitLogPatch({ time_to_first_token_ms: firstChunkAt - requestSentAt });
1905
1924
  }
@@ -13157,6 +13176,38 @@ var init_ThreadStateImpl = __esm({
13157
13176
  }
13158
13177
  await this._threadInstance.setValue(key, value);
13159
13178
  }
13179
+ // Account-wide sibling of the thread KV: values scoped to the thread's user,
13180
+ // shared across every thread that user owns (backed by the singleton
13181
+ // DurableAgentBuilder `user_values` table).
13182
+ async getUserValue(key) {
13183
+ const userId = this._metadata.user_id;
13184
+ if (!userId) return null;
13185
+ const stub = this._getAgentBuilderStub();
13186
+ if (typeof stub.getUserValue !== "function") {
13187
+ throw new Error("getUserValue is not supported by this runtime");
13188
+ }
13189
+ return await stub.getUserValue(userId, key);
13190
+ }
13191
+ async setUserValue(key, value) {
13192
+ const userId = this._metadata.user_id;
13193
+ if (!userId) {
13194
+ throw new Error("setUserValue requires a thread with an associated user");
13195
+ }
13196
+ const stub = this._getAgentBuilderStub();
13197
+ if (typeof stub.setUserValue !== "function") {
13198
+ throw new Error("setUserValue is not supported by this runtime");
13199
+ }
13200
+ await stub.setUserValue(userId, key, value);
13201
+ }
13202
+ async listUserValues(options = {}) {
13203
+ const userId = this._metadata.user_id;
13204
+ if (!userId) return { entries: [], total: 0 };
13205
+ const stub = this._getAgentBuilderStub();
13206
+ if (typeof stub.listUserValues !== "function") {
13207
+ throw new Error("listUserValues is not supported by this runtime");
13208
+ }
13209
+ return stub.listUserValues(userId, options);
13210
+ }
13160
13211
  // ─────────────────────────────────────────────────────────────────────────
13161
13212
  // Client-forwarded tools
13162
13213
  // ─────────────────────────────────────────────────────────────────────────
@@ -13200,7 +13251,7 @@ var init_ThreadStateImpl = __esm({
13200
13251
  const instance = this._threadInstance;
13201
13252
  const toolCallId = this._flowState?.currentToolCall?.id;
13202
13253
  const mustPark = request.requestPermission != null && await this.forwardedCallNeedsApproval(request);
13203
- if (mustPark && toolCallId && this._executionState && typeof instance.dispatchClientTool === "function" && typeof instance.hasBridge === "function" && instance.hasBridge()) {
13254
+ if (mustPark && toolCallId && this._executionState && typeof instance.dispatchClientTool === "function" && typeof instance.hasBridge === "function" && await instance.hasBridge()) {
13204
13255
  const side = this._flowState?.currentSide === "b" ? "b" : "a";
13205
13256
  await instance.dispatchClientTool(request, toolCallId, side);
13206
13257
  this._executionState.stop();
@@ -14944,8 +14995,38 @@ var migration33 = {
14944
14995
  }
14945
14996
  };
14946
14997
 
14998
+ // src/durable-objects/migrations/034_add_owner_fencing.ts
14999
+ var migration34 = {
15000
+ version: 34,
15001
+ name: "add_owner_fencing",
15002
+ async up(sql) {
15003
+ sql.exec(`
15004
+ CREATE TABLE IF NOT EXISTS pending_client_calls (
15005
+ tool_call_id TEXT PRIMARY KEY,
15006
+ tool TEXT NOT NULL,
15007
+ args TEXT,
15008
+ request_permission TEXT,
15009
+ risk INTEGER,
15010
+ side TEXT NOT NULL DEFAULT 'a',
15011
+ status TEXT NOT NULL DEFAULT 'pending',
15012
+ created_at INTEGER NOT NULL
15013
+ )
15014
+ `);
15015
+ const tableInfo = sql.exec(`PRAGMA table_info(pending_client_calls)`).toArray();
15016
+ const hasColumn = (name) => tableInfo.some((col) => col.name === name);
15017
+ if (!hasColumn("dispatched_client_id")) {
15018
+ sql.exec(`ALTER TABLE pending_client_calls ADD COLUMN dispatched_client_id TEXT`);
15019
+ }
15020
+ if (!hasColumn("dispatched_generation")) {
15021
+ sql.exec(
15022
+ `ALTER TABLE pending_client_calls ADD COLUMN dispatched_generation INTEGER NOT NULL DEFAULT 0`
15023
+ );
15024
+ }
15025
+ }
15026
+ };
15027
+
14947
15028
  // src/durable-objects/migrations/index.ts
14948
- var migrations = [migration, migration2, migration3, migration4, migration5, migration6, migration7, migration8, migration9, migration10, migration11, migration12, migration13, migration14, migration15, migration16, migration17, migration18, migration19, migration20, migration21, migration22, migration23, migration24, migration25, migration26, migration27, migration28, migration29, migration30, migration31, migration32, migration33];
15029
+ var migrations = [migration, migration2, migration3, migration4, migration5, migration6, migration7, migration8, migration9, migration10, migration11, migration12, migration13, migration14, migration15, migration16, migration17, migration18, migration19, migration20, migration21, migration22, migration23, migration24, migration25, migration26, migration27, migration28, migration29, migration30, migration31, migration32, migration33, migration34];
14949
15030
  var LATEST_SCHEMA_VERSION = migrations.length;
14950
15031
 
14951
15032
  // src/durable-objects/DurableThread.ts
@@ -15745,6 +15826,61 @@ function isQualifiedName(name) {
15745
15826
 
15746
15827
  // src/durable-objects/DurableThread.ts
15747
15828
  init_sessionTools();
15829
+
15830
+ // src/durable-objects/bridge-owner.ts
15831
+ var EXECUTION_OWNER_KEY = "execution_owner";
15832
+ var CLAIM_MODES = /* @__PURE__ */ new Set([
15833
+ "none",
15834
+ "if_unowned",
15835
+ "if_stale",
15836
+ "takeover"
15837
+ ]);
15838
+ function parseBridgeClientParams(url) {
15839
+ const readParam = (name) => {
15840
+ const raw = url.searchParams.get(name);
15841
+ if (raw == null) return null;
15842
+ const trimmed = raw.trim().slice(0, 256);
15843
+ return trimmed.length > 0 ? trimmed : null;
15844
+ };
15845
+ const clientId = readParam("client_id");
15846
+ const info = {
15847
+ clientId,
15848
+ clientName: readParam("client_name"),
15849
+ clientKind: readParam("client_kind")
15850
+ };
15851
+ const rawClaim = readParam("claim");
15852
+ let claim;
15853
+ if (rawClaim && CLAIM_MODES.has(rawClaim)) {
15854
+ claim = rawClaim;
15855
+ } else {
15856
+ claim = clientId ? "if_unowned" : "none";
15857
+ }
15858
+ return { info, claim };
15859
+ }
15860
+ function resolveBridgeClaim(options) {
15861
+ const { clientId, claim, owner, ownerHasLiveSocket } = options;
15862
+ if (!clientId || claim === "none") return false;
15863
+ if (!owner) return true;
15864
+ if (owner.client_id === clientId) return true;
15865
+ if (claim === "takeover") return true;
15866
+ if (claim === "if_stale") return !ownerHasLiveSocket;
15867
+ return false;
15868
+ }
15869
+ function selectBridgeSocket(candidates, ownerClientId) {
15870
+ if (ownerClientId == null) {
15871
+ return candidates.length > 0 ? candidates[0].socket : null;
15872
+ }
15873
+ let legacyFallback = null;
15874
+ for (const candidate of candidates) {
15875
+ if (candidate.clientId === ownerClientId) return candidate.socket;
15876
+ if (candidate.clientId == null && legacyFallback == null) {
15877
+ legacyFallback = candidate.socket;
15878
+ }
15879
+ }
15880
+ return legacyFallback;
15881
+ }
15882
+
15883
+ // src/durable-objects/DurableThread.ts
15748
15884
  init_code_execution();
15749
15885
  var DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 5 * 60 * 1e3;
15750
15886
  var BRIDGE_LIVENESS_TIMEOUT_MS = 20 * 1e3;
@@ -15910,6 +16046,14 @@ var DurableThread = class _DurableThread extends DurableObject {
15910
16046
  // Last time we heard anything (incl. heartbeat pings) from each bridge socket,
15911
16047
  // used to detect a dead/half-open client quickly while a tool is in flight.
15912
16048
  bridgeLastSeen = /* @__PURE__ */ new Map();
16049
+ // Identity each bridge socket presented at connect (`client_id` etc.). Legacy
16050
+ // clients present nothing and map to null fields. Restored from the socket
16051
+ // attachment after hibernation.
16052
+ bridgeClientInfo = /* @__PURE__ */ new Map();
16053
+ // In-memory mirror of the thread-KV `execution_owner` record. `undefined`
16054
+ // means "not loaded yet"; `null` means "loaded, no owner". Kept coherent by
16055
+ // setValue() so external KV writes (release/reassign) invalidate it too.
16056
+ executionOwnerCache = void 0;
15913
16057
  pendingClientCalls = /* @__PURE__ */ new Map();
15914
16058
  alarmQueue;
15915
16059
  alarmQueueRearmed = false;
@@ -15980,6 +16124,11 @@ var DurableThread = class _DurableThread extends DurableObject {
15980
16124
  } else if (attachment?.type === "bridge") {
15981
16125
  this.bridgeSockets.add(ws);
15982
16126
  this.bridgeLastSeen.set(ws, Date.now());
16127
+ this.bridgeClientInfo.set(ws, {
16128
+ clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
16129
+ clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
16130
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
16131
+ });
15983
16132
  }
15984
16133
  }
15985
16134
  }
@@ -16473,6 +16622,9 @@ var DurableThread = class _DurableThread extends DurableObject {
16473
16622
  async setValue(key, value) {
16474
16623
  await this.ensureMigrated();
16475
16624
  this.assertValidThreadValueKey(key);
16625
+ if (key === EXECUTION_OWNER_KEY) {
16626
+ this.executionOwnerCache = this.parseExecutionOwner(value);
16627
+ }
16476
16628
  const updatedAt = Date.now() * 1e3;
16477
16629
  if (value === null || value === void 0) {
16478
16630
  await this.ctx.storage.sql.exec(`DELETE FROM thread_values WHERE key = ?`, key);
@@ -17120,9 +17272,9 @@ var DurableThread = class _DurableThread extends DurableObject {
17120
17272
  * Each migration is run in order, starting from the current version + 1.
17121
17273
  */
17122
17274
  async runMigrations(fromVersion) {
17123
- for (const migration44 of migrations) {
17124
- if (migration44.version > fromVersion) {
17125
- await migration44.up(this.ctx.storage.sql);
17275
+ for (const migration46 of migrations) {
17276
+ if (migration46.version > fromVersion) {
17277
+ await migration46.up(this.ctx.storage.sql);
17126
17278
  }
17127
17279
  }
17128
17280
  }
@@ -19169,20 +19321,208 @@ ${resultContent}${attachmentSummary}`;
19169
19321
  async handleBridgeWebSocketUpgrade(request) {
19170
19322
  const pair = new WebSocketPair();
19171
19323
  const [client, server] = Object.values(pair);
19324
+ const requestUrl2 = new URL(request.url);
19325
+ const { info, claim } = parseBridgeClientParams(requestUrl2);
19326
+ const routeThreadId = requestUrl2.pathname.match(/\/api\/threads\/([^/]+)\//)?.[1] ?? null;
19172
19327
  this.ctx.acceptWebSocket(server);
19173
- server.serializeAttachment({ type: "bridge" });
19328
+ server.serializeAttachment({
19329
+ type: "bridge",
19330
+ clientId: info.clientId,
19331
+ clientName: info.clientName,
19332
+ clientKind: info.clientKind
19333
+ });
19174
19334
  this.bridgeSockets.add(server);
19175
19335
  this.bridgeLastSeen.set(server, Date.now());
19336
+ this.bridgeClientInfo.set(server, info);
19337
+ if (info.clientId) {
19338
+ for (const ws of Array.from(this.bridgeSockets)) {
19339
+ if (ws === server) continue;
19340
+ if ((this.bridgeClientInfo.get(ws)?.clientId ?? null) !== info.clientId) continue;
19341
+ this.bridgeSockets.delete(ws);
19342
+ this.bridgeLastSeen.delete(ws);
19343
+ this.bridgeClientInfo.delete(ws);
19344
+ try {
19345
+ ws.send(JSON.stringify({ type: "superseded" }));
19346
+ } catch {
19347
+ }
19348
+ try {
19349
+ ws.close(4001, "Superseded by a newer connection from this client");
19350
+ } catch {
19351
+ }
19352
+ }
19353
+ }
19354
+ let owner = await this.getExecutionOwner();
19355
+ let claimRefused = null;
19356
+ const shouldClaim = resolveBridgeClaim({
19357
+ clientId: info.clientId,
19358
+ claim,
19359
+ owner,
19360
+ ownerHasLiveSocket: owner ? this.hasLiveBridgeSocketForClient(owner.client_id, server) : false
19361
+ });
19362
+ if (shouldClaim && info.clientId) {
19363
+ const changed = !owner || owner.client_id !== info.clientId;
19364
+ const ownerLive = owner ? this.hasLiveBridgeSocketForClient(owner.client_id, server) : false;
19365
+ if (changed && ownerLive && await this.hasInFlightClientCalls()) {
19366
+ claimRefused = "in_flight";
19367
+ } else {
19368
+ const previous = owner;
19369
+ const next = {
19370
+ client_id: info.clientId,
19371
+ client_name: info.clientName,
19372
+ client_kind: info.clientKind,
19373
+ claimed_at: Date.now(),
19374
+ generation: changed ? (owner?.generation ?? 0) + 1 : owner?.generation ?? 1
19375
+ };
19376
+ await this.setExecutionOwner(next);
19377
+ owner = next;
19378
+ if (changed) {
19379
+ this.broadcastOwnerChanged(next, server);
19380
+ if (previous) await this.failStalePendingWork(previous, routeThreadId);
19381
+ }
19382
+ }
19383
+ }
19384
+ const isOwner = owner ? info.clientId === owner.client_id : true;
19176
19385
  try {
19177
- server.send(JSON.stringify({ type: "bridge_ready", threadId: this.ctx.id.toString() }));
19386
+ server.send(
19387
+ JSON.stringify({
19388
+ type: "bridge_ready",
19389
+ threadId: this.ctx.id.toString(),
19390
+ owner: isOwner,
19391
+ ...claimRefused ? { claim_refused: claimRefused } : {},
19392
+ current_owner: owner ? {
19393
+ client_id: owner.client_id,
19394
+ client_name: owner.client_name ?? null,
19395
+ client_kind: owner.client_kind ?? null,
19396
+ generation: owner.generation
19397
+ } : null
19398
+ })
19399
+ );
19178
19400
  } catch {
19179
19401
  }
19180
- void this.resendPendingClientCalls(server);
19402
+ if (isOwner) {
19403
+ void this.resendPendingClientCalls(server);
19404
+ }
19181
19405
  return new Response(null, {
19182
19406
  status: 101,
19183
19407
  webSocket: client
19184
19408
  });
19185
19409
  }
19410
+ /** Parse a raw KV value into an {@link ExecutionOwner}, or null. */
19411
+ parseExecutionOwner(value) {
19412
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
19413
+ const record = value;
19414
+ if (typeof record.client_id !== "string" || record.client_id.length === 0) {
19415
+ return null;
19416
+ }
19417
+ return {
19418
+ client_id: record.client_id,
19419
+ client_name: typeof record.client_name === "string" ? record.client_name : null,
19420
+ client_kind: typeof record.client_kind === "string" ? record.client_kind : null,
19421
+ claimed_at: typeof record.claimed_at === "number" ? record.claimed_at : 0,
19422
+ generation: typeof record.generation === "number" ? record.generation : 0
19423
+ };
19424
+ }
19425
+ /** Load (and cache) the thread's persisted execution-owner record. */
19426
+ async getExecutionOwner() {
19427
+ if (this.executionOwnerCache !== void 0) return this.executionOwnerCache;
19428
+ const raw = await this.getValue(EXECUTION_OWNER_KEY);
19429
+ this.executionOwnerCache = this.parseExecutionOwner(raw);
19430
+ return this.executionOwnerCache;
19431
+ }
19432
+ /** Owner id from the in-memory mirror only (for sync paths); null when
19433
+ * unloaded or unowned — both degrade to the historical any-socket rule. */
19434
+ currentExecutionOwnerId() {
19435
+ return this.executionOwnerCache?.client_id ?? null;
19436
+ }
19437
+ /** Persist + cache the execution-owner record (null releases ownership). */
19438
+ async setExecutionOwner(owner) {
19439
+ await this.setValue(EXECUTION_OWNER_KEY, owner);
19440
+ }
19441
+ /** Whether the given client id has a live bridge socket (excluding `except`). */
19442
+ hasLiveBridgeSocketForClient(clientId, except) {
19443
+ const now = Date.now();
19444
+ for (const ws of this.bridgeSockets) {
19445
+ if (ws === except) continue;
19446
+ if (ws.readyState !== WebSocket.OPEN) continue;
19447
+ const lastSeen = this.bridgeLastSeen.get(ws) ?? 0;
19448
+ if (now - lastSeen > BRIDGE_LIVENESS_TIMEOUT_MS) continue;
19449
+ if ((this.bridgeClientInfo.get(ws)?.clientId ?? null) === clientId) return true;
19450
+ }
19451
+ return false;
19452
+ }
19453
+ /** Tell every other bridge socket that execution ownership moved. */
19454
+ broadcastOwnerChanged(owner, except) {
19455
+ const frame = JSON.stringify({
19456
+ type: "owner_changed",
19457
+ owner: {
19458
+ client_id: owner.client_id,
19459
+ client_name: owner.client_name ?? null,
19460
+ client_kind: owner.client_kind ?? null,
19461
+ generation: owner.generation
19462
+ }
19463
+ });
19464
+ for (const ws of this.bridgeSockets) {
19465
+ if (ws === except) continue;
19466
+ try {
19467
+ if (ws.readyState === WebSocket.OPEN) ws.send(frame);
19468
+ } catch {
19469
+ }
19470
+ }
19471
+ }
19472
+ /** Whether any forwarded call is mid-flight (blocking await or parked). */
19473
+ async hasInFlightClientCalls() {
19474
+ if (this.pendingClientCalls.size > 0) return true;
19475
+ await this.ensureMigrated();
19476
+ await this.ensurePendingClientTable();
19477
+ const cursor = await this.ctx.storage.sql.exec(
19478
+ `SELECT COUNT(*) AS total FROM pending_client_calls WHERE status = 'pending'`
19479
+ );
19480
+ return Number(cursor.toArray()[0]?.total ?? 0) > 0;
19481
+ }
19482
+ /**
19483
+ * Ownership moved away from a dead client: settle everything that was
19484
+ * dispatched to it. In-memory blocking calls fail immediately; parked
19485
+ * durable calls get an error result and the turn resumes — re-sending them
19486
+ * to the NEW owner would silently re-run the operation on a different
19487
+ * machine's filesystem, which is worse than an honest retryable error.
19488
+ */
19489
+ async failStalePendingWork(previous, threadId) {
19490
+ for (const id of Array.from(this.pendingClientCalls.keys())) {
19491
+ this.settleClientCall(id, {
19492
+ ok: false,
19493
+ error: "The client executing this operation was replaced before it finished.",
19494
+ data: { noClient: true }
19495
+ });
19496
+ }
19497
+ try {
19498
+ await this.ensureMigrated();
19499
+ await this.ensurePendingClientTable();
19500
+ const cursor = await this.ctx.storage.sql.exec(
19501
+ `SELECT tool_call_id FROM pending_client_calls
19502
+ WHERE status = 'pending' AND dispatched_client_id = ?`,
19503
+ previous.client_id
19504
+ );
19505
+ const sides = /* @__PURE__ */ new Set();
19506
+ for (const row of cursor.toArray()) {
19507
+ const delivered = await this.deliverClientToolResult(
19508
+ row.tool_call_id,
19509
+ false,
19510
+ void 0,
19511
+ "The client executing this operation was replaced before it finished. Retry if the work is still needed.",
19512
+ previous.client_id,
19513
+ previous.generation
19514
+ );
19515
+ if (delivered) sides.add(delivered.side);
19516
+ }
19517
+ if (threadId) {
19518
+ for (const side of sides) {
19519
+ await this.continueExecution(threadId, side);
19520
+ }
19521
+ }
19522
+ } catch (error) {
19523
+ console.error("[DurableThread] failStalePendingWork:", error);
19524
+ }
19525
+ }
19186
19526
  /**
19187
19527
  * Forward a tool operation to a connected bridge client and await its result.
19188
19528
  *
@@ -19191,7 +19531,8 @@ ${resultContent}${attachmentSummary}`;
19191
19531
  * denial) for the model to reason about.
19192
19532
  */
19193
19533
  async callClientTool(request) {
19194
- const socket = this.getOpenBridgeSocket();
19534
+ const owner = await this.getExecutionOwner();
19535
+ const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
19195
19536
  if (!socket) {
19196
19537
  return {
19197
19538
  ok: false,
@@ -19222,7 +19563,7 @@ ${resultContent}${attachmentSummary}`;
19222
19563
  });
19223
19564
  }
19224
19565
  }, 5e3);
19225
- this.pendingClientCalls.set(id, { resolve, timer, liveness });
19566
+ this.pendingClientCalls.set(id, { resolve, timer, liveness, socket });
19226
19567
  const frame = JSON.stringify({
19227
19568
  type: "tool_request",
19228
19569
  id,
@@ -19256,6 +19597,7 @@ ${resultContent}${attachmentSummary}`;
19256
19597
  markBridgeDead(socket) {
19257
19598
  this.bridgeSockets.delete(socket);
19258
19599
  this.bridgeLastSeen.delete(socket);
19600
+ this.bridgeClientInfo.delete(socket);
19259
19601
  try {
19260
19602
  socket.close();
19261
19603
  } catch {
@@ -19263,33 +19605,42 @@ ${resultContent}${attachmentSummary}`;
19263
19605
  this.failPendingClientCallsIfNoBridge();
19264
19606
  }
19265
19607
  /**
19266
- * Return the first bridge socket that is open AND still proving liveness,
19267
- * pruning dead ones. A half-open client (idle NAT/proxy drop) keeps a
19608
+ * Return the bridge socket a forwarded call should be dispatched to, pruning
19609
+ * dead ones. A half-open client (idle NAT/proxy drop) keeps a
19268
19610
  * `readyState === OPEN` socket that never fires close/error, so we also treat
19269
19611
  * a socket we haven't heard from within {@link BRIDGE_LIVENESS_TIMEOUT_MS} as
19270
19612
  * dead. The CLI heartbeats every few seconds, so this only trips on a truly
19271
19613
  * gone client — and it means we won't dispatch a forwarded call into the void.
19614
+ *
19615
+ * With an execution owner recorded, only the owner's socket (or, as a
19616
+ * back-compat fallback, an unidentified legacy socket) is eligible; with no
19617
+ * owner, the first live socket wins as it always has.
19272
19618
  */
19273
- getOpenBridgeSocket() {
19619
+ getOpenBridgeSocket(ownerClientId) {
19274
19620
  const now = Date.now();
19621
+ const live = [];
19275
19622
  for (const ws of Array.from(this.bridgeSockets)) {
19276
19623
  const lastSeen = this.bridgeLastSeen.get(ws) ?? 0;
19277
19624
  if (ws.readyState === WebSocket.OPEN && now - lastSeen <= BRIDGE_LIVENESS_TIMEOUT_MS) {
19278
- return ws;
19625
+ live.push({ socket: ws, clientId: this.bridgeClientInfo.get(ws)?.clientId ?? null });
19626
+ continue;
19279
19627
  }
19280
19628
  this.bridgeSockets.delete(ws);
19281
19629
  this.bridgeLastSeen.delete(ws);
19630
+ this.bridgeClientInfo.delete(ws);
19282
19631
  try {
19283
19632
  ws.close();
19284
19633
  } catch {
19285
19634
  }
19286
19635
  }
19287
- return null;
19636
+ return selectBridgeSocket(live, ownerClientId);
19288
19637
  }
19289
19638
  /** Resolve an in-flight forwarded tool call from a client `tool_response`. */
19290
- resolveClientCall(payload) {
19639
+ resolveClientCall(ws, payload) {
19291
19640
  const id = typeof payload.id === "string" ? payload.id : null;
19292
19641
  if (!id) return;
19642
+ const pending = this.pendingClientCalls.get(id);
19643
+ if (pending?.socket && pending.socket !== ws) return;
19293
19644
  this.settleClientCall(id, {
19294
19645
  ok: payload.ok === true,
19295
19646
  result: typeof payload.result === "string" ? payload.result : void 0,
@@ -19298,11 +19649,14 @@ ${resultContent}${attachmentSummary}`;
19298
19649
  });
19299
19650
  }
19300
19651
  /**
19301
- * When the last bridge client disconnects, fail any in-flight forwarded tool
19302
- * calls so the agent loop doesn't hang waiting on a client that is gone.
19652
+ * When the last bridge client that could serve forwarded calls disconnects,
19653
+ * fail any in-flight ones so the agent loop doesn't hang waiting on a client
19654
+ * that is gone. Uses the in-memory owner mirror (sync path); when the mirror
19655
+ * isn't loaded this degrades to the historical any-socket check, and the
19656
+ * per-call liveness interval still catches a dead dispatch target.
19303
19657
  */
19304
19658
  failPendingClientCallsIfNoBridge() {
19305
- if (this.getOpenBridgeSocket()) return;
19659
+ if (this.getOpenBridgeSocket(this.currentExecutionOwnerId())) return;
19306
19660
  for (const id of Array.from(this.pendingClientCalls.keys())) {
19307
19661
  this.settleClientCall(id, {
19308
19662
  ok: false,
@@ -19311,9 +19665,11 @@ ${resultContent}${attachmentSummary}`;
19311
19665
  });
19312
19666
  }
19313
19667
  }
19314
- /** Whether a bridge client is connected right now (used to decide durable park). */
19315
- hasBridge() {
19316
- return this.getOpenBridgeSocket() !== null;
19668
+ /** Whether a bridge client that can execute forwarded calls is connected
19669
+ * right now (used to decide durable park). */
19670
+ async hasBridge() {
19671
+ const owner = await this.getExecutionOwner();
19672
+ return this.getOpenBridgeSocket(owner?.client_id ?? null) !== null;
19317
19673
  }
19318
19674
  // ─── Durable forwarded tool calls (park & resume) ──────────────────────────
19319
19675
  // A permission-gated forwarded call must not hold the agent loop open across
@@ -19330,7 +19686,9 @@ ${resultContent}${attachmentSummary}`;
19330
19686
  risk INTEGER,
19331
19687
  side TEXT NOT NULL DEFAULT 'a',
19332
19688
  status TEXT NOT NULL DEFAULT 'pending',
19333
- created_at INTEGER NOT NULL
19689
+ created_at INTEGER NOT NULL,
19690
+ dispatched_client_id TEXT,
19691
+ dispatched_generation INTEGER NOT NULL DEFAULT 0
19334
19692
  )
19335
19693
  `);
19336
19694
  }
@@ -19355,13 +19713,31 @@ ${resultContent}${attachmentSummary}`;
19355
19713
  side,
19356
19714
  Date.now()
19357
19715
  );
19358
- return { dispatched: this.sendClientToolRequest(toolCallId, request) };
19716
+ return { dispatched: await this.sendClientToolRequest(toolCallId, request) };
19359
19717
  }
19360
- /** Send one durable `tool_request` frame to the bridge if a client is connected. */
19361
- sendClientToolRequest(toolCallId, request) {
19362
- const socket = this.getOpenBridgeSocket();
19718
+ /**
19719
+ * Send one durable `tool_request` frame to the bridge if a client is
19720
+ * connected and stamp the pending row with the identity+generation it was
19721
+ * dispatched to, fencing the eventual HTTP delivery to that client. A
19722
+ * legacy (unidentified) executor leaves the row unstamped, keeping the
19723
+ * historical accept-anything delivery for old CLIs.
19724
+ */
19725
+ async sendClientToolRequest(toolCallId, request) {
19726
+ const owner = await this.getExecutionOwner();
19727
+ const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
19363
19728
  if (!socket) return false;
19729
+ const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
19730
+ const fenced = owner && socketClientId === owner.client_id;
19731
+ const generation = fenced ? owner.generation : 0;
19364
19732
  try {
19733
+ await this.ctx.storage.sql.exec(
19734
+ `UPDATE pending_client_calls
19735
+ SET dispatched_client_id = ?, dispatched_generation = ?
19736
+ WHERE tool_call_id = ? AND status = 'pending'`,
19737
+ fenced ? owner.client_id : null,
19738
+ generation,
19739
+ toolCallId
19740
+ );
19365
19741
  socket.send(
19366
19742
  JSON.stringify({
19367
19743
  type: "tool_request",
@@ -19370,7 +19746,8 @@ ${resultContent}${attachmentSummary}`;
19370
19746
  tool: request.tool,
19371
19747
  args: request.args ?? {},
19372
19748
  requestPermission: request.requestPermission ?? null,
19373
- risk: typeof request.risk === "number" ? request.risk : null
19749
+ risk: typeof request.risk === "number" ? request.risk : null,
19750
+ generation
19374
19751
  })
19375
19752
  );
19376
19753
  this.bridgeLastSeen.set(socket, Date.now());
@@ -19406,9 +19783,21 @@ ${resultContent}${attachmentSummary}`;
19406
19783
  /** Re-send all pending durable requests to a freshly connected bridge socket. */
19407
19784
  async resendPendingClientCalls(socket) {
19408
19785
  try {
19786
+ const owner = await this.getExecutionOwner();
19787
+ const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
19788
+ const fenced = owner && socketClientId === owner.client_id;
19789
+ const generation = fenced ? owner.generation : 0;
19409
19790
  const pending = await this.getPendingClientCalls();
19410
19791
  for (const p of pending) {
19411
19792
  if (socket.readyState !== WebSocket.OPEN) break;
19793
+ await this.ctx.storage.sql.exec(
19794
+ `UPDATE pending_client_calls
19795
+ SET dispatched_client_id = ?, dispatched_generation = ?
19796
+ WHERE tool_call_id = ? AND status = 'pending'`,
19797
+ fenced ? owner.client_id : null,
19798
+ generation,
19799
+ p.toolCallId
19800
+ );
19412
19801
  socket.send(
19413
19802
  JSON.stringify({
19414
19803
  type: "tool_request",
@@ -19417,7 +19806,8 @@ ${resultContent}${attachmentSummary}`;
19417
19806
  tool: p.tool,
19418
19807
  args: p.args,
19419
19808
  requestPermission: p.requestPermission,
19420
- risk: p.risk
19809
+ risk: p.risk,
19810
+ generation
19421
19811
  })
19422
19812
  );
19423
19813
  }
@@ -19430,15 +19820,21 @@ ${resultContent}${attachmentSummary}`;
19430
19820
  * call resolved. Returns the side to continue, or null if there is no pending
19431
19821
  * call (unknown id or already resolved — the delivery is idempotent).
19432
19822
  */
19433
- async deliverClientToolResult(toolCallId, ok, result, error) {
19823
+ async deliverClientToolResult(toolCallId, ok, result, error, deliveredByClientId, deliveredGeneration) {
19434
19824
  await this.ensureMigrated();
19435
19825
  await this.ensurePendingClientTable();
19436
19826
  const pendingCursor = await this.ctx.storage.sql.exec(
19437
- `SELECT side, status FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
19827
+ `SELECT side, status, dispatched_client_id, dispatched_generation
19828
+ FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
19438
19829
  toolCallId
19439
19830
  );
19440
19831
  const pending = pendingCursor.toArray()[0];
19441
19832
  if (!pending || pending.status !== "pending") return null;
19833
+ if (pending.dispatched_client_id) {
19834
+ if (pending.dispatched_client_id !== deliveredByClientId || Number(pending.dispatched_generation ?? 0) !== (deliveredGeneration ?? -1)) {
19835
+ return null;
19836
+ }
19837
+ }
19442
19838
  const side = pending.side === "b" ? "b" : "a";
19443
19839
  const content = ok ? result ?? "" : `Error: ${error ?? "tool failed"}`;
19444
19840
  const toolStatus = ok ? "success" : "error";
@@ -19674,7 +20070,7 @@ ${resultContent}${attachmentSummary}`;
19674
20070
  parsed = null;
19675
20071
  }
19676
20072
  if (parsed && parsed.type === "tool_response") {
19677
- this.resolveClientCall(parsed);
20073
+ this.resolveClientCall(ws, parsed);
19678
20074
  return;
19679
20075
  }
19680
20076
  }
@@ -19696,6 +20092,7 @@ ${resultContent}${attachmentSummary}`;
19696
20092
  } else if (attachment?.type === "bridge") {
19697
20093
  this.bridgeSockets.delete(ws);
19698
20094
  this.bridgeLastSeen.delete(ws);
20095
+ this.bridgeClientInfo.delete(ws);
19699
20096
  this.failPendingClientCallsIfNoBridge();
19700
20097
  }
19701
20098
  }
@@ -19716,6 +20113,7 @@ ${resultContent}${attachmentSummary}`;
19716
20113
  } else if (attachment?.type === "bridge") {
19717
20114
  this.bridgeSockets.delete(ws);
19718
20115
  this.bridgeLastSeen.delete(ws);
20116
+ this.bridgeClientInfo.delete(ws);
19719
20117
  this.failPendingClientCallsIfNoBridge();
19720
20118
  }
19721
20119
  }
@@ -21276,7 +21674,7 @@ Its execution was lost (the executor restarted or hung) and automatic recovery w
21276
21674
  };
21277
21675
 
21278
21676
  // src/durable-objects/agentbuilder-migrations/0001_initial.ts
21279
- var migration34 = {
21677
+ var migration35 = {
21280
21678
  version: 1,
21281
21679
  async up(sql) {
21282
21680
  sql.exec(`
@@ -21375,7 +21773,7 @@ var migration34 = {
21375
21773
  };
21376
21774
 
21377
21775
  // src/durable-objects/agentbuilder-migrations/0002_add_tenvs_properties.ts
21378
- var migration35 = {
21776
+ var migration36 = {
21379
21777
  version: 2,
21380
21778
  async up(sql) {
21381
21779
  sql.exec(`ALTER TABLE threads ADD COLUMN tenvs TEXT`);
@@ -21387,7 +21785,7 @@ var migration35 = {
21387
21785
  };
21388
21786
 
21389
21787
  // src/durable-objects/agentbuilder-migrations/0003_add_parent_and_terminated.ts
21390
- var migration36 = {
21788
+ var migration37 = {
21391
21789
  version: 3,
21392
21790
  async up(sql) {
21393
21791
  sql.exec(`ALTER TABLE threads ADD COLUMN parent TEXT REFERENCES threads(id)`);
@@ -21400,7 +21798,7 @@ var migration36 = {
21400
21798
  };
21401
21799
 
21402
21800
  // src/durable-objects/agentbuilder-migrations/0004_add_env_tables.ts
21403
- var migration37 = {
21801
+ var migration38 = {
21404
21802
  version: 4,
21405
21803
  async up(sql) {
21406
21804
  sql.exec(`ALTER TABLE threads ADD COLUMN env TEXT`);
@@ -21424,7 +21822,7 @@ var migration37 = {
21424
21822
  };
21425
21823
 
21426
21824
  // src/durable-objects/agentbuilder-migrations/0005_add_pending_subagent_env_requests.ts
21427
- var migration38 = {
21825
+ var migration39 = {
21428
21826
  version: 5,
21429
21827
  async up(sql) {
21430
21828
  sql.exec(`
@@ -21450,7 +21848,7 @@ var migration38 = {
21450
21848
  };
21451
21849
 
21452
21850
  // src/durable-objects/agentbuilder-migrations/0006_add_env_value_types.ts
21453
- var migration39 = {
21851
+ var migration40 = {
21454
21852
  version: 6,
21455
21853
  async up(sql) {
21456
21854
  sql.exec(
@@ -21465,7 +21863,7 @@ var migration39 = {
21465
21863
  };
21466
21864
 
21467
21865
  // src/durable-objects/agentbuilder-migrations/0007_platform_identity_replica.ts
21468
- var migration40 = {
21866
+ var migration41 = {
21469
21867
  version: 7,
21470
21868
  async up(sql) {
21471
21869
  sql.exec(`ALTER TABLE users ADD COLUMN platform_user_id TEXT`);
@@ -21494,7 +21892,7 @@ var migration40 = {
21494
21892
  };
21495
21893
 
21496
21894
  // src/durable-objects/agentbuilder-migrations/0008_add_skills.ts
21497
- var migration41 = {
21895
+ var migration42 = {
21498
21896
  version: 8,
21499
21897
  async up(sql) {
21500
21898
  sql.exec(`
@@ -21525,7 +21923,7 @@ var migration41 = {
21525
21923
  };
21526
21924
 
21527
21925
  // src/durable-objects/agentbuilder-migrations/0009_add_user_permissions.ts
21528
- var migration42 = {
21926
+ var migration43 = {
21529
21927
  version: 9,
21530
21928
  async up(sql) {
21531
21929
  sql.exec(`ALTER TABLE users ADD COLUMN permissions TEXT`);
@@ -21536,7 +21934,7 @@ var migration42 = {
21536
21934
  };
21537
21935
 
21538
21936
  // src/durable-objects/agentbuilder-migrations/0010_add_device_logins.ts
21539
- var migration43 = {
21937
+ var migration44 = {
21540
21938
  version: 10,
21541
21939
  async up(sql) {
21542
21940
  sql.exec(`
@@ -21555,9 +21953,32 @@ var migration43 = {
21555
21953
  }
21556
21954
  };
21557
21955
 
21956
+ // src/durable-objects/agentbuilder-migrations/0011_add_user_values.ts
21957
+ var migration45 = {
21958
+ version: 11,
21959
+ async up(sql) {
21960
+ sql.exec(`
21961
+ CREATE TABLE IF NOT EXISTS user_values (
21962
+ user_id TEXT NOT NULL,
21963
+ key TEXT NOT NULL,
21964
+ value TEXT NOT NULL,
21965
+ created_at INTEGER NOT NULL,
21966
+ updated_at INTEGER NOT NULL,
21967
+ PRIMARY KEY (user_id, key)
21968
+ )
21969
+ `);
21970
+ sql.exec(`
21971
+ CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)
21972
+ `);
21973
+ sql.exec(`
21974
+ INSERT OR REPLACE INTO _metadata (key, value) VALUES ('schema_version', '11')
21975
+ `);
21976
+ }
21977
+ };
21978
+
21558
21979
  // src/durable-objects/agentbuilder-migrations/index.ts
21559
- var migrations2 = [migration34, migration35, migration36, migration37, migration38, migration39, migration40, migration41, migration42, migration43];
21560
- var LATEST_SCHEMA_VERSION2 = 10;
21980
+ var migrations2 = [migration35, migration36, migration37, migration38, migration39, migration40, migration41, migration42, migration43, migration44, migration45];
21981
+ var LATEST_SCHEMA_VERSION2 = 11;
21561
21982
 
21562
21983
  // src/utils/crypto.ts
21563
21984
  var CryptoUtil = class {
@@ -22319,9 +22740,9 @@ ${result ?? error ?? "No result content."}${attachmentSummary}`;
22319
22740
  }
22320
22741
  }
22321
22742
  async runMigrations(fromVersion) {
22322
- for (const migration44 of migrations2) {
22323
- if (migration44.version > fromVersion) {
22324
- await migration44.up(this.ctx.storage.sql);
22743
+ for (const migration46 of migrations2) {
22744
+ if (migration46.version > fromVersion) {
22745
+ await migration46.up(this.ctx.storage.sql);
22325
22746
  }
22326
22747
  }
22327
22748
  }
@@ -22865,6 +23286,117 @@ ${result ?? error ?? "No result content."}${attachmentSummary}`;
22865
23286
  async patchUserEnv(userId, params) {
22866
23287
  await this.patchScopedEnv("user", userId, params);
22867
23288
  }
23289
+ // ============================================================
23290
+ // Per-user durable key-value store (account-wide, not thread-tied)
23291
+ //
23292
+ // The account-scoped sibling of the per-thread `thread_values` store:
23293
+ // JSON documents keyed by (user_id, key), for state a user carries across
23294
+ // every thread on the instance (e.g. a coding client's machine/daemon
23295
+ // registry). Mirrors thread-KV semantics — null/undefined deletes.
23296
+ // ============================================================
23297
+ assertValidUserValueKey(key) {
23298
+ if (typeof key !== "string" || key.length === 0) {
23299
+ throw new Error("User value key must be a non-empty string");
23300
+ }
23301
+ if (key.length > 512) {
23302
+ throw new Error("User value key must be at most 512 characters");
23303
+ }
23304
+ }
23305
+ assertValidUserValueUserId(userId) {
23306
+ if (typeof userId !== "string" || userId.length === 0) {
23307
+ throw new Error("User value user_id must be a non-empty string");
23308
+ }
23309
+ }
23310
+ /** Read one JSON value from a user's account-wide key-value store. */
23311
+ async getUserValue(userId, key) {
23312
+ await this.ensureMigrated();
23313
+ this.assertValidUserValueUserId(userId);
23314
+ this.assertValidUserValueKey(key);
23315
+ const cursor = await this.ctx.storage.sql.exec(
23316
+ `SELECT value FROM user_values WHERE user_id = ? AND key = ? LIMIT 1`,
23317
+ userId,
23318
+ key
23319
+ );
23320
+ const raw = cursor.toArray()[0]?.value;
23321
+ if (raw === void 0) return null;
23322
+ try {
23323
+ return JSON.parse(raw);
23324
+ } catch (error) {
23325
+ throw new Error(
23326
+ `Stored user value for key "${key}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
23327
+ );
23328
+ }
23329
+ }
23330
+ /** Write one JSON value to a user's account-wide store; null/undefined deletes. */
23331
+ async setUserValue(userId, key, value) {
23332
+ await this.ensureMigrated();
23333
+ this.assertValidUserValueUserId(userId);
23334
+ this.assertValidUserValueKey(key);
23335
+ if (value === null || value === void 0) {
23336
+ await this.ctx.storage.sql.exec(
23337
+ `DELETE FROM user_values WHERE user_id = ? AND key = ?`,
23338
+ userId,
23339
+ key
23340
+ );
23341
+ return;
23342
+ }
23343
+ let serialized;
23344
+ try {
23345
+ serialized = JSON.stringify(value);
23346
+ } catch (error) {
23347
+ throw new Error(
23348
+ `User value for key "${key}" is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`
23349
+ );
23350
+ }
23351
+ if (typeof serialized !== "string") {
23352
+ throw new Error(`User value for key "${key}" is not JSON-serializable`);
23353
+ }
23354
+ const now = Date.now();
23355
+ await this.ctx.storage.sql.exec(
23356
+ `INSERT INTO user_values (user_id, key, value, created_at, updated_at)
23357
+ VALUES (?1, ?2, ?3, ?4, ?4)
23358
+ ON CONFLICT(user_id, key) DO UPDATE SET
23359
+ value = excluded.value,
23360
+ updated_at = excluded.updated_at`,
23361
+ userId,
23362
+ key,
23363
+ serialized,
23364
+ now
23365
+ );
23366
+ }
23367
+ /** List a user's account-wide values, optionally filtered by key prefix. */
23368
+ async listUserValues(userId, options = {}) {
23369
+ await this.ensureMigrated();
23370
+ this.assertValidUserValueUserId(userId);
23371
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? 100), 1), 500);
23372
+ const offset = Math.max(Math.trunc(options.offset ?? 0), 0);
23373
+ const prefix = typeof options.prefix === "string" && options.prefix.length > 0 ? options.prefix : null;
23374
+ const escapedPrefix = prefix ? `${prefix.replace(/[\\%_]/g, (m) => `\\${m}`)}%` : null;
23375
+ const where = escapedPrefix ? `WHERE user_id = ? AND key LIKE ? ESCAPE '\\'` : `WHERE user_id = ?`;
23376
+ const whereArgs = escapedPrefix ? [userId, escapedPrefix] : [userId];
23377
+ const totalCursor = await this.ctx.storage.sql.exec(
23378
+ `SELECT COUNT(*) AS total FROM user_values ${where}`,
23379
+ ...whereArgs
23380
+ );
23381
+ const total = Number(totalCursor.toArray()[0]?.total ?? 0);
23382
+ const cursor = await this.ctx.storage.sql.exec(
23383
+ `SELECT key, value, updated_at FROM user_values ${where}
23384
+ ORDER BY key ASC LIMIT ? OFFSET ?`,
23385
+ ...whereArgs,
23386
+ limit,
23387
+ offset
23388
+ );
23389
+ const entries = cursor.toArray().map((row) => {
23390
+ let value = null;
23391
+ try {
23392
+ value = JSON.parse(row.value);
23393
+ } catch {
23394
+ value = null;
23395
+ }
23396
+ return { key: row.key, value, updated_at: Number(row.updated_at) };
23397
+ });
23398
+ return { entries, total, limit, offset };
23399
+ }
22868
23400
  async getMissingRequiredScopedSubagentEnv(params) {
22869
23401
  await this.ensureMigrated();
22870
23402
  const requiredScoped = await this.getRequiredScopedVariableNamesForAgent(