@kal-elsam/kairo-runtime 0.23.2 → 0.25.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,47 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.25.0 — 2026-09-19 (Kairo Runtime)
9
+
10
+ Minor release. Cursor joins ASK.
11
+
12
+ ### Changed
13
+
14
+ - ASK can now call Cursor, via its real, documented read-only mode
15
+ (`cursor-agent --mode ask`, verified live) — never combined with
16
+ `--force`/`--yolo`. PROJECT TEAM roles assigned to Cursor (Architect/
17
+ Builder/Debugger in this project's own team) are now real ASK
18
+ candidates, not just execution candidates.
19
+ - OpenCode (Go/Zen) stays excluded from ASK: verified its CLI has no
20
+ portable, flag-driven read-only mode — its permission model is
21
+ project/user-config-driven, so a read-only call can't be guaranteed
22
+ safe across different installs.
23
+
24
+ ## 0.24.0 — 2026-09-18 (Kairo Runtime)
25
+
26
+ Minor release. ASK mode joins PROJECT TEAM.
27
+
28
+ ### Changed
29
+
30
+ - ASK mode now checks PROJECT TEAM's Explorer role first (read-only
31
+ investigation is exactly Explorer's job), via the same routing real
32
+ execution already uses — instead of always going through a fully
33
+ separate, generic quota/effort heuristic. Falls back to that
34
+ heuristic only when there's no active team yet, or Explorer's real
35
+ assignment isn't one ASK can actually invoke (Cursor/OpenCode
36
+ Go/Zen aren't ask-capable yet). Role is always Explorer, never
37
+ inferred from the question's text.
38
+
39
+ ### Fixed
40
+
41
+ - A flaky timing test in `quick-ask.test.js` (from 0.23.2) used
42
+ margins too tight to reliably survive full-suite load; widened them.
43
+
44
+ Not yet in this release: PLAN mode routing through the Orchestrator
45
+ role (plan creation is currently structurally Codex-only, a bigger
46
+ change than a routing choice), and AGENT mode (an open product
47
+ question on whether it should skip the plan→approve gate).
48
+
8
49
  ## 0.23.2 — 2026-09-18 (Kairo Runtime)
9
50
 
10
51
  Patch release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.23.2",
3
+ "version": "0.25.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,10 +21,15 @@ 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
- // here would be a menu item Kairo can't actually run.
27
- const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude"]);
26
+ // here would be a menu item Kairo can't actually run. Exported: ASK mode's
27
+ // own real-time routing (service.js's planAsk) needs this exact same real
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 (its real CLI has no
31
+ // portable read-only mode — see quick-ask.js's own askProvider doc).
32
+ export const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude", "cursor"]);
28
33
 
29
34
  /**
30
35
  * The Bootstrap Analyst as a temporary, read-only WORKFLOW — deliberately
@@ -26,8 +26,8 @@ import { appendTranscriptEntry, clearTranscript, readTranscript } from "./transc
26
26
  import { readSession, writeSessionMode } from "./session-store.js";
27
27
  import { computeProjectProfile } from "./project-profile.js";
28
28
  import {
29
- buildProjectStrategy, computeBootstrapAnalystAlternatives, computeBootstrapAnalystCatalog, isStrategyStale,
30
- computeProjectTeamEditCatalog, applyProjectTeamOverride, resetProjectTeamAssignment
29
+ ASK_SUPPORTED_ADAPTERS, buildProjectStrategy, computeBootstrapAnalystAlternatives, computeBootstrapAnalystCatalog,
30
+ isStrategyStale, computeProjectTeamEditCatalog, applyProjectTeamOverride, resetProjectTeamAssignment
31
31
  } from "./project-strategy.js";
32
32
  import { buildAnalystPrompt, deriveRoleRequirements, parseProjectAnalysis } from "./project-analysis.js";
33
33
  import { buildSanitizedSnapshot } from "./sanitized-snapshot.js";
@@ -662,6 +662,34 @@ export function createConversationService(deps = {}) {
662
662
  */
663
663
  async planAsk({ cwd, task }) {
664
664
  const projectRoot = await root(cwd);
665
+ // PROJECT TEAM's own Explorer role first — a real, approved,
666
+ // project-specific assignment beats the generic heuristic below,
667
+ // same principle as real execution routing. Explorer, never a
668
+ // guess from the question's text: resolveProjectRoute's whole
669
+ // design is that a role is always the caller's own explicit fixed
670
+ // choice, never inferred per-call — ASK questions are read-only
671
+ // investigation, which is exactly Explorer's job.
672
+ const teamRoute = await this.routeProjectExecution("Explorer", projectRoot);
673
+ const teamModel = teamRoute.decision === "ROUTED" ? teamRoute.model
674
+ : teamRoute.decision === "WAIT_FOR_PROJECT_TEAM" ? teamRoute.suggestedAlternative?.model ?? null
675
+ : null;
676
+ // Only when that real assignment is one askProvider can actually
677
+ // call (see ASK_SUPPORTED_ADAPTERS's own doc) — a role can be
678
+ // validly assigned to Cursor/OpenCode Go/Zen, which ASK simply
679
+ // can't invoke yet, so that's a real reason to fall through below,
680
+ // never an error.
681
+ if (teamModel && ASK_SUPPORTED_ADAPTERS.has(teamModel.adapterId)) {
682
+ return {
683
+ decision: {
684
+ decision: "ROUTED", provider: teamModel.adapterId, model: teamModel.modelId,
685
+ why: `Explorer (PROJECT TEAM): ${teamRoute.why}`
686
+ },
687
+ projectRoot
688
+ };
689
+ }
690
+ // No active team, or Explorer's real assignment isn't ask-capable —
691
+ // fall back to the generic quota/capability heuristic so ASK stays
692
+ // useful even before a team is approved.
665
693
  const adapters = inspectAdapters({ cwd: projectRoot });
666
694
  let codexUsage = null;
667
695
  let claudeUsage = null;
@@ -33,6 +33,22 @@ function buildCodexExecutionEnv(sourceEnv = process.env) {
33
33
  return env;
34
34
  }
35
35
 
36
+ // Same real scrubbing principle as Codex/Claude above — CURSOR_API_KEY/
37
+ // CURSOR_API_ENDPOINT are cursor-agent's own documented real auth env
38
+ // vars (verified via `cursor-agent -p --help`), never a guess.
39
+ const CURSOR_SAFE_ENV_KEYS = Object.freeze([
40
+ "PATH", "HOME", "USER", "LOGNAME", "SHELL", "LANG", "LC_ALL", "LC_CTYPE",
41
+ "TMPDIR", "TERM", "CURSOR_API_KEY", "CURSOR_API_ENDPOINT", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY",
42
+ "http_proxy", "https_proxy", "no_proxy", "NODE_EXTRA_CA_CERTS"
43
+ ]);
44
+ function buildCursorExecutionEnv(sourceEnv = process.env) {
45
+ const env = Object.create(null);
46
+ for (const key of CURSOR_SAFE_ENV_KEYS) {
47
+ if (sourceEnv[key] != null && sourceEnv[key] !== "") env[key] = sourceEnv[key];
48
+ }
49
+ return env;
50
+ }
51
+
36
52
  function unknown(error) {
37
53
  return { status: "error", answer: null, error: String(error) };
38
54
  }
@@ -102,6 +118,54 @@ function askClaude({ question, model, cwd, spawn, timeoutMs, env }) {
102
118
  });
103
119
  }
104
120
 
121
+ /**
122
+ * cursor-agent's own real, documented read-only mode (verified via
123
+ * `cursor-agent -p --help`): "ask: Q&A style for explanations and
124
+ * questions (read-only)" — never combined with --force/--yolo, which
125
+ * would grant real write/shell access. A real invalid-model failure
126
+ * (verified live) exits non-zero with a plain-text error on stderr, no
127
+ * JSON at all — unlike Claude's/Codex's own failure shapes, so a failed
128
+ * JSON parse here reports the real stderr text, never a generic guess.
129
+ * @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args
130
+ */
131
+ function askCursor({ question, model, cwd, spawn, timeoutMs, env }) {
132
+ const args = ["-p", question, "--mode", "ask", "--output-format", "json"];
133
+ if (model) args.push("--model", model);
134
+ return new Promise((resolve) => {
135
+ let child;
136
+ try {
137
+ child = spawn("cursor-agent", args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
138
+ } catch (error) {
139
+ resolve(unknown(error?.message ?? error));
140
+ return;
141
+ }
142
+ let stdout = "";
143
+ let stderr = "";
144
+ let finished = false;
145
+ const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`cursor-agent idle-timed out after ${timeoutMs}ms with no output`)));
146
+ function finish(result) {
147
+ if (finished) return;
148
+ finished = true;
149
+ clearIdleTimer();
150
+ try { child.kill?.(); } catch { /* best effort */ }
151
+ resolve(result);
152
+ }
153
+ child.stdout?.on("data", (chunk) => { stdout += chunk; });
154
+ child.stderr?.on("data", (chunk) => { stderr += chunk; });
155
+ child.once?.("error", (error) => finish(unknown(error?.message ?? error)));
156
+ child.once?.("close", (code) => {
157
+ let parsed;
158
+ try { parsed = JSON.parse(stdout); } catch {
159
+ return finish(unknown(stderr.trim() || `cursor-agent exited ${code} with no parseable output`));
160
+ }
161
+ if (parsed?.is_error === true || typeof parsed?.result !== "string") {
162
+ return finish(unknown(parsed?.result ?? stderr.trim() ?? "cursor-agent returned no answer"));
163
+ }
164
+ finish({ status: "answered", answer: parsed.result, error: null });
165
+ });
166
+ });
167
+ }
168
+
105
169
  /** @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args */
106
170
  async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
107
171
  let outDir;
@@ -158,10 +222,15 @@ async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
158
222
 
159
223
  /**
160
224
  * 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.
225
+ * answer text. Supports Codex, Claude, and Cursor today; any other
226
+ * provider yields an honest "unsupported" result rather than a guess.
227
+ * OpenCode (Go/Zen) is deliberately excluded — its real CLI has no
228
+ * portable, CLI-flag-driven read-only mode (verified via `opencode run
229
+ * --help`: `--auto` only ever loosens permissions further, never
230
+ * restricts them), so a real read-only call can't be guaranteed safe
231
+ * across different users' local opencode.json permission configs.
163
232
  * @param {object} args
164
- * @param {"codex"|"claude"} args.provider
233
+ * @param {"codex"|"claude"|"cursor"} args.provider
165
234
  * @param {string} args.question
166
235
  * @param {string|null} [args.model]
167
236
  * @param {string} args.cwd
@@ -171,5 +240,6 @@ export async function askProvider({
171
240
  }) {
172
241
  if (provider === "claude") return askClaude({ question, model, cwd, spawn, timeoutMs, env: buildClaudeExecutionEnv(sourceEnv) });
173
242
  if (provider === "codex") return askCodex({ question, model, cwd, spawn, timeoutMs, env: buildCodexExecutionEnv(sourceEnv) });
243
+ if (provider === "cursor") return askCursor({ question, model, cwd, spawn, timeoutMs, env: buildCursorExecutionEnv(sourceEnv) });
174
244
  return { status: "unsupported", answer: null, error: `ASK is not supported for provider "${provider}" yet.` };
175
245
  }
@@ -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.23.2" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.25.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.23.2" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.25.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });