@kontextmind/kxm
Version:
KXM local-first multi-agent orchestration and operator dashboard
968 lines (936 loc) • 39.8 kB
text/typescript
import { randomUUID } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { redactSecrets } from "../redact.ts";
import { defaultProjectName } from "../project-name.ts";
import { agentSessionEnv, hasClientHubCredential, HubEnvError, readHubEnvRecord, resolveClientHubAuthToken } from "../hub-env.ts";
import { deleteKxmConfigValue, findConfigRoot, isHubOpReference, KxmHubConfigError, writeHubEndpoint } from "../config.ts";
import { describeHubConnection, opReferenceFromTokenCommand, resolveProjectIdentity } from "../hub-identity.ts";
import { agentWorker, type Worker } from "../envelope.ts";
import { MESH_TUI_PANELS, runMeshTui, type MeshTuiPanel } from "../tui.ts";
import { formatSessionBriefText, loadSessionBriefAsync, readCachedSessionBrief, type SessionHubStatus } from "../session-work.ts";
import {
HUB_BINDING_SCHEMA,
CloudTokenError,
HubBindingError,
hubBindingCloudFile,
hubBindingFile,
probeHubHealth,
readHubEndpoint,
hubBindingScope,
effectiveHubBindingScope,
isCloudTokenEnvName,
readHubBinding,
removeHubBinding,
splitTokenCommand,
validateHubUrl,
writeHubBinding,
type HubBindingRecord,
type HubHealth,
} from "../hub-binding.ts";
import {
classifyInstallRoot,
type InstallProbe,
} from "../kxm-install-kind.ts";
import { kxmUserStateRoot } from "../bindings.ts";
import {
fetchLatestKxmVersion,
kxmReleaseAssetName,
noticeFromVersions,
readInstalledKxmVersion,
readUpdateCache,
writeUpdateCache,
KxmUpdateConfigError,
type KxmUpdateConfig,
type KxmUpdateNotice,
} from "../kxm-update.ts";
import { loadKxmUpdateConfig } from "../kxm-update-config.ts";
import {
clearSessionTokenFromDisk,
mintSessionToken,
parseSessionToken,
persistSessionTokenToDisk,
readSessionTokenFromDisk,
sessionTokenPath,
} from "../commands.ts";
import { createSession, loadNamedWorkers, rosterNames, sessionAssetDirs, workflowAssetDirs, writeSession } from "../session.ts";
import {
print,
printPlan,
printWorker,
hostMode,
workspaceEnv,
spawnScript,
processExists,
type PlannedChange,
type Runtime,
} from "./types.ts";
export async function hubGet(
url: string,
fetchImpl: typeof fetch,
headers?: Record<string, string>,
): Promise<{ ok: boolean; status: number; body: unknown }> {
try {
const response = await fetchImpl(url, headers ? { headers } : undefined);
const text = redactSecrets((await response.text()).slice(0, 8_000));
let body: unknown = text;
try {
body = JSON.parse(text);
} catch {
// Keep text for diagnostics without treating it as a secret.
}
return { ok: response.ok, status: response.status, body };
} catch {
return { ok: false, status: 0, body: { error: "hub_unreachable" } };
}
}
export function installProbeFrom(runtime: Runtime): InstallProbe {
const partial = runtime.io.installProbe ?? {};
const moduleDir = partial.moduleDir ?? dirname(fileURLToPath(import.meta.url));
return {
moduleDir,
// Classify the install that is **running**, never the directory the caller
// happens to be standing in. Deriving this from cwd made `kxm update --kxm`
// answer "kxm is running from source at <cwd>; update it with git pull there"
// on an npm-global install whenever the operator ran it inside a kxm
// checkout — and a git pull there deploys nothing, because the services exec
// the installed copy. An unrecognisable layout falls back to the module
// directory, which classifies as `unknown` ("update it the way it was
// installed") rather than inventing a source install.
repoRoot: partial.repoRoot ?? tryFindKxmRepoRoot(import.meta.url) ?? moduleDir,
homeDir: partial.homeDir ?? homedir(),
platform: partial.platform ?? process.platform,
env: partial.env ?? runtime.env,
};
}
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { findKxmRepoRoot, tryFindKxmRepoRoot } from "../repo-root.ts";
import { installedKxmVersion } from "../kxm-update.ts";
/** The install root derived from the loaded module; `unknown` never crashes a caller. */
function moduleInstallRoot(): string {
try {
return findKxmRepoRoot(import.meta.url);
} catch {
return dirname(fileURLToPath(import.meta.url));
}
}
import { homedir } from "node:os";
export function warnIgnoredProjectUpdateYaml(runtime: Runtime): void {
const projectFile = join(runtime.dirs.workspace, "update.yaml");
if (!existsSync(projectFile)) return;
const userFile = join(kxmUserStateRoot({ env: runtime.env }), "update.yaml");
runtime.io.stderr(`kxm: ignoring .kxm/update.yaml in ${runtime.dirs.workdir}; update settings are read only from ${userFile}\n`);
}
export async function refreshKxmUpdateNotice(
runtime: Runtime,
config?: KxmUpdateConfig,
current?: string,
): Promise<KxmUpdateNotice> {
const resolved = config ?? loadKxmUpdateConfig(runtime.env);
// The version being compared is the one **running**, taken from the install
// root. Reading it from the caller's directory made `cd ~ && kxm update --kxm`
// die with a raw ENOENT on `$HOME/package.json` — and silently compared a
// stranger's version wherever the operator happened to stand and a
// package.json did exist. Callers that already resolved it pass it in.
const installed = current
?? installedKxmVersion(findKxmRepoRoot(import.meta.url))
?? installedKxmVersion(moduleInstallRoot());
if (installed === undefined) {
// No version to compare against: say nothing rather than crash a hub start
// over an update notice.
return {
current: "unknown",
available: false,
auto: false,
source: resolved.source,
message: `kxm update check skipped: the installed version is unreadable at ${moduleInstallRoot()}`,
};
}
const fetched = await fetchLatestKxmVersion(resolved.source, runtime.env, runtime.fetchImpl);
const notice = noticeFromVersions(installed, fetched.latest, resolved, fetched.error, fetched.asset);
if (!runtime.dryRun) writeUpdateCache(runtime.dirs.state, notice);
return notice;
}
export async function cmdStatus(runtime: Runtime): Promise<number> {
const connection = describeHubConnection(runtime.dirs.workdir, runtime.env, {
hasSavedProjectToken: (project) => {
try {
const token = readHubEnvRecord(runtime.env)?.projectTokens?.[project];
return typeof token === "string" && token.trim().length > 0;
} catch {
return false;
}
},
});
let headers: Record<string, string> | undefined;
try {
if (connection.mode === "cloud") {
const token = resolveClientHubAuthToken(runtime.env, connection.project, runtime.dirs.workdir);
if (token) headers = { authorization: `Bearer ${token}` };
}
} catch (error) {
if (error instanceof CloudTokenError) {
print(runtime.io, runtime.json, { ok: false, command: "hub view", error: error.code, detail: error.message }, error.message);
return 2;
}
if (!(error instanceof HubBindingError)) throw error;
}
const health = await readHubEndpoint(runtime.serverUrl, "health", runtime.fetchImpl, headers ? { headers } : {});
const ready = await readHubEndpoint(runtime.serverUrl, "ready", runtime.fetchImpl, headers ? { headers } : {});
const proxyKind = [health.kind, ready.kind].find((kind) => kind === "redirect" || kind === "auth_proxy");
const notHub = !proxyKind && (health.kind === "not_hub" || ready.kind === "not_hub");
const probeNote = proxyKind === "redirect"
? " · auth proxy or redirect"
: proxyKind === "auth_proxy"
? " · auth proxy (HTML login page)"
: notHub
? " · not a kxm hub"
: "";
// Scope on the status line deliberately: "attached across a network" and "attached on
// this box" are otherwise indistinguishable, and only one of them ships a token.
// Scope is a property of the URL actually contacted, not of whichever file the
// binding came from: KXM_SERVER_URL overrides the binding, and labelling the binding
// while probing an override would report "loopback" about a remote request.
// A cloud mode is remote when the contacted URL is that endpoint, including a
// loopback SSH forward.
const effectiveScope = connection.scope;
const overridden = connection.urlSource === "env";
const identity = {
mode: connection.mode,
modeSource: connection.modeSource,
url: connection.url,
urlSource: connection.urlSource,
project: connection.project,
projectSource: connection.source,
keySource: connection.keySource,
};
const payload = {
ok: health.ok && ready.ok,
command: "hub view",
target: { url: runtime.serverUrl, scope: effectiveScope, ...(overridden ? { source: "env" } : {}) },
identity,
health: health.body,
ready: ready.body,
};
print(runtime.io, runtime.json, payload,
`hub health=${health.ok} ready=${ready.ok} · ${effectiveScope} hub${overridden ? " (KXM_SERVER_URL)" : ""} · mode=${identity.mode} project=${identity.project} (${connection.sourceLabel}) key=${identity.keySource}${probeNote}`);
return payload.ok ? 0 : 1;
}
export async function cmdDash(runtime: Runtime, options: { screen?: string | undefined } = {}): Promise<number> {
const requested = options.screen?.trim();
if (requested && !(MESH_TUI_PANELS as readonly string[]).includes(requested)) {
print(runtime.io, runtime.json, { ok: false, command: "dash", error: "unknown_screen" }, `unknown screen ${requested}; use agents, tasks, workflows, plans, inbox, procs, or spend`);
return 2;
}
const screen = requested as MeshTuiPanel | undefined;
const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
if (runtime.dryRun) {
print(runtime.io, runtime.json, {
ok: true,
command: "dash",
dryRun: true,
serverUrl: runtime.serverUrl,
transport: "sse",
...(screen ? { screen } : {}),
}, "would start kxm dash");
return 0;
}
if (runtime.json) {
runtime.io.stderr("kxm dash does not support --json; use kxm hub view\n");
return 2;
}
const project = resolveProjectIdentity(runtime.dirs.workdir, runtime.env).project || "project";
let authToken: string | undefined;
try {
authToken = resolveClientHubAuthToken(runtime.env, project, runtime.dirs.workdir);
} catch (error) {
if (error instanceof CloudTokenError) {
print(runtime.io, runtime.json, { ok: false, command: "dash", error: error.code, detail: error.message }, error.message);
return 2;
}
throw error;
}
return await runMeshTui({
serverUrl: runtime.serverUrl,
dataPath,
stateDir: runtime.dirs.state,
project,
env: runtime.env,
...(authToken ? { authToken } : {}),
...(screen ? { screen } : {}),
fetchImpl: runtime.fetchImpl,
stdout: runtime.io.stdout,
stdin: process.stdin,
isTty: Boolean(process.stdin.isTTY && process.stdout.isTTY),
});
}
export async function cmdHub(runtime: Runtime): Promise<number> {
let refresh: Promise<unknown> | undefined;
if (!runtime.dryRun) {
const probe = installProbeFrom(runtime);
if (classifyInstallRoot(probe).kind !== "source") {
warnIgnoredProjectUpdateYaml(runtime);
const cached = readUpdateCache(runtime.dirs.state);
if (cached?.available) runtime.io.stderr(`${cached.message}\n`);
let config: KxmUpdateConfig | undefined;
try {
config = loadKxmUpdateConfig(runtime.env);
} catch (error) {
if (error instanceof KxmUpdateConfigError) {
const yamlPath = join(kxmUserStateRoot({ env: runtime.env }), "update.yaml");
runtime.io.stderr(`kxm: ${error.message}; update check skipped; fix or remove ${yamlPath}\n`);
} else {
throw error;
}
}
if (config) {
refresh = refreshKxmUpdateNotice(runtime, config).then((notice) => {
if (notice.available && !cached?.available) runtime.io.stderr(`${notice.message}\n`);
}).catch(() => undefined);
}
}
}
const extraEnv = workspaceEnv(runtime);
if (runtime.dryRun) {
print(runtime.io, runtime.json, { ok: true, command: "hub start", dryRun: true, workspace: runtime.dirs.workspace }, "would start hub");
return 0;
}
const code = await (runtime.io.spawnHub ?? ((launchEnv) => spawnScript("kxm-hub.mjs", launchEnv)))(extraEnv);
if (refresh) await refresh;
return code;
}
export function formatHubBindHealth(health: HubHealth): string {
if (health === "on") return "health=on";
if (health === "off") return "health=off (nothing answered; run kxm hub start)";
return "health=unknown (no reply within 300 ms)";
}
/** Kept in one place so the JSON payload and the prose line cannot drift apart. */
const HUB_BIND_UNAUTHENTICATED_HINT =
"export KXM_AUTH_TOKEN (or point KXM_STATE_HOME at the hub-env record that already holds one), then re-run; the hub itself requires a token beyond loopback";
export interface HubBindOptions {
cloud?: boolean | undefined;
tokenEnv?: string | undefined;
tokenCommand?: string | undefined;
keyOp?: string | undefined;
force?: boolean | undefined;
}
export async function cmdHubBind(runtime: Runtime, rawUrl: string, options: HubBindOptions = {}): Promise<number> {
let url: string;
try {
url = validateHubUrl(rawUrl);
} catch (error) {
if (error instanceof HubBindingError) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "hub_url_invalid" },
"hub bind needs an http or https URL without credentials, query, or fragment",
);
return 2;
}
throw error;
}
const cloud = options.cloud === true;
const tokenEnv = options.tokenEnv?.trim() || undefined;
const tokenCommand = options.tokenCommand?.trim() || undefined;
const keyOp = options.keyOp?.trim() || opReferenceFromTokenCommand(tokenCommand ?? "") || undefined;
if (options.keyOp?.trim() && !isHubOpReference(options.keyOp.trim())) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "hub_config_invalid", url },
"--key-op must be an op://vault/item/field reference, not a token; no binding was written",
);
return 2;
}
if (tokenCommand && !cloud) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "cloud_flag_required", url },
"--token-command requires --cloud; no binding was written",
);
return 2;
}
if (cloud && !tokenEnv && !tokenCommand && !keyOp) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "cloud_token_source_required", url },
"--cloud needs --token-env, --token-command, --key-op, or a combination; the token is not stored",
);
return 2;
}
if (tokenEnv && !isCloudTokenEnvName(tokenEnv)) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "cloud_token_env_invalid", url },
"--token-env must be an environment variable name; no binding was written",
);
return 2;
}
if (cloud && tokenCommand) {
try {
splitTokenCommand(tokenCommand);
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: "cloud_token_command_invalid", url, detail },
`${detail}; no binding was written`,
);
return 2;
}
}
const scope = cloud ? "remote" : hubBindingScope(url);
// Scope-scoped on purpose, and only for **this command**. A remote binding puts a
// bearer on a network path, so it is refused when nothing can authenticate it; a
// stored-but-unusable URL otherwise reads later like a network fault and gets debugged
// as one. Loopback is not consulted here because a loopback URL puts nothing on a wire —
// not because credentials are never resolved locally: other client paths still call
// `resolveClientHubAuthToken` regardless of scope, so a damaged host record can still
// fail `kxm peer list` on loopback. Narrowing the claim is the point; the first version
// of this guard checked the record *before* the scope and refused loopback binds that had
// always worked, which is a regression against behaviour predating this slice.
if (!cloud && scope === "remote") {
// The project that will actually authenticate: a record holding only another
// project's token cannot authorise this one.
const bindProject = resolveProjectIdentity(runtime.dirs.workdir, runtime.env).project || "project";
let credentialReady = false;
try {
credentialReady = hasClientHubCredential(runtime.env, bindProject, runtime.dirs.workdir);
} catch (error) {
// A malformed record is a readable configuration failure, not an uncaught throw
// past a user-facing entry point.
print(
runtime.io,
runtime.json,
{
ok: false,
command: "hub bind",
error: "hub_credential_unreadable",
url,
scope,
nextAction: "repair_hub_env_record",
hint: `${error instanceof Error ? error.message : String(error)}; no binding was written`,
},
`cannot read the hub credential: ${error instanceof Error ? error.message : String(error)}`,
);
return 2;
}
if (!credentialReady) {
print(
runtime.io,
runtime.json,
{
ok: false,
command: "hub bind",
error: "hub_bind_unauthenticated",
url,
scope,
project: bindProject,
// The hint belongs in the payload, not only the prose line: under --json the
// prose is suppressed, and a refusal that names no next step gets debugged by
// reading source.
nextAction: "export_kxm_auth_token",
hint: `${HUB_BIND_UNAUTHENTICATED_HINT} (needs a token for project ${bindProject})`,
},
`refusing to bind remote hub ${url} with no credential for project ${bindProject}; ${HUB_BIND_UNAUTHENTICATED_HINT}`,
);
return 2;
}
}
const file = cloud ? hubBindingCloudFile(runtime.env) : hubBindingFile(runtime.env);
const bindingCommand = tokenCommand ?? (cloud && keyOp ? `op read ${keyOp}` : undefined);
const cloudFields = cloud
? { cloud: true as const, ...(tokenEnv ? { tokenEnv } : {}), ...(bindingCommand ? { tokenCommand: bindingCommand } : {}) }
: {};
const record: HubBindingRecord = {
schema: HUB_BINDING_SCHEMA,
url,
boundAt: new Date().toISOString(),
...cloudFields,
};
const identity = resolveProjectIdentity(runtime.dirs.workdir, runtime.env);
const configFile = join(findConfigRoot(runtime.dirs.workdir), ".kxm", "config.yaml");
const key = {
...(keyOp ? { op: keyOp } : {}),
...(tokenEnv ? { env: tokenEnv } : {}),
};
const cloudNote = cloud ? " · token is not stored" : "";
if (runtime.dryRun) {
print(
runtime.io,
runtime.json,
{ ok: true, command: "hub bind", dryRun: true, url, scope, file, configFile, project: identity.project, ...cloudFields },
`would bind hub ${url} (${scope})${cloudNote}`,
);
return 0;
}
const probe = await probeHubHealth(url, runtime.fetchImpl);
const acceptable = probe.kind === "hub" || probe.kind === "timeout" || probe.kind === "unreachable";
if (!acceptable && options.force !== true) {
print(
runtime.io,
runtime.json,
{
ok: false,
command: "hub bind",
error: "hub_not_kxm",
url,
scope,
kind: probe.kind,
status: probe.status,
detail: probe.detail,
nextAction: "bind_a_kxm_hub_or_pass_force",
},
`refusing to bind ${url}: ${probe.detail}; no binding was written. Pass --force to bind anyway`,
);
return 2;
}
try {
writeHubEndpoint(runtime.dirs.workdir, {
mode: cloud ? "cloud" : "local",
url,
project: identity.project,
...(key.op || key.env ? { key } : {}),
});
} catch (error) {
if (error instanceof KxmHubConfigError) {
print(
runtime.io,
runtime.json,
{ ok: false, command: "hub bind", error: error.code, detail: error.message },
`${error.message}; no binding was written`,
);
return 2;
}
throw error;
}
writeHubBinding({ ...record, boundAt: new Date().toISOString() }, runtime.env);
const healthText = acceptable
? formatHubBindHealth(probe.health)
: `${probe.detail} (--force)`;
print(runtime.io, runtime.json, { ok: true, command: "hub bind", url, scope, file, configFile, project: identity.project, health: probe.health, probeMs: probe.probeMs, kind: probe.kind, ...cloudFields },
`bound hub ${url} · ${scope} · ${healthText}${cloud ? cloudNote : scope === "remote" ? " · token leaves this machine" : ""}`);
return 0;
}
export async function cmdHubUnbind(runtime: Runtime): Promise<number> {
const file = hubBindingFile(runtime.env);
if (runtime.dryRun) {
print(
runtime.io,
runtime.json,
{ ok: true, command: "hub unbind", dryRun: true, ...(runtime.boundHubUrl ? { url: runtime.boundHubUrl } : {}), file },
`would unbind hub${runtime.boundHubUrl ? ` ${runtime.boundHubUrl}` : ""}`,
);
return 0;
}
let url: string | undefined;
let malformed = false;
try {
url = readHubBinding(runtime.env)?.url;
} catch (error) {
if (error instanceof HubBindingError) malformed = true;
else throw error;
}
if (!malformed && !url) {
print(runtime.io, runtime.json, { ok: false, command: "hub unbind", error: "hub_not_bound" }, `no hub binding at ${file}`);
return 1;
}
removeHubBinding(runtime.env);
deleteKxmConfigValue(findConfigRoot(runtime.dirs.workdir), "hub.mode");
if (malformed) {
print(runtime.io, runtime.json, { ok: true, command: "hub unbind", file }, "unbound hub (record was malformed)");
return 0;
}
print(runtime.io, runtime.json, { ok: true, command: "hub unbind", url, file }, `unbound hub ${url}`);
return 0;
}
export async function cmdWorker(runtime: Runtime, options: {
name?: string | undefined;
project?: string | undefined;
model?: string | undefined;
fallbackModels?: string | undefined;
tools?: string | undefined;
sessionIsolation?: string | undefined;
continue?: boolean | undefined;
freshStart?: boolean | undefined;
}): Promise<number> {
const name = options.name?.trim() || runtime.env.KXM_AGENT_NAME?.trim();
const project = defaultProjectName(runtime.dirs.workdir, runtime.env, options.project);
const model = options.model?.trim() || runtime.env.KXM_WORKER_MODEL?.trim();
const fallbackModels = options.fallbackModels?.trim() || runtime.env.KXM_WORKER_FALLBACK_MODELS?.trim();
const tools = options.tools?.trim() || runtime.env.KXM_WORKER_TOOLS?.trim();
const sessionIsolation = options.sessionIsolation?.trim() || runtime.env.KXM_WORKER_SESSION_ISOLATION?.trim() || "off";
if (sessionIsolation !== "workflow" && sessionIsolation !== "off") {
runtime.io.stderr("worker --session-isolation must be workflow or off\n");
return 2;
}
let sessionEnv: NodeJS.ProcessEnv = { KXM_PROJECT: project };
if (!runtime.dryRun) {
try {
sessionEnv = agentSessionEnv(runtime.dirs.workdir, runtime.env, options.project);
} catch (error) {
if (error instanceof CloudTokenError || error instanceof HubEnvError) {
runtime.io.stderr(`${error.message}\n`);
return 2;
}
throw error;
}
}
const extraEnv = {
...workspaceEnv(runtime),
...sessionEnv,
...(name ? { KXM_AGENT_NAME: name } : {}),
...(model ? { KXM_WORKER_MODEL: model } : {}),
...(fallbackModels ? { KXM_WORKER_FALLBACK_MODELS: fallbackModels } : {}),
...(tools ? { KXM_WORKER_TOOLS: tools } : {}),
KXM_WORKER_SESSION_ISOLATION: sessionIsolation,
...(options.continue === false ? { KXM_WORKER_CONTINUE: "false" } : {}),
...(options.freshStart ? { KXM_WORKER_INITIAL_CONTINUE: "false" } : {}),
};
if (runtime.dryRun) {
printWorker(runtime, agentWorker({
name: name || "required",
project: project || "required",
...(model ? { model } : {}),
}), {
ok: true,
command: "worker",
dryRun: true,
workspace: runtime.dirs.workspace,
name: name || "required",
project: project || "required",
model: model || "provider default",
fallbackModels: fallbackModels || "none",
tools: tools || "Pi defaults",
sessionIsolation,
continue: options.continue !== false,
freshStart: Boolean(options.freshStart),
}, "would start worker");
return 0;
}
if (!name || !project) {
runtime.io.stderr("worker requires --name and --project (or KXM_AGENT_NAME and KXM_PROJECT)\n");
return 2;
}
return await (runtime.io.spawnWorker ?? ((launchEnv) => spawnScript("kxm-worker.mjs", launchEnv)))(extraEnv);
}
export async function cmdStop(runtime: Runtime, waitMsFlag?: string | undefined): Promise<number> {
const pids = existsSync(runtime.dirs.state)
? readdirSync(runtime.dirs.state).filter((name) => name.endsWith(".pid"))
: [];
if (runtime.dryRun) {
print(runtime.io, runtime.json, { ok: true, command: "stop", dryRun: true, pidFiles: pids }, "would signal pid files");
return 0;
}
if (pids.length === 0) {
print(runtime.io, runtime.json, { ok: false, command: "stop", error: "no_pid_files" }, "no hub/worker pid files found");
return 1;
}
const requested: string[] = [];
const ignored: string[] = [];
const records = new Map<string, { pid: number; startedAt: string; generation?: string }>();
const orphans: string[] = [];
for (const file of pids) {
try {
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 };
const expectedControl = file === "hub.pid" ? "hub.stop" : file.startsWith("worker-") ? `${file.slice(0, -4)}.stop` : undefined;
const expectedRole = file === "hub.pid" ? "hub" : file.startsWith("worker-") ? "worker" : undefined;
if (record.version !== 1 || !Number.isInteger(record.pid) || record.pid! <= 0 || !record.startedAt || !expectedControl || record.controlFile !== expectedControl || record.role !== expectedRole) { ignored.push(file); continue; }
if (!processExists(record.pid!)) {
if (file === "hub.pid" && Number.isInteger(record.serverPid) && record.serverPid! > 0 && record.serverPid !== record.pid && processExists(record.serverPid!)) {
try { process.kill(record.serverPid!, "SIGTERM"); } catch { /* racing exit */ }
orphans.push(file);
records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
continue;
}
ignored.push(file);
continue;
}
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 });
requested.push(file); records.set(file, { pid: record.pid!, startedAt: record.startedAt, ...(record.generation ? { generation: record.generation } : {}) });
} catch { ignored.push(file); }
}
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; }
const waitMs = Math.min(30_000, Math.max(100, Number(waitMsFlag || 5_000)));
const deadline = Date.now() + waitMs;
const stopped = new Set<string>();
while (Date.now() <= deadline && stopped.size < requested.length) {
for (const [file, record] of records) {
try {
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; startedAt?: string; generation?: string };
if (current.pid !== record.pid || current.startedAt !== record.startedAt || current.generation !== record.generation || !processExists(record.pid)) stopped.add(file);
} catch { stopped.add(file); }
}
if (stopped.size < requested.length) await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
}
const timedOut = requested.filter((file) => !stopped.has(file));
const deadlineOrphans = Date.now() + Math.min(10_000, waitMs);
for (const file of orphans) {
while (Date.now() <= deadlineOrphans) {
try {
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
if (!Number.isInteger(current.serverPid) || !processExists(current.serverPid!)) break;
} catch { break; }
await (runtime.io.sleep ?? ((ms) => new Promise((resolveSleep) => setTimeout(resolveSleep, ms))))(100);
}
try {
const current = JSON.parse(readFileSync(join(runtime.dirs.state, file), "utf8")) as { pid?: number; serverPid?: number };
if (Number.isInteger(current.serverPid) && processExists(current.serverPid!)) {
try { process.kill(current.serverPid!, "SIGKILL"); } catch { /* racing exit */ }
}
rmSync(join(runtime.dirs.state, file), { force: true });
} catch { /* claim already replaced or removed */ }
stopped.add(file);
}
const ok = timedOut.length === 0;
print(runtime.io, runtime.json, { ok, command: "stop", requested, stopped: [...stopped], timedOut, ...(orphans.length ? { orphans } : {}), ignored }, ok ? "managed processes stopped" : "stop request timed out");
return ok ? 0 : 1;
}
export async function cmdSessionStatus(runtime: Runtime): Promise<number> {
const stateDir = runtime.dirs.state;
const names = existsSync(stateDir) ? readdirSync(stateDir) : [];
const claims = [];
for (const file of names.filter((name) => name.endsWith(".pid"))) {
try {
const record = JSON.parse(readFileSync(join(stateDir, file), "utf8")) as { pid?: number; role?: string; startedAt?: string; generation?: string };
claims.push({
file,
role: record.role,
pid: record.pid,
startedAt: record.startedAt,
live: Number.isInteger(record.pid) && record.pid! > 0 && processExists(record.pid!),
});
} catch {
claims.push({ file, live: false, error: "invalid_pid_record" });
}
}
const recoveries = [];
for (const file of names.filter((name) => name.startsWith("worker-recovery-") && name.endsWith(".json"))) {
try {
const envelope = JSON.parse(readFileSync(join(stateDir, file), "utf8")) as {
reason?: string;
agentName?: string;
project?: string;
createdAt?: string;
runId?: string | null;
stageId?: string | null;
freshSession?: boolean;
};
recoveries.push({
file,
reason: envelope.reason,
agentName: envelope.agentName,
project: envelope.project,
createdAt: envelope.createdAt,
runId: envelope.runId ?? undefined,
stageId: envelope.stageId ?? undefined,
freshSession: envelope.freshSession === true,
});
} catch {
recoveries.push({ file, error: "invalid_recovery_envelope" });
}
}
print(
runtime.io,
runtime.json,
{ ok: true, command: "session status", claims, recoveries },
`${claims.length} session claim(s), ${recoveries.length} recovery envelope(s)`,
);
return 0;
}
export async function cmdAuthToken(runtime: Runtime, options: { status?: boolean | undefined; clear?: boolean | undefined; issue?: boolean | undefined } = {}): Promise<number> {
const tokenFile = sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR);
if (options.clear && runtime.dryRun) {
const present = existsSync(tokenFile);
printPlan(runtime, { command: "auth token", cleared: present }, present ? [{ action: "delete", target: tokenFile }] : [], present ? "clear the session token from disk" : "no session token file to clear");
return 0;
}
if (options.clear) {
const cleared = clearSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
print(
runtime.io,
runtime.json,
{ ok: true, command: "auth token", cleared },
cleared ? "Session token cleared from disk." : "No session token file found to clear.",
);
return 0;
}
if (options.status) {
const fromEnv = runtime.env.KXM_SESSION_TOKEN?.trim();
if (fromEnv) {
const parsed = parseSessionToken(fromEnv);
print(
runtime.io,
runtime.json,
{
ok: true,
command: "auth token",
source: "env",
valid: Boolean(parsed),
...(parsed ? { sessionId: parsed.sessionId, issuedAt: parsed.issuedAt, expiresAt: parsed.expiresAt } : {}),
},
parsed ? `Active session token from env (session=${parsed.sessionId})` : "Session token in env is invalid or expired",
);
return parsed ? 0 : 1;
}
const disk = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
if (disk) {
print(
runtime.io,
runtime.json,
{
ok: true,
command: "auth token",
source: "disk",
valid: true,
sessionId: disk.payload.sessionId,
issuedAt: disk.payload.issuedAt,
expiresAt: disk.payload.expiresAt,
path: sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR),
},
`Active session token on disk (session=${disk.payload.sessionId}, expires=${disk.payload.expiresAt ?? "never"})`,
);
return 0;
}
print(
runtime.io,
runtime.json,
{ ok: false, command: "auth token", error: "no_token", message: "No active session token found in env or disk" },
"No active session token found in env or disk",
);
return 1;
}
const existingToken = options.issue ? undefined : readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
if (runtime.dryRun && !existingToken) {
// The token is only worth printing once it is on disk; a dry run never is.
printPlan(runtime, { command: "auth token" }, [{ action: "write", target: tokenFile }], "issue an operator session token and persist it");
return 0;
}
let token: string;
if (existingToken) {
token = existingToken.token;
} else {
token = mintSessionToken({ preset: "operator" });
persistSessionTokenToDisk(token, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
}
print(runtime.io, runtime.json, { ok: true, command: "auth token", token }, token);
return 0;
}
export async function cmdSessionBrief(runtime: Runtime, options: { status?: boolean | undefined; token?: boolean | undefined } = {}): Promise<number> {
const existing = readSessionTokenFromDisk({ userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
// A dry run neither mints nor persists a token; it names the file it would write.
const planned: PlannedChange[] = existing ? [] : [{ action: "write", target: sessionTokenPath(runtime.env.KXM_USER_CONFIG_DIR) }];
const sessionToken = existing?.token ?? (runtime.dryRun ? undefined : mintSessionToken({ preset: "operator" }));
if (!existing && sessionToken) {
persistSessionTokenToDisk(sessionToken, { userConfigDir: runtime.env.KXM_USER_CONFIG_DIR });
}
if (options.token) {
if (!sessionToken) {
printPlan(runtime, { command: "session brief" }, planned, "issue an operator session token and persist it");
return 0;
}
print(runtime.io, runtime.json, { ok: true, command: "session brief", sessionToken }, sessionToken);
return 0;
}
const dataPath = resolve(runtime.dirs.workdir, runtime.env.KXM_DATA_PATH?.trim() || join(runtime.dirs.state, "kxm.db"));
const env = {
...runtime.env,
KXM_STATE_DIR: runtime.dirs.state,
KXM_DATA_PATH: runtime.env.KXM_DATA_PATH?.trim() || dataPath,
};
let hub: SessionHubStatus | undefined;
const targetUrl = runtime.env.KXM_SERVER_URL?.trim() || runtime.boundHubUrl || readHubBinding(runtime.env)?.url;
if (targetUrl) {
const { health } = await probeHubHealth(targetUrl, runtime.fetchImpl, 300);
hub = {
state: health,
evidence: health === "unknown" ? "timeout" : "probed",
online: health === "on",
url: targetUrl,
scope: effectiveHubBindingScope(targetUrl, runtime.env),
};
} else {
hub = { state: "off", evidence: "unconfigured", online: false };
}
const brief = await loadSessionBriefAsync(runtime.dirs.workdir, env, undefined, hub, {
fetchImpl: runtime.fetchImpl,
...(sessionToken ? { sessionToken } : {}),
writeCache: !runtime.dryRun,
});
if (runtime.dryRun) {
if (!readCachedSessionBrief(runtime.dirs.state)) planned.push({ action: "write", target: join(runtime.dirs.state, "session-brief.json") });
print(runtime.io, runtime.json, { ...brief, dryRun: true, planned }, [
options.status ? brief.statusLine : formatSessionBriefText(brief),
...planned.map((change) => `dry run: would ${change.action} ${change.target}`),
].join("\n"));
return 0;
}
if (options.status) {
print(runtime.io, runtime.json, brief, brief.statusLine);
return 0;
}
print(
runtime.io,
runtime.json,
brief,
`${formatSessionBriefText(brief)}\n\nSession token: ${sessionToken}\n`,
);
return 0;
}
export async function cmdSessionStart(runtime: Runtime, options: { id?: string | undefined; workflow?: string | undefined; mix?: string | undefined }): Promise<number> {
const id = options.id?.trim() || `session_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
const workflowId = options.workflow?.trim();
const mix = options.mix?.trim();
if (workflowId && mix) {
runtime.io.stderr("session start takes --workflow or --mix, not both\n");
return 2;
}
if (!workflowId && !mix) {
runtime.io.stderr("session start requires --workflow <id> or --mix <agent,gate,...>\n");
return 2;
}
const project = runtime.env.KXM_PROJECT?.trim();
let workers: Worker[];
try {
const names = mix
? mix.split(",").map((name) => name.trim()).filter(Boolean)
: rosterNames(runtime.dirs.config);
workers = loadNamedWorkers(runtime.dirs.config, names, project);
} catch (error) {
const errorName = error && typeof error === "object" && "name" in error ? String(error.name) : "";
if (errorName !== "SessionConfigError") throw error;
const message = error instanceof Error ? redactSecrets(error.message) : "invalid session configuration";
runtime.io.stderr(`${message}\n`);
return 2;
}
const session = createSession({
id,
host: hostMode(runtime),
mode: workflowId ? "workflow" : "mix",
workers,
assetsDir: runtime.dirs.assets,
...(workflowId ? { workflowId } : {}),
});
const created = [
...sessionAssetDirs(runtime.dirs.assets, session.id),
...(workflowId ? workflowAssetDirs(runtime.dirs.assets, workflowId) : []),
];
if (!runtime.dryRun) {
for (const directory of created) mkdirSync(directory, { recursive: true });
writeSession(runtime.dirs.assets, session);
}
print(runtime.io, runtime.json, {
ok: true,
command: "session start",
dryRun: runtime.dryRun || undefined,
session,
created,
}, `session ${session.id} (${session.mode})`);
return 0;
}