@kontextmind/kxm 0.7.18 → 0.7.20
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.
- package/.claude-plugin/marketplace.json +1 -1
- package/docs/configuration.md +17 -1
- package/docs/getting-started.md +4 -1
- package/docs/kxm-handbook.md +6 -2
- package/docs/troubleshooting.md +4 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +441 -419
- package/plugins/kxm/dist/extension.js +13981 -13557
- package/plugins/kxm/dist/mcp-server.js +21 -10
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/hub.ts +3 -2
- package/plugins/kxm/src/cli.ts +3 -2
- package/plugins/kxm/src/config.ts +20 -0
- package/plugins/kxm/src/extension.ts +37 -3
- package/plugins/kxm/src/hub-autostart.ts +214 -0
- package/plugins/kxm/src/mcp-server.ts +3 -3
- package/plugins/kxm/src/project-name.ts +20 -0
|
@@ -7187,9 +7187,6 @@ var require_dist = __commonJS({
|
|
|
7187
7187
|
}
|
|
7188
7188
|
});
|
|
7189
7189
|
|
|
7190
|
-
// plugins/kxm/src/mcp-server.ts
|
|
7191
|
-
import { basename } from "node:path";
|
|
7192
|
-
|
|
7193
7190
|
// node_modules/zod/v4/core/core.js
|
|
7194
7191
|
var _a;
|
|
7195
7192
|
// @__NO_SIDE_EFFECTS__
|
|
@@ -16169,10 +16166,24 @@ var HubClient = class {
|
|
|
16169
16166
|
}
|
|
16170
16167
|
};
|
|
16171
16168
|
|
|
16169
|
+
// plugins/kxm/src/project-name.ts
|
|
16170
|
+
import { readFileSync } from "node:fs";
|
|
16171
|
+
import { basename, join } from "node:path";
|
|
16172
|
+
function defaultProjectName(cwd, env = process.env) {
|
|
16173
|
+
const fromEnv = env.KXM_PROJECT?.trim();
|
|
16174
|
+
if (fromEnv) return fromEnv;
|
|
16175
|
+
try {
|
|
16176
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
16177
|
+
if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
|
|
16178
|
+
} catch {
|
|
16179
|
+
}
|
|
16180
|
+
return basename(cwd);
|
|
16181
|
+
}
|
|
16182
|
+
|
|
16172
16183
|
// plugins/kxm/src/commands.ts
|
|
16173
|
-
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
16184
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync as readFileSync2, unlinkSync, writeFileSync } from "node:fs";
|
|
16174
16185
|
import { homedir } from "node:os";
|
|
16175
|
-
import { dirname, join, resolve } from "node:path";
|
|
16186
|
+
import { dirname, join as join2, resolve } from "node:path";
|
|
16176
16187
|
function requiredString(value, name) {
|
|
16177
16188
|
if (typeof value !== "string" || value.trim() === "") throw new Error(`${name} is required`);
|
|
16178
16189
|
return value.trim();
|
|
@@ -16908,10 +16919,10 @@ function parseSessionToken(token) {
|
|
|
16908
16919
|
}
|
|
16909
16920
|
function resolveUserConfigDirectory(overrideDir) {
|
|
16910
16921
|
if (overrideDir) return resolve(overrideDir);
|
|
16911
|
-
return resolve(process.env.KXM_USER_CONFIG_DIR?.trim() ||
|
|
16922
|
+
return resolve(process.env.KXM_USER_CONFIG_DIR?.trim() || join2(homedir(), ".config", "kxm"));
|
|
16912
16923
|
}
|
|
16913
16924
|
function sessionTokenPath(userConfigDir) {
|
|
16914
|
-
return
|
|
16925
|
+
return join2(resolveUserConfigDirectory(userConfigDir), "session.token");
|
|
16915
16926
|
}
|
|
16916
16927
|
function matchToolPattern(pattern, toolName) {
|
|
16917
16928
|
if (pattern === "*" || pattern === toolName) return true;
|
|
@@ -17007,7 +17018,7 @@ function enforceToolPolicy(commandName, env = process.env, options) {
|
|
|
17007
17018
|
if (existsSync(tokenFile)) {
|
|
17008
17019
|
let tokenRaw;
|
|
17009
17020
|
try {
|
|
17010
|
-
tokenRaw =
|
|
17021
|
+
tokenRaw = readFileSync2(tokenFile, "utf8").trim();
|
|
17011
17022
|
} catch {
|
|
17012
17023
|
return { allowed: false, error: "session_token_invalid", detail: "Session token file on disk could not be read" };
|
|
17013
17024
|
}
|
|
@@ -17036,7 +17047,7 @@ async function deliverInboxNotification(messageId, delivered, notify) {
|
|
|
17036
17047
|
}
|
|
17037
17048
|
|
|
17038
17049
|
// plugins/kxm/src/mcp-server.ts
|
|
17039
|
-
var VERSION = "0.7.
|
|
17050
|
+
var VERSION = "0.7.20";
|
|
17040
17051
|
var inbox = /* @__PURE__ */ new Map();
|
|
17041
17052
|
var notifiedInbox = /* @__PURE__ */ new Set();
|
|
17042
17053
|
var meshClient;
|
|
@@ -17109,7 +17120,7 @@ async function ensureClient() {
|
|
|
17109
17120
|
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
|
|
17110
17121
|
name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
|
|
17111
17122
|
purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
|
|
17112
|
-
project: process.env
|
|
17123
|
+
project: defaultProjectName(projectDir, process.env),
|
|
17113
17124
|
model: "claude-code",
|
|
17114
17125
|
...authToken ? { authToken } : {}
|
|
17115
17126
|
});
|
package/plugins/kxm/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
4
|
import { redactSecrets } from "../redact.ts";
|
|
5
|
+
import { defaultProjectName } from "../project-name.ts";
|
|
5
6
|
import { agentWorker, type Worker } from "../envelope.ts";
|
|
6
7
|
import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
|
|
7
8
|
import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
|
|
@@ -130,7 +131,7 @@ export async function cmdDash(runtime: Runtime, options: { screen?: string | und
|
|
|
130
131
|
runtime.io.stderr("kxm dash does not support --json; use kxm hub view\n");
|
|
131
132
|
return 2;
|
|
132
133
|
}
|
|
133
|
-
const project = runtime.
|
|
134
|
+
const project = defaultProjectName(runtime.dirs.workdir, runtime.env) || "project";
|
|
134
135
|
const authToken = runtime.env.KXM_AUTH_TOKEN?.trim();
|
|
135
136
|
return await runMeshTui({
|
|
136
137
|
serverUrl: runtime.serverUrl,
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -12,11 +12,12 @@
|
|
|
12
12
|
* - System, update, and configuration (`kxm update`, `kxm config`, `kxm completion`, `kxm improve`)
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import {
|
|
15
|
+
import { dirname, join, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
import { Command, CommanderError } from "commander";
|
|
18
18
|
import { readInstalledKxmVersion } from "./kxm-update.ts";
|
|
19
19
|
import { HubClient } from "./client.ts";
|
|
20
|
+
import { defaultProjectName } from "./project-name.ts";
|
|
20
21
|
import {
|
|
21
22
|
AGENT_COMMANDS_MAP,
|
|
22
23
|
enforceToolPolicy,
|
|
@@ -179,7 +180,7 @@ function addGlobalOptions(command: Command): Command {
|
|
|
179
180
|
|
|
180
181
|
async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
|
|
181
182
|
const serverUrl = runtime.serverUrl;
|
|
182
|
-
const project =
|
|
183
|
+
const project = defaultProjectName(runtime.cwd, runtime.env);
|
|
183
184
|
const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
|
|
184
185
|
const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
|
|
185
186
|
const authToken = runtime.env.KXM_AUTH_TOKEN?.trim();
|
|
@@ -45,6 +45,14 @@ export interface KxmSyncTrackerConfig {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
export type KxmHubAutoStart = "off" | "background";
|
|
49
|
+
|
|
50
|
+
export interface KxmHubConfig {
|
|
51
|
+
/** Pi extension hub startup: `background` starts a detached hub when no
|
|
52
|
+
* healthy bound hub or live local claim exists; `off` never starts one. */
|
|
53
|
+
autoStart: KxmHubAutoStart;
|
|
54
|
+
}
|
|
55
|
+
|
|
48
56
|
export type ImprovementPromotionPolicy = "manual_pr" | "critic_quorum" | "auto_threshold";
|
|
49
57
|
|
|
50
58
|
export interface KxmImprovementConfig {
|
|
@@ -90,6 +98,7 @@ export interface KxmResolvedConfig {
|
|
|
90
98
|
defaults: KxmProjectDefaults;
|
|
91
99
|
dash: KxmDashConfig;
|
|
92
100
|
sync: KxmSyncTrackerConfig;
|
|
101
|
+
hub: KxmHubConfig;
|
|
93
102
|
improvement: KxmImprovementConfig;
|
|
94
103
|
routing: KxmRoutingConfig;
|
|
95
104
|
telemetry: KxmTelemetryConfig;
|
|
@@ -118,6 +127,9 @@ export const DEFAULT_KXM_CONFIG: Omit<KxmResolvedConfig, "loadedFrom"> = {
|
|
|
118
127
|
sync: {
|
|
119
128
|
defaultTracker: "none",
|
|
120
129
|
},
|
|
130
|
+
hub: {
|
|
131
|
+
autoStart: "background",
|
|
132
|
+
},
|
|
121
133
|
improvement: {
|
|
122
134
|
promotionPolicy: "manual_pr",
|
|
123
135
|
telemetryHalfLifeDays: 14,
|
|
@@ -176,6 +188,13 @@ function deepMerge<T extends Record<string, unknown>>(target: T, source: Record<
|
|
|
176
188
|
return result as T;
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
/** Unknown `hub.autoStart` values fail closed to the default so a config typo
|
|
192
|
+
* never silently changes hub startup behavior. */
|
|
193
|
+
function normalizeHubConfig(raw: unknown): KxmHubConfig {
|
|
194
|
+
const autoStart = (raw as { autoStart?: unknown } | undefined)?.autoStart;
|
|
195
|
+
return { autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart };
|
|
196
|
+
}
|
|
197
|
+
|
|
179
198
|
export function loadKxmConfig(
|
|
180
199
|
repoRoot = process.cwd(),
|
|
181
200
|
options: { userConfigDir?: string } = {},
|
|
@@ -221,6 +240,7 @@ export function loadKxmConfig(
|
|
|
221
240
|
defaults: (mergedAll.defaults as KxmProjectDefaults) ?? {},
|
|
222
241
|
dash: (mergedAll.dash as KxmDashConfig) ?? {},
|
|
223
242
|
sync: (mergedAll.sync as KxmSyncTrackerConfig) ?? {},
|
|
243
|
+
hub: normalizeHubConfig(mergedAll.hub),
|
|
224
244
|
improvement: (mergedAll.improvement as KxmImprovementConfig) ?? DEFAULT_KXM_CONFIG.improvement,
|
|
225
245
|
routing: (mergedAll.routing as KxmRoutingConfig) ?? DEFAULT_KXM_CONFIG.routing,
|
|
226
246
|
telemetry: (mergedAll.telemetry as KxmTelemetryConfig) ?? DEFAULT_KXM_CONFIG.telemetry,
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
-
import {
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { AGENT_COMMANDS, enforceToolPolicy } from "./commands.ts";
|
|
6
6
|
import { HubClient, HubHttpError } from "./client.ts";
|
|
7
|
+
import { loadKxmConfig } from "./config.ts";
|
|
8
|
+
import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
|
|
9
|
+
import { readHubEnvRecord } from "./hub-env.ts";
|
|
10
|
+
import { defaultProjectName } from "./project-name.ts";
|
|
7
11
|
import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
|
|
8
12
|
import {
|
|
9
13
|
antigravityRegistrationNotice,
|
|
@@ -673,7 +677,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
673
677
|
}
|
|
674
678
|
currentPiSessionId = ctx.sessionManager?.getSessionId();
|
|
675
679
|
const serverUrl = process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331";
|
|
676
|
-
const project =
|
|
680
|
+
const project = defaultProjectName(ctx.cwd, process.env);
|
|
677
681
|
const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
|
|
678
682
|
agentName = name;
|
|
679
683
|
projectName = project;
|
|
@@ -681,12 +685,42 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
|
|
|
681
685
|
removeMatchingLegacyRecoveryContext();
|
|
682
686
|
const purpose = process.env.KXM_AGENT_PURPOSE ?? "General-purpose coding agent";
|
|
683
687
|
const model = ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
|
|
688
|
+
let autoStartToken: string | undefined;
|
|
689
|
+
try {
|
|
690
|
+
const config = loadKxmConfig(ctx.cwd);
|
|
691
|
+
if (hubAutoStartMode(config) === "background") {
|
|
692
|
+
const ensured = await ensureHubRunning({ config, cwd: ctx.cwd });
|
|
693
|
+
if (ensured.status === "started") {
|
|
694
|
+
autoStartToken = ensured.authToken;
|
|
695
|
+
ctx.ui.notify(
|
|
696
|
+
`kxm hub started in the background (pid ${ensured.pid}); logs: ${ensured.logPath}`
|
|
697
|
+
+ (ensured.authTokenSource === "generated" ? "; new admin token generated and persisted to user state" : ""),
|
|
698
|
+
"info",
|
|
699
|
+
);
|
|
700
|
+
} else if (ensured.status === "failed") {
|
|
701
|
+
ctx.ui.notify(`kxm hub auto-start failed: ${ensured.reason} (log: ${ensured.logPath})`, "warning");
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
} catch (error) {
|
|
705
|
+
ctx.ui.notify(`kxm hub auto-start failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
706
|
+
}
|
|
707
|
+
// Authenticate with the explicit env token first, then the token resolved
|
|
708
|
+
// by auto-start, then the credential persisted for this machine's hubs.
|
|
709
|
+
const envAuthToken = process.env.KXM_AUTH_TOKEN?.trim();
|
|
710
|
+
let hubAuthToken = envAuthToken || autoStartToken;
|
|
711
|
+
if (!hubAuthToken) {
|
|
712
|
+
try {
|
|
713
|
+
hubAuthToken = readHubEnvRecord()?.authToken?.trim() || undefined;
|
|
714
|
+
} catch (error) {
|
|
715
|
+
ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
716
|
+
}
|
|
717
|
+
}
|
|
684
718
|
client = new HubClient({
|
|
685
719
|
serverUrl,
|
|
686
720
|
name,
|
|
687
721
|
purpose,
|
|
688
722
|
project,
|
|
689
|
-
...(
|
|
723
|
+
...(hubAuthToken ? { authToken: hubAuthToken } : {}),
|
|
690
724
|
...(model ? { model } : {}),
|
|
691
725
|
});
|
|
692
726
|
notify = (message, type) => ctx.ui.notify(message, type);
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { probeHubHealth, readHubBinding } from "./hub-binding.ts";
|
|
6
|
+
import { resolveHubCredentials } from "./hub-env.ts";
|
|
7
|
+
import { redactSecrets } from "./redact.ts";
|
|
8
|
+
|
|
9
|
+
/** Background hub auto-start for harness extensions (Pi TUI, one-shot CLIs).
|
|
10
|
+
*
|
|
11
|
+
* On extension load: reuse a healthy bound hub, then a live local `hub.pid`
|
|
12
|
+
* claim, and only then spawn the detached `kxm-hub.mjs` wrapper. Credentials
|
|
13
|
+
* are resolved before launch, so a first run generates the admin token and
|
|
14
|
+
* persists it (`0600`) under the user state root, exactly as
|
|
15
|
+
* `kxm hub start` does. The single-hub-per-user invariants (exclusive PID
|
|
16
|
+
* claim, reclaimed dead wrappers) come from the wrapper itself. */
|
|
17
|
+
|
|
18
|
+
export const HUB_AUTOSTART_LOG_NAME = "hub-autostart.log";
|
|
19
|
+
const CLAIM_POLL_MS = 50;
|
|
20
|
+
const DEFAULT_CLAIM_TIMEOUT_MS = 3000;
|
|
21
|
+
|
|
22
|
+
export type HubAutoStartMode = "off" | "background";
|
|
23
|
+
|
|
24
|
+
export type EnsureHubResult =
|
|
25
|
+
| { status: "disabled" }
|
|
26
|
+
| { status: "bound-healthy"; url: string }
|
|
27
|
+
| { status: "url-healthy"; url: string }
|
|
28
|
+
| { status: "claim-alive"; pid: number }
|
|
29
|
+
| { status: "started"; pid: number; logPath: string; authToken: string | undefined; authTokenSource: string }
|
|
30
|
+
| { status: "failed"; reason: string; logPath: string };
|
|
31
|
+
|
|
32
|
+
export interface HubSpawnOptions {
|
|
33
|
+
cwd: string;
|
|
34
|
+
env: NodeJS.ProcessEnv;
|
|
35
|
+
stdio: ["ignore", number, number];
|
|
36
|
+
detached: boolean;
|
|
37
|
+
windowsHide: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface HubSpawned {
|
|
41
|
+
pid?: number | undefined;
|
|
42
|
+
once(event: "exit", listener: (code: number | null, signal: NodeJS.Signals | null) => void): unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type HubSpawner = (command: string, args: readonly string[], options: HubSpawnOptions) => HubSpawned;
|
|
46
|
+
|
|
47
|
+
export interface EnsureHubOptions {
|
|
48
|
+
config: { hub?: { autoStart?: unknown } } | undefined;
|
|
49
|
+
cwd: string;
|
|
50
|
+
env?: NodeJS.ProcessEnv | undefined;
|
|
51
|
+
fetchImpl?: typeof fetch | undefined;
|
|
52
|
+
spawner?: HubSpawner | undefined;
|
|
53
|
+
processExists?: ((pid: number) => boolean) | undefined;
|
|
54
|
+
scriptPath?: string | undefined;
|
|
55
|
+
sleep?: ((ms: number) => Promise<void>) | undefined;
|
|
56
|
+
now?: (() => number) | undefined;
|
|
57
|
+
claimTimeoutMs?: number | undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Resolve the configured auto-start mode; unknown values fail closed to the
|
|
61
|
+
* default so a typo never silently disables (or re-enables) hub startup. */
|
|
62
|
+
export function hubAutoStartMode(config: { hub?: { autoStart?: unknown } } | undefined): HubAutoStartMode {
|
|
63
|
+
const raw = config?.hub?.autoStart;
|
|
64
|
+
return raw === "off" || raw === "background" ? raw : "background";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function defaultProcessExists(pid: number): boolean {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
return (error as NodeJS.ErrnoException).code === "EPERM";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface HubClaimSnapshot {
|
|
77
|
+
pid: number;
|
|
78
|
+
serverPid: number | undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Read the workspace hub PID claim. A missing or malformed claim is not
|
|
82
|
+
* live; the wrapper fails closed on malformed claims when it starts, so the
|
|
83
|
+
* extension must not try to clean claims up itself. */
|
|
84
|
+
export function readLiveHubClaim(stateDir: string, processExists: (pid: number) => boolean = defaultProcessExists): HubClaimSnapshot | undefined {
|
|
85
|
+
let parsed: unknown;
|
|
86
|
+
try {
|
|
87
|
+
parsed = JSON.parse(readFileSync(join(stateDir, "hub.pid"), "utf8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
|
|
92
|
+
const record = parsed as { version?: unknown; role?: unknown; pid?: unknown; serverPid?: unknown };
|
|
93
|
+
if (record.version !== 1 || record.role !== "hub" || !Number.isInteger(record.pid)) return undefined;
|
|
94
|
+
const pid = record.pid as number;
|
|
95
|
+
if (pid <= 0 || !processExists(pid)) return undefined;
|
|
96
|
+
return {
|
|
97
|
+
pid,
|
|
98
|
+
...(Number.isInteger(record.serverPid) && (record.serverPid as number) > 0 ? { serverPid: record.serverPid as number } : { serverPid: undefined }),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The hub wrapper script next to the checked-out KXM repo. The extension and
|
|
103
|
+
* the CLI both resolve the same path so auto-start launches the identical
|
|
104
|
+
* supervision wrapper as `kxm hub start`. */
|
|
105
|
+
export function defaultHubWrapperScriptPath(moduleUrl: string = import.meta.url): string {
|
|
106
|
+
return resolve(dirname(fileURLToPath(moduleUrl)), "../../../scripts/kxm-hub.mjs");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultHubSpawner(command: string, args: readonly string[], options: HubSpawnOptions): HubSpawned {
|
|
110
|
+
const child = spawn(command, [...args], options);
|
|
111
|
+
child.unref();
|
|
112
|
+
return child;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function logTail(logPath: string, maxChars = 2000): string {
|
|
116
|
+
try {
|
|
117
|
+
return redactSecrets(readFileSync(logPath, "utf8").trim().slice(-maxChars));
|
|
118
|
+
} catch {
|
|
119
|
+
return "";
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Ensure a hub is available, starting a detached one only when no healthy
|
|
124
|
+
* bound hub and no live local claim exists. Never throws for ordinary
|
|
125
|
+
* failures; structured results let callers notify without blocking load. */
|
|
126
|
+
export async function ensureHubRunning(options: EnsureHubOptions): Promise<EnsureHubResult> {
|
|
127
|
+
const env = options.env ?? process.env;
|
|
128
|
+
if (hubAutoStartMode(options.config) !== "background") return { status: "disabled" };
|
|
129
|
+
|
|
130
|
+
// 1. A bound hub that answers health is authoritative — reuse it.
|
|
131
|
+
const binding = readHubBinding(env);
|
|
132
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
133
|
+
if (binding) {
|
|
134
|
+
const { health } = await probeHubHealth(binding.url, fetchImpl);
|
|
135
|
+
if (health === "on") return { status: "bound-healthy", url: binding.url };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. A live local claim means a hub (started any way) already owns this
|
|
139
|
+
// workspace state — do not start a second one.
|
|
140
|
+
const workdir = resolve(env.KXM_WORKDIR?.trim() || options.cwd);
|
|
141
|
+
const workspaceDir = resolve(workdir, env.KXM_WORKSPACE_DIR?.trim() || ".kxm");
|
|
142
|
+
const stateDir = resolve(workdir, env.KXM_STATE_DIR?.trim() || join(workspaceDir, "state"));
|
|
143
|
+
const logsDir = resolve(workdir, env.KXM_LOGS_DIR?.trim() || join(workspaceDir, "logs"));
|
|
144
|
+
const processExists = options.processExists ?? defaultProcessExists;
|
|
145
|
+
const live = readLiveHubClaim(stateDir, processExists);
|
|
146
|
+
if (live) return { status: "claim-alive", pid: live.pid };
|
|
147
|
+
|
|
148
|
+
// 3. A hub already serving the configured URL (for example one launched
|
|
149
|
+
// directly by a harness, without a claim or binding) is reused too: the
|
|
150
|
+
// client will connect there regardless, so spawning a duplicate would
|
|
151
|
+
// only fail to bind the same port.
|
|
152
|
+
const serverUrl = (env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331").replace(/\/+$/, "");
|
|
153
|
+
if (serverUrl !== binding?.url) {
|
|
154
|
+
const { health } = await probeHubHealth(serverUrl, fetchImpl);
|
|
155
|
+
if (health === "on") return { status: "url-healthy", url: serverUrl };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 4. Resolve credentials before launch so the key exists by the time the
|
|
159
|
+
// wrapper starts: env wins, then the persisted user-state file, then a
|
|
160
|
+
// generated token persisted with 0600 for later restarts and workers.
|
|
161
|
+
const credentials = resolveHubCredentials({ env });
|
|
162
|
+
const logPath = join(logsDir, HUB_AUTOSTART_LOG_NAME);
|
|
163
|
+
mkdirSync(logsDir, { recursive: true });
|
|
164
|
+
const logFd = openSync(logPath, "a");
|
|
165
|
+
let child: HubSpawned;
|
|
166
|
+
try {
|
|
167
|
+
child = (options.spawner ?? defaultHubSpawner)(process.execPath, [options.scriptPath ?? defaultHubWrapperScriptPath()], {
|
|
168
|
+
cwd: workdir,
|
|
169
|
+
env: {
|
|
170
|
+
...env,
|
|
171
|
+
KXM_WORKDIR: workdir,
|
|
172
|
+
KXM_WORKSPACE_DIR: workspaceDir,
|
|
173
|
+
KXM_STATE_DIR: stateDir,
|
|
174
|
+
KXM_LOGS_DIR: logsDir,
|
|
175
|
+
...(credentials.authToken ? { KXM_AUTH_TOKEN: credentials.authToken } : {}),
|
|
176
|
+
},
|
|
177
|
+
stdio: ["ignore", logFd, logFd],
|
|
178
|
+
detached: process.platform !== "win32",
|
|
179
|
+
windowsHide: true,
|
|
180
|
+
});
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(logFd);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// 4. Verify the wrapper claimed the hub. A simultaneous loader may have won
|
|
186
|
+
// the exclusive claim first; that is a live hub, not a failure.
|
|
187
|
+
const sleep = options.sleep ?? ((ms: number) => new Promise<void>((resolveSleep) => setTimeout(resolveSleep, ms)));
|
|
188
|
+
const now = options.now ?? Date.now;
|
|
189
|
+
const timeoutMs = options.claimTimeoutMs ?? DEFAULT_CLAIM_TIMEOUT_MS;
|
|
190
|
+
let earlyExit: { code: number | null; signal: NodeJS.Signals | null } | undefined;
|
|
191
|
+
child.once("exit", (code, signal) => {
|
|
192
|
+
earlyExit = { code, signal };
|
|
193
|
+
});
|
|
194
|
+
const deadline = now() + timeoutMs;
|
|
195
|
+
for (;;) {
|
|
196
|
+
if (earlyExit) {
|
|
197
|
+
// A simultaneous loader may have won the exclusive claim first; that
|
|
198
|
+
// is a live hub, not a failure.
|
|
199
|
+
const raced = readLiveHubClaim(stateDir, processExists);
|
|
200
|
+
if (raced) return { status: "claim-alive", pid: raced.pid };
|
|
201
|
+
const detail = logTail(logPath);
|
|
202
|
+
const reason = `hub wrapper exited before claiming the hub (code ${earlyExit.code ?? "null"}${earlyExit.signal ? `, signal ${earlyExit.signal}` : ""})${detail ? `: ${detail}` : ""}`;
|
|
203
|
+
return { status: "failed", reason, logPath };
|
|
204
|
+
}
|
|
205
|
+
const claimed = readLiveHubClaim(stateDir, processExists);
|
|
206
|
+
if (claimed) return { status: "started", pid: claimed.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource };
|
|
207
|
+
if (now() >= deadline) {
|
|
208
|
+
return child.pid
|
|
209
|
+
? { status: "started", pid: child.pid, logPath, authToken: credentials.authToken, authTokenSource: credentials.authTokenSource }
|
|
210
|
+
: { status: "failed", reason: `hub wrapper did not claim the hub within ${timeoutMs} ms`, logPath };
|
|
211
|
+
}
|
|
212
|
+
await sleep(CLAIM_POLL_MS);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import { basename } from "node:path";
|
|
2
1
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
3
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5
4
|
import { HubClient } from "./client.ts";
|
|
5
|
+
import { defaultProjectName } from "./project-name.ts";
|
|
6
6
|
import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
|
|
7
7
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
8
8
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
9
9
|
|
|
10
|
-
const VERSION = "0.7.
|
|
10
|
+
const VERSION = "0.7.20";
|
|
11
11
|
const inbox = new Map<string, MessageRecord>();
|
|
12
12
|
const notifiedInbox = new Set<string>();
|
|
13
13
|
let meshClient: HubClient | undefined;
|
|
@@ -88,7 +88,7 @@ async function ensureClient(): Promise<HubClient> {
|
|
|
88
88
|
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
|
|
89
89
|
name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
|
|
90
90
|
purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
|
|
91
|
-
project: process.env
|
|
91
|
+
project: defaultProjectName(projectDir, process.env),
|
|
92
92
|
model: "claude-code",
|
|
93
93
|
...(authToken ? { authToken } : {}),
|
|
94
94
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Default project identity for a workspace.
|
|
5
|
+
*
|
|
6
|
+
* Precedence: `KXM_PROJECT` environment, then the workspace `package.json`
|
|
7
|
+
* `name`, then the workspace directory name. The package.json read is
|
|
8
|
+
* best-effort and never walks upward: a missing, malformed, or name-less
|
|
9
|
+
* file falls through to the directory name. */
|
|
10
|
+
export function defaultProjectName(cwd: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
11
|
+
const fromEnv = env.KXM_PROJECT?.trim();
|
|
12
|
+
if (fromEnv) return fromEnv;
|
|
13
|
+
try {
|
|
14
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8")) as { name?: unknown };
|
|
15
|
+
if (typeof pkg.name === "string" && pkg.name.trim().length > 0) return pkg.name.trim();
|
|
16
|
+
} catch {
|
|
17
|
+
// No readable package.json: fall through to the directory name.
|
|
18
|
+
}
|
|
19
|
+
return basename(cwd);
|
|
20
|
+
}
|