@kontextmind/kxm 0.7.157 → 0.7.159

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.157",
14
+ "version": "0.7.159",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -167,9 +167,10 @@ All notable user-facing changes are documented here. The project follows [Semant
167
167
  checks, 8.3-versus-long temp paths, and forward-slash path checks in the
168
168
  unit tests match what Windows can report. A spawned MCP server is stopped
169
169
  by closing stdin, which runs its unregister path, before its temp directory
170
- is removed; a directory that is still locked is retried and then left
171
- behind instead of failing the test. `CI / required` still fails when a lane
172
- fails or is cancelled.
170
+ is removed. A directory that is still locked is retried for about three
171
+ seconds and then left behind, instead of a 20-by-250ms backoff (about 52
172
+ seconds) that the Windows light lane still paid once per locked directory.
173
+ `CI / required` still fails when a lane fails or is cancelled.
173
174
  See [CI and release](docs/contributing/ci-and-release.md).
174
175
 
175
176
  - **Workforce ids use one convention, and old ids still resolve.**
@@ -413,22 +414,14 @@ All notable user-facing changes are documented here. The project follows [Semant
413
414
  reply for any declared outcome *word* and, failing that, returned `passed`. So
414
415
  `"the gate did not pass, so I would not call this passed"` settled the step as passed — the
415
416
  word was all it took — and an empty or prose-only reply was passed by default. Only a declared
416
- result counts now: a reply that is one JSON object, or prose carrying an explicit
417
- `{"outcome": "…"}` block, and only when the step declares that outcome. Anything else is
417
+ result counts now, and only when the step declares that outcome. Anything else is
418
418
  `failed`; an undeclared value still lands in `outcome_unknown` and terminates as `failed`, so a
419
419
  step without a `failed` transition cannot pass on a bad reply either. This matches the rule the
420
420
  one-shot producer already enforced, and `docs/contracts/lifecycles.md` now states it where
421
421
  `result_recorded` is defined. Three usage-capture fixtures that had been replying in prose now
422
422
  declare their result, which is what they were always supposed to do; the new test in
423
- `test/core/pi-producer.test.ts` covers prose, empty, out-of-vocabulary, and both accepted
424
- structured shapes. Review of that first cut found two more ways to mint success, both now
425
- closed: a result block was matched **anywhere** in the reply, so
426
- `Example: {"outcome": "passed"}. Actual result: {"outcome": "failed"}` returned `passed`;
427
- a declaration is now a standalone JSON object — the whole reply, or one object on its own
428
- line, with the **last** such object winning so an illustration cannot outrank the answer, and
429
- the whole reply settling `failed` when anything after that line still looks like an outcome
430
- key, because at that point the producer cannot tell which declaration was meant.
431
- And cancellation fell through to `allowedOutcomes[0]` when a step declared neither
423
+ `test/core/pi-producer.test.ts` covers prose, empty, and out-of-vocabulary replies.
424
+ Cancellation fell through to `allowedOutcomes[0]` when a step declared neither
432
425
  `cancelled` nor `failed`, so aborting a `passed`-only step reported `passed`; a cancel now
433
426
  reports `cancelled` unconditionally and the engine terminates it `failed` when the step
434
427
  does not declare that outcome.
@@ -571,9 +564,7 @@ All notable user-facing changes are documented here. The project follows [Semant
571
564
 
572
565
  ### Fixed
573
566
 
574
- - **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`.
575
-
576
- - **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`.
567
+ - **A one-shot outcome is the last top-level JSON object that ends the reply.** It is accepted only when the prefix before it leaves every string, object, and array closed (a naive state machine plus a container lexer over the last container openers). Anything ambiguous, truncated, or followed by prose fails closed. The authoring witness counts `HEAD`, so a writer that commits is credited.
577
568
 
578
569
  - **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.
579
570
 
@@ -891,8 +882,6 @@ All notable user-facing changes are documented here. The project follows [Semant
891
882
 
892
883
  - **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.
893
884
 
894
- - **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`.
895
-
896
885
  ## 0.7.0 - 2026-09-11
897
886
 
898
887
  ### Added
@@ -106,17 +106,28 @@ created → accepted → dispatched → executing → result_recorded → termin
106
106
  | `terminal` | The logical assignment outcome is final and immutable: passed, failed, or cancelled |
107
107
 
108
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`.
109
+ reply declares that result when a candidate slice from a `{` to a `}` parses as a JSON
110
+ object that closes at the end of the trimmed text. Candidates are tried nearest last.
111
+ The walk considers at most the last 32 closers and the last 32 openers, skips a slice
112
+ whose UTF-8 size is over 256 KiB, and stops after 1 MiB of UTF-8 bytes from parsed
113
+ slices. One closing code fence may follow the object, with whitespace. The object may be the whole reply, a fenced reply,
114
+ or the end of surrounding prose, on one line or several. `JSON.parse` is the authority
115
+ for strings and escapes inside the slice. The value must be a plain object whose
116
+ `outcome` string is one of the step's declared values. The prefix before that opener,
117
+ with trailing whitespace removed, is scanned by a naive JSON lexer: an unescaped quote
118
+ toggles string state, a backslash escapes the next character inside a string, and `{`
119
+ and `[` track depth. Extra `}` and `]` do not drive depth below zero. The candidate is
120
+ accepted only when that scan ends at depth 0 for both braces and arrays, outside a
121
+ string, with no dangling escape. Any other end state is truncated or ambiguous, so
122
+ that candidate is rejected and the next one is tried. The naive machine plus a
123
+ container-lexer guard reject truncation. For each of the last 16 `{` or `[`
124
+ openers in the prefix, a lexer from that opener through the end of the body
125
+ rejects the candidate when the scan stays valid and ends inside a container or
126
+ a string. A quote is not an opener. Prose with an odd quote count fails closed.
127
+ Quotes, backticks, and balanced braces in the prose do not hide an
128
+ object that ends the reply. Prose after the object, a reply with no such object, a
129
+ disallowed outcome, an inner object at the end of a truncated outer object or array,
130
+ and an object inside an unclosed string settle `failed`.
120
131
 
121
132
  Naming an outcome *word* anywhere in a reply is not a result. `"the gate did not pass, so I
122
133
  would not call this passed"` must not advance a step, and an empty or unstructured reply is
@@ -90,7 +90,10 @@ which was 268 seconds when every non-engine file shared one job.
90
90
  `package-install.test.ts` is serial because on Windows it took 171 seconds
91
91
  and the light job stopped reporting tests after that file finished (cancelled
92
92
  at 20 minutes on pull requests #361, #362, and #363). The other light files
93
- are round-robin split so each Windows job is about half of that pool. Linux
93
+ are round-robin split so each Windows job is about half of that pool. A
94
+ locked test directory is abandoned after about three seconds. The previous
95
+ retry was about 52 seconds and still failed with `EPERM`, which is what
96
+ pushed Windows `light-1` to 13 minutes when many tests hit it. Linux
94
97
  jobs use the npm cache from `actions/setup-node`. Restoring a
95
98
  `node_modules` tarball was slower than `npm ci` on the Linux runners (about
96
99
  24s versus 17s on 2026-09-24), so that cache stays on the Windows jobs,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.157",
3
+ "version": "0.7.159",
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.157",
5
+ "version": "0.7.159",
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",
@@ -27105,6 +27105,7 @@ var init_engine = __esm({
27105
27105
  });
27106
27106
 
27107
27107
  // plugins/kxm/src/oneshot-producer.ts
27108
+ var MAX_OUTCOME_SLICE_BYTES, MAX_OUTCOME_PARSE_BYTES;
27108
27109
  var init_oneshot_producer = __esm({
27109
27110
  "plugins/kxm/src/oneshot-producer.ts"() {
27110
27111
  "use strict";
@@ -27114,6 +27115,8 @@ var init_oneshot_producer = __esm({
27114
27115
  init_harness();
27115
27116
  init_prices();
27116
27117
  init_engine();
27118
+ MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
27119
+ MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
27117
27120
  }
27118
27121
  });
27119
27122
 
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
17313
17313
  }
17314
17314
 
17315
17315
  // plugins/kxm/src/mcp-server.ts
17316
- var VERSION = "0.7.157";
17316
+ var VERSION = "0.7.159";
17317
17317
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17318
17318
  var inbox = /* @__PURE__ */ new Map();
17319
17319
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -30790,18 +30790,46 @@ function stripOneClosingFence(trimmed) {
30790
30790
  if (lineBreak < 0) return "";
30791
30791
  return trimmed.slice(0, lineBreak).trimEnd();
30792
30792
  }
30793
- function determineOutcome(text, allowedOutcomes) {
30794
- const trimmed = text.trim();
30795
- if (!trimmed) return "failed";
30796
- const body = stripOneClosingFence(trimmed);
30797
- if (!body) return "failed";
30798
- let depth = 0;
30793
+ var MAX_OUTCOME_CLOSERS = 32;
30794
+ var MAX_OUTCOME_OPENERS = 32;
30795
+ var MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
30796
+ var MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
30797
+ var MAX_PREFIX_CONTAINER_OPENERS = 16;
30798
+ function outcomePrefixIsAnchored(prefix) {
30799
+ let objectDepth = 0;
30800
+ let arrayDepth = 0;
30801
+ let inString = false;
30802
+ let escape2 = false;
30803
+ for (let i = 0; i < prefix.length; i++) {
30804
+ const ch = prefix[i];
30805
+ if (inString) {
30806
+ if (escape2) {
30807
+ escape2 = false;
30808
+ continue;
30809
+ }
30810
+ if (ch === "\\") {
30811
+ escape2 = true;
30812
+ continue;
30813
+ }
30814
+ if (ch === '"') inString = false;
30815
+ continue;
30816
+ }
30817
+ if (ch === '"') {
30818
+ inString = true;
30819
+ continue;
30820
+ }
30821
+ if (ch === "{") objectDepth += 1;
30822
+ else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
30823
+ else if (ch === "[") arrayDepth += 1;
30824
+ else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
30825
+ }
30826
+ return objectDepth === 0 && arrayDepth === 0 && !inString && !escape2;
30827
+ }
30828
+ function containerLexIsTruncated(body, start) {
30829
+ const stack = [];
30799
30830
  let inString = false;
30800
30831
  let escape2 = false;
30801
- let currentStart = -1;
30802
- let objectStart = -1;
30803
- let objectEnd = -1;
30804
- for (let i = 0; i < body.length; i++) {
30832
+ for (let i = start; i < body.length; i++) {
30805
30833
  const ch = body[i];
30806
30834
  if (inString) {
30807
30835
  if (escape2) {
@@ -30819,31 +30847,67 @@ function determineOutcome(text, allowedOutcomes) {
30819
30847
  inString = true;
30820
30848
  continue;
30821
30849
  }
30822
- if (ch === "{") {
30823
- if (depth === 0) currentStart = i;
30824
- depth++;
30850
+ if (ch === "{" || ch === "[") {
30851
+ stack.push(ch);
30825
30852
  continue;
30826
30853
  }
30827
- if (ch === "}") {
30828
- if (depth === 0) continue;
30829
- depth--;
30830
- if (depth === 0) {
30831
- objectStart = currentStart;
30832
- objectEnd = i;
30833
- }
30854
+ if (ch === "}" || ch === "]") {
30855
+ const open2 = ch === "}" ? "{" : "[";
30856
+ const top = stack[stack.length - 1];
30857
+ if (top !== open2) return false;
30858
+ stack.pop();
30834
30859
  }
30835
30860
  }
30836
- if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
30837
- let result;
30838
- try {
30839
- result = JSON.parse(body.slice(objectStart, objectEnd + 1));
30840
- } catch {
30841
- return "failed";
30861
+ return stack.length > 0 || inString || escape2;
30862
+ }
30863
+ function prefixHidesTruncatedContainer(body, openAt) {
30864
+ let seen = 0;
30865
+ for (let i = openAt - 1; i >= 0; i--) {
30866
+ const ch = body[i];
30867
+ if (ch !== "{" && ch !== "[") continue;
30868
+ if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
30869
+ seen++;
30870
+ if (containerLexIsTruncated(body, i)) return true;
30871
+ }
30872
+ return false;
30873
+ }
30874
+ function determineOutcome(text, allowedOutcomes) {
30875
+ const trimmed = text.trim();
30876
+ if (!trimmed) return "failed";
30877
+ const body = stripOneClosingFence(trimmed);
30878
+ if (!body) return "failed";
30879
+ let seenClosers = 0;
30880
+ let parseBytes = 0;
30881
+ for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
30882
+ if (body[closeAt] !== "}") continue;
30883
+ seenClosers++;
30884
+ const end = closeAt + 1;
30885
+ if (end !== body.length) continue;
30886
+ let seenOpeners = 0;
30887
+ for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
30888
+ if (body[openAt] !== "{") continue;
30889
+ seenOpeners++;
30890
+ const slice = body.slice(openAt, end);
30891
+ const sliceBytes = Buffer.byteLength(slice, "utf8");
30892
+ if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
30893
+ if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
30894
+ parseBytes += sliceBytes;
30895
+ let result;
30896
+ try {
30897
+ result = JSON.parse(slice);
30898
+ } catch {
30899
+ continue;
30900
+ }
30901
+ if (!result || typeof result !== "object" || Array.isArray(result)) continue;
30902
+ const outcome = result.outcome;
30903
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
30904
+ const prefix = body.slice(0, openAt).trimEnd();
30905
+ if (!outcomePrefixIsAnchored(prefix)) continue;
30906
+ if (prefixHidesTruncatedContainer(body, openAt)) continue;
30907
+ return outcome;
30908
+ }
30842
30909
  }
30843
- if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
30844
- const outcome = result.outcome;
30845
- if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
30846
- return outcome;
30910
+ return "failed";
30847
30911
  }
30848
30912
  function createKxmOneShotProducer(options = {}) {
30849
30913
  const running = /* @__PURE__ */ new Map();
@@ -31538,18 +31538,46 @@ function stripOneClosingFence(trimmed) {
31538
31538
  if (lineBreak < 0) return "";
31539
31539
  return trimmed.slice(0, lineBreak).trimEnd();
31540
31540
  }
31541
- function determineOutcome(text, allowedOutcomes) {
31542
- const trimmed = text.trim();
31543
- if (!trimmed) return "failed";
31544
- const body = stripOneClosingFence(trimmed);
31545
- if (!body) return "failed";
31546
- let depth = 0;
31541
+ var MAX_OUTCOME_CLOSERS = 32;
31542
+ var MAX_OUTCOME_OPENERS = 32;
31543
+ var MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
31544
+ var MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
31545
+ var MAX_PREFIX_CONTAINER_OPENERS = 16;
31546
+ function outcomePrefixIsAnchored(prefix) {
31547
+ let objectDepth = 0;
31548
+ let arrayDepth = 0;
31549
+ let inString = false;
31550
+ let escape2 = false;
31551
+ for (let i = 0; i < prefix.length; i++) {
31552
+ const ch = prefix[i];
31553
+ if (inString) {
31554
+ if (escape2) {
31555
+ escape2 = false;
31556
+ continue;
31557
+ }
31558
+ if (ch === "\\") {
31559
+ escape2 = true;
31560
+ continue;
31561
+ }
31562
+ if (ch === '"') inString = false;
31563
+ continue;
31564
+ }
31565
+ if (ch === '"') {
31566
+ inString = true;
31567
+ continue;
31568
+ }
31569
+ if (ch === "{") objectDepth += 1;
31570
+ else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
31571
+ else if (ch === "[") arrayDepth += 1;
31572
+ else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
31573
+ }
31574
+ return objectDepth === 0 && arrayDepth === 0 && !inString && !escape2;
31575
+ }
31576
+ function containerLexIsTruncated(body, start) {
31577
+ const stack = [];
31547
31578
  let inString = false;
31548
31579
  let escape2 = false;
31549
- let currentStart = -1;
31550
- let objectStart = -1;
31551
- let objectEnd = -1;
31552
- for (let i = 0; i < body.length; i++) {
31580
+ for (let i = start; i < body.length; i++) {
31553
31581
  const ch = body[i];
31554
31582
  if (inString) {
31555
31583
  if (escape2) {
@@ -31567,31 +31595,67 @@ function determineOutcome(text, allowedOutcomes) {
31567
31595
  inString = true;
31568
31596
  continue;
31569
31597
  }
31570
- if (ch === "{") {
31571
- if (depth === 0) currentStart = i;
31572
- depth++;
31598
+ if (ch === "{" || ch === "[") {
31599
+ stack.push(ch);
31573
31600
  continue;
31574
31601
  }
31575
- if (ch === "}") {
31576
- if (depth === 0) continue;
31577
- depth--;
31578
- if (depth === 0) {
31579
- objectStart = currentStart;
31580
- objectEnd = i;
31581
- }
31602
+ if (ch === "}" || ch === "]") {
31603
+ const open2 = ch === "}" ? "{" : "[";
31604
+ const top = stack[stack.length - 1];
31605
+ if (top !== open2) return false;
31606
+ stack.pop();
31582
31607
  }
31583
31608
  }
31584
- if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
31585
- let result;
31586
- try {
31587
- result = JSON.parse(body.slice(objectStart, objectEnd + 1));
31588
- } catch {
31589
- return "failed";
31609
+ return stack.length > 0 || inString || escape2;
31610
+ }
31611
+ function prefixHidesTruncatedContainer(body, openAt) {
31612
+ let seen = 0;
31613
+ for (let i = openAt - 1; i >= 0; i--) {
31614
+ const ch = body[i];
31615
+ if (ch !== "{" && ch !== "[") continue;
31616
+ if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
31617
+ seen++;
31618
+ if (containerLexIsTruncated(body, i)) return true;
31619
+ }
31620
+ return false;
31621
+ }
31622
+ function determineOutcome(text, allowedOutcomes) {
31623
+ const trimmed = text.trim();
31624
+ if (!trimmed) return "failed";
31625
+ const body = stripOneClosingFence(trimmed);
31626
+ if (!body) return "failed";
31627
+ let seenClosers = 0;
31628
+ let parseBytes = 0;
31629
+ for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
31630
+ if (body[closeAt] !== "}") continue;
31631
+ seenClosers++;
31632
+ const end = closeAt + 1;
31633
+ if (end !== body.length) continue;
31634
+ let seenOpeners = 0;
31635
+ for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
31636
+ if (body[openAt] !== "{") continue;
31637
+ seenOpeners++;
31638
+ const slice = body.slice(openAt, end);
31639
+ const sliceBytes = Buffer.byteLength(slice, "utf8");
31640
+ if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
31641
+ if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
31642
+ parseBytes += sliceBytes;
31643
+ let result;
31644
+ try {
31645
+ result = JSON.parse(slice);
31646
+ } catch {
31647
+ continue;
31648
+ }
31649
+ if (!result || typeof result !== "object" || Array.isArray(result)) continue;
31650
+ const outcome = result.outcome;
31651
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
31652
+ const prefix = body.slice(0, openAt).trimEnd();
31653
+ if (!outcomePrefixIsAnchored(prefix)) continue;
31654
+ if (prefixHidesTruncatedContainer(body, openAt)) continue;
31655
+ return outcome;
31656
+ }
31590
31657
  }
31591
- if (!result || typeof result !== "object" || Array.isArray(result)) return "failed";
31592
- const outcome = result.outcome;
31593
- if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) return "failed";
31594
- return outcome;
31658
+ return "failed";
31595
31659
  }
31596
31660
  function createKxmOneShotProducer(options = {}) {
31597
31661
  const running = /* @__PURE__ */ new Map();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.157",
3
+ "version": "0.7.159",
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.157";
14
+ const VERSION = "0.7.159";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -65,32 +65,77 @@ function stripOneClosingFence(trimmed: string): string {
65
65
  return trimmed.slice(0, lineBreak).trimEnd();
66
66
  }
67
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";
87
- let depth = 0;
68
+ // How many `}` characters, counting from the end of the reply, we will try
69
+ // as the end of an outcome object.
70
+ const MAX_OUTCOME_CLOSERS = 32;
71
+
72
+ // How many `{` openers before that closer we will consider, nearest last.
73
+ const MAX_OUTCOME_OPENERS = 32;
74
+
75
+ // A candidate whose UTF-8 size is over this is not parsed. Older openers
76
+ // for the same closer contain the nearer slice, so the opener walk stops
77
+ // there. The count is Buffer.byteLength, not JavaScript string length.
78
+ const MAX_OUTCOME_SLICE_BYTES = 256 * 1024;
79
+
80
+ // Total UTF-8 bytes of candidate slices JSON.parse may see in one lookup.
81
+ // Crossing this budget stops the lookup. Same byte measure as the slice cap.
82
+ const MAX_OUTCOME_PARSE_BYTES = 1024 * 1024;
83
+
84
+ // How many `{` / `[` openers at the end of the prefix are re-lexed. A `"`
85
+ // is not an opener. Each re-lex walks the body from that opener to the end,
86
+ // so the lexer work is linear in the body per opener checked, per candidate.
87
+ // This cap only limits how many openers are re-lexed. It does not bound the
88
+ // lexer. The slice budgets bound JSON.parse bytes, not these scans. If the
89
+ // walk reaches the cap and another container opener is still earlier in the
90
+ // prefix, the prefix is ambiguous and the candidate is rejected.
91
+ const MAX_PREFIX_CONTAINER_OPENERS = 16;
92
+
93
+ // Naive prefix scan. Quote handling toggles on every unescaped `"`. Braces
94
+ // and brackets count only outside a string. Extra closers stay at depth 0
95
+ // because they are prose, not an unclosed container. The slice itself is
96
+ // not scanned: JSON.parse owns strings and escapes there. O(prefix).
97
+ function outcomePrefixIsAnchored(prefix: string): boolean {
98
+ let objectDepth = 0;
99
+ let arrayDepth = 0;
88
100
  let inString = false;
89
101
  let escape = false;
90
- let currentStart = -1;
91
- let objectStart = -1;
92
- let objectEnd = -1;
93
- for (let i = 0; i < body.length; i++) {
102
+ for (let i = 0; i < prefix.length; i++) {
103
+ const ch = prefix[i];
104
+ if (inString) {
105
+ if (escape) {
106
+ escape = false;
107
+ continue;
108
+ }
109
+ if (ch === "\\") {
110
+ escape = true;
111
+ continue;
112
+ }
113
+ if (ch === "\"") inString = false;
114
+ continue;
115
+ }
116
+ if (ch === "\"") {
117
+ inString = true;
118
+ continue;
119
+ }
120
+ if (ch === "{") objectDepth += 1;
121
+ else if (ch === "}" && objectDepth > 0) objectDepth -= 1;
122
+ else if (ch === "[") arrayDepth += 1;
123
+ else if (ch === "]" && arrayDepth > 0) arrayDepth -= 1;
124
+ }
125
+ return objectDepth === 0 && arrayDepth === 0 && !inString && !escape;
126
+ }
127
+
128
+ // Same string, escape, and depth machine as the prefix scan, started at a
129
+ // `{` or `[`. A closer that does not match an opener this scan pushed is
130
+ // invalid. An invalid scan is not truncation. Truncation is a valid scan
131
+ // that reaches the end of the body still inside a container or a string.
132
+ // One call walks from `start` through the rest of the body. The walk is
133
+ // linear in that span. No byte budget stops it.
134
+ function containerLexIsTruncated(body: string, start: number): boolean {
135
+ const stack: string[] = [];
136
+ let inString = false;
137
+ let escape = false;
138
+ for (let i = start; i < body.length; i++) {
94
139
  const ch = body[i];
95
140
  if (inString) {
96
141
  if (escape) {
@@ -101,38 +146,103 @@ function determineOutcome(text: string, allowedOutcomes: readonly string[]): str
101
146
  escape = true;
102
147
  continue;
103
148
  }
104
- if (ch === '"') inString = false;
149
+ if (ch === "\"") inString = false;
105
150
  continue;
106
151
  }
107
- if (ch === '"') {
152
+ if (ch === "\"") {
108
153
  inString = true;
109
154
  continue;
110
155
  }
111
- if (ch === "{") {
112
- if (depth === 0) currentStart = i;
113
- depth++;
156
+ if (ch === "{" || ch === "[") {
157
+ stack.push(ch);
114
158
  continue;
115
159
  }
116
- if (ch === "}") {
117
- if (depth === 0) continue;
118
- depth--;
119
- if (depth === 0) {
120
- objectStart = currentStart;
121
- objectEnd = i;
122
- }
160
+ if (ch === "}" || ch === "]") {
161
+ const open = ch === "}" ? "{" : "[";
162
+ const top = stack[stack.length - 1];
163
+ if (top !== open) return false;
164
+ stack.pop();
123
165
  }
124
166
  }
125
- if (objectStart < 0 || objectEnd !== body.length - 1) return "failed";
126
- let result: unknown;
127
- try {
128
- result = JSON.parse(body.slice(objectStart, objectEnd + 1));
129
- } catch {
130
- return "failed";
167
+ return stack.length > 0 || inString || escape;
168
+ }
169
+
170
+ // True when one of the last container openers in the prefix lexes as a
171
+ // value that is still open at the end of the body, or when more container
172
+ // openers sit further back than the re-lex cap. Those further openers are
173
+ // not scanned. The prefix is ambiguous, so the candidate fails closed.
174
+ // Quote positions are left to the naive machine.
175
+ function prefixHidesTruncatedContainer(body: string, openAt: number): boolean {
176
+ let seen = 0;
177
+ for (let i = openAt - 1; i >= 0; i--) {
178
+ const ch = body[i];
179
+ if (ch !== "{" && ch !== "[") continue;
180
+ if (seen >= MAX_PREFIX_CONTAINER_OPENERS) return true;
181
+ seen++;
182
+ if (containerLexIsTruncated(body, i)) return true;
183
+ }
184
+ return false;
185
+ }
186
+
187
+ // The outcome object is a JSON object that ends the trimmed reply. One
188
+ // closing code fence may follow it; the fence is removed before the walk.
189
+ // Candidates are slices from a `{` to a recent `}`, nearest last. Only the
190
+ // last 32 closers are visited, and only a closer on the reply's last
191
+ // character can end the reply. For that closer the last 32 openers are
192
+ // tried. A slice whose UTF-8 size is over 256 KiB is skipped. Parsed slices
193
+ // share a 1 MiB UTF-8 budget, and crossing it stops the lookup. Both limits
194
+ // use Buffer.byteLength. JSON.parse is the only
195
+ // string and escape authority inside the slice. A slice that parses to a
196
+ // plain object whose outcome is a string in allowedOutcomes is a candidate.
197
+ // The prefix before that opener, with trailing whitespace removed, must end
198
+ // at depth 0 for both braces and arrays, outside a string, with no dangling
199
+ // escape. Any other end state is truncated or ambiguous, so the candidate
200
+ // is rejected and the next one is tried. The naive machine plus the
201
+ // container-lexer guard reject truncation. A prefix with more container
202
+ // openers than the re-lex cap is ambiguous and fails closed. Prose with an
203
+ // odd quote count fails closed. An accepted candidate ends the lookup.
204
+ // Nothing left is failed: prose after the object, no object, a disallowed
205
+ // outcome, an inner object at the end of a truncated outer object or array,
206
+ // an object inside an unclosed string, an ambiguous prefix past the re-lex
207
+ // cap, or a budget exhausted before a candidate.
208
+ function determineOutcome(text: string, allowedOutcomes: readonly string[]): string {
209
+ const trimmed = text.trim();
210
+ if (!trimmed) return "failed";
211
+ const body = stripOneClosingFence(trimmed);
212
+ if (!body) return "failed";
213
+ let seenClosers = 0;
214
+ let parseBytes = 0;
215
+ for (let closeAt = body.length - 1; closeAt >= 0 && seenClosers < MAX_OUTCOME_CLOSERS; closeAt--) {
216
+ if (body[closeAt] !== "}") continue;
217
+ seenClosers++;
218
+ const end = closeAt + 1;
219
+ // A candidate has to end the reply. One closer can sit at that position.
220
+ if (end !== body.length) continue;
221
+ let seenOpeners = 0;
222
+ for (let openAt = closeAt - 1; openAt >= 0 && seenOpeners < MAX_OUTCOME_OPENERS; openAt--) {
223
+ if (body[openAt] !== "{") continue;
224
+ seenOpeners++;
225
+ const slice = body.slice(openAt, end);
226
+ const sliceBytes = Buffer.byteLength(slice, "utf8");
227
+ if (sliceBytes > MAX_OUTCOME_SLICE_BYTES) break;
228
+ if (parseBytes + sliceBytes > MAX_OUTCOME_PARSE_BYTES) return "failed";
229
+ parseBytes += sliceBytes;
230
+ let result: unknown;
231
+ try {
232
+ result = JSON.parse(slice);
233
+ } catch {
234
+ continue;
235
+ }
236
+ if (!result || typeof result !== "object" || Array.isArray(result)) continue;
237
+ const outcome = (result as Record<string, unknown>).outcome;
238
+ if (typeof outcome !== "string" || !allowedOutcomes.includes(outcome)) continue;
239
+ const prefix = body.slice(0, openAt).trimEnd();
240
+ if (!outcomePrefixIsAnchored(prefix)) continue;
241
+ if (prefixHidesTruncatedContainer(body, openAt)) continue;
242
+ return outcome;
243
+ }
131
244
  }
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;
245
+ return "failed";
136
246
  }
137
247
 
138
248
  export function createKxmOneShotProducer(options: KxmOneShotProducerOptions = {}): KxmOneShotProducer {