@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.
@@ -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.101",
14
+ "version": "0.7.102",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
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 a Claude Code session restarted after acknowledging it. | Wait, or cancel and send it again with a new idempotency key. |
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. |
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.101",
3
+ "version": "0.7.102",
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.101",
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",
@@ -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.101";
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 onHubEvent(event) {
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: event.message.id,
17360
- from_agent: event.message.fromName,
17361
- delivery: event.message.delivery
17354
+ message_id: message.id,
17355
+ from_agent: message.fromName,
17356
+ delivery: message.delivery
17362
17357
  };
17363
- if (event.message.correlationId) meta2.correlation_id = event.message.correlationId;
17364
- await deliverInboxNotification(event.message.id, notifiedInbox, async () => {
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 ${event.message.fromName}:`,
17364
+ `Peer request from ${message.fromName}:`,
17370
17365
  "",
17371
- event.message.content,
17366
+ message.content,
17372
17367
  "",
17373
- `When complete, call kxm_reply with messageId ${event.message.id}.`
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) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.101",
3
+ "version": "0.7.102",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -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.101";
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
- async function onHubEvent(event: HubEvent): Promise<void> {
59
- if (event.type === "cancelled" || event.type === "expired") {
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: event.message.id,
71
- from_agent: event.message.fromName,
72
- delivery: event.message.delivery,
61
+ message_id: message.id,
62
+ from_agent: message.fromName,
63
+ delivery: message.delivery,
73
64
  };
74
- if (event.message.correlationId) meta.correlation_id = event.message.correlationId;
75
- await deliverInboxNotification(event.message.id, notifiedInbox, async () => {
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 ${event.message.fromName}:`,
71
+ `Peer request from ${message.fromName}:`,
81
72
  "",
82
- event.message.content,
73
+ message.content,
83
74
  "",
84
- `When complete, call kxm_reply with messageId ${event.message.id}.`,
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) {