@kontextmind/kxm 0.7.69 → 0.7.71

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,17 @@ 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
+ function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
7392
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
7393
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
7394
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
7395
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
7396
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
7397
+ }
7398
+ function toAgentRecord(agent, staleAfterMs, now) {
7399
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
7400
+ }
7390
7401
  var ProtocolError = class extends Error {
7391
7402
  statusCode;
7392
7403
  code;
@@ -8575,14 +8586,19 @@ var AGENT_COMMANDS = [
8575
8586
  group: "peer",
8576
8587
  verb: "list",
8577
8588
  label: "List hub peers",
8578
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
8589
+ 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
8590
  parameters: {
8580
8591
  type: "object",
8581
- properties: {},
8592
+ properties: {
8593
+ includeOffline: {
8594
+ type: "boolean",
8595
+ description: "Also list registered peers whose hub lease has expired"
8596
+ }
8597
+ },
8582
8598
  additionalProperties: false
8583
8599
  },
8584
- async execute(client) {
8585
- return { agents: await client.listAgents() };
8600
+ async execute(client, args) {
8601
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
8586
8602
  }
8587
8603
  },
8588
8604
  {
@@ -8622,7 +8638,12 @@ var AGENT_COMMANDS = [
8622
8638
  required: ["runId", "stageId", "requirementKey", "attempt"],
8623
8639
  additionalProperties: false
8624
8640
  },
8625
- ttlMs: { type: "number", minimum: 1e3, maximum: 6048e5, description: "Message TTL in milliseconds" }
8641
+ ttlMs: { type: "number", minimum: 1e3, maximum: 6048e5, description: "Message TTL in milliseconds" },
8642
+ allowOffline: {
8643
+ type: "boolean",
8644
+ default: false,
8645
+ description: "Queue the request if the target is a registered offline agent in this project"
8646
+ }
8626
8647
  },
8627
8648
  required: ["target", "content"],
8628
8649
  additionalProperties: false
@@ -8639,7 +8660,8 @@ var AGENT_COMMANDS = [
8639
8660
  ...correlationId ? { correlationId } : {},
8640
8661
  ...idempotencyKey ? { idempotencyKey } : {},
8641
8662
  ...workflowContext ? { workflowContext } : {},
8642
- ...typeof args.ttlMs === "number" ? { ttlMs: args.ttlMs } : {}
8663
+ ...typeof args.ttlMs === "number" ? { ttlMs: args.ttlMs } : {},
8664
+ ...args.allowOffline === true ? { allowOffline: true } : {}
8643
8665
  });
8644
8666
  return { messageId: message.id, status: message.status, target: message.toName };
8645
8667
  }
@@ -12132,9 +12154,9 @@ function isLoopback(host2) {
12132
12154
  if (host2 === "localhost" || host2 === "::1") return true;
12133
12155
  return isIP(host2) === 4 && host2.startsWith("127.");
12134
12156
  }
12135
- function publicAgent(agent) {
12136
- const { key: _key, ...record } = agent;
12137
- return record;
12157
+ function publicAgent(agent, staleAfterMs, now = Date.now()) {
12158
+ const { key: _key, ...identity } = agent;
12159
+ return toAgentRecord(identity, staleAfterMs, now);
12138
12160
  }
12139
12161
  function safeTokenEqual(actual, expected) {
12140
12162
  return timingSafeStringCompare(actual, expected);
@@ -12198,6 +12220,11 @@ function sameWorkflowMessageContext(left, right) {
12198
12220
  if (!left || !right) return left === right;
12199
12221
  return left.schema === right.schema && left.runId === right.runId && left.stageId === right.stageId && left.requirementKey === right.requirementKey && left.attempt === right.attempt;
12200
12222
  }
12223
+ function parseOptionalBoolean(value, field) {
12224
+ if (value === void 0 || value === null) return false;
12225
+ if (typeof value === "boolean") return value;
12226
+ throw new ProtocolError(400, `${field} must be a boolean`);
12227
+ }
12201
12228
  function sameIdempotentRequest(message, target, content, delivery, correlationId, replyTo, hops, maxHops, ttlMs, workflowContext) {
12202
12229
  return (message.to === target || message.toName.toLowerCase() === target.toLowerCase()) && message.content === content && message.delivery === delivery && message.correlationId === correlationId && message.replyTo === replyTo && message.hops === hops && message.maxHops === maxHops && sameWorkflowMessageContext(message.workflowContext, workflowContext) && Date.parse(message.expiresAt) - Date.parse(message.createdAt) === ttlMs;
12203
12230
  }
@@ -12500,7 +12527,10 @@ function createMeshHub(options = {}) {
12500
12527
  agent.lastSeenAt = nowIso();
12501
12528
  agent.online = true;
12502
12529
  store.saveAgent(agent);
12503
- if (wasOffline) broadcastPresence(agent);
12530
+ if (wasOffline) {
12531
+ broadcastPresence(agent);
12532
+ if ((streams.get(agent.id)?.size ?? 0) > 0) flushPending(agent.id);
12533
+ }
12504
12534
  return agent;
12505
12535
  }
12506
12536
  function checkRateLimit(request, response) {
@@ -12546,7 +12576,8 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12546
12576
  }
12547
12577
  }
12548
12578
  function opsSnapshot(project) {
12549
- 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));
12579
+ const snapshotAt = Date.now();
12580
+ 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));
12550
12581
  const open = store.getOpenMessages(project);
12551
12582
  const projectRuns = [...workflowRuns.values()].filter((run) => run.project === project).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
12552
12583
  return {
@@ -12596,16 +12627,16 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12596
12627
  function broadcastPresence(agent) {
12597
12628
  for (const candidate of agents.values()) {
12598
12629
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
12599
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
12630
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
12600
12631
  }
12601
12632
  }
12602
12633
  publishOps(agent.project, "agents");
12603
12634
  }
12604
- function findTarget(project, target) {
12635
+ function findTarget(project, target, allowOffline = false) {
12605
12636
  const byId = agents.get(target);
12606
- if (byId?.project === project && byId.online) return byId;
12637
+ if (byId?.project === project && (allowOffline || byId.online)) return byId;
12607
12638
  const byName = [...agents.values()].find(
12608
- (agent) => agent.project === project && agent.online && agent.name.toLowerCase() === target.toLowerCase()
12639
+ (agent) => agent.project === project && (allowOffline || agent.online) && agent.name.toLowerCase() === target.toLowerCase()
12609
12640
  );
12610
12641
  if (!byName) throw new ProtocolError(404, `online target not found: ${target}`, "target_not_found");
12611
12642
  return byName;
@@ -13898,6 +13929,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13898
13929
  const project = requireString(body.project, "project", { max: 128 });
13899
13930
  requireProjectAuth(request, project);
13900
13931
  const model = optionalString(body.model, "model", 128);
13932
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
13901
13933
  const existing = [...agents.values()].find(
13902
13934
  (agent2) => agent2.project === project && agent2.name.toLowerCase() === name.toLowerCase()
13903
13935
  );
@@ -13924,17 +13956,27 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13924
13956
  agent.online = true;
13925
13957
  if (model) agent.model = model;
13926
13958
  else delete agent.model;
13959
+ if (hostLabel) agent.host = hostLabel;
13960
+ else delete agent.host;
13927
13961
  store.saveAgent(agent);
13928
13962
  counters.registrations += 1;
13929
- logger({ event: existing ? "agent_resumed" : "agent_registered", agentId: agent.id, name, project });
13963
+ logger({
13964
+ event: existing ? "agent_resumed" : "agent_registered",
13965
+ agentId: agent.id,
13966
+ name,
13967
+ project,
13968
+ ...hostLabel ? { host: hostLabel } : {}
13969
+ });
13930
13970
  broadcastPresence(agent);
13931
- json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
13971
+ json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
13932
13972
  return;
13933
13973
  }
13934
13974
  if (method === "GET" && url.pathname === "/v1/agents") {
13935
13975
  const current = requireAgent(request);
13936
13976
  requireProjectAuth(request, current.project);
13937
- const result = [...agents.values()].filter((agent) => agent.project === current.project && agent.online).map(publicAgent);
13977
+ const includeOffline = url.searchParams.get("includeOffline") === "true";
13978
+ const listedAt = Date.now();
13979
+ const result = [...agents.values()].filter((agent) => agent.project === current.project && (agent.online || includeOffline)).map((agent) => publicAgent(agent, staleAfterMs, listedAt));
13938
13980
  json(response, 200, { agents: result });
13939
13981
  return;
13940
13982
  }
@@ -13943,7 +13985,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13943
13985
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]));
13944
13986
  requireProjectAuth(request, current.project);
13945
13987
  await readJson(request);
13946
- json(response, 200, { agent: publicAgent(current) });
13988
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
13947
13989
  return;
13948
13990
  }
13949
13991
  const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
@@ -13975,7 +14017,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13975
14017
  ...presenceOnly ? { "x-kxm-events-mode": "presence" } : {}
13976
14018
  });
13977
14019
  response.write(`event: ready
13978
- data: ${JSON.stringify({ agent: publicAgent(current) })}
14020
+ data: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}
13979
14021
 
13980
14022
  `);
13981
14023
  const client = {
@@ -14026,6 +14068,7 @@ data: ${JSON.stringify({ agent: publicAgent(current) })}
14026
14068
  MIN_MESSAGE_TTL_MS,
14027
14069
  MAX_MESSAGE_TTL_MS
14028
14070
  );
14071
+ const allowOffline = parseOptionalBoolean(body.allowOffline, "allowOffline");
14029
14072
  const idempotencyKey = optionalString(body.idempotencyKey, "idempotencyKey", 128);
14030
14073
  if (idempotencyKey) {
14031
14074
  const existing = store.findMessageByIdempotency(sender.id, idempotencyKey);
@@ -14048,7 +14091,7 @@ data: ${JSON.stringify({ agent: publicAgent(current) })}
14048
14091
  return;
14049
14092
  }
14050
14093
  }
14051
- const target = findTarget(sender.project, targetInput);
14094
+ const target = findTarget(sender.project, targetInput, allowOffline);
14052
14095
  if (target.id === sender.id) {
14053
14096
  throw new ProtocolError(400, "cannot send a request to yourself", "self_target");
14054
14097
  }
@@ -14076,7 +14119,7 @@ data: ${JSON.stringify({ agent: publicAgent(current) })}
14076
14119
  };
14077
14120
  store.saveMessage(message);
14078
14121
  publishOps(message.project, "messages");
14079
- publish(target.id, { type: "message", message });
14122
+ if (target.online) publish(target.id, { type: "message", message });
14080
14123
  counters.messagesSent += 1;
14081
14124
  logger({ event: "message_sent", messageId: message.id, hops, ...messageLog(message, sender.id, sender.name, target.id, target.name) });
14082
14125
  json(response, 202, { message, idempotent: false });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.69",
3
+ "version": "0.7.71",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -81,6 +81,12 @@ declared in policy, is bound to the active attempt, and is idempotent only when
81
81
  the reason is unchanged. Approval does not checkpoint the stage. Webhook and
82
82
  signal authentication cannot call this route.
83
83
 
84
+ `POST /v1/messages` requires an online target unless `allowOffline` is true.
85
+ With `allowOffline: true`, a registered offline agent in the same project is
86
+ stored `queued` and delivered once through the reconnect cursor; unknown names
87
+ still return `target_not_found`. Queued messages are not evidence unless
88
+ `workflowContext` was hub-authorized at send.
89
+
84
90
  ## Request states
85
91
 
86
92
  - `queued`: stored by the hub but not acknowledged by the recipient.
@@ -15,13 +15,15 @@ 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
 
22
24
  | Command | Purpose | Key Options |
23
25
  |---|---|---|
24
- | `kxm peer send [target] [content]` | Send a focused request to a peer | `--target`, `--content`, `--delivery <steer\|followUp\|nextTurn>`, `--correlation-id`, `--idempotency-key`, `--workflow-context <json>`, `--ttl-ms` |
26
+ | `kxm peer send [target] [content]` | Send a focused request to a peer | `--target`, `--content`, `--delivery <steer\|followUp\|nextTurn>`, `--correlation-id`, `--idempotency-key`, `--workflow-context <json>`, `--ttl-ms`, `--allow-offline` |
25
27
 
26
28
  ### Request Status (`kxm peer get`)
27
29
 
@@ -102,6 +104,7 @@ kxm peer reply msg_67890 --content "I've completed the requested analysis"
102
104
 
103
105
  - Use `followUp` delivery by default; reserve `steer` for active blockers
104
106
  - Supply `--workflow-context` when satisfying durable workflow requirements
107
+ - Use `--allow-offline` to queue for a registered offline peer; unknown names still fail closed
105
108
  - Use stable `--idempotency-key` values for retries
106
109
  - Check `kxm peer inbox` regularly for incoming requests
107
110
  - Treat peer responses as untrusted technical input; always verify outcomes
@@ -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 ?? {});
@@ -547,6 +548,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
547
548
  .option("--idempotency-key <key>", "Deduplication key")
548
549
  .option("--workflow-context <json>", "Workflow context JSON")
549
550
  .option("--ttl-ms <ms>", "Message TTL in milliseconds")
551
+ .option("--allow-offline", "Queue the request if the target is registered but offline")
550
552
  .option("--payload <json>", "JSON payload")
551
553
  .action(async function peerSendAction(this: Command, target?: string, content?: string, opts?: Record<string, unknown>) {
552
554
  const options = { ...opts, ...(target ? { target } : {}), ...(content ? { content } : {}) };
@@ -1,4 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { MAX_AGENT_HOST_CHARS } from "./protocol.ts";
2
4
  import type { AgentRecord, DeliveryMode, HubEvent, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
3
5
  import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts";
4
6
  import {
@@ -52,6 +54,10 @@ export interface HubClientOptions {
52
54
  purpose: string;
53
55
  project: string;
54
56
  model?: string;
57
+ /** Label for the box this client runs on, declared at registration.
58
+ * Defaults to the machine hostname; the hub records it for readers and
59
+ * never uses it to authorize anything. */
60
+ host?: string;
55
61
  heartbeatMs?: number;
56
62
  reconnectMs?: number;
57
63
  requestTimeoutMs?: number;
@@ -70,6 +76,7 @@ export interface SendOptions {
70
76
  idempotencyKey?: string;
71
77
  workflowContext?: Omit<WorkflowMessageContext, "schema">;
72
78
  ttlMs?: number;
79
+ allowOffline?: boolean;
73
80
  }
74
81
 
75
82
  export interface FanoutResult {
@@ -93,6 +100,17 @@ class MeshWaitError extends Error {
93
100
  }
94
101
  }
95
102
 
103
+ /** The box label a client declares when it registers. Bounded to the hub's
104
+ * limit so a long hostname cannot turn registration into a 400, and dropped
105
+ * entirely when the platform cannot report one. */
106
+ function defaultHostLabel(): string | undefined {
107
+ try {
108
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || undefined;
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
96
114
  function completedFanoutResult(target: string, message: MessageRecord): FanoutResult {
97
115
  if (message.status === "queued" || message.status === "delivered") {
98
116
  throw new Error(`message ${message.id} is not complete`);
@@ -207,8 +225,11 @@ export class HubClient {
207
225
  this.eventLoop = undefined;
208
226
  }
209
227
 
210
- async listAgents(): Promise<AgentRecord[]> {
211
- const result = await this.request<{ agents: AgentRecord[] }>("/v1/agents");
228
+ /** Online peers of this client's project. `includeOffline` also returns
229
+ * registered members whose lease the hub has already retired. */
230
+ async listAgents(options: { includeOffline?: boolean } = {}): Promise<AgentRecord[]> {
231
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
232
+ const result = await this.request<{ agents: AgentRecord[] }>(path);
212
233
  return result.agents;
213
234
  }
214
235
 
@@ -554,6 +575,7 @@ export class HubClient {
554
575
  purpose: this.options.purpose,
555
576
  project: this.options.project,
556
577
  model: this.options.model,
578
+ host: this.options.host ?? defaultHostLabel(),
557
579
  }),
558
580
  }, false);
559
581
  this.agent = registration.agent;
@@ -194,14 +194,20 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
194
194
  group: "peer",
195
195
  verb: "list",
196
196
  label: "List hub peers",
197
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
197
+ description:
198
+ "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.",
198
199
  parameters: {
199
200
  type: "object",
200
- properties: {},
201
+ properties: {
202
+ includeOffline: {
203
+ type: "boolean",
204
+ description: "Also list registered peers whose hub lease has expired",
205
+ },
206
+ },
201
207
  additionalProperties: false,
202
208
  },
203
- async execute(client) {
204
- return { agents: await client.listAgents() };
209
+ async execute(client, args) {
210
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
205
211
  },
206
212
  },
207
213
  {
@@ -243,6 +249,11 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
243
249
  additionalProperties: false,
244
250
  },
245
251
  ttlMs: { type: "number", minimum: 1_000, maximum: 604_800_000, description: "Message TTL in milliseconds" },
252
+ allowOffline: {
253
+ type: "boolean",
254
+ default: false,
255
+ description: "Queue the request if the target is a registered offline agent in this project",
256
+ },
246
257
  },
247
258
  required: ["target", "content"],
248
259
  additionalProperties: false,
@@ -260,6 +271,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
260
271
  ...(idempotencyKey ? { idempotencyKey } : {}),
261
272
  ...(workflowContext ? { workflowContext } : {}),
262
273
  ...(typeof args.ttlMs === "number" ? { ttlMs: args.ttlMs } : {}),
274
+ ...(args.allowOffline === true ? { allowOffline: true } : {}),
263
275
  });
264
276
  return { messageId: message.id, status: message.status, target: message.toName };
265
277
  },
@@ -10,6 +10,7 @@ import {
10
10
  DEFAULT_RATE_LIMIT_MAX,
11
11
  DEFAULT_RATE_LIMIT_WINDOW_MS,
12
12
  DEFAULT_STALE_AFTER_MS,
13
+ MAX_AGENT_HOST_CHARS,
13
14
  MAX_BODY_BYTES,
14
15
  MAX_CONTENT_CHARS,
15
16
  MAX_MESSAGE_TTL_MS,
@@ -22,6 +23,7 @@ import {
22
23
  parseBoundedInteger,
23
24
  parseDeliveryMode,
24
25
  requireString,
26
+ toAgentRecord,
25
27
  type AgentRecord,
26
28
  type DeliveryMode,
27
29
  type HubEvent,
@@ -118,9 +120,12 @@ function isLoopback(host: string): boolean {
118
120
  return isIP(host) === 4 && host.startsWith("127.");
119
121
  }
120
122
 
121
- function publicAgent(agent: StoredAgent): AgentRecord {
122
- const { key: _key, ...record } = agent;
123
- return record;
123
+ /** Drop the agent key and attach hub-clocked presence. Every caller passes the
124
+ * hub's own `staleAfterMs` and clock, so no reader ever sees a lease derived
125
+ * from a client timestamp. */
126
+ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now()): AgentRecord {
127
+ const { key: _key, ...identity } = agent;
128
+ return toAgentRecord(identity, staleAfterMs, now);
124
129
  }
125
130
 
126
131
  function safeTokenEqual(actual: string | undefined, expected: string): boolean {
@@ -200,6 +205,12 @@ function sameWorkflowMessageContext(
200
205
  && left.attempt === right.attempt;
201
206
  }
202
207
 
208
+ function parseOptionalBoolean(value: unknown, field: string): boolean {
209
+ if (value === undefined || value === null) return false;
210
+ if (typeof value === "boolean") return value;
211
+ throw new ProtocolError(400, `${field} must be a boolean`);
212
+ }
213
+
203
214
  function sameIdempotentRequest(
204
215
  message: MessageRecord,
205
216
  target: string,
@@ -565,7 +576,13 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
565
576
  agent.lastSeenAt = nowIso();
566
577
  agent.online = true;
567
578
  store.saveAgent(agent);
568
- if (wasOffline) broadcastPresence(agent);
579
+ if (wasOffline) {
580
+ broadcastPresence(agent);
581
+ // An agent the stale sweep retired may still hold an open SSE stream (the sweep
582
+ // marks offline without closing it). Without this flush, messages queued while it
583
+ // read offline expire unread even though the stream could deliver them.
584
+ if ((streams.get(agent.id)?.size ?? 0) > 0) flushPending(agent.id);
585
+ }
569
586
  return agent;
570
587
  }
571
588
 
@@ -611,9 +628,10 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
611
628
  }
612
629
 
613
630
  function opsSnapshot(project: string) {
631
+ const snapshotAt = Date.now();
614
632
  const projectAgents = [...agents.values()]
615
633
  .filter((agent) => agent.project === project)
616
- .map(publicAgent)
634
+ .map((agent) => publicAgent(agent, staleAfterMs, snapshotAt))
617
635
  .sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
618
636
  const open = store.getOpenMessages(project);
619
637
  const projectRuns = [...workflowRuns.values()]
@@ -671,17 +689,19 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
671
689
  function broadcastPresence(agent: StoredAgent): void {
672
690
  for (const candidate of agents.values()) {
673
691
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
674
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
692
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
675
693
  }
676
694
  }
677
695
  publishOps(agent.project, "agents");
678
696
  }
679
697
 
680
- function findTarget(project: string, target: string): StoredAgent {
698
+ function findTarget(project: string, target: string, allowOffline = false): StoredAgent {
681
699
  const byId = agents.get(target);
682
- if (byId?.project === project && byId.online) return byId;
700
+ if (byId?.project === project && (allowOffline || byId.online)) return byId;
683
701
  const byName = [...agents.values()].find(
684
- (agent) => agent.project === project && agent.online && agent.name.toLowerCase() === target.toLowerCase(),
702
+ (agent) => agent.project === project
703
+ && (allowOffline || agent.online)
704
+ && agent.name.toLowerCase() === target.toLowerCase(),
685
705
  );
686
706
  if (!byName) throw new ProtocolError(404, `online target not found: ${target}`, "target_not_found");
687
707
  return byName;
@@ -2095,6 +2115,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2095
2115
  const project = requireString(body.project, "project", { max: 128 });
2096
2116
  requireProjectAuth(request, project);
2097
2117
  const model = optionalString(body.model, "model", 128);
2118
+ // A client declares which box it runs on. The label is a reading aid
2119
+ // only: the project token is still the whole of admission.
2120
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
2098
2121
  const existing = [...agents.values()].find(
2099
2122
  (agent) => agent.project === project && agent.name.toLowerCase() === name.toLowerCase(),
2100
2123
  );
@@ -2121,20 +2144,34 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2121
2144
  agent.online = true;
2122
2145
  if (model) agent.model = model;
2123
2146
  else delete agent.model;
2147
+ if (hostLabel) agent.host = hostLabel;
2148
+ else delete agent.host;
2124
2149
  store.saveAgent(agent);
2125
2150
  counters.registrations += 1;
2126
- logger({ event: existing ? "agent_resumed" : "agent_registered", agentId: agent.id, name, project });
2151
+ logger({
2152
+ event: existing ? "agent_resumed" : "agent_registered",
2153
+ agentId: agent.id,
2154
+ name,
2155
+ project,
2156
+ ...(hostLabel ? { host: hostLabel } : {}),
2157
+ });
2127
2158
  broadcastPresence(agent);
2128
- json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
2159
+ json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
2129
2160
  return;
2130
2161
  }
2131
2162
 
2132
2163
  if (method === "GET" && url.pathname === "/v1/agents") {
2133
2164
  const current = requireAgent(request);
2134
2165
  requireProjectAuth(request, current.project);
2166
+ // Offline members are registered peers of the caller's own project
2167
+ // whose lease the sweep has already retired. They stay out of the
2168
+ // list unless a caller asks for them, so discovery keeps meaning
2169
+ // "who can answer right now".
2170
+ const includeOffline = url.searchParams.get("includeOffline") === "true";
2171
+ const listedAt = Date.now();
2135
2172
  const result = [...agents.values()]
2136
- .filter((agent) => agent.project === current.project && agent.online)
2137
- .map(publicAgent);
2173
+ .filter((agent) => agent.project === current.project && (agent.online || includeOffline))
2174
+ .map((agent) => publicAgent(agent, staleAfterMs, listedAt));
2138
2175
  json(response, 200, { agents: result });
2139
2176
  return;
2140
2177
  }
@@ -2144,7 +2181,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2144
2181
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]!));
2145
2182
  requireProjectAuth(request, current.project);
2146
2183
  await readJson(request);
2147
- json(response, 200, { agent: publicAgent(current) });
2184
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
2148
2185
  return;
2149
2186
  }
2150
2187
 
@@ -2177,7 +2214,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2177
2214
  "x-content-type-options": "nosniff",
2178
2215
  ...(presenceOnly ? { "x-kxm-events-mode": "presence" } : {}),
2179
2216
  });
2180
- response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current) })}\n\n`);
2217
+ response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}\n\n`);
2181
2218
  const client: SseClient = {
2182
2219
  response,
2183
2220
  heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
@@ -2229,6 +2266,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2229
2266
  MIN_MESSAGE_TTL_MS,
2230
2267
  MAX_MESSAGE_TTL_MS,
2231
2268
  );
2269
+ const allowOffline = parseOptionalBoolean(body.allowOffline, "allowOffline");
2232
2270
  const idempotencyKey = optionalString(body.idempotencyKey, "idempotencyKey", 128);
2233
2271
  if (idempotencyKey) {
2234
2272
  const existing = store.findMessageByIdempotency(sender.id, idempotencyKey);
@@ -2251,7 +2289,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2251
2289
  return;
2252
2290
  }
2253
2291
  }
2254
- const target = findTarget(sender.project, targetInput);
2292
+ const target = findTarget(sender.project, targetInput, allowOffline);
2255
2293
  if (target.id === sender.id) {
2256
2294
  throw new ProtocolError(400, "cannot send a request to yourself", "self_target");
2257
2295
  }
@@ -2279,7 +2317,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2279
2317
  };
2280
2318
  store.saveMessage(message);
2281
2319
  publishOps(message.project, "messages");
2282
- publish(target.id, { type: "message", message });
2320
+ if (target.online) publish(target.id, { type: "message", message });
2283
2321
  counters.messagesSent += 1;
2284
2322
  logger({ event: "message_sent", messageId: message.id, hops, ...messageLog(message, sender.id, sender.name, target.id, target.name) });
2285
2323
  json(response, 202, { message, idempotent: false });
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, join, resolve } from "node:path";
4
4
  import { DatabaseSync } from "./sqlite.ts";
5
- import type { AgentRecord, MessageRecord } from "./protocol.ts";
5
+ import type { AgentIdentity, AgentRecord, MessageRecord } from "./protocol.ts";
6
6
  import type { WorkflowRun } from "./workflow.ts";
7
7
  import { readRoutingRecords } from "./telemetry.ts";
8
8
  import type { RoutingRecord, RoutingRecordV2 } from "./routing.ts";
@@ -253,7 +253,15 @@ export function loadLocalMeshSnapshot(
253
253
  const database = new DatabaseSync(dataPath, { readOnly: true });
254
254
  try {
255
255
  database.exec("PRAGMA busy_timeout = 5000");
256
- agents = readJsonRows<AgentRecord>(database, "SELECT record FROM agents");
256
+ // Stored rows carry identity only. Presence is derived here against the
257
+ // default lease window, because the hub's configured `staleAfterMs` is
258
+ // not in the file; a reachable hub's own records replace these.
259
+ // Stored rows carry identity only. Presence is NOT computed here: the hub's
260
+ // configured staleAfterMs and its clock are not in the file, so a reader-side
261
+ // lease would mislabel agents against the hub's own projection. The stored
262
+ // `online` boolean is the only durable truth; the hub's /v1/agents or the
263
+ // ops snapshot is the authoritative presence source.
264
+ agents = readJsonRows<AgentIdentity>(database, "SELECT record FROM agents");
257
265
  openMessages = readOpenMessageMetadata(database);
258
266
  openMessageTotal = countRows(database, "messages", " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')");
259
267
  legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.69";
11
+ const VERSION = "0.7.71";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;