@suveren/gateway 0.16.1 → 0.18.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.
@@ -2984,7 +2984,7 @@ function createGatedToolHandler(tool, integrationManager2, state2) {
2984
2984
  const gateable = !!tool.gating?.profile && tool.gating.category !== "disabled";
2985
2985
  const inner = async (args) => {
2986
2986
  const normalized = normalizeIncomingArgs(tool, args);
2987
- const refusal = gateable ? integrationManager2.precheckBuiltin?.(tool, normalized) : null;
2987
+ const refusal = gateable ? await integrationManager2.precheckBuiltin?.(tool, normalized) : null;
2988
2988
  if (refusal) return { content: [{ type: "text", text: refusal }], isError: true };
2989
2989
  return gated(normalized);
2990
2990
  };
@@ -5248,6 +5248,18 @@ var IntegrationManager = class {
5248
5248
  available: !def.simulationOnly || isSimulationMode()
5249
5249
  }));
5250
5250
  }
5251
+ /**
5252
+ * What the approval screen needs to show a proposal for each tool: its argument
5253
+ * schema (labels from the descriptions) and the optional approvalView hints.
5254
+ * Keyed by the namespaced tool name a proposal carries.
5255
+ */
5256
+ getToolDisplay() {
5257
+ const out = {};
5258
+ for (const t of this.getAllTools()) {
5259
+ out[t.namespacedName] = { inputSchema: t.inputSchema, ...t.gating?.approvalView ? { approvalView: t.gating.approvalView } : {} };
5260
+ }
5261
+ return out;
5262
+ }
5251
5263
  /** Whether `id` names a built-in (in-process) integration. */
5252
5264
  isBuiltin(id) {
5253
5265
  return this.builtins.has(id);
@@ -5266,14 +5278,14 @@ var IntegrationManager = class {
5266
5278
  * no ticket): `simulationOnly` outside simulation mode, then the tool's `validate`.
5267
5279
  * Null for connector tools and for calls that may proceed.
5268
5280
  */
5269
- precheckBuiltin(tool, args) {
5281
+ async precheckBuiltin(tool, args) {
5270
5282
  const builtin = this.builtins.get(tool.integrationId);
5271
5283
  if (!builtin) return null;
5272
5284
  if (builtin.def.simulationOnly && !isSimulationMode()) {
5273
5285
  return `Refused: "${tool.namespacedName}" is not available. No ticket was requested.`;
5274
5286
  }
5275
5287
  const def = builtin.def.tools.find((t) => t.name === tool.originalName);
5276
- const refusal = def?.validate?.(args);
5288
+ const refusal = await def?.validate?.(args);
5277
5289
  return refusal ? `Refused: ${refusal} No ticket was requested.` : null;
5278
5290
  }
5279
5291
  /**
@@ -5484,7 +5496,8 @@ var IntegrationManager = class {
5484
5496
  readGovernance: ext.readGovernance,
5485
5497
  readGovernanceReason: ext.readGovernanceReason,
5486
5498
  blockedArgs: ext.blockedArgs,
5487
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5499
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5500
+ approvalView: ext.approvalView
5488
5501
  };
5489
5502
  }
5490
5503
  return {
@@ -5499,7 +5512,8 @@ var IntegrationManager = class {
5499
5512
  blockedArgs: ext.blockedArgs,
5500
5513
  argEncoding: ext.argEncoding,
5501
5514
  argNormalization: ext.argNormalization,
5502
- hideUnlessAuthorized: ext.hideUnlessAuthorized
5515
+ hideUnlessAuthorized: ext.hideUnlessAuthorized,
5516
+ approvalView: ext.approvalView
5503
5517
  };
5504
5518
  }
5505
5519
  return {
@@ -5606,6 +5620,98 @@ function builtinText(text) {
5606
5620
  return { content: [{ type: "text", text }] };
5607
5621
  }
5608
5622
 
5623
+ // src/lib/cp-mandate.ts
5624
+ var CP_PORT2 = process.env.SUVEREN_CP_PORT ?? "3402";
5625
+ var CP_BASE2 = process.env.SUVEREN_CP_INTERNAL_URL ?? `http://127.0.0.1:${CP_PORT2}`;
5626
+ async function controlPlaneMandate(dryRun, request) {
5627
+ const secret = process.env.SUVEREN_INTERNAL_SECRET ?? "";
5628
+ if (!secret) return { ok: false, message: "the gateway is not fully started (no control plane connection)." };
5629
+ try {
5630
+ const res = await fetch(`${CP_BASE2}/internal/mandate`, {
5631
+ method: "POST",
5632
+ headers: { "Content-Type": "application/json", "X-Internal-Secret": secret },
5633
+ body: JSON.stringify({ dryRun, request }),
5634
+ signal: AbortSignal.timeout(3e4)
5635
+ });
5636
+ const body = await res.json().catch(() => ({}));
5637
+ if (res.ok) return { ok: true, ...body };
5638
+ return { ok: false, message: typeof body.message === "string" ? body.message : `control plane answered ${res.status}` };
5639
+ } catch (err) {
5640
+ return { ok: false, message: `the control plane could not be reached (${err instanceof Error ? err.message : String(err)}).` };
5641
+ }
5642
+ }
5643
+
5644
+ // src/lib/guides.ts
5645
+ import { existsSync as existsSync16, readdirSync, readFileSync as readFileSync17 } from "fs";
5646
+ import { dirname as dirname11, join as join16 } from "path";
5647
+ var FILE_RE = /^(?:(\d+)-)?([a-z0-9][a-z0-9-]*)\.md$/;
5648
+ function builtinGuidesDir() {
5649
+ if (process.env.SUVEREN_BUILTIN_GUIDES_DIR) return process.env.SUVEREN_BUILTIN_GUIDES_DIR;
5650
+ let dir = import.meta.dirname ?? __dirname;
5651
+ for (let i = 0; i < 8; i++) {
5652
+ const candidate = join16(dir, "content", "guides");
5653
+ if (existsSync16(candidate)) return candidate;
5654
+ const parent = dirname11(dir);
5655
+ if (parent === dir) break;
5656
+ dir = parent;
5657
+ }
5658
+ return join16(import.meta.dirname ?? __dirname, "content", "guides");
5659
+ }
5660
+ function parse(body) {
5661
+ const lines = body.split(/\r?\n/);
5662
+ const titleLine = lines.find((l) => l.startsWith("# ")) ?? "";
5663
+ const title = titleLine.slice(2).trim();
5664
+ const after = lines.slice(lines.indexOf(titleLine) + 1);
5665
+ const summary = after.find((l) => l.trim() !== "" && !l.startsWith("#"))?.trim() ?? "";
5666
+ return { title, summary };
5667
+ }
5668
+ function readDir(dir, source) {
5669
+ if (!dir || !existsSync16(dir)) return [];
5670
+ const out = [];
5671
+ for (const file of readdirSync(dir)) {
5672
+ const m = FILE_RE.exec(file);
5673
+ if (!m) continue;
5674
+ let body;
5675
+ try {
5676
+ body = readFileSync17(join16(dir, file), "utf8");
5677
+ } catch {
5678
+ continue;
5679
+ }
5680
+ const { title, summary } = parse(body);
5681
+ out.push({ topic: m[2], order: m[1] ? Number(m[1]) : Number.POSITIVE_INFINITY, title: title || m[2], summary, body, source });
5682
+ }
5683
+ return out;
5684
+ }
5685
+ function loadGuides(builtinDir = builtinGuidesDir(), overrideDir = process.env.SUVEREN_GUIDES_DIR) {
5686
+ const byTopic = /* @__PURE__ */ new Map();
5687
+ for (const g of readDir(builtinDir, "default")) byTopic.set(g.topic, g);
5688
+ for (const g of readDir(overrideDir, "override")) {
5689
+ const replaced = byTopic.get(g.topic);
5690
+ byTopic.set(g.topic, { ...g, order: Number.isFinite(g.order) ? g.order : replaced?.order ?? Number.POSITIVE_INFINITY });
5691
+ }
5692
+ return [...byTopic.values()].sort((a, b) => a.order - b.order || a.topic.localeCompare(b.topic));
5693
+ }
5694
+ function guideHeader(systems) {
5695
+ const lines = [
5696
+ "> This guide is in English. Talk to the person in their language, and write the brief and mandate intents in the language the team uses.",
5697
+ "",
5698
+ "**Systems connected to this gateway:**",
5699
+ ""
5700
+ ];
5701
+ if (systems.length === 0) {
5702
+ lines.push("- none yet \u2014 ask the person to connect the systems the work needs before you continue.");
5703
+ } else {
5704
+ for (const s of systems) {
5705
+ const parts = [`profile \`${s.profile}\``];
5706
+ if (s.actionTypes.length) parts.push(`action types ${s.actionTypes.map((a) => `\`${a}\``).join(", ")}`);
5707
+ if (s.writeTools.length) parts.push(`changes via ${s.writeTools.map((t) => `\`${t}\``).join(", ")}`);
5708
+ if (s.readTools.length) parts.push(`reads via ${s.readTools.map((t) => `\`${t}\``).join(", ")}`);
5709
+ lines.push(`- **${s.id}** \u2014 ${parts.join("; ")}`);
5710
+ }
5711
+ }
5712
+ return lines.join("\n");
5713
+ }
5714
+
5609
5715
  // src/lib/builtins/setup.ts
5610
5716
  var DELEGATION_PROFILE = "github.com/humanagencyprotocol/hap-profiles/delegation@0.1";
5611
5717
  function briefRefusal(args) {
@@ -5614,11 +5720,27 @@ function briefRefusal(args) {
5614
5720
  if (bytes > CONTEXT_MAX_BYTES) return `the brief is ${bytes} bytes; the limit is ${CONTEXT_MAX_BYTES}. Shorten it.`;
5615
5721
  return void 0;
5616
5722
  }
5617
- function setupBuiltin(_deps) {
5723
+ function connectedSystems(im) {
5724
+ const byId = /* @__PURE__ */ new Map();
5725
+ for (const t of im.getAllTools()) {
5726
+ if (im.isBuiltin(t.integrationId) || !t.gating?.profile || t.gating.category === "disabled") continue;
5727
+ const s = byId.get(t.integrationId) ?? { id: t.integrationId, profile: t.gating.profile, actionTypes: [], writeTools: [], readTools: [] };
5728
+ if (t.gating.category === "read") {
5729
+ s.readTools.push(t.originalName);
5730
+ } else {
5731
+ s.writeTools.push(t.originalName);
5732
+ const at = t.gating.staticExecution?.action_type;
5733
+ if (typeof at === "string" && !s.actionTypes.includes(at)) s.actionTypes.push(at);
5734
+ }
5735
+ byId.set(t.integrationId, s);
5736
+ }
5737
+ return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id));
5738
+ }
5739
+ function setupBuiltin(deps) {
5618
5740
  return {
5619
5741
  id: "setup",
5620
5742
  name: "Test setup",
5621
- description: "Your AI proposes its own setup for a test \u2014 for now its agent brief. Every proposal waits for your approval. Simulation mode only.",
5743
+ description: "Your AI sets up a test of itself: it reads the setup guides and proposes its agent brief and its mandates. Every proposal waits for your approval. Simulation mode only.",
5622
5744
  profile: DELEGATION_PROFILE,
5623
5745
  simulation: true,
5624
5746
  simulationOnly: true,
@@ -5627,11 +5749,67 @@ function setupBuiltin(_deps) {
5627
5749
  set_agent_brief: {
5628
5750
  executionMapping: {},
5629
5751
  staticExecution: { action_type: "brief" },
5630
- hideUnlessAuthorized: true
5752
+ hideUnlessAuthorized: true,
5753
+ approvalView: { content: { label: "New agent brief", kind: "markdown" } }
5754
+ },
5755
+ get_guide: {
5756
+ category: "read",
5757
+ boundField: "read_access",
5758
+ requiredValue: "unlimited"
5759
+ },
5760
+ create_mandate: {
5761
+ executionMapping: {},
5762
+ staticExecution: { action_type: "mandate" },
5763
+ hideUnlessAuthorized: true,
5764
+ // Shown like the mandate screen: limits and scope by the profile's own names.
5765
+ approvalView: {
5766
+ team: { label: "Team" },
5767
+ profile: { label: "Profile", kind: "profile" },
5768
+ limits: { label: "Limits", kind: "profile-limits", profileArg: "profile" },
5769
+ scope: { label: "Scope", kind: "profile-scope", profileArg: "profile" },
5770
+ intent: { label: "Intent", kind: "markdown" },
5771
+ mode: { label: "Mode" },
5772
+ duration_hours: { label: "Valid for (hours)" },
5773
+ title: { label: "Title" }
5774
+ }
5631
5775
  }
5632
5776
  }
5633
5777
  },
5634
5778
  tools: [
5779
+ {
5780
+ name: "get_guide",
5781
+ description: "Simulation mode only: the setup guides \u2014 how to interview the person, build the test data, propose mandates and the agent brief, set up the working AI's process, and what can make a test misleading. Call without `topic` for the list and the order to follow; then read each topic before doing that step.",
5782
+ inputSchema: {
5783
+ type: "object",
5784
+ properties: {
5785
+ topic: { type: "string", description: 'The guide to read, e.g. "interview". Omit for the list of topics.' }
5786
+ },
5787
+ required: []
5788
+ },
5789
+ handler: async (args) => {
5790
+ if (!isSimulationMode()) return { ...builtinText("Refused: not available outside simulation mode."), isError: true };
5791
+ const guides = loadGuides();
5792
+ const header = guideHeader(connectedSystems(deps.integrationManager));
5793
+ const topic = typeof args.topic === "string" ? args.topic.trim().toLowerCase() : "";
5794
+ if (!topic) {
5795
+ const list = guides.map((g, i) => `${i + 1}. **${g.topic}** \u2014 ${g.summary}`).join("\n");
5796
+ return builtinText(`${header}
5797
+
5798
+ **Setup guides, in order:**
5799
+
5800
+ ${list || "- none installed"}
5801
+
5802
+ Read each topic with get_guide(topic) before doing that step.`);
5803
+ }
5804
+ const guide = guides.find((g) => g.topic === topic);
5805
+ if (!guide) {
5806
+ return { ...builtinText(`Unknown topic "${topic}". Topics: ${guides.map((g) => g.topic).join(", ") || "none installed"}.`), isError: true };
5807
+ }
5808
+ return builtinText(`${header}
5809
+
5810
+ ${guide.body.trim()}`);
5811
+ }
5812
+ },
5635
5813
  {
5636
5814
  name: "set_agent_brief",
5637
5815
  description: "Simulation mode only: propose a new agent brief \u2014 the standing instructions every agent session starts with. `content` replaces the whole brief (markdown, at most 16 KB). A person approves or rejects the proposal; only after approval is the brief replaced, and it applies from the next session. Write the complete brief, not a change to the current one.",
@@ -5656,10 +5834,56 @@ function setupBuiltin(_deps) {
5656
5834
  `Agent brief replaced (${Buffer.byteLength(args.content, "utf-8")} bytes). It applies from the next agent session.`
5657
5835
  );
5658
5836
  }
5837
+ },
5838
+ {
5839
+ name: "create_mandate",
5840
+ description: "Simulation mode only: propose a new mandate for the person running this gateway \u2014 the authority another tool needs (e.g. sales quotes up to a value). A person approves or rejects the proposal; only after approval is the mandate created, exactly as if they had signed it on the mandate screen. Checked against the profile before the proposal: unknown limits, a mode the profile does not allow, or a team where the person may not give this mandate are refused at once. Create only.",
5841
+ inputSchema: {
5842
+ type: "object",
5843
+ properties: {
5844
+ team: { type: "string", description: "Team name or id. Omit for the personal workspace." },
5845
+ profile: { type: "string", description: 'Profile, e.g. "sales" (newest version) or a full profile id.' },
5846
+ limits: { type: "object", description: "The limits (bounds) by field name, as the profile defines them." },
5847
+ scope: { type: "object", description: "The scope (context) by field name, as the profile defines it." },
5848
+ intent: { type: "string", description: "Why, goal and watch-outs \u2014 signed with the mandate (at most 2000 characters)." },
5849
+ mode: { type: "string", enum: ["review", "automatic"], description: "review = each action needs approval; automatic = within the limits." },
5850
+ duration_hours: { type: "number", description: "How long the mandate is valid, in hours. Omit for the profile default." },
5851
+ title: { type: "string", description: "A short name for the mandate." },
5852
+ receipt_id: {
5853
+ type: "string",
5854
+ description: "Authorization reference for this call, set by the governing gateway \u2014 agents do not set this."
5855
+ }
5856
+ },
5857
+ required: ["profile", "limits", "intent", "mode"]
5858
+ },
5859
+ validate: async (args) => {
5860
+ const check = await controlPlaneMandate(true, mandateRequest(args));
5861
+ return check.ok ? void 0 : check.message;
5862
+ },
5863
+ handler: async (args) => {
5864
+ if (!isSimulationMode()) return { ...builtinText("Refused: not available outside simulation mode."), isError: true };
5865
+ const r = await controlPlaneMandate(false, mandateRequest(args));
5866
+ if (!r.ok) return { ...builtinText(`Refused: ${r.message}`), isError: true };
5867
+ return builtinText(
5868
+ `Mandate created: ${r.authorizationId} \u2014 profile ${r.profileId}, ${r.groupName}, mode ${r.mode}, valid for ${Math.round((r.ttlSeconds ?? 0) / 3600)} h. It is active now.`
5869
+ );
5870
+ }
5659
5871
  }
5660
5872
  ]
5661
5873
  };
5662
5874
  }
5875
+ function mandateRequest(args) {
5876
+ return {
5877
+ team: typeof args.team === "string" && args.team.trim() ? args.team.trim() : void 0,
5878
+ profile: args.profile,
5879
+ limits: args.limits,
5880
+ scope: args.scope,
5881
+ intent: args.intent,
5882
+ mode: args.mode,
5883
+ durationHours: args.duration_hours,
5884
+ title: args.title
5885
+ };
5886
+ }
5663
5887
 
5664
5888
  // src/lib/builtins/index.ts
5665
5889
  var BUILTIN_FACTORIES = [setupBuiltin];
@@ -5681,8 +5905,8 @@ function registerBuiltins(deps, factories = BUILTIN_FACTORIES) {
5681
5905
  import { decodeAttestationBlob as decodeAttestationBlob3 } from "@hap/core";
5682
5906
 
5683
5907
  // src/lib/committed-executor.ts
5684
- import { existsSync as existsSync16, mkdirSync as mkdirSync12, readFileSync as readFileSync17, unlinkSync as unlinkSync6, writeFileSync as writeFileSync12 } from "fs";
5685
- import { dirname as dirname11, join as join16 } from "path";
5908
+ import { existsSync as existsSync17, mkdirSync as mkdirSync12, readFileSync as readFileSync18, unlinkSync as unlinkSync6, writeFileSync as writeFileSync12 } from "fs";
5909
+ import { dirname as dirname12, join as join17 } from "path";
5686
5910
  var CommittedExecutor = class {
5687
5911
  constructor(run) {
5688
5912
  this.run = run;
@@ -5710,7 +5934,7 @@ var ExecutorLock = class {
5710
5934
  path;
5711
5935
  held = false;
5712
5936
  constructor(dataDir2) {
5713
- this.path = join16(dataDir2, "executor.lock");
5937
+ this.path = join17(dataDir2, "executor.lock");
5714
5938
  }
5715
5939
  /** Try to take the lock. Returns the holder's pid when someone else has it. */
5716
5940
  acquire() {
@@ -5718,7 +5942,7 @@ var ExecutorLock = class {
5718
5942
  if (current && current.pid !== process.pid && isAlive(current.pid)) {
5719
5943
  return { held: false, holderPid: current.pid };
5720
5944
  }
5721
- mkdirSync12(dirname11(this.path), { recursive: true, mode: 448 });
5945
+ mkdirSync12(dirname12(this.path), { recursive: true, mode: 448 });
5722
5946
  const lock = { pid: process.pid, startedAt: Math.floor(Date.now() / 1e3) };
5723
5947
  writeFileSync12(this.path, JSON.stringify(lock), { mode: 384 });
5724
5948
  this.held = true;
@@ -5742,9 +5966,9 @@ var ExecutorLock = class {
5742
5966
  }
5743
5967
  }
5744
5968
  read() {
5745
- if (!existsSync16(this.path)) return null;
5969
+ if (!existsSync17(this.path)) return null;
5746
5970
  try {
5747
- const parsed = JSON.parse(readFileSync17(this.path, "utf8"));
5971
+ const parsed = JSON.parse(readFileSync18(this.path, "utf8"));
5748
5972
  return typeof parsed?.pid === "number" ? parsed : null;
5749
5973
  } catch {
5750
5974
  return null;
@@ -5762,19 +5986,19 @@ function isAlive(pid) {
5762
5986
 
5763
5987
  // bin/http.ts
5764
5988
  import { homedir as homedir10 } from "os";
5765
- import { join as join18 } from "path";
5989
+ import { join as join19 } from "path";
5766
5990
 
5767
5991
  // src/lib/mcp-as-tracker.ts
5768
- import { existsSync as existsSync17, mkdirSync as mkdirSync13, readFileSync as readFileSync18, writeFileSync as writeFileSync13 } from "fs";
5769
- import { dirname as dirname12, join as join17 } from "path";
5992
+ import { existsSync as existsSync18, mkdirSync as mkdirSync13, readFileSync as readFileSync19, writeFileSync as writeFileSync13 } from "fs";
5993
+ import { dirname as dirname13, join as join18 } from "path";
5770
5994
  function trackerPath(dataDir2) {
5771
- return join17(dataDir2, "mcp-as-pairing.json");
5995
+ return join18(dataDir2, "mcp-as-pairing.json");
5772
5996
  }
5773
5997
  function readMcpPairedAsUrl(dataDir2) {
5774
5998
  const path = trackerPath(dataDir2);
5775
- if (!existsSync17(path)) return null;
5999
+ if (!existsSync18(path)) return null;
5776
6000
  try {
5777
- const data = JSON.parse(readFileSync18(path, "utf-8"));
6001
+ const data = JSON.parse(readFileSync19(path, "utf-8"));
5778
6002
  return typeof data.asUrl === "string" ? data.asUrl : null;
5779
6003
  } catch {
5780
6004
  return null;
@@ -5782,12 +6006,12 @@ function readMcpPairedAsUrl(dataDir2) {
5782
6006
  }
5783
6007
  function writeMcpPairedAsUrl(dataDir2, asUrl) {
5784
6008
  const path = trackerPath(dataDir2);
5785
- mkdirSync13(dirname12(path), { recursive: true, mode: 448 });
6009
+ mkdirSync13(dirname13(path), { recursive: true, mode: 448 });
5786
6010
  writeFileSync13(path, JSON.stringify({ asUrl }, null, 2), { encoding: "utf-8", mode: 384 });
5787
6011
  }
5788
6012
 
5789
6013
  // bin/http.ts
5790
- var dataDir = process.env.SUVEREN_DATA_DIR ?? join18(homedir10(), ".suveren");
6014
+ var dataDir = process.env.SUVEREN_DATA_DIR ?? join19(homedir10(), ".suveren");
5791
6015
  var spUrl = resolveAsUrl(dataDir);
5792
6016
  validatePinTlsForUrl(resolvePinTls(dataDir), spUrl);
5793
6017
  var port = parseInt(process.env.SUVEREN_MCP_PORT ?? "3430", 10);
@@ -6151,6 +6375,9 @@ app.get("/health", (_req, res) => {
6151
6375
  builtins: integrationManager.getBuiltins()
6152
6376
  });
6153
6377
  });
6378
+ app.get("/internal/tool-display", internalOnly, (_req, res) => {
6379
+ res.json({ tools: integrationManager.getToolDisplay() });
6380
+ });
6154
6381
  app.get("/internal/gate-content", internalOnly, (req, res) => {
6155
6382
  const path = req.query.path;
6156
6383
  const gates = state.gateStore.getAll();
@@ -6318,7 +6545,7 @@ app.listen(port, "0.0.0.0", () => {
6318
6545
  (proposal) => executeCommitted(proposal, state, integrationManager)
6319
6546
  );
6320
6547
  installCommittedExecutor(committedExecutor);
6321
- const dataDir2 = process.env.SUVEREN_DATA_DIR ?? join18(homedir10(), ".suveren");
6548
+ const dataDir2 = process.env.SUVEREN_DATA_DIR ?? join19(homedir10(), ".suveren");
6322
6549
  const executorLock = new ExecutorLock(dataDir2);
6323
6550
  const lock = executorLock.acquire();
6324
6551
  if (!lock.held) {