@kontextmind/kxm 0.7.0 → 0.7.4

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 (51) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.kxm/roles/writer.yaml +2 -0
  3. package/docs/README.md +2 -0
  4. package/docs/agent-skills.md +5 -2
  5. package/docs/configuration.md +10 -1
  6. package/docs/getting-started.md +21 -0
  7. package/docs/operations.md +24 -0
  8. package/docs/skills/repo-work-delivery.md +5 -0
  9. package/docs/skills.md +2 -0
  10. package/docs/troubleshooting.md +22 -1
  11. package/package.json +1 -1
  12. package/plugins/kxm/.claude-plugin/plugin.json +1 -1
  13. package/plugins/kxm/dist/cli.js +2257 -439
  14. package/plugins/kxm/dist/core.js +57 -0
  15. package/plugins/kxm/dist/extension.js +40 -3
  16. package/plugins/kxm/dist/mcp-server.js +1 -1
  17. package/plugins/kxm/dist/runtime.js +140 -17
  18. package/plugins/kxm/dist/server.js +129 -4
  19. package/plugins/kxm/dist/vnext-runtime-supervisor.js +136 -13
  20. package/plugins/kxm/package.json +1 -1
  21. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +9 -0
  22. package/plugins/kxm/skills/kxm-project-setup/SKILL.md +9 -2
  23. package/plugins/kxm/src/autocomplete.ts +1 -1
  24. package/plugins/kxm/src/cli.ts +507 -9
  25. package/plugins/kxm/src/completion-install.ts +223 -0
  26. package/plugins/kxm/src/database.ts +1 -1
  27. package/plugins/kxm/src/external-effects.ts +1 -1
  28. package/plugins/kxm/src/hub-env.ts +193 -0
  29. package/plugins/kxm/src/init-guide-setup.ts +547 -0
  30. package/plugins/kxm/src/local-snapshot.ts +1 -1
  31. package/plugins/kxm/src/mcp-server.ts +1 -1
  32. package/plugins/kxm/src/protocol.ts +111 -0
  33. package/plugins/kxm/src/role.ts +335 -0
  34. package/plugins/kxm/src/safety-integrity.ts +76 -0
  35. package/plugins/kxm/src/sqlite.ts +76 -0
  36. package/plugins/kxm/src/store.ts +1 -1
  37. package/plugins/kxm/src/vnext-bindings.ts +1 -1
  38. package/plugins/kxm/src/vnext-config.ts +38 -1
  39. package/plugins/kxm/src/vnext-engine-command.ts +2 -0
  40. package/plugins/kxm/src/vnext-engine.ts +16 -0
  41. package/plugins/kxm/src/vnext-harness.ts +4 -2
  42. package/plugins/kxm/src/vnext-oneshot-process.ts +21 -4
  43. package/plugins/kxm/src/vnext-oneshot-producer.ts +18 -0
  44. package/plugins/kxm/src/vnext-runtime-store.ts +1 -1
  45. package/plugins/kxm/src/workflow-tui.ts +1 -1
  46. package/plugins/kxm/src/workflow.ts +144 -0
  47. package/scripts/kxm-bump-version.mjs +146 -0
  48. package/scripts/kxm-hub.mjs +145 -3
  49. package/scripts/kxm-publish-npm.mjs +3 -1
  50. package/scripts/kxm-release-github.mjs +3 -1
  51. package/scripts/kxm.mjs +0 -0
@@ -195,6 +195,24 @@ export function createVnextOneShotProducer(options: VnextOneShotProducerOptions
195
195
  args = ["--model", resolved.model,
196
196
  ...(resolved.thinking ? ["--reasoning-effort", resolved.thinking] : []),
197
197
  ...permissionArgs, "--output-format", "json"];
198
+ } else if (harness === "agy") {
199
+ args = [
200
+ "-m",
201
+ resolved.model,
202
+ ...permissionArgs,
203
+ "--output-format",
204
+ "json",
205
+ "-p",
206
+ ];
207
+ } else if (harness === "kimi") {
208
+ args = [
209
+ "-m",
210
+ resolved.model,
211
+ ...permissionArgs,
212
+ "--output-format",
213
+ "stream-json",
214
+ "-p",
215
+ ];
198
216
  } else {
199
217
  throw new Error(`oneshot_harness_unsupported: ${harness} permission_profile_unaudited`);
200
218
  }
@@ -1,7 +1,7 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { DatabaseSync } from "node:sqlite";
4
+ import { DatabaseSync } from "./sqlite.ts";
5
5
  import { VnextConfigError, validateRunEvent, vnextCanonicalJson, type JsonValue, type VnextConfigIssue, type VnextConfigOptions } from "./vnext-config.ts";
6
6
  import { vnextUserStateRoot } from "./vnext-bindings.ts";
7
7
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  import { existsSync, readFileSync } from "node:fs";
7
7
  import { join } from "node:path";
8
- import { DatabaseSync } from "node:sqlite";
8
+ import { DatabaseSync } from "./sqlite.ts";
9
9
  import { repoConfigDirectory } from "./config.ts";
10
10
  import { readRoutingRecords, telemetryPath } from "./telemetry.ts";
11
11
 
@@ -3,11 +3,17 @@ import {
3
3
  MAX_MESSAGE_TTL_MS,
4
4
  MIN_MESSAGE_TTL_MS,
5
5
  ProtocolError,
6
+ TERMINAL_RECEIPT_SCHEMA,
6
7
  newId,
7
8
  requireString,
9
+ validateTerminalReceipt,
8
10
  type ImprovementArea,
9
11
  type JournalCategory,
10
12
  type MessageRecord,
13
+ type TerminalReceipt,
14
+ type TerminalReceiptEvidence,
15
+ type TerminalReceiptMetrics,
16
+ type TerminalReceiptStatus,
11
17
  type WorkflowCheckpointStatus,
12
18
  type WorkflowEvidenceInput,
13
19
  type WorkflowEvidenceReference,
@@ -18,11 +24,16 @@ import {
18
24
  export type {
19
25
  ImprovementArea,
20
26
  JournalCategory,
27
+ TerminalReceipt,
28
+ TerminalReceiptEvidence,
29
+ TerminalReceiptMetrics,
30
+ TerminalReceiptStatus,
21
31
  WorkflowCheckpointStatus,
22
32
  WorkflowEvidenceInput,
23
33
  WorkflowEvidenceReference,
24
34
  WorkflowEvidenceReferenceInput,
25
35
  } from "./protocol.ts";
36
+ export { TERMINAL_RECEIPT_SCHEMA, validateTerminalReceipt } from "./protocol.ts";
26
37
 
27
38
  export type WorkflowRunStatus = "running" | "waiting" | "completed" | "failed";
28
39
  export type WorkflowStageStatus = "pending" | "in_progress" | "waiting" | WorkflowCheckpointStatus;
@@ -153,6 +164,8 @@ export interface WorkflowStageDefinition {
153
164
  instructions: string;
154
165
  requiredEvidence: string[];
155
166
  maxAttempts: number;
167
+ /** Bounded in-place retry limit before audit escalation (default: 2). */
168
+ autoResumeLimit?: number;
156
169
  area?: ImprovementArea;
157
170
  evidencePolicies?: WorkflowEvidencePolicies;
158
171
  /** Typed outcome map (v0.5). Keys are outcome identities ("passed",
@@ -250,6 +263,13 @@ export interface WorkflowStageState extends WorkflowStageDefinition {
250
263
  startedAt?: string;
251
264
  completedAt?: string;
252
265
  updatedAt?: string;
266
+ receipt?: TerminalReceipt;
267
+ auditEscalation?: {
268
+ reason: string;
269
+ timestamp: string;
270
+ ruling?: string;
271
+ receipt?: TerminalReceipt;
272
+ };
253
273
  }
254
274
 
255
275
  export interface CapturedOracle {
@@ -1003,12 +1023,18 @@ export function parseWorkflowDefinitions(
1003
1023
  && (!Number.isInteger(stageMaxTransitions) || (stageMaxTransitions as number) < 1 || (stageMaxTransitions as number) > 100)) {
1004
1024
  throw new Error(`stage ${stageId} maxTransitions must be an integer between 1 and 100`);
1005
1025
  }
1026
+ const autoResumeLimit = stage.autoResumeLimit;
1027
+ if (autoResumeLimit !== undefined
1028
+ && (!Number.isInteger(autoResumeLimit) || (autoResumeLimit as number) < 1 || (autoResumeLimit as number) > 20)) {
1029
+ throw new Error(`stage ${stageId} autoResumeLimit must be an integer between 1 and 20`);
1030
+ }
1006
1031
  return {
1007
1032
  id: stageId,
1008
1033
  label: requireString(stage.label ?? stageId, "stage.label", { max: 128 }),
1009
1034
  instructions: requireString(stage.instructions, "stage.instructions", { max: 4_000 }),
1010
1035
  requiredEvidence,
1011
1036
  maxAttempts: maxAttempts as number,
1037
+ ...(autoResumeLimit !== undefined ? { autoResumeLimit: autoResumeLimit as number } : {}),
1012
1038
  ...(area ? { area } : {}),
1013
1039
  ...(evidencePolicies ? { evidencePolicies } : {}),
1014
1040
  ...(on ? { on } : {}),
@@ -1377,6 +1403,31 @@ export function checkpointRun(
1377
1403
  delete run.currentStage;
1378
1404
  return { retry: false, completed: false, run };
1379
1405
  }
1406
+ if (stage.autoResumeLimit !== undefined && stage.attempts >= stage.autoResumeLimit) {
1407
+ stage.status = "in_progress";
1408
+ const reason = summary || `autoResumeLimit of ${stage.autoResumeLimit} reached on stage ${stage.id}`;
1409
+ const receipt = validateTerminalReceipt({
1410
+ schema: TERMINAL_RECEIPT_SCHEMA,
1411
+ status: "audit_escalation",
1412
+ seat: stage.id,
1413
+ runId: run.id,
1414
+ stageId: stage.id,
1415
+ timestamp,
1416
+ host: "pi",
1417
+ model: "default",
1418
+ escalationReason: reason,
1419
+ });
1420
+ const expiresAt = new Date(Date.parse(timestamp) + 24 * 60 * 60 * 1000).toISOString();
1421
+ waitForWorkflowSignal(run, stage.id, "audit_escalation", reason, timestamp, expiresAt);
1422
+ stage.receipt = receipt;
1423
+ stage.auditEscalation = {
1424
+ reason,
1425
+ timestamp,
1426
+ receipt,
1427
+ };
1428
+ return { retry: false, completed: false, run };
1429
+ }
1430
+ stage.status = status;
1380
1431
  // Declared failure outcomes create typed transitions (bounded); the
1381
1432
  // outcome identity defaults to the checkpoint status.
1382
1433
  const outcomeKey = outcome ?? status;
@@ -1546,3 +1597,96 @@ export function approveWorkflowDegradation(
1546
1597
  run.updatedAt = timestamp;
1547
1598
  return { run, approval, created: true };
1548
1599
  }
1600
+
1601
+ export function escalateWorkflowStage(
1602
+ run: WorkflowRun,
1603
+ stageId: string,
1604
+ reason: string,
1605
+ timestamp: string,
1606
+ options: {
1607
+ seat?: string;
1608
+ host?: string;
1609
+ model?: string;
1610
+ evidence?: TerminalReceiptEvidence;
1611
+ metrics?: TerminalReceiptMetrics;
1612
+ } = {},
1613
+ ): { run: WorkflowRun; receipt: TerminalReceipt } {
1614
+ if (run.status !== "running") throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_terminal");
1615
+ const stage = run.stages.find((s) => s.id === stageId);
1616
+ if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
1617
+ if (stage.id !== run.currentStage || stage.status !== "in_progress") {
1618
+ throw new ProtocolError(409, `stage ${stageId} is not currently active`, "workflow_stage_out_of_order");
1619
+ }
1620
+
1621
+ const receipt: TerminalReceipt = validateTerminalReceipt({
1622
+ schema: TERMINAL_RECEIPT_SCHEMA,
1623
+ status: "audit_escalation",
1624
+ seat: options.seat ?? stage.id,
1625
+ runId: run.id,
1626
+ stageId,
1627
+ timestamp,
1628
+ host: options.host ?? "pi",
1629
+ model: options.model ?? "default",
1630
+ escalationReason: reason,
1631
+ ...(options.evidence ? { evidence: options.evidence } : {}),
1632
+ ...(options.metrics ? { metrics: options.metrics } : {}),
1633
+ });
1634
+
1635
+ const expiresAt = new Date(Date.parse(timestamp) + 24 * 60 * 60 * 1000).toISOString();
1636
+ waitForWorkflowSignal(run, stageId, "audit_escalation", reason, timestamp, expiresAt);
1637
+
1638
+ stage.receipt = receipt;
1639
+ stage.auditEscalation = {
1640
+ reason,
1641
+ timestamp,
1642
+ receipt,
1643
+ };
1644
+
1645
+ return { run, receipt };
1646
+ }
1647
+
1648
+ export function resumeWorkflowFromRuling(
1649
+ run: WorkflowRun,
1650
+ ruling: string,
1651
+ timestamp: string,
1652
+ options: {
1653
+ status?: WorkflowCheckpointStatus;
1654
+ evidence?: WorkflowEvidenceInput;
1655
+ } = {},
1656
+ ): { retry: boolean; completed: boolean; run: WorkflowRun; stageId: string } {
1657
+ if (run.status !== "waiting" || !run.waiting) {
1658
+ throw new ProtocolError(409, `workflow is ${run.status}`, "workflow_not_waiting");
1659
+ }
1660
+ if (run.waiting.signalKey !== "audit_escalation") {
1661
+ throw new ProtocolError(
1662
+ 409,
1663
+ `workflow is waiting for signal '${run.waiting.signalKey}', not 'audit_escalation'`,
1664
+ "workflow_signal_mismatch",
1665
+ );
1666
+ }
1667
+
1668
+ const stageId = run.waiting.stageId;
1669
+ const stage = run.stages.find((s) => s.id === stageId);
1670
+ if (!stage) throw new ProtocolError(404, `workflow stage not found: ${stageId}`, "workflow_stage_not_found");
1671
+
1672
+ const status = options.status ?? "passed";
1673
+ const evidence = options.evidence ?? {};
1674
+
1675
+ if (stage.auditEscalation) {
1676
+ stage.auditEscalation.ruling = ruling;
1677
+ if (stage.auditEscalation.receipt) {
1678
+ stage.auditEscalation.receipt.ruling = ruling;
1679
+ }
1680
+ }
1681
+ stage.summary = `Resumed by operator ruling: ${ruling}`;
1682
+
1683
+ return resumeWorkflowFromSignal(
1684
+ run,
1685
+ "audit_escalation",
1686
+ status,
1687
+ `Resumed: ${ruling}`,
1688
+ evidence,
1689
+ timestamp,
1690
+ );
1691
+ }
1692
+
@@ -0,0 +1,146 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Bump the package version across all tracked surfaces.
5
+ *
6
+ * Usage:
7
+ * node scripts/kxm-bump-version.mjs <new-version> # explicit version
8
+ * node scripts/kxm-bump-version.mjs patch # bump patch
9
+ * node scripts/kxm-bump-version.mjs minor # bump minor
10
+ * node scripts/kxm-bump-version.mjs major # bump major
11
+ *
12
+ * Surfaces updated (must stay in sync with scripts/check-versions.mjs):
13
+ * 1. package.json .version
14
+ * 2. package-lock.json .version + .packages[""].version
15
+ * 3. plugins/kxm/package.json .version
16
+ * 4. plugins/kxm/.claude-plugin/plugin.json .version
17
+ * 5. .claude-plugin/marketplace.json .plugins[name=kxm].version
18
+ * 6. plugins/kxm/src/mcp-server.ts const VERSION = "..."
19
+ * 7. plugins/kxm/dist/mcp-server.js const VERSION = "..." (rebuilt)
20
+ *
21
+ * After running this script, run `npm run build` to rebuild dist bundles.
22
+ */
23
+
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+ import { resolve } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+
28
+ const SEMVER_RE = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+([0-9A-Za-z.-]+))?$/;
29
+
30
+ export function parseSemver(version) {
31
+ const match = SEMVER_RE.exec(version);
32
+ if (!match) return undefined;
33
+ return {
34
+ major: Number(match[1]),
35
+ minor: Number(match[2]),
36
+ patch: Number(match[3]),
37
+ prerelease: match[4],
38
+ build: match[5],
39
+ };
40
+ }
41
+
42
+ export function bumpVersion(current, kind) {
43
+ const parsed = parseSemver(current);
44
+ if (!parsed) throw new Error(`invalid current version: ${current}`);
45
+ switch (kind) {
46
+ case "major":
47
+ return `${parsed.major + 1}.0.0`;
48
+ case "minor":
49
+ return `${parsed.major}.${parsed.minor + 1}.0`;
50
+ case "patch":
51
+ return `${parsed.major}.${parsed.minor}.${parsed.patch + 1}`;
52
+ default:
53
+ throw new Error(`unknown bump kind: ${kind} (use major, minor, or patch)`);
54
+ }
55
+ }
56
+
57
+ /** Replace a version string in a JSON file using targeted string patching. */
58
+ function patchJsonVersion(filePath, oldVersion, newVersion) {
59
+ const raw = readFileSync(filePath, "utf8");
60
+ const escaped = oldVersion.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
61
+ const pattern = new RegExp(`("version"\\s*:\\s*)"${escaped}"`, "g");
62
+ const patched = raw.replace(pattern, `$1"${newVersion}"`);
63
+ if (patched === raw) {
64
+ throw new Error(`no version field found to patch in ${filePath}`);
65
+ }
66
+ writeFileSync(filePath, patched, "utf8");
67
+ }
68
+
69
+ /** Replace a version string in a TypeScript/JavaScript source file. */
70
+ function patchSourceVersion(filePath, newVersion) {
71
+ const raw = readFileSync(filePath, "utf8");
72
+ const patched = raw.replace(
73
+ /const VERSION = "[^"]+"/,
74
+ `const VERSION = "${newVersion}"`,
75
+ );
76
+ if (patched === raw) {
77
+ throw new Error(`no VERSION constant found in ${filePath}`);
78
+ }
79
+ writeFileSync(filePath, patched, "utf8");
80
+ }
81
+
82
+ export function applyVersionBump(root, newVersion) {
83
+ if (!parseSemver(newVersion)) {
84
+ throw new Error(`invalid semver: ${newVersion}`);
85
+ }
86
+
87
+ // Read current version
88
+ const pkgPath = resolve(root, "package.json");
89
+ const oldVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
90
+
91
+ // JSON surfaces (targeted patch preserves formatting)
92
+ const jsonFiles = [
93
+ "package.json",
94
+ "package-lock.json",
95
+ "plugins/kxm/package.json",
96
+ "plugins/kxm/.claude-plugin/plugin.json",
97
+ ".claude-plugin/marketplace.json",
98
+ ];
99
+ for (const rel of jsonFiles) {
100
+ patchJsonVersion(resolve(root, rel), oldVersion, newVersion);
101
+ }
102
+
103
+ // TypeScript source
104
+ patchSourceVersion(resolve(root, "plugins/kxm/src/mcp-server.ts"), newVersion);
105
+
106
+ // Generated dist (update in place if it exists; check:generated rebuilds anyway)
107
+ const distPath = resolve(root, "plugins/kxm/dist/mcp-server.js");
108
+ try {
109
+ patchSourceVersion(distPath, newVersion);
110
+ } catch {
111
+ // dist may not exist yet; will be rebuilt by npm run build
112
+ }
113
+
114
+ return { oldVersion, newVersion };
115
+ }
116
+
117
+ export function main(argv = process.argv.slice(2), root = process.cwd()) {
118
+ const arg = argv[0];
119
+ if (!arg) {
120
+ process.stderr.write("usage: node scripts/kxm-bump-version.mjs <version|patch|minor|major>\n");
121
+ return 1;
122
+ }
123
+
124
+ const pkgPath = resolve(root, "package.json");
125
+ const currentVersion = JSON.parse(readFileSync(pkgPath, "utf8")).version;
126
+
127
+ let newVersion;
128
+ if (SEMVER_RE.test(arg)) {
129
+ newVersion = arg;
130
+ } else {
131
+ newVersion = bumpVersion(currentVersion, arg);
132
+ }
133
+
134
+ if (newVersion === currentVersion) {
135
+ process.stdout.write(`version unchanged: ${currentVersion}\n`);
136
+ return 0;
137
+ }
138
+
139
+ const result = applyVersionBump(root, newVersion);
140
+ process.stdout.write(`bumped ${result.oldVersion} -> ${result.newVersion}\n`);
141
+ return 0;
142
+ }
143
+
144
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
145
+ process.exitCode = main();
146
+ }
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn, spawnSync } from "node:child_process";
4
- import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { randomBytes } from "node:crypto";
5
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
5
7
  import { join, resolve } from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
9
 
@@ -15,6 +17,19 @@ const startedAt = new Date().toISOString();
15
17
  const controlFile = "hub.stop";
16
18
  const controlPath = join(stateDir, controlFile);
17
19
 
20
+ // --- Persistent hub credentials (kxm.hub-env.v1) -------------------------
21
+ // Precedence: KXM_AUTH_TOKEN/KXM_PROJECT_TOKENS env, then the persisted
22
+ // user-state file, then a generated admin token that is persisted so later
23
+ // restarts, workers, and dashboards reuse the same credential.
24
+ function resolveUserStateRoot() {
25
+ const explicit = process.env.KXM_STATE_HOME?.trim();
26
+ if (explicit) return resolve(explicit);
27
+ const home = homedir();
28
+ if (process.platform === "win32") return resolve(process.env.LOCALAPPDATA?.trim() || join(home, "AppData", "Local"), "KXM");
29
+ if (process.platform === "darwin") return resolve(home, "Library", "Application Support", "KXM");
30
+ return resolve(process.env.XDG_STATE_HOME?.trim() || join(home, ".local", "state"), "kxm");
31
+ }
32
+
18
33
  function processExists(pid) {
19
34
  try {
20
35
  process.kill(pid, 0);
@@ -24,6 +39,119 @@ function processExists(pid) {
24
39
  }
25
40
  }
26
41
 
42
+ function parseHubEnvFile() {
43
+ const file = join(resolveUserStateRoot(), "hub-env.json");
44
+ let raw;
45
+ try {
46
+ raw = readFileSync(file, "utf8");
47
+ } catch (error) {
48
+ if (error?.code === "ENOENT") return { file, record: undefined };
49
+ throw new Error(`KXM hub could not read the persisted hub env at ${file}: ${error.message}`);
50
+ }
51
+ let parsed;
52
+ try {
53
+ parsed = JSON.parse(raw);
54
+ } catch (error) {
55
+ throw new Error(`KXM hub env file is malformed at ${file}; fix or remove it (it holds only KXM_AUTH_TOKEN/KXM_PROJECT_TOKENS values): ${error.message}`);
56
+ }
57
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || parsed.schema !== "kxm.hub-env.v1") {
58
+ throw new Error(`KXM hub env file at ${file} does not use schema kxm.hub-env.v1`);
59
+ }
60
+ return { file, record: parsed };
61
+ }
62
+
63
+ function generateAdminToken() {
64
+ return `kxm_admin_${randomBytes(24).toString("base64url")}`;
65
+ }
66
+
67
+ function persistHubEnv(file, record) {
68
+ mkdirSync(resolve(file, ".."), { recursive: true });
69
+ const temporary = join(resolve(file, ".."), `.hub-env-${process.pid}.tmp`);
70
+ writeFileSync(temporary, `${JSON.stringify(record, undefined, 2)}\n`, { encoding: "utf8", mode: 0o600 });
71
+ try {
72
+ renameSync(temporary, file);
73
+ } catch (error) {
74
+ rmSync(temporary, { force: true });
75
+ throw error;
76
+ }
77
+ }
78
+
79
+ function parseProjectTokensEnv(raw) {
80
+ const trimmed = raw?.trim();
81
+ if (!trimmed) return undefined;
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(trimmed);
85
+ } catch {
86
+ throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens");
87
+ }
88
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
89
+ throw new Error("KXM_PROJECT_TOKENS must be a JSON object of project names to tokens");
90
+ }
91
+ for (const [project, token] of Object.entries(parsed)) {
92
+ if (!project.trim() || typeof token !== "string" || !token.trim()) {
93
+ throw new Error("KXM_PROJECT_TOKENS must contain non-empty project names and token strings");
94
+ }
95
+ }
96
+ return parsed;
97
+ }
98
+
99
+ /** Resolve credentials, persisting generated/env values into the user-state file. */
100
+ function resolveCredentials() {
101
+ const { file, record } = parseHubEnvFile();
102
+ const envToken = process.env.KXM_AUTH_TOKEN?.trim();
103
+ const envProjectTokens = parseProjectTokensEnv(process.env.KXM_PROJECT_TOKENS);
104
+ let authToken = envToken || record?.authToken;
105
+ let authTokenSource = envToken ? "env" : record?.authToken ? "file" : "none";
106
+ if (!authToken) {
107
+ authToken = generateAdminToken();
108
+ authTokenSource = "generated";
109
+ }
110
+ const projectTokens = envProjectTokens || record?.projectTokens;
111
+ const projectTokensSource = envProjectTokens ? "env" : record?.projectTokens ? "file" : "none";
112
+ const envChanged = (authTokenSource === "env" && record?.authToken !== authToken)
113
+ || (projectTokensSource === "env" && JSON.stringify(record?.projectTokens ?? undefined) !== JSON.stringify(projectTokens ?? undefined));
114
+ if (authTokenSource === "generated" || envChanged) {
115
+ persistHubEnv(file, {
116
+ schema: "kxm.hub-env.v1",
117
+ createdAt: record?.createdAt || new Date().toISOString(),
118
+ ...(authToken ? { authToken } : {}),
119
+ ...(projectTokens ? { projectTokens } : {}),
120
+ });
121
+ }
122
+ return { authToken, authTokenSource, projectTokens, projectTokensSource, file };
123
+ }
124
+
125
+ let credentials;
126
+ try {
127
+ credentials = resolveCredentials();
128
+ } catch (error) {
129
+ process.stderr.write(`kxm hub: ${error.message}\n`);
130
+ process.exit(1);
131
+ }
132
+ const launchEnv = { ...process.env };
133
+ if (credentials.authToken) launchEnv.KXM_AUTH_TOKEN = credentials.authToken;
134
+ if (credentials.projectTokens) launchEnv.KXM_PROJECT_TOKENS = JSON.stringify(credentials.projectTokens);
135
+ if (credentials.authTokenSource !== "env") {
136
+ process.stdout.write(`kxm hub: using ${credentials.authTokenSource === "generated" ? "newly generated" : "persisted"} KXM_AUTH_TOKEN from ${credentials.file}\n`);
137
+ }
138
+
139
+ function terminateTree(pid) {
140
+ // Best-effort cleanup of an orphaned hub server whose wrapper died
141
+ // without reclaiming (e.g. SIGKILL). The wrapper could not be stopped
142
+ // gracefully in that state, so the server is asked to shut down first and
143
+ // forced after a short grace period.
144
+ if (!Number.isInteger(pid) || pid <= 0) return;
145
+ try { process.kill(pid, "SIGTERM"); } catch { /* already gone */ }
146
+ const deadline = Date.now() + 3000;
147
+ while (Date.now() < deadline) {
148
+ if (!processExists(pid)) return;
149
+ const wait = Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
150
+ if (wait !== "timed-out") return;
151
+ }
152
+ try { process.kill(pid, "SIGKILL"); } catch { /* already gone */ }
153
+ }
154
+
27
155
  function claimPidFile() {
28
156
  const record = { version: 1, pid: process.pid, role: "hub", startedAt, controlFile };
29
157
  for (let attempt = 0; attempt < 3; attempt += 1) {
@@ -50,9 +178,16 @@ function claimPidFile() {
50
178
  if (existing?.version === 1 && existing.role === "hub" && Number.isInteger(existing.pid) && processExists(existing.pid)) {
51
179
  throw new Error(`KXM hub is already managed by PID ${existing.pid}`);
52
180
  }
53
- // A well-formed claim whose owner is dead is safe to reclaim. Keep
181
+ // A well-formed claim whose wrapper is dead is safe to reclaim. Keep
54
182
  // malformed claims fail-closed because they may belong to another tool.
55
183
  if (existing?.version === 1 && existing.role === "hub" && Number.isInteger(existing.pid) && !processExists(existing.pid)) {
184
+ // The wrapper is gone. An orphaned server child recorded by the dead
185
+ // wrapper is cleaned up before reclaiming so a SIGKILLed wrapper does
186
+ // not leave a port-holding server behind.
187
+ if (Number.isInteger(existing.serverPid) && existing.serverPid !== existing.pid && processExists(existing.serverPid)) {
188
+ process.stderr.write(`kxm hub: cleaning up orphaned hub server PID ${existing.serverPid} from a dead wrapper\n`);
189
+ terminateTree(existing.serverPid);
190
+ }
56
191
  rmSync(pidPath, { force: true });
57
192
  continue;
58
193
  }
@@ -79,8 +214,15 @@ const child = spawn(process.execPath, [
79
214
  ...process.argv.slice(2),
80
215
  ], {
81
216
  stdio: ["inherit", "inherit", "inherit", "ipc"],
82
- env: process.env,
217
+ env: launchEnv,
83
218
  });
219
+ // Record the server child PID inside the claim so a later wrapper can clean
220
+ // up an orphaned server if this wrapper is killed without running cleanupPid.
221
+ if (child.pid) {
222
+ try {
223
+ writeFileSync(pidPath, `${JSON.stringify({ version: 1, pid: process.pid, serverPid: child.pid, role: "hub", startedAt, controlFile })}\n`, "utf8");
224
+ } catch { /* claim already written; serverPid tracking is best-effort */ }
225
+ }
84
226
  let stopping = false;
85
227
 
86
228
  child.once("error", (error) => {
@@ -275,7 +275,9 @@ export async function main(env = process.env, stdout = process.stdout, stderr =
275
275
  const token = env.GITHUB_TOKEN || env.GH_TOKEN;
276
276
  const npmToken = env.NODE_AUTH_TOKEN || env.NPM_TOKEN;
277
277
  const repo = env.GITHUB_REPOSITORY;
278
- const tag = env.GITHUB_REF_NAME || env.TAG;
278
+ // KXM_RELEASE_TAG is explicit for workflow_dispatch runs; GITHUB_REF_NAME
279
+ // is the branch name for that event, not the requested release tag.
280
+ const tag = env.KXM_RELEASE_TAG || env.TAG || env.GITHUB_REF_NAME;
279
281
  const assetName = env.KXM_ASSET;
280
282
  const assetPath = env.KXM_ASSET_PATH;
281
283
  const declaredSha256 = env.KXM_ASSET_SHA256;
@@ -427,7 +427,9 @@ export function fileSha256(path) {
427
427
  export async function main(env = process.env, stdout = process.stdout, stderr = process.stderr) {
428
428
  const token = env.GITHUB_TOKEN || env.GH_TOKEN;
429
429
  const repo = env.GITHUB_REPOSITORY;
430
- const tag = env.GITHUB_REF_NAME;
430
+ // KXM_RELEASE_TAG is explicit for workflow_dispatch runs; GITHUB_REF_NAME
431
+ // is the branch name for that event, not the requested release tag.
432
+ const tag = env.KXM_RELEASE_TAG || env.TAG || env.GITHUB_REF_NAME;
431
433
  const assetName = env.KXM_ASSET;
432
434
  const assetPath = env.KXM_ASSET_PATH;
433
435
  const declared = env.KXM_ASSET_SHA256;
package/scripts/kxm.mjs CHANGED
File without changes