@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.
@@ -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() || join(homedir(), ".config", "kxm"));
16922
+ return resolve(process.env.KXM_USER_CONFIG_DIR?.trim() || join2(homedir(), ".config", "kxm"));
16912
16923
  }
16913
16924
  function sessionTokenPath(userConfigDir) {
16914
- return join(resolveUserConfigDirectory(userConfigDir), "session.token");
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 = readFileSync(tokenFile, "utf8").trim();
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.18";
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.KXM_PROJECT?.trim() || basename(projectDir),
17123
+ project: defaultProjectName(projectDir, process.env),
17113
17124
  model: "claude-code",
17114
17125
  ...authToken ? { authToken } : {}
17115
17126
  });
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kxm-claude-plugin",
3
- "version": "0.7.18",
3
+ "version": "0.7.20",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -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 { basename, join, resolve } from "node:path";
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.env.KXM_PROJECT?.trim() || basename(runtime.dirs.workdir) || "project";
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,
@@ -12,11 +12,12 @@
12
12
  * - System, update, and configuration (`kxm update`, `kxm config`, `kxm completion`, `kxm improve`)
13
13
  */
14
14
 
15
- import { basename, dirname, join, resolve } from "node:path";
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 = runtime.env.KXM_PROJECT?.trim() || basename(runtime.dirs.workspace || runtime.cwd);
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 { basename, dirname, join } from "node:path";
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 = process.env.KXM_PROJECT ?? basename(ctx.cwd);
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
- ...(process.env.KXM_AUTH_TOKEN ? { authToken: process.env.KXM_AUTH_TOKEN } : {}),
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.18";
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.KXM_PROJECT?.trim() || basename(projectDir),
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
+ }