@kontextmind/kxm 0.7.100 → 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.100",
14
+ "version": "0.7.102",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -375,8 +375,9 @@ All notable user-facing changes are documented here. The project follows [Semant
375
375
  or the Claude MCP server send one hop past the inbound request being handled, so a
376
376
  chain of agents forwarding to each other stops at `hop_limit_reached`.
377
377
  - **Workflow prompts no longer point agents at `.kxm/config`**, a path KXM refuses.
378
- - **`kxm gate signal` and `kxm workflow wait` inside a KXM project reach the hub for hub
379
- runs.** They go to the local Runtime only for a run its store holds.
378
+ - **`kxm gate signal`, `kxm workflow wait` and `kxm role resume` inside a KXM project reach
379
+ the hub for hub runs.** They go to the local Runtime only for a run its store holds, and
380
+ the lookup leaves no files behind, so `--dry-run` changes nothing.
380
381
  - **`kxm peer inbox` lists the requests waiting for a named CLI agent.** It returned
381
382
  `{"messages":[]}` every time. The hub now serves `GET /v1/agents/:id/inbox`
382
383
  (agent-authenticated, project-scoped): the caller's queued and delivered requests,
@@ -384,6 +385,14 @@ All notable user-facing changes are documented here. The project follows [Semant
384
385
  `codex`) to list requests peers queued for it while it was offline, then answer them
385
386
  with `kxm peer reply`. The Pi extension's `kxm_inbox` tool now refuses instead of
386
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.
387
396
 
388
397
  - **The Claude plugin's SessionStart hook is one bundled, read-only, project-scoped
389
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. |
@@ -1351,11 +1351,13 @@ kxm role resume <runId> [ruling]
1351
1351
  Resumes an audit-escalated run with an operator directive. The default ruling is `operator_ruling: waived and resumed`.
1352
1352
 
1353
1353
  - Arguments: `<runId>`; `[ruling]`, free text recorded with the decision.
1354
- - For a KXM run ID (`run_` followed by 32 hex digits) inside a project, posts an `audit_escalation` signal with action `unblock` to the Runtime, starting the supervisor if needed. `--dry-run` plans the request without starting the supervisor. JSON keys: `runId`, `ruling`, `unblocked`.
1355
- - For any other ID, updates the hub store at `.kxm/state/kxm.db` in the current directory directly (ignoring `--workspace` and `KXM_DATA_PATH`) and adds a `decision` journal entry. `--dry-run` reads the store read-only, reports the stage it would resume and the resulting `status`, and plans the write. JSON keys: `runId`, `stageId`, `ruling`, `status`.
1354
+ - Inside a project, a run that this project's Runtime store holds gets an `audit_escalation` signal with action `unblock` in the Runtime, starting the supervisor if needed. Hub workflow runs share the `run_` + 32-hex shape, so the store, not the ID, decides. `--dry-run` plans the request without starting the supervisor. JSON keys: `runId`, `ruling`, `unblocked`.
1355
+ - Any other run ID, including a hub workflow run of the same shape, updates the hub store at `.kxm/state/kxm.db` in the current directory directly (ignoring `--workspace` and `KXM_DATA_PATH`) and adds a `decision` journal entry. `--dry-run` reads the store read-only, reports the stage it would resume and the resulting `status`, and plans the write. JSON keys: `runId`, `stageId`, `ruling`, `status`.
1356
1356
  - The hub-run path bypasses the hub even while one is running: it writes SQLite directly, without authentication, in two statements outside one transaction. A running hub keeps runs in memory, so it does not see the change until it restarts, and its next write to that run overwrites it; it also pushes no event and sends the coordinator no resume message. Stop the hub first, or resume a live hub's run with a signed `audit_escalation` signal (see [Waits, signals and escalation](workflow-definitions.md#waits-signals-and-escalation)).
1357
1357
  - Errors: `resume_failed` (exit 1), or a plain `not found` line (exit 1).
1358
1358
 
1359
+ A run the project's Runtime holds:
1360
+
1359
1361
  ```bash
1360
1362
  kxm role resume run_0123456789abcdef0123456789abcdef "waive the audit" --dry-run --json
1361
1363
  ```
@@ -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.100",
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.100",
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
 
@@ -24677,7 +24677,7 @@ function kxmProjectRunEventsPath(projectRoot, env) {
24677
24677
  function projectRuntimeOwnsRun(projectRoot, runId, env) {
24678
24678
  const path4 = kxmProjectRunEventsPath(projectRoot, env);
24679
24679
  if (!existsSync10(path4)) return false;
24680
- const database = new DatabaseSync(path4, { readOnly: true });
24680
+ const database = openReadOnlyDatabase(path4);
24681
24681
  try {
24682
24682
  database.exec("PRAGMA busy_timeout = 5000");
24683
24683
  return database.prepare("SELECT 1 FROM runs WHERE run_id = ?").get(runId) !== void 0;
@@ -28834,7 +28834,7 @@ async function cmdRoleResume(runtime, runId, ruling) {
28834
28834
  }
28835
28835
  const effectiveRuling = ruling?.trim() || "operator_ruling: waived and resumed";
28836
28836
  const projectRoot = discoverKxmProjectRoot(runtime.cwd);
28837
- if (projectRoot && /^run_[a-f0-9]{32}$/i.test(runId)) {
28837
+ if (projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
28838
28838
  if (runtime.dryRun) {
28839
28839
  printPlan(
28840
28840
  runtime,
@@ -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.100";
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) {