@kontextmind/kxm 0.7.96 → 0.7.97

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.
Files changed (41) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +22 -0
  3. package/docs/concepts/trust-model.md +3 -2
  4. package/docs/glossary.md +1 -1
  5. package/docs/guides/peer-messaging.md +2 -2
  6. package/docs/guides/pi-workers.md +1 -1
  7. package/docs/guides/webhook-workflows.md +53 -18
  8. package/docs/operations/deploy.md +1 -1
  9. package/docs/operations/troubleshooting.md +3 -2
  10. package/docs/reference/cli-reference.md +6 -6
  11. package/docs/reference/configuration.md +2 -2
  12. package/docs/reference/http-api.md +6 -6
  13. package/docs/reference/tools.md +1 -1
  14. package/docs/reference/workflow-definitions.md +2 -2
  15. package/docs/start/quickstart-pi.md +1 -1
  16. package/examples/workflow-signal.ts +4 -5
  17. package/package.json +1 -1
  18. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  19. package/plugins/kxm/dist/claude-hook.js +11 -1
  20. package/plugins/kxm/dist/cli.js +159 -74
  21. package/plugins/kxm/dist/client.js +3 -1
  22. package/plugins/kxm/dist/core.js +11 -1
  23. package/plugins/kxm/dist/extension.js +45 -13
  24. package/plugins/kxm/dist/mcp-server.js +20 -4
  25. package/plugins/kxm/dist/runtime-supervisor.js +1 -3
  26. package/plugins/kxm/dist/runtime.js +17 -3
  27. package/plugins/kxm/dist/server.js +115 -20
  28. package/plugins/kxm/package.json +1 -1
  29. package/plugins/kxm/src/cli/workflows.ts +12 -7
  30. package/plugins/kxm/src/cli.ts +19 -5
  31. package/plugins/kxm/src/client.ts +4 -0
  32. package/plugins/kxm/src/commands.ts +23 -1
  33. package/plugins/kxm/src/extension.ts +20 -14
  34. package/plugins/kxm/src/github-watch.ts +8 -5
  35. package/plugins/kxm/src/hub-env.ts +19 -1
  36. package/plugins/kxm/src/hub.ts +105 -21
  37. package/plugins/kxm/src/improve-sources.ts +2 -7
  38. package/plugins/kxm/src/mcp-server.ts +9 -2
  39. package/plugins/kxm/src/runtime-store.ts +23 -0
  40. package/plugins/kxm/src/workflow.ts +70 -1
  41. package/scripts/smoke-multi-pi.mjs +5 -1
@@ -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.96",
14
+ "version": "0.7.97",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -307,6 +307,28 @@ All notable user-facing changes are documented here. The project follows [Semant
307
307
 
308
308
  ### Fixed
309
309
 
310
+ - **Signed webhooks cannot be replayed.** KXM's own webhook senders now sign the
311
+ timestamp, delivery ID, definition, run and signal key along with the body
312
+ (`x-kxm-signature`, `x-kxm-timestamp`, `x-kxm-delivery-id`; see
313
+ `docs/guides/webhook-workflows.md#kxm-sender-contract`), and the hub refuses a signature
314
+ more than 300 seconds old. Every `generic` workflow start and every signal callback
315
+ must use this contract; a body-only `X-Hub-Signature-256` there is refused. Jira and
316
+ GitHub deliveries keep their provider signature, and a signed body starts at most one
317
+ run (`webhook_payload_replayed`). A reused delivery ID with a different body is
318
+ refused with 409 `webhook_delivery_conflict`, and a duplicate start returns only
319
+ `duplicate`, `runId` and `status`. Update any custom sender to the new contract.
320
+ - **Agents never borrow the hub admin token.** With `KXM_AUTH_TOKEN` unset, the Pi
321
+ extension and the `kxm peer` / `kxm workflow` agent commands used the persisted admin
322
+ token. They now use only this project's saved project token, as the Claude MCP server
323
+ does, and otherwise stop with a message naming the fix (`project_token_missing`, exit 2,
324
+ on the CLI).
325
+ - **The hop limit bounds agent forwarding chains.** `kxm_send` and `kxm_fanout` from Pi
326
+ or the Claude MCP server send one hop past the inbound request being handled, so a
327
+ chain of agents forwarding to each other stops at `hop_limit_reached`.
328
+ - **Workflow prompts no longer point agents at `.kxm/config`**, a path KXM refuses.
329
+ - **`kxm gate signal` and `kxm workflow wait` inside a KXM project reach the hub for hub
330
+ runs.** They go to the local Runtime only for a run its store holds.
331
+
310
332
  - **The Claude plugin's SessionStart hook is one bundled, read-only, project-scoped
311
333
  script.** The two shell hooks it replaces (`kxm session brief --status` and
312
334
  `kxm memory brief`) exited 127 without `kxm` on `PATH`, ran whichever `kxm` was on
@@ -55,8 +55,9 @@ flowchart LR
55
55
 
56
56
  - **Claude Code plugin.** The MCP server uses the plugin's `auth_token` setting, else the project token the hub saved for this project. It never falls back to the saved admin token; without a project token, its tools report that and do nothing.
57
57
  - **SessionStart hook.** It reads local state read-only. It mints no token, writes no file, and never prints a token.
58
- - **Pi extension.** It uses `KXM_AUTH_TOKEN`, else the token from a hub it auto-started, else the saved admin token. When your project has its own project token, set `KXM_AUTH_TOKEN` to it for Pi, because the hub rejects the admin token for that project.
59
- - **CLI and Runtime supervisor.** Operator tools use `KXM_AUTH_TOKEN`, else the saved project token, else the saved admin token.
58
+ - **Pi extension.** It uses `KXM_AUTH_TOKEN`, else the project token the hub saved for this project. It never uses the saved admin token, nor the one a hub it auto-started generated; without a project token it reports the fix and stays offline.
59
+ - **`kxm peer` and `kxm workflow` agent commands.** They act as a peer agent, so they choose a token as the Pi extension does, and exit 2 with `project_token_missing` when none resolves.
60
+ - **Dashboard and Runtime supervisor.** Operator tools use `KXM_AUTH_TOKEN`, else the saved project token, else the saved admin token.
60
61
 
61
62
  ## Project isolation
62
63
 
package/docs/glossary.md CHANGED
@@ -184,7 +184,7 @@ The coordinator's recorded result for the active [stage](#stage): `passed`, `war
184
184
 
185
185
  ### Delivery ID
186
186
 
187
- The identifier a webhook sender puts in `X-GitHub-Delivery`, `X-Atlassian-Webhook-Identifier` or `x-kxm-delivery-id`. The hub requires one and deduplicates on it: a repeated start delivery returns the existing run.
187
+ The identifier a webhook sender puts in `x-kxm-delivery-id`, which the KXM sender contract signs, or for a Jira or GitHub delivery in `X-Atlassian-Webhook-Identifier` or `X-GitHub-Delivery`. The hub requires one and deduplicates on it: a repeated start with the same body returns the existing run ID, and the same ID with a different body is refused.
188
188
 
189
189
  ### Journal
190
190
 
@@ -246,7 +246,7 @@ Neither field proves where a reply came from. Workflow evidence uses `workflowCo
246
246
 
247
247
  When a request expires, both the sender and the recipient receive an `expired` event.
248
248
 
249
- The hop limit refuses a request whose `hops` count has reached `maxHops` (`hop_limit_reached`). Only a client that forwards a request through the [Hub HTTP API reference](../reference/http-api.md) and carries its hop count is affected. `kxm_send`, `kxm_fanout`, and `kxm peer send` always start at hop 0, so the limit does not stop a chain of agents that each send a new request.
249
+ The hop limit refuses a request whose `hops` count has reached `maxHops` (`hop_limit_reached`). In Pi and in Claude Code, `kxm_send` and `kxm_fanout` send one hop past the inbound request the session is handling, under that request's `maxHops`, so a chain of agents forwarding work to each other stops at the limit. The Claude Code MCP server counts from the furthest request in its open inbox. `kxm peer send` from the CLI handles no inbound request, so it always starts a new chain at hop 0.
250
250
 
251
251
  ## Queue work for an offline agent
252
252
 
@@ -272,7 +272,7 @@ Tools and the CLI report the message text; the HTTP response also carries the co
272
272
  | `online target not found: <name>` | `target_not_found` | The target is offline or unknown. Check `kxm_list`; use `allowOffline` for a registered agent. |
273
273
  | `cannot send a request to yourself` | `self_target` | Send to a different agent. |
274
274
  | `idempotency key was already used for another request` | `idempotency_conflict` | A different request reused the key. Repeat the original exactly, or use a new key for new work. |
275
- | `hop limit reached (<hops>/<max>)` | `hop_limit_reached` | A forwarding client reached `maxHops`. Stop the chain. |
275
+ | `hop limit reached (<hops>/<max>): …` | `hop_limit_reached` | Forwarding would extend the chain past `maxHops`. Answer the inbound request directly instead of forwarding it. |
276
276
  | `ttlMs must be an integer between 1000 and 604800000` | `protocol_error` | Use a TTL from 1 second to 7 days. |
277
277
  | `message is not visible to this agent` | `message_forbidden` | Run as the sender or recipient. |
278
278
  | `message already has a reply` | `duplicate_reply` | The request is already answered. |
@@ -60,7 +60,7 @@ would start worker
60
60
  ```
61
61
 
62
62
  > [!IMPORTANT]
63
- > Set `KXM_AUTH_TOKEN` to the project token in the worker's environment. When it is unset, the Pi extension falls back to the hub credential persisted on this machine, which is the admin token for a hub that `kxm hub start` or Pi auto-start created.
63
+ > Set `KXM_AUTH_TOKEN` to the project token in the worker's environment. When it is unset, the Pi extension uses only the project token saved for this project on this machine. It never falls back to the saved admin token, so without a project token the worker's Pi session stays offline and says so.
64
64
 
65
65
  | Flag | Environment variable | Effect |
66
66
  |---|---|---|
@@ -47,7 +47,7 @@ sequenceDiagram
47
47
  Hub-->>Coord: completed = true
48
48
  ```
49
49
 
50
- The hub verifies the signature over the raw body, deduplicates retries by delivery ID, and records the run before it answers. It keeps a SHA-256 hash of the payload and the rendered prompt, not the raw body, so keep prompt templates narrow.
50
+ The hub verifies the delivery's signature, refuses replays, deduplicates retries by delivery ID, and records the run before it answers. It keeps a SHA-256 hash of the payload and the rendered prompt, not the raw body, so keep prompt templates narrow.
51
51
 
52
52
  ## Copy the example definition
53
53
 
@@ -102,7 +102,7 @@ A file holds a JSON array of definitions. The hub checks every limit below at st
102
102
  | Field | Meaning |
103
103
  |---|---|
104
104
  | `id` | Up to 64 characters; the last segment of the webhook URL. |
105
- | `source` | `jira`, `github`, or `generic` (the default). A label on the run. |
105
+ | `source` | `jira`, `github`, or `generic` (the default). A label on the run, and it decides which signatures the hub accepts: see [Signatures and delivery IDs](#signatures-and-delivery-ids). |
106
106
  | `project`, `target` | Hub project, and the coordinator's agent name or ID. |
107
107
  | `secretEnv` | Variable holding the start secret. `secret` takes a literal instead; prefer the variable. |
108
108
  | `signalSecretEnv` | Variable holding the callback secret. Without it, callbacks use the start secret. |
@@ -180,7 +180,7 @@ kxm agent worker --name coordinator --project product --model <pi-model> \
180
180
 
181
181
  ## Send a test delivery
182
182
 
183
- `kxm workflow start` signs a payload and posts it to the hub at `KXM_SERVER_URL`, as a provider would. It signs with `KXM_WORKFLOW_SECRET`, or with the definition's own start secret when `KXM_WEBHOOK_WORKFLOWS_FILE` is set in the same shell.
183
+ `kxm workflow start` signs a payload under the [KXM sender contract](#kxm-sender-contract) and posts it to the hub at `KXM_SERVER_URL`, as a provider would. It signs with `KXM_WORKFLOW_SECRET`, or with the definition's own start secret when `KXM_WEBHOOK_WORKFLOWS_FILE` is set in the same shell.
184
184
 
185
185
  ```bash
186
186
  export KXM_SERVER_URL=http://127.0.0.1:7331
@@ -195,7 +195,7 @@ Expected output:
195
195
  started workflow run_7c187d2a0fde408ea408f0d8c53201c8
196
196
  ```
197
197
 
198
- Repeating the command with the same delivery ID returns the same run. Inspect runs from the hub's workspace on the hub host; these commands read its local SQLite store:
198
+ Repeating the command with the same delivery ID and payload returns the same run ID; the same delivery ID with a different payload is refused with `409`. Inspect runs from the hub's workspace on the hub host; these commands read its local SQLite store:
199
199
 
200
200
  ```bash
201
201
  kxm workflow list
@@ -204,26 +204,60 @@ kxm workflow get run_7c187d2a0fde408ea408f0d8c53201c8 --json
204
204
 
205
205
  ## Connect Jira
206
206
 
207
- In Jira, create a webhook for the `jira:issue_updated` event that posts to `https://<kxm-host>/v1/webhooks/jira-development`, and set the same start secret. The hub accepts these headers:
207
+ In Jira, create a webhook for the `jira:issue_updated` event that posts to `https://<kxm-host>/v1/webhooks/jira-development`, and set the same start secret.
208
208
 
209
- | Header | Sent by | Purpose |
209
+ ### Signatures and delivery IDs
210
+
211
+ The hub accepts two ways to sign a start:
212
+
213
+ | Headers | Sent by | Accepted by |
210
214
  |---|---|---|
211
- | `X-Hub-Signature-256` or `X-Hub-Signature` | GitHub, Jira, `kxm` | `sha256=<hex>` HMAC of the exact body. Other algorithms are refused. |
212
- | `X-Atlassian-Webhook-Identifier` | Jira | Delivery ID. Checked first. |
213
- | `X-GitHub-Delivery` | GitHub | Delivery ID. Checked second. |
214
- | `X-KXM-Delivery-ID` | `kxm` and your own senders | Delivery ID. Checked last. |
215
+ | `x-kxm-signature`, `x-kxm-timestamp`, `x-kxm-delivery-id` | `kxm` and your own senders | Every definition. See [KXM sender contract](#kxm-sender-contract). |
216
+ | `X-Hub-Signature-256` or `X-Hub-Signature`, with `X-Atlassian-Webhook-Identifier` | Jira | A `jira` definition only. |
217
+ | `X-Hub-Signature-256` with `X-GitHub-Delivery` | GitHub | A `github` definition only. |
218
+
219
+ Jira and GitHub sign only the body, as `sha256=<hex>`; other algorithms are refused. Because the delivery ID is not signed, the body is the delivery's replay identity: a signed body starts at most one run, under one delivery ID.
215
220
 
216
221
  | Response | Meaning |
217
222
  |---|---|
218
223
  | `202` | Run created and coordinator prompt queued. |
219
- | `200` with `"duplicate": true` | The delivery ID was seen before; the existing run is returned. The body is not compared. |
224
+ | `200` with `"duplicate": true` | The delivery ID and body were seen before. Only `runId` and `status` come back, never the run. |
220
225
  | `204` | The event or filter did not match. Nothing was stored. |
221
- | `401` | Missing, unsupported, or wrong signature. |
226
+ | `401` | Missing, unsupported, or wrong signature, or a KXM signature outside its 300-second window. |
222
227
  | `404` | No definition with that ID. |
223
- | `409` | The coordinator has never registered. |
228
+ | `409` `workflow_target_unavailable` | The coordinator has never registered. |
229
+ | `409` `webhook_delivery_conflict` | The delivery ID was already used with a different body. |
230
+ | `409` `webhook_payload_replayed` | A Jira or GitHub body already started a run under another delivery ID. |
224
231
 
225
232
  Webhook authentication authorizes only workflow creation. The `report` stage updates Jira through the coordinator's own authorized Jira tool; never put Jira credentials in a definition or prompt.
226
233
 
234
+ ## KXM sender contract
235
+
236
+ A body-only signature cannot tell a retry from a replay, so KXM's own senders sign more: `kxm workflow start`, `kxm gate signal`, `kxm gate github watch`, and [`examples/workflow-signal.ts`](../../examples/workflow-signal.ts). A start of a `generic` definition and every signal must use this contract; a body-only signature there is refused with `401 webhook_signature_missing`.
237
+
238
+ | Header | Value |
239
+ |---|---|
240
+ | `x-kxm-delivery-id` | Stable retry identifier, at most 128 characters |
241
+ | `x-kxm-timestamp` | Unix seconds at send time |
242
+ | `x-kxm-signature` | `sha256=` and the hex HMAC-SHA256 of the signed material, under the start or callback secret |
243
+
244
+ The signed material is seven newline-terminated fields followed by the exact body bytes. No field may contain a line break. `workflowWebhookHeaders` in `plugins/kxm/src/workflow.ts` builds all three headers.
245
+
246
+ ```text
247
+ kxm-webhook-v1
248
+ start | signal
249
+ <x-kxm-timestamp>
250
+ <x-kxm-delivery-id>
251
+ <definition ID>
252
+ <run ID; empty for a start>
253
+ <signal key; empty for a start>
254
+ <body>
255
+ ```
256
+
257
+ - A signature over any other timestamp, delivery ID, definition, run, signal key, or body is refused with `401 webhook_signature_invalid`, so a captured request cannot be replayed under a new delivery ID or against another run.
258
+ - An authentic signature whose timestamp is more than 300 seconds from the hub clock is refused with `401 webhook_timestamp_expired`. Sign at send time: a retry re-signs with a fresh timestamp and keeps the same delivery ID and body, which the hub answers as a duplicate.
259
+ - The signed kind keeps a start signature from ever verifying as a signal, even when both use the start secret.
260
+
227
261
  ## Follow the coordinator procedure
228
262
 
229
263
  For every stage, the coordinator:
@@ -276,7 +310,7 @@ Tool call (`kxm_workflow_wait`):
276
310
 
277
311
  Store the run ID and signal key where the external system can find them, such as pull-request metadata; they are not secrets.
278
312
 
279
- The external system reports back with a signed signal to `POST /v1/webhooks/<definition-id>/runs/<run-id>/signals/<signal-key>`. The body is `{"status": "passed" | "warning" | "failed", "summary": "...", "evidence": {...}}`, signed with the callback secret in `X-Hub-Signature-256`, with a stable `X-KXM-Delivery-ID`.
313
+ The external system reports back with a signed signal to `POST /v1/webhooks/<definition-id>/runs/<run-id>/signals/<signal-key>`. The body is `{"status": "passed" | "warning" | "failed", "summary": "...", "evidence": {...}}`, signed with the callback secret under the [KXM sender contract](#kxm-sender-contract), bound to the route's run ID and signal key, with a stable delivery ID.
280
314
 
281
315
  - `passed` applies the evidence rule to the saved and new evidence together, then advances or completes the run.
282
316
  - `warning` or `failed` uses up an attempt, records an error, and sends the coordinator a correction prompt while attempts remain.
@@ -303,8 +337,8 @@ Expected output:
303
337
  posted signed signal
304
338
  ```
305
339
 
306
- > [!WARNING]
307
- > Run `kxm gate signal` and `kxm workflow wait` outside any KXM project directory. Inside a Git repository with `.kxm/project.yaml`, they treat a `run_<32-hex>` ID as a Runtime run and send it to the Runtime supervisor, not the hub. Check with `--dry-run`: the hub path prints `would post signed signal`, the Runtime path `would post signal to KXM run`.
340
+ > [!NOTE]
341
+ > Hub and Runtime run IDs share the `run_<32-hex>` shape. Inside a KXM project, `kxm gate signal` and `kxm workflow wait` send a run to the Runtime supervisor only when the project's Runtime store holds that run; a hub run goes to the hub. Check with `--dry-run`: the hub path prints `would post signed signal`, the Runtime path `would post signal to KXM run`.
308
342
 
309
343
  For your own adapters, [`examples/workflow-signal.ts`](../../examples/workflow-signal.ts) shows the same signed request in about 60 lines.
310
344
 
@@ -339,7 +373,7 @@ If a stage's peer policy declares a lower `degradation.minProducers`, an operato
339
373
  ## Security notes
340
374
 
341
375
  - The start secret authorizes creating runs; the callback secret authorizes only checkpointing a waiting run with a matching signal key. Keep them separate with `signalSecretEnv`.
342
- - The signature covers the body, not the delivery-ID header, and there is no timestamp window. Anyone who captures a signed request can replay it under a new delivery ID, so terminate TLS and restrict ingress.
376
+ - A KXM signature covers the timestamp, delivery ID, definition, run, and signal key, and expires after 300 seconds, so a captured request cannot be replayed under a new delivery ID or against another run. Jira and GitHub sign only the body: a captured provider delivery re-sent under its own delivery ID only returns the duplicate, and a body starts at most one run. Terminate TLS and restrict ingress anyway.
343
377
  - Repository rules, human approvals, and harness permissions stay in charge of pushing, merging, and changing Jira.
344
378
 
345
379
  ## Troubleshooting
@@ -353,7 +387,8 @@ If a stage's peer policy declares a lower `degradation.minProducers`, an operato
353
387
  | `stage <id> is missing required evidence: <key>` | A required key is missing or empty. | Supply every key from `kxm_workflow_get`. |
354
388
  | `stage <id> is not currently active` or `workflow is waiting` | Wrong stage, or the stage is paused for a signal. | Use `currentStage`; send the signal instead of a checkpoint. |
355
389
  | The run fails right after the coordinator replies | It settled before the last checkpoint. | Checkpoint every stage, or wait, before replying. |
356
- | `kxm gate signal` prints `signed signal failed` | Wrong signal key, run not waiting, or a delivery-ID conflict. | Compare the run's `waiting.signalKey`; use a new delivery ID for a new result. |
390
+ | `kxm gate signal` prints `signed signal failed` | Wrong signal key, run not waiting, a delivery-ID conflict, or a clock more than 300 seconds off. | Compare the run's `waiting.signalKey`; use a new delivery ID for a new result; check the sender's clock. |
391
+ | A custom sender gets `401 webhook_signature_missing` | It sends only `X-Hub-Signature-256` to a `generic` definition or a signal. | Sign with the [KXM sender contract](#kxm-sender-contract). |
357
392
 
358
393
  ## Next steps
359
394
 
@@ -68,7 +68,7 @@ Expected output on first start:
68
68
  kxm hub: using newly generated KXM_AUTH_TOKEN from /home/kxm/.local/state/kxm/hub-env.json
69
69
  ```
70
70
 
71
- One-shot clients on the same machine (`kxm dash`, `kxm peer`, the Runtime) read the same file. They use `KXM_AUTH_TOKEN` from their environment first, then the saved project token for their project, then the saved admin token. The Claude Code MCP server never falls back to the admin token.
71
+ Operator clients on the same machine (`kxm dash`, the Runtime) read the same file. They use `KXM_AUTH_TOKEN` from their environment first, then the saved project token for their project, then the saved admin token. Agent clients (the Pi extension, the Claude Code MCP server, and the `kxm peer` and `kxm workflow` agent commands) never fall back to the admin token.
72
72
 
73
73
  To rotate the admin token, restart the hub with a new `KXM_AUTH_TOKEN`; the hub saves it in place of the old one and keeps the saved project tokens. Rotate a project token by restarting with an updated full `KXM_PROJECT_TOKENS` map. Then restart every client that held the old value. Deleting `hub-env.json` also works, but it drops the saved project tokens too.
74
74
 
@@ -183,14 +183,15 @@ The hub does not poll GitHub. Run `kxm gate github watch` with the run's `--run-
183
183
 
184
184
  | Response | Meaning |
185
185
  |---|---|
186
- | 401 | The HMAC-SHA256 signature is missing or does not match the raw body; a callback must use `signalSecretEnv` when the definition sets it |
186
+ | 401 | The signature is missing, does not match, or is older than 300 seconds (`webhook_timestamp_expired`: check the sender's clock). Signals and `generic` starts need the [KXM sender contract](../guides/webhook-workflows.md#kxm-sender-contract); a callback must use `signalSecretEnv` when the definition sets it |
187
187
  | 400 | The delivery ID or JSON body is missing; `workflow_evidence_incomplete` names `missingRequirements`; `invalid_workflow_evidence` means non-string or duplicate keys |
188
188
  | 404 | The definition or run ID does not match this hub |
189
189
  | 409 `workflow_target_unavailable` | The configured coordinator has never registered; start it once with the matching project and name |
190
190
  | 409 `workflow_not_waiting` | No wait is active: `kxm_workflow_wait` was not called, the deadline failed the run, or a prior signal advanced it |
191
191
  | 409 `workflow_signal_mismatch`, `workflow_signal_context_mismatch` | Use the run's exact `waiting.signalKey`; fix or omit `workflow.run`, `workflow.stage` and `workflow.signal` evidence |
192
192
  | 204 | The event or filter did not match, so no run was intended |
193
- | 200 with `duplicate: true` | A retry of the same delivery ID was deduplicated |
193
+ | 409 `webhook_delivery_conflict`, `webhook_payload_replayed` | The delivery ID was reused with a different body, or a Jira or GitHub body already started a run under another delivery ID |
194
+ | 200 with `duplicate: true` | A retry of the same delivery ID and body was deduplicated |
194
195
 
195
196
  A failed or timed-out callback consumes that wait. Re-enter the wait and start a new `github watch` or `kxm gate signal`; keep an explicit `--delivery-id` only for retries of one unchanged body. See [Run webhook workflows](../guides/webhook-workflows.md).
196
197
 
@@ -1826,7 +1826,7 @@ kxm workflow record wf_123 lesson "Flaky test hid a race" --stage-id verify --ev
1826
1826
  kxm workflow wait [runId] [stageId] [signalKey] [summary] [--evidence <json>] [--evidence-refs <json>] [--timeout-ms <ms>]
1827
1827
  ```
1828
1828
 
1829
- Wait for a workflow signal callback: pauses the active stage until a signed external callback checkpoints it. For a KXM run ID (`run_` followed by 32 hex digits) inside a project, the command posts to the Runtime instead of the hub (the supervisor starts if needed). The Runtime has no wait state yet: it checks that the run exists, answers `waiting: true`, and records nothing, so the command prints `waiting for signal on KXM run <id>` although the run is unchanged.
1829
+ Wait for a workflow signal callback: pauses the active stage until a signed external callback checkpoints it. Inside a project, a run that this project's Runtime store holds goes to the Runtime instead of the hub (the supervisor starts if needed); any other run ID, including a hub workflow run of the same `run_` + 32-hex shape, goes to the hub. The Runtime has no wait state yet: it checks that the run exists, answers `waiting: true`, and records nothing, so the command prints `waiting for signal on KXM run <id>` although the run is unchanged.
1830
1830
 
1831
1831
  | Option | Argument | Default | Description |
1832
1832
  |---|---|---|---|
@@ -1878,7 +1878,7 @@ KXM_WORKFLOW_ID=provenance-review KXM_WORKFLOW_SIGNAL_SECRET="$SIGNAL_SECRET" kx
1878
1878
  kxm workflow start [definitionId] [--payload <json|@file>] [--delivery-id <id>] [--event <name>]
1879
1879
  ```
1880
1880
 
1881
- POST a signed workflow-start webhook to `<hub>/v1/webhooks/<definitionId>`. The body is HMAC-SHA256 signed (`x-hub-signature-256`) and carries a delivery ID (`x-kxm-delivery-id`) so the hub deduplicates retries.
1881
+ POST a signed workflow-start webhook to `<hub>/v1/webhooks/<definitionId>` under the [KXM sender contract](../guides/webhook-workflows.md#kxm-sender-contract): `x-kxm-signature` is an HMAC-SHA256 over the timestamp (`x-kxm-timestamp`), the delivery ID (`x-kxm-delivery-id`), the definition ID and the body, so a captured request cannot start a second run under another delivery ID. Repeating a delivery ID with the same body returns the existing run ID as a duplicate; with a different body the hub answers 409 `webhook_delivery_conflict`.
1882
1882
 
1883
1883
  | Option | Argument | Default | Description |
1884
1884
  |---|---|---|---|
@@ -2166,9 +2166,9 @@ Post a signed workflow callback that checkpoints a waiting stage.
2166
2166
  | `--recovery-action` | `<action>` | none | KXM recovery action: retry, fail, cancel, unblock |
2167
2167
 
2168
2168
  - Arguments: `<runId>` Workflow run ID; `<signalKey>` Wait signal key; `<status>` passed, warning, or failed; `<summary>` Callback summary; `[evidence...]` required-key=evidence pairs.
2169
- - For a KXM run ID (`run_` followed by 32 hex digits) inside a project, the signal goes to the Runtime (the supervisor starts if needed) and `--recovery-action` is passed through. JSON keys: `runId`, `signalKey`, `status`, `unblocked`, `deliveryId`.
2170
- - Otherwise it is a hub webhook callback: `KXM_WORKFLOW_ID` names the definition, and the secret is the definition's `signalSecretEnv` (falling back to `secretEnv`) when a definition source is configured, else `KXM_WORKFLOW_SIGNAL_SECRET`. JSON keys: `duplicate`, `deliveryId`.
2171
- - Reuse `--delivery-id` to retry one unchanged callback without a duplicate.
2169
+ - Inside a project, a run that this project's Runtime store holds is signaled in the Runtime (the supervisor starts if needed) and `--recovery-action` is passed through. JSON keys: `runId`, `signalKey`, `status`, `unblocked`, `deliveryId`. Hub workflow runs share the `run_` + 32-hex shape, so the store, not the ID, decides.
2170
+ - Otherwise it is a hub webhook callback: `KXM_WORKFLOW_ID` names the definition, and the secret is the definition's `signalSecretEnv` (falling back to `secretEnv`) when a definition source is configured, else `KXM_WORKFLOW_SIGNAL_SECRET`. The callback is signed under the [KXM sender contract](../guides/webhook-workflows.md#kxm-sender-contract), bound to its timestamp, delivery ID, definition, run and signal key. JSON keys: `duplicate`, `deliveryId`.
2171
+ - Reuse `--delivery-id` to retry one unchanged callback without a duplicate; each send re-signs with a fresh timestamp.
2172
2172
  - Honors `--dry-run`. Exit 2 for a missing argument, an invalid status, malformed or duplicate evidence keys, or missing `KXM_WORKFLOW_ID` or secret; exit 1 for `signal_failed`.
2173
2173
 
2174
2174
  ```bash
@@ -2227,7 +2227,7 @@ Captured without a GitHub token. With `GITHUB_TOKEN` set, the same command polls
2227
2227
 
2228
2228
  ## `kxm peer`
2229
2229
 
2230
- Peer agent messaging through the hub. Each invocation connects to the hub as a short-lived agent named `KXM_AGENT_NAME` (default `cli-<pid>`) in project `KXM_PROJECT` (default: the `package.json` name, else the directory name), using `KXM_AUTH_TOKEN`, a project token, or the persisted `hub-env.json` credential. That agent appears in `peer list` and the dashboard. Every subcommand accepts `--payload <json>` with the tool's fields as one object; explicit flags override it. Tool policy from `KXM_ATTEMPT_TOKEN`, `KXM_SESSION_TOKEN`, or the on-disk session token is enforced first (`tool_policy_denied`, `session_token_invalid`, `attempt_token_invalid`).
2230
+ Peer agent messaging through the hub. Each invocation connects to the hub as a short-lived agent named `KXM_AGENT_NAME` (default `cli-<pid>`) in project `KXM_PROJECT` (default: the `package.json` name, else the directory name), using `KXM_AUTH_TOKEN`, else this project's saved project token from `hub-env.json`. It never uses the persisted admin token: with neither, the command exits 2 with `project_token_missing` (`nextAction: "export_kxm_auth_token"`) before contacting the hub. The `kxm workflow` agent verbs (`checkpoint`, `record`, `wait`, `get`, `list`) connect the same way. That agent appears in `peer list` and the dashboard. Every subcommand accepts `--payload <json>` with the tool's fields as one object; explicit flags override it. Tool policy from `KXM_ATTEMPT_TOKEN`, `KXM_SESSION_TOKEN`, or the on-disk session token is enforced first (`tool_policy_denied`, `session_token_invalid`, `attempt_token_invalid`).
2231
2231
 
2232
2232
  All subcommands need a hub, honor `--dry-run` (printing the parsed `args` without connecting), print the hub's result object on success, and print `{"ok":false,"error":"command_failed","detail":"..."}` with exit 1 on failure. Malformed `--payload` exits 2 with `invalid_payload`.
2233
2233
 
@@ -114,9 +114,9 @@ Every agent client (the Pi extension, the Claude Code MCP server, and the `kxm p
114
114
 
115
115
  | Harness | Default name | Default purpose | Token when `KXM_AUTH_TOKEN` is unset |
116
116
  |---|---|---|---|
117
- | Pi extension | The Pi session name, else `pi-<pid>` | `General-purpose coding agent` | The admin token of a hub it auto-started, else the saved admin token |
117
+ | Pi extension | The Pi session name, else `pi-<pid>` | `General-purpose coding agent` | This project's saved project token only; never the admin token |
118
118
  | Claude Code MCP server | `claude` from the plugin settings, else `claude-<pid>` | `Claude Code implementation and review agent` | This project's saved project token only; never the admin token |
119
- | `kxm peer` commands | `cli-<pid>` | `CLI agent client` | The saved project token for the project, else the saved admin token |
119
+ | `kxm peer` and `kxm workflow` agent commands | `cli-<pid>` | `CLI agent client` | This project's saved project token only; never the admin token (`project_token_missing`, exit 2) |
120
120
 
121
121
  A clean shutdown marks an identity offline. Reconnecting with the same project and name resumes its durable agent ID and rotates its agent key. If the Claude Code name is already online in the project, the MCP server registers once more as `<name>-<pid>`.
122
122
 
@@ -26,7 +26,7 @@ Each route requires one of these credential classes. Send tokens as `Authorizati
26
26
  | Project | The project's token, or the admin token for a project with no token of its own | Admission for agent registration and Runtime sync |
27
27
  | Agent | The project token plus `x-kxm-agent-id` and `x-kxm-agent-key` from registration | The agent key rotates on every registration (401 `invalid_agent_identity` when stale) |
28
28
  | Agent or admin | Agent headers for your own project, or the admin token with an explicit `project` | Admin calls may name a caller in `x-kxm-caller-id` |
29
- | Signed | HMAC-SHA256 of the raw body in `x-hub-signature-256` (or `x-hub-signature`) as `sha256=<hex>`, plus a delivery ID | No bearer token; the secret is the workflow definition's |
29
+ | Signed | The [KXM sender contract](../guides/webhook-workflows.md#kxm-sender-contract): `x-kxm-signature` over the timestamp, delivery ID, route and body, within 300 seconds. A `jira` or `github` start may instead carry its provider's body HMAC | No bearer token; the secret is the workflow definition's |
30
30
 
31
31
  A wrong or missing token is 401 `invalid_auth`. The [trust model](../concepts/trust-model.md) explains which person or process holds each credential.
32
32
 
@@ -75,7 +75,7 @@ sequenceDiagram
75
75
 
76
76
  | Method | Path | Auth | Purpose |
77
77
  |---|---|---|---|
78
- | `POST` | `/v1/messages` | Agent | Send a request: `target`, `content`, optional `delivery`, `correlationId`, `idempotencyKey`, `workflowContext`, `ttlMs`, `maxHops`, `allowOffline`. 202 new; 200 with `idempotent: true` for an exact retry |
78
+ | `POST` | `/v1/messages` | Agent | Send a request: `target`, `content`, optional `delivery`, `correlationId`, `idempotencyKey`, `workflowContext`, `ttlMs`, `hops`, `maxHops`, `allowOffline`. `kxm_send` and `kxm_fanout` set `hops` one past the inbound request being handled. 202 new; 200 with `idempotent: true` for an exact retry |
79
79
  | `GET` | `/v1/messages/<id>` | Agent | Read a message you sent or received |
80
80
  | `POST` | `/v1/messages/<id>/ack` | Agent | Recipient marks a queued message `delivered` |
81
81
  | `POST` | `/v1/messages/<id>/reply` | Agent | Recipient replies with `content`; the sender gets a `reply` event |
@@ -115,13 +115,13 @@ Key errors: `workflow_not_found` (404), `workflow_stage_out_of_order`, `workflow
115
115
  | `POST` | `/v1/webhooks/<definitionId>` | Signed with the definition's start secret | Start a run and prompt the coordinator (status codes below) |
116
116
  | `POST` | `/v1/webhooks/<definitionId>/runs/<runId>/signals/<signalKey>` | Signed with the signal secret, else the start secret | Report an external result for a waiting stage: `status` (`passed`, `warning`, `failed`), `summary`, `evidence`. 202 when the coordinator is resumed, 200 otherwise |
117
117
 
118
- A start answers 202 for a new run, 200 with `duplicate: true` for a known delivery ID, and 204 when the event or filter does not match.
118
+ A start answers 202 for a new run, 200 with `duplicate: true`, `runId` and `status` for a known delivery ID and body, and 204 when the event or filter does not match.
119
119
 
120
- The delivery ID comes from `x-atlassian-webhook-identifier`, `x-github-delivery` or `x-kxm-delivery-id`, in that order, and is required. The event comes from `x-github-event`, else the payload's `webhookEvent` or `event` field.
120
+ Every signal, and every start of a `generic` definition, is signed under the KXM sender contract, and its delivery ID is the signed `x-kxm-delivery-id`. A `jira` or `github` start may instead carry the provider's body HMAC in `x-hub-signature-256` (or `x-hub-signature`); its delivery ID is then that provider's own header, `x-atlassian-webhook-identifier` or `x-github-delivery`, and the signed body starts at most one run. The event comes from `x-github-event`, else the payload's `webhookEvent` or `event` field.
121
121
 
122
- A start with a known delivery ID returns the existing run even if the body differs. A signal with a known delivery ID returns the original receipt when the body matches and 409 `workflow_signal_delivery_conflict` when it does not. Deduplication lasts as long as the run is retained.
122
+ A start with a known delivery ID and a different body is refused with 409 `webhook_delivery_conflict`. A signal with a known delivery ID returns the original receipt when the body matches and 409 `workflow_signal_delivery_conflict` when it does not. Deduplication lasts as long as the run is retained.
123
123
 
124
- Key errors: `webhook_not_found` (404), `webhook_signature_missing`, `webhook_signature_unsupported`, `webhook_signature_invalid` (401), `workflow_target_unavailable` (409, the coordinator never registered), `workflow_not_waiting`, `workflow_signal_mismatch` (409, the run waits for another key) and `workflow_signal_context_mismatch` (409, evidence names another run, stage or signal). See [Webhook workflows](../guides/webhook-workflows.md).
124
+ Key errors: `webhook_not_found` (404), `webhook_signature_missing`, `webhook_signature_unsupported`, `webhook_signature_invalid`, `webhook_timestamp_invalid`, `webhook_timestamp_expired` (401), `webhook_delivery_conflict`, `webhook_payload_replayed` (409, a provider body already started a run under another delivery ID), `workflow_target_unavailable` (409, the coordinator never registered), `workflow_not_waiting`, `workflow_signal_mismatch` (409, the run waits for another key) and `workflow_signal_context_mismatch` (409, evidence names another run, stage or signal). See [Webhook workflows](../guides/webhook-workflows.md).
125
125
 
126
126
  ## Context and state
127
127
 
@@ -44,7 +44,7 @@ The workflow tools act on hub [webhook workflow runs](workflow-definitions.md#we
44
44
 
45
45
  ### Identity and credentials
46
46
 
47
- Each tool call acts as the calling session's registered agent in one hub project. The Claude Code MCP server authenticates with a project token only: `auth_token` from the plugin settings, else this project's token saved in `hub-env.json`, and never the admin token. The Pi extension uses `KXM_AUTH_TOKEN`, then the admin token that hub auto-start resolved or generated for the hub it started, then the saved admin token. See [Agent settings](configuration.md#agent-settings).
47
+ Each tool call acts as the calling session's registered agent in one hub project. The Claude Code MCP server authenticates with a project token only: `auth_token` from the plugin settings, else this project's token saved in `hub-env.json`, and never the admin token. The Pi extension uses `KXM_AUTH_TOKEN`, else this project's saved project token, and never the admin token, including one that hub auto-start generated. `kxm_send` and `kxm_fanout` send one hop past the inbound request the session is handling, so the hub's hop limit stops a chain of forwarding agents. See [Agent settings](configuration.md#agent-settings).
48
48
 
49
49
  ### Tool policy
50
50
 
@@ -49,7 +49,7 @@ Keep the file out of `.kxm/config/workflows/`: any JSON there counts as legacy c
49
49
  | Field | Type and limits | Default | Effect |
50
50
  |---|---|---|---|
51
51
  | `id` | String, 64 characters, unique | Required | The URL segment in `/v1/webhooks/<id>` and the name `kxm workflow start` takes |
52
- | `source` | `jira`, `github` or `generic` | `generic` | Recorded on each run; does not change how requests are read |
52
+ | `source` | `jira`, `github` or `generic` | `generic` | Recorded on each run. A `jira` or `github` definition also accepts its provider's body-only signature and delivery header; a `generic` one accepts only the KXM sender contract |
53
53
  | `project` | String, 128 characters | Required | Hub project the run and its coordinator belong to |
54
54
  | `target` | Agent name or ID, 80 characters | Required | The coordinator. It must have registered at least once before a run starts; it may be offline |
55
55
  | `secretEnv` | Environment variable name | Use this or `secret` | Variable holding the start secret, read when the definitions are parsed |
@@ -148,7 +148,7 @@ Every transition taken is journaled as a `state-change` entry.
148
148
 
149
149
  `kxm_workflow_wait` parks the active stage in `waiting` under a `signalKey` until a signed callback arrives or the wait expires (1 second to 30 days, default 24 hours; expiry fails the run). The coordinator can then reply to release its turn.
150
150
 
151
- The callback is `POST /v1/webhooks/<id>/runs/<runId>/signals/<signalKey>`, signed with HMAC-SHA256 of the raw body using the signal secret (or the start secret), with a delivery ID header. Its body carries `status`, `summary` and `evidence`. Evidence keys `workflow.run`, `workflow.stage` and `workflow.signal`, when present, must match the route (`workflow_signal_context_mismatch`). The signal checkpoints the stage with its status; if work remains, the hub sends the coordinator a resume message. See the [HTTP API](http-api.md#webhooks-and-signals) for headers and deduplication.
151
+ The callback is `POST /v1/webhooks/<id>/runs/<runId>/signals/<signalKey>`, signed under the [KXM sender contract](../guides/webhook-workflows.md#kxm-sender-contract) with the signal secret (or the start secret), bound to its timestamp, delivery ID, run and signal key. Its body carries `status`, `summary` and `evidence`. Evidence keys `workflow.run`, `workflow.stage` and `workflow.signal`, when present, must match the route (`workflow_signal_context_mismatch`). The signal checkpoints the stage with its status; if work remains, the hub sends the coordinator a resume message. See the [HTTP API](http-api.md#webhooks-and-signals) for headers and deduplication.
152
152
 
153
153
  When `autoResumeLimit` is set and a stage's attempts reach it without passing, the stage escalates instead of retrying: it waits for signal key `audit_escalation` for 24 hours and records a `kxm.terminal-receipt.v1` receipt. A signed `audit_escalation` signal, or `kxm role resume`, resumes it. Anyone holding the signal secret can clear an escalation.
154
154
 
@@ -155,7 +155,7 @@ kxm hub view: health=ok; planner; 1 online agent(s)
155
155
  ```
156
156
 
157
157
  > [!NOTE]
158
- > Without `KXM_AUTH_TOKEN`, the extension signs in with this machine's saved admin token, which the hub accepts only for projects that have no token of their own. Set the project token so that each agent holds only its own project's credential.
158
+ > Without `KXM_AUTH_TOKEN`, the extension signs in with the project token saved for this project on this machine, and never with the saved admin token. With neither, it reports the fix and stays offline. Set the project token so that each agent holds only its own project's credential.
159
159
 
160
160
  ## 7. Start the reviewer and send a request
161
161
 
@@ -1,5 +1,5 @@
1
- import { createHmac, randomUUID } from "node:crypto";
2
- import { canonicalWorkflowEvidenceKey } from "../plugins/kxm/src/workflow.ts";
1
+ import { randomUUID } from "node:crypto";
2
+ import { canonicalWorkflowEvidenceKey, workflowWebhookHeaders } from "../plugins/kxm/src/workflow.ts";
3
3
 
4
4
  const [runId, signalKey, status, summary, ...evidenceArgs] = process.argv.slice(2);
5
5
  const serverUrl = process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331";
@@ -31,7 +31,6 @@ if (!runId || !signalKey || !status || !summary || !definitionId || !secret) {
31
31
  }
32
32
  const evidence = Object.fromEntries(evidenceEntries);
33
33
  const body = JSON.stringify({ status, summary, evidence });
34
- const signature = `sha256=${createHmac("sha256", secret).update(body).digest("hex")}`;
35
34
  const deliveryId = process.env.KXM_SIGNAL_DELIVERY_ID?.trim() || `example-${randomUUID()}`;
36
35
  const endpoint = [
37
36
  serverUrl.replace(/\/$/, ""),
@@ -46,8 +45,8 @@ if (!runId || !signalKey || !status || !summary || !definitionId || !secret) {
46
45
  method: "POST",
47
46
  headers: {
48
47
  "content-type": "application/json",
49
- "x-hub-signature-256": signature,
50
- "x-kxm-delivery-id": deliveryId,
48
+ // Signs the timestamp, delivery ID, definition, run, signal key, and body.
49
+ ...workflowWebhookHeaders({ secret, scope: { definitionId, runId, signalKey }, deliveryId, body }),
51
50
  },
52
51
  body,
53
52
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.96",
3
+ "version": "0.7.97",
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.96",
5
+ "version": "0.7.97",
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",
@@ -8913,6 +8913,7 @@ __export(commands_exports, {
8913
8913
  AGENT_COMMANDS_MAP: () => AGENT_COMMANDS_MAP,
8914
8914
  clearSessionTokenFromDisk: () => clearSessionTokenFromDisk,
8915
8915
  enforceToolPolicy: () => enforceToolPolicy,
8916
+ forwardedHops: () => forwardedHops,
8916
8917
  getCliAgentCommands: () => getCliAgentCommands,
8917
8918
  getMcpTools: () => getMcpTools,
8918
8919
  getPiToolDefinitions: () => getPiToolDefinitions,
@@ -8932,6 +8933,13 @@ import { createHash, randomUUID as randomUUID3, timingSafeEqual } from "node:cry
8932
8933
  import { chmodSync, existsSync as existsSync7, mkdirSync as mkdirSync6, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync5 } from "node:fs";
8933
8934
  import { homedir as homedir3 } from "node:os";
8934
8935
  import { dirname as dirname3, join as join7, resolve as resolve5 } from "node:path";
8936
+ function forwardedHops(handling) {
8937
+ if (!handling?.length) return void 0;
8938
+ return {
8939
+ hops: Math.max(...handling.map((message) => message.hops)) + 1,
8940
+ maxHops: Math.min(...handling.map((message) => message.maxHops))
8941
+ };
8942
+ }
8935
8943
  function requiredString(value, name) {
8936
8944
  if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);
8937
8945
  return value.trim();
@@ -9336,12 +9344,13 @@ var init_commands = __esm({
9336
9344
  required: ["target", "content"],
9337
9345
  additionalProperties: false
9338
9346
  },
9339
- async execute(client, args) {
9347
+ async execute(client, args, context) {
9340
9348
  const delivery = optionalString(args.delivery);
9341
9349
  const correlationId = optionalString(args.correlationId);
9342
9350
  const idempotencyKey = optionalString(args.idempotencyKey);
9343
9351
  const workflowContext = optionalWorkflowContext(args.workflowContext);
9344
9352
  const message = await client.send({
9353
+ ...forwardedHops(context?.handling),
9345
9354
  target: requiredString(args.target, "target"),
9346
9355
  content: requiredString(args.content, "content"),
9347
9356
  ...delivery ? { delivery } : {},
@@ -9419,6 +9428,7 @@ var init_commands = __esm({
9419
9428
  const targets = Array.isArray(args.targets) ? args.targets.map((t) => requiredString(t, "target")) : [];
9420
9429
  return {
9421
9430
  responses: await client.fanout({
9431
+ ...forwardedHops(context?.handling),
9422
9432
  targets,
9423
9433
  content: requiredString(args.content, "content"),
9424
9434
  ...optionalString(args.correlationId) ? { correlationId: optionalString(args.correlationId) } : {},