@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +7 -0
- package/docs/test-matrix.md +1 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +50 -20
- package/plugins/kxm/dist/client.js +16 -3
- package/plugins/kxm/dist/core.js +31 -6
- package/plugins/kxm/dist/extension.js +33 -9
- package/plugins/kxm/dist/mcp-server.js +34 -10
- package/plugins/kxm/dist/server.js +65 -22
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +6 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +5 -2
- package/plugins/kxm/src/cli.ts +3 -1
- package/plugins/kxm/src/client.ts +24 -2
- package/plugins/kxm/src/commands.ts +16 -4
- package/plugins/kxm/src/hub.ts +55 -17
- package/plugins/kxm/src/local-snapshot.ts +10 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +42 -1
- package/plugins/kxm/src/store.ts +5 -2
- package/plugins/kxm/src/tui.ts +20 -4
- package/scripts/emit-codex-artifacts.mjs +1 -1
|
@@ -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
|
|
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, ...
|
|
12137
|
-
return
|
|
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)
|
|
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
|
|
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({
|
|
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
|
|
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 });
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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
|
|
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
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
211
|
-
|
|
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:
|
|
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
|
},
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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)
|
|
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
|
|
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({
|
|
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
|
-
|
|
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.
|
|
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;
|