@kontextmind/kxm 0.7.152 → 0.7.153

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.152",
14
+ "version": "0.7.153",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -6,20 +6,6 @@ All notable user-facing changes are documented here. The project follows [Semant
6
6
 
7
7
  ### Added
8
8
 
9
- - **An opted-in role continues a failed attempt on the next admitted route.**
10
- `policy.fallback.onError` lists `rate_limit`, `transport`,
11
- `provider_unavailable`, and `context_overflow`. An empty or absent list is
12
- unchanged: the attempt fails on the first route. The walk is bounded by
13
- `maxSwitches` (default 1), skips a critic-vendor collision when the role
14
- requires vendor independence, and records `routing.route_switched` plus a
15
- `route_switch` log line. A bounded redacted transcript is appended to the
16
- next prompt. `revert: never` keeps the successful route for later steps in
17
- the same run. `kxm routing report` and `kxm improve report` show the switch.
18
- Cancellation, policy refusal, authentication, an unhosted model, a gate
19
- failure, tool policy, and admission errors do not walk. See
20
- [role fallback](docs/reference/config-reference.md#kxmrolesroleyaml-kxmrolev2)
21
- and [mid-attempt fallback](docs/reference/harness-routing.md#mid-attempt-fallback).
22
-
23
9
  - **A live agent step uses a configurable one-shot timeout, and a cancelling run recovers when its child has already exited.**
24
10
  The bound is the step `timeoutMs`, or the project `limits.agentStepTimeoutMs`
25
11
  when the step omits it (minimum 60 seconds, default one hour). A wider step
@@ -154,38 +140,21 @@ All notable user-facing changes are documented here. The project follows [Semant
154
140
 
155
141
  - **Playwright uses Obscura by default.** `resolveBrowserCdpEndpoint()` returns `OBSCURA_CDP_URL` or `http://127.0.0.1:${OBSCURA_PORT:-9222}`. `KXM_BROWSER=steel` still returns the Steel session CDP URL. `node scripts/obscura.mjs` downloads pinned Obscura v0.2.3 and serves it with `--allow-private-network`. `npm run e2e` runs the Playwright smoke test in `test/e2e/` over CDP and does not run `playwright install`. Steel remains the path for human takeover, MFA, and the live session viewer. See [ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md) and [How do I connect Playwright to Obscura?](docs/kb/how-to-connect-playwright-to-obscura.md).
156
142
 
157
- ### Changed
158
-
159
- - **Pull-request CI runs the unit suite on Linux Node 24, and `CI / required` is the aggregate check.**
160
- Docs and plan markdown skip the code jobs. `engine.test.ts` is split by
161
- test name. `permission.test.ts` and `runtime.test.ts` run one file at a
162
- time, and every other unit file runs in one light lane. Pushes to `main`
163
- still run `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24.
164
- A pull request that touches path, process, shell, spawn, package, lockfile,
165
- or workflow files also runs the unit lanes on Windows. Playwright stays on
166
- Obscura. See [CI and release](docs/contributing/ci-and-release.md).
143
+ - **An opted-in role continues a failed attempt on the next admitted route.**
144
+ `policy.fallback.onError` lists `rate_limit`, `transport`,
145
+ `provider_unavailable`, and `context_overflow`. An empty or absent list is
146
+ unchanged: the attempt fails on the first route. The walk is bounded by
147
+ `maxSwitches` (default 1), skips a critic-vendor collision when the role
148
+ requires vendor independence, and records `routing.route_switched` plus a
149
+ `route_switch` log line. A bounded redacted transcript is appended to the
150
+ next prompt. `revert: never` keeps the successful route for later steps in
151
+ the same run. `kxm routing report` and `kxm improve report` show the switch.
152
+ Cancellation, policy refusal, authentication, an unhosted model, a gate
153
+ failure, tool policy, and admission errors do not walk. See
154
+ [role fallback](docs/reference/config-reference.md#kxmrolesroleyaml-kxmrolev2)
155
+ and [mid-attempt fallback](docs/reference/harness-routing.md#mid-attempt-fallback).
167
156
 
168
- - **Docs match the 2026-09-27 Steel and machine-account infrastructure.**
169
- Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
170
- through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
171
- LAN, tailnet, and host-forward access is blocked. Sessions return
172
- `websocketUrl` `wss://steel.kontextmind.com/` (previously
173
- `ws://steel-browser/`). The CDP path is `/v1/devtools` with an
174
- `Authorization` header. Allowed groups are `steel-users`, `kxmd-users`,
175
- `kxmd-admins`, and `kxmd-owners`. `STEEL_API_URL` defaults to
176
- `https://steel.kontextmind.com`. `STEEL_API_KEY` is deprecated and is not
177
- enforced by Steel or Caddy. Migrate to `STEEL_AUTH_HEADER`, then
178
- `STEEL_AUTH_BASIC`, then `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`; those
179
- override the key. The `svc-steel` credential is 1Password vault
180
- `kontextmind`, item `Steel (svc-steel)`, field `basic_auth`, read with
181
- `op read` and never written to disk. Steel requires `kxm` 0.7.135 or
182
- newer. Playwright stays on Obscura ([ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md)).
183
- The Proxmox boot order and VM names are in
184
- [Deploy KXM](docs/operations/deploy.md#boot-the-kxmd-proxmox-host).
185
- Machine accounts follow
186
- [ADR-0006](docs/adr/ADR-0006-machine-account-names.md). The DOKS deployment
187
- record is superseded by
188
- [ADR-0007](docs/adr/ADR-0007-steel-caddy-authentik.md).
157
+ ### Changed
189
158
 
190
159
  - **Workforce ids use one convention, and old ids still resolve.**
191
160
  Role ids stay `planner`, `writer`, `reviewer-arch`, and `reviewer-cli`.
@@ -511,6 +480,49 @@ All notable user-facing changes are documented here. The project follows [Semant
511
480
  persisting a projection write, so a read cannot mutate run state or surface a
512
481
  false `run_projection_divergent`.
513
482
 
483
+ - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
484
+ `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
485
+ header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
486
+ `KXM_BROWSER=steel` passes those headers through `connectBrowserOverCdp()`
487
+ into `chromium.connectOverCDP`. Obscura stays the default and sends no Steel
488
+ headers. `STEEL_AUTH_HEADER` overrides the value. The CDP URL omits the credential when
489
+ those variables are set. A 302 to the identity provider fails closed and does
490
+ not follow the login redirect. `STEEL_API_KEY` still sends the legacy
491
+ `x-steel-api-key` header and `apiKey` query parameter for the temporary proxy
492
+ shim, and warns once. See
493
+ [Browser automation](docs/guides/browser-automation.md).
494
+
495
+ - **Pull-request CI runs the unit suite on Linux Node 24, and `CI / required` is the aggregate check.**
496
+ Docs and plan markdown skip the code jobs. `engine.test.ts` is split by
497
+ test name. `permission.test.ts` and `runtime.test.ts` run one file at a
498
+ time, and every other unit file runs in one light lane. Pushes to `main`
499
+ still run `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24.
500
+ A pull request that touches path, process, shell, spawn, package, lockfile,
501
+ or workflow files also runs the unit lanes on Windows. Playwright stays on
502
+ Obscura. See [CI and release](docs/contributing/ci-and-release.md).
503
+
504
+ - **Docs match the 2026-09-27 Steel and machine-account infrastructure.**
505
+ Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
506
+ through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
507
+ LAN, tailnet, and host-forward access is blocked. Sessions return
508
+ `websocketUrl` `wss://steel.kontextmind.com/` (previously
509
+ `ws://steel-browser/`). The CDP path is `/v1/devtools` with an
510
+ `Authorization` header. Allowed groups are `steel-users`, `kxmd-users`,
511
+ `kxmd-admins`, and `kxmd-owners`. `STEEL_API_URL` defaults to
512
+ `https://steel.kontextmind.com`. `STEEL_API_KEY` is deprecated and is not
513
+ enforced by Steel or Caddy. Migrate to `STEEL_AUTH_HEADER`, then
514
+ `STEEL_AUTH_BASIC`, then `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`; those
515
+ override the key. The `svc-steel` credential is 1Password vault
516
+ `kontextmind`, item `Steel (svc-steel)`, field `basic_auth`, read with
517
+ `op read` and never written to disk. Steel requires `kxm` 0.7.135 or
518
+ newer. Playwright stays on Obscura ([ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md)).
519
+ The Proxmox boot order and VM names are in
520
+ [Deploy KXM](docs/operations/deploy.md#boot-the-kxmd-proxmox-host).
521
+ Machine accounts follow
522
+ [ADR-0006](docs/adr/ADR-0006-machine-account-names.md). The DOKS deployment
523
+ record is superseded by
524
+ [ADR-0007](docs/adr/ADR-0007-steel-caddy-authentik.md).
525
+
514
526
  - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
515
527
  `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
516
528
  header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
@@ -542,9 +554,9 @@ All notable user-facing changes are documented here. The project follows [Semant
542
554
 
543
555
  ### Fixed
544
556
 
545
- - **Dry-run ship status no longer rewrites `.git/index`.** `git status` refreshes the index under an optional lock. The ship-status read passes `--no-optional-locks`, so a dry run leaves the checkout untouched.
557
+ - **A one-shot outcome is the JSON object that ends the reply.** One forward scan tracks strings, escapes, and brace depth. The newest top-level object, opened at depth 0, must close at the end of the trimmed text (one closing code fence and trailing whitespace allowed). `JSON.parse` runs once on that slice. The `outcome` must be a string in the step's declared values. Braces inside a string do not count as structure, so a summary may contain any number of `{` characters. A bare object, a fenced object, and prose followed by an object that ends the reply settle when the outcome is allowed. Prose after the object, a reply with no object, a disallowed outcome, a stray `"` in the prose, and an inner object at the end of a truncated outer object settle `failed`.
546
558
 
547
- - **A committed checkout counts as authored work, and a one-shot outcome must be a standalone JSON object.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`. A one-shot outcome is accepted when the whole reply is one JSON object, or when that object stands alone on the final line. A closing code fence around the final object is allowed. An object followed by prose, a truncated reply, and an ambiguous tail settle `failed`.
559
+ - **A committed checkout counts as authored work.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`.
548
560
 
549
561
  - **The test suite no longer passes `--test-timeout`.** Under `node --test` that flag bounds each file, so coverage on CI timed out `test/core/engine.test.ts` at three minutes. The wall clock in `scripts/run-bounded.mjs` still bounds each script.
550
562
 
@@ -860,6 +872,10 @@ All notable user-facing changes are documented here. The project follows [Semant
860
872
 
861
873
  - **`kxm vision` is owned by the browser-verify skill.** `plugins/kxm/skill-suite.json` lists `vision` on `kxm-browser-verify`, the skill that already teaches `kxm vision assert`.
862
874
 
875
+ - **Dry-run ship status no longer rewrites `.git/index`.** `git status` refreshes the index under an optional lock. The ship-status read passes `--no-optional-locks`, so a dry run leaves the checkout untouched.
876
+
877
+ - **A committed checkout counts as authored work, and a one-shot outcome must be a standalone JSON object.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`. A one-shot outcome is accepted when the whole reply is one JSON object, or when that object stands alone on the final line. A closing code fence around the final object is allowed. An object followed by prose, a truncated reply, and an ambiguous tail settle `failed`.
878
+
863
879
  ## 0.7.0 - 2026-09-11
864
880
 
865
881
  ### Added
@@ -105,10 +105,18 @@ created → accepted → dispatched → executing → result_recorded → termin
105
105
  | `blocked_uncertain` | A dependent effect cannot be reconciled safely |
106
106
  | `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
107
107
 
108
- A producer reply becomes a terminal outcome **only** through a declared result: the whole
109
- reply is one JSON object, or the object stands alone on the final line. A closing code fence
110
- around that final object is allowed. If the tail is ambiguous, or prose follows the object,
111
- the outcome is `failed`.
108
+ A producer reply becomes a terminal outcome **only** through a declared result. A one-shot
109
+ reply declares that result when one forward scan finds a JSON object that closes at the
110
+ end of the trimmed text. The scan tracks strings, escapes, and brace depth together. A
111
+ `{` outside a string at depth 0 opens a top-level object, and the newest object that
112
+ returns to depth 0 is the candidate. One closing code fence may follow the object, with
113
+ whitespace. The object may be the whole reply, a fenced reply, or the end of surrounding
114
+ prose, on one line or several. `JSON.parse` runs once on that slice. The value must be a
115
+ plain object whose `outcome` string is one of the step's declared values. Braces inside
116
+ strings do not change depth. A stray `"` leaves the scan inside a string, so a following
117
+ object is not top-level and settles `failed`. Prose after the object, a reply with no
118
+ such object, a disallowed outcome, and an inner object at the end of a truncated outer
119
+ object settle `failed`.
112
120
 
113
121
  Naming an outcome *word* anywhere in a reply is not a result. `"the gate did not pass, so I
114
122
  would not call this passed"` must not advance a step, and an empty or unstructured reply is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.152",
3
+ "version": "0.7.153",
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.152",
5
+ "version": "0.7.153",
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",
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
17313
17313
  }
17314
17314
 
17315
17315
  // plugins/kxm/src/mcp-server.ts
17316
- var VERSION = "0.7.152";
17316
+ var VERSION = "0.7.153";
17317
17317
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17318
17318
  var inbox = /* @__PURE__ */ new Map();
17319
17319
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -30773,15 +30773,29 @@ function recoverKxmRun(context, runId, request) {
30773
30773
  }
30774
30774
 
30775
30775
  // plugins/kxm/src/oneshot-producer.ts
30776
- function lastBalancedJsonObject(text) {
30776
+ function isClosingFence(line) {
30777
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
30778
+ }
30779
+ function stripOneClosingFence(trimmed) {
30780
+ const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
30781
+ const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
30782
+ if (!isClosingFence(lastLine)) return trimmed;
30783
+ if (lineBreak < 0) return "";
30784
+ return trimmed.slice(0, lineBreak).trimEnd();
30785
+ }
30786
+ function determineOutcome(text, allowedOutcomes) {
30787
+ const trimmed = text.trim();
30788
+ if (!trimmed) return "failed";
30789
+ const body = stripOneClosingFence(trimmed);
30790
+ if (!body) return "failed";
30777
30791
  let depth = 0;
30778
30792
  let inString = false;
30779
30793
  let escape2 = false;
30780
- let start = -1;
30781
- let lastStart = -1;
30782
- let lastEnd = -1;
30783
- for (let i = 0; i < text.length; i++) {
30784
- const ch = text[i];
30794
+ let currentStart = -1;
30795
+ let objectStart = -1;
30796
+ let objectEnd = -1;
30797
+ for (let i = 0; i < body.length; i++) {
30798
+ const ch = body[i];
30785
30799
  if (inString) {
30786
30800
  if (escape2) {
30787
30801
  escape2 = false;
@@ -30799,7 +30813,7 @@ function lastBalancedJsonObject(text) {
30799
30813
  continue;
30800
30814
  }
30801
30815
  if (ch === "{") {
30802
- if (depth === 0) start = i;
30816
+ if (depth === 0) currentStart = i;
30803
30817
  depth++;
30804
30818
  continue;
30805
30819
  }
@@ -30807,50 +30821,22 @@ function lastBalancedJsonObject(text) {
30807
30821
  if (depth === 0) continue;
30808
30822
  depth--;
30809
30823
  if (depth === 0) {
30810
- lastStart = start;
30811
- lastEnd = i;
30824
+ objectStart = currentStart;
30825
+ objectEnd = i;
30812
30826
  }
30813
30827
  }
30814
30828
  }
30815
- if (lastStart < 0) return void 0;
30816
- return text.slice(lastStart, lastEnd + 1);
30817
- }
30818
- function standaloneObjectText(text) {
30819
- const trimmed = text.trim();
30820
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
30821
- const slice = lastBalancedJsonObject(trimmed);
30822
- return slice === trimmed ? slice : void 0;
30823
- }
30824
- function outcomeOfObjectText(text) {
30825
- const slice = standaloneObjectText(text);
30826
- if (slice === void 0) return void 0;
30829
+ if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
30830
+ let result;
30827
30831
  try {
30828
- const result = JSON.parse(slice);
30829
- if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
30830
- const outcome = result.outcome;
30831
- return typeof outcome === "string" ? outcome : void 0;
30832
+ result = JSON.parse(body.slice(objectStart, objectEnd + 1));
30832
30833
  } catch {
30833
- return void 0;
30834
+ return "failed";
30834
30835
  }
30835
- }
30836
- function isClosingFence(line) {
30837
- return /^(`{3,}|~{3,})\s*$/.test(line.trim());
30838
- }
30839
- function determineOutcome(text, allowedOutcomes) {
30840
- const trimmed = text.trim();
30841
- if (!trimmed) return "failed";
30842
- const whole = outcomeOfObjectText(trimmed);
30843
- if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
30844
- const lines = trimmed.split(/\r?\n/);
30845
- while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
30846
- if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
30847
- lines.pop();
30848
- while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
30849
- }
30850
- if (lines.length === 0) return "failed";
30851
- const declared = outcomeOfObjectText(lines[lines.length - 1]);
30852
- if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
30853
- return declared;
30836
+ if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
30837
+ const outcome = result.outcome;
30838
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
30839
+ return outcome;
30854
30840
  }
30855
30841
  function createKxmOneShotProducer(options = {}) {
30856
30842
  const running = /* @__PURE__ */ new Map();
@@ -31521,15 +31521,29 @@ function recoverKxmRun(context, runId, request) {
31521
31521
  }
31522
31522
 
31523
31523
  // plugins/kxm/src/oneshot-producer.ts
31524
- function lastBalancedJsonObject(text) {
31524
+ function isClosingFence(line) {
31525
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
31526
+ }
31527
+ function stripOneClosingFence(trimmed) {
31528
+ const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
31529
+ const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
31530
+ if (!isClosingFence(lastLine)) return trimmed;
31531
+ if (lineBreak < 0) return "";
31532
+ return trimmed.slice(0, lineBreak).trimEnd();
31533
+ }
31534
+ function determineOutcome(text, allowedOutcomes) {
31535
+ const trimmed = text.trim();
31536
+ if (!trimmed) return "failed";
31537
+ const body = stripOneClosingFence(trimmed);
31538
+ if (!body) return "failed";
31525
31539
  let depth = 0;
31526
31540
  let inString = false;
31527
31541
  let escape2 = false;
31528
- let start = -1;
31529
- let lastStart = -1;
31530
- let lastEnd = -1;
31531
- for (let i = 0; i < text.length; i++) {
31532
- const ch = text[i];
31542
+ let currentStart = -1;
31543
+ let objectStart = -1;
31544
+ let objectEnd = -1;
31545
+ for (let i = 0; i < body.length; i++) {
31546
+ const ch = body[i];
31533
31547
  if (inString) {
31534
31548
  if (escape2) {
31535
31549
  escape2 = false;
@@ -31547,7 +31561,7 @@ function lastBalancedJsonObject(text) {
31547
31561
  continue;
31548
31562
  }
31549
31563
  if (ch === "{") {
31550
- if (depth === 0) start = i;
31564
+ if (depth === 0) currentStart = i;
31551
31565
  depth++;
31552
31566
  continue;
31553
31567
  }
@@ -31555,50 +31569,22 @@ function lastBalancedJsonObject(text) {
31555
31569
  if (depth === 0) continue;
31556
31570
  depth--;
31557
31571
  if (depth === 0) {
31558
- lastStart = start;
31559
- lastEnd = i;
31572
+ objectStart = currentStart;
31573
+ objectEnd = i;
31560
31574
  }
31561
31575
  }
31562
31576
  }
31563
- if (lastStart < 0) return void 0;
31564
- return text.slice(lastStart, lastEnd + 1);
31565
- }
31566
- function standaloneObjectText(text) {
31567
- const trimmed = text.trim();
31568
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return void 0;
31569
- const slice = lastBalancedJsonObject(trimmed);
31570
- return slice === trimmed ? slice : void 0;
31571
- }
31572
- function outcomeOfObjectText(text) {
31573
- const slice = standaloneObjectText(text);
31574
- if (slice === void 0) return void 0;
31577
+ if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
31578
+ let result;
31575
31579
  try {
31576
- const result = JSON.parse(slice);
31577
- if (!result || typeof result !== "object" || Array.isArray(result)) return void 0;
31578
- const outcome = result.outcome;
31579
- return typeof outcome === "string" ? outcome : void 0;
31580
+ result = JSON.parse(body.slice(objectStart, objectEnd + 1));
31580
31581
  } catch {
31581
- return void 0;
31582
+ return "failed";
31582
31583
  }
31583
- }
31584
- function isClosingFence(line) {
31585
- return /^(`{3,}|~{3,})\s*$/.test(line.trim());
31586
- }
31587
- function determineOutcome(text, allowedOutcomes) {
31588
- const trimmed = text.trim();
31589
- if (!trimmed) return "failed";
31590
- const whole = outcomeOfObjectText(trimmed);
31591
- if (whole !== void 0) return allowedOutcomes.includes(whole) ? whole : "failed";
31592
- const lines = trimmed.split(/\r?\n/);
31593
- while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
31594
- if (lines.length > 0 && isClosingFence(lines[lines.length - 1])) {
31595
- lines.pop();
31596
- while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
31597
- }
31598
- if (lines.length === 0) return "failed";
31599
- const declared = outcomeOfObjectText(lines[lines.length - 1]);
31600
- if (declared === void 0 || !allowedOutcomes.includes(declared)) return "failed";
31601
- return declared;
31584
+ if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
31585
+ const outcome = result.outcome;
31586
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
31587
+ return outcome;
31602
31588
  }
31603
31589
  function createKxmOneShotProducer(options = {}) {
31604
31590
  const running = /* @__PURE__ */ new Map();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.152",
3
+ "version": "0.7.153",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -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.152";
14
+ const VERSION = "0.7.153";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -51,21 +51,47 @@ export interface KxmOneShotProducer extends KxmProducer {
51
51
  close(): Promise<void>;
52
52
  }
53
53
 
54
- // The last complete top-level `{...}`. One forward pass tracks strings,
55
- // escapes, and brace depth. A `{` that lifts depth from 0 to 1 opens a
56
- // candidate; the matching `}` that returns depth from 1 to 0 closes it.
57
- // The last such span wins. Callers accept the span only when it is the
58
- // entire trimmed reply or the entire final line; an inner object is not
59
- // an outcome.
60
- function lastBalancedJsonObject(text: string): string | undefined {
54
+ function isClosingFence(line: string): boolean {
55
+ return /^(`{3,}|~{3,})\s*$/.test(line.trim());
56
+ }
57
+
58
+ // Drop one closing code fence when it is the last line. The caller has already
59
+ // trimmed the reply. Any other tail stays, so prose after the object still fails.
60
+ function stripOneClosingFence(trimmed: string): string {
61
+ const lineBreak = Math.max(trimmed.lastIndexOf("\n"), trimmed.lastIndexOf("\r"));
62
+ const lastLine = lineBreak < 0 ? trimmed : trimmed.slice(lineBreak + 1);
63
+ if (!isClosingFence(lastLine)) return trimmed;
64
+ if (lineBreak < 0) return "";
65
+ return trimmed.slice(0, lineBreak).trimEnd();
66
+ }
67
+
68
+ // The outcome object is the newest complete top-level JSON object in the
69
+ // trimmed reply, and that object must close on the reply's last character.
70
+ // One closing code fence may follow it; the fence is removed before the scan.
71
+ // One forward pass tracks string state and brace depth together. A `"` toggles
72
+ // a string, and `\` escapes only inside a string. A `{` outside a string at
73
+ // depth 0 opens a top-level object. When depth returns to 0, that object is
74
+ // the newest complete top-level object. Braces inside strings do not change
75
+ // depth, so the same scan shows that the object started at depth 0 and that
76
+ // quotes in the prefix are closed. After the scan, if that object closes at
77
+ // the end of the body, JSON.parse runs once on the slice. The value must be
78
+ // a plain object whose outcome is a string in allowedOutcomes. Anything else
79
+ // is failed: prose after the object, no object, a disallowed outcome, an
80
+ // inner object at the end of a truncated outer object, a stray quote that
81
+ // leaves a string open, or a prefix whose braces never return to depth 0.
82
+ function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
83
+ const trimmed = text.trim();
84
+ if (!trimmed) return "failed";
85
+ const body = stripOneClosingFence(trimmed);
86
+ if (!body) return "failed";
61
87
  let depth = 0;
62
88
  let inString = false;
63
89
  let escape = false;
64
- let start = -1;
65
- let lastStart = -1;
66
- let lastEnd = -1;
67
- for (let i = 0; i < text.length; i++) {
68
- const ch = text[i];
90
+ let currentStart = -1;
91
+ let objectStart = -1;
92
+ let objectEnd = -1;
93
+ for (let i = 0; i < body.length; i++) {
94
+ const ch = body[i];
69
95
  if (inString) {
70
96
  if (escape) {
71
97
  escape = false;
@@ -83,7 +109,7 @@ function lastBalancedJsonObject(text: string): string | undefined {
83
109
  continue;
84
110
  }
85
111
  if (ch === "{") {
86
- if (depth === 0) start = i;
112
+ if (depth === 0) currentStart = i;
87
113
  depth++;
88
114
  continue;
89
115
  }
@@ -91,59 +117,22 @@ function lastBalancedJsonObject(text: string): string | undefined {
91
117
  if (depth === 0) continue;
92
118
  depth--;
93
119
  if (depth === 0) {
94
- lastStart = start;
95
- lastEnd = i;
120
+ objectStart = currentStart;
121
+ objectEnd = i;
96
122
  }
97
123
  }
98
124
  }
99
- if (lastStart < 0) return undefined;
100
- return text.slice(lastStart, lastEnd + 1);
101
- }
102
-
103
- // A standalone object is one balanced `{...}` covering the entire trimmed text.
104
- function standaloneObjectText(text: string): string | undefined {
105
- const trimmed = text.trim();
106
- if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
107
- const slice = lastBalancedJsonObject(trimmed);
108
- return slice === trimmed ? slice : undefined;
109
- }
110
-
111
- function outcomeOfObjectText(text: string): string | undefined {
112
- const slice = standaloneObjectText(text);
113
- if (slice === undefined) return undefined;
125
+ if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
126
+ let result: unknown;
114
127
  try {
115
- const result: unknown = JSON.parse(slice);
116
- if (!result || typeof result !== "object" || Array.isArray(result)) return undefined;
117
- const outcome = (result as Record<string, unknown>).outcome;
118
- return typeof outcome === "string" ? outcome : undefined;
128
+ result = JSON.parse(body.slice(objectStart, objectEnd + 1));
119
129
  } catch {
120
- return undefined;
121
- }
122
- }
123
-
124
- function isClosingFence(line: string): boolean {
125
- return /^(`{3,}|~{3,})\s*$/.test(line.trim());
126
- }
127
-
128
- // The whole reply is one JSON object, or the object stands alone on the final
129
- // line. A closing code fence after that line is allowed. Prose after the
130
- // object, an ambiguous tail, a truncated reply, and a missing or disallowed
131
- // outcome settle failed.
132
- function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
133
- const trimmed = text.trim();
134
- if (!trimmed) return "failed";
135
- const whole = outcomeOfObjectText(trimmed);
136
- if (whole !== undefined) return allowedOutcomes.includes(whole) ? whole : "failed";
137
- const lines = trimmed.split(/\r?\n/);
138
- while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
139
- if (lines.length > 0 && isClosingFence(lines[lines.length - 1]!)) {
140
- lines.pop();
141
- while (lines.length > 0 && lines[lines.length - 1]!.trim() === "") lines.pop();
130
+ return "failed";
142
131
  }
143
- if (lines.length === 0) return "failed";
144
- const declared = outcomeOfObjectText(lines[lines.length - 1]!);
145
- if (declared === undefined || !allowedOutcomes.includes(declared)) return "failed";
146
- return declared;
132
+ if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
133
+ const outcome = (result as Record<string, unknown>).outcome;
134
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
135
+ return outcome;
147
136
  }
148
137
 
149
138
  export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {