@kontextmind/kxm 0.7.177 → 0.7.179

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.
@@ -0,0 +1,479 @@
1
+ /**
2
+ * Owner for the `kxm.config.v1` terminal editor.
3
+ *
4
+ * The panel publishes this contribution and routes every write back here.
5
+ * `listSections` re-reads the merged files on each publish. `set` and `clear`
6
+ * validate the field and the value, then call the config owner. A refusal
7
+ * throws before either owner call, so the file bytes stay as they were.
8
+ */
9
+
10
+ import { existsSync, readFileSync } from "node:fs";
11
+ import { parse } from "yaml";
12
+ import {
13
+ KXM_TUI_CLEAR_ACTION,
14
+ KXM_TUI_SET_ACTION,
15
+ createKxmTuiRegistry,
16
+ createKxmTuiPiPanel,
17
+ createPlainKxmTuiTheme,
18
+ runKxmTuiPanel,
19
+ type KxmTuiContribution,
20
+ type KxmTuiField,
21
+ type KxmTuiSectionView,
22
+ } from "@kontextmind/tui";
23
+ import {
24
+ deleteKxmConfigValue,
25
+ getKxmConfigValue,
26
+ kxmConfigFileForScope,
27
+ loadKxmConfig,
28
+ setKxmConfigValue,
29
+ userConfigDirForEnv,
30
+ type KxmResolvedConfig,
31
+ } from "./config.ts";
32
+
33
+ export const KXM_CONFIG_EDITOR_SOURCE = "kxm/config";
34
+
35
+ export type KxmConfigEditorScope = "project" | "user";
36
+
37
+ const INTEGER_TEXT = /^(0|[1-9][0-9]*)$/;
38
+ const DECIMAL_TEXT = /^(0|[1-9][0-9]*)(\.[0-9]{1,6})?$/;
39
+ /** Workflow and harness ids already in use, including `@`, with no whitespace. */
40
+ const TEXT_VALUE = /^[A-Za-z0-9@][A-Za-z0-9@._:/-]{0,127}$/;
41
+
42
+ interface EnumSpec {
43
+ readonly kind: "enum";
44
+ readonly choices: readonly string[];
45
+ }
46
+
47
+ interface NumberSpec {
48
+ readonly kind: "number";
49
+ readonly integer: boolean;
50
+ readonly min: number;
51
+ readonly max: number;
52
+ }
53
+
54
+ interface TextSpec {
55
+ readonly kind: "text";
56
+ }
57
+
58
+ type ValueSpec = EnumSpec | NumberSpec | TextSpec;
59
+
60
+ interface ConfigEditorFieldSpec {
61
+ readonly id: string;
62
+ readonly label: string;
63
+ readonly keyPath: string;
64
+ readonly spec: ValueSpec;
65
+ readonly note?: string;
66
+ }
67
+
68
+ interface ConfigEditorSectionSpec {
69
+ readonly id: string;
70
+ readonly title: string;
71
+ readonly order: number;
72
+ readonly fields: readonly ConfigEditorFieldSpec[];
73
+ }
74
+
75
+ const SCREEN_CHOICES = ["agents", "tasks", "workflows", "plans", "inbox", "procs", "spend"] as const;
76
+
77
+ const SECTIONS: readonly ConfigEditorSectionSpec[] = [
78
+ {
79
+ id: "user",
80
+ title: "User",
81
+ order: 10,
82
+ fields: [
83
+ { id: "user-theme", label: "Theme", keyPath: "user.theme", spec: { kind: "enum", choices: ["dark", "light", "minimal"] } },
84
+ { id: "user-token-budget", label: "Token budget", keyPath: "user.tokenBudget", spec: { kind: "number", integer: true, min: 1, max: 2_000_000 } },
85
+ ],
86
+ },
87
+ {
88
+ id: "defaults",
89
+ title: "Defaults",
90
+ order: 20,
91
+ fields: [
92
+ { id: "defaults-workflow", label: "Workflow", keyPath: "defaults.workflow", spec: { kind: "text" } },
93
+ { id: "defaults-harness", label: "Harness", keyPath: "defaults.harness", spec: { kind: "text" } },
94
+ ],
95
+ },
96
+ {
97
+ id: "dash",
98
+ title: "Dashboard",
99
+ order: 30,
100
+ fields: [
101
+ { id: "dash-screen", label: "Default screen", keyPath: "dash.defaultScreen", spec: { kind: "enum", choices: SCREEN_CHOICES } },
102
+ { id: "dash-refresh", label: "Refresh interval", keyPath: "dash.refreshIntervalMs", spec: { kind: "number", integer: true, min: 250, max: 3_600_000 } },
103
+ { id: "dash-auto-open", label: "Auto-open", keyPath: "dash.autoOpen", spec: { kind: "enum", choices: ["true", "false"] } },
104
+ ],
105
+ },
106
+ {
107
+ id: "sync",
108
+ title: "Sync",
109
+ order: 40,
110
+ fields: [
111
+ { id: "sync-tracker", label: "Default tracker", keyPath: "sync.defaultTracker", spec: { kind: "enum", choices: ["github", "jira", "none"] } },
112
+ ],
113
+ },
114
+ {
115
+ id: "hub",
116
+ title: "Hub",
117
+ order: 50,
118
+ fields: [
119
+ {
120
+ id: "hub-auto-start",
121
+ label: "Auto-start",
122
+ keyPath: "hub.autoStart",
123
+ spec: { kind: "enum", choices: ["background", "off"] },
124
+ note: "Hub URLs and key references stay out of this panel.",
125
+ },
126
+ ],
127
+ },
128
+ {
129
+ id: "improvement",
130
+ title: "Improvement",
131
+ order: 60,
132
+ fields: [
133
+ { id: "improvement-policy", label: "Promotion policy", keyPath: "improvement.promotionPolicy", spec: { kind: "enum", choices: ["manual_pr", "critic_quorum", "auto_threshold"] } },
134
+ { id: "improvement-half-life", label: "Telemetry half-life", keyPath: "improvement.telemetryHalfLifeDays", spec: { kind: "number", integer: false, min: 0.000001, max: 3650 } },
135
+ { id: "improvement-min-runs", label: "Minimum runs", keyPath: "improvement.autoThreshold.minRuns", spec: { kind: "number", integer: true, min: 1, max: 1_000_000 } },
136
+ { id: "improvement-min-pass-rate", label: "Minimum pass rate", keyPath: "improvement.autoThreshold.minPassRate", spec: { kind: "number", integer: false, min: 0, max: 1 } },
137
+ { id: "improvement-min-cost-savings", label: "Minimum cost savings", keyPath: "improvement.autoThreshold.minCostSavings", spec: { kind: "number", integer: false, min: 0, max: 1_000_000 } },
138
+ ],
139
+ },
140
+ {
141
+ id: "routing",
142
+ title: "Routing",
143
+ order: 70,
144
+ fields: [
145
+ {
146
+ id: "routing-shadow",
147
+ label: "Shadow execution",
148
+ keyPath: "routing.shadowExecution.enabled",
149
+ spec: { kind: "enum", choices: ["true", "false"] },
150
+ note: "Strategy, fallback, and context limits stay in role and route files.",
151
+ },
152
+ { id: "routing-sample-rate", label: "Sample rate", keyPath: "routing.shadowExecution.sampleRate", spec: { kind: "number", integer: false, min: 0, max: 1 } },
153
+ { id: "routing-breaker-mode", label: "Breaker mode", keyPath: "routing.circuitBreaker.mode", spec: { kind: "enum", choices: ["soft_demotion", "quarantine"] } },
154
+ { id: "routing-failure-threshold", label: "Failure threshold", keyPath: "routing.circuitBreaker.failureThreshold", spec: { kind: "number", integer: true, min: 1, max: 10_000 } },
155
+ { id: "routing-window-seconds", label: "Window seconds", keyPath: "routing.circuitBreaker.windowSeconds", spec: { kind: "number", integer: true, min: 1, max: 31_536_000 } },
156
+ { id: "routing-cooldown-seconds", label: "Cooldown seconds", keyPath: "routing.circuitBreaker.cooldownSeconds", spec: { kind: "number", integer: true, min: 0, max: 31_536_000 } },
157
+ { id: "routing-penalty", label: "Penalty multiplier", keyPath: "routing.circuitBreaker.penaltyMultiplier", spec: { kind: "number", integer: false, min: 0.000001, max: 1000 } },
158
+ ],
159
+ },
160
+ {
161
+ id: "telemetry",
162
+ title: "Telemetry",
163
+ order: 80,
164
+ fields: [
165
+ { id: "telemetry-federated", label: "Federated", keyPath: "telemetry.federated", spec: { kind: "enum", choices: ["true", "false"] } },
166
+ { id: "telemetry-anonymize", label: "Anonymize", keyPath: "telemetry.anonymize", spec: { kind: "enum", choices: ["true", "false"] } },
167
+ ],
168
+ },
169
+ ];
170
+
171
+
172
+ export class KxmConfigEditorError extends Error {
173
+ readonly code = "config_edit_refused";
174
+
175
+ constructor(message: string) {
176
+ super(message);
177
+ this.name = "KxmConfigEditorError";
178
+ }
179
+ }
180
+
181
+ export interface KxmConfigEditorTarget {
182
+ readonly cwd: string;
183
+ readonly scope: KxmConfigEditorScope;
184
+ readonly userConfigDir?: string | undefined;
185
+ }
186
+
187
+ export function kxmConfigEditorKeyPaths(): readonly string[] {
188
+ return SECTIONS.flatMap((section) => section.fields.map((field) => field.keyPath));
189
+ }
190
+
191
+ export function createKxmConfigEditorContribution(target: KxmConfigEditorTarget): KxmTuiContribution {
192
+ return {
193
+ source: KXM_CONFIG_EDITOR_SOURCE,
194
+ republishAfterWrite: true,
195
+ listSections: () => listConfigEditorSections(target),
196
+ handlers: {
197
+ [KXM_TUI_SET_ACTION]: (input) => {
198
+ writeConfigEditorValue(target, input.sectionId, input.fieldId, input.value);
199
+ },
200
+ [KXM_TUI_CLEAR_ACTION]: (input) => {
201
+ clearConfigEditorValue(target, input.sectionId, input.fieldId);
202
+ },
203
+ },
204
+ describeError: (error) => (error instanceof Error && error.message ? error.message : "config edit failed"),
205
+ };
206
+ }
207
+
208
+ function scopeFile(target: KxmConfigEditorTarget): string {
209
+ return kxmConfigFileForScope(target.cwd, target.scope, target.userConfigDir);
210
+ }
211
+
212
+ function scopePhrase(scope: KxmConfigEditorScope): string {
213
+ return scope === "project" ? "the project file .kxm/config.yaml" : "the user file config.yaml";
214
+ }
215
+
216
+ function listConfigEditorSections(target: KxmConfigEditorTarget): KxmTuiSectionView[] {
217
+ const config = loadKxmConfig(target.cwd, {
218
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
219
+ layers: target.scope === "user" ? "user" : "all",
220
+ });
221
+ const owned = readScopeDocument(scopeFile(target));
222
+ const where = scopePhrase(target.scope);
223
+ return SECTIONS.map((section) => {
224
+ const fields = section.fields.map((field) => publishField(field, config, owned, where));
225
+ const diagnosis = fields.find((field) => field.statusText)?.statusText;
226
+ return {
227
+ source: KXM_CONFIG_EDITOR_SOURCE,
228
+ id: section.id,
229
+ title: section.title,
230
+ order: section.order,
231
+ detail: `kxm.config.v1 · ${target.scope} scope. Re-read from disk after each write.`,
232
+ ...(diagnosis === undefined ? {} : { notice: diagnosis, noticeLevel: "error" as const }),
233
+ fields,
234
+ };
235
+ });
236
+ }
237
+
238
+ function publishField(
239
+ field: ConfigEditorFieldSpec,
240
+ config: KxmResolvedConfig,
241
+ owned: Record<string, unknown> | undefined,
242
+ where: string,
243
+ ): KxmTuiField {
244
+ const effective = getKxmConfigValue(config, field.keyPath);
245
+ const stored = ownValue(owned, field.keyPath);
246
+ const shown = displayScalar(effective);
247
+ const inherited = !stored.present;
248
+ const diagnosis = storedDiagnosis(field, stored, effective);
249
+ const detail = [
250
+ `Writes ${where}.`,
251
+ inherited ? "Inherited." : "Set in this scope.",
252
+ "Clear removes only this override.",
253
+ field.note ?? "",
254
+ diagnosis ?? "",
255
+ ].filter((part) => part.length > 0).join(" ");
256
+ const choices = field.spec.kind === "enum"
257
+ ? field.spec.choices.map((choice) => ({ id: choice, label: choice }))
258
+ : undefined;
259
+ return {
260
+ id: field.id,
261
+ label: field.label,
262
+ kind: field.spec.kind === "enum" ? "enum" : "text",
263
+ keyPath: field.keyPath,
264
+ detail,
265
+ status: "ready",
266
+ ...(shown === undefined ? {} : { value: shown }),
267
+ ...(choices === undefined ? {} : { choices }),
268
+ ...(diagnosis === undefined ? {} : { statusText: diagnosis }),
269
+ };
270
+ }
271
+
272
+ function displayScalar(value: unknown): string | undefined {
273
+ if (typeof value === "string" && value.length > 0) return value;
274
+ if (typeof value === "boolean") return value ? "true" : "false";
275
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
276
+ return undefined;
277
+ }
278
+
279
+ function isBooleanEnum(spec: ValueSpec): boolean {
280
+ return spec.kind === "enum"
281
+ && spec.choices.length === 2
282
+ && spec.choices.every((choice) => choice === "true" || choice === "false");
283
+ }
284
+
285
+ /** A display string such as `"true"` is not a boolean. The stored YAML type has to match the descriptor. */
286
+ function storedTypeMatches(field: ConfigEditorFieldSpec, value: unknown): boolean {
287
+ if (field.spec.kind === "number") {
288
+ return typeof value === "number"
289
+ && Number.isFinite(value)
290
+ && (!field.spec.integer || Number.isInteger(value));
291
+ }
292
+ if (isBooleanEnum(field.spec)) return typeof value === "boolean";
293
+ return typeof value === "string";
294
+ }
295
+
296
+ function storedDiagnosis(
297
+ field: ConfigEditorFieldSpec,
298
+ stored: { present: boolean; value: unknown },
299
+ effective: unknown,
300
+ ): string | undefined {
301
+ if (!stored.present) return undefined;
302
+ if (!storedTypeMatches(field, stored.value)) return "stored type does not match this field";
303
+ if (field.spec.kind === "enum" && !isBooleanEnum(field.spec) && !field.spec.choices.includes(stored.value as string)) {
304
+ return "stored value is not a supported scalar";
305
+ }
306
+ if (field.spec.kind === "number") {
307
+ const value = stored.value as number;
308
+ if (value < field.spec.min || value > field.spec.max) return "stored value is not a supported scalar";
309
+ }
310
+ if (stored.value !== effective) return "stored value is not a supported scalar";
311
+ return undefined;
312
+ }
313
+
314
+ function writeConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string, raw: string | undefined): void {
315
+ const field = requireField(sectionId, fieldId);
316
+ const value = parseEditorValue(field, raw);
317
+ setKxmConfigValue(target.cwd, field.keyPath, value, {
318
+ scope: target.scope,
319
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
320
+ });
321
+ }
322
+
323
+ function clearConfigEditorValue(target: KxmConfigEditorTarget, sectionId: string, fieldId: string): void {
324
+ const field = requireField(sectionId, fieldId);
325
+ deleteKxmConfigValue(target.cwd, field.keyPath, {
326
+ scope: target.scope,
327
+ ...(target.userConfigDir === undefined ? {} : { userConfigDir: target.userConfigDir }),
328
+ });
329
+ }
330
+
331
+ function requireField(sectionId: string, fieldId: string): ConfigEditorFieldSpec {
332
+ const section = SECTIONS.find((item) => item.id === sectionId);
333
+ const field = section?.fields.find((item) => item.id === fieldId);
334
+ if (!field) throw new KxmConfigEditorError("unknown configuration field");
335
+ return field;
336
+ }
337
+
338
+ function parseEditorValue(field: ConfigEditorFieldSpec, raw: string | undefined): string | number | boolean {
339
+ if (raw === undefined) throw new KxmConfigEditorError("a value is required");
340
+ if (raw.length === 0 || raw.length > 128 || /[\u0000-\u001f\u007f]/u.test(raw)) {
341
+ throw new KxmConfigEditorError("value must be a single bounded line");
342
+ }
343
+ if (field.spec.kind === "enum") {
344
+ if (!field.spec.choices.includes(raw)) throw new KxmConfigEditorError("value is not a supported choice");
345
+ if (isBooleanEnum(field.spec)) return raw === "true";
346
+ return raw;
347
+ }
348
+ if (field.spec.kind === "text") {
349
+ if (!TEXT_VALUE.test(raw)) throw new KxmConfigEditorError("value must be a single bounded line");
350
+ return raw;
351
+ }
352
+ if (!(field.spec.integer ? INTEGER_TEXT : DECIMAL_TEXT).test(raw)) {
353
+ throw new KxmConfigEditorError("value must be a finite number in range");
354
+ }
355
+ const parsed = Number(raw);
356
+ if (!Number.isFinite(parsed) || (field.spec.integer && !Number.isInteger(parsed)) || parsed < field.spec.min || parsed > field.spec.max) {
357
+ throw new KxmConfigEditorError("value must be a finite number in range");
358
+ }
359
+ return parsed;
360
+ }
361
+
362
+ function readScopeDocument(file: string): Record<string, unknown> | undefined {
363
+ if (!existsSync(file)) return undefined;
364
+ const parsed = parse(readFileSync(file, "utf8")) as unknown;
365
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
366
+ throw new KxmConfigEditorError(`config file ${file} must be a mapping`);
367
+ }
368
+ return parsed as Record<string, unknown>;
369
+ }
370
+
371
+ function ownValue(root: Record<string, unknown> | undefined, keyPath: string): { present: boolean; value: unknown } {
372
+ if (!root) return { present: false, value: undefined };
373
+ let cursor: unknown = root;
374
+ for (const part of keyPath.split(".")) {
375
+ if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) return { present: false, value: undefined };
376
+ const record = cursor as Record<string, unknown>;
377
+ if (!Object.prototype.hasOwnProperty.call(record, part)) return { present: false, value: undefined };
378
+ cursor = record[part];
379
+ }
380
+ return { present: true, value: cursor };
381
+ }
382
+
383
+ const EDITOR_HELP = [
384
+ "↑↓ move · enter edits · x clears this scope's override · q quits",
385
+ "Writes are checked, saved, then re-read. A refused edit leaves the file unchanged.",
386
+ ] as const;
387
+
388
+ const FRAME_NOW_MS = Date.UTC(2026, 9, 11);
389
+
390
+ export async function runKxmConfigEditor(input: {
391
+ readonly target: KxmConfigEditorTarget;
392
+ readonly stdout: (text: string) => void;
393
+ readonly isTty: boolean;
394
+ readonly dryRun: boolean;
395
+ }): Promise<number> {
396
+ const registry = createKxmTuiRegistry();
397
+ try {
398
+ registry.register(createKxmConfigEditorContribution(input.target));
399
+ registry.getSections();
400
+ const failed = registry.getIssues()[0];
401
+ if (failed) throw new KxmConfigEditorError(failed.message);
402
+ const interactive = input.isTty && !input.dryRun;
403
+ return await runKxmTuiPanel({
404
+ registry,
405
+ title: "KXM configuration",
406
+ breadcrumb: `kxm.config.v1 · ${input.target.scope} scope`,
407
+ helpLines: EDITOR_HELP,
408
+ stdout: input.stdout,
409
+ isTty: interactive,
410
+ ...(interactive ? {} : { theme: createPlainKxmTuiTheme() }),
411
+ now: () => FRAME_NOW_MS,
412
+ });
413
+ } finally {
414
+ registry.dispose();
415
+ }
416
+ }
417
+
418
+ interface ConfigPanelUi {
419
+ notify?: ((message: string, type?: "error" | "info" | "warning") => void) | undefined;
420
+ custom?: ((
421
+ factory: (
422
+ tui: { requestRender(): void },
423
+ theme: { fg(token: string, text: string): string; bg(token: string, text: string): string },
424
+ keybindings: unknown,
425
+ done: (result: undefined) => void,
426
+ ) => { render(width: number): string[]; handleInput(data: string): void; invalidate(): void },
427
+ options?: { overlay?: boolean },
428
+ ) => Promise<unknown>) | undefined;
429
+ }
430
+
431
+ /** Mount the project-scope editor on Pi's generic custom panel. Only a TUI session may open it. */
432
+ export async function openKxmProjectConfigPanel(
433
+ ui: { notify?: (message: string, type?: "error" | "info" | "warning") => void; custom?: unknown },
434
+ cwd: string,
435
+ env: NodeJS.ProcessEnv = process.env,
436
+ mode?: string,
437
+ ): Promise<void> {
438
+ if (mode !== "tui") {
439
+ ui.notify?.(
440
+ "kxm: /kxm config opens only in the Pi TUI. Nothing was written. Run kxm config edit --scope project",
441
+ "warning",
442
+ );
443
+ return;
444
+ }
445
+ if (typeof ui.custom !== "function") {
446
+ ui.notify?.(
447
+ "kxm: /kxm config needs Pi's custom panel. Open the repository in the Pi TUI, or run kxm config edit --scope project",
448
+ "warning",
449
+ );
450
+ return;
451
+ }
452
+ const custom = ui.custom as NonNullable<ConfigPanelUi["custom"]>;
453
+ const registry = createKxmTuiRegistry();
454
+ const userConfigDir = userConfigDirForEnv(env);
455
+ try {
456
+ registry.register(createKxmConfigEditorContribution({
457
+ cwd,
458
+ scope: "project",
459
+ ...(userConfigDir === undefined ? {} : { userConfigDir }),
460
+ }));
461
+ await custom(
462
+ (tui, theme, _keybindings, done) => createKxmTuiPiPanel({
463
+ registry,
464
+ tui,
465
+ theme,
466
+ title: "KXM configuration",
467
+ breadcrumb: "kxm.config.v1 · project scope",
468
+ helpLines: EDITOR_HELP,
469
+ done: () => {
470
+ registry.dispose();
471
+ done(undefined);
472
+ },
473
+ }),
474
+ { overlay: true },
475
+ );
476
+ } finally {
477
+ registry.dispose();
478
+ }
479
+ }
@@ -440,13 +440,14 @@ function normalizeImprovementConfig(raw: unknown): KxmImprovementConfig {
440
440
 
441
441
  export function loadKxmConfig(
442
442
  repoRoot = process.cwd(),
443
- options: { userConfigDir?: string } = {},
443
+ options: { userConfigDir?: string; layers?: "user" | "all" } = {},
444
444
  ): KxmResolvedConfig {
445
445
  const userDir = userConfigDirectory(options.userConfigDir);
446
446
  const userConfigFile = join(userDir, "config.yaml");
447
447
 
448
448
  const repoDir = repoConfigDirectory(repoRoot);
449
449
  const repoConfigFile = join(repoDir, "config.yaml");
450
+ const includeProject = options.layers !== "user";
450
451
 
451
452
  let userRaw: Record<string, unknown> = {};
452
453
  let userLoadedPath: string | undefined;
@@ -462,7 +463,7 @@ export function loadKxmConfig(
462
463
 
463
464
  let repoRaw: Record<string, unknown> = {};
464
465
  let repoLoadedPath: string | undefined;
465
- if (existsSync(repoConfigFile)) {
466
+ if (includeProject && existsSync(repoConfigFile)) {
466
467
  try {
467
468
  const text = readFileSync(repoConfigFile, "utf8");
468
469
  repoRaw = (parse(text) as Record<string, unknown>) ?? {};
@@ -39,6 +39,7 @@ import {
39
39
  type SessionHubStatus,
40
40
  type SessionWorkItem,
41
41
  } from "./session-work.ts";
42
+ import { openKxmProjectConfigPanel } from "./config-editor.ts";
42
43
  import {
43
44
  loadActiveWorkflowProgress,
44
45
  renderWorkflowTuiText,
@@ -915,7 +916,7 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
915
916
  }
916
917
 
917
918
  pi.registerCommand("kxm", {
918
- description: "Hub session brief, status line, and hub view",
919
+ description: "KXM session, workflow, hub, and configuration surfaces",
919
920
  getArgumentCompletions: (prefix: string) => {
920
921
  const items = kxmSlashCompletions(prefix);
921
922
  return items.length > 0 ? items : null;
@@ -949,8 +950,14 @@ export default function piMeshExtension(pi: ExtensionAPI): void | Promise<void>
949
950
  }
950
951
  return;
951
952
  }
953
+ if (command === "config") {
954
+ const cwd = typeof ctx.cwd === "string" && ctx.cwd.trim().length > 0 ? ctx.cwd : process.cwd();
955
+ const mode = typeof ctx.mode === "string" ? ctx.mode : undefined;
956
+ await openKxmProjectConfigPanel(ctx.ui, cwd, process.env, mode);
957
+ return;
958
+ }
952
959
  if (command === "help") {
953
- ctx.ui.notify("kxm: /kxm | /kxm status | /kxm progress | /kxm workflow | /kxm hub | /kxm memory | /kxm help. CLI: kxm session brief, kxm hub view, kxm memory brief", "info");
960
+ ctx.ui.notify("kxm: /kxm | /kxm status | /kxm progress | /kxm workflow | /kxm hub | /kxm memory | /kxm config | /kxm help. CLI: kxm session brief, kxm hub view, kxm memory brief, kxm config edit --scope project|user", "info");
954
961
  return;
955
962
  }
956
963
  if (command === "memory") {
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.177";
14
+ const VERSION = "0.7.179";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();