UNPKG

@kontextmind/kxm

Version:

KXM local-first multi-agent orchestration and operator dashboard

968 lines (936 loc) • 39.8 kB
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; }