@kontextmind/kxm 0.7.152 → 0.7.154

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.154",
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,30 @@ 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
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).
158
156
 
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).
157
+ ### Changed
167
158
 
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).
159
+ - **Windows pull-request unit lanes finish inside the job limit.** The light
160
+ lane on `windows-latest` was cancelled at 20 minutes on #361, #362, and
161
+ #363 after it stopped reporting tests. `package-install.test.ts` (171s on
162
+ that lane, 67s on Linux) now runs with the serial files. The other light
163
+ files are split into `light-1` and `light-2`. POSIX `#!/bin/sh` shims in
164
+ the land tests are skipped on Windows. `CI / required` still fails when a
165
+ lane fails or is cancelled. See
166
+ [CI and release](docs/contributing/ci-and-release.md).
189
167
 
190
168
  - **Workforce ids use one convention, and old ids still resolve.**
191
169
  Role ids stay `planner`, `writer`, `reviewer-arch`, and `reviewer-cli`.
@@ -511,6 +489,50 @@ All notable user-facing changes are documented here. The project follows [Semant
511
489
  persisting a projection write, so a read cannot mutate run state or surface a
512
490
  false `run_projection_divergent`.
513
491
 
492
+ - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
493
+ `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
494
+ header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
495
+ `KXM_BROWSER=steel` passes those headers through `connectBrowserOverCdp()`
496
+ into `chromium.connectOverCDP`. Obscura stays the default and sends no Steel
497
+ headers. `STEEL_AUTH_HEADER` overrides the value. The CDP URL omits the credential when
498
+ those variables are set. A 302 to the identity provider fails closed and does
499
+ not follow the login redirect. `STEEL_API_KEY` still sends the legacy
500
+ `x-steel-api-key` header and `apiKey` query parameter for the temporary proxy
501
+ shim, and warns once. See
502
+ [Browser automation](docs/guides/browser-automation.md).
503
+
504
+ - **Pull-request CI runs the unit suite on Linux Node 24, and `CI / required` is the aggregate check.**
505
+ Docs and plan markdown skip the code jobs. `engine.test.ts` is split by
506
+ test name. `permission.test.ts`, `runtime.test.ts`, and
507
+ `package-install.test.ts` run one file at a time, and every other unit file
508
+ runs in two light shards. Pushes to `main`
509
+ still run `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24.
510
+ A pull request that touches path, process, shell, spawn, package, lockfile,
511
+ or workflow files also runs the unit lanes on Windows. Playwright stays on
512
+ Obscura. See [CI and release](docs/contributing/ci-and-release.md).
513
+
514
+ - **Docs match the 2026-09-27 Steel and machine-account infrastructure.**
515
+ Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
516
+ through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
517
+ LAN, tailnet, and host-forward access is blocked. Sessions return
518
+ `websocketUrl` `wss://steel.kontextmind.com/` (previously
519
+ `ws://steel-browser/`). The CDP path is `/v1/devtools` with an
520
+ `Authorization` header. Allowed groups are `steel-users`, `kxmd-users`,
521
+ `kxmd-admins`, and `kxmd-owners`. `STEEL_API_URL` defaults to
522
+ `https://steel.kontextmind.com`. `STEEL_API_KEY` is deprecated and is not
523
+ enforced by Steel or Caddy. Migrate to `STEEL_AUTH_HEADER`, then
524
+ `STEEL_AUTH_BASIC`, then `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`; those
525
+ override the key. The `svc-steel` credential is 1Password vault
526
+ `kontextmind`, item `Steel (svc-steel)`, field `basic_auth`, read with
527
+ `op read` and never written to disk. Steel requires `kxm` 0.7.135 or
528
+ newer. Playwright stays on Obscura ([ADR-0005](docs/adr/ADR-0005-obscura-default-playwright.md)).
529
+ The Proxmox boot order and VM names are in
530
+ [Deploy KXM](docs/operations/deploy.md#boot-the-kxmd-proxmox-host).
531
+ Machine accounts follow
532
+ [ADR-0006](docs/adr/ADR-0006-machine-account-names.md). The DOKS deployment
533
+ record is superseded by
534
+ [ADR-0007](docs/adr/ADR-0007-steel-caddy-authentik.md).
535
+
514
536
  - **Steel clients authenticate to Authentik with `Authorization: Basic`.**
515
537
  `STEEL_AUTH_BASIC`, or `STEEL_AUTH_USER` and `STEEL_AUTH_TOKEN`, set that
516
538
  header on Steel HTTP requests and on the CDP options from `formatCDPConnect()`.
@@ -542,9 +564,9 @@ All notable user-facing changes are documented here. The project follows [Semant
542
564
 
543
565
  ### Fixed
544
566
 
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.
567
+ - **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
568
 
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`.
569
+ - **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
570
 
549
571
  - **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
572
 
@@ -860,6 +882,10 @@ All notable user-facing changes are documented here. The project follows [Semant
860
882
 
861
883
  - **`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
884
 
885
+ - **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.
886
+
887
+ - **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`.
888
+
863
889
  ## 0.7.0 - 2026-09-11
864
890
 
865
891
  ### 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
@@ -39,8 +39,8 @@ together.
39
39
  | Before you push | Local | `npm run verify` |
40
40
  | Pull request, push to `main`, or manual | `ci.yml` (`required`) | Aggregates the lanes below into one pass/fail check named `CI / required` |
41
41
  | Pull request and push | `ci.yml` (Docs lint) | `lint:docs` and `check:versions`, always |
42
- | Code pull request and code push | `ci.yml` (Unit engine and Unit, Linux Node 24) | `engine.test.ts` split by test name; `permission.test.ts` and `runtime.test.ts` one file at a time; every other unit file together. Typecheck and `check-generated` run on the light lane |
43
- | Platform-sensitive pull request | `ci.yml` (Unit, Windows Node 24) | The same four unit lanes on `windows-latest` |
42
+ | Code pull request and code push | `ci.yml` (Unit engine and Unit, Linux Node 24) | `engine.test.ts` split by test name; `permission.test.ts`, `runtime.test.ts`, and `package-install.test.ts` one file at a time; every other unit file in two light shards. Typecheck and `check-generated` run on `light-1` |
43
+ | Platform-sensitive pull request | `ci.yml` (Unit, Windows Node 24) | The same unit lanes on `windows-latest` |
44
44
  | Push to `main`, or manual | `ci.yml` (Validate matrix) | `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24; not on a pull request |
45
45
  | Code pull request and code push | `ci.yml` (Plugin validation) | `claude plugin validate --strict` on the marketplace and the plugin |
46
46
  | Daily at 04:00 UTC, or manual | `nightly.yml` | `test:coverage:complete`, `check`, `check:generated`, `npm pack --dry-run` |
@@ -54,7 +54,7 @@ The npm scripts behind those rows:
54
54
  | Script | Composition |
55
55
  |---|---|
56
56
  | `verify` | `npm test` (core and package unit tests), `check`, `check:generated` |
57
- | `test:ci-shard` | One unit lane: build, then `engine <index> <total>`, `serial`, or `light` over `test/core/*.test.ts` and `packages/core/*/tests/unit/*.test.ts` |
57
+ | `test:ci-shard` | One unit lane: build, then `engine <index> <total>`, `serial`, `light`, or `light-<index>` over `test/core/*.test.ts` and `packages/core/*/tests/unit/*.test.ts` |
58
58
  | `validate:pr` | `build`, `typecheck`, a compact contract and smoke set of nine `test/core` files, `check:versions`, and the generated-`dist` check |
59
59
  | `validate:ci` | `test:coverage` (core and package tests, 91/80/92 floors), `check`, `npm pack --dry-run`; the Release workflow runs it, and it stays available locally |
60
60
  | `test:coverage:complete` | Core, simulation and package tests with 93/80/93 floors |
@@ -81,15 +81,20 @@ blocker: it is the only place the simulation suite and coverage floors run.
81
81
  ### Lanes and the required check
82
82
 
83
83
  Code pull requests run Docs lint, two Linux Node 24 engine shards, a serial
84
- lane (`permission.test.ts` then `runtime.test.ts`), a light lane for every
85
- other unit file, and Plugin validation. The light lane typechecks and checks
86
- generated bundles. `engine.test.ts` is split by test name because that file
87
- alone was 174 seconds; the serial lane keeps the next two longest files off
88
- the light pool, which was 268 seconds when every non-engine file shared one
89
- job. Linux jobs use the npm cache from
90
- `actions/setup-node`. Restoring a `node_modules` tarball was slower than
91
- `npm ci` on the Linux runners (about 24s versus 17s on 2026-09-24), so that
92
- cache stays on the Windows jobs, where `npm ci` is the slow step.
84
+ lane (`permission.test.ts`, then `runtime.test.ts`, then
85
+ `package-install.test.ts`), two light shards for every other unit file, and
86
+ Plugin validation. `light-1` typechecks and checks generated bundles.
87
+ `engine.test.ts` is split by test name because that file alone was 174
88
+ seconds; the serial lane keeps the next longest files off the light pool,
89
+ which was 268 seconds when every non-engine file shared one job.
90
+ `package-install.test.ts` is serial because on Windows it took 171 seconds
91
+ and the light job stopped reporting tests after that file finished (cancelled
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
94
+ jobs use the npm cache from `actions/setup-node`. Restoring a
95
+ `node_modules` tarball was slower than `npm ci` on the Linux runners (about
96
+ 24s versus 17s on 2026-09-24), so that cache stays on the Windows jobs,
97
+ where `npm ci` is the slow step.
93
98
 
94
99
  The job `required` always runs. Its check name is `CI / required`. It fails
95
100
  when a lane fails or is cancelled, and it passes when a lane was skipped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.152",
3
+ "version": "0.7.154",
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.154",
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.154";
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.154",
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.154";
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 {
@@ -7,24 +7,33 @@
7
7
  // sequentially, and running them in one process pool stretched the rest of
8
8
  // the suite to 268s. engine.test.ts is split by test name across two jobs.
9
9
  // permission and runtime run one file at a time in a serial job so they do
10
- // not steal cores from each other. Every other unit file runs in a light
11
- // job at concurrency 4.
10
+ // not steal cores from each other.
11
+ //
12
+ // package-install.test.ts is serial as well. On the Windows light lane for
13
+ // PRs #361, #362, and #363 it took 171s (67s on Linux) and was the file that
14
+ // finished immediately before that lane stopped reporting tests. The job
15
+ // then sat until the 20-minute cancel. npm pack/install stays off the light
16
+ // pool. The other light files are round-robin split into two shards so a
17
+ // Windows job is not one 120-file process.
12
18
  //
13
19
  // Dynamic `test(\`...\${...}\`)` names stay together as one pattern so a
14
20
  // loop is not dropped. Run via `npm run test:ci-shard` so npm_execpath is
15
- // set for the packed-install test: `engine <index> <total>`, `serial`, or
16
- // `light`.
21
+ // set for the packed-install test: `engine <index> <total>`, `serial`,
22
+ // `light`, or `light-<index>` (two shards).
17
23
 
18
24
  import { spawn, spawnSync } from "node:child_process";
19
25
  import { globSync, readFileSync } from "node:fs";
20
26
  import { join } from "node:path";
21
27
 
22
28
  export const SHARD_TOTAL = 2;
29
+ export const LIGHT_SHARD_TOTAL = 2;
23
30
  export const ENGINE_FILE = "test/core/engine.test.ts";
24
- // Solo Node 24 timings: permission 74s, runtime 53s. One file at a time.
31
+ // Solo Node 24 timings: permission 74s, runtime 53s. package-install is 171s
32
+ // on Windows CI and 67s on Linux. One file at a time.
25
33
  export const SERIAL_FILES = [
26
34
  "test/core/permission.test.ts",
27
35
  "test/core/runtime.test.ts",
36
+ "test/core/package-install.test.ts",
28
37
  ];
29
38
  const LIGHT_CONCURRENCY = 4;
30
39
 
@@ -125,6 +134,15 @@ export function planLight(root) {
125
134
  return light;
126
135
  }
127
136
 
137
+ export function planLightShard(root, index, total = LIGHT_SHARD_TOTAL) {
138
+ if (!Number.isInteger(index) || !Number.isInteger(total) || index < 1 || index > total) {
139
+ throw new Error(`light shard ${index}/${total} is outside 1..${total}`);
140
+ }
141
+ const shard = planLight(root).filter((_, fileIndex) => fileIndex % total === index - 1);
142
+ if (shard.length === 0) throw new Error(`light shard ${index}/${total} assigned no unit files`);
143
+ return shard;
144
+ }
145
+
128
146
  export function coverageOfShards(root, total = SHARD_TOTAL) {
129
147
  const heavy = new Map();
130
148
  for (let index = 1; index <= total; index += 1) {
@@ -138,7 +156,15 @@ export function coverageOfShards(root, total = SHARD_TOTAL) {
138
156
 
139
157
  function runNode(args, label, children) {
140
158
  return new Promise((resolve) => {
141
- const child = spawn(process.execPath, args, { env: process.env });
159
+ const child = spawn(process.execPath, args, {
160
+ env: {
161
+ ...process.env,
162
+ // Git Credential Manager on windows-latest can wait on a prompt
163
+ // until the job is cancelled. Refuse that wait for every lane.
164
+ GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT || "0",
165
+ GCM_INTERACTIVE: process.env.GCM_INTERACTIVE || "Never",
166
+ },
167
+ });
142
168
  children.add(child);
143
169
  let stdout = "";
144
170
  let stderr = "";
@@ -220,10 +246,14 @@ async function main() {
220
246
  (children) => runNode(testArgs(1, files), "serial", children),
221
247
  ], 1));
222
248
  }
223
- if (mode === "light") {
224
- const files = planLight(root);
249
+ const lightShard = /^light-(\d+)$/.exec(mode);
250
+ if (mode === "light" || lightShard) {
251
+ const files = lightShard
252
+ ? planLightShard(root, Number(lightShard[1]), LIGHT_SHARD_TOTAL)
253
+ : planLight(root);
254
+ const label = lightShard ? mode : "light";
225
255
  process.exit(await runPool([
226
- (children) => runNode(testArgs(LIGHT_CONCURRENCY, files), "light", children),
256
+ (children) => runNode(testArgs(LIGHT_CONCURRENCY, files), label, children),
227
257
  ], 1));
228
258
  }
229
259
  if (mode === "engine") {
@@ -234,7 +264,7 @@ async function main() {
234
264
  (children) => runNode(testArgs(1, [plan.file], plan.patterns), "engine", children),
235
265
  ], 1));
236
266
  }
237
- process.stderr.write("usage: ci-unit-shard.mjs <serial|light|engine> [index total]\n");
267
+ process.stderr.write("usage: ci-unit-shard.mjs <serial|light|light-<index>|engine> [index total]\n");
238
268
  process.exit(2);
239
269
  }
240
270