@kontextmind/kxm 0.7.70 → 0.7.72

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.
@@ -7387,6 +7387,21 @@ var DEFAULT_RATE_LIMIT_MAX = 600;
7387
7387
  var DEFAULT_RATE_LIMIT_WINDOW_MS = 6e4;
7388
7388
  var MAX_BODY_BYTES = 256 * 1024;
7389
7389
  var MAX_CONTENT_CHARS = 32e3;
7390
+ var MAX_AGENT_HOST_CHARS = 64;
7391
+ var MIN_LEASE_TTL_MS = 5e3;
7392
+ var MAX_LEASE_TTL_MS = 10 * 6e4;
7393
+ var DEFAULT_LEASE_TTL_MS = 5 * 6e4;
7394
+ var MAX_LEASE_RESOURCE_CHARS = 200;
7395
+ function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
7396
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
7397
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
7398
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
7399
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
7400
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
7401
+ }
7402
+ function toAgentRecord(agent, staleAfterMs, now) {
7403
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
7404
+ }
7390
7405
  var ProtocolError = class extends Error {
7391
7406
  statusCode;
7392
7407
  code;
@@ -8575,14 +8590,19 @@ var AGENT_COMMANDS = [
8575
8590
  group: "peer",
8576
8591
  verb: "list",
8577
8592
  label: "List hub peers",
8578
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
8593
+ description: "List peer agents in this project's hub pool with their names, purposes, host label, and hub-clocked presence (online, stale, offline). Registered offline peers are listed only when includeOffline is set.",
8579
8594
  parameters: {
8580
8595
  type: "object",
8581
- properties: {},
8596
+ properties: {
8597
+ includeOffline: {
8598
+ type: "boolean",
8599
+ description: "Also list registered peers whose hub lease has expired"
8600
+ }
8601
+ },
8582
8602
  additionalProperties: false
8583
8603
  },
8584
- async execute(client) {
8585
- return { agents: await client.listAgents() };
8604
+ async execute(client, args) {
8605
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
8586
8606
  }
8587
8607
  },
8588
8608
  {
@@ -11661,7 +11681,7 @@ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = mon
11661
11681
  }
11662
11682
 
11663
11683
  // plugins/kxm/src/store.ts
11664
- var HUB_STORE_SCHEMA_VERSION = 3;
11684
+ var HUB_STORE_SCHEMA_VERSION = 4;
11665
11685
  var HUB_STORE_TABLES = Object.freeze({
11666
11686
  agents: Object.freeze(["id", "record"]),
11667
11687
  messages: Object.freeze(["id", "record"]),
@@ -11669,9 +11689,10 @@ var HUB_STORE_TABLES = Object.freeze({
11669
11689
  agent_sequences: Object.freeze(["agent_id", "next_seq"]),
11670
11690
  workflow_runs: Object.freeze(["id", "definition_id", "delivery_id", "record"]),
11671
11691
  workflow_journal: Object.freeze(["id", "run_id", "category", "area", "record"]),
11672
- context_items: Object.freeze(["id", "project", "kind", "record"])
11692
+ context_items: Object.freeze(["id", "project", "kind", "record"]),
11693
+ leases: Object.freeze(["resource", "holder_agent_id", "fencing_token", "expires_at", "record"])
11673
11694
  });
11674
- var HUB_STORE_SCHEMA_V3 = `
11695
+ var HUB_STORE_SCHEMA_V4 = `
11675
11696
  CREATE TABLE IF NOT EXISTS agents (
11676
11697
  id TEXT PRIMARY KEY,
11677
11698
  record TEXT NOT NULL
@@ -11720,12 +11741,30 @@ var HUB_STORE_SCHEMA_V3 = `
11720
11741
  record TEXT NOT NULL
11721
11742
  ) STRICT;
11722
11743
  CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
11744
+ CREATE TABLE IF NOT EXISTS leases (
11745
+ resource TEXT PRIMARY KEY,
11746
+ holder_agent_id TEXT NOT NULL,
11747
+ fencing_token INTEGER NOT NULL,
11748
+ expires_at TEXT NOT NULL,
11749
+ record TEXT NOT NULL
11750
+ ) STRICT;
11723
11751
  `;
11724
11752
  var HUB_STORE_SCHEMA_SPEC = Object.freeze({
11725
- schema: HUB_STORE_SCHEMA_V3,
11753
+ schema: HUB_STORE_SCHEMA_V4,
11726
11754
  version: HUB_STORE_SCHEMA_VERSION,
11727
11755
  tables: HUB_STORE_TABLES
11728
11756
  });
11757
+ function parseLeaseRecord(record) {
11758
+ try {
11759
+ return JSON.parse(record);
11760
+ } catch {
11761
+ return void 0;
11762
+ }
11763
+ }
11764
+ function leaseHasLapsed(lease, nowMs) {
11765
+ const expiresAtMs = Date.parse(lease.expiresAt);
11766
+ return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
11767
+ }
11729
11768
  var MessageMap = class extends Map {
11730
11769
  store;
11731
11770
  constructor(store) {
@@ -11758,6 +11797,7 @@ var MeshStore = class {
11758
11797
  workflowRuns = /* @__PURE__ */ new Map();
11759
11798
  journal = /* @__PURE__ */ new Map();
11760
11799
  contextItems = /* @__PURE__ */ new Map();
11800
+ leases = /* @__PURE__ */ new Map();
11761
11801
  path;
11762
11802
  database;
11763
11803
  agentSequences = /* @__PURE__ */ new Map();
@@ -11950,6 +11990,117 @@ var MeshStore = class {
11950
11990
  }
11951
11991
  return result;
11952
11992
  }
11993
+ /** Read the lease over one already project-scoped resource. */
11994
+ getLease(resource) {
11995
+ if (!this.database) return this.leases.get(resource);
11996
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource);
11997
+ if (!row) {
11998
+ this.leases.delete(resource);
11999
+ return void 0;
12000
+ }
12001
+ const lease = parseLeaseRecord(row.record);
12002
+ if (lease) this.leases.set(resource, lease);
12003
+ return lease;
12004
+ }
12005
+ /**
12006
+ * Take or extend the lease over `resource`, deciding the whole outcome inside
12007
+ * one transaction so two writers cannot both read "free" and both insert.
12008
+ *
12009
+ * The token is the fence: a first acquisition starts at 1, the holder's own
12010
+ * re-acquisition keeps its token, and only a takeover of an expired lease
12011
+ * increments it. That is what lets a holder that wakes up after its deadline
12012
+ * be refused at commit rather than silently writing behind the new holder.
12013
+ */
12014
+ acquireLease(input) {
12015
+ return this.inLeaseTransaction(() => {
12016
+ const current = this.readLeaseForUpdate(input.resource);
12017
+ const expired = current !== void 0 && leaseHasLapsed(current, input.nowMs);
12018
+ if (current && !expired && current.holderAgentId !== input.holderAgentId) {
12019
+ return { ok: false, reason: "held", lease: current };
12020
+ }
12021
+ const fencingToken = current === void 0 ? 1 : expired ? current.fencingToken + 1 : current.fencingToken;
12022
+ const renewed = current !== void 0 && !expired;
12023
+ const lease = {
12024
+ resource: input.resource,
12025
+ project: input.project,
12026
+ name: input.name,
12027
+ holderAgentId: input.holderAgentId,
12028
+ holderAgentName: input.holderAgentName,
12029
+ fencingToken,
12030
+ acquiredAt: renewed && current ? current.acquiredAt : new Date(input.nowMs).toISOString(),
12031
+ expiresAt: new Date(input.nowMs + input.ttlMs).toISOString()
12032
+ };
12033
+ this.writeLease(lease);
12034
+ return { ok: true, lease, renewed };
12035
+ });
12036
+ }
12037
+ /** Extend a lease the caller still holds under the token it was given. The
12038
+ * token never changes on renewal — a renewal that would need a new token is
12039
+ * a takeover, and takeovers go through `acquireLease`. */
12040
+ renewLease(input) {
12041
+ return this.inLeaseTransaction(() => {
12042
+ const current = this.readLeaseForUpdate(input.resource);
12043
+ if (!current) return { ok: false, reason: "missing" };
12044
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
12045
+ return { ok: false, reason: "superseded", lease: current };
12046
+ }
12047
+ if (leaseHasLapsed(current, input.nowMs)) {
12048
+ return { ok: false, reason: "expired", lease: current };
12049
+ }
12050
+ const lease = { ...current, expiresAt: new Date(input.nowMs + input.ttlMs).toISOString() };
12051
+ this.writeLease(lease);
12052
+ return { ok: true, lease, renewed: true };
12053
+ });
12054
+ }
12055
+ /** Drop a lease the caller holds. A clean release ends the fence: there is no
12056
+ * stale writer left to keep a token for, so the next acquisition starts over. */
12057
+ releaseLease(input) {
12058
+ return this.inLeaseTransaction(() => {
12059
+ const current = this.readLeaseForUpdate(input.resource);
12060
+ if (!current) return { ok: false, reason: "missing" };
12061
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
12062
+ return { ok: false, reason: "superseded", lease: current };
12063
+ }
12064
+ this.deleteLease(input.resource);
12065
+ return { ok: true, lease: current, renewed: false };
12066
+ });
12067
+ }
12068
+ /** Every lease of one project, newest deadline last. Reader surface only. */
12069
+ listLeases(project) {
12070
+ if (this.database) {
12071
+ const rows = this.database.prepare("SELECT record FROM leases").all();
12072
+ this.leases.clear();
12073
+ for (const row of rows) {
12074
+ const lease = parseLeaseRecord(row.record);
12075
+ if (lease) this.leases.set(lease.resource, lease);
12076
+ }
12077
+ }
12078
+ return [...this.leases.values()].filter((lease) => lease.project === project).sort((left, right) => left.resource.localeCompare(right.resource));
12079
+ }
12080
+ inLeaseTransaction(work) {
12081
+ if (!this.database) return work();
12082
+ return withDatabaseTransaction(this.database, work);
12083
+ }
12084
+ readLeaseForUpdate(resource) {
12085
+ if (!this.database) return this.leases.get(resource);
12086
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource);
12087
+ return row ? parseLeaseRecord(row.record) : void 0;
12088
+ }
12089
+ writeLease(lease) {
12090
+ this.leases.set(lease.resource, lease);
12091
+ this.database?.prepare(`
12092
+ INSERT INTO leases (resource, holder_agent_id, fencing_token, expires_at, record) VALUES (?, ?, ?, ?, ?)
12093
+ ON CONFLICT(resource) DO UPDATE SET
12094
+ holder_agent_id = excluded.holder_agent_id,
12095
+ fencing_token = excluded.fencing_token,
12096
+ expires_at = excluded.expires_at,
12097
+ record = excluded.record
12098
+ `).run(lease.resource, lease.holderAgentId, lease.fencingToken, lease.expiresAt, JSON.stringify(lease));
12099
+ }
12100
+ deleteLease(resource) {
12101
+ this.leases.delete(resource);
12102
+ this.database?.prepare("DELETE FROM leases WHERE resource = ?").run(resource);
12103
+ }
11953
12104
  deleteWorkflowRun(runId) {
11954
12105
  this.workflowRuns.delete(runId);
11955
12106
  this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
@@ -11970,6 +12121,7 @@ var MeshStore = class {
11970
12121
  const purgedRuns = [];
11971
12122
  const purgedJournal = [];
11972
12123
  const purgedContextItems = [];
12124
+ const purgedLeases = [];
11973
12125
  if (!this.database) {
11974
12126
  for (const m of [...Map.prototype.values.call(this.messages)]) {
11975
12127
  const terminal = m.status === "replied" || m.status === "cancelled" || m.status === "expired" || m.status === "error";
@@ -12040,7 +12192,24 @@ var MeshStore = class {
12040
12192
  this.deleteContextItem(id);
12041
12193
  purgedContextItems.push(id);
12042
12194
  }
12043
- return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems };
12195
+ const leaseCutoffMs = nowMs - runRetentionMs;
12196
+ for (const lease of this.listAllLeases()) {
12197
+ if (leaseHasLapsed(lease, leaseCutoffMs)) {
12198
+ this.deleteLease(lease.resource);
12199
+ purgedLeases.push(lease);
12200
+ }
12201
+ }
12202
+ return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems, purgedLeases };
12203
+ }
12204
+ listAllLeases() {
12205
+ if (!this.database) return [...this.leases.values()];
12206
+ const rows = this.database.prepare("SELECT record FROM leases").all();
12207
+ const result = [];
12208
+ for (const row of rows) {
12209
+ const lease = parseLeaseRecord(row.record);
12210
+ if (lease) result.push(lease);
12211
+ }
12212
+ return result;
12044
12213
  }
12045
12214
  saveWorkflowRun(run) {
12046
12215
  this.workflowRuns.set(run.id, run);
@@ -12111,6 +12280,7 @@ var MeshStore = class {
12111
12280
  const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all();
12112
12281
  const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all();
12113
12282
  const contextRows = this.database.prepare("SELECT record FROM context_items").all();
12283
+ const leaseRows = this.database.prepare("SELECT record FROM leases").all();
12114
12284
  for (const row of agentRows) {
12115
12285
  const agent = JSON.parse(row.record);
12116
12286
  this.agents.set(agent.id, agent);
@@ -12127,6 +12297,10 @@ var MeshStore = class {
12127
12297
  const item = JSON.parse(row.record);
12128
12298
  this.contextItems.set(item.id, item);
12129
12299
  }
12300
+ for (const row of leaseRows) {
12301
+ const lease = parseLeaseRecord(row.record);
12302
+ if (lease) this.leases.set(lease.resource, lease);
12303
+ }
12130
12304
  }
12131
12305
  };
12132
12306
 
@@ -12138,13 +12312,40 @@ function isLoopback(host2) {
12138
12312
  if (host2 === "localhost" || host2 === "::1") return true;
12139
12313
  return isIP(host2) === 4 && host2.startsWith("127.");
12140
12314
  }
12141
- function publicAgent(agent) {
12142
- const { key: _key, ...record } = agent;
12143
- return record;
12315
+ function publicAgent(agent, staleAfterMs, now = Date.now()) {
12316
+ const { key: _key, ...identity } = agent;
12317
+ return toAgentRecord(identity, staleAfterMs, now);
12144
12318
  }
12145
12319
  function safeTokenEqual(actual, expected) {
12146
12320
  return timingSafeStringCompare(actual, expected);
12147
12321
  }
12322
+ function leaseRefusal(reason, name, lease) {
12323
+ if (reason === "missing") {
12324
+ return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
12325
+ }
12326
+ if (reason === "expired") {
12327
+ return new ProtocolError(
12328
+ 409,
12329
+ `lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
12330
+ "lease_expired",
12331
+ { lease }
12332
+ );
12333
+ }
12334
+ if (reason === "held") {
12335
+ return new ProtocolError(
12336
+ 409,
12337
+ `lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
12338
+ "lease_held",
12339
+ { lease }
12340
+ );
12341
+ }
12342
+ return new ProtocolError(
12343
+ 409,
12344
+ `fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
12345
+ "lease_superseded",
12346
+ { lease }
12347
+ );
12348
+ }
12148
12349
  function bearerToken(request) {
12149
12350
  const header = request.headers.authorization;
12150
12351
  return header?.startsWith("Bearer ") ? header.slice(7) : void 0;
@@ -12367,6 +12568,7 @@ function createMeshHub(options = {}) {
12367
12568
  };
12368
12569
  const webhookWorkflows2 = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
12369
12570
  const logger = options.logger ?? (() => void 0);
12571
+ const hubNow = options.now ?? (() => Date.now());
12370
12572
  const assetsDir2 = options.assetsDir;
12371
12573
  const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve6(dirname5(dirname5(options.dataPath))) : process.cwd());
12372
12574
  const store = new MeshStore(options.dataPath);
@@ -12415,7 +12617,10 @@ function createMeshHub(options = {}) {
12415
12617
  journalEntries: 0,
12416
12618
  contextRequests: 0,
12417
12619
  attemptLatencySecondsTotal: 0,
12418
- meteredCostUsdTotal: 0
12620
+ meteredCostUsdTotal: 0,
12621
+ leasesGranted: 0,
12622
+ leasesRefused: 0,
12623
+ leasesReleased: 0
12419
12624
  };
12420
12625
  let cleanupTimer;
12421
12626
  let closed = false;
@@ -12560,7 +12765,8 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12560
12765
  }
12561
12766
  }
12562
12767
  function opsSnapshot(project) {
12563
- const projectAgents = [...agents.values()].filter((agent) => agent.project === project).map(publicAgent).sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
12768
+ const snapshotAt = Date.now();
12769
+ const projectAgents = [...agents.values()].filter((agent) => agent.project === project).map((agent) => publicAgent(agent, staleAfterMs, snapshotAt)).sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
12564
12770
  const open = store.getOpenMessages(project);
12565
12771
  const projectRuns = [...workflowRuns.values()].filter((run) => run.project === project).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
12566
12772
  return {
@@ -12610,7 +12816,7 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12610
12816
  function broadcastPresence(agent) {
12611
12817
  for (const candidate of agents.values()) {
12612
12818
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
12613
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
12819
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
12614
12820
  }
12615
12821
  }
12616
12822
  publishOps(agent.project, "agents");
@@ -12948,6 +13154,16 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12948
13154
  for (const runId of swept.purgedRuns) {
12949
13155
  logger({ event: "workflow_run_purged", runId });
12950
13156
  }
13157
+ for (const lease of swept.purgedLeases) {
13158
+ logger({
13159
+ event: "lease_purged",
13160
+ resource: lease.resource,
13161
+ project: lease.project,
13162
+ agentId: lease.holderAgentId,
13163
+ fencingToken: lease.fencingToken,
13164
+ expiresAt: lease.expiresAt
13165
+ });
13166
+ }
12951
13167
  }
12952
13168
  function metricsBody() {
12953
13169
  const onlineAgents = [...agents.values()].filter((agent) => agent.online).length;
@@ -12994,6 +13210,12 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12994
13210
  `kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
12995
13211
  "# TYPE kxm_metered_cost_usd_total counter",
12996
13212
  `kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
13213
+ "# TYPE kxm_leases_granted_total counter",
13214
+ `kxm_leases_granted_total ${counters.leasesGranted}`,
13215
+ "# TYPE kxm_leases_refused_total counter",
13216
+ `kxm_leases_refused_total ${counters.leasesRefused}`,
13217
+ "# TYPE kxm_leases_released_total counter",
13218
+ `kxm_leases_released_total ${counters.leasesReleased}`,
12997
13219
  ""
12998
13220
  ].join("\n");
12999
13221
  }
@@ -13912,6 +14134,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13912
14134
  const project = requireString(body.project, "project", { max: 128 });
13913
14135
  requireProjectAuth(request, project);
13914
14136
  const model = optionalString(body.model, "model", 128);
14137
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
13915
14138
  const existing = [...agents.values()].find(
13916
14139
  (agent2) => agent2.project === project && agent2.name.toLowerCase() === name.toLowerCase()
13917
14140
  );
@@ -13938,17 +14161,27 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13938
14161
  agent.online = true;
13939
14162
  if (model) agent.model = model;
13940
14163
  else delete agent.model;
14164
+ if (hostLabel) agent.host = hostLabel;
14165
+ else delete agent.host;
13941
14166
  store.saveAgent(agent);
13942
14167
  counters.registrations += 1;
13943
- logger({ event: existing ? "agent_resumed" : "agent_registered", agentId: agent.id, name, project });
14168
+ logger({
14169
+ event: existing ? "agent_resumed" : "agent_registered",
14170
+ agentId: agent.id,
14171
+ name,
14172
+ project,
14173
+ ...hostLabel ? { host: hostLabel } : {}
14174
+ });
13944
14175
  broadcastPresence(agent);
13945
- json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
14176
+ json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
13946
14177
  return;
13947
14178
  }
13948
14179
  if (method === "GET" && url.pathname === "/v1/agents") {
13949
14180
  const current = requireAgent(request);
13950
14181
  requireProjectAuth(request, current.project);
13951
- const result = [...agents.values()].filter((agent) => agent.project === current.project && agent.online).map(publicAgent);
14182
+ const includeOffline = url.searchParams.get("includeOffline") === "true";
14183
+ const listedAt = Date.now();
14184
+ const result = [...agents.values()].filter((agent) => agent.project === current.project && (agent.online || includeOffline)).map((agent) => publicAgent(agent, staleAfterMs, listedAt));
13952
14185
  json(response, 200, { agents: result });
13953
14186
  return;
13954
14187
  }
@@ -13957,7 +14190,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13957
14190
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]));
13958
14191
  requireProjectAuth(request, current.project);
13959
14192
  await readJson(request);
13960
- json(response, 200, { agent: publicAgent(current) });
14193
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
13961
14194
  return;
13962
14195
  }
13963
14196
  const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
@@ -13972,6 +14205,70 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13972
14205
  response.writeHead(204, { "cache-control": "no-store" }).end();
13973
14206
  return;
13974
14207
  }
14208
+ const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
14209
+ if (method === "POST" && leaseMatch) {
14210
+ const holder = requireAgent(request);
14211
+ requireProjectAuth(request, holder.project);
14212
+ const name = requireString(decodeURIComponent(leaseMatch[1]), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
14213
+ const action = leaseMatch[2];
14214
+ const body = await readJson(request);
14215
+ const resource = `${holder.project}/${name}`;
14216
+ const nowMs = hubNow();
14217
+ const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
14218
+ if (action === "acquire") {
14219
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
14220
+ const outcome2 = store.acquireLease({
14221
+ resource,
14222
+ project: holder.project,
14223
+ name,
14224
+ holderAgentId: holder.id,
14225
+ holderAgentName: holder.name,
14226
+ ttlMs,
14227
+ nowMs
14228
+ });
14229
+ if (!outcome2.ok) {
14230
+ counters.leasesRefused += 1;
14231
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome2.reason, heldBy: outcome2.lease?.holderAgentId });
14232
+ throw leaseRefusal(outcome2.reason, name, outcome2.lease);
14233
+ }
14234
+ counters.leasesGranted += 1;
14235
+ logger({
14236
+ event: outcome2.renewed ? "lease_renewed" : "lease_acquired",
14237
+ ...leaseLog,
14238
+ fencingToken: outcome2.lease.fencingToken,
14239
+ expiresAt: outcome2.lease.expiresAt
14240
+ });
14241
+ json(response, 200, { lease: outcome2.lease, renewed: outcome2.renewed });
14242
+ return;
14243
+ }
14244
+ if (body.fencingToken === void 0) {
14245
+ throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
14246
+ }
14247
+ const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
14248
+ if (action === "renew") {
14249
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
14250
+ const outcome2 = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
14251
+ if (!outcome2.ok) {
14252
+ counters.leasesRefused += 1;
14253
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome2.reason, fencingToken });
14254
+ throw leaseRefusal(outcome2.reason, name, outcome2.lease);
14255
+ }
14256
+ counters.leasesGranted += 1;
14257
+ logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome2.lease.expiresAt });
14258
+ json(response, 200, { lease: outcome2.lease, renewed: true });
14259
+ return;
14260
+ }
14261
+ const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
14262
+ if (!outcome.ok) {
14263
+ counters.leasesRefused += 1;
14264
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
14265
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
14266
+ }
14267
+ counters.leasesReleased += 1;
14268
+ logger({ event: "lease_released", ...leaseLog, fencingToken });
14269
+ json(response, 200, { released: true, lease: outcome.lease });
14270
+ return;
14271
+ }
13975
14272
  if (method === "GET" && url.pathname === "/v1/events") {
13976
14273
  const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
13977
14274
  const current = requireAgent(request, agentId);
@@ -13989,7 +14286,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13989
14286
  ...presenceOnly ? { "x-kxm-events-mode": "presence" } : {}
13990
14287
  });
13991
14288
  response.write(`event: ready
13992
- data: ${JSON.stringify({ agent: publicAgent(current) })}
14289
+ data: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}
13993
14290
 
13994
14291
  `);
13995
14292
  const client = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.70",
3
+ "version": "0.7.72",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -87,6 +87,24 @@ stored `queued` and delivered once through the reconnect cursor; unknown names
87
87
  still return `target_not_found`. Queued messages are not evidence unless
88
88
  `workflowContext` was hub-authorized at send.
89
89
 
90
+ ## Fenced leases
91
+
92
+ - `POST /v1/leases/:resource/acquire` takes or extends the lease over a
93
+ resource. Body `{ttlMs}` is bounded to 5 s–10 min. A live lease held by
94
+ another agent returns `409 lease_held` with the current holder and token.
95
+ - `POST /v1/leases/:resource/renew` extends a lease under the token it was
96
+ issued. Body `{fencingToken, ttlMs?}`.
97
+ - `POST /v1/leases/:resource/release` gives the resource back. Body
98
+ `{fencingToken}`.
99
+
100
+ All three are agent-authenticated and project-scoped: the hub prefixes the
101
+ caller's project onto `:resource`, so the same name in two projects is two
102
+ leases. Every decision is a compare-and-set on the hub clock inside one store
103
+ transaction. The fencing token starts at 1, is unchanged by renewal, and
104
+ increments only when a new holder takes over an expired lease; a stale token
105
+ returns `409 lease_superseded` with the token the hub now holds. Present the
106
+ token before committing anything shared — a refusal means stop, not retry.
107
+
90
108
  ## Request states
91
109
 
92
110
  - `queued`: stored by the hub but not acknowledged by the recipient.
@@ -15,7 +15,9 @@ All commands support `--json` for machine-readable output.
15
15
 
16
16
  | Command | Purpose | Key Options |
17
17
  |---|---|---|
18
- | `kxm peer list` | List online peer agents and their purposes | `--json` |
18
+ | `kxm peer list` | List peer agents with purpose, host label, and presence | `--json`, `--include-offline` |
19
+
20
+ Every listed peer carries `host` (the box it declared at registration), `lastSeenAt`, `leaseExpiresAt`, and `presence`. Presence is the hub's own reading of its heartbeat lease: `online` holds the lease, `stale` has passed `leaseExpiresAt` but has not been swept yet, and `offline` is a registered peer the hub has retired. Offline peers are listed only with `--include-offline`. The host label is a reading aid, never a permission.
19
21
 
20
22
  ### Sending Requests (`kxm peer send`)
21
23
 
@@ -533,7 +533,8 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
533
533
  const peer = addGlobalOptions(program.command("peer").description("Peer agent messaging and coordination"));
534
534
  peer.helpCommand("help", "Show peer help");
535
535
 
536
- addGlobalOptions(peer.command("list").description("List online peer agents in this project's hub pool"))
536
+ addGlobalOptions(peer.command("list").description("List peer agents in this project's hub pool with host and presence"))
537
+ .option("--include-offline", "Also list registered peers whose hub lease has expired")
537
538
  .option("--payload <json>", "JSON payload")
538
539
  .action(async function peerListAction(this: Command, opts?: Record<string, unknown>) {
539
540
  result.code = await dispatchAgentCliCommand(runtimeFrom(ctx, this), "kxm_list", opts ?? {});