@kontextmind/kxm 0.7.6 → 0.7.8
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/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +43389 -42846
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime.js +645 -25
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/context-skills.ts +373 -0
- package/plugins/kxm/src/cli/hub.ts +614 -0
- package/plugins/kxm/src/cli/roles.ts +615 -0
- package/plugins/kxm/src/cli/system.ts +906 -0
- package/plugins/kxm/src/cli/tasks.ts +364 -0
- package/plugins/kxm/src/cli/types.ts +270 -0
- package/plugins/kxm/src/cli/vnext.ts +698 -0
- package/plugins/kxm/src/cli/workflows.ts +699 -0
- package/plugins/kxm/src/cli.ts +195 -4285
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime.ts +2 -0
- package/plugins/kxm/src/ssh-remote.ts +560 -0
- package/plugins/kxm/src/subagent-control.ts +312 -0
|
@@ -0,0 +1,614 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { redactSecrets } from "../redact.ts";
|
|
5
|
+
import { agentWorker, type Worker } from "../envelope.ts";
|
|
6
|
+
import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
|
|
7
|
+
import { formatSessionBriefText, loadSessionBriefAsync, type SessionHubStatus } from "../session-work.ts";
|
|
8
|
+
import {
|
|
9
|
+
HUB_BINDING_SCHEMA,
|
|
10
|
+
HubBindingError,
|
|
11
|
+
hubBindingFile,
|
|
12
|
+
probeHubHealth,
|
|
13
|
+
readHubBinding,
|
|
14
|
+
removeHubBinding,
|
|
15
|
+
validateHubUrl,
|
|
16
|
+
writeHubBinding,
|
|
17
|
+
type HubHealth,
|
|
18
|
+
} from "../hub-binding.ts";
|
|
19
|
+
import {
|
|
20
|
+
classifyInstallRoot,
|
|
21
|
+
type InstallProbe,
|
|
22
|
+
} from "../kxm-install-kind.ts";
|
|
23
|
+
import { vnextUserStateRoot } from "../vnext-bindings.ts";
|
|
24
|
+
import {
|
|
25
|
+
fetchLatestKxmVersion,
|
|
26
|
+
kxmReleaseAssetName,
|
|
27
|
+
noticeFromVersions,
|
|
28
|
+
readInstalledKxmVersion,
|
|
29
|
+
readUpdateCache,
|
|
30
|
+
writeUpdateCache,
|
|
31
|
+
KxmUpdateConfigError,
|
|
32
|
+
type KxmUpdateConfig,
|
|
33
|
+
type KxmUpdateNotice,
|
|
34
|
+
} from "../kxm-update.ts";
|
|
35
|
+
import { loadKxmUpdateConfig } from "../kxm-update-config.ts";
|
|
36
|
+
import {
|
|
37
|
+
clearSessionTokenFromDisk,
|
|
38
|
+
mintSessionToken,
|
|
39
|
+
parseSessionToken,
|
|
40
|
+
persistSessionTokenToDisk,
|
|
41
|
+
readSessionTokenFromDisk,
|
|
42
|
+
sessionTokenPath,
|
|
43
|
+
} from "../commands.ts";
|
|
44
|
+
import { createSession, loadNamedWorkers, rosterNames, sessionAssetDirs, workflowAssetDirs, writeSession } from "../session.ts";
|
|
45
|
+
import {
|
|
46
|
+
print,
|
|
47
|
+
printWorker,
|
|
48
|
+
hostMode,
|
|
49
|
+
workspaceEnv,
|
|
50
|
+
spawnScript,
|
|
51
|
+
processExists,
|
|
52
|
+
type Runtime,
|
|
53
|
+
} from "./types.ts";
|
|
54
|
+
|
|
55
|
+
export async function hubGet(url: string, fetchImpl: typeof fetch): Promise<{ ok: boolean; status: number; body: unknown }> {
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetchImpl(url);
|
|
58
|
+
const text = redactSecrets((await response.text()).slice(0, 8_000));
|
|
59
|
+
let body: unknown = text;
|
|
60
|
+
try {
|
|
61
|
+
body = JSON.parse(text);
|
|
62
|
+
} catch {
|
|
63
|
+
// Keep text for diagnostics without treating it as a secret.
|
|
64
|
+
}
|
|
65
|
+
return { ok: response.ok, status: response.status, body };
|
|
66
|
+
} catch {
|
|
67
|
+
return { ok: false, status: 0, body: { error: "hub_unreachable" } };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function installProbeFrom(runtime: Runtime): InstallProbe {
|
|
72
|
+
const partial = runtime.io.installProbe ?? {};
|
|
73
|
+
return {
|
|
74
|
+
moduleDir: partial.moduleDir ?? dirname(fileURLToPath(import.meta.url)),
|
|
75
|
+
repoRoot: partial.repoRoot ?? resolve("."),
|
|
76
|
+
homeDir: partial.homeDir ?? homedir(),
|
|
77
|
+
platform: partial.platform ?? process.platform,
|
|
78
|
+
env: partial.env ?? runtime.env,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
import { fileURLToPath } from "node:url";
|
|
83
|
+
import { dirname } from "node:path";
|
|
84
|
+
import { homedir } from "node:os";
|
|
85
|
+
|
|
86
|
+
export function warnIgnoredProjectUpdateYaml(runtime: Runtime): void {
|
|
87
|
+
const projectFile = join(runtime.dirs.workspace, "update.yaml");
|
|
88
|
+
if (!existsSync(projectFile)) return;
|
|
89
|
+
const userFile = join(vnextUserStateRoot({ env: runtime.env }), "update.yaml");
|
|
90
|
+
runtime.io.stderr(`kxm: ignoring .kxm/update.yaml in ${runtime.dirs.workdir}; update settings are read only from ${userFile}\n`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export async function refreshKxmUpdateNotice(runtime: Runtime, config?: KxmUpdateConfig): Promise<KxmUpdateNotice> {
|
|
94
|
+
const resolved = config ?? loadKxmUpdateConfig(runtime.env);
|
|
95
|
+
const current = readInstalledKxmVersion(resolve("."));
|
|
96
|
+
const fetched = await fetchLatestKxmVersion(resolved.source, runtime.env, runtime.fetchImpl);
|
|
97
|
+
const notice = noticeFromVersions(current, fetched.latest, resolved, fetched.error, fetched.asset);
|
|
98
|
+
writeUpdateCache(runtime.dirs.state, notice);
|
|
99
|
+
return notice;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function cmdStatus(runtime: Runtime): Promise<number> {
|
|
103
|
+
const health = await hubGet(`${runtime.serverUrl}/health`, runtime.fetchImpl);
|
|
104
|
+
const ready = await hubGet(`${runtime.serverUrl}/ready`, runtime.fetchImpl);
|
|
105
|
+
const payload = { ok: health.ok && ready.ok, command: "hub view", health: health.body, ready: ready.body };
|
|
106
|
+
print(runtime.io, runtime.json, payload, `hub health=${health.ok} ready=${ready.ok}`);
|
|
107
|
+
return payload.ok ? 0 : 1;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function cmdDash(runtime: Runtime, options: { screen?: string | undefined } = {}): Promise<number> {
|
|
111
|
+
const requested = options.screen?.trim();
|
|
112
|
+
if (requested && !(MESH_TUI_PANELS as readonly string[]).includes(requested)) {
|
|
113
|
+
print(runtime.io, runtime.json, { ok: false, command: "dash", error: "unknown_screen" }, `unknown screen ${requested}; use agents, tasks, workflows, plans, inbox, procs, or spend`);
|
|
114
|
+
return 2;
|
|
115
|
+
}
|
|
116
|
+
const screen = requested as MeshTuiPanel | undefined;
|
|
117
|
+
const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
|
|
118
|
+
if (runtime.dryRun) {
|
|
119
|
+
print(runtime.io, runtime.json, {
|
|
120
|
+
ok: true,
|
|
121
|
+
command: "dash",
|
|
122
|
+
dryRun: true,
|
|
123
|
+
serverUrl: runtime.serverUrl,
|
|
124
|
+
transport: "sse",
|
|
125
|
+
...(screen ? { screen } : {}),
|
|
126
|
+
}, "would start kxm dash");
|
|
127
|
+
return 0;
|
|
128
|
+
}
|
|
129
|
+
if (runtime.json) {
|
|
130
|
+
runtime.io.stderr("kxm dash does not support --json; use kxm hub view\n");
|
|
131
|
+
return 2;
|
|
132
|
+
}
|
|
133
|
+
const project = runtime.env.KXM_PROJECT?.trim() || basename(runtime.dirs.workdir) || "project";
|
|
134
|
+
const authToken = runtime.env.KXM_AUTH_TOKEN?.trim();
|
|
135
|
+
return await runMeshTui({
|
|
136
|
+
serverUrl: runtime.serverUrl,
|
|
137
|
+
dataPath,
|
|
138
|
+
stateDir: runtime.dirs.state,
|
|
139
|
+
project,
|
|
140
|
+
env: runtime.env,
|
|
141
|
+
...(authToken ? { authToken } : {}),
|
|
142
|
+
...(screen ? { screen } : {}),
|
|
143
|
+
fetchImpl: runtime.fetchImpl,
|
|
144
|
+
stdout: runtime.io.stdout,
|
|
145
|
+
stdin: process.stdin,
|
|
146
|
+
isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export async function cmdHub(runtime: Runtime): Promise<number> {
|
|
151
|
+
let refresh: Promise<unknown> | undefined;
|
|
152
|
+
if (!runtime.dryRun) {
|
|
153
|
+
const probe = installProbeFrom(runtime);
|
|
154
|
+
if (classifyInstallRoot(probe).kind !== "source") {
|
|
155
|
+
warnIgnoredProjectUpdateYaml(runtime);
|
|
156
|
+
const cached = readUpdateCache(runtime.dirs.state);
|
|
157
|
+
if (cached?.available) runtime.io.stderr(`${cached.message}\n`);
|
|
158
|
+
let config: KxmUpdateConfig | undefined;
|
|
159
|
+
try {
|
|
160
|
+
config = loadKxmUpdateConfig(runtime.env);
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (error instanceof KxmUpdateConfigError) {
|
|
163
|
+
const yamlPath = join(vnextUserStateRoot({ env: runtime.env }), "update.yaml");
|
|
164
|
+
runtime.io.stderr(`kxm: ${error.message}; update check skipped; fix or remove ${yamlPath}\n`);
|
|
165
|
+
} else {
|
|
166
|
+
throw error;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (config) {
|
|
170
|
+
refresh = refreshKxmUpdateNotice(runtime, config).then((notice) => {
|
|
171
|
+
if (notice.available && !cached?.available) runtime.io.stderr(`${notice.message}\n`);
|
|
172
|
+
}).catch(() => undefined);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const extraEnv = workspaceEnv(runtime);
|
|
177
|
+
if (runtime.dryRun) {
|
|
178
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub start", dryRun: true, workspace: runtime.dirs.workspace }, "would start hub");
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
const code = await (runtime.io.spawnHub ?? ((launchEnv) => spawnScript("kxm-hub.mjs", launchEnv)))(extraEnv);
|
|
182
|
+
if (refresh) await refresh;
|
|
183
|
+
return code;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function formatHubBindHealth(health: HubHealth): string {
|
|
187
|
+
if (health === "on") return "health=on";
|
|
188
|
+
if (health === "off") return "health=off (nothing answered; run kxm hub start)";
|
|
189
|
+
return "health=unknown (no reply within 300 ms)";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function cmdHubBind(runtime: Runtime, rawUrl: string): Promise<number> {
|
|
193
|
+
let url: string;
|
|
194
|
+
try {
|
|
195
|
+
url = validateHubUrl(rawUrl);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
if (error instanceof HubBindingError) {
|
|
198
|
+
print(
|
|
199
|
+
runtime.io,
|
|
200
|
+
runtime.json,
|
|
201
|
+
{ ok: false, command: "hub bind", error: "hub_url_invalid" },
|
|
202
|
+
"hub bind needs an http or https URL without credentials, query, or fragment",
|
|
203
|
+
);
|
|
204
|
+
return 2;
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
const file = hubBindingFile(runtime.env);
|
|
209
|
+
if (runtime.dryRun) {
|
|
210
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", dryRun: true, url, file }, `would bind hub ${url}`);
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
writeHubBinding({ schema: HUB_BINDING_SCHEMA, url, boundAt: new Date().toISOString() }, runtime.env);
|
|
214
|
+
const { health, probeMs } = await probeHubHealth(url, runtime.fetchImpl);
|
|
215
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, file, health, probeMs }, `bound hub ${url} · ${formatHubBindHealth(health)}`);
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function cmdHubUnbind(runtime: Runtime): Promise<number> {
|
|
220
|
+
const file = hubBindingFile(runtime.env);
|
|
221
|
+
if (runtime.dryRun) {
|
|
222
|
+
print(
|
|
223
|
+
runtime.io,
|
|
224
|
+
runtime.json,
|
|
225
|
+
{ ok: true, command: "hub unbind", dryRun: true, ...(runtime.boundHubUrl ? { url: runtime.boundHubUrl } : {}), file },
|
|
226
|
+
`would unbind hub${runtime.boundHubUrl ? ` ${runtime.boundHubUrl}` : ""}`,
|
|
227
|
+
);
|
|
228
|
+
return 0;
|
|
229
|
+
}
|
|
230
|
+
let url: string | undefined;
|
|
231
|
+
let malformed = false;
|
|
232
|
+
try {
|
|
233
|
+
url = readHubBinding(runtime.env)?.url;
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (error instanceof HubBindingError) malformed = true;
|
|
236
|
+
else throw error;
|
|
237
|
+
}
|
|
238
|
+
if (!malformed && !url) {
|
|
239
|
+
print(runtime.io, runtime.json, { ok: false, command: "hub unbind", error: "hub_not_bound" }, `no hub binding at ${file}`);
|
|
240
|
+
return 1;
|
|
241
|
+
}
|
|
242
|
+
removeHubBinding(runtime.env);
|
|
243
|
+
if (malformed) {
|
|
244
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub unbind", file }, "unbound hub (record was malformed)");
|
|
245
|
+
return 0;
|
|
246
|
+
}
|
|
247
|
+
print(runtime.io, runtime.json, { ok: true, command: "hub unbind", url, file }, `unbound hub ${url}`);
|
|
248
|
+
return 0;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export async function cmdWorker(runtime: Runtime, options: {
|
|
252
|
+
name?: string | undefined;
|
|
253
|
+
project?: string | undefined;
|
|
254
|
+
model?: string | undefined;
|
|
255
|
+
fallbackModels?: string | undefined;
|
|
256
|
+
tools?: string | undefined;
|
|
257
|
+
sessionIsolation?: string | undefined;
|
|
258
|
+
continue?: boolean | undefined;
|
|
259
|
+
freshStart?: boolean | undefined;
|
|
260
|
+
}): Promise<number> {
|
|
261
|
+
const name = options.name?.trim() || runtime.env.KXM_AGENT_NAME?.trim();
|
|
262
|
+
const project = options.project?.trim() || runtime.env.KXM_PROJECT?.trim();
|
|
263
|
+
const model = options.model?.trim() || runtime.env.KXM_WORKER_MODEL?.trim();
|
|
264
|
+
const fallbackModels = options.fallbackModels?.trim() || runtime.env.KXM_WORKER_FALLBACK_MODELS?.trim();
|
|
265
|
+
const tools = options.tools?.trim() || runtime.env.KXM_WORKER_TOOLS?.trim();
|
|
266
|
+
const sessionIsolation = options.sessionIsolation?.trim() || runtime.env.KXM_WORKER_SESSION_ISOLATION?.trim() || "off";
|
|
267
|
+
if (sessionIsolation !== "workflow" && sessionIsolation !== "off") {
|
|
268
|
+
runtime.io.stderr("worker --session-isolation must be workflow or off\n");
|
|
269
|
+
return 2;
|
|
270
|
+
}
|
|
271
|
+
const extraEnv = {
|
|
272
|
+
...workspaceEnv(runtime),
|
|
273
|
+
...(name ? { KXM_AGENT_NAME: name } : {}),
|
|
274
|
+
...(project ? { KXM_PROJECT: project } : {}),
|
|
275
|
+
...(model ? { KXM_WORKER_MODEL: model } : {}),
|
|
276
|
+
...(fallbackModels ? { KXM_WORKER_FALLBACK_MODELS: fallbackModels } : {}),
|
|
277
|
+
...(tools ? { KXM_WORKER_TOOLS: tools } : {}),
|
|
278
|
+
KXM_WORKER_SESSION_ISOLATION: sessionIsolation,
|
|
279
|
+
...(options.continue === false ? { KXM_WORKER_CONTINUE: "false" } : {}),
|
|
280
|
+
...(options.freshStart ? { KXM_WORKER_INITIAL_CONTINUE: "false" } : {}),
|
|
281
|
+
};
|
|
282
|
+
if (runtime.dryRun) {
|
|
283
|
+
printWorker(runtime, agentWorker({
|
|
284
|
+
name: name || "required",
|
|
285
|
+
project: project || "required",
|
|
286
|
+
...(model ? { model } : {}),
|
|
287
|
+
}), {
|
|
288
|
+
ok: true,
|
|
289
|
+
command: "worker",
|
|
290
|
+
dryRun: true,
|
|
291
|
+
workspace: runtime.dirs.workspace,
|
|
292
|
+
name: name || "required",
|
|
293
|
+
project: project || "required",
|
|
294
|
+
model: model || "provider default",
|
|
295
|
+
fallbackModels: fallbackModels || "none",
|
|
296
|
+
tools: tools || "Pi defaults",
|
|
297
|
+
sessionIsolation,
|
|
298
|
+
continue: options.continue !== false,
|
|
299
|
+
freshStart: Boolean(options.freshStart),
|
|
300
|
+
}, "would start worker");
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
if (!name || !project) {
|
|
304
|
+
runtime.io.stderr("worker requires --name and --project (or KXM_AGENT_NAME and KXM_PROJECT)\n");
|
|
305
|
+
return 2;
|
|
306
|
+
}
|
|
307
|
+
return await (runtime.io.spawnWorker ?? ((launchEnv) => spawnScript("kxm-worker.mjs", launchEnv)))(extraEnv);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function cmdStop(runtime: Runtime, waitMsFlag?: string | undefined): Promise<number> {
|
|
311
|
+
const pids = existsSync(runtime.dirs.state)
|
|
312
|
+
? readdirSync(runtime.dirs.state).filter((name) => name.endsWith(".pid"))
|
|
313
|
+
: [];
|
|
314
|
+
if (runtime.dryRun) {
|
|
315
|
+
print(runtime.io, runtime.json, { ok: true, command: "stop", dryRun: true, pidFiles: pids }, "would signal pid files");
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
if (pids.length === 0) {
|
|
319
|
+
print(runtime.io, runtime.json, { ok: false, command: "stop", error: "no_pid_files" }, "no hub/worker pid files found");
|
|
320
|
+
return 1;
|
|
321
|
+
}
|
|
322
|
+
const requested: string[] = [];
|
|
323
|
+
const ignored: string[] = [];
|
|
324
|
+
const records = new Map<string, { pid: number; startedAt: string; generation?: string }>();
|
|
325
|
+
const orphans: string[] = [];
|
|
326
|
+
for (const file of pids) {
|
|
327
|
+
try {
|
|
328
|
+
const record = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { version?: number; pid?: number; serverPid?: number; role?: string; startedAt?: string; generation?: string; controlFile?: string };
|
|
329
|
+
const expectedControl = file === "hub.pid" ? "hub.stop" : file.startsWith("worker-") ? `${file.slice(0, -4)}.stop` : undefined;
|
|
330
|
+
const expectedRole = file === "hub.pid" ? "hub" : file.startsWith("worker-") ? "worker" : undefined;
|
|
331
|
+
if (record.version !== 1 || !Number.isInteger(record.pid) || record.pid! <= 0 || !record.startedAt || !expectedControl || record.controlFile !== expectedControl || record.role !== expectedRole) { ignored.push(file); continue; }
|
|
332
|
+
if (!processExists(record.pid!)) {
|
|
333
|
+
if (file === "hub.pid" && Number.isInteger(record.serverPid) && record.serverPid! > 0 && record.serverPid !== record.pid && processExists(record.serverPid!)) {
|
|
334
|
+
try { process.kill(record.serverPid!, "SIGTERM"); } catch { /* racing exit */ }
|
|
335
|
+
orphans.push(file);
|
|
336
|
+
records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
ignored.push(file);
|
|
340
|
+
continue;
|
|
341
|
+
}
|
|
342
|
+
writeFileSync(join(runtime.dirs.state, record.controlFile!), `${JSON.stringify({ startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}), requestedAt: new Date().toISOString() })}\n`, { encoding: "utf8", mode: 0o600 });
|
|
343
|
+
requested.push(file); records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
|
|
344
|
+
} catch { ignored.push(file); }
|
|
345
|
+
}
|
|
346
|
+
if (requested.length === 0 && orphans.length === 0) { print(runtime.io, runtime.json, { ok: false, command: "stop", requested, ignored }, "no current managed processes found"); return 1; }
|
|
347
|
+
const waitMs = Math.min(30_000, Math.max(100, Number(waitMsFlag || 5_000)));
|
|
348
|
+
const deadline = Date.now() + waitMs;
|
|
349
|
+
const stopped = new Set<string>();
|
|
350
|
+
while (Date.now() <= deadline && stopped.size < requested.length) {
|
|
351
|
+
for (const [file, record] of records) {
|
|
352
|
+
try {
|
|
353
|
+
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; startedAt?: string; generation?: string };
|
|
354
|
+
if (current.pid !== record.pid || current.startedAt !== record.startedAt || current.generation !== record.generation || !processExists(record.pid)) stopped.add(file);
|
|
355
|
+
} catch { stopped.add(file); }
|
|
356
|
+
}
|
|
357
|
+
if (stopped.size < requested.length) await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
|
|
358
|
+
}
|
|
359
|
+
const timedOut = requested.filter((file) => !stopped.has(file));
|
|
360
|
+
const deadlineOrphans = Date.now() + Math.min(10_000, waitMs);
|
|
361
|
+
for (const file of orphans) {
|
|
362
|
+
while (Date.now() <= deadlineOrphans) {
|
|
363
|
+
try {
|
|
364
|
+
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
|
|
365
|
+
if (!Number.isInteger(current.serverPid) || !processExists(current.serverPid!)) break;
|
|
366
|
+
} catch { break; }
|
|
367
|
+
await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
|
|
371
|
+
if (Number.isInteger(current.serverPid) && processExists(current.serverPid!)) {
|
|
372
|
+
try { process.kill(current.serverPid!, "SIGKILL"); } catch { /* racing exit */ }
|
|
373
|
+
}
|
|
374
|
+
rmSync(join(runtime.dirs.state, file), { force: true });
|
|
375
|
+
} catch { /* claim already replaced or removed */ }
|
|
376
|
+
stopped.add(file);
|
|
377
|
+
}
|
|
378
|
+
const ok = timedOut.length === 0;
|
|
379
|
+
print(runtime.io, runtime.json, { ok, command: "stop", requested, stopped: [...stopped], timedOut, ...(orphans.length ? { orphans } : {}), ignored }, ok ? "managed processes stopped" : "stop request timed out");
|
|
380
|
+
return ok ? 0 : 1;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export async function cmdSessionStatus(runtime: Runtime): Promise<number> {
|
|
384
|
+
const stateDir = runtime.dirs.state;
|
|
385
|
+
const names = existsSync(stateDir) ? readdirSync(stateDir) : [];
|
|
386
|
+
const claims = [];
|
|
387
|
+
for (const file of names.filter((name) => name.endsWith(".pid"))) {
|
|
388
|
+
try {
|
|
389
|
+
const record = JSON.parse(readFileSync(join(stateDir, file), "utf8")) as { pid?: number; role?: string; startedAt?: string; generation?: string };
|
|
390
|
+
claims.push({
|
|
391
|
+
file,
|
|
392
|
+
role: record.role,
|
|
393
|
+
pid: record.pid,
|
|
394
|
+
startedAt: record.startedAt,
|
|
395
|
+
live: Number.isInteger(record.pid) && record.pid! > 0 && processExists(record.pid!),
|
|
396
|
+
});
|
|
397
|
+
} catch {
|
|
398
|
+
claims.push({ file, live: false, error: "invalid_pid_record" });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
const recoveries = [];
|
|
402
|
+
for (const file of names.filter((name) => name.startsWith("worker-recovery-") && name.endsWith(".json"))) {
|
|
403
|
+
try {
|
|
404
|
+
const envelope = JSON.parse(readFileSync(join(stateDir, file), "utf8")) as {
|
|
405
|
+
reason?: string;
|
|
406
|
+
agentName?: string;
|
|
407
|
+
project?: string;
|
|
408
|
+
createdAt?: string;
|
|
409
|
+
runId?: string | null;
|
|
410
|
+
stageId?: string | null;
|
|
411
|
+
freshSession?: boolean;
|
|
412
|
+
};
|
|
413
|
+
recoveries.push({
|
|
414
|
+
file,
|
|
415
|
+
reason: envelope.reason,
|
|
416
|
+
agentName: envelope.agentName,
|
|
417
|
+
project: envelope.project,
|
|
418
|
+
createdAt: envelope.createdAt,
|
|
419
|
+
runId: envelope.runId ?? undefined,
|
|
420
|
+
stageId: envelope.stageId ?? undefined,
|
|
421
|
+
freshSession: envelope.freshSession === true,
|
|
422
|
+
});
|
|
423
|
+
} catch {
|
|
424
|
+
recoveries.push({ file, error: "invalid_recovery_envelope" });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
print(
|
|
428
|
+
runtime.io,
|
|
429
|
+
runtime.json,
|
|
430
|
+
{ ok: true, command: "session status", claims, recoveries },
|
|
431
|
+
`${claims.length} session claim(s), ${recoveries.length} recovery envelope(s)`,
|
|
432
|
+
);
|
|
433
|
+
return 0;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function cmdAuthToken(runtime: Runtime, options: { status?: boolean | undefined; clear?: boolean | undefined; issue?: boolean | undefined } = {}): Promise<number> {
|
|
437
|
+
if (options.clear) {
|
|
438
|
+
const cleared = clearSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
439
|
+
print(
|
|
440
|
+
runtime.io,
|
|
441
|
+
runtime.json,
|
|
442
|
+
{ ok: true, command: "auth token", cleared },
|
|
443
|
+
cleared ? "Session token cleared from disk." : "No session token file found to clear.",
|
|
444
|
+
);
|
|
445
|
+
return 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (options.status) {
|
|
449
|
+
const fromEnv = runtime.env.KXM_SESSION_TOKEN?.trim();
|
|
450
|
+
if (fromEnv) {
|
|
451
|
+
const parsed = parseSessionToken(fromEnv);
|
|
452
|
+
print(
|
|
453
|
+
runtime.io,
|
|
454
|
+
runtime.json,
|
|
455
|
+
{
|
|
456
|
+
ok: true,
|
|
457
|
+
command: "auth token",
|
|
458
|
+
source: "env",
|
|
459
|
+
valid: Boolean(parsed),
|
|
460
|
+
...(parsed ? { sessionId: parsed.sessionId, issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt } : {}),
|
|
461
|
+
},
|
|
462
|
+
parsed ? `Active session token from env (session=${parsed.sessionId})` : "Session token in env is invalid or expired",
|
|
463
|
+
);
|
|
464
|
+
return parsed ? 0 : 1;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const disk = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
468
|
+
if (disk) {
|
|
469
|
+
print(
|
|
470
|
+
runtime.io,
|
|
471
|
+
runtime.json,
|
|
472
|
+
{
|
|
473
|
+
ok: true,
|
|
474
|
+
command: "auth token",
|
|
475
|
+
source: "disk",
|
|
476
|
+
valid: true,
|
|
477
|
+
sessionId: disk.payload.sessionId,
|
|
478
|
+
issuedAt: disk.payload.issuedAt,
|
|
479
|
+
expiresAt: disk.payload.expiresAt,
|
|
480
|
+
path: sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR),
|
|
481
|
+
},
|
|
482
|
+
`Active session token on disk (session=${disk.payload.sessionId}, expires=${disk.payload.expiresAt ?? "never"})`,
|
|
483
|
+
);
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
print(
|
|
488
|
+
runtime.io,
|
|
489
|
+
runtime.json,
|
|
490
|
+
{ ok: false, command: "auth token", error: "no_token", message: "No active session token found in env or disk" },
|
|
491
|
+
"No active session token found in env or disk",
|
|
492
|
+
);
|
|
493
|
+
return 1;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
let token: string;
|
|
497
|
+
if (options.issue) {
|
|
498
|
+
token = mintSessionToken({ preset: "operator" });
|
|
499
|
+
persistSessionTokenToDisk(token, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
500
|
+
} else {
|
|
501
|
+
const existing = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
502
|
+
if (existing) {
|
|
503
|
+
token = existing.token;
|
|
504
|
+
} else {
|
|
505
|
+
token = mintSessionToken({ preset: "operator" });
|
|
506
|
+
persistSessionTokenToDisk(token, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
print(runtime.io, runtime.json, { ok: true, command: "auth token", token }, token);
|
|
511
|
+
return 0;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
export async function cmdSessionBrief(runtime: Runtime, options: { status?: boolean | undefined; token?: boolean | undefined } = {}): Promise<number> {
|
|
515
|
+
const existing = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
516
|
+
const sessionToken = existing ? existing.token : mintSessionToken({ preset: "operator" });
|
|
517
|
+
if (!existing) {
|
|
518
|
+
persistSessionTokenToDisk(sessionToken, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
|
|
519
|
+
}
|
|
520
|
+
if (options.token) {
|
|
521
|
+
print(runtime.io, runtime.json, { ok: true, command: "session brief", sessionToken }, sessionToken);
|
|
522
|
+
return 0;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
|
|
526
|
+
const env = {
|
|
527
|
+
...runtime.env,
|
|
528
|
+
KXM_STATE_DIR: runtime.dirs.state,
|
|
529
|
+
KXM_DATA_PATH: runtime.env.KXM_DATA_PATH?.trim() || dataPath,
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
let hub: SessionHubStatus | undefined;
|
|
533
|
+
const targetUrl = runtime.env.KXM_SERVER_URL?.trim() || runtime.boundHubUrl || readHubBinding(runtime.env)?.url;
|
|
534
|
+
if (targetUrl) {
|
|
535
|
+
const { health } = await probeHubHealth(targetUrl, runtime.fetchImpl, 300);
|
|
536
|
+
hub = {
|
|
537
|
+
state: health,
|
|
538
|
+
evidence: health === "unknown" ? "timeout" : "probed",
|
|
539
|
+
online: health === "on",
|
|
540
|
+
url: targetUrl,
|
|
541
|
+
};
|
|
542
|
+
} else {
|
|
543
|
+
hub = { state: "off", evidence: "unconfigured", online: false };
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const brief = await loadSessionBriefAsync(runtime.dirs.workdir, env, undefined, hub, {
|
|
547
|
+
fetchImpl: runtime.fetchImpl,
|
|
548
|
+
sessionToken,
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
if (options.status) {
|
|
552
|
+
print(runtime.io, runtime.json, brief, brief.statusLine);
|
|
553
|
+
return 0;
|
|
554
|
+
}
|
|
555
|
+
print(
|
|
556
|
+
runtime.io,
|
|
557
|
+
runtime.json,
|
|
558
|
+
brief,
|
|
559
|
+
`${formatSessionBriefText(brief)}\n\nSession token: ${sessionToken}\n`,
|
|
560
|
+
);
|
|
561
|
+
return 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export async function cmdSessionStart(runtime: Runtime, options: { id?: string | undefined; workflow?: string | undefined; mix?: string | undefined }): Promise<number> {
|
|
565
|
+
const id = options.id?.trim() || `session_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
|
|
566
|
+
const workflowId = options.workflow?.trim();
|
|
567
|
+
const mix = options.mix?.trim();
|
|
568
|
+
if (workflowId && mix) {
|
|
569
|
+
runtime.io.stderr("session start takes --workflow or --mix, not both\n");
|
|
570
|
+
return 2;
|
|
571
|
+
}
|
|
572
|
+
if (!workflowId && !mix) {
|
|
573
|
+
runtime.io.stderr("session start requires --workflow <id> or --mix <agent,gate,...>\n");
|
|
574
|
+
return 2;
|
|
575
|
+
}
|
|
576
|
+
const project = runtime.env.KXM_PROJECT?.trim();
|
|
577
|
+
let workers: Worker[];
|
|
578
|
+
try {
|
|
579
|
+
const names = mix
|
|
580
|
+
? mix.split(",").map((name) => name.trim()).filter(Boolean)
|
|
581
|
+
: rosterNames(runtime.dirs.config);
|
|
582
|
+
workers = loadNamedWorkers(runtime.dirs.config, names, project);
|
|
583
|
+
} catch (error) {
|
|
584
|
+
const errorName = error && typeof error === "object" && "name" in error ? String(error.name) : "";
|
|
585
|
+
if (errorName !== "SessionConfigError") throw error;
|
|
586
|
+
const message = error instanceof Error ? redactSecrets(error.message) : "invalid session configuration";
|
|
587
|
+
runtime.io.stderr(`${message}\n`);
|
|
588
|
+
return 2;
|
|
589
|
+
}
|
|
590
|
+
const session = createSession({
|
|
591
|
+
id,
|
|
592
|
+
host: hostMode(runtime),
|
|
593
|
+
mode: workflowId ? "workflow" : "mix",
|
|
594
|
+
workers,
|
|
595
|
+
assetsDir: runtime.dirs.assets,
|
|
596
|
+
...(workflowId ? { workflowId } : {}),
|
|
597
|
+
});
|
|
598
|
+
const created = [
|
|
599
|
+
...sessionAssetDirs(runtime.dirs.assets, session.id),
|
|
600
|
+
...(workflowId ? workflowAssetDirs(runtime.dirs.assets, workflowId) : []),
|
|
601
|
+
];
|
|
602
|
+
if (!runtime.dryRun) {
|
|
603
|
+
for (const directory of created) mkdirSync(directory, { recursive: true });
|
|
604
|
+
writeSession(runtime.dirs.assets, session);
|
|
605
|
+
}
|
|
606
|
+
print(runtime.io, runtime.json, {
|
|
607
|
+
ok: true,
|
|
608
|
+
command: "session start",
|
|
609
|
+
dryRun: runtime.dryRun || undefined,
|
|
610
|
+
session,
|
|
611
|
+
created,
|
|
612
|
+
}, `session ${session.id} (${session.mode})`);
|
|
613
|
+
return 0;
|
|
614
|
+
}
|