@kal-elsam/kairo-runtime 0.24.0 → 0.26.0

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/CHANGELOG.md CHANGED
@@ -5,6 +5,39 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.26.0 — 2026-09-19 (Kairo Runtime)
9
+
10
+ Minor release. ASK is now ready for every real automatic adapter.
11
+
12
+ ### Changed
13
+
14
+ - ASK can now call OpenCode Go/Zen too — via Kairo's own real,
15
+ verified read-only agent ("kairo-ask"), idempotently ensured in the
16
+ user's global `opencode.json` (merged non-destructively). OpenCode's
17
+ real CLI has no flag-driven read-only mode, so this is what makes a
18
+ genuinely portable, config-independent guarantee possible. Live-
19
+ verified: it answers correctly and genuinely refuses to write a file
20
+ when asked directly.
21
+ - Every real automatic PROJECT TEAM adapter (Codex, Claude, Cursor,
22
+ OpenCode Go, OpenCode Zen) is now ASK-capable — a role's real
23
+ assignment routes to ASK regardless of which one it lands on.
24
+
25
+ ## 0.25.0 — 2026-09-19 (Kairo Runtime)
26
+
27
+ Minor release. Cursor joins ASK.
28
+
29
+ ### Changed
30
+
31
+ - ASK can now call Cursor, via its real, documented read-only mode
32
+ (`cursor-agent --mode ask`, verified live) — never combined with
33
+ `--force`/`--yolo`. PROJECT TEAM roles assigned to Cursor (Architect/
34
+ Builder/Debugger in this project's own team) are now real ASK
35
+ candidates, not just execution candidates.
36
+ - OpenCode (Go/Zen) stays excluded from ASK: verified its CLI has no
37
+ portable, flag-driven read-only mode — its permission model is
38
+ project/user-config-driven, so a read-only call can't be guaranteed
39
+ safe across different installs.
40
+
8
41
  ## 0.24.0 — 2026-09-18 (Kairo Runtime)
9
42
 
10
43
  Minor release. ASK mode joins PROJECT TEAM.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "Kairo Runtime — local agent operating system for Codex, Cursor, Claude, Pi, Engram, and Graphify.",
5
5
  "type": "module",
6
6
  "homepage": "https://github.com/Kal-elSam/harness#readme",
@@ -21,14 +21,23 @@ import { ROLE_CAPABILITIES } from "../intelligence/role-profiles.js";
21
21
  import { computeRoleEvaluations } from "../intelligence/capability-scoring.js";
22
22
 
23
23
  // The Bootstrap Analyst investigates read-only via askProvider
24
- // (intelligence/quick-ask.js), which only actually supports these two
24
+ // (intelligence/quick-ask.js), which only actually supports these
25
25
  // providers today — offering any other real candidate as an "alternative"
26
26
  // here would be a menu item Kairo can't actually run. Exported: ASK mode's
27
27
  // own real-time routing (service.js's planAsk) needs this exact same real
28
28
  // constraint when it tries to route a plain question through a PROJECT
29
- // TEAM role — a role assigned to, say, opencode-go is a real, valid team
30
- // assignment, just not one askProvider can call.
31
- export const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude"]);
29
+ // TEAM role.
30
+ //
31
+ // Pure CAPABILITY (can askProvider invoke this adapter's CLI at all?),
32
+ // never a cost-risk judgment — OpenCode Zen's real PAYG billing risk is
33
+ // deliberately NOT re-litigated here; that's checkCandidate's own job
34
+ // (it hard-excludes opencode-zen unconditionally), and the real
35
+ // eligibility this module receives already reflects that exclusion. Both
36
+ // opencode-go and opencode-zen genuinely run through the same real
37
+ // askOpencode call (Kairo's own read-only agent — see
38
+ // intelligence/opencode-ask-agent.js), same as real execution routing
39
+ // already treats capability and cost-risk as two separate layers.
40
+ export const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude", "cursor", "opencode-go", "opencode-zen"]);
32
41
 
33
42
  /**
34
43
  * The Bootstrap Analyst as a temporary, read-only WORKFLOW — deliberately
@@ -0,0 +1,74 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
5
+
6
+ // OpenCode's real CLI (`opencode run --help`) has no flag-driven read-only
7
+ // mode — its permission model lives only in opencode.json (verified via
8
+ // its own published schema, https://opencode.ai/config.json, and a real
9
+ // working precedent already present in this machine's own global config:
10
+ // gentle-ai/sdd's "explore" agent, which uses this exact same
11
+ // bash/edit/task/write-deny shape to stay read-only). So a genuinely
12
+ // portable read-only ASK call needs Kairo to own one real agent entry in
13
+ // the user's GLOBAL opencode.json — never per-project (would pollute
14
+ // every repo it touches) — merged in non-destructively and never
15
+ // overwriting anything else already there.
16
+ export const KAIRO_ASK_AGENT_NAME = "kairo-ask";
17
+
18
+ // No "model" field on purpose — the real model comes from askOpencode's
19
+ // own `--model` CLI flag per call, exactly like askCodex/askClaude/
20
+ // askCursor already do; this agent only ever fixes the PERMISSION shape.
21
+ // No "read" key: the same real, working "explore" agent precedent above
22
+ // carries no explicit "read" entry either — omitted means allowed.
23
+ //
24
+ // Deliberately NO "__managed_by" marker (unlike the sibling "explore"
25
+ // agent's convention this was modeled on) — verified live that it isn't
26
+ // a real AgentConfig property at all: the real opencode.ai/config.json
27
+ // schema never defines it, and a live `opencode run` call with it present
28
+ // failed outright ("Unsupported parameter(s): `__managed_by`" from the
29
+ // real upstream API, which apparently receives it verbatim). Ownership is
30
+ // tracked by the unique key name (KAIRO_ASK_AGENT_NAME) instead.
31
+ export const KAIRO_ASK_AGENT_CONFIG = Object.freeze({
32
+ description: "Kairo's own real, read-only agent for ASK mode — investigates and answers, never edits, writes, or runs shell commands.",
33
+ hidden: true,
34
+ mode: "primary",
35
+ permission: Object.freeze({ bash: "deny", edit: "deny", task: "deny", write: "deny" })
36
+ });
37
+
38
+ function deepEqual(a, b) {
39
+ return JSON.stringify(a) === JSON.stringify(b);
40
+ }
41
+
42
+ export function resolveOpencodeConfigPath(homeDir = homedir()) {
43
+ return join(homeDir, ".config", "opencode", "opencode.json");
44
+ }
45
+
46
+ /**
47
+ * Idempotently ensures the real, global opencode.json carries Kairo's own
48
+ * read-only "kairo-ask" agent — merged non-destructively (every other real
49
+ * key, including every other agent, is preserved byte-for-byte) and only
50
+ * ever written when actually missing or drifted, never on every call.
51
+ * A missing config file is created fresh with just $schema + this agent —
52
+ * never treated as an error (a real, common first-run state).
53
+ * @param {{homeDir?: string, readFileImpl?: Function, writeAtomicJsonImpl?: Function}} [deps]
54
+ * @returns {Promise<{changed: boolean, configPath: string}>}
55
+ */
56
+ export async function ensureKairoAskAgent({
57
+ homeDir = homedir(), readFileImpl = readFile, writeAtomicJsonImpl = writeAtomicJson
58
+ } = {}) {
59
+ const configPath = resolveOpencodeConfigPath(homeDir);
60
+ let config = { $schema: "https://opencode.ai/config.json" };
61
+ try {
62
+ const raw = await readFileImpl(configPath, "utf8");
63
+ config = JSON.parse(raw);
64
+ } catch (error) {
65
+ if (error?.code !== "ENOENT") throw error;
66
+ }
67
+ const existing = config.agent?.[KAIRO_ASK_AGENT_NAME];
68
+ if (deepEqual(existing, KAIRO_ASK_AGENT_CONFIG)) {
69
+ return { changed: false, configPath };
70
+ }
71
+ const updated = { ...config, agent: { ...(config.agent ?? {}), [KAIRO_ASK_AGENT_NAME]: KAIRO_ASK_AGENT_CONFIG } };
72
+ await writeAtomicJsonImpl(configPath, updated);
73
+ return { changed: true, configPath };
74
+ }
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { buildClaudeExecutionEnv } from "../runtime/execution-adapters/claude.js";
6
+ import { ensureKairoAskAgent, KAIRO_ASK_AGENT_NAME } from "./opencode-ask-agent.js";
7
+ import { toRuntimeModelRef } from "./transport-registry.js";
6
8
 
7
9
  // A real, read-only question -> answer call — no task, no plan, no
8
10
  // approval gate. This spends real provider usage (unlike the zero-cost
@@ -33,6 +35,22 @@ function buildCodexExecutionEnv(sourceEnv = process.env) {
33
35
  return env;
34
36
  }
35
37
 
38
+ // Same real scrubbing principle as Codex/Claude above — CURSOR_API_KEY/
39
+ // CURSOR_API_ENDPOINT are cursor-agent's own documented real auth env
40
+ // vars (verified via `cursor-agent -p --help`), never a guess.
41
+ const CURSOR_SAFE_ENV_KEYS = Object.freeze([
42
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
43
+ "TMPDIR", "TERM", "CURSOR_API_KEY", "CURSOR_API_ENDPOINT", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
44
+ "http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
45
+ ]);
46
+ function buildCursorExecutionEnv(sourceEnv = process.env) {
47
+ const env = Object.create(null);
48
+ for (const key of CURSOR_SAFE_ENV_KEYS) {
49
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
50
+ }
51
+ return env;
52
+ }
53
+
36
54
  function unknown(error) {
37
55
  return { status: "error", answer: null, error: String(error) };
38
56
  }
@@ -102,6 +120,146 @@ function askClaude({ question, model, cwd, spawn, timeoutMs, env }) {
102
120
  });
103
121
  }
104
122
 
123
+ /**
124
+ * cursor-agent's own real, documented read-only mode (verified via
125
+ * `cursor-agent -p --help`): "ask: Q&A style for explanations and
126
+ * questions (read-only)" — never combined with --force/--yolo, which
127
+ * would grant real write/shell access. A real invalid-model failure
128
+ * (verified live) exits non-zero with a plain-text error on stderr, no
129
+ * JSON at all — unlike Claude's/Codex's own failure shapes, so a failed
130
+ * JSON parse here reports the real stderr text, never a generic guess.
131
+ * @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args
132
+ */
133
+ function askCursor({ question, model, cwd, spawn, timeoutMs, env }) {
134
+ const args = ["-p", question, "--mode", "ask", "--output-format", "json"];
135
+ if (model) args.push("--model", model);
136
+ return new Promise((resolve) => {
137
+ let child;
138
+ try {
139
+ child = spawn("cursor-agent", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
140
+ } catch (error) {
141
+ resolve(unknown(error?.message ?? error));
142
+ return;
143
+ }
144
+ let stdout = "";
145
+ let stderr = "";
146
+ let finished = false;
147
+ const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`cursor-agent idle-timed out after ${timeoutMs}ms with no output`)));
148
+ function finish(result) {
149
+ if (finished) return;
150
+ finished = true;
151
+ clearIdleTimer();
152
+ try { child.kill?.(); } catch { /* best effort */ }
153
+ resolve(result);
154
+ }
155
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
156
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
157
+ child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
158
+ child.once?.("close", (code) => {
159
+ let parsed;
160
+ try { parsed = JSON.parse(stdout); } catch {
161
+ return finish(unknown(stderr.trim() || `cursor-agent exited ${code} with no parseable output`));
162
+ }
163
+ if (parsed?.is_error === true || typeof parsed?.result !== "string") {
164
+ return finish(unknown(parsed?.result ?? stderr.trim() ?? "cursor-agent returned no answer"));
165
+ }
166
+ finish({ status: "answered", answer: parsed.result, error: null });
167
+ });
168
+ });
169
+ }
170
+
171
+ // Same real scrubbing principle as the others — OPENCODE_API_KEY is
172
+ // opencode's own documented real auth env var (types.js's
173
+ // OPENCODE_API_KEY_ENV).
174
+ const OPENCODE_SAFE_ENV_KEYS = Object.freeze([
175
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
176
+ "TMPDIR", "TERM", "OPENCODE_API_KEY", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
177
+ "http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
178
+ ]);
179
+ function buildOpencodeExecutionEnv(sourceEnv = process.env) {
180
+ const env = Object.create(null);
181
+ for (const key of OPENCODE_SAFE_ENV_KEYS) {
182
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
183
+ }
184
+ return env;
185
+ }
186
+
187
+ /**
188
+ * OpenCode's real CLI has no flag-driven read-only mode (verified via
189
+ * `opencode run --help`) — its permission model lives only in
190
+ * opencode.json, so this always runs against Kairo's own real, verified
191
+ * read-only agent (see opencode-ask-agent.js's own doc — live-verified
192
+ * both that it genuinely blocks a real write attempt and that
193
+ * `--agent`/`--model` compose correctly), ensured to exist in the user's
194
+ * global config before every call (cheap idempotent check — no real
195
+ * write unless actually missing or drifted).
196
+ *
197
+ * Verified live (`opencode run --agent kairo-ask --format json`): the
198
+ * real NDJSON stream emits `type: "text"` events carrying the real
199
+ * answer in `part.text` (possibly across multiple steps — accumulated in
200
+ * order) and a real `type: "error"` event on failure, both handled
201
+ * per-line as chunks arrive, mirroring the same idle-reset principle as
202
+ * every other ask call here.
203
+ * @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args
204
+ */
205
+ async function askOpencode({ question, model, cwd, spawn, timeoutMs, env, ensureAgent = ensureKairoAskAgent }) {
206
+ try {
207
+ await ensureAgent();
208
+ } catch (error) {
209
+ return unknown(`could not ensure Kairo's read-only OpenCode agent: ${error?.message ?? error}`);
210
+ }
211
+ const args = ["run", "--agent", KAIRO_ASK_AGENT_NAME, "--format", "json"];
212
+ if (model) args.push("--model", model);
213
+ args.push(question);
214
+
215
+ return new Promise((resolve) => {
216
+ let child;
217
+ try {
218
+ child = spawn("opencode", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
219
+ } catch (error) {
220
+ resolve(unknown(error?.message ?? error));
221
+ return;
222
+ }
223
+ let buffer = "";
224
+ const answerParts = [];
225
+ let realError = null;
226
+ let stderr = "";
227
+ let finished = false;
228
+ const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`opencode run idle-timed out after ${timeoutMs}ms with no output`)));
229
+ function finish(result) {
230
+ if (finished) return;
231
+ finished = true;
232
+ clearIdleTimer();
233
+ try { child.kill?.(); } catch { /* best effort */ }
234
+ resolve(result);
235
+ }
236
+ function handleLine(line) {
237
+ let parsed;
238
+ try { parsed = JSON.parse(line); } catch { return; }
239
+ if (parsed?.type === "text" && typeof parsed?.part?.text === "string") {
240
+ answerParts.push(parsed.part.text);
241
+ } else if (parsed?.type === "error") {
242
+ realError = parsed.error?.data?.message ?? parsed.error?.name ?? "opencode run returned a real error event";
243
+ }
244
+ }
245
+ child.stdout?.on("data", (chunk) => {
246
+ buffer += String(chunk);
247
+ const lines = buffer.split("\n");
248
+ buffer = lines.pop() ?? "";
249
+ for (const line of lines) if (line.trim()) handleLine(line.trim());
250
+ });
251
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
252
+ child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
253
+ child.once?.("close", (code) => {
254
+ if (buffer.trim()) handleLine(buffer.trim());
255
+ if (realError) return finish(unknown(realError));
256
+ const answer = answerParts.join("").trim();
257
+ if (!answer) return finish(unknown(stderr.trim() || `opencode run exited ${code} with no real text output`));
258
+ finish({ status: "answered", answer, error: null });
259
+ });
260
+ });
261
+ }
262
+
105
263
  /** @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args */
106
264
  async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
107
265
  let outDir;
@@ -158,18 +316,39 @@ async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
158
316
 
159
317
  /**
160
318
  * Asks the given provider a real, read-only question and returns its real
161
- * answer text. Supports Codex and Claude today; any other provider yields
162
- * an honest "unsupported" result rather than a guess.
319
+ * answer text every real automatic PROJECT TEAM adapter today (Codex,
320
+ * Claude, Cursor, OpenCode Go, OpenCode Zen); any other provider yields
321
+ * an honest "unsupported" result rather than a guess. OpenCode's real CLI
322
+ * has no flag-driven read-only mode (verified via `opencode run --help`:
323
+ * `--auto` only ever loosens permissions further, never restricts them),
324
+ * so it always runs against Kairo's own real, verified read-only agent
325
+ * instead (see opencode-ask-agent.js) — a project/user-config-independent
326
+ * guarantee, never relying on whatever the local opencode.json happens to
327
+ * already allow.
163
328
  * @param {object} args
164
- * @param {"codex"|"claude"} args.provider
329
+ * @param {"codex"|"claude"|"cursor"|"opencode-go"|"opencode-zen"} args.provider
165
330
  * @param {string} args.question
166
331
  * @param {string|null} [args.model]
167
332
  * @param {string} args.cwd
168
333
  */
169
334
  export async function askProvider({
170
- provider, question, model = null, cwd, spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env
335
+ provider, question, model = null, cwd, spawn = defaultSpawn, timeoutMs = DEFAULT_TIMEOUT_MS, sourceEnv = process.env,
336
+ ensureOpencodeAskAgent = ensureKairoAskAgent
171
337
  }) {
172
338
  if (provider === "claude") return askClaude({ question, model, cwd, spawn, timeoutMs, env: buildClaudeExecutionEnv(sourceEnv) });
173
339
  if (provider === "codex") return askCodex({ question, model, cwd, spawn, timeoutMs, env: buildCodexExecutionEnv(sourceEnv) });
340
+ if (provider === "cursor") return askCursor({ question, model, cwd, spawn, timeoutMs, env: buildCursorExecutionEnv(sourceEnv) });
341
+ if (provider === "opencode-go" || provider === "opencode-zen") {
342
+ // The real catalog stores bare model ids (see opencode-models.js's
343
+ // normalizeModel) — the CLI needs the real, fully-qualified
344
+ // "opencode-go/<id>" (or "opencode/<id>" for Zen) ref to
345
+ // deterministically route to the intended product, exactly like
346
+ // service.js's executePlan already does for real task execution.
347
+ const runtimeModel = model ? toRuntimeModelRef(provider === "opencode-go" ? "go" : "zen", model) : null;
348
+ return askOpencode({
349
+ question, model: runtimeModel, cwd, spawn, timeoutMs, env: buildOpencodeExecutionEnv(sourceEnv),
350
+ ensureAgent: ensureOpencodeAskAgent
351
+ });
352
+ }
174
353
  return { status: "unsupported", answer: null, error: `ASK is not supported for provider "${provider}" yet.` };
175
354
  }
@@ -89,7 +89,7 @@ export async function readCodexModels({
89
89
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before model list")); });
90
90
 
91
91
  writeRequest(child, 1, "initialize", {
92
- clientInfo: { name: "kairo", title: "Kairo", version: "0.24.0" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.26.0" },
93
93
  capabilities: {}
94
94
  });
95
95
  });
@@ -151,7 +151,7 @@ export async function readCodexUsage({
151
151
  child.once?.("close", () => { if (!finished) finish(unknown("codex app-server closed before rate limits")); });
152
152
 
153
153
  writeRequest(child, 1, "initialize", {
154
- clientInfo: { name: "kairo", title: "Kairo", version: "0.24.0" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.26.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });