@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.
@@ -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]));
@@ -30,7 +30,7 @@ The \`kxm\` CLI is the unified agent surface for peer collaboration and workflow
30
30
  | Command | Purpose | Key options |
31
31
  |---|---|---|
32
32
  | \`kxm peer list\` | List online peer agents and purposes | \`--json\` |
33
- | \`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\` |
33
+ | \`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\` |
34
34
  | \`kxm peer get [messageId]\` | Check request status without blocking | \`--message-id\` |
35
35
  | \`kxm peer await [messageId]\` | Wait for reply (capped at 60 seconds) | \`--message-id\`, \`--timeout-ms\` (max 60000) |
36
36
  | \`kxm peer cancel [messageId]\` | Cancel a queued or delivered request | \`--message-id\` |