@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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.70",
14
+ "version": "0.7.72",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ All notable user-facing changes are documented here. The project follows [Semant
6
6
 
7
7
  ### Added
8
8
 
9
+ - **Fenced hub leases, and shared external effects that will not run without one.**
10
+ `POST /v1/leases/:resource/acquire|renew|release` are agent-authenticated and
11
+ project-scoped (the hub prefixes the caller's project onto the resource name). Each call
12
+ is a compare-and-set inside one store transaction on the **hub clock**, with TTLs bounded
13
+ to 5 s–10 min. The fencing token starts at 1, survives renewal unchanged, and increments
14
+ only when a new holder takes over an expired lease, so a holder that returns after its
15
+ deadline is told its token was superseded instead of writing behind its replacement.
16
+ Shared external effects (`git-push` to a ref the run does not own, `pr-create`,
17
+ `tracker-issue`, `webhook`) acquire a lease keyed by `targetRef` before executing, record
18
+ `{leaseResource, fencingToken}` in the receipt, renew on the existing Q6 heartbeat, and
19
+ re-present the token at commit; a superseded token leaves the effect `in-flight` and the
20
+ attempt `blocked_uncertain` with nothing retrying. An unreachable hub refuses the effect
21
+ (`effect_lease_unavailable`) rather than executing it unfenced. Unique-namespace kinds
22
+ (`git-branch`, `git-commit`, and a push to the run's own branch) stay lease-free.
23
+
9
24
  - **`kxm peer send --allow-offline` queues to a registered offline peer.**
10
25
  `POST /v1/messages` accepts `allowOffline: true` (also `kxm_send.allowOffline`): a
11
26
  registered agent in the same project is stored `queued` instead of `target_not_found`,
@@ -42,6 +57,13 @@ All notable user-facing changes are documented here. The project follows [Semant
42
57
 
43
58
  ### Changed
44
59
 
60
+ - **Hub store schema v3 → v4, external-effects ledger v1 → v2.** The hub store gains a
61
+ `leases` table and the ledger gains `lease_resource`/`fencing_token` columns. Neither has
62
+ a migration lane: an older file is refused at open with `runtime_schema_outdated`, and the
63
+ hub backup/restore ceiling in `database.ts` moves to 4 with the bump, so a v3 backup must
64
+ be restored with the release that produced it. Delete the state file to start fresh and
65
+ let `kxm hub start` recreate it.
66
+
45
67
  - **`kxm tenant status`: one composed read for the portal, with the authorities labelled.**
46
68
  The portal needs hub metadata (roster, message queue, the hub's run projection) *and* the
47
69
  Runtime's authoritative run state, and the failure mode S2 exists to prevent is rendering
@@ -315,7 +315,10 @@ restore lands somewhere the running service will not look.
315
315
  copies, not the file copy**: configuration, repository bindings, prompt sidecars,
316
316
  routing manifests and update configuration are not databases, so a snapshot-only backup
317
317
  reproduces exactly the failure this section exists to remove. The hub's own backup path already writes a hashed manifest and records
318
- a schema version ceiling; keep that manifest with the files.
318
+ a schema version ceiling; keep that manifest with the files. That ceiling is
319
+ **hub store v4** as of the fenced-lease release: a backup taken by an earlier
320
+ build records v3 and is refused by this one, because there is no migration lane.
321
+ Restore such a backup with the release that produced it, or start fresh.
319
322
  3. Record the package version, configuration revision and schema versions beside the copy.
320
323
  A restore that cannot state which release produced it is not a restore path.
321
324
  4. Keep at least one rotation, and bound retention explicitly — run events and prompt
@@ -392,10 +395,10 @@ Broader deployments need shared state and coordination, external identity and fi
392
395
 
393
396
  ## v0.5 context/state storage
394
397
 
395
- The hub database (schema version 3) carries `context_items` alongside
396
- agents, messages, workflow runs, and the journal. Temporal state, knowledge
397
- records, and their audit trails live in the same SQLite file and upgrade in
398
- place from v0.4 databases.
398
+ The hub database (schema version 4) carries `context_items` and `leases`
399
+ alongside agents, messages, workflow runs, and the journal. Temporal state,
400
+ knowledge records, and their audit trails live in the same SQLite file and
401
+ upgrade in place from v0.4 databases.
399
402
 
400
403
  - **Backup and restore**: include the hub database file and, if used, the
401
404
  `.kxm/skills/` and `.kxm/knowledge/` trees. The wiki is a compiled view and
@@ -412,6 +415,35 @@ place from v0.4 databases.
412
415
  to open on an older runtime). Restore a database backup taken before the
413
416
  upgrade instead.
414
417
 
418
+ ### Fenced leases over shared resources
419
+
420
+ `leases` holds one row per project-scoped resource: the holder, a monotonic
421
+ fencing token, and a deadline. `POST /v1/leases/:resource/acquire|renew|release`
422
+ are agent-authenticated and scoped to the caller's project, which the hub
423
+ prefixes onto the resource name — two projects naming the same branch never
424
+ contend. TTLs are bounded to 5 s–10 min and every decision is made on the **hub
425
+ clock** inside one store transaction, so a skewed client cannot extend its own
426
+ grip.
427
+
428
+ The token is the safety property. It starts at 1, stays put across renewals, and
429
+ increments only when a new holder takes over an expired lease. A holder that
430
+ comes back after its deadline is therefore told its token was superseded rather
431
+ than allowed to write behind whoever replaced it. Shared external effects
432
+ (`git-push` to a ref the run does not own, `pr-create`, `tracker-issue`,
433
+ `webhook`) take a lease before executing and re-present the token at commit; a
434
+ superseded token leaves the effect `in-flight` and the attempt
435
+ `blocked_uncertain` for an operator to resolve, and nothing retries it. An
436
+ unreachable hub refuses the effect (`effect_lease_unavailable`) rather than
437
+ running it unfenced.
438
+
439
+ Expired rows are **not** reaped immediately — their token is what the next
440
+ takeover has to increment past. The retention sweep drops rows whose deadline is
441
+ older than the run-retention window (7 days by default), far beyond any live
442
+ holder. `kxm_leases_granted_total`, `kxm_leases_refused_total` and
443
+ `kxm_leases_released_total` in `/metrics` report contention;
444
+ `lease_acquired`, `lease_renewed`, `lease_released`, `lease_denied` and
445
+ `lease_purged` are the structured log events.
446
+
415
447
  ## Hub Q&A / knowledge base
416
448
 
417
449
  - [What is all stored on the hub?](kb/qa-what-the-hub-stores.md)
@@ -23,6 +23,7 @@ npm run verify
23
23
  | Queue, acknowledgement, visibility, reply, and authorization | `test/core/hub-api.test.ts`, `test/core/hub.test.ts` |
24
24
  | Queued/delivered replay after recipient restart reuses one message record | `test/core/hub.test.ts`, `test/core/extension.test.ts`, `test/core/mcp.test.ts` |
25
25
  | Known-offline peer send with `allowOffline` queues, delivers once on resumption, and expires unread by TTL | `test/core/hub-api.test.ts` |
26
+ | Fenced hub leases: CAS acquire/renew/release, monotonic token on takeover only, hub-clocked expiry, and a shared external effect refused at commit under a superseded token | `test/core/hub-api.test.ts`, `test/core/store.test.ts`, `test/core/external-effects.test.ts` |
26
27
  | One-to-three-peer fanout, recoverable local timeouts/aborts, exact retries, and partial-error collection | `test/core/client.test.ts`, `test/core/hub-api.test.ts`, `test/core/extension.test.ts`, `test/core/mcp.test.ts` |
27
28
  | TTL expiry, sender cancellation, and terminal retention | `test/core/hub-api.test.ts` |
28
29
  | Terminal inbound cleanup and next-request activation | `test/core/extension.test.ts`, `test/core/mcp.test.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.70",
3
+ "version": "0.7.72",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.70",
5
+ "version": "0.7.72",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -18350,9 +18350,7 @@ function findKxmRepoRoot(fromUrl = import.meta.url) {
18350
18350
 
18351
18351
  // plugins/kxm/src/client.ts
18352
18352
  import { createHash as createHash3 } from "node:crypto";
18353
-
18354
- // plugins/kxm/src/workflow.ts
18355
- import { createHash as createHash2 } from "node:crypto";
18353
+ import { hostname } from "node:os";
18356
18354
 
18357
18355
  // plugins/kxm/src/protocol.ts
18358
18356
  import { randomUUID } from "node:crypto";
@@ -18361,6 +18359,9 @@ var MIN_MESSAGE_TTL_MS = 1e3;
18361
18359
  var MAX_MESSAGE_TTL_MS = 7 * 24 * 60 * 6e4;
18362
18360
  var DEFAULT_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 6e4;
18363
18361
  var MAX_BODY_BYTES = 256 * 1024;
18362
+ var MAX_AGENT_HOST_CHARS = 64;
18363
+ var MAX_LEASE_TTL_MS = 10 * 6e4;
18364
+ var DEFAULT_LEASE_TTL_MS = 5 * 6e4;
18364
18365
  var ProtocolError = class extends Error {
18365
18366
  statusCode;
18366
18367
  code;
@@ -18453,6 +18454,7 @@ function validateTerminalReceipt(value) {
18453
18454
  }
18454
18455
 
18455
18456
  // plugins/kxm/src/workflow.ts
18457
+ import { createHash as createHash2 } from "node:crypto";
18456
18458
  var PROMOTABLE_JOURNAL_CATEGORIES = ["skill-candidate", "hypothesis", "experiment"];
18457
18459
  var WORKFLOW_TERMINAL_TARGET = "$terminal";
18458
18460
  function normalizeOutcomeValue(value, field) {
@@ -19200,6 +19202,13 @@ var MeshWaitError = class extends Error {
19200
19202
  this.waitStatus = waitStatus;
19201
19203
  }
19202
19204
  };
19205
+ function defaultHostLabel() {
19206
+ try {
19207
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
19208
+ } catch {
19209
+ return void 0;
19210
+ }
19211
+ }
19203
19212
  function completedFanoutResult(target, message) {
19204
19213
  if (message.status === "queued" || message.status === "delivered") {
19205
19214
  throw new Error(`message ${message.id} is not complete`);
@@ -19289,8 +19298,11 @@ var HubClient = class {
19289
19298
  this.onEvent = void 0;
19290
19299
  this.eventLoop = void 0;
19291
19300
  }
19292
- async listAgents() {
19293
- const result = await this.request("/v1/agents");
19301
+ /** Online peers of this client's project. `includeOffline` also returns
19302
+ * registered members whose lease the hub has already retired. */
19303
+ async listAgents(options = {}) {
19304
+ const path4 = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
19305
+ const result = await this.request(path4);
19294
19306
  return result.agents;
19295
19307
  }
19296
19308
  async send(options) {
@@ -19386,6 +19398,37 @@ var HubClient = class {
19386
19398
  );
19387
19399
  return result.message;
19388
19400
  }
19401
+ // ----- Fenced leases over shared resources (P3) -----
19402
+ /**
19403
+ * Take or extend the lease over `resource` inside this client's project.
19404
+ *
19405
+ * The returned `fencingToken` is the whole point: hold it, present it on every
19406
+ * renewal, and present it again before committing anything shared. A hub that
19407
+ * has moved past it refuses, and the caller must stop rather than retry —
19408
+ * another holder owns the resource now. Rejects `HubHttpError` with code
19409
+ * `lease_held` when a live holder has it.
19410
+ */
19411
+ async acquireLease(resource, ttlMs) {
19412
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
19413
+ method: "POST",
19414
+ body: JSON.stringify(ttlMs === void 0 ? {} : { ttlMs })
19415
+ });
19416
+ }
19417
+ /** Extend a lease this client holds. The token never changes on renewal; a
19418
+ * `lease_superseded` or `lease_expired` refusal means it is gone. */
19419
+ async renewLease(resource, fencingToken, ttlMs) {
19420
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
19421
+ method: "POST",
19422
+ body: JSON.stringify(ttlMs === void 0 ? { fencingToken } : { fencingToken, ttlMs })
19423
+ });
19424
+ }
19425
+ /** Give the resource back. */
19426
+ async releaseLease(resource, fencingToken) {
19427
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
19428
+ method: "POST",
19429
+ body: JSON.stringify({ fencingToken })
19430
+ });
19431
+ }
19389
19432
  async listWorkflows() {
19390
19433
  const result = await this.request("/v1/workflows");
19391
19434
  return result.runs;
@@ -19530,7 +19573,8 @@ var HubClient = class {
19530
19573
  name: this.options.name,
19531
19574
  purpose: this.options.purpose,
19532
19575
  project: this.options.project,
19533
- model: this.options.model
19576
+ model: this.options.model,
19577
+ host: this.options.host ?? defaultHostLabel()
19534
19578
  })
19535
19579
  }, false);
19536
19580
  this.agent = registration.agent;
@@ -19578,6 +19622,7 @@ var HubClient = class {
19578
19622
  for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
19579
19623
  if (typeof body[key] === "string") extras[key] = body[key];
19580
19624
  }
19625
+ if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
19581
19626
  throw new HubHttpError(
19582
19627
  response.status,
19583
19628
  String(body.error ?? `HTTP ${response.status}`),
@@ -19770,14 +19815,19 @@ var AGENT_COMMANDS = [
19770
19815
  group: "peer",
19771
19816
  verb: "list",
19772
19817
  label: "List hub peers",
19773
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
19818
+ 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.",
19774
19819
  parameters: {
19775
19820
  type: "object",
19776
- properties: {},
19821
+ properties: {
19822
+ includeOffline: {
19823
+ type: "boolean",
19824
+ description: "Also list registered peers whose hub lease has expired"
19825
+ }
19826
+ },
19777
19827
  additionalProperties: false
19778
19828
  },
19779
- async execute(client) {
19780
- return { agents: await client.listAgents() };
19829
+ async execute(client, args) {
19830
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
19781
19831
  }
19782
19832
  },
19783
19833
  {
@@ -23846,7 +23896,7 @@ function discoverProjectStores(projectRoot, options = {}) {
23846
23896
  const stores = [];
23847
23897
  const hubPath = options.hubDataPath ? resolve5(options.hubDataPath) : join8(root, ".kxm", "state", "kxm.db");
23848
23898
  if (existsSync8(hubPath)) {
23849
- stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 3 });
23899
+ stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 4 });
23850
23900
  }
23851
23901
  const registryPath = join8(root, ".kxm", "runtime", "registry.db");
23852
23902
  if (existsSync8(registryPath)) {
@@ -23952,7 +24002,7 @@ function restoreBackup(manifestPathOrDir, options = {}) {
23952
24002
  `backup file ${store.backupFile} sha256 ${actualSha256} does not match manifest hash ${store.sha256}`
23953
24003
  );
23954
24004
  }
23955
- let maxSupported = 3;
24005
+ let maxSupported = 4;
23956
24006
  if (store.storeId === "registry" || store.storeId === "binding-store") {
23957
24007
  maxSupported = 1;
23958
24008
  } else if (store.storeId.startsWith("events:")) {
@@ -26188,8 +26238,8 @@ function printWorker(runtime, worker, payload, text, outcome) {
26188
26238
  }
26189
26239
  function hostMode(runtime) {
26190
26240
  try {
26191
- const hostname = new URL(runtime.serverUrl).hostname;
26192
- if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1") return "local";
26241
+ const hostname2 = new URL(runtime.serverUrl).hostname;
26242
+ if (hostname2 === "127.0.0.1" || hostname2 === "localhost" || hostname2 === "::1") return "local";
26193
26243
  } catch {
26194
26244
  }
26195
26245
  return "hub";
@@ -42587,6 +42637,12 @@ function slugifyBranchPart(text, maxLength = 40) {
42587
42637
  if (cleaned.length <= maxLength) return cleaned;
42588
42638
  return cleaned.slice(0, maxLength).replace(/-+$/, "");
42589
42639
  }
42640
+ var SHARED_EFFECT_KINDS = Object.freeze([
42641
+ "git-push",
42642
+ "pr-create",
42643
+ "tracker-issue",
42644
+ "webhook"
42645
+ ]);
42590
42646
 
42591
42647
  // plugins/kxm/src/local-snapshot.ts
42592
42648
  import { existsSync as existsSync26, readdirSync as readdirSync11, readFileSync as readFileSync26 } from "node:fs";
@@ -43087,6 +43143,13 @@ function meshTuiTheme(color) {
43087
43143
  function visibleAgents(snapshot) {
43088
43144
  return snapshot.agents.filter((agent) => agent.model !== "tui");
43089
43145
  }
43146
+ function presenceCell(agent, theme, width = 7) {
43147
+ const presence = agent.presence ?? "n/a";
43148
+ const cell = pad(presence, width);
43149
+ if (presence === "online") return theme.success(cell);
43150
+ if (presence === "stale") return theme.warning(cell);
43151
+ return theme.dim(cell);
43152
+ }
43090
43153
  function panelMetric(snapshot, panel) {
43091
43154
  if (panel === "agents") {
43092
43155
  const agents = visibleAgents(snapshot);
@@ -43137,7 +43200,7 @@ function listLines(snapshot, view, theme) {
43137
43200
  const now = Date.parse(snapshot.fetchedAt);
43138
43201
  const mark = (index) => cursor(theme, index === view.selected, "list", view.pane);
43139
43202
  if (view.tab === "agents") {
43140
- return visibleAgents(snapshot).map((agent, index) => `${mark(index)}${pad(agent.name, 14)} ${agent.online ? theme.success(pad("yes", 3)) : theme.dim(pad("no", 3))} ${pad(agent.model ?? "-", 18)} ${pad(age(agent.lastSeenAt, now), 4)}`);
43203
+ return visibleAgents(snapshot).map((agent, index) => `${mark(index)}${pad(agent.name, 14)} ${presenceCell(agent, theme)} ${pad(agent.host ?? "-", 12)} ${pad(agent.model ?? "-", 14)} ${pad(age(agent.lastSeenAt, now), 4)}`);
43141
43204
  }
43142
43205
  if (view.tab === "tasks" || view.tab === "workflows") {
43143
43206
  const runs = view.tab === "tasks" ? snapshot.runs.filter((run) => run.status === "running" || run.status === "waiting") : snapshot.runs;
@@ -43175,9 +43238,10 @@ function detailLines(snapshot, view, theme) {
43175
43238
  const related = snapshot.openMessages.filter((message) => message.fromName === agent.name || message.toName === agent.name);
43176
43239
  return [
43177
43240
  theme.accent(agent.name),
43178
- `${agent.online ? theme.success("online") : theme.dim("offline")} ${agent.model ?? "-"}`,
43241
+ `${presenceCell(agent, theme, (agent.presence ?? "n/a").length)} ${agent.model ?? "-"}`,
43242
+ `host ${agent.host ?? "-"}`,
43179
43243
  agent.purpose,
43180
- `seen ${age(agent.lastSeenAt, now)} ago`,
43244
+ `seen ${age(agent.lastSeenAt, now)} ago \xB7 lease ${agent.leaseExpiresAt?.slice(11, 19) ?? "n/a"} UTC`,
43181
43245
  "",
43182
43246
  theme.dim("Open work"),
43183
43247
  ...related.length === 0 ? [theme.dim("none")] : related.map((message) => `${message.status} ${message.fromName} \u2192 ${message.toName} ${age(message.createdAt, now)}`)
@@ -43486,7 +43550,7 @@ async function runMeshTui(input) {
43486
43550
  }
43487
43551
  if (!useOpsStream && identity) {
43488
43552
  try {
43489
- const listed = await input.fetchImpl(`${base}/v1/agents`, { headers: headers(identity) });
43553
+ const listed = await input.fetchImpl(`${base}/v1/agents?includeOffline=true`, { headers: headers(identity) });
43490
43554
  if (listed.ok) {
43491
43555
  const body = await readJson(listed);
43492
43556
  const byId = new Map(local.agents.map((agent) => [agent.id, agent]));
@@ -47744,7 +47808,7 @@ function createProgram(ctx, result) {
47744
47808
  });
47745
47809
  const peer = addGlobalOptions(program2.command("peer").description("Peer agent messaging and coordination"));
47746
47810
  peer.helpCommand("help", "Show peer help");
47747
- addGlobalOptions(peer.command("list").description("List online peer agents in this project's hub pool")).option("--payload <json>", "JSON payload").action(async function peerListAction(opts) {
47811
+ addGlobalOptions(peer.command("list").description("List peer agents in this project's hub pool with host and presence")).option("--include-offline", "Also list registered peers whose hub lease has expired").option("--payload <json>", "JSON payload").action(async function peerListAction(opts) {
47748
47812
  result.code = await dispatchAgentCliCommand(runtimeFrom(ctx, this), "kxm_list", opts ?? {});
47749
47813
  });
47750
47814
  addGlobalOptions(peer.command("send [target] [content]").description("Send a focused request to a peer agent")).option("--target <name>", "Peer name or agent ID").option("--content <text>", "Focused request content").option("--delivery <mode>", "steer, followUp, or nextTurn").option("--correlation-id <id>", "Task grouping ID").option("--idempotency-key <key>", "Deduplication key").option("--workflow-context <json>", "Workflow context JSON").option("--ttl-ms <ms>", "Message TTL in milliseconds").option("--allow-offline", "Queue the request if the target is registered but offline").option("--payload <json>", "JSON payload").action(async function peerSendAction(target, content, opts) {
@@ -1,11 +1,15 @@
1
1
  // plugins/kxm/src/client.ts
2
2
  import { createHash } from "node:crypto";
3
+ import { hostname } from "node:os";
3
4
 
4
5
  // plugins/kxm/src/protocol.ts
5
6
  var DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 6e4;
6
7
  var MAX_MESSAGE_TTL_MS = 7 * 24 * 60 * 6e4;
7
8
  var DEFAULT_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 6e4;
8
9
  var MAX_BODY_BYTES = 256 * 1024;
10
+ var MAX_AGENT_HOST_CHARS = 64;
11
+ var MAX_LEASE_TTL_MS = 10 * 6e4;
12
+ var DEFAULT_LEASE_TTL_MS = 5 * 6e4;
9
13
 
10
14
  // plugins/kxm/src/workflow.ts
11
15
  function canonicalWorkflowEvidenceKey(value) {
@@ -21,6 +25,13 @@ var MeshWaitError = class extends Error {
21
25
  this.waitStatus = waitStatus;
22
26
  }
23
27
  };
28
+ function defaultHostLabel() {
29
+ try {
30
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
31
+ } catch {
32
+ return void 0;
33
+ }
34
+ }
24
35
  function completedFanoutResult(target, message) {
25
36
  if (message.status === "queued" || message.status === "delivered") {
26
37
  throw new Error(`message ${message.id} is not complete`);
@@ -110,8 +121,11 @@ var HubClient = class {
110
121
  this.onEvent = void 0;
111
122
  this.eventLoop = void 0;
112
123
  }
113
- async listAgents() {
114
- const result = await this.request("/v1/agents");
124
+ /** Online peers of this client's project. `includeOffline` also returns
125
+ * registered members whose lease the hub has already retired. */
126
+ async listAgents(options = {}) {
127
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
128
+ const result = await this.request(path);
115
129
  return result.agents;
116
130
  }
117
131
  async send(options) {
@@ -207,6 +221,37 @@ var HubClient = class {
207
221
  );
208
222
  return result.message;
209
223
  }
224
+ // ----- Fenced leases over shared resources (P3) -----
225
+ /**
226
+ * Take or extend the lease over `resource` inside this client's project.
227
+ *
228
+ * The returned `fencingToken` is the whole point: hold it, present it on every
229
+ * renewal, and present it again before committing anything shared. A hub that
230
+ * has moved past it refuses, and the caller must stop rather than retry —
231
+ * another holder owns the resource now. Rejects `HubHttpError` with code
232
+ * `lease_held` when a live holder has it.
233
+ */
234
+ async acquireLease(resource, ttlMs) {
235
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
236
+ method: "POST",
237
+ body: JSON.stringify(ttlMs === void 0 ? {} : { ttlMs })
238
+ });
239
+ }
240
+ /** Extend a lease this client holds. The token never changes on renewal; a
241
+ * `lease_superseded` or `lease_expired` refusal means it is gone. */
242
+ async renewLease(resource, fencingToken, ttlMs) {
243
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
244
+ method: "POST",
245
+ body: JSON.stringify(ttlMs === void 0 ? { fencingToken } : { fencingToken, ttlMs })
246
+ });
247
+ }
248
+ /** Give the resource back. */
249
+ async releaseLease(resource, fencingToken) {
250
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
251
+ method: "POST",
252
+ body: JSON.stringify({ fencingToken })
253
+ });
254
+ }
210
255
  async listWorkflows() {
211
256
  const result = await this.request("/v1/workflows");
212
257
  return result.runs;
@@ -351,7 +396,8 @@ var HubClient = class {
351
396
  name: this.options.name,
352
397
  purpose: this.options.purpose,
353
398
  project: this.options.project,
354
- model: this.options.model
399
+ model: this.options.model,
400
+ host: this.options.host ?? defaultHostLabel()
355
401
  })
356
402
  }, false);
357
403
  this.agent = registration.agent;
@@ -399,6 +445,7 @@ var HubClient = class {
399
445
  for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
400
446
  if (typeof body[key] === "string") extras[key] = body[key];
401
447
  }
448
+ if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
402
449
  throw new HubHttpError(
403
450
  response.status,
404
451
  String(body.error ?? `HTTP ${response.status}`),
@@ -12,6 +12,21 @@ var DEFAULT_RATE_LIMIT_MAX = 600;
12
12
  var DEFAULT_RATE_LIMIT_WINDOW_MS = 6e4;
13
13
  var MAX_BODY_BYTES = 256 * 1024;
14
14
  var MAX_CONTENT_CHARS = 32e3;
15
+ var MAX_AGENT_HOST_CHARS = 64;
16
+ var MIN_LEASE_TTL_MS = 5e3;
17
+ var MAX_LEASE_TTL_MS = 10 * 6e4;
18
+ var DEFAULT_LEASE_TTL_MS = 5 * 6e4;
19
+ var MAX_LEASE_RESOURCE_CHARS = 200;
20
+ function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
21
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
22
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
23
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
24
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
25
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
26
+ }
27
+ function toAgentRecord(agent, staleAfterMs, now) {
28
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
29
+ }
15
30
  var ProtocolError = class extends Error {
16
31
  statusCode;
17
32
  code;
@@ -945,14 +960,19 @@ var AGENT_COMMANDS = [
945
960
  group: "peer",
946
961
  verb: "list",
947
962
  label: "List hub peers",
948
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
963
+ 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.",
949
964
  parameters: {
950
965
  type: "object",
951
- properties: {},
966
+ properties: {
967
+ includeOffline: {
968
+ type: "boolean",
969
+ description: "Also list registered peers whose hub lease has expired"
970
+ }
971
+ },
952
972
  additionalProperties: false
953
973
  },
954
- async execute(client) {
955
- return { agents: await client.listAgents() };
974
+ async execute(client, args) {
975
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
956
976
  }
957
977
  },
958
978
  {
@@ -2001,6 +2021,7 @@ export {
2001
2021
  AGENT_COMMANDS,
2002
2022
  AGENT_COMMANDS_MAP,
2003
2023
  BEHAVIORAL_HASH_VERSION,
2024
+ DEFAULT_LEASE_TTL_MS,
2004
2025
  DEFAULT_LOG_MAX_BYTES,
2005
2026
  DEFAULT_LOG_MAX_FILES,
2006
2027
  DEFAULT_MAX_HOPS,
@@ -2011,12 +2032,16 @@ export {
2011
2032
  DEFAULT_RATE_LIMIT_WINDOW_MS,
2012
2033
  DEFAULT_STALE_AFTER_MS,
2013
2034
  LOG_LEVEL_PRIORITY,
2035
+ MAX_AGENT_HOST_CHARS,
2014
2036
  MAX_BODY_BYTES,
2015
2037
  MAX_CONTENT_CHARS,
2016
2038
  MAX_CONTEXT_ITEM_IDS,
2039
+ MAX_LEASE_RESOURCE_CHARS,
2040
+ MAX_LEASE_TTL_MS,
2017
2041
  MAX_MESSAGE_TTL_MS,
2018
2042
  MAX_PROVIDER_METADATA_FIELDS,
2019
2043
  MAX_SKILL_REFS,
2044
+ MIN_LEASE_TTL_MS,
2020
2045
  MIN_MESSAGE_RETENTION_MS,
2021
2046
  MIN_MESSAGE_TTL_MS,
2022
2047
  ProtocolError,
@@ -2026,6 +2051,7 @@ export {
2026
2051
  TERMINAL_RECEIPT_SCHEMA,
2027
2052
  WORKER_RESULT_SCHEMA,
2028
2053
  WORKER_SCHEMA,
2054
+ agentPresenceView,
2029
2055
  agentWorker,
2030
2056
  behavioralConfigHash,
2031
2057
  clearSessionTokenFromDisk,
@@ -2066,6 +2092,7 @@ export {
2066
2092
  rotateLogFiles,
2067
2093
  sessionTokenPath,
2068
2094
  timingSafeStringCompare,
2095
+ toAgentRecord,
2069
2096
  validateTerminalReceipt,
2070
2097
  workerResult
2071
2098
  };