@kontextmind/kxm 0.7.167 → 0.7.169

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +30 -0
  3. package/docs/contributing/ci-and-release.md +6 -1
  4. package/docs/operations/runtime-sync.md +2 -1
  5. package/docs/operations/troubleshooting.md +4 -0
  6. package/docs/operations/upgrade.md +18 -3
  7. package/docs/operations.md +70 -7
  8. package/docs/reference/cli-reference.md +21 -6
  9. package/docs/reference/config-reference.md +28 -13
  10. package/docs/start/install.md +1 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  13. package/plugins/kxm/dist/claude-hook.js +170 -36
  14. package/plugins/kxm/dist/cli.js +451 -132
  15. package/plugins/kxm/dist/extension.js +184 -53
  16. package/plugins/kxm/dist/mcp-server.js +97 -40
  17. package/plugins/kxm/dist/runtime-supervisor.js +240 -47
  18. package/plugins/kxm/dist/runtime.js +240 -47
  19. package/plugins/kxm/dist/server.js +9 -0
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-definitions/SKILL.md +3 -2
  22. package/plugins/kxm/skills/kxm-harness-auth/SKILL.md +4 -2
  23. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -4
  24. package/plugins/kxm/src/cli/hub.ts +42 -7
  25. package/plugins/kxm/src/cli/system.ts +3 -1
  26. package/plugins/kxm/src/cli/types.ts +5 -3
  27. package/plugins/kxm/src/cli.ts +2 -1
  28. package/plugins/kxm/src/engine.ts +105 -8
  29. package/plugins/kxm/src/hub-binding.ts +301 -55
  30. package/plugins/kxm/src/hub-identity.ts +17 -9
  31. package/plugins/kxm/src/kxm-update.ts +29 -6
  32. package/plugins/kxm/src/mcp-server.ts +1 -1
  33. package/plugins/kxm/src/project-config.ts +63 -1
  34. package/plugins/kxm/src/runtime-supervisor.ts +22 -4
  35. package/scripts/kxm-publish-npm.mjs +47 -1
@@ -1,3 +1,4 @@
1
+ import { spawnSync } from "node:child_process";
1
2
  import { createHash } from "node:crypto";
2
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
3
4
  import { join } from "node:path";
@@ -43,6 +44,21 @@ export function kxmReleaseAssetName(version: string): string {
43
44
  return `kxm-${version}.tgz`;
44
45
  }
45
46
 
47
+ export function kxmReleaseDownloadUrl(version: string, name = kxmReleaseAssetName(version)): string {
48
+ return `https://github.com/kontextmind/kxm/releases/download/v${version}/${name}`;
49
+ }
50
+
51
+ /** `gh` is optional. Hosts without it download the same asset with curl and still verify the digest. */
52
+ export function ghReleaseDownloadAvailable(env: NodeJS.ProcessEnv = process.env): boolean {
53
+ const result = spawnSync("gh", ["--version"], {
54
+ encoding: "utf8",
55
+ env,
56
+ windowsHide: true,
57
+ timeout: 15_000,
58
+ });
59
+ return !result.error && result.status === 0;
60
+ }
61
+
46
62
  export function readInstalledKxmVersion(root: string): string {
47
63
  const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { version?: unknown };
48
64
  if (typeof pkg.version !== "string" || !parseSemver(pkg.version)) {
@@ -213,7 +229,7 @@ export async function fetchLatestKxmVersion(
213
229
  }
214
230
 
215
231
  export type KxmPackageUpdateStep =
216
- | { kind: "download"; command: "gh"; args: string[] }
232
+ | { kind: "download"; command: "gh" | "curl"; args: string[] }
217
233
  | { kind: "verify"; path: string; sha256: string }
218
234
  | { kind: "install"; command: "npm"; args: string[] };
219
235
 
@@ -233,17 +249,24 @@ export function planKxmPackageUpdate(
233
249
  latest: string,
234
250
  releaseDir: string,
235
251
  asset?: KxmReleaseAsset,
252
+ downloader: "gh" | "curl" = "gh",
236
253
  ): KxmPackageUpdateStep[] {
237
254
  if (source === "npm") {
238
255
  return [{ kind: "install", command: "npm", args: ["install", "--global", "--omit=peer", `@kontextmind/kxm@${latest}`] }];
239
256
  }
240
257
  const name = asset?.name ?? kxmReleaseAssetName(latest);
241
258
  const steps: KxmPackageUpdateStep[] = [
242
- {
243
- kind: "download",
244
- command: "gh",
245
- args: ["release", "download", `v${latest}`, "--repo", "kontextmind/kxm", "--pattern", name, "--dir", releaseDir, "--clobber"],
246
- },
259
+ downloader === "curl"
260
+ ? {
261
+ kind: "download",
262
+ command: "curl",
263
+ args: ["--fail", "--silent", "--show-error", "--location", "--output", join(releaseDir, name), kxmReleaseDownloadUrl(latest, name)],
264
+ }
265
+ : {
266
+ kind: "download",
267
+ command: "gh",
268
+ args: ["release", "download", `v${latest}`, "--repo", "kontextmind/kxm", "--pattern", name, "--dir", releaseDir, "--clobber"],
269
+ },
247
270
  ];
248
271
  if (asset?.sha256) {
249
272
  steps.push({ kind: "verify", path: join(releaseDir, name), sha256: asset.sha256 });
@@ -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.167";
14
+ const VERSION = "0.7.169";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -104,7 +104,40 @@ const RESOURCE_SCHEMA: Readonly<Record<KxmResourceKind, { identity: string; file
104
104
  const IDENTIFIER = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
105
105
  const WINDOWS_RESERVED = /^(?:con|prn|aux|nul|com[1-9¹²³]|lpt[1-9¹²³]|conin\$|conout\$|clock\$)$/i;
106
106
  const BUILTIN_EXECUTORS = ["local", "ssh", "exe-dev"];
107
- const BUILTIN_TOOL_PRESETS = ["coordinator", "read-only", "workspace-writer", "tests-writer"];
107
+ /** Builtin preset to the harness one-shot profile. An agent preset may only narrow. */
108
+ export const TOOL_PRESET_PROFILES = Object.freeze({
109
+ coordinator: "read-only",
110
+ "read-only": "read-only",
111
+ "workspace-writer": "edit",
112
+ "tests-writer": "edit",
113
+ } as const);
114
+ const BUILTIN_TOOL_PRESETS = Object.keys(TOOL_PRESET_PROFILES);
115
+
116
+ export function toolPresetProfile(preset: string): "read-only" | "edit" | undefined {
117
+ if (!Object.hasOwn(TOOL_PRESET_PROFILES, preset)) return undefined;
118
+ return TOOL_PRESET_PROFILES[preset as keyof typeof TOOL_PRESET_PROFILES];
119
+ }
120
+
121
+ /**
122
+ * Set when `preset` is wider than the role permission, or wider than a
123
+ * registered role preset. Same text at load and at dispatch.
124
+ */
125
+ export function toolPresetExceedsRoleMessage(
126
+ preset: string,
127
+ roleId: string,
128
+ rolePermission: "edit" | "read-only",
129
+ rolePreset: string | undefined,
130
+ ): string | undefined {
131
+ const profile = toolPresetProfile(preset);
132
+ if (profile !== "edit") return undefined;
133
+ if (rolePermission === "read-only") {
134
+ return `tools.preset ${preset} exceeds role ${roleId} permission read-only`;
135
+ }
136
+ if (rolePreset !== undefined && toolPresetProfile(rolePreset) === "read-only") {
137
+ return `tools.preset ${preset} exceeds role ${roleId} preset ${rolePreset}`;
138
+ }
139
+ return undefined;
140
+ }
108
141
  const SECRET_VALUE_PATTERNS: readonly RegExp[] = [
109
142
  /-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----/,
110
143
  /\b(?:ghp|github_pat)_[A-Za-z0-9_]{20,}\b/,
@@ -583,6 +616,16 @@ function stringValue(value: JsonValue | undefined): string | undefined {
583
616
  return typeof value === "string" ? value : undefined;
584
617
  }
585
618
 
619
+ function roleById(roles: ReadonlyMap<string, KxmResource>, roleId: string): KxmResource | undefined {
620
+ const direct = roles.get(roleId);
621
+ if (direct) return direct;
622
+ const folded = roleId.toLocaleLowerCase("en-US");
623
+ for (const role of roles.values()) {
624
+ if ((role.id ?? "").toLocaleLowerCase("en-US") === folded) return role;
625
+ }
626
+ return undefined;
627
+ }
628
+
586
629
  function numberValue(value: JsonValue | undefined, fallback: number): number {
587
630
  return typeof value === "number" ? value : fallback;
588
631
  }
@@ -1224,10 +1267,29 @@ function validateBundle(
1224
1267
  if (executor && !executors.has(executor)) issues.push(issue("reference", "executor_unknown", agent.logicalPath, `executor ${executor} is not registered`));
1225
1268
  const preset = stringValue(objectValue(agent.value.tools)?.preset);
1226
1269
  if (preset && !presets.has(preset)) issues.push(issue("reference", "tool_preset_unknown", agent.logicalPath, `tool preset ${preset} is not registered`));
1270
+ const roleId = stringValue(agent.value.role);
1271
+ const role = roleId ? roleById(roles, roleId) : undefined;
1272
+ const rolePermission = stringValue(role?.value.permission);
1273
+ if (preset && role && (rolePermission === "edit" || rolePermission === "read-only")) {
1274
+ const message = toolPresetExceedsRoleMessage(
1275
+ preset,
1276
+ role.id ?? roleId ?? "role",
1277
+ rolePermission,
1278
+ stringValue(objectValue(role.value.tools)?.preset),
1279
+ );
1280
+ if (message) issues.push(issue("semantic", "tool_preset_exceeds_role", agent.logicalPath, message));
1281
+ }
1227
1282
  for (const repositoryId of Object.keys(objectValue(agent.value.repositories) ?? {})) {
1228
1283
  if (!repositoryIds.has(repositoryId)) issues.push(issue("reference", "repository_unknown", agent.logicalPath, `references unknown repository ${repositoryId}`));
1229
1284
  }
1230
1285
  }
1286
+ for (const role of roles.values()) {
1287
+ const permission = stringValue(role.value.permission);
1288
+ const rolePreset = stringValue(objectValue(role.value.tools)?.preset);
1289
+ if (permission === "read-only" && rolePreset && toolPresetProfile(rolePreset) === "edit") {
1290
+ issues.push(issue("semantic", "tool_preset_exceeds_role", role.logicalPath, `tools.preset ${rolePreset} exceeds permission read-only`));
1291
+ }
1292
+ }
1231
1293
  validateModelReferences(models, issues);
1232
1294
 
1233
1295
  const defaultWorkflow = stringValue(project.value.defaultWorkflow) ?? "default";
@@ -31,7 +31,7 @@ import { createKxmOneShotProducer } from "./oneshot-producer.ts";
31
31
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
32
32
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
33
33
  import { RuntimeHubClient, HubHttpError, type SyncPushResponse } from "./client.ts";
34
- import { CloudTokenError, readHubBinding } from "./hub-binding.ts";
34
+ import { CloudTokenError, HubBindingError, loadHubBinding } from "./hub-binding.ts";
35
35
  import { resolveClientHubAuthToken } from "./hub-env.ts";
36
36
  import { createLogger } from "./logger.ts";
37
37
  import type { KxmOutboxRow, KxmOutboxStatus } from "./runtime-store.ts";
@@ -572,8 +572,21 @@ export async function syncKxmOutbox(
572
572
  }
573
573
 
574
574
  /** Where this Runtime reports one project, or nothing when no hub is bound. */
575
- function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv): RuntimeHubClient | undefined {
576
- const serverUrl = env.KXM_SERVER_URL?.trim() || readHubBinding(env)?.url;
575
+ function runtimeHubClientFor(
576
+ context: KxmRuntimeContext,
577
+ env: NodeJS.ProcessEnv,
578
+ onBindingWarning?: (message: string) => void,
579
+ ): RuntimeHubClient | undefined {
580
+ let recordUrl: string | undefined;
581
+ try {
582
+ const loaded = loadHubBinding(env);
583
+ if (loaded.warning) onBindingWarning?.(loaded.warning);
584
+ recordUrl = loaded.record?.url;
585
+ } catch (error) {
586
+ if (!(error instanceof HubBindingError)) throw error;
587
+ onBindingWarning?.(`${error.message}; falling back to the local hub so sync can continue`);
588
+ }
589
+ const serverUrl = env.KXM_SERVER_URL?.trim() || recordUrl;
577
590
  if (!serverUrl) return undefined;
578
591
  // Use the context's actual projectId — the same identity sync events carry — so the
579
592
  // ops snapshot can join runs to their home Runtime. The npm package name (what an
@@ -1271,6 +1284,7 @@ async function startKxmRuntimeSupervisorInner(
1271
1284
  };
1272
1285
 
1273
1286
  let syncing = false;
1287
+ let bindingWarning: string | undefined;
1274
1288
  const syncTimer = setInterval(() => {
1275
1289
  if (syncing || stopping) return;
1276
1290
  syncing = true;
@@ -1292,7 +1306,11 @@ async function startKxmRuntimeSupervisorInner(
1292
1306
  consecutiveFailures: 0,
1293
1307
  };
1294
1308
  try {
1295
- const client = runtimeHubClientFor(context, env);
1309
+ const client = runtimeHubClientFor(context, env, (message) => {
1310
+ if (bindingWarning === message) return;
1311
+ bindingWarning = message;
1312
+ logger.warn({ event: "hub_binding_fallback", message });
1313
+ });
1296
1314
  if (!client) {
1297
1315
  // Not a failure: a box with no bound hub simply keeps its rows.
1298
1316
  recordSyncStatus(context, { ...base, state: "no_hub" });
@@ -271,6 +271,36 @@ export async function runKxmNpmPublish(input) {
271
271
  };
272
272
  }
273
273
 
274
+ /**
275
+ * npm can accept a publish minutes before `npm view <pkg>@<version>` answers.
276
+ * Callers that report success should wait until this returns visible.
277
+ */
278
+ export function npmViewVersion(version, exec = execFile) {
279
+ return new Promise((resolve) => {
280
+ exec("npm", ["view", `@kontextmind/kxm@${version}`, "version"], { encoding: "utf8", timeout: 30_000 }, (error, stdout) => {
281
+ if (error) resolve("");
282
+ else resolve(String(stdout ?? "").trim());
283
+ });
284
+ });
285
+ }
286
+
287
+ export async function waitForNpmVersion(version, options = {}) {
288
+ const waitMs = Math.max(0, options.waitMs ?? 0);
289
+ const intervalMs = Math.max(250, options.intervalMs ?? 15_000);
290
+ const exec = options.npmView ?? ((value) => npmViewVersion(value));
291
+ const deadline = Date.now() + waitMs;
292
+ let last = "";
293
+ for (;;) {
294
+ const seen = String(await exec(version) ?? "").trim();
295
+ if (seen === version) return { visible: true, version };
296
+ last = seen || last;
297
+ if (Date.now() >= deadline) return { visible: false, version, detail: last };
298
+ const pause = Math.min(intervalMs, Math.max(0, deadline - Date.now()));
299
+ if (pause === 0) return { visible: false, version, detail: last };
300
+ await new Promise((resolve) => setTimeout(resolve, pause));
301
+ }
302
+ }
303
+
274
304
  export async function main(env = process.env, stdout = process.stdout, stderr = process.stderr) {
275
305
  const token = env.GITHUB_TOKEN || env.GH_TOKEN;
276
306
  const npmToken = env.NODE_AUTH_TOKEN || env.NPM_TOKEN;
@@ -313,7 +343,23 @@ export async function main(env = process.env, stdout = process.stdout, stderr =
313
343
  stdout.write(`release ${tag} is currently a draft; waiting for publish (${sec}s remaining)...\n`);
314
344
  },
315
345
  });
316
- stdout.write(`${JSON.stringify(result)}\n`);
346
+ const version = String(tag).replace(/^v/, "");
347
+ const visibleWaitRaw = env.KXM_NPM_VISIBLE_WAIT_MS;
348
+ const visibleWaitMs = visibleWaitRaw == null || visibleWaitRaw === ""
349
+ ? 20 * 60 * 1000
350
+ : Math.max(0, Number(visibleWaitRaw) || 0);
351
+ const intervalRaw = env.KXM_NPM_VISIBLE_INTERVAL_MS;
352
+ const intervalMs = intervalRaw == null || intervalRaw === "" ? 15_000 : Math.max(250, Number(intervalRaw) || 15_000);
353
+ if (visibleWaitMs > 0) {
354
+ stdout.write(`npm publish submitted for @kontextmind/kxm@${version}; waiting until npm view shows it\n`);
355
+ const visible = await waitForNpmVersion(version, { waitMs: visibleWaitMs, intervalMs });
356
+ if (!visible.visible) {
357
+ stderr.write(`npm_not_visible: npm view @kontextmind/kxm@${version} version did not appear within ${visibleWaitMs}ms\n`);
358
+ return 1;
359
+ }
360
+ stdout.write(`npm view @kontextmind/kxm@${version} version is visible\n`);
361
+ }
362
+ stdout.write(`${JSON.stringify({ ...result, npmVisible: visibleWaitMs > 0 })}\n`);
317
363
  return 0;
318
364
  } catch (error) {
319
365
  const code = error instanceof KxmPublishNpmError ? error.code : "publish_failed";