@standardagents/builder 0.26.0 → 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/index.js CHANGED
@@ -1097,6 +1097,25 @@ async function awaitBounded(work, budgetMs, label) {
1097
1097
  if (timer) clearTimeout(timer);
1098
1098
  }
1099
1099
  }
1100
+ function chunkCarriesPayload(chunk) {
1101
+ switch (chunk.type) {
1102
+ case "content-delta":
1103
+ case "reasoning-delta":
1104
+ return typeof chunk.delta === "string" && chunk.delta.length > 0;
1105
+ case "tool-call-delta":
1106
+ return typeof chunk.argumentsDelta === "string" && chunk.argumentsDelta.length > 0;
1107
+ case "tool-call-start":
1108
+ case "tool-call-done":
1109
+ case "finish":
1110
+ case "image-done":
1111
+ case "web-search-done":
1112
+ case "provider-tool-done":
1113
+ case "error":
1114
+ return true;
1115
+ default:
1116
+ return false;
1117
+ }
1118
+ }
1100
1119
  function modelSupportsImageInput(modelDef) {
1101
1120
  const supportsImages = modelDef.capabilities?.supportsImages;
1102
1121
  if (typeof supportsImages === "boolean") {
@@ -1908,7 +1927,7 @@ var init_LLMRequest = __esm({
1908
1927
  if (next.done) break;
1909
1928
  const chunk = next.value;
1910
1929
  state.thread.instance.noteExecutionProgress?.();
1911
- if (firstChunkAt === 0) {
1930
+ if (firstChunkAt === 0 && chunkCarriesPayload(chunk)) {
1912
1931
  firstChunkAt = Date.now();
1913
1932
  emitLogPatch({ time_to_first_token_ms: firstChunkAt - requestSentAt });
1914
1933
  }
@@ -13166,6 +13185,38 @@ var init_ThreadStateImpl = __esm({
13166
13185
  }
13167
13186
  await this._threadInstance.setValue(key, value);
13168
13187
  }
13188
+ // Account-wide sibling of the thread KV: values scoped to the thread's user,
13189
+ // shared across every thread that user owns (backed by the singleton
13190
+ // DurableAgentBuilder `user_values` table).
13191
+ async getUserValue(key) {
13192
+ const userId = this._metadata.user_id;
13193
+ if (!userId) return null;
13194
+ const stub = this._getAgentBuilderStub();
13195
+ if (typeof stub.getUserValue !== "function") {
13196
+ throw new Error("getUserValue is not supported by this runtime");
13197
+ }
13198
+ return await stub.getUserValue(userId, key);
13199
+ }
13200
+ async setUserValue(key, value) {
13201
+ const userId = this._metadata.user_id;
13202
+ if (!userId) {
13203
+ throw new Error("setUserValue requires a thread with an associated user");
13204
+ }
13205
+ const stub = this._getAgentBuilderStub();
13206
+ if (typeof stub.setUserValue !== "function") {
13207
+ throw new Error("setUserValue is not supported by this runtime");
13208
+ }
13209
+ await stub.setUserValue(userId, key, value);
13210
+ }
13211
+ async listUserValues(options = {}) {
13212
+ const userId = this._metadata.user_id;
13213
+ if (!userId) return { entries: [], total: 0 };
13214
+ const stub = this._getAgentBuilderStub();
13215
+ if (typeof stub.listUserValues !== "function") {
13216
+ throw new Error("listUserValues is not supported by this runtime");
13217
+ }
13218
+ return stub.listUserValues(userId, options);
13219
+ }
13169
13220
  // ─────────────────────────────────────────────────────────────────────────
13170
13221
  // Client-forwarded tools
13171
13222
  // ─────────────────────────────────────────────────────────────────────────
@@ -13209,7 +13260,7 @@ var init_ThreadStateImpl = __esm({
13209
13260
  const instance = this._threadInstance;
13210
13261
  const toolCallId = this._flowState?.currentToolCall?.id;
13211
13262
  const mustPark = request.requestPermission != null && await this.forwardedCallNeedsApproval(request);
13212
- if (mustPark && toolCallId && this._executionState && typeof instance.dispatchClientTool === "function" && typeof instance.hasBridge === "function" && instance.hasBridge()) {
13263
+ if (mustPark && toolCallId && this._executionState && typeof instance.dispatchClientTool === "function" && typeof instance.hasBridge === "function" && await instance.hasBridge()) {
13213
13264
  const side = this._flowState?.currentSide === "b" ? "b" : "a";
13214
13265
  await instance.dispatchClientTool(request, toolCallId, side);
13215
13266
  this._executionState.stop();
@@ -23259,8 +23310,38 @@ var migration33 = {
23259
23310
  }
23260
23311
  };
23261
23312
 
23313
+ // src/durable-objects/migrations/034_add_owner_fencing.ts
23314
+ var migration34 = {
23315
+ version: 34,
23316
+ name: "add_owner_fencing",
23317
+ async up(sql) {
23318
+ sql.exec(`
23319
+ CREATE TABLE IF NOT EXISTS pending_client_calls (
23320
+ tool_call_id TEXT PRIMARY KEY,
23321
+ tool TEXT NOT NULL,
23322
+ args TEXT,
23323
+ request_permission TEXT,
23324
+ risk INTEGER,
23325
+ side TEXT NOT NULL DEFAULT 'a',
23326
+ status TEXT NOT NULL DEFAULT 'pending',
23327
+ created_at INTEGER NOT NULL
23328
+ )
23329
+ `);
23330
+ const tableInfo = sql.exec(`PRAGMA table_info(pending_client_calls)`).toArray();
23331
+ const hasColumn = (name) => tableInfo.some((col) => col.name === name);
23332
+ if (!hasColumn("dispatched_client_id")) {
23333
+ sql.exec(`ALTER TABLE pending_client_calls ADD COLUMN dispatched_client_id TEXT`);
23334
+ }
23335
+ if (!hasColumn("dispatched_generation")) {
23336
+ sql.exec(
23337
+ `ALTER TABLE pending_client_calls ADD COLUMN dispatched_generation INTEGER NOT NULL DEFAULT 0`
23338
+ );
23339
+ }
23340
+ }
23341
+ };
23342
+
23262
23343
  // src/durable-objects/migrations/index.ts
23263
- 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];
23344
+ 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];
23264
23345
  var LATEST_SCHEMA_VERSION = migrations.length;
23265
23346
 
23266
23347
  // src/durable-objects/DurableThread.ts
@@ -24060,6 +24141,61 @@ function isQualifiedName(name) {
24060
24141
 
24061
24142
  // src/durable-objects/DurableThread.ts
24062
24143
  init_sessionTools();
24144
+
24145
+ // src/durable-objects/bridge-owner.ts
24146
+ var EXECUTION_OWNER_KEY = "execution_owner";
24147
+ var CLAIM_MODES = /* @__PURE__ */ new Set([
24148
+ "none",
24149
+ "if_unowned",
24150
+ "if_stale",
24151
+ "takeover"
24152
+ ]);
24153
+ function parseBridgeClientParams(url) {
24154
+ const readParam = (name) => {
24155
+ const raw = url.searchParams.get(name);
24156
+ if (raw == null) return null;
24157
+ const trimmed = raw.trim().slice(0, 256);
24158
+ return trimmed.length > 0 ? trimmed : null;
24159
+ };
24160
+ const clientId = readParam("client_id");
24161
+ const info = {
24162
+ clientId,
24163
+ clientName: readParam("client_name"),
24164
+ clientKind: readParam("client_kind")
24165
+ };
24166
+ const rawClaim = readParam("claim");
24167
+ let claim;
24168
+ if (rawClaim && CLAIM_MODES.has(rawClaim)) {
24169
+ claim = rawClaim;
24170
+ } else {
24171
+ claim = clientId ? "if_unowned" : "none";
24172
+ }
24173
+ return { info, claim };
24174
+ }
24175
+ function resolveBridgeClaim(options) {
24176
+ const { clientId, claim, owner, ownerHasLiveSocket } = options;
24177
+ if (!clientId || claim === "none") return false;
24178
+ if (!owner) return true;
24179
+ if (owner.client_id === clientId) return true;
24180
+ if (claim === "takeover") return true;
24181
+ if (claim === "if_stale") return !ownerHasLiveSocket;
24182
+ return false;
24183
+ }
24184
+ function selectBridgeSocket(candidates, ownerClientId) {
24185
+ if (ownerClientId == null) {
24186
+ return candidates.length > 0 ? candidates[0].socket : null;
24187
+ }
24188
+ let legacyFallback = null;
24189
+ for (const candidate of candidates) {
24190
+ if (candidate.clientId === ownerClientId) return candidate.socket;
24191
+ if (candidate.clientId == null && legacyFallback == null) {
24192
+ legacyFallback = candidate.socket;
24193
+ }
24194
+ }
24195
+ return legacyFallback;
24196
+ }
24197
+
24198
+ // src/durable-objects/DurableThread.ts
24063
24199
  init_code_execution();
24064
24200
  var DEFAULT_CLIENT_TOOL_TIMEOUT_MS = 5 * 60 * 1e3;
24065
24201
  var BRIDGE_LIVENESS_TIMEOUT_MS = 20 * 1e3;
@@ -24225,6 +24361,14 @@ var DurableThread = class _DurableThread extends DurableObject {
24225
24361
  // Last time we heard anything (incl. heartbeat pings) from each bridge socket,
24226
24362
  // used to detect a dead/half-open client quickly while a tool is in flight.
24227
24363
  bridgeLastSeen = /* @__PURE__ */ new Map();
24364
+ // Identity each bridge socket presented at connect (`client_id` etc.). Legacy
24365
+ // clients present nothing and map to null fields. Restored from the socket
24366
+ // attachment after hibernation.
24367
+ bridgeClientInfo = /* @__PURE__ */ new Map();
24368
+ // In-memory mirror of the thread-KV `execution_owner` record. `undefined`
24369
+ // means "not loaded yet"; `null` means "loaded, no owner". Kept coherent by
24370
+ // setValue() so external KV writes (release/reassign) invalidate it too.
24371
+ executionOwnerCache = void 0;
24228
24372
  pendingClientCalls = /* @__PURE__ */ new Map();
24229
24373
  alarmQueue;
24230
24374
  alarmQueueRearmed = false;
@@ -24295,6 +24439,11 @@ var DurableThread = class _DurableThread extends DurableObject {
24295
24439
  } else if (attachment?.type === "bridge") {
24296
24440
  this.bridgeSockets.add(ws);
24297
24441
  this.bridgeLastSeen.set(ws, Date.now());
24442
+ this.bridgeClientInfo.set(ws, {
24443
+ clientId: typeof attachment.clientId === "string" ? attachment.clientId : null,
24444
+ clientName: typeof attachment.clientName === "string" ? attachment.clientName : null,
24445
+ clientKind: typeof attachment.clientKind === "string" ? attachment.clientKind : null
24446
+ });
24298
24447
  }
24299
24448
  }
24300
24449
  }
@@ -24788,6 +24937,9 @@ var DurableThread = class _DurableThread extends DurableObject {
24788
24937
  async setValue(key, value) {
24789
24938
  await this.ensureMigrated();
24790
24939
  this.assertValidThreadValueKey(key);
24940
+ if (key === EXECUTION_OWNER_KEY) {
24941
+ this.executionOwnerCache = this.parseExecutionOwner(value);
24942
+ }
24791
24943
  const updatedAt = Date.now() * 1e3;
24792
24944
  if (value === null || value === void 0) {
24793
24945
  await this.ctx.storage.sql.exec(`DELETE FROM thread_values WHERE key = ?`, key);
@@ -25435,9 +25587,9 @@ var DurableThread = class _DurableThread extends DurableObject {
25435
25587
  * Each migration is run in order, starting from the current version + 1.
25436
25588
  */
25437
25589
  async runMigrations(fromVersion) {
25438
- for (const migration44 of migrations) {
25439
- if (migration44.version > fromVersion) {
25440
- await migration44.up(this.ctx.storage.sql);
25590
+ for (const migration46 of migrations) {
25591
+ if (migration46.version > fromVersion) {
25592
+ await migration46.up(this.ctx.storage.sql);
25441
25593
  }
25442
25594
  }
25443
25595
  }
@@ -27484,20 +27636,208 @@ ${resultContent}${attachmentSummary}`;
27484
27636
  async handleBridgeWebSocketUpgrade(request) {
27485
27637
  const pair = new WebSocketPair();
27486
27638
  const [client, server] = Object.values(pair);
27639
+ const requestUrl2 = new URL(request.url);
27640
+ const { info, claim } = parseBridgeClientParams(requestUrl2);
27641
+ const routeThreadId = requestUrl2.pathname.match(/\/api\/threads\/([^/]+)\//)?.[1] ?? null;
27487
27642
  this.ctx.acceptWebSocket(server);
27488
- server.serializeAttachment({ type: "bridge" });
27643
+ server.serializeAttachment({
27644
+ type: "bridge",
27645
+ clientId: info.clientId,
27646
+ clientName: info.clientName,
27647
+ clientKind: info.clientKind
27648
+ });
27489
27649
  this.bridgeSockets.add(server);
27490
27650
  this.bridgeLastSeen.set(server, Date.now());
27651
+ this.bridgeClientInfo.set(server, info);
27652
+ if (info.clientId) {
27653
+ for (const ws of Array.from(this.bridgeSockets)) {
27654
+ if (ws === server) continue;
27655
+ if ((this.bridgeClientInfo.get(ws)?.clientId ?? null) !== info.clientId) continue;
27656
+ this.bridgeSockets.delete(ws);
27657
+ this.bridgeLastSeen.delete(ws);
27658
+ this.bridgeClientInfo.delete(ws);
27659
+ try {
27660
+ ws.send(JSON.stringify({ type: "superseded" }));
27661
+ } catch {
27662
+ }
27663
+ try {
27664
+ ws.close(4001, "Superseded by a newer connection from this client");
27665
+ } catch {
27666
+ }
27667
+ }
27668
+ }
27669
+ let owner = await this.getExecutionOwner();
27670
+ let claimRefused = null;
27671
+ const shouldClaim = resolveBridgeClaim({
27672
+ clientId: info.clientId,
27673
+ claim,
27674
+ owner,
27675
+ ownerHasLiveSocket: owner ? this.hasLiveBridgeSocketForClient(owner.client_id, server) : false
27676
+ });
27677
+ if (shouldClaim && info.clientId) {
27678
+ const changed = !owner || owner.client_id !== info.clientId;
27679
+ const ownerLive = owner ? this.hasLiveBridgeSocketForClient(owner.client_id, server) : false;
27680
+ if (changed && ownerLive && await this.hasInFlightClientCalls()) {
27681
+ claimRefused = "in_flight";
27682
+ } else {
27683
+ const previous = owner;
27684
+ const next = {
27685
+ client_id: info.clientId,
27686
+ client_name: info.clientName,
27687
+ client_kind: info.clientKind,
27688
+ claimed_at: Date.now(),
27689
+ generation: changed ? (owner?.generation ?? 0) + 1 : owner?.generation ?? 1
27690
+ };
27691
+ await this.setExecutionOwner(next);
27692
+ owner = next;
27693
+ if (changed) {
27694
+ this.broadcastOwnerChanged(next, server);
27695
+ if (previous) await this.failStalePendingWork(previous, routeThreadId);
27696
+ }
27697
+ }
27698
+ }
27699
+ const isOwner = owner ? info.clientId === owner.client_id : true;
27491
27700
  try {
27492
- server.send(JSON.stringify({ type: "bridge_ready", threadId: this.ctx.id.toString() }));
27701
+ server.send(
27702
+ JSON.stringify({
27703
+ type: "bridge_ready",
27704
+ threadId: this.ctx.id.toString(),
27705
+ owner: isOwner,
27706
+ ...claimRefused ? { claim_refused: claimRefused } : {},
27707
+ current_owner: owner ? {
27708
+ client_id: owner.client_id,
27709
+ client_name: owner.client_name ?? null,
27710
+ client_kind: owner.client_kind ?? null,
27711
+ generation: owner.generation
27712
+ } : null
27713
+ })
27714
+ );
27493
27715
  } catch {
27494
27716
  }
27495
- void this.resendPendingClientCalls(server);
27717
+ if (isOwner) {
27718
+ void this.resendPendingClientCalls(server);
27719
+ }
27496
27720
  return new Response(null, {
27497
27721
  status: 101,
27498
27722
  webSocket: client
27499
27723
  });
27500
27724
  }
27725
+ /** Parse a raw KV value into an {@link ExecutionOwner}, or null. */
27726
+ parseExecutionOwner(value) {
27727
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
27728
+ const record = value;
27729
+ if (typeof record.client_id !== "string" || record.client_id.length === 0) {
27730
+ return null;
27731
+ }
27732
+ return {
27733
+ client_id: record.client_id,
27734
+ client_name: typeof record.client_name === "string" ? record.client_name : null,
27735
+ client_kind: typeof record.client_kind === "string" ? record.client_kind : null,
27736
+ claimed_at: typeof record.claimed_at === "number" ? record.claimed_at : 0,
27737
+ generation: typeof record.generation === "number" ? record.generation : 0
27738
+ };
27739
+ }
27740
+ /** Load (and cache) the thread's persisted execution-owner record. */
27741
+ async getExecutionOwner() {
27742
+ if (this.executionOwnerCache !== void 0) return this.executionOwnerCache;
27743
+ const raw = await this.getValue(EXECUTION_OWNER_KEY);
27744
+ this.executionOwnerCache = this.parseExecutionOwner(raw);
27745
+ return this.executionOwnerCache;
27746
+ }
27747
+ /** Owner id from the in-memory mirror only (for sync paths); null when
27748
+ * unloaded or unowned — both degrade to the historical any-socket rule. */
27749
+ currentExecutionOwnerId() {
27750
+ return this.executionOwnerCache?.client_id ?? null;
27751
+ }
27752
+ /** Persist + cache the execution-owner record (null releases ownership). */
27753
+ async setExecutionOwner(owner) {
27754
+ await this.setValue(EXECUTION_OWNER_KEY, owner);
27755
+ }
27756
+ /** Whether the given client id has a live bridge socket (excluding `except`). */
27757
+ hasLiveBridgeSocketForClient(clientId, except) {
27758
+ const now = Date.now();
27759
+ for (const ws of this.bridgeSockets) {
27760
+ if (ws === except) continue;
27761
+ if (ws.readyState !== WebSocket.OPEN) continue;
27762
+ const lastSeen = this.bridgeLastSeen.get(ws) ?? 0;
27763
+ if (now - lastSeen > BRIDGE_LIVENESS_TIMEOUT_MS) continue;
27764
+ if ((this.bridgeClientInfo.get(ws)?.clientId ?? null) === clientId) return true;
27765
+ }
27766
+ return false;
27767
+ }
27768
+ /** Tell every other bridge socket that execution ownership moved. */
27769
+ broadcastOwnerChanged(owner, except) {
27770
+ const frame = JSON.stringify({
27771
+ type: "owner_changed",
27772
+ owner: {
27773
+ client_id: owner.client_id,
27774
+ client_name: owner.client_name ?? null,
27775
+ client_kind: owner.client_kind ?? null,
27776
+ generation: owner.generation
27777
+ }
27778
+ });
27779
+ for (const ws of this.bridgeSockets) {
27780
+ if (ws === except) continue;
27781
+ try {
27782
+ if (ws.readyState === WebSocket.OPEN) ws.send(frame);
27783
+ } catch {
27784
+ }
27785
+ }
27786
+ }
27787
+ /** Whether any forwarded call is mid-flight (blocking await or parked). */
27788
+ async hasInFlightClientCalls() {
27789
+ if (this.pendingClientCalls.size > 0) return true;
27790
+ await this.ensureMigrated();
27791
+ await this.ensurePendingClientTable();
27792
+ const cursor = await this.ctx.storage.sql.exec(
27793
+ `SELECT COUNT(*) AS total FROM pending_client_calls WHERE status = 'pending'`
27794
+ );
27795
+ return Number(cursor.toArray()[0]?.total ?? 0) > 0;
27796
+ }
27797
+ /**
27798
+ * Ownership moved away from a dead client: settle everything that was
27799
+ * dispatched to it. In-memory blocking calls fail immediately; parked
27800
+ * durable calls get an error result and the turn resumes — re-sending them
27801
+ * to the NEW owner would silently re-run the operation on a different
27802
+ * machine's filesystem, which is worse than an honest retryable error.
27803
+ */
27804
+ async failStalePendingWork(previous, threadId) {
27805
+ for (const id of Array.from(this.pendingClientCalls.keys())) {
27806
+ this.settleClientCall(id, {
27807
+ ok: false,
27808
+ error: "The client executing this operation was replaced before it finished.",
27809
+ data: { noClient: true }
27810
+ });
27811
+ }
27812
+ try {
27813
+ await this.ensureMigrated();
27814
+ await this.ensurePendingClientTable();
27815
+ const cursor = await this.ctx.storage.sql.exec(
27816
+ `SELECT tool_call_id FROM pending_client_calls
27817
+ WHERE status = 'pending' AND dispatched_client_id = ?`,
27818
+ previous.client_id
27819
+ );
27820
+ const sides = /* @__PURE__ */ new Set();
27821
+ for (const row of cursor.toArray()) {
27822
+ const delivered = await this.deliverClientToolResult(
27823
+ row.tool_call_id,
27824
+ false,
27825
+ void 0,
27826
+ "The client executing this operation was replaced before it finished. Retry if the work is still needed.",
27827
+ previous.client_id,
27828
+ previous.generation
27829
+ );
27830
+ if (delivered) sides.add(delivered.side);
27831
+ }
27832
+ if (threadId) {
27833
+ for (const side of sides) {
27834
+ await this.continueExecution(threadId, side);
27835
+ }
27836
+ }
27837
+ } catch (error) {
27838
+ console.error("[DurableThread] failStalePendingWork:", error);
27839
+ }
27840
+ }
27501
27841
  /**
27502
27842
  * Forward a tool operation to a connected bridge client and await its result.
27503
27843
  *
@@ -27506,7 +27846,8 @@ ${resultContent}${attachmentSummary}`;
27506
27846
  * denial) for the model to reason about.
27507
27847
  */
27508
27848
  async callClientTool(request) {
27509
- const socket = this.getOpenBridgeSocket();
27849
+ const owner = await this.getExecutionOwner();
27850
+ const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
27510
27851
  if (!socket) {
27511
27852
  return {
27512
27853
  ok: false,
@@ -27537,7 +27878,7 @@ ${resultContent}${attachmentSummary}`;
27537
27878
  });
27538
27879
  }
27539
27880
  }, 5e3);
27540
- this.pendingClientCalls.set(id, { resolve: resolve2, timer, liveness });
27881
+ this.pendingClientCalls.set(id, { resolve: resolve2, timer, liveness, socket });
27541
27882
  const frame = JSON.stringify({
27542
27883
  type: "tool_request",
27543
27884
  id,
@@ -27571,6 +27912,7 @@ ${resultContent}${attachmentSummary}`;
27571
27912
  markBridgeDead(socket) {
27572
27913
  this.bridgeSockets.delete(socket);
27573
27914
  this.bridgeLastSeen.delete(socket);
27915
+ this.bridgeClientInfo.delete(socket);
27574
27916
  try {
27575
27917
  socket.close();
27576
27918
  } catch {
@@ -27578,33 +27920,42 @@ ${resultContent}${attachmentSummary}`;
27578
27920
  this.failPendingClientCallsIfNoBridge();
27579
27921
  }
27580
27922
  /**
27581
- * Return the first bridge socket that is open AND still proving liveness,
27582
- * pruning dead ones. A half-open client (idle NAT/proxy drop) keeps a
27923
+ * Return the bridge socket a forwarded call should be dispatched to, pruning
27924
+ * dead ones. A half-open client (idle NAT/proxy drop) keeps a
27583
27925
  * `readyState === OPEN` socket that never fires close/error, so we also treat
27584
27926
  * a socket we haven't heard from within {@link BRIDGE_LIVENESS_TIMEOUT_MS} as
27585
27927
  * dead. The CLI heartbeats every few seconds, so this only trips on a truly
27586
27928
  * gone client — and it means we won't dispatch a forwarded call into the void.
27929
+ *
27930
+ * With an execution owner recorded, only the owner's socket (or, as a
27931
+ * back-compat fallback, an unidentified legacy socket) is eligible; with no
27932
+ * owner, the first live socket wins as it always has.
27587
27933
  */
27588
- getOpenBridgeSocket() {
27934
+ getOpenBridgeSocket(ownerClientId) {
27589
27935
  const now = Date.now();
27936
+ const live = [];
27590
27937
  for (const ws of Array.from(this.bridgeSockets)) {
27591
27938
  const lastSeen = this.bridgeLastSeen.get(ws) ?? 0;
27592
27939
  if (ws.readyState === WebSocket.OPEN && now - lastSeen <= BRIDGE_LIVENESS_TIMEOUT_MS) {
27593
- return ws;
27940
+ live.push({ socket: ws, clientId: this.bridgeClientInfo.get(ws)?.clientId ?? null });
27941
+ continue;
27594
27942
  }
27595
27943
  this.bridgeSockets.delete(ws);
27596
27944
  this.bridgeLastSeen.delete(ws);
27945
+ this.bridgeClientInfo.delete(ws);
27597
27946
  try {
27598
27947
  ws.close();
27599
27948
  } catch {
27600
27949
  }
27601
27950
  }
27602
- return null;
27951
+ return selectBridgeSocket(live, ownerClientId);
27603
27952
  }
27604
27953
  /** Resolve an in-flight forwarded tool call from a client `tool_response`. */
27605
- resolveClientCall(payload) {
27954
+ resolveClientCall(ws, payload) {
27606
27955
  const id = typeof payload.id === "string" ? payload.id : null;
27607
27956
  if (!id) return;
27957
+ const pending = this.pendingClientCalls.get(id);
27958
+ if (pending?.socket && pending.socket !== ws) return;
27608
27959
  this.settleClientCall(id, {
27609
27960
  ok: payload.ok === true,
27610
27961
  result: typeof payload.result === "string" ? payload.result : void 0,
@@ -27613,11 +27964,14 @@ ${resultContent}${attachmentSummary}`;
27613
27964
  });
27614
27965
  }
27615
27966
  /**
27616
- * When the last bridge client disconnects, fail any in-flight forwarded tool
27617
- * calls so the agent loop doesn't hang waiting on a client that is gone.
27967
+ * When the last bridge client that could serve forwarded calls disconnects,
27968
+ * fail any in-flight ones so the agent loop doesn't hang waiting on a client
27969
+ * that is gone. Uses the in-memory owner mirror (sync path); when the mirror
27970
+ * isn't loaded this degrades to the historical any-socket check, and the
27971
+ * per-call liveness interval still catches a dead dispatch target.
27618
27972
  */
27619
27973
  failPendingClientCallsIfNoBridge() {
27620
- if (this.getOpenBridgeSocket()) return;
27974
+ if (this.getOpenBridgeSocket(this.currentExecutionOwnerId())) return;
27621
27975
  for (const id of Array.from(this.pendingClientCalls.keys())) {
27622
27976
  this.settleClientCall(id, {
27623
27977
  ok: false,
@@ -27626,9 +27980,11 @@ ${resultContent}${attachmentSummary}`;
27626
27980
  });
27627
27981
  }
27628
27982
  }
27629
- /** Whether a bridge client is connected right now (used to decide durable park). */
27630
- hasBridge() {
27631
- return this.getOpenBridgeSocket() !== null;
27983
+ /** Whether a bridge client that can execute forwarded calls is connected
27984
+ * right now (used to decide durable park). */
27985
+ async hasBridge() {
27986
+ const owner = await this.getExecutionOwner();
27987
+ return this.getOpenBridgeSocket(owner?.client_id ?? null) !== null;
27632
27988
  }
27633
27989
  // ─── Durable forwarded tool calls (park & resume) ──────────────────────────
27634
27990
  // A permission-gated forwarded call must not hold the agent loop open across
@@ -27645,7 +28001,9 @@ ${resultContent}${attachmentSummary}`;
27645
28001
  risk INTEGER,
27646
28002
  side TEXT NOT NULL DEFAULT 'a',
27647
28003
  status TEXT NOT NULL DEFAULT 'pending',
27648
- created_at INTEGER NOT NULL
28004
+ created_at INTEGER NOT NULL,
28005
+ dispatched_client_id TEXT,
28006
+ dispatched_generation INTEGER NOT NULL DEFAULT 0
27649
28007
  )
27650
28008
  `);
27651
28009
  }
@@ -27670,13 +28028,31 @@ ${resultContent}${attachmentSummary}`;
27670
28028
  side,
27671
28029
  Date.now()
27672
28030
  );
27673
- return { dispatched: this.sendClientToolRequest(toolCallId, request) };
28031
+ return { dispatched: await this.sendClientToolRequest(toolCallId, request) };
27674
28032
  }
27675
- /** Send one durable `tool_request` frame to the bridge if a client is connected. */
27676
- sendClientToolRequest(toolCallId, request) {
27677
- const socket = this.getOpenBridgeSocket();
28033
+ /**
28034
+ * Send one durable `tool_request` frame to the bridge if a client is
28035
+ * connected and stamp the pending row with the identity+generation it was
28036
+ * dispatched to, fencing the eventual HTTP delivery to that client. A
28037
+ * legacy (unidentified) executor leaves the row unstamped, keeping the
28038
+ * historical accept-anything delivery for old CLIs.
28039
+ */
28040
+ async sendClientToolRequest(toolCallId, request) {
28041
+ const owner = await this.getExecutionOwner();
28042
+ const socket = this.getOpenBridgeSocket(owner?.client_id ?? null);
27678
28043
  if (!socket) return false;
28044
+ const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
28045
+ const fenced = owner && socketClientId === owner.client_id;
28046
+ const generation = fenced ? owner.generation : 0;
27679
28047
  try {
28048
+ await this.ctx.storage.sql.exec(
28049
+ `UPDATE pending_client_calls
28050
+ SET dispatched_client_id = ?, dispatched_generation = ?
28051
+ WHERE tool_call_id = ? AND status = 'pending'`,
28052
+ fenced ? owner.client_id : null,
28053
+ generation,
28054
+ toolCallId
28055
+ );
27680
28056
  socket.send(
27681
28057
  JSON.stringify({
27682
28058
  type: "tool_request",
@@ -27685,7 +28061,8 @@ ${resultContent}${attachmentSummary}`;
27685
28061
  tool: request.tool,
27686
28062
  args: request.args ?? {},
27687
28063
  requestPermission: request.requestPermission ?? null,
27688
- risk: typeof request.risk === "number" ? request.risk : null
28064
+ risk: typeof request.risk === "number" ? request.risk : null,
28065
+ generation
27689
28066
  })
27690
28067
  );
27691
28068
  this.bridgeLastSeen.set(socket, Date.now());
@@ -27721,9 +28098,21 @@ ${resultContent}${attachmentSummary}`;
27721
28098
  /** Re-send all pending durable requests to a freshly connected bridge socket. */
27722
28099
  async resendPendingClientCalls(socket) {
27723
28100
  try {
28101
+ const owner = await this.getExecutionOwner();
28102
+ const socketClientId = this.bridgeClientInfo.get(socket)?.clientId ?? null;
28103
+ const fenced = owner && socketClientId === owner.client_id;
28104
+ const generation = fenced ? owner.generation : 0;
27724
28105
  const pending = await this.getPendingClientCalls();
27725
28106
  for (const p of pending) {
27726
28107
  if (socket.readyState !== WebSocket.OPEN) break;
28108
+ await this.ctx.storage.sql.exec(
28109
+ `UPDATE pending_client_calls
28110
+ SET dispatched_client_id = ?, dispatched_generation = ?
28111
+ WHERE tool_call_id = ? AND status = 'pending'`,
28112
+ fenced ? owner.client_id : null,
28113
+ generation,
28114
+ p.toolCallId
28115
+ );
27727
28116
  socket.send(
27728
28117
  JSON.stringify({
27729
28118
  type: "tool_request",
@@ -27732,7 +28121,8 @@ ${resultContent}${attachmentSummary}`;
27732
28121
  tool: p.tool,
27733
28122
  args: p.args,
27734
28123
  requestPermission: p.requestPermission,
27735
- risk: p.risk
28124
+ risk: p.risk,
28125
+ generation
27736
28126
  })
27737
28127
  );
27738
28128
  }
@@ -27745,15 +28135,21 @@ ${resultContent}${attachmentSummary}`;
27745
28135
  * call resolved. Returns the side to continue, or null if there is no pending
27746
28136
  * call (unknown id or already resolved — the delivery is idempotent).
27747
28137
  */
27748
- async deliverClientToolResult(toolCallId, ok, result, error) {
28138
+ async deliverClientToolResult(toolCallId, ok, result, error, deliveredByClientId, deliveredGeneration) {
27749
28139
  await this.ensureMigrated();
27750
28140
  await this.ensurePendingClientTable();
27751
28141
  const pendingCursor = await this.ctx.storage.sql.exec(
27752
- `SELECT side, status FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
28142
+ `SELECT side, status, dispatched_client_id, dispatched_generation
28143
+ FROM pending_client_calls WHERE tool_call_id = ? LIMIT 1`,
27753
28144
  toolCallId
27754
28145
  );
27755
28146
  const pending = pendingCursor.toArray()[0];
27756
28147
  if (!pending || pending.status !== "pending") return null;
28148
+ if (pending.dispatched_client_id) {
28149
+ if (pending.dispatched_client_id !== deliveredByClientId || Number(pending.dispatched_generation ?? 0) !== (deliveredGeneration ?? -1)) {
28150
+ return null;
28151
+ }
28152
+ }
27757
28153
  const side = pending.side === "b" ? "b" : "a";
27758
28154
  const content = ok ? result ?? "" : `Error: ${error ?? "tool failed"}`;
27759
28155
  const toolStatus = ok ? "success" : "error";
@@ -27989,7 +28385,7 @@ ${resultContent}${attachmentSummary}`;
27989
28385
  parsed = null;
27990
28386
  }
27991
28387
  if (parsed && parsed.type === "tool_response") {
27992
- this.resolveClientCall(parsed);
28388
+ this.resolveClientCall(ws, parsed);
27993
28389
  return;
27994
28390
  }
27995
28391
  }
@@ -28011,6 +28407,7 @@ ${resultContent}${attachmentSummary}`;
28011
28407
  } else if (attachment?.type === "bridge") {
28012
28408
  this.bridgeSockets.delete(ws);
28013
28409
  this.bridgeLastSeen.delete(ws);
28410
+ this.bridgeClientInfo.delete(ws);
28014
28411
  this.failPendingClientCallsIfNoBridge();
28015
28412
  }
28016
28413
  }
@@ -28031,6 +28428,7 @@ ${resultContent}${attachmentSummary}`;
28031
28428
  } else if (attachment?.type === "bridge") {
28032
28429
  this.bridgeSockets.delete(ws);
28033
28430
  this.bridgeLastSeen.delete(ws);
28431
+ this.bridgeClientInfo.delete(ws);
28034
28432
  this.failPendingClientCallsIfNoBridge();
28035
28433
  }
28036
28434
  }
@@ -29591,7 +29989,7 @@ Its execution was lost (the executor restarted or hung) and automatic recovery w
29591
29989
  };
29592
29990
 
29593
29991
  // src/durable-objects/agentbuilder-migrations/0001_initial.ts
29594
- var migration34 = {
29992
+ var migration35 = {
29595
29993
  version: 1,
29596
29994
  async up(sql) {
29597
29995
  sql.exec(`
@@ -29690,7 +30088,7 @@ var migration34 = {
29690
30088
  };
29691
30089
 
29692
30090
  // src/durable-objects/agentbuilder-migrations/0002_add_tenvs_properties.ts
29693
- var migration35 = {
30091
+ var migration36 = {
29694
30092
  version: 2,
29695
30093
  async up(sql) {
29696
30094
  sql.exec(`ALTER TABLE threads ADD COLUMN tenvs TEXT`);
@@ -29702,7 +30100,7 @@ var migration35 = {
29702
30100
  };
29703
30101
 
29704
30102
  // src/durable-objects/agentbuilder-migrations/0003_add_parent_and_terminated.ts
29705
- var migration36 = {
30103
+ var migration37 = {
29706
30104
  version: 3,
29707
30105
  async up(sql) {
29708
30106
  sql.exec(`ALTER TABLE threads ADD COLUMN parent TEXT REFERENCES threads(id)`);
@@ -29715,7 +30113,7 @@ var migration36 = {
29715
30113
  };
29716
30114
 
29717
30115
  // src/durable-objects/agentbuilder-migrations/0004_add_env_tables.ts
29718
- var migration37 = {
30116
+ var migration38 = {
29719
30117
  version: 4,
29720
30118
  async up(sql) {
29721
30119
  sql.exec(`ALTER TABLE threads ADD COLUMN env TEXT`);
@@ -29739,7 +30137,7 @@ var migration37 = {
29739
30137
  };
29740
30138
 
29741
30139
  // src/durable-objects/agentbuilder-migrations/0005_add_pending_subagent_env_requests.ts
29742
- var migration38 = {
30140
+ var migration39 = {
29743
30141
  version: 5,
29744
30142
  async up(sql) {
29745
30143
  sql.exec(`
@@ -29765,7 +30163,7 @@ var migration38 = {
29765
30163
  };
29766
30164
 
29767
30165
  // src/durable-objects/agentbuilder-migrations/0006_add_env_value_types.ts
29768
- var migration39 = {
30166
+ var migration40 = {
29769
30167
  version: 6,
29770
30168
  async up(sql) {
29771
30169
  sql.exec(
@@ -29780,7 +30178,7 @@ var migration39 = {
29780
30178
  };
29781
30179
 
29782
30180
  // src/durable-objects/agentbuilder-migrations/0007_platform_identity_replica.ts
29783
- var migration40 = {
30181
+ var migration41 = {
29784
30182
  version: 7,
29785
30183
  async up(sql) {
29786
30184
  sql.exec(`ALTER TABLE users ADD COLUMN platform_user_id TEXT`);
@@ -29809,7 +30207,7 @@ var migration40 = {
29809
30207
  };
29810
30208
 
29811
30209
  // src/durable-objects/agentbuilder-migrations/0008_add_skills.ts
29812
- var migration41 = {
30210
+ var migration42 = {
29813
30211
  version: 8,
29814
30212
  async up(sql) {
29815
30213
  sql.exec(`
@@ -29840,7 +30238,7 @@ var migration41 = {
29840
30238
  };
29841
30239
 
29842
30240
  // src/durable-objects/agentbuilder-migrations/0009_add_user_permissions.ts
29843
- var migration42 = {
30241
+ var migration43 = {
29844
30242
  version: 9,
29845
30243
  async up(sql) {
29846
30244
  sql.exec(`ALTER TABLE users ADD COLUMN permissions TEXT`);
@@ -29851,7 +30249,7 @@ var migration42 = {
29851
30249
  };
29852
30250
 
29853
30251
  // src/durable-objects/agentbuilder-migrations/0010_add_device_logins.ts
29854
- var migration43 = {
30252
+ var migration44 = {
29855
30253
  version: 10,
29856
30254
  async up(sql) {
29857
30255
  sql.exec(`
@@ -29870,9 +30268,32 @@ var migration43 = {
29870
30268
  }
29871
30269
  };
29872
30270
 
30271
+ // src/durable-objects/agentbuilder-migrations/0011_add_user_values.ts
30272
+ var migration45 = {
30273
+ version: 11,
30274
+ async up(sql) {
30275
+ sql.exec(`
30276
+ CREATE TABLE IF NOT EXISTS user_values (
30277
+ user_id TEXT NOT NULL,
30278
+ key TEXT NOT NULL,
30279
+ value TEXT NOT NULL,
30280
+ created_at INTEGER NOT NULL,
30281
+ updated_at INTEGER NOT NULL,
30282
+ PRIMARY KEY (user_id, key)
30283
+ )
30284
+ `);
30285
+ sql.exec(`
30286
+ CREATE INDEX IF NOT EXISTS idx_user_values_user ON user_values(user_id)
30287
+ `);
30288
+ sql.exec(`
30289
+ INSERT OR REPLACE INTO _metadata (key, value) VALUES ('schema_version', '11')
30290
+ `);
30291
+ }
30292
+ };
30293
+
29873
30294
  // src/durable-objects/agentbuilder-migrations/index.ts
29874
- var migrations2 = [migration34, migration35, migration36, migration37, migration38, migration39, migration40, migration41, migration42, migration43];
29875
- var LATEST_SCHEMA_VERSION2 = 10;
30295
+ var migrations2 = [migration35, migration36, migration37, migration38, migration39, migration40, migration41, migration42, migration43, migration44, migration45];
30296
+ var LATEST_SCHEMA_VERSION2 = 11;
29876
30297
 
29877
30298
  // src/utils/crypto.ts
29878
30299
  var CryptoUtil = class {
@@ -30634,9 +31055,9 @@ ${result ?? error ?? "No result content."}${attachmentSummary}`;
30634
31055
  }
30635
31056
  }
30636
31057
  async runMigrations(fromVersion) {
30637
- for (const migration44 of migrations2) {
30638
- if (migration44.version > fromVersion) {
30639
- await migration44.up(this.ctx.storage.sql);
31058
+ for (const migration46 of migrations2) {
31059
+ if (migration46.version > fromVersion) {
31060
+ await migration46.up(this.ctx.storage.sql);
30640
31061
  }
30641
31062
  }
30642
31063
  }
@@ -31180,6 +31601,117 @@ ${result ?? error ?? "No result content."}${attachmentSummary}`;
31180
31601
  async patchUserEnv(userId, params) {
31181
31602
  await this.patchScopedEnv("user", userId, params);
31182
31603
  }
31604
+ // ============================================================
31605
+ // Per-user durable key-value store (account-wide, not thread-tied)
31606
+ //
31607
+ // The account-scoped sibling of the per-thread `thread_values` store:
31608
+ // JSON documents keyed by (user_id, key), for state a user carries across
31609
+ // every thread on the instance (e.g. a coding client's machine/daemon
31610
+ // registry). Mirrors thread-KV semantics — null/undefined deletes.
31611
+ // ============================================================
31612
+ assertValidUserValueKey(key) {
31613
+ if (typeof key !== "string" || key.length === 0) {
31614
+ throw new Error("User value key must be a non-empty string");
31615
+ }
31616
+ if (key.length > 512) {
31617
+ throw new Error("User value key must be at most 512 characters");
31618
+ }
31619
+ }
31620
+ assertValidUserValueUserId(userId) {
31621
+ if (typeof userId !== "string" || userId.length === 0) {
31622
+ throw new Error("User value user_id must be a non-empty string");
31623
+ }
31624
+ }
31625
+ /** Read one JSON value from a user's account-wide key-value store. */
31626
+ async getUserValue(userId, key) {
31627
+ await this.ensureMigrated();
31628
+ this.assertValidUserValueUserId(userId);
31629
+ this.assertValidUserValueKey(key);
31630
+ const cursor = await this.ctx.storage.sql.exec(
31631
+ `SELECT value FROM user_values WHERE user_id = ? AND key = ? LIMIT 1`,
31632
+ userId,
31633
+ key
31634
+ );
31635
+ const raw = cursor.toArray()[0]?.value;
31636
+ if (raw === void 0) return null;
31637
+ try {
31638
+ return JSON.parse(raw);
31639
+ } catch (error) {
31640
+ throw new Error(
31641
+ `Stored user value for key "${key}" is not valid JSON: ${error instanceof Error ? error.message : String(error)}`
31642
+ );
31643
+ }
31644
+ }
31645
+ /** Write one JSON value to a user's account-wide store; null/undefined deletes. */
31646
+ async setUserValue(userId, key, value) {
31647
+ await this.ensureMigrated();
31648
+ this.assertValidUserValueUserId(userId);
31649
+ this.assertValidUserValueKey(key);
31650
+ if (value === null || value === void 0) {
31651
+ await this.ctx.storage.sql.exec(
31652
+ `DELETE FROM user_values WHERE user_id = ? AND key = ?`,
31653
+ userId,
31654
+ key
31655
+ );
31656
+ return;
31657
+ }
31658
+ let serialized;
31659
+ try {
31660
+ serialized = JSON.stringify(value);
31661
+ } catch (error) {
31662
+ throw new Error(
31663
+ `User value for key "${key}" is not JSON-serializable: ${error instanceof Error ? error.message : String(error)}`
31664
+ );
31665
+ }
31666
+ if (typeof serialized !== "string") {
31667
+ throw new Error(`User value for key "${key}" is not JSON-serializable`);
31668
+ }
31669
+ const now = Date.now();
31670
+ await this.ctx.storage.sql.exec(
31671
+ `INSERT INTO user_values (user_id, key, value, created_at, updated_at)
31672
+ VALUES (?1, ?2, ?3, ?4, ?4)
31673
+ ON CONFLICT(user_id, key) DO UPDATE SET
31674
+ value = excluded.value,
31675
+ updated_at = excluded.updated_at`,
31676
+ userId,
31677
+ key,
31678
+ serialized,
31679
+ now
31680
+ );
31681
+ }
31682
+ /** List a user's account-wide values, optionally filtered by key prefix. */
31683
+ async listUserValues(userId, options = {}) {
31684
+ await this.ensureMigrated();
31685
+ this.assertValidUserValueUserId(userId);
31686
+ const limit = Math.min(Math.max(Math.trunc(options.limit ?? 100), 1), 500);
31687
+ const offset = Math.max(Math.trunc(options.offset ?? 0), 0);
31688
+ const prefix = typeof options.prefix === "string" && options.prefix.length > 0 ? options.prefix : null;
31689
+ const escapedPrefix = prefix ? `${prefix.replace(/[\\%_]/g, (m) => `\\${m}`)}%` : null;
31690
+ const where = escapedPrefix ? `WHERE user_id = ? AND key LIKE ? ESCAPE '\\'` : `WHERE user_id = ?`;
31691
+ const whereArgs = escapedPrefix ? [userId, escapedPrefix] : [userId];
31692
+ const totalCursor = await this.ctx.storage.sql.exec(
31693
+ `SELECT COUNT(*) AS total FROM user_values ${where}`,
31694
+ ...whereArgs
31695
+ );
31696
+ const total = Number(totalCursor.toArray()[0]?.total ?? 0);
31697
+ const cursor = await this.ctx.storage.sql.exec(
31698
+ `SELECT key, value, updated_at FROM user_values ${where}
31699
+ ORDER BY key ASC LIMIT ? OFFSET ?`,
31700
+ ...whereArgs,
31701
+ limit,
31702
+ offset
31703
+ );
31704
+ const entries = cursor.toArray().map((row) => {
31705
+ let value = null;
31706
+ try {
31707
+ value = JSON.parse(row.value);
31708
+ } catch {
31709
+ value = null;
31710
+ }
31711
+ return { key: row.key, value, updated_at: Number(row.updated_at) };
31712
+ });
31713
+ return { entries, total, limit, offset };
31714
+ }
31183
31715
  async getMissingRequiredScopedSubagentEnv(params) {
31184
31716
  await this.ensureMigrated();
31185
31717
  const requiredScoped = await this.getRequiredScopedVariableNamesForAgent(