@kontextmind/kxm 0.7.164 → 0.7.166

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 (45) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +2 -0
  4. package/docs/README.md +1 -0
  5. package/docs/architecture/inventory.md +4 -3
  6. package/docs/concepts/trust-model.md +1 -1
  7. package/docs/glossary.md +4 -4
  8. package/docs/operations/troubleshooting.md +4 -1
  9. package/docs/operations.md +208 -0
  10. package/docs/reference/cli-reference.md +12 -11
  11. package/docs/reference/config-reference.md +36 -11
  12. package/docs/reference/configuration.md +11 -1
  13. package/docs/reference/tools.md +1 -1
  14. package/docs/start/quickstart-claude-code.md +2 -2
  15. package/package.json +1 -1
  16. package/plugins/kxm/.claude-plugin/plugin.json +2 -2
  17. package/plugins/kxm/README.md +4 -4
  18. package/plugins/kxm/dist/claude-hook.js +511 -56
  19. package/plugins/kxm/dist/cli.js +15661 -14775
  20. package/plugins/kxm/dist/extension.js +645 -133
  21. package/plugins/kxm/dist/mcp-server.js +8152 -81
  22. package/plugins/kxm/dist/runtime-supervisor.js +804 -155
  23. package/plugins/kxm/dist/runtime.js +851 -202
  24. package/plugins/kxm/dist/server.js +466 -58
  25. package/plugins/kxm/package.json +1 -1
  26. package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +17 -5
  27. package/plugins/kxm/src/cli/context-skills.ts +64 -10
  28. package/plugins/kxm/src/cli/hub.ts +192 -22
  29. package/plugins/kxm/src/cli/project.ts +7 -3
  30. package/plugins/kxm/src/cli/types.ts +14 -1
  31. package/plugins/kxm/src/cli/workflows.ts +12 -1
  32. package/plugins/kxm/src/cli.ts +63 -11
  33. package/plugins/kxm/src/config.ts +246 -3
  34. package/plugins/kxm/src/extension.ts +12 -7
  35. package/plugins/kxm/src/hub-autostart.ts +23 -0
  36. package/plugins/kxm/src/hub-binding.ts +182 -2
  37. package/plugins/kxm/src/hub-env.ts +132 -9
  38. package/plugins/kxm/src/hub-identity.ts +337 -0
  39. package/plugins/kxm/src/mcp-server.ts +17 -14
  40. package/plugins/kxm/src/project-name.ts +6 -20
  41. package/plugins/kxm/src/runtime-supervisor.ts +9 -3
  42. package/plugins/kxm/src/server.ts +22 -4
  43. package/plugins/kxm/src/tenant-status.ts +9 -2
  44. package/scripts/build-runtime.mjs +5 -0
  45. package/scripts/kxm-hub.mjs +5 -0
@@ -17,9 +17,11 @@ import { fileURLToPath } from "node:url";
17
17
  import { Command, CommanderError } from "commander";
18
18
  import { readInstalledKxmVersion } from "./kxm-update.ts";
19
19
  import { findKxmRepoRoot } from "./repo-root.ts";
20
- import { HubClient } from "./client.ts";
20
+ import { HubClient, HubHttpError } from "./client.ts";
21
21
  import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
22
- import { defaultProjectName } from "./project-name.ts";
22
+ import { CloudTokenError } from "./hub-binding.ts";
23
+ import { KxmHubConfigError } from "./config.ts";
24
+ import { resolveProjectIdentity } from "./hub-identity.ts";
23
25
  import {
24
26
  AGENT_COMMANDS_MAP,
25
27
  enforceToolPolicy,
@@ -255,13 +257,14 @@ function addGlobalOptions(command: Command): Command {
255
257
 
256
258
  async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
257
259
  const serverUrl = runtime.serverUrl;
258
- const project = defaultProjectName(runtime.cwd, runtime.env);
260
+ const identity = resolveProjectIdentity(runtime.cwd, runtime.env);
261
+ const project = identity.project;
259
262
  const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
260
263
  const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
261
264
  // These commands act as a peer agent, so they take the agent credential: never the
262
265
  // persisted admin token, which the hub accepts for any project without its own token.
263
- const authToken = resolveAgentHubAuthToken(runtime.env, project);
264
- if (!authToken) throw new AgentProjectTokenMissingError(project);
266
+ const authToken = resolveAgentHubAuthToken(runtime.env, project, runtime.cwd);
267
+ if (!authToken) throw new AgentProjectTokenMissingError(identity);
265
268
  const client = new HubClient({
266
269
  serverUrl,
267
270
  name,
@@ -310,6 +313,9 @@ async function dispatchAgentCliCommand(
310
313
  return 2;
311
314
  }
312
315
 
316
+ const explicitProject = typeof rawArgs.project === "string" ? rawArgs.project.trim() : "";
317
+ if (explicitProject) runtime = { ...runtime, env: { ...runtime.env, KXM_PROJECT: explicitProject } };
318
+
313
319
  let args: Record<string, unknown> = {};
314
320
  if (typeof rawArgs.payload === "string" && rawArgs.payload.trim()) {
315
321
  try {
@@ -324,7 +330,7 @@ async function dispatchAgentCliCommand(
324
330
  }
325
331
 
326
332
  for (const [key, val] of Object.entries(rawArgs)) {
327
- if (val !== undefined && key !== "payload") {
333
+ if (val !== undefined && key !== "payload" && key !== "project") {
328
334
  if (key === "timeoutMs" || key === "ttlMs" || key === "attempt") {
329
335
  args[key] = Number(val);
330
336
  } else if (key === "targets" && typeof val === "string") {
@@ -383,7 +389,43 @@ async function dispatchAgentCliCommand(
383
389
  print(
384
390
  runtime.io,
385
391
  runtime.json,
386
- { ok: false, error: error.code, project: error.project, nextAction: "export_kxm_auth_token", detail: message },
392
+ {
393
+ ok: false,
394
+ error: error.code,
395
+ project: error.project,
396
+ projectSource: error.projectSource,
397
+ nextAction: "configure_hub_project",
398
+ hint: error.hint,
399
+ detail: message,
400
+ },
401
+ message,
402
+ );
403
+ return 2;
404
+ }
405
+ if (error instanceof HubHttpError && error.code === "project_token_missing") {
406
+ const identity = resolveProjectIdentity(runtime.cwd, runtime.env);
407
+ const missing = new AgentProjectTokenMissingError(identity);
408
+ print(
409
+ runtime.io,
410
+ runtime.json,
411
+ {
412
+ ok: false,
413
+ error: missing.code,
414
+ project: identity.project,
415
+ projectSource: identity.source,
416
+ nextAction: "configure_hub_project",
417
+ hint: missing.hint,
418
+ detail: missing.message,
419
+ },
420
+ missing.message,
421
+ );
422
+ return 2;
423
+ }
424
+ if (error instanceof CloudTokenError) {
425
+ print(
426
+ runtime.io,
427
+ runtime.json,
428
+ { ok: false, error: error.code, detail: message },
387
429
  message,
388
430
  );
389
431
  return 2;
@@ -649,8 +691,9 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
649
691
  const tenantCmd = addGlobalOptions(program.command("tenant").description("Composed tenant reads for machine clients (portal)"));
650
692
  tenantCmd.helpCommand("help", "Show tenant help");
651
693
  addGlobalOptions(tenantCmd.command("status").description("Read hub metadata and authoritative Runtime run state as one labeled view"))
652
- .action(async function tenantStatusAction(this: Command) {
653
- result.code = await cmdTenantStatus(runtimeFrom(ctx, this));
694
+ .option("--project <id>", "Hub project; overrides KXM_PROJECT for this command")
695
+ .action(async function tenantStatusAction(this: Command, options: { project?: string }) {
696
+ result.code = await cmdTenantStatus(runtimeFrom(ctx, this), options);
654
697
  });
655
698
 
656
699
  const pricesCmd = addGlobalOptions(program.command("prices").description("Stamp the local list-price catalog. Estimates stay unknown until today's stamp"));
@@ -816,6 +859,7 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
816
859
  peer.helpCommand("help", "Show peer help");
817
860
 
818
861
  addGlobalOptions(peer.command("list").description("List peer agents in this project's hub pool with host and presence"))
862
+ .option("--project <id>", "Hub project; overrides KXM_PROJECT for this command")
819
863
  .option("--include-offline", "Also list registered peers whose hub lease has expired")
820
864
  .option("--payload <json>", "JSON payload")
821
865
  .action(async function peerListAction(this: Command, opts?: Record<string, unknown>) {
@@ -1326,8 +1370,12 @@ function createProgram(ctx: CliContext, result: { code: number }, argv: readonly
1326
1370
  result.code = await cmdStop(runtimeFrom(ctx, this), options.waitMs);
1327
1371
  });
1328
1372
  addGlobalOptions(hub.command("bind").description("Bind this machine to a running hub").argument("<url>", "Hub base URL (http or https)"))
1329
- .action(async function hubBindAction(this: Command, url: string) {
1330
- result.code = await cmdHubBind(runtimeFrom(ctx, this), url);
1373
+ .option("--cloud", "Mark the hub remote even when the URL is a loopback forward")
1374
+ .option("--token-env <name>", "Environment variable that holds the hub token; the token is not stored")
1375
+ .option("--token-command <command>", "Program that prints the hub token; the token is not stored")
1376
+ .option("--key-op <ref>", "op:// reference for the hub token; resolved with op read, never stored")
1377
+ .action(async function hubBindAction(this: Command, url: string, options: { cloud?: boolean; tokenEnv?: string; tokenCommand?: string; keyOp?: string }) {
1378
+ result.code = await cmdHubBind(runtimeFrom(ctx, this), url, options);
1331
1379
  });
1332
1380
  addGlobalOptions(hub.command("unbind").description("Remove this machine's hub binding")).action(bind(cmdHubUnbind));
1333
1381
  addGlobalOptions(program.command("dash").description("Live screens for headless agents, tasks, workflows, and plans")
@@ -1544,6 +1592,10 @@ export async function runCli(
1544
1592
  } catch (error) {
1545
1593
  if (error instanceof CommanderError) return mapCommanderError(error, argv, program, io);
1546
1594
  if (error instanceof DryRunRefused) return error.code;
1595
+ if (error instanceof KxmHubConfigError) {
1596
+ print(io, hasJsonFlag(argv), { ok: false, error: error.code, detail: error.message }, error.message);
1597
+ return 2;
1598
+ }
1547
1599
  throw error;
1548
1600
  }
1549
1601
  }
@@ -1,5 +1,5 @@
1
1
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
- import { homedir } from "node:os";
2
+ import { tmpdir, homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
4
  import { parse, stringify } from "yaml";
5
5
  import { redactSecrets } from "./redact.ts";
@@ -47,10 +47,55 @@ export interface KxmSyncTrackerConfig {
47
47
 
48
48
  export type KxmHubAutoStart = "off" | "background";
49
49
 
50
+ export type KxmHubMode = "local" | "cloud";
51
+
52
+ /** A secret reference. `op` is an `op://` reference resolved with `op read` at
53
+ * use time. `env` is an environment variable name. Neither field holds a token. */
54
+ export interface HubKeyRef {
55
+ op?: string;
56
+ env?: string;
57
+ }
58
+
59
+ /** One hub endpoint. `project` is the id presented to that hub. `key` is a
60
+ * reference, never a literal token. */
61
+ export interface HubEndpointConfig {
62
+ url?: string;
63
+ project?: string;
64
+ key?: HubKeyRef;
65
+ }
66
+
50
67
  export interface KxmHubConfig {
51
68
  /** Pi extension hub startup: `background` starts a detached hub when no
52
69
  * healthy bound hub or live local claim exists; `off` never starts one. */
53
70
  autoStart: KxmHubAutoStart;
71
+ /** Which endpoint is in effect. Unset means local unless a cloud binding says otherwise. */
72
+ mode?: KxmHubMode;
73
+ local?: HubEndpointConfig;
74
+ cloud?: HubEndpointConfig;
75
+ /** Hub-process project id to key reference. Resolved in memory; never persisted. */
76
+ projects?: Record<string, HubKeyRef>;
77
+ }
78
+
79
+ /** A hub config value is a token, or a key reference that is not an `op://` ref or an env var name. */
80
+ export class KxmHubConfigError extends Error {
81
+ readonly code = "hub_config_invalid";
82
+
83
+ constructor(message: string) {
84
+ super(message);
85
+ this.name = "KxmHubConfigError";
86
+ }
87
+ }
88
+
89
+ const HUB_ENV_NAME = /^[A-Za-z_][A-Za-z0-9_]{0,127}$/;
90
+ const HUB_OP_REF = /^op:\/\/\S+\/\S+\/\S+$/;
91
+ const LITERAL_SECRET_KEY = /^(token|secret|password|authToken|auth_token|apiKey|api_key|api_token|bearer|credential|access_token)$/i;
92
+
93
+ export function isHubKeyEnvName(value: string): boolean {
94
+ return HUB_ENV_NAME.test(value);
95
+ }
96
+
97
+ export function isHubOpReference(value: string): boolean {
98
+ return HUB_OP_REF.test(value);
54
99
  }
55
100
 
56
101
  export type ImprovementPromotionPolicy = "manual_pr" | "critic_quorum" | "auto_threshold";
@@ -191,11 +236,174 @@ function deepMerge<T extends Record<string, unknown>>(target: T, source: Record<
191
236
  return result as T;
192
237
  }
193
238
 
239
+ function literalSecretMessage(path: string): string {
240
+ return `refusing literal hub token at ${path}: store an op:// reference (key.op) or an environment variable name (key.env), never the token`;
241
+ }
242
+
243
+ function rejectLiteralHubSecrets(value: unknown, path: string): void {
244
+ if (Array.isArray(value)) {
245
+ value.forEach((item, index) => rejectLiteralHubSecrets(item, `${path}[${index}]`));
246
+ return;
247
+ }
248
+ if (!value || typeof value !== "object") return;
249
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
250
+ const childPath = `${path}.${key}`;
251
+ if (typeof child === "string" && (key === "key" || LITERAL_SECRET_KEY.test(key))) {
252
+ throw new KxmHubConfigError(literalSecretMessage(childPath));
253
+ }
254
+ if (child && typeof child === "object") rejectLiteralHubSecrets(child, childPath);
255
+ }
256
+ }
257
+
258
+ function parseKeyRef(raw: unknown, path: string): HubKeyRef | undefined {
259
+ if (raw === undefined) return undefined;
260
+ if (typeof raw === "string") throw new KxmHubConfigError(literalSecretMessage(path));
261
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
262
+ throw new KxmHubConfigError(`${path} must be a key reference with op and/or env, never a token`);
263
+ }
264
+ const row = raw as Record<string, unknown>;
265
+ for (const key of Object.keys(row)) {
266
+ if (key !== "op" && key !== "env") {
267
+ throw new KxmHubConfigError(literalSecretMessage(`${path}.${key}`));
268
+ }
269
+ }
270
+ const ref: HubKeyRef = {};
271
+ if (row.op !== undefined) {
272
+ if (typeof row.op !== "string" || !isHubOpReference(row.op)) {
273
+ throw new KxmHubConfigError(`${path}.op must be an op://vault/item/field reference, not a token`);
274
+ }
275
+ ref.op = row.op;
276
+ }
277
+ if (row.env !== undefined) {
278
+ if (typeof row.env !== "string" || !isHubKeyEnvName(row.env)) {
279
+ throw new KxmHubConfigError(`${path}.env must be an environment variable name, not a token`);
280
+ }
281
+ ref.env = row.env;
282
+ }
283
+ if (!ref.op && !ref.env) throw new KxmHubConfigError(`${path} needs key.op, key.env, or both`);
284
+ return ref;
285
+ }
286
+
287
+ function parseEndpoint(raw: unknown, path: string): HubEndpointConfig | undefined {
288
+ if (raw === undefined) return undefined;
289
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
290
+ throw new KxmHubConfigError(`${path} must be an object with url, project, and key`);
291
+ }
292
+ const row = raw as Record<string, unknown>;
293
+ const endpoint: HubEndpointConfig = {};
294
+ if (row.url !== undefined) {
295
+ if (typeof row.url !== "string" || !isHubUrl(row.url)) {
296
+ throw new KxmHubConfigError(`${path}.url must be an http or https URL without credentials, query, or fragment`);
297
+ }
298
+ endpoint.url = row.url.replace(/\/$/, "");
299
+ }
300
+ if (row.project !== undefined) {
301
+ if (typeof row.project !== "string" || !isHubProjectId(row.project)) {
302
+ throw new KxmHubConfigError(`${path}.project must be a single-line project id, not a token`);
303
+ }
304
+ endpoint.project = row.project.trim();
305
+ }
306
+ const key = parseKeyRef(row.key, `${path}.key`);
307
+ if (key) endpoint.key = key;
308
+ for (const name of Object.keys(row)) {
309
+ if (name !== "url" && name !== "project" && name !== "key") {
310
+ if (typeof row[name] === "string") throw new KxmHubConfigError(literalSecretMessage(`${path}.${name}`));
311
+ throw new KxmHubConfigError(`${path}.${name} is not a hub endpoint field`);
312
+ }
313
+ }
314
+ return endpoint;
315
+ }
316
+
317
+ function isHubUrl(raw: string): boolean {
318
+ try {
319
+ const parsed = new URL(raw);
320
+ return (parsed.protocol === "http:" || parsed.protocol === "https:")
321
+ && parsed.username === ""
322
+ && parsed.password === ""
323
+ && parsed.search === ""
324
+ && parsed.hash === ""
325
+ && !raw.includes("?")
326
+ && !raw.includes("#");
327
+ } catch {
328
+ return false;
329
+ }
330
+ }
331
+
332
+ function isHubProjectId(raw: string): boolean {
333
+ const value = raw.trim();
334
+ return value.length > 0 && value.length <= 200 && !/[\s\r\n]/.test(value);
335
+ }
336
+
337
+ function parseProjectMap(raw: unknown): Record<string, HubKeyRef> | undefined {
338
+ if (raw === undefined) return undefined;
339
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
340
+ throw new KxmHubConfigError("hub.projects must be a map of project id to key reference, never a token");
341
+ }
342
+ const projects: Record<string, HubKeyRef> = {};
343
+ for (const [project, value] of Object.entries(raw as Record<string, unknown>)) {
344
+ if (!isHubProjectId(project)) {
345
+ throw new KxmHubConfigError(`hub.projects key ${JSON.stringify(project)} must be a project id, not a token`);
346
+ }
347
+ const ref = parseKeyRef(value, `hub.projects.${project}`);
348
+ if (!ref) throw new KxmHubConfigError(`hub.projects.${project} needs an op:// reference or an environment variable name`);
349
+ projects[project.trim()] = ref;
350
+ }
351
+ return projects;
352
+ }
353
+
194
354
  /** Unknown `hub.autoStart` values fail closed to the default so a config typo
195
- * never silently changes hub startup behavior. */
355
+ * never silently changes hub startup behavior. Literal tokens under `hub` are
356
+ * refused. Key references stay unresolved. */
196
357
  function normalizeHubConfig(raw: unknown): KxmHubConfig {
197
358
  const autoStart = (raw as { autoStart?: unknown } | undefined)?.autoStart;
198
- return { autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart };
359
+ const hub: KxmHubConfig = {
360
+ autoStart: autoStart === "off" || autoStart === "background" ? autoStart : DEFAULT_KXM_CONFIG.hub.autoStart,
361
+ };
362
+ if (raw === undefined) return hub;
363
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
364
+ throw new KxmHubConfigError("hub must be an object");
365
+ }
366
+ rejectLiteralHubSecrets(raw, "hub");
367
+ const row = raw as Record<string, unknown>;
368
+ if (row.mode !== undefined) {
369
+ if (row.mode !== "local" && row.mode !== "cloud") {
370
+ throw new KxmHubConfigError("hub.mode must be local or cloud");
371
+ }
372
+ hub.mode = row.mode;
373
+ }
374
+ const local = parseEndpoint(row.local, "hub.local");
375
+ const cloud = parseEndpoint(row.cloud, "hub.cloud");
376
+ const projects = parseProjectMap(row.projects);
377
+ if (local) hub.local = local;
378
+ if (cloud) hub.cloud = cloud;
379
+ if (projects && Object.keys(projects).length > 0) hub.projects = projects;
380
+ return hub;
381
+ }
382
+
383
+ /** Directory that holds this checkout's `.kxm/` config: the nearest
384
+ * `project.yaml` or `config.yaml`, otherwise `start`. */
385
+ export function findConfigRoot(start: string): string {
386
+ let dir = resolve(start);
387
+ for (;;) {
388
+ if (existsSync(join(dir, ".kxm", "project.yaml")) || existsSync(join(dir, ".kxm", "config.yaml"))) return dir;
389
+ const parent = dirname(dir);
390
+ if (parent === dir) return resolve(start);
391
+ dir = parent;
392
+ }
393
+ }
394
+
395
+ /** User config directory for this env. A partial env (tests, a command env
396
+ * that is not the process env) does not read `~/.config/kxm`. */
397
+ export function userConfigDirForEnv(env: NodeJS.ProcessEnv): string | undefined {
398
+ const explicit = env.KXM_USER_CONFIG_DIR?.trim();
399
+ if (explicit) return explicit;
400
+ if (env === process.env) return undefined;
401
+ return join(tmpdir(), "kxm-no-user-config");
402
+ }
403
+
404
+ export function loadHubSettings(start: string, env: NodeJS.ProcessEnv = process.env): KxmHubConfig {
405
+ const userConfigDir = userConfigDirForEnv(env);
406
+ return loadKxmConfig(findConfigRoot(start), userConfigDir === undefined ? {} : { userConfigDir }).hub;
199
407
  }
200
408
 
201
409
  function recordOf(raw: unknown): Record<string, unknown> {
@@ -352,10 +560,45 @@ export function setKxmConfigValue(
352
560
  cursor = cursor[p] as Record<string, unknown>;
353
561
  }
354
562
  cursor[parts[parts.length - 1]!] = value;
563
+ if (parts[0] === "hub") normalizeHubConfig(existing.hub);
355
564
  if (!options.dryRun) writeConfigFile(targetFile, existing);
356
565
  return { file: targetFile };
357
566
  }
358
567
 
568
+ /** Record the non-secret parts of a hub bind: mode, URL, project id, and key
569
+ * reference. The resolved token is never written. */
570
+ export function writeHubEndpoint(
571
+ start: string,
572
+ endpoint: { mode: KxmHubMode; url: string; project: string; key?: HubKeyRef },
573
+ ): { file: string } {
574
+ if (!isHubUrl(endpoint.url)) {
575
+ throw new KxmHubConfigError("hub url must be an http or https URL without credentials, query, or fragment");
576
+ }
577
+ if (!isHubProjectId(endpoint.project)) {
578
+ throw new KxmHubConfigError("hub project id must be a single-line project id, not a token");
579
+ }
580
+ if (endpoint.key) parseKeyRef(endpoint.key, `hub.${endpoint.mode}.key`);
581
+ const root = findConfigRoot(start);
582
+ const file = join(root, ".kxm", "config.yaml");
583
+ const existing = readConfigFile(file);
584
+ const hub = recordOf(existing.hub);
585
+ const current = recordOf(hub[endpoint.mode]);
586
+ current.url = endpoint.url.replace(/\/$/, "");
587
+ current.project = endpoint.project.trim();
588
+ if (endpoint.key && (endpoint.key.op || endpoint.key.env)) {
589
+ const key = recordOf(current.key);
590
+ if (endpoint.key.op) key.op = endpoint.key.op;
591
+ if (endpoint.key.env) key.env = endpoint.key.env;
592
+ current.key = key;
593
+ }
594
+ hub[endpoint.mode] = current;
595
+ hub.mode = endpoint.mode;
596
+ existing.hub = hub;
597
+ normalizeHubConfig(hub);
598
+ writeConfigFile(file, existing);
599
+ return { file };
600
+ }
601
+
359
602
  /**
360
603
  * Remove a key so the next scope, then the built-in default, applies.
361
604
  *
@@ -7,7 +7,8 @@ import { HubClient, HubHttpError } from "./client.ts";
7
7
  import { loadKxmConfig } from "./config.ts";
8
8
  import { ensureHubRunning, hubAutoStartMode } from "./hub-autostart.ts";
9
9
  import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
10
- import { defaultProjectName } from "./project-name.ts";
10
+ import { CloudTokenError } from "./hub-binding.ts";
11
+ import { describeHubConnection, resolveProjectIdentity } from "./hub-identity.ts";
11
12
  import { nousFactoryWork, type NousRegistrationReport } from "./nous-pi.ts";
12
13
  import {
13
14
  antigravityRegistrationNotice,
@@ -346,7 +347,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
346
347
  }
347
348
 
348
349
  async function showKxmHub(ctx: { ui: { notify: (message: string, type: "info" | "warning" | "error") => void } }): Promise<void> {
349
- const url = (process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331").replace(/\/$/, "");
350
+ const url = describeHubConnection(process.cwd(), process.env).url;
350
351
  let health = "unreachable";
351
352
  try {
352
353
  const response = await fetch(`${url}/health`);
@@ -676,8 +677,9 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
676
677
  return;
677
678
  }
678
679
  currentPiSessionId = ctx.sessionManager?.getSessionId();
679
- const serverUrl = process.env.KXM_SERVER_URL ?? "http://127.0.0.1:7331";
680
- const project = defaultProjectName(ctx.cwd, process.env);
680
+ const identity = resolveProjectIdentity(ctx.cwd, process.env);
681
+ const serverUrl = describeHubConnection(ctx.cwd, process.env).url;
682
+ const project = identity.project;
681
683
  const name = process.env.KXM_AGENT_NAME ?? pi.getSessionName() ?? `pi-${process.pid}`;
682
684
  agentName = name;
683
685
  projectName = project;
@@ -708,14 +710,17 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
708
710
  // stand in for a project token.
709
711
  let hubAuthToken: string | undefined;
710
712
  try {
711
- hubAuthToken = resolveAgentHubAuthToken(process.env, project);
713
+ hubAuthToken = resolveAgentHubAuthToken(process.env, project, ctx.cwd);
712
714
  } catch (error) {
713
- ctx.ui.notify(`kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`, "error");
715
+ const detail = error instanceof CloudTokenError
716
+ ? error.message
717
+ : `kxm could not read persisted hub credentials: ${error instanceof Error ? error.message : String(error)}`;
718
+ ctx.ui.notify(detail, "error");
714
719
  await applySessionChrome(ctx, event, true);
715
720
  return;
716
721
  }
717
722
  if (!hubAuthToken) {
718
- ctx.ui.notify(new AgentProjectTokenMissingError(project).message, "error");
723
+ ctx.ui.notify(new AgentProjectTokenMissingError(identity).message, "error");
719
724
  await applySessionChrome(ctx, event, true);
720
725
  return;
721
726
  }
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
2
2
  import { closeSync, mkdirSync, openSync, readFileSync } from "node:fs";
3
3
  import { join, resolve } from "node:path";
4
4
  import { probeHubHealth, readHubBinding } from "./hub-binding.ts";
5
+ import { describeHubConnection } from "./hub-identity.ts";
5
6
  import { resolveHubCredentials } from "./hub-env.ts";
6
7
  import { findKxmRepoRoot } from "./repo-root.ts";
7
8
  import { redactSecrets } from "./redact.ts";
@@ -133,6 +134,28 @@ export async function ensureHubRunning(options: EnsureHubOptions): Promise<Ensur
133
134
  if (binding) {
134
135
  const { health } = await probeHubHealth(binding.url, fetchImpl);
135
136
  if (health === "on") return { status: "bound-healthy", url: binding.url };
137
+ // A cloud binding names a remote hub. A dead forward must not start a local hub
138
+ // that would then authenticate with this machine's hub-env token.
139
+ if (binding.cloud) return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" };
140
+ }
141
+
142
+ // A cloud endpoint in config is the same rule when no cloud binding is on disk:
143
+ // a dead forward must not start a local hub.
144
+ if (!binding?.cloud) {
145
+ try {
146
+ const connection = describeHubConnection(options.cwd, env);
147
+ if (connection.mode === "cloud") {
148
+ const { health } = await probeHubHealth(connection.url, fetchImpl);
149
+ if (health === "on") return { status: "bound-healthy", url: connection.url };
150
+ return { status: "failed", reason: "cloud_hub_unreachable", logPath: "" };
151
+ }
152
+ } catch (error) {
153
+ return {
154
+ status: "failed",
155
+ reason: error instanceof Error ? error.message : "hub_config_invalid",
156
+ logPath: "",
157
+ };
158
+ }
136
159
  }
137
160
 
138
161
  // 2. A live local claim means a hub (started any way) already owns this