@kal-elsam/kairo-runtime 0.23.1 → 0.24.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,54 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.24.0 — 2026-09-18 (Kairo Runtime)
9
+
10
+ Minor release. ASK mode joins PROJECT TEAM.
11
+
12
+ ### Changed
13
+
14
+ - ASK mode now checks PROJECT TEAM's Explorer role first (read-only
15
+ investigation is exactly Explorer's job), via the same routing real
16
+ execution already uses — instead of always going through a fully
17
+ separate, generic quota/effort heuristic. Falls back to that
18
+ heuristic only when there's no active team yet, or Explorer's real
19
+ assignment isn't one ASK can actually invoke (Cursor/OpenCode
20
+ Go/Zen aren't ask-capable yet). Role is always Explorer, never
21
+ inferred from the question's text.
22
+
23
+ ### Fixed
24
+
25
+ - A flaky timing test in `quick-ask.test.js` (from 0.23.2) used
26
+ margins too tight to reliably survive full-suite load; widened them.
27
+
28
+ Not yet in this release: PLAN mode routing through the Orchestrator
29
+ role (plan creation is currently structurally Codex-only, a bigger
30
+ change than a routing choice), and AGENT mode (an open product
31
+ question on whether it should skip the plan→approve gate).
32
+
33
+ ## 0.23.2 — 2026-09-18 (Kairo Runtime)
34
+
35
+ Patch release.
36
+
37
+ ### Fixed
38
+
39
+ - Routing eligibility (`checkCandidate`, shared by real execution
40
+ routing, ASK routing, and the FIT widget) and ASK's own ordering
41
+ only ever read the primary (5h) quota window — a provider whose
42
+ weekly window was nearly exhausted still got picked first and was
43
+ never excluded, as long as its 5h window looked healthy. Now takes
44
+ the worse of the two windows everywhere, fail-closed, matching the
45
+ same principle already applied to OpenCode Go's own windows.
46
+ - `askCodex`/`askClaude` (the ASK-mode question path) used a fixed 30s
47
+ deadline from process start. Codex's ASK path always uses the
48
+ provider's single default model regardless of question complexity
49
+ (no effort-based tiering for Codex today), so a heavier default
50
+ model plus a cold sandboxed `codex exec` start can genuinely exceed
51
+ 30s with real quota to spare — a real, slow answer, not a hang. Both
52
+ ask calls now reset their timeout on every real stdout/stderr chunk
53
+ instead, never an absolute one, so only a genuine hang still times
54
+ out.
55
+
8
56
  ## 0.23.1 — 2026-09-18 (Kairo Runtime)
9
57
 
10
58
  Patch release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.23.1",
3
+ "version": "0.24.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",
@@ -23,8 +23,12 @@ import { computeRoleEvaluations } from "../intelligence/capability-scoring.js";
23
23
  // The Bootstrap Analyst investigates read-only via askProvider
24
24
  // (intelligence/quick-ask.js), which only actually supports these two
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.
31
+ export const ASK_SUPPORTED_ADAPTERS = new Set(["codex", "claude"]);
28
32
 
29
33
  /**
30
34
  * 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;
@@ -118,9 +118,21 @@ function findAdapter(adapterId, adapters) {
118
118
  return adapters.find((adapter) => adapter.id === baseId) ?? null;
119
119
  }
120
120
 
121
- /** @param {object|null} usageEntry - a codex/claude usage-probe result (primary/secondary windows) */
121
+ /**
122
+ * The WORST remaining headroom across both real windows (5h "primary" and
123
+ * weekly "secondary") — never just the primary one. A provider whose
124
+ * weekly quota is nearly gone must be treated that way everywhere
125
+ * (exclusion AND ordering) even while its 5h window still looks healthy;
126
+ * otherwise Kairo keeps routing to it, burning through the one budget
127
+ * that's actually about to run out. Mirrors the same fail-closed
128
+ * principle checkCandidate already applies to OpenCode Go's windows (any
129
+ * one window being real trouble is real trouble, full stop).
130
+ * @param {object|null} usageEntry - a codex/claude usage-probe result (primary/secondary windows)
131
+ */
122
132
  function remainingPercent(usageEntry) {
123
- return usageEntry?.primary?.remainingPercent ?? null;
133
+ const known = [usageEntry?.primary?.remainingPercent, usageEntry?.secondary?.remainingPercent]
134
+ .filter((value) => typeof value === "number");
135
+ return known.length > 0 ? Math.min(...known) : null;
124
136
  }
125
137
 
126
138
  // Below this real remaining-quota percentage, a provider is treated as
@@ -37,6 +37,32 @@ function unknown(error) {
37
37
  return { status: "error", answer: null, error: String(error) };
38
38
  }
39
39
 
40
+ /**
41
+ * Resets on every real stdout/stderr chunk from the child, never an
42
+ * absolute deadline from process start — the same real distinction
43
+ * execution-adapters/opencode.js's own idle timeout draws: a real, live
44
+ * answer that's just taking a while (a heavier reasoning model, a cold
45
+ * sandbox start) must never be killed for merely being slow, only a
46
+ * process that's produced nothing at all for `timeoutMs` really looks
47
+ * hung. A single-shot ask call, so this stays local rather than reusing
48
+ * run-supervisor.js's own detached-run mechanism.
49
+ * @param {import("node:child_process").ChildProcess} child
50
+ * @param {number} timeoutMs
51
+ * @param {() => void} onIdle
52
+ * @returns {() => void} call to clear the timer once the call finishes
53
+ */
54
+ function armIdleTimeout(child, timeoutMs, onIdle) {
55
+ let handle = null;
56
+ const reset = () => {
57
+ if (handle) clearTimeout(handle);
58
+ handle = setTimeout(onIdle, timeoutMs);
59
+ };
60
+ child.stdout?.on("data", reset);
61
+ child.stderr?.on("data", reset);
62
+ reset();
63
+ return () => { if (handle) clearTimeout(handle); };
64
+ }
65
+
40
66
  /** @param {{question:string, model:string|null, cwd:string, spawn:Function, timeoutMs:number, env:object}} args */
41
67
  function askClaude({ question, model, cwd, spawn, timeoutMs, env }) {
42
68
  // --restricted: removes Bash/code-execution tools and WebFetch, ignores
@@ -57,11 +83,11 @@ function askClaude({ question, model, cwd, spawn, timeoutMs, env }) {
57
83
  }
58
84
  let stdout = "";
59
85
  let finished = false;
60
- const timer = setTimeout(() => finish(unknown("claude -p timed out")), timeoutMs);
86
+ const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`claude -p idle-timed out after ${timeoutMs}ms with no output`)));
61
87
  function finish(result) {
62
88
  if (finished) return;
63
89
  finished = true;
64
- clearTimeout(timer);
90
+ clearIdleTimer();
65
91
  try { child.kill?.(); } catch { /* best effort */ }
66
92
  resolve(result);
67
93
  }
@@ -106,11 +132,11 @@ async function askCodex({ question, model, cwd, spawn, timeoutMs, env }) {
106
132
  return;
107
133
  }
108
134
  let finished = false;
109
- const timer = setTimeout(() => finish(unknown("codex exec timed out")), timeoutMs);
135
+ const clearIdleTimer = armIdleTimeout(child, timeoutMs, () => finish(unknown(`codex exec idle-timed out after ${timeoutMs}ms with no output`)));
110
136
  function finish(result) {
111
137
  if (finished) return;
112
138
  finished = true;
113
- clearTimeout(timer);
139
+ clearIdleTimer();
114
140
  try { child.kill?.(); } catch { /* best effort */ }
115
141
  resolve(result);
116
142
  }
@@ -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.1" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.24.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.1" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.24.0" },
155
155
  capabilities: {}
156
156
  });
157
157
  });