@kontextmind/kxm 0.7.101 → 0.7.102
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 +8 -0
- package/docs/concepts/architecture.md +1 -1
- package/docs/contributing/test-matrix.md +1 -0
- package/docs/guides/peer-messaging.md +2 -2
- package/docs/reference/tools.md +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/README.md +1 -1
- package/plugins/kxm/dist/mcp-server.js +39 -22
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/inbox.ts +9 -1
- package/plugins/kxm/src/mcp-server.ts +44 -21
package/CHANGELOG.md
CHANGED
|
@@ -385,6 +385,14 @@ All notable user-facing changes are documented here. The project follows [Semant
|
|
|
385
385
|
`codex`) to list requests peers queued for it while it was offline, then answer them
|
|
386
386
|
with `kxm peer reply`. The Pi extension's `kxm_inbox` tool now refuses instead of
|
|
387
387
|
returning an empty list, because Pi activates each inbound request as a turn itself.
|
|
388
|
+
- **A restarted Claude Code session keeps the requests it acknowledged but never answered.**
|
|
389
|
+
The MCP server acknowledges a request on arrival, and the hub pushes only unacknowledged
|
|
390
|
+
requests again on reconnect, so a session that restarted under the same agent name (the
|
|
391
|
+
plugin's `agent_name`) resumed its agent id but lost those requests from `kxm_inbox` and
|
|
392
|
+
the channel until they expired. After it registers, the server now reads them back from
|
|
393
|
+
the hub into `kxm_inbox` and announces each once as a channel event; one cancelled or
|
|
394
|
+
expired meanwhile is dropped, not announced. A tool call waits for that read, and a failed
|
|
395
|
+
read fails the call instead of listing a partial inbox.
|
|
388
396
|
|
|
389
397
|
- **The Claude plugin's SessionStart hook is one bundled, read-only, project-scoped
|
|
390
398
|
script.** The two shell hooks it replaces (`kxm session brief --status` and
|
|
@@ -90,7 +90,7 @@ stateDiagram-v2
|
|
|
90
90
|
|
|
91
91
|
- The sender gets the message ID at once. The recipient may reply straight from `queued` without acknowledging first.
|
|
92
92
|
- `error` is declared in the protocol, but the hub never sets it. Treat it as reserved.
|
|
93
|
-
- Open messages survive a hub restart. When an agent reconnects under the same project and name, the hub rotates its key and pushes every `queued` message again with its original ID. A `delivered` message is not pushed again; it stays open until a reply, cancellation, or expiry. Clients suppress a second turn for an ID they already handle.
|
|
93
|
+
- Open messages survive a hub restart. When an agent reconnects under the same project and name, the hub rotates its key and pushes every `queued` message again with its original ID. A `delivered` message is not pushed again; it stays open until a reply, cancellation, or expiry. The Claude Code MCP server reads its agent's `delivered` messages back from the hub when it registers, so a session restarted under the same name still lists and announces them. Clients suppress a second turn for an ID they already handle.
|
|
94
94
|
- An idempotency key deduplicates an exact retry by the same sender. It does not stop the recipient from repeating a side effect, so handlers must be safe to repeat.
|
|
95
95
|
- The default TTL is 24 hours (at most 7 days). Terminal messages are purged after the retention window, 7 days by default.
|
|
96
96
|
- If a workflow coordinator's prompt expires before its run finishes, the run fails.
|
|
@@ -20,6 +20,7 @@ to run one file is in [Develop KXM](development.md#run-one-file-or-one-test).
|
|
|
20
20
|
| Delivery modes, message fields, hop limits and validation | `hub-api.test.ts`, `protocol.test.ts` |
|
|
21
21
|
| Queue, acknowledgement, visibility, reply and authorization | `hub-api.test.ts`, `hub.test.ts` |
|
|
22
22
|
| An unacknowledged (queued) message replays after a recipient restart as the same record | `hub.test.ts`, `extension.test.ts`, `mcp.test.ts` |
|
|
23
|
+
| An acknowledged, unanswered request survives a Claude Code restart under the same agent name and is announced once; one cancelled during the restart is not announced | `mcp.test.ts`, `inbox.test.ts` |
|
|
23
24
|
| An `allowOffline` send queues, delivers once on resumption, and expires unread by TTL | `hub-api.test.ts` |
|
|
24
25
|
| TTL expiry, sender cancellation and terminal retention | `hub-api.test.ts` |
|
|
25
26
|
| Exact-retry idempotency, and rejection of a reused key with different content | `hub-api.test.ts` |
|
|
@@ -37,7 +37,7 @@ stateDiagram-v2
|
|
|
37
37
|
| State | Meaning |
|
|
38
38
|
|---|---|
|
|
39
39
|
| `queued` | Accepted and stored. Survives hub and agent restarts. The hub pushes it again each time the recipient reconnects, until the recipient acknowledges it. |
|
|
40
|
-
| `delivered` | Acknowledged: by Pi when the model turn starts, by the Claude Code plugin on arrival. It is not pushed again after a reconnect. |
|
|
40
|
+
| `delivered` | Acknowledged: by Pi when the model turn starts, by the Claude Code plugin on arrival. It is not pushed again after a reconnect; a Claude Code session that restarts under the same agent name reads it back from the hub. |
|
|
41
41
|
| `replied`, `cancelled`, `expired` | Terminal. A later reply or cancel is refused. |
|
|
42
42
|
| `error` | Declared in the protocol but never set by the hub. Treat it as reserved. |
|
|
43
43
|
|
|
@@ -288,7 +288,7 @@ Errors about `workflowContext` are covered in [Peer provenance and quorum gates]
|
|
|
288
288
|
| Symptom | Cause | Fix |
|
|
289
289
|
|---|---|---|
|
|
290
290
|
| A request stays `queued` | The recipient is offline, busy with earlier work, or swapping its Pi session for a workflow run. | Check `kxm_list` and the recipient's log. Do not send a duplicate. |
|
|
291
|
-
| A request stays `delivered` | The recipient's turn, tool, or provider call is still running, or
|
|
291
|
+
| A request stays `delivered` | The recipient's turn, tool, or provider call is still running, or the Claude Code session that acknowledged it came back under another agent name: `claude-<pid>` when `agent_name` is empty, or `<name>-<pid>` while the old session was still online. | Wait, or cancel and send it again with a new idempotency key. Keep the plugin's `agent_name` set, so a restarted session reads its requests back. |
|
|
292
292
|
| `kxm_fanout` returns `pending` | The local wait ended before a reply. | Use the returned message IDs with `kxm_get`, or repeat the exact call. |
|
|
293
293
|
| Claude Code never sees requests | Channel mode is off or blocked by policy. | Use `kxm_inbox` and `kxm_reply`. |
|
|
294
294
|
| `kxm peer inbox` is always empty | `KXM_AGENT_NAME` is unset, so each call registers a new `cli-<pid>` agent that nobody has addressed. | Set `KXM_AGENT_NAME` to the name peers send to. |
|
package/docs/reference/tools.md
CHANGED
|
@@ -161,7 +161,7 @@ Returns the message record with `status: "cancelled"`. Cancelling an already can
|
|
|
161
161
|
|
|
162
162
|
Lists inbound requests that still need a reply. It takes no parameters.
|
|
163
163
|
|
|
164
|
-
- **Claude Code:** returns `{ "messages": [...] }` from the session's inbox, which fills from the hub's event stream while the session is registered. Before returning, it re-reads each request and drops those already answered, cancelled or expired. This is pull mode; see [Pushed channel mode and pull mode](../../plugins/kxm/README.md#pushed-channel-mode-and-pull-mode).
|
|
164
|
+
- **Claude Code:** returns `{ "messages": [...] }` from the session's inbox, which fills from the hub's event stream while the session is registered. A session that restarts under the same agent name also reads back the requests the previous session acknowledged but never answered. Before returning, it re-reads each request and drops those already answered, cancelled or expired. This is pull mode; see [Pushed channel mode and pull mode](../../plugins/kxm/README.md#pushed-channel-mode-and-pull-mode).
|
|
165
165
|
- **Pi:** refuses. The extension turns each inbound request into a model turn itself, and that turn's final response is the reply, so listing would offer requests its own queue is about to activate.
|
|
166
166
|
- **CLI:** `kxm peer inbox` reads the agent's open requests from the hub (`GET /v1/agents/<agentId>/inbox`), acknowledging nothing. Run it with a stable `KXM_AGENT_NAME` to see requests queued for that name while it was offline; the default `cli-<pid>` is a new agent on every call, so its list is empty.
|
|
167
167
|
|
package/package.json
CHANGED
|
@@ -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.
|
|
5
|
+
"version": "0.7.102",
|
|
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",
|
package/plugins/kxm/README.md
CHANGED
|
@@ -160,7 +160,7 @@ Workflow authors can require replies from a snapshotted set of eligible peers. T
|
|
|
160
160
|
|
|
161
161
|
Peer requests reach Claude in one of two ways. Everything else, including `kxm_send`, `kxm_fanout`, the workflow tools and the context tools, works the same in both.
|
|
162
162
|
|
|
163
|
-
**Pull mode** is the default. Claude checks for work with `kxm_inbox`, handles one request, and answers it with `kxm_reply` and the request's message ID. Nothing arrives on its own: ask Claude to check the inbox, or to poll it with a backoff while it waits. The MCP server keeps the inbox, filling it from the hub's event stream while it is registered; in a KXM project with a project token that starts with the session.
|
|
163
|
+
**Pull mode** is the default. Claude checks for work with `kxm_inbox`, handles one request, and answers it with `kxm_reply` and the request's message ID. Nothing arrives on its own: ask Claude to check the inbox, or to poll it with a backoff while it waits. The MCP server keeps the inbox, filling it from the hub's event stream while it is registered; in a KXM project with a project token that starts with the session. A session that restarts under the same `agent_name` also reads back the requests the previous session acknowledged but never answered, and announces each once on the channel.
|
|
164
164
|
|
|
165
165
|
**Pushed channel mode** uses Claude Code channels to inject each peer request into the running session as a `<channel source="kxm" message_id="...">` event, which Claude handles and answers with `kxm_reply`. During the channels research preview, start Claude Code with the community channel explicitly and review the trust prompt:
|
|
166
166
|
|
|
@@ -17294,8 +17294,13 @@ function enforceToolPolicy(commandName, env = process.env, options) {
|
|
|
17294
17294
|
// plugins/kxm/src/inbox.ts
|
|
17295
17295
|
async function deliverInboxNotification(messageId, delivered, notify) {
|
|
17296
17296
|
if (delivered.has(messageId)) return false;
|
|
17297
|
-
await notify();
|
|
17298
17297
|
delivered.add(messageId);
|
|
17298
|
+
try {
|
|
17299
|
+
await notify();
|
|
17300
|
+
} catch (error2) {
|
|
17301
|
+
delivered.delete(messageId);
|
|
17302
|
+
throw error2;
|
|
17303
|
+
}
|
|
17299
17304
|
return true;
|
|
17300
17305
|
}
|
|
17301
17306
|
|
|
@@ -17308,7 +17313,7 @@ function sessionTokenFixHint(policy) {
|
|
|
17308
17313
|
}
|
|
17309
17314
|
|
|
17310
17315
|
// plugins/kxm/src/mcp-server.ts
|
|
17311
|
-
var VERSION = "0.7.
|
|
17316
|
+
var VERSION = "0.7.102";
|
|
17312
17317
|
var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
17313
17318
|
var inbox = /* @__PURE__ */ new Map();
|
|
17314
17319
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
@@ -17344,39 +17349,50 @@ function sessionIdentity() {
|
|
|
17344
17349
|
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331"
|
|
17345
17350
|
};
|
|
17346
17351
|
}
|
|
17347
|
-
async function
|
|
17348
|
-
if (event.type === "cancelled" || event.type === "expired") {
|
|
17349
|
-
inbox.delete(event.message.id);
|
|
17350
|
-
notifiedInbox.delete(event.message.id);
|
|
17351
|
-
return;
|
|
17352
|
-
}
|
|
17353
|
-
if (event.type !== "message") return;
|
|
17354
|
-
const client = meshClient;
|
|
17355
|
-
if (!client) return;
|
|
17356
|
-
if (event.message.status === "queued") await client.acknowledge(event.message.id);
|
|
17357
|
-
inbox.set(event.message.id, event.message);
|
|
17352
|
+
async function announce(message) {
|
|
17358
17353
|
const meta2 = {
|
|
17359
|
-
message_id:
|
|
17360
|
-
from_agent:
|
|
17361
|
-
delivery:
|
|
17354
|
+
message_id: message.id,
|
|
17355
|
+
from_agent: message.fromName,
|
|
17356
|
+
delivery: message.delivery
|
|
17362
17357
|
};
|
|
17363
|
-
if (
|
|
17364
|
-
await deliverInboxNotification(
|
|
17358
|
+
if (message.correlationId) meta2.correlation_id = message.correlationId;
|
|
17359
|
+
await deliverInboxNotification(message.id, notifiedInbox, async () => {
|
|
17365
17360
|
await mcp.notification({
|
|
17366
17361
|
method: "notifications/claude/channel",
|
|
17367
17362
|
params: {
|
|
17368
17363
|
content: [
|
|
17369
|
-
`Peer request from ${
|
|
17364
|
+
`Peer request from ${message.fromName}:`,
|
|
17370
17365
|
"",
|
|
17371
|
-
|
|
17366
|
+
message.content,
|
|
17372
17367
|
"",
|
|
17373
|
-
`When complete, call kxm_reply with messageId ${
|
|
17368
|
+
`When complete, call kxm_reply with messageId ${message.id}.`
|
|
17374
17369
|
].join("\n"),
|
|
17375
17370
|
meta: meta2
|
|
17376
17371
|
}
|
|
17377
17372
|
});
|
|
17378
17373
|
});
|
|
17379
17374
|
}
|
|
17375
|
+
async function onHubEvent(client, event) {
|
|
17376
|
+
if (event.type === "cancelled" || event.type === "expired") {
|
|
17377
|
+
inbox.delete(event.message.id);
|
|
17378
|
+
notifiedInbox.delete(event.message.id);
|
|
17379
|
+
return;
|
|
17380
|
+
}
|
|
17381
|
+
if (event.type !== "message") return;
|
|
17382
|
+
if (event.message.status === "queued") await client.acknowledge(event.message.id);
|
|
17383
|
+
inbox.set(event.message.id, event.message);
|
|
17384
|
+
await announce(event.message);
|
|
17385
|
+
}
|
|
17386
|
+
async function seedInbox(client) {
|
|
17387
|
+
for (const message of await client.listInbox()) {
|
|
17388
|
+
if (message.status === "delivered" && !inbox.has(message.id)) inbox.set(message.id, message);
|
|
17389
|
+
}
|
|
17390
|
+
await reconcileInbox(client, inbox, notifiedInbox);
|
|
17391
|
+
for (const messageId of [...inbox.keys()]) {
|
|
17392
|
+
const message = inbox.get(messageId);
|
|
17393
|
+
if (message) await announce(message);
|
|
17394
|
+
}
|
|
17395
|
+
}
|
|
17380
17396
|
async function startClient(project, serverUrl, name, authToken) {
|
|
17381
17397
|
const candidate = new HubClient({
|
|
17382
17398
|
serverUrl,
|
|
@@ -17387,7 +17403,8 @@ async function startClient(project, serverUrl, name, authToken) {
|
|
|
17387
17403
|
authToken
|
|
17388
17404
|
});
|
|
17389
17405
|
try {
|
|
17390
|
-
await candidate.start(onHubEvent);
|
|
17406
|
+
await candidate.start((event) => onHubEvent(candidate, event));
|
|
17407
|
+
await seedInbox(candidate);
|
|
17391
17408
|
meshClient = candidate;
|
|
17392
17409
|
return candidate;
|
|
17393
17410
|
} catch (error2) {
|
package/plugins/kxm/package.json
CHANGED
package/plugins/kxm/src/inbox.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
|
+
/** Announce an inbox request at most once per process. The id is claimed before the send, so a
|
|
2
|
+
* request read back from the hub and the same request arriving on the event stream cannot both
|
|
3
|
+
* announce it; a failed send releases the claim, so the request stays retryable. */
|
|
1
4
|
export async function deliverInboxNotification(
|
|
2
5
|
messageId: string,
|
|
3
6
|
delivered: Set<string>,
|
|
4
7
|
notify: () => Promise<void>,
|
|
5
8
|
): Promise<boolean> {
|
|
6
9
|
if (delivered.has(messageId)) return false;
|
|
7
|
-
await notify();
|
|
8
10
|
delivered.add(messageId);
|
|
11
|
+
try {
|
|
12
|
+
await notify();
|
|
13
|
+
} catch (error) {
|
|
14
|
+
delivered.delete(messageId);
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
9
17
|
return true;
|
|
10
18
|
}
|
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.102";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -55,33 +55,24 @@ function sessionIdentity(): { projectDir: string; project: string; serverUrl: st
|
|
|
55
55
|
};
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
inbox.delete(event.message.id);
|
|
61
|
-
notifiedInbox.delete(event.message.id);
|
|
62
|
-
return;
|
|
63
|
-
}
|
|
64
|
-
if (event.type !== "message") return;
|
|
65
|
-
const client = meshClient;
|
|
66
|
-
if (!client) return;
|
|
67
|
-
if (event.message.status === "queued") await client.acknowledge(event.message.id);
|
|
68
|
-
inbox.set(event.message.id, event.message);
|
|
58
|
+
/** Tell the session about an inbox request on the channel, at most once per process. */
|
|
59
|
+
async function announce(message: MessageRecord): Promise<void> {
|
|
69
60
|
const meta: Record<string, string> = {
|
|
70
|
-
message_id:
|
|
71
|
-
from_agent:
|
|
72
|
-
delivery:
|
|
61
|
+
message_id: message.id,
|
|
62
|
+
from_agent: message.fromName,
|
|
63
|
+
delivery: message.delivery,
|
|
73
64
|
};
|
|
74
|
-
if (
|
|
75
|
-
await deliverInboxNotification(
|
|
65
|
+
if (message.correlationId) meta.correlation_id = message.correlationId;
|
|
66
|
+
await deliverInboxNotification(message.id, notifiedInbox, async () => {
|
|
76
67
|
await mcp.notification({
|
|
77
68
|
method: "notifications/claude/channel",
|
|
78
69
|
params: {
|
|
79
70
|
content: [
|
|
80
|
-
`Peer request from ${
|
|
71
|
+
`Peer request from ${message.fromName}:`,
|
|
81
72
|
"",
|
|
82
|
-
|
|
73
|
+
message.content,
|
|
83
74
|
"",
|
|
84
|
-
`When complete, call kxm_reply with messageId ${
|
|
75
|
+
`When complete, call kxm_reply with messageId ${message.id}.`,
|
|
85
76
|
].join("\n"),
|
|
86
77
|
meta,
|
|
87
78
|
},
|
|
@@ -89,6 +80,36 @@ async function onHubEvent(event: HubEvent): Promise<void> {
|
|
|
89
80
|
});
|
|
90
81
|
}
|
|
91
82
|
|
|
83
|
+
async function onHubEvent(client: HubClient, event: HubEvent): Promise<void> {
|
|
84
|
+
if (event.type === "cancelled" || event.type === "expired") {
|
|
85
|
+
inbox.delete(event.message.id);
|
|
86
|
+
notifiedInbox.delete(event.message.id);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
if (event.type !== "message") return;
|
|
90
|
+
if (event.message.status === "queued") await client.acknowledge(event.message.id);
|
|
91
|
+
inbox.set(event.message.id, event.message);
|
|
92
|
+
await announce(event.message);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** A durable KXM_AGENT_NAME resumes its agent id, but the event stream replays only requests
|
|
96
|
+
* that agent has not acknowledged. One an earlier process acknowledged and never answered is
|
|
97
|
+
* still open on the hub, so it is read back here and announced like a pushed request: this
|
|
98
|
+
* session has not been told about it. The inbox is reconciled before anything is announced,
|
|
99
|
+
* so a request cancelled or expired while the list was in flight is dropped, not announced;
|
|
100
|
+
* its event may have gone to a stream that was not connected yet. Queued requests are left
|
|
101
|
+
* to the stream, which acknowledges them in order. */
|
|
102
|
+
async function seedInbox(client: HubClient): Promise<void> {
|
|
103
|
+
for (const message of await client.listInbox()) {
|
|
104
|
+
if (message.status === "delivered" && !inbox.has(message.id)) inbox.set(message.id, message);
|
|
105
|
+
}
|
|
106
|
+
await reconcileInbox(client, inbox, notifiedInbox);
|
|
107
|
+
for (const messageId of [...inbox.keys()]) {
|
|
108
|
+
const message = inbox.get(messageId);
|
|
109
|
+
if (message) await announce(message);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
async function startClient(project: string, serverUrl: string, name: string, authToken: string): Promise<HubClient> {
|
|
93
114
|
const candidate = new HubClient({
|
|
94
115
|
serverUrl,
|
|
@@ -99,7 +120,9 @@ async function startClient(project: string, serverUrl: string, name: string, aut
|
|
|
99
120
|
authToken,
|
|
100
121
|
});
|
|
101
122
|
try {
|
|
102
|
-
await candidate.start(onHubEvent);
|
|
123
|
+
await candidate.start((event) => onHubEvent(candidate, event));
|
|
124
|
+
// A tool call waits for the seeded inbox: `starting` stays pending until this returns.
|
|
125
|
+
await seedInbox(candidate);
|
|
103
126
|
meshClient = candidate;
|
|
104
127
|
return candidate;
|
|
105
128
|
} catch (error) {
|