@kontextmind/kxm 0.7.70 → 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.
@@ -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.71",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.70",
3
+ "version": "0.7.71",
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.71",
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,7 @@ 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;
18364
18363
  var ProtocolError = class extends Error {
18365
18364
  statusCode;
18366
18365
  code;
@@ -18453,6 +18452,7 @@ function validateTerminalReceipt(value) {
18453
18452
  }
18454
18453
 
18455
18454
  // plugins/kxm/src/workflow.ts
18455
+ import { createHash as createHash2 } from "node:crypto";
18456
18456
  var PROMOTABLE_JOURNAL_CATEGORIES = ["skill-candidate", "hypothesis", "experiment"];
18457
18457
  var WORKFLOW_TERMINAL_TARGET = "$terminal";
18458
18458
  function normalizeOutcomeValue(value, field) {
@@ -19200,6 +19200,13 @@ var MeshWaitError = class extends Error {
19200
19200
  this.waitStatus = waitStatus;
19201
19201
  }
19202
19202
  };
19203
+ function defaultHostLabel() {
19204
+ try {
19205
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
19206
+ } catch {
19207
+ return void 0;
19208
+ }
19209
+ }
19203
19210
  function completedFanoutResult(target, message) {
19204
19211
  if (message.status === "queued" || message.status === "delivered") {
19205
19212
  throw new Error(`message ${message.id} is not complete`);
@@ -19289,8 +19296,11 @@ var HubClient = class {
19289
19296
  this.onEvent = void 0;
19290
19297
  this.eventLoop = void 0;
19291
19298
  }
19292
- async listAgents() {
19293
- const result = await this.request("/v1/agents");
19299
+ /** Online peers of this client's project. `includeOffline` also returns
19300
+ * registered members whose lease the hub has already retired. */
19301
+ async listAgents(options = {}) {
19302
+ const path4 = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
19303
+ const result = await this.request(path4);
19294
19304
  return result.agents;
19295
19305
  }
19296
19306
  async send(options) {
@@ -19530,7 +19540,8 @@ var HubClient = class {
19530
19540
  name: this.options.name,
19531
19541
  purpose: this.options.purpose,
19532
19542
  project: this.options.project,
19533
- model: this.options.model
19543
+ model: this.options.model,
19544
+ host: this.options.host ?? defaultHostLabel()
19534
19545
  })
19535
19546
  }, false);
19536
19547
  this.agent = registration.agent;
@@ -19770,14 +19781,19 @@ var AGENT_COMMANDS = [
19770
19781
  group: "peer",
19771
19782
  verb: "list",
19772
19783
  label: "List hub peers",
19773
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
19784
+ 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
19785
  parameters: {
19775
19786
  type: "object",
19776
- properties: {},
19787
+ properties: {
19788
+ includeOffline: {
19789
+ type: "boolean",
19790
+ description: "Also list registered peers whose hub lease has expired"
19791
+ }
19792
+ },
19777
19793
  additionalProperties: false
19778
19794
  },
19779
- async execute(client) {
19780
- return { agents: await client.listAgents() };
19795
+ async execute(client, args) {
19796
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
19781
19797
  }
19782
19798
  },
19783
19799
  {
@@ -26188,8 +26204,8 @@ function printWorker(runtime, worker, payload, text, outcome) {
26188
26204
  }
26189
26205
  function hostMode(runtime) {
26190
26206
  try {
26191
- const hostname = new URL(runtime.serverUrl).hostname;
26192
- if (hostname === "127.0.0.1" || hostname === "localhost" || hostname === "::1") return "local";
26207
+ const hostname2 = new URL(runtime.serverUrl).hostname;
26208
+ if (hostname2 === "127.0.0.1" || hostname2 === "localhost" || hostname2 === "::1") return "local";
26193
26209
  } catch {
26194
26210
  }
26195
26211
  return "hub";
@@ -43087,6 +43103,13 @@ function meshTuiTheme(color) {
43087
43103
  function visibleAgents(snapshot) {
43088
43104
  return snapshot.agents.filter((agent) => agent.model !== "tui");
43089
43105
  }
43106
+ function presenceCell(agent, theme, width = 7) {
43107
+ const presence = agent.presence ?? "n/a";
43108
+ const cell = pad(presence, width);
43109
+ if (presence === "online") return theme.success(cell);
43110
+ if (presence === "stale") return theme.warning(cell);
43111
+ return theme.dim(cell);
43112
+ }
43090
43113
  function panelMetric(snapshot, panel) {
43091
43114
  if (panel === "agents") {
43092
43115
  const agents = visibleAgents(snapshot);
@@ -43137,7 +43160,7 @@ function listLines(snapshot, view, theme) {
43137
43160
  const now = Date.parse(snapshot.fetchedAt);
43138
43161
  const mark = (index) => cursor(theme, index === view.selected, "list", view.pane);
43139
43162
  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)}`);
43163
+ 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
43164
  }
43142
43165
  if (view.tab === "tasks" || view.tab === "workflows") {
43143
43166
  const runs = view.tab === "tasks" ? snapshot.runs.filter((run) => run.status === "running" || run.status === "waiting") : snapshot.runs;
@@ -43175,9 +43198,10 @@ function detailLines(snapshot, view, theme) {
43175
43198
  const related = snapshot.openMessages.filter((message) => message.fromName === agent.name || message.toName === agent.name);
43176
43199
  return [
43177
43200
  theme.accent(agent.name),
43178
- `${agent.online ? theme.success("online") : theme.dim("offline")} ${agent.model ?? "-"}`,
43201
+ `${presenceCell(agent, theme, (agent.presence ?? "n/a").length)} ${agent.model ?? "-"}`,
43202
+ `host ${agent.host ?? "-"}`,
43179
43203
  agent.purpose,
43180
- `seen ${age(agent.lastSeenAt, now)} ago`,
43204
+ `seen ${age(agent.lastSeenAt, now)} ago \xB7 lease ${agent.leaseExpiresAt?.slice(11, 19) ?? "n/a"} UTC`,
43181
43205
  "",
43182
43206
  theme.dim("Open work"),
43183
43207
  ...related.length === 0 ? [theme.dim("none")] : related.map((message) => `${message.status} ${message.fromName} \u2192 ${message.toName} ${age(message.createdAt, now)}`)
@@ -43486,7 +43510,7 @@ async function runMeshTui(input) {
43486
43510
  }
43487
43511
  if (!useOpsStream && identity) {
43488
43512
  try {
43489
- const listed = await input.fetchImpl(`${base}/v1/agents`, { headers: headers(identity) });
43513
+ const listed = await input.fetchImpl(`${base}/v1/agents?includeOffline=true`, { headers: headers(identity) });
43490
43514
  if (listed.ok) {
43491
43515
  const body = await readJson(listed);
43492
43516
  const byId = new Map(local.agents.map((agent) => [agent.id, agent]));
@@ -47744,7 +47768,7 @@ function createProgram(ctx, result) {
47744
47768
  });
47745
47769
  const peer = addGlobalOptions(program2.command("peer").description("Peer agent messaging and coordination"));
47746
47770
  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) {
47771
+ 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
47772
  result.code = await dispatchAgentCliCommand(runtimeFrom(ctx, this), "kxm_list", opts ?? {});
47749
47773
  });
47750
47774
  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,13 @@
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;
9
11
 
10
12
  // plugins/kxm/src/workflow.ts
11
13
  function canonicalWorkflowEvidenceKey(value) {
@@ -21,6 +23,13 @@ var MeshWaitError = class extends Error {
21
23
  this.waitStatus = waitStatus;
22
24
  }
23
25
  };
26
+ function defaultHostLabel() {
27
+ try {
28
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
29
+ } catch {
30
+ return void 0;
31
+ }
32
+ }
24
33
  function completedFanoutResult(target, message) {
25
34
  if (message.status === "queued" || message.status === "delivered") {
26
35
  throw new Error(`message ${message.id} is not complete`);
@@ -110,8 +119,11 @@ var HubClient = class {
110
119
  this.onEvent = void 0;
111
120
  this.eventLoop = void 0;
112
121
  }
113
- async listAgents() {
114
- const result = await this.request("/v1/agents");
122
+ /** Online peers of this client's project. `includeOffline` also returns
123
+ * registered members whose lease the hub has already retired. */
124
+ async listAgents(options = {}) {
125
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
126
+ const result = await this.request(path);
115
127
  return result.agents;
116
128
  }
117
129
  async send(options) {
@@ -351,7 +363,8 @@ var HubClient = class {
351
363
  name: this.options.name,
352
364
  purpose: this.options.purpose,
353
365
  project: this.options.project,
354
- model: this.options.model
366
+ model: this.options.model,
367
+ host: this.options.host ?? defaultHostLabel()
355
368
  })
356
369
  }, false);
357
370
  this.agent = registration.agent;
@@ -12,6 +12,17 @@ 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
+ function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
17
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
18
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
19
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
20
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
21
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
22
+ }
23
+ function toAgentRecord(agent, staleAfterMs, now) {
24
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
25
+ }
15
26
  var ProtocolError = class extends Error {
16
27
  statusCode;
17
28
  code;
@@ -945,14 +956,19 @@ var AGENT_COMMANDS = [
945
956
  group: "peer",
946
957
  verb: "list",
947
958
  label: "List hub peers",
948
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
959
+ 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
960
  parameters: {
950
961
  type: "object",
951
- properties: {},
962
+ properties: {
963
+ includeOffline: {
964
+ type: "boolean",
965
+ description: "Also list registered peers whose hub lease has expired"
966
+ }
967
+ },
952
968
  additionalProperties: false
953
969
  },
954
- async execute(client) {
955
- return { agents: await client.listAgents() };
970
+ async execute(client, args) {
971
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
956
972
  }
957
973
  },
958
974
  {
@@ -2011,6 +2027,7 @@ export {
2011
2027
  DEFAULT_RATE_LIMIT_WINDOW_MS,
2012
2028
  DEFAULT_STALE_AFTER_MS,
2013
2029
  LOG_LEVEL_PRIORITY,
2030
+ MAX_AGENT_HOST_CHARS,
2014
2031
  MAX_BODY_BYTES,
2015
2032
  MAX_CONTENT_CHARS,
2016
2033
  MAX_CONTEXT_ITEM_IDS,
@@ -2026,6 +2043,7 @@ export {
2026
2043
  TERMINAL_RECEIPT_SCHEMA,
2027
2044
  WORKER_RESULT_SCHEMA,
2028
2045
  WORKER_SCHEMA,
2046
+ agentPresenceView,
2029
2047
  agentWorker,
2030
2048
  behavioralConfigHash,
2031
2049
  clearSessionTokenFromDisk,
@@ -2066,6 +2084,7 @@ export {
2066
2084
  rotateLogFiles,
2067
2085
  sessionTokenPath,
2068
2086
  timingSafeStringCompare,
2087
+ toAgentRecord,
2069
2088
  validateTerminalReceipt,
2070
2089
  workerResult
2071
2090
  };
@@ -14534,6 +14534,7 @@ import { dirname, join, resolve } from "node:path";
14534
14534
 
14535
14535
  // plugins/kxm/src/client.ts
14536
14536
  import { createHash } from "node:crypto";
14537
+ import { hostname } from "node:os";
14537
14538
 
14538
14539
  // plugins/kxm/src/protocol.ts
14539
14540
  var DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 6e4;
@@ -14541,6 +14542,7 @@ var MAX_MESSAGE_TTL_MS = 7 * 24 * 60 * 6e4;
14541
14542
  var DEFAULT_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 6e4;
14542
14543
  var MAX_BODY_BYTES = 256 * 1024;
14543
14544
  var MAX_CONTENT_CHARS = 32e3;
14545
+ var MAX_AGENT_HOST_CHARS = 64;
14544
14546
 
14545
14547
  // plugins/kxm/src/workflow.ts
14546
14548
  function canonicalWorkflowEvidenceKey(value) {
@@ -14556,6 +14558,13 @@ var MeshWaitError = class extends Error {
14556
14558
  this.waitStatus = waitStatus;
14557
14559
  }
14558
14560
  };
14561
+ function defaultHostLabel() {
14562
+ try {
14563
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
14564
+ } catch {
14565
+ return void 0;
14566
+ }
14567
+ }
14559
14568
  function completedFanoutResult(target, message) {
14560
14569
  if (message.status === "queued" || message.status === "delivered") {
14561
14570
  throw new Error(`message ${message.id} is not complete`);
@@ -14645,8 +14654,11 @@ var HubClient = class {
14645
14654
  this.onEvent = void 0;
14646
14655
  this.eventLoop = void 0;
14647
14656
  }
14648
- async listAgents() {
14649
- const result2 = await this.request("/v1/agents");
14657
+ /** Online peers of this client's project. `includeOffline` also returns
14658
+ * registered members whose lease the hub has already retired. */
14659
+ async listAgents(options = {}) {
14660
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
14661
+ const result2 = await this.request(path);
14650
14662
  return result2.agents;
14651
14663
  }
14652
14664
  async send(options) {
@@ -14886,7 +14898,8 @@ var HubClient = class {
14886
14898
  name: this.options.name,
14887
14899
  purpose: this.options.purpose,
14888
14900
  project: this.options.project,
14889
- model: this.options.model
14901
+ model: this.options.model,
14902
+ host: this.options.host ?? defaultHostLabel()
14890
14903
  })
14891
14904
  }, false);
14892
14905
  this.agent = registration.agent;
@@ -15014,14 +15027,19 @@ var AGENT_COMMANDS = [
15014
15027
  group: "peer",
15015
15028
  verb: "list",
15016
15029
  label: "List hub peers",
15017
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
15030
+ 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.",
15018
15031
  parameters: {
15019
15032
  type: "object",
15020
- properties: {},
15033
+ properties: {
15034
+ includeOffline: {
15035
+ type: "boolean",
15036
+ description: "Also list registered peers whose hub lease has expired"
15037
+ }
15038
+ },
15021
15039
  additionalProperties: false
15022
15040
  },
15023
- async execute(client) {
15024
- return { agents: await client.listAgents() };
15041
+ async execute(client, args) {
15042
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
15025
15043
  }
15026
15044
  },
15027
15045
  {
@@ -15755,12 +15755,14 @@ var StdioServerTransport = class {
15755
15755
 
15756
15756
  // plugins/kxm/src/client.ts
15757
15757
  import { createHash } from "node:crypto";
15758
+ import { hostname } from "node:os";
15758
15759
 
15759
15760
  // plugins/kxm/src/protocol.ts
15760
15761
  var DEFAULT_MESSAGE_TTL_MS = 24 * 60 * 6e4;
15761
15762
  var MAX_MESSAGE_TTL_MS = 7 * 24 * 60 * 6e4;
15762
15763
  var DEFAULT_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 6e4;
15763
15764
  var MAX_BODY_BYTES = 256 * 1024;
15765
+ var MAX_AGENT_HOST_CHARS = 64;
15764
15766
 
15765
15767
  // plugins/kxm/src/workflow.ts
15766
15768
  function canonicalWorkflowEvidenceKey(value) {
@@ -15776,6 +15778,13 @@ var MeshWaitError = class extends Error {
15776
15778
  this.waitStatus = waitStatus;
15777
15779
  }
15778
15780
  };
15781
+ function defaultHostLabel() {
15782
+ try {
15783
+ return hostname().trim().slice(0, MAX_AGENT_HOST_CHARS) || void 0;
15784
+ } catch {
15785
+ return void 0;
15786
+ }
15787
+ }
15779
15788
  function completedFanoutResult(target, message) {
15780
15789
  if (message.status === "queued" || message.status === "delivered") {
15781
15790
  throw new Error(`message ${message.id} is not complete`);
@@ -15865,8 +15874,11 @@ var HubClient = class {
15865
15874
  this.onEvent = void 0;
15866
15875
  this.eventLoop = void 0;
15867
15876
  }
15868
- async listAgents() {
15869
- const result = await this.request("/v1/agents");
15877
+ /** Online peers of this client's project. `includeOffline` also returns
15878
+ * registered members whose lease the hub has already retired. */
15879
+ async listAgents(options = {}) {
15880
+ const path = options.includeOffline ? "/v1/agents?includeOffline=true" : "/v1/agents";
15881
+ const result = await this.request(path);
15870
15882
  return result.agents;
15871
15883
  }
15872
15884
  async send(options) {
@@ -16106,7 +16118,8 @@ var HubClient = class {
16106
16118
  name: this.options.name,
16107
16119
  purpose: this.options.purpose,
16108
16120
  project: this.options.project,
16109
- model: this.options.model
16121
+ model: this.options.model,
16122
+ host: this.options.host ?? defaultHostLabel()
16110
16123
  })
16111
16124
  }, false);
16112
16125
  this.agent = registration.agent;
@@ -16325,14 +16338,19 @@ var AGENT_COMMANDS = [
16325
16338
  group: "peer",
16326
16339
  verb: "list",
16327
16340
  label: "List hub peers",
16328
- description: "List online peer agents in this project's hub pool, including their names and purposes.",
16341
+ 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.",
16329
16342
  parameters: {
16330
16343
  type: "object",
16331
- properties: {},
16344
+ properties: {
16345
+ includeOffline: {
16346
+ type: "boolean",
16347
+ description: "Also list registered peers whose hub lease has expired"
16348
+ }
16349
+ },
16332
16350
  additionalProperties: false
16333
16351
  },
16334
- async execute(client) {
16335
- return { agents: await client.listAgents() };
16352
+ async execute(client, args) {
16353
+ return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
16336
16354
  }
16337
16355
  },
16338
16356
  {
@@ -17127,7 +17145,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
17127
17145
  }
17128
17146
 
17129
17147
  // plugins/kxm/src/mcp-server.ts
17130
- var VERSION = "0.7.70";
17148
+ var VERSION = "0.7.71";
17131
17149
  var inbox = /* @__PURE__ */ new Map();
17132
17150
  var notifiedInbox = /* @__PURE__ */ new Set();
17133
17151
  var meshClient;
@@ -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
  {
@@ -12138,9 +12154,9 @@ function isLoopback(host2) {
12138
12154
  if (host2 === "localhost" || host2 === "::1") return true;
12139
12155
  return isIP(host2) === 4 && host2.startsWith("127.");
12140
12156
  }
12141
- function publicAgent(agent) {
12142
- const { key: _key, ...record } = agent;
12143
- return record;
12157
+ function publicAgent(agent, staleAfterMs, now = Date.now()) {
12158
+ const { key: _key, ...identity } = agent;
12159
+ return toAgentRecord(identity, staleAfterMs, now);
12144
12160
  }
12145
12161
  function safeTokenEqual(actual, expected) {
12146
12162
  return timingSafeStringCompare(actual, expected);
@@ -12560,7 +12576,8 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12560
12576
  }
12561
12577
  }
12562
12578
  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));
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));
12564
12581
  const open = store.getOpenMessages(project);
12565
12582
  const projectRuns = [...workflowRuns.values()].filter((run) => run.project === project).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
12566
12583
  return {
@@ -12610,7 +12627,7 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12610
12627
  function broadcastPresence(agent) {
12611
12628
  for (const candidate of agents.values()) {
12612
12629
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
12613
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
12630
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
12614
12631
  }
12615
12632
  }
12616
12633
  publishOps(agent.project, "agents");
@@ -13912,6 +13929,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13912
13929
  const project = requireString(body.project, "project", { max: 128 });
13913
13930
  requireProjectAuth(request, project);
13914
13931
  const model = optionalString(body.model, "model", 128);
13932
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
13915
13933
  const existing = [...agents.values()].find(
13916
13934
  (agent2) => agent2.project === project && agent2.name.toLowerCase() === name.toLowerCase()
13917
13935
  );
@@ -13938,17 +13956,27 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13938
13956
  agent.online = true;
13939
13957
  if (model) agent.model = model;
13940
13958
  else delete agent.model;
13959
+ if (hostLabel) agent.host = hostLabel;
13960
+ else delete agent.host;
13941
13961
  store.saveAgent(agent);
13942
13962
  counters.registrations += 1;
13943
- 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
+ });
13944
13970
  broadcastPresence(agent);
13945
- 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) });
13946
13972
  return;
13947
13973
  }
13948
13974
  if (method === "GET" && url.pathname === "/v1/agents") {
13949
13975
  const current = requireAgent(request);
13950
13976
  requireProjectAuth(request, current.project);
13951
- 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));
13952
13980
  json(response, 200, { agents: result });
13953
13981
  return;
13954
13982
  }
@@ -13957,7 +13985,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13957
13985
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]));
13958
13986
  requireProjectAuth(request, current.project);
13959
13987
  await readJson(request);
13960
- json(response, 200, { agent: publicAgent(current) });
13988
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
13961
13989
  return;
13962
13990
  }
13963
13991
  const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
@@ -13989,7 +14017,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
13989
14017
  ...presenceOnly ? { "x-kxm-events-mode": "presence" } : {}
13990
14018
  });
13991
14019
  response.write(`event: ready
13992
- data: ${JSON.stringify({ agent: publicAgent(current) })}
14020
+ data: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}
13993
14021
 
13994
14022
  `);
13995
14023
  const client = {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.70",
3
+ "version": "0.7.71",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -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 ?? {});
@@ -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;
@@ -94,6 +100,17 @@ class MeshWaitError extends Error {
94
100
  }
95
101
  }
96
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
+
97
114
  function completedFanoutResult(target: string, message: MessageRecord): FanoutResult {
98
115
  if (message.status === "queued" || message.status === "delivered") {
99
116
  throw new Error(`message ${message.id} is not complete`);
@@ -208,8 +225,11 @@ export class HubClient {
208
225
  this.eventLoop = undefined;
209
226
  }
210
227
 
211
- async listAgents(): Promise<AgentRecord[]> {
212
- 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);
213
233
  return result.agents;
214
234
  }
215
235
 
@@ -555,6 +575,7 @@ export class HubClient {
555
575
  purpose: this.options.purpose,
556
576
  project: this.options.project,
557
577
  model: this.options.model,
578
+ host: this.options.host ?? defaultHostLabel(),
558
579
  }),
559
580
  }, false);
560
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
  {
@@ -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 {
@@ -623,9 +628,10 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
623
628
  }
624
629
 
625
630
  function opsSnapshot(project: string) {
631
+ const snapshotAt = Date.now();
626
632
  const projectAgents = [...agents.values()]
627
633
  .filter((agent) => agent.project === project)
628
- .map(publicAgent)
634
+ .map((agent) => publicAgent(agent, staleAfterMs, snapshotAt))
629
635
  .sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
630
636
  const open = store.getOpenMessages(project);
631
637
  const projectRuns = [...workflowRuns.values()]
@@ -683,7 +689,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
683
689
  function broadcastPresence(agent: StoredAgent): void {
684
690
  for (const candidate of agents.values()) {
685
691
  if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
686
- publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
692
+ publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
687
693
  }
688
694
  }
689
695
  publishOps(agent.project, "agents");
@@ -2109,6 +2115,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2109
2115
  const project = requireString(body.project, "project", { max: 128 });
2110
2116
  requireProjectAuth(request, project);
2111
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);
2112
2121
  const existing = [...agents.values()].find(
2113
2122
  (agent) => agent.project === project && agent.name.toLowerCase() === name.toLowerCase(),
2114
2123
  );
@@ -2135,20 +2144,34 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2135
2144
  agent.online = true;
2136
2145
  if (model) agent.model = model;
2137
2146
  else delete agent.model;
2147
+ if (hostLabel) agent.host = hostLabel;
2148
+ else delete agent.host;
2138
2149
  store.saveAgent(agent);
2139
2150
  counters.registrations += 1;
2140
- 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
+ });
2141
2158
  broadcastPresence(agent);
2142
- 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) });
2143
2160
  return;
2144
2161
  }
2145
2162
 
2146
2163
  if (method === "GET" && url.pathname === "/v1/agents") {
2147
2164
  const current = requireAgent(request);
2148
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();
2149
2172
  const result = [...agents.values()]
2150
- .filter((agent) => agent.project === current.project && agent.online)
2151
- .map(publicAgent);
2173
+ .filter((agent) => agent.project === current.project && (agent.online || includeOffline))
2174
+ .map((agent) => publicAgent(agent, staleAfterMs, listedAt));
2152
2175
  json(response, 200, { agents: result });
2153
2176
  return;
2154
2177
  }
@@ -2158,7 +2181,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2158
2181
  const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]!));
2159
2182
  requireProjectAuth(request, current.project);
2160
2183
  await readJson(request);
2161
- json(response, 200, { agent: publicAgent(current) });
2184
+ json(response, 200, { agent: publicAgent(current, staleAfterMs) });
2162
2185
  return;
2163
2186
  }
2164
2187
 
@@ -2191,7 +2214,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2191
2214
  "x-content-type-options": "nosniff",
2192
2215
  ...(presenceOnly ? { "x-kxm-events-mode": "presence" } : {}),
2193
2216
  });
2194
- 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`);
2195
2218
  const client: SseClient = {
2196
2219
  response,
2197
2220
  heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
@@ -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.70";
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;
@@ -12,21 +12,62 @@ export const DEFAULT_RATE_LIMIT_MAX = 600;
12
12
  export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
13
13
  export const MAX_BODY_BYTES = 256 * 1024;
14
14
  export const MAX_CONTENT_CHARS = 32_000;
15
+ export const MAX_AGENT_HOST_CHARS = 64;
15
16
 
16
17
  export type DeliveryMode = "steer" | "followUp" | "nextTurn";
17
18
  export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
19
+ export type AgentPresence = "online" | "stale" | "offline";
18
20
 
19
- export interface AgentRecord {
21
+ /** The durable half of an agent: exactly the fields the hub persists in the
22
+ * agents record JSON. Presence is never stored, so a restored database can
23
+ * never claim an agent was alive. */
24
+ export interface AgentIdentity {
20
25
  id: string;
21
26
  name: string;
22
27
  purpose: string;
23
28
  project: string;
24
29
  model?: string;
30
+ /** Client-declared label for the box this agent runs on. It exists so a
31
+ * reader can tell two boxes apart; the hub never reads it for
32
+ * authorization, project scope, or target resolution. */
33
+ host?: string;
25
34
  connectedAt: string;
26
35
  lastSeenAt: string;
27
36
  online: boolean;
28
37
  }
29
38
 
39
+ /** The public projection of an agent: durable identity plus presence derived
40
+ * from the hub clock at read time. */
41
+ export interface AgentRecord extends AgentIdentity {
42
+ /** `lastSeenAt + staleAfterMs`: the heartbeat lease the stale sweep enforces.
43
+ * Absent when projected from the local store without the hub's clock or lease. */
44
+ leaseExpiresAt?: string;
45
+ /** Hub-clocked presence. Absent when the reader has no hub projection. */
46
+ presence?: AgentPresence;
47
+ }
48
+
49
+ /** Presence is hub-clocked and never client-reported. An agent holds its lease
50
+ * until `lastSeenAt + staleAfterMs`; between lease expiry and the sweep that
51
+ * retires it, it reads `stale`; once retired it reads `offline`. An
52
+ * unparseable `lastSeenAt` leaves the lease at the epoch, which reads as an
53
+ * expired lease rather than as a live agent. */
54
+ export function agentPresenceView(
55
+ agent: AgentIdentity,
56
+ staleAfterMs: number = DEFAULT_STALE_AFTER_MS,
57
+ now: number = Date.now(),
58
+ ): { leaseExpiresAt: string; presence: AgentPresence } {
59
+ const lastSeenMs = Date.parse(agent.lastSeenAt);
60
+ const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
61
+ const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
62
+ if (!agent.online) return { leaseExpiresAt, presence: "offline" };
63
+ return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
64
+ }
65
+
66
+ /** Project a durable identity onto the wire shape readers consume. */
67
+ export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?: number): AgentRecord {
68
+ return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
69
+ }
70
+
30
71
  export interface MessageReply {
31
72
  content: string;
32
73
  createdAt: string;
@@ -1,6 +1,6 @@
1
1
  import { resolve } from "node:path";
2
2
  import type { DatabaseSync } from "./sqlite.ts";
3
- import type { AgentRecord, MessageRecord } from "./protocol.ts";
3
+ import type { AgentIdentity, MessageRecord } from "./protocol.ts";
4
4
  import type { ContextItem } from "./context.ts";
5
5
  import type { WorkflowJournalEntry, WorkflowRun } from "./workflow.ts";
6
6
  import {
@@ -78,7 +78,10 @@ export const HUB_STORE_SCHEMA_SPEC: DatabaseSchemaSpec = Object.freeze({
78
78
  tables: HUB_STORE_TABLES,
79
79
  });
80
80
 
81
- export interface StoredAgent extends AgentRecord {
81
+ /** The stored shape stays the durable identity plus the agent key: presence
82
+ * and the lease are derived on read, so the agents record JSON grows only by
83
+ * the additive `host` label. */
84
+ export interface StoredAgent extends AgentIdentity {
82
85
  key: string;
83
86
  }
84
87
 
@@ -310,6 +310,19 @@ function visibleAgents(snapshot: MeshTuiSnapshot): AgentRecord[] {
310
310
  return snapshot.agents.filter((agent) => agent.model !== "tui");
311
311
  }
312
312
 
313
+ /** Presence comes from the hub clock, so the dash colours it rather than
314
+ * recomputing it: green holds a lease, amber has lost it but has not been
315
+ * swept, dim is a registered peer that is gone. */
316
+ function presenceCell(agent: AgentRecord, theme: MeshTuiTheme, width = 7): string {
317
+ // Local-store projections carry no hub-clocked presence; the dash shows "n/a"
318
+ // rather than inventing a lease from the reader's clock.
319
+ const presence = agent.presence ?? "n/a";
320
+ const cell = pad(presence, width);
321
+ if (presence === "online") return theme.success(cell);
322
+ if (presence === "stale") return theme.warning(cell);
323
+ return theme.dim(cell);
324
+ }
325
+
313
326
  function panelMetric(snapshot: MeshTuiSnapshot, panel: MeshTuiPanel): string {
314
327
  if (panel === "agents") {
315
328
  const agents = visibleAgents(snapshot);
@@ -441,7 +454,7 @@ function listLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTuiT
441
454
  const mark = (index: number) => cursor(theme, index === view.selected, "list", view.pane);
442
455
  if (view.tab === "agents") {
443
456
  return visibleAgents(snapshot).map((agent, index) => (
444
- `${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)}`
457
+ `${mark(index)}${pad(agent.name, 14)} ${presenceCell(agent, theme)} ${pad(agent.host ?? "-", 12)} ${pad(agent.model ?? "-", 14)} ${pad(age(agent.lastSeenAt, now), 4)}`
445
458
  ));
446
459
  }
447
460
  if (view.tab === "tasks" || view.tab === "workflows") {
@@ -489,9 +502,10 @@ function detailLines(snapshot: MeshTuiSnapshot, view: MeshTuiView, theme: MeshTu
489
502
  const related = snapshot.openMessages.filter((message) => message.fromName === agent.name || message.toName === agent.name);
490
503
  return [
491
504
  theme.accent(agent.name),
492
- `${agent.online ? theme.success("online") : theme.dim("offline")} ${agent.model ?? "-"}`,
505
+ `${presenceCell(agent, theme, (agent.presence ?? "n/a").length)} ${agent.model ?? "-"}`,
506
+ `host ${agent.host ?? "-"}`,
493
507
  agent.purpose,
494
- `seen ${age(agent.lastSeenAt, now)} ago`,
508
+ `seen ${age(agent.lastSeenAt, now)} ago · lease ${agent.leaseExpiresAt?.slice(11, 19) ?? "n/a"} UTC`,
495
509
  "",
496
510
  theme.dim("Open work"),
497
511
  ...(related.length === 0 ? [theme.dim("none")] : related.map((message) => `${message.status} ${message.fromName} → ${message.toName} ${age(message.createdAt, now)}`)),
@@ -875,7 +889,9 @@ export async function runMeshTui(input: {
875
889
  }
876
890
  if (!useOpsStream && identity) {
877
891
  try {
878
- const listed = await input.fetchImpl(`${base}/v1/agents`, { headers: headers(identity) });
892
+ // Offline members too: the ops snapshot lists the whole project, and
893
+ // the legacy path is a fallback for the same screen, not a narrower one.
894
+ const listed = await input.fetchImpl(`${base}/v1/agents?includeOffline=true`, { headers: headers(identity) });
879
895
  if (listed.ok) {
880
896
  const body = await readJson<{ agents: AgentRecord[] }>(listed);
881
897
  const byId = new Map(local.agents.map((agent) => [agent.id, agent]));