@kal-elsam/kairo-runtime 0.22.1 → 0.23.1

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,44 @@ Historical entries below may reference the legacy `@kal-elsam/harness` package n
5
5
 
6
6
  ## Unreleased
7
7
 
8
+ ## 0.23.1 — 2026-09-18 (Kairo Runtime)
9
+
10
+ Patch release.
11
+
12
+ ### Fixed
13
+
14
+ - A plain chat question's action label and any failure message said
15
+ generic "Asking Kairo" throughout the wait, even though one specific
16
+ real adapter (Codex or Claude) is what's actually being asked and
17
+ can actually fail — Kairo is the system, never the one answering.
18
+ Adds `service.planAsk`, a read-only preview of ASK mode's routing
19
+ decision (reusing the same cached probes `submitTask` already uses),
20
+ so the cockpit now names the real provider/model from the start of
21
+ the wait instead of only leaking it incidentally through a raw error
22
+ message on failure.
23
+
24
+ ## 0.23.0 — 2026-09-18 (Kairo Runtime)
25
+
26
+ Minor release. First slice of PROJECT TEAM's automatic model-fallback
27
+ cascade.
28
+
29
+ ### Changed
30
+
31
+ - When a role's assigned model is unavailable but a real, currently-
32
+ eligible fallback exists, the cockpit no longer stops for an extra
33
+ y/n confirm on the substitution. The human already asked for this
34
+ task to execute now (picked the role, pressed execute) — Kairo runs
35
+ the real fallback automatically and narrates the substitution into
36
+ the transcript, so it stays visible without a confirmation prompt.
37
+ ROUTED, MANUAL_HANDOFF, and WAIT_FOR_PROJECT_TEAM with no real
38
+ alternative are all unchanged — still explicit confirm steps.
39
+
40
+ Not yet in this release: showing which model is currently running for
41
+ a role directly in the PROJECT TEAM view, auto-reverting to the
42
+ originally-assigned model once it becomes eligible again, and
43
+ retrying a run that starts on the assigned model and then fails or
44
+ hangs mid-execution.
45
+
8
46
  ## 0.22.1 — 2026-09-18 (Kairo Runtime)
9
47
 
10
48
  Patch release.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.22.1",
3
+ "version": "0.23.1",
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",
@@ -189,6 +189,24 @@ export async function runCockpitApp({
189
189
  // handleListInput — 'x' never calls this without one).
190
190
  return runAction("Asking PROJECT TEAM who should execute this", async () => {
191
191
  const decision = await service.planExecution({ cwd, taskId, role });
192
+ // WAIT_FOR_PROJECT_TEAM with a real suggested alternative: the
193
+ // human already explicitly asked for this task to execute now
194
+ // (picked the role, pressed execute) — the assigned model just
195
+ // isn't the one that got them there. Kairo runs the real,
196
+ // currently-eligible fallback automatically, with zero extra
197
+ // y/n gate, and narrates the substitution into the transcript
198
+ // so it stays visible even without a confirm prompt. Every
199
+ // other decision (ROUTED, MANUAL_HANDOFF, or WAIT_FOR_PROJECT_TEAM
200
+ // with no real alternative) is unchanged — still an explicit
201
+ // confirm step.
202
+ if (decision.decision === "WAIT_FOR_PROJECT_TEAM" && decision.confirmationTarget?.selection === "suggested-alternative") {
203
+ const blockedLabel = decision.blockedAssignment?.model?.displayName ?? decision.blockedAssignment?.model?.modelId ?? decision.blockedAssignment?.provider ?? "the assigned model";
204
+ const alt = decision.suggestedAlternative;
205
+ const altLabel = alt?.model?.displayName ?? alt?.model?.modelId ?? "unknown model";
206
+ pushTranscript("kairo", `${blockedLabel} is unavailable for ${decision.role} — automatically falling back to ${alt?.provider} · ${altLabel}.`);
207
+ await service.executePlan({ cwd, taskId, confirmationTarget: decision.confirmationTarget });
208
+ return;
209
+ }
192
210
  view.showExecuteConfirm(taskId, decision);
193
211
  // MANUAL_HANDOFF: Kairo can't launch this itself, so the real
194
212
  // task text (the exact same one an automatic run would get —
@@ -230,7 +248,7 @@ export async function runCockpitApp({
230
248
  });
231
249
  }
232
250
 
233
- editor.onSubmit = (text) => {
251
+ editor.onSubmit = async (text) => {
234
252
  const task = text.trim();
235
253
  if (!task) return;
236
254
  if (task.startsWith("/")) {
@@ -417,7 +435,38 @@ export async function runCockpitApp({
417
435
  // PLAN/AGENT always create a plan (see service.submitTask) — replacing
418
436
  // the old isLikelyQuestion guess with what the user explicitly told
419
437
  // Kairo they're doing (Shift+Tab / /plan).
420
- return runAction("Asking Kairo", async () => {
438
+ //
439
+ // Kairo is the system, never the thing actually answering — a real
440
+ // adapter (Codex, Claude, …) always is, and that real name should be
441
+ // visible from the moment the wait starts, not just in a successful
442
+ // result or leaked incidentally through an error message. PLAN/AGENT
443
+ // is deterministically Codex (see submitArchitecture/createPlan), so
444
+ // that label needs no extra call; ASK's real provider depends on
445
+ // current quota/eligibility, so a cheap planAsk preview (the same
446
+ // routing submitTask itself will use, its probes cached — see
447
+ // service.js's planAsk doc) resolves it first. A caller that predates
448
+ // planAsk (or a preview that itself fails) falls back to the old
449
+ // generic label rather than ever blocking the real submit on it.
450
+ const mode = view.workMode;
451
+ let askLabel = "Asking Kairo";
452
+ if (mode === "plan" || mode === "agent") {
453
+ askLabel = "Asking Codex to plan";
454
+ } else {
455
+ try {
456
+ const preview = await service.planAsk?.({ cwd, task });
457
+ if (preview?.decision?.decision === "ROUTED") {
458
+ const provider = preview.decision.provider;
459
+ const providerLabel = provider ? provider.charAt(0).toUpperCase() + provider.slice(1) : null;
460
+ if (providerLabel) {
461
+ askLabel = `Asking ${providerLabel}${preview.decision.model ? ` · ${preview.decision.model}` : ""}`;
462
+ }
463
+ }
464
+ } catch {
465
+ // Best-effort label only — a real routing failure still surfaces
466
+ // through submitTask itself below, never swallowed here.
467
+ }
468
+ }
469
+ return runAction(askLabel, async () => {
421
470
  const result = await service.submitTask({ cwd, task, mode: view.workMode });
422
471
  if (result.kind === "answer") {
423
472
  pushTranscript("kairo", `${result.provider}${result.model ? ` · ${result.model}` : ""}: ${result.answer}`);
@@ -86,9 +86,13 @@ function blocked(role, strategyFingerprint, why, { blockedAssignment = null, sug
86
86
  * `suggestedAlternative` set to the strategy's own PERSISTED fallback
87
87
  * for this role (see project-strategy.js) when that fallback is
88
88
  * itself currently real-eligible and automatically executable —
89
- * otherwise honestly null. Never a silent automatic substitution: the
90
- * caller must still explicitly confirm before anything runs on the
91
- * suggested alternative.
89
+ * otherwise honestly null. This module never launches anything itself
90
+ * it only ever decides what WOULD run but by explicit, deliberate
91
+ * product decision the cockpit caller (app.js's onRequestExecute) does
92
+ * treat a real suggestedAlternative as automatically executable once
93
+ * the human has already asked for this task to run, narrating the
94
+ * substitution into the transcript instead of gating it behind an
95
+ * extra y/n prompt.
92
96
  * 5. Otherwise -> ROUTED, with the real provider/model/assignmentSource
93
97
  * this role delegates to.
94
98
  * @param {object} args
@@ -651,12 +651,16 @@ export function createConversationService(deps = {}) {
651
651
  return { ...publicPlan(result.status), reused: result.reused === true, projectRoot };
652
652
  },
653
653
  /**
654
- * Real read-only question -> real answer, via whichever provider is
655
- * actually available/quota-healthy no task, no plan, no approval
656
- * gate. Throws (never returns a fabricated answer) if no provider can
657
- * answer or the call itself fails.
654
+ * Read-only preview of who ASK mode would actually ask right now —
655
+ * never calls a provider. Lets a caller (the cockpit's action label)
656
+ * show the real provider/model BEFORE the potentially slow real call
657
+ * starts, instead of a generic "Asking Kairo" that stays true no
658
+ * matter which real provider ends up answering (or timing out).
659
+ * Cheap to call again right after: the same underlying usage/catalog
660
+ * probes askQuestion itself uses are cached (see readCodexUsageCached
661
+ * etc.), so there's no real duplicate provider I/O.
658
662
  */
659
- async askQuestion({ cwd, task }) {
663
+ async planAsk({ cwd, task }) {
660
664
  const projectRoot = await root(cwd);
661
665
  const adapters = inspectAdapters({ cwd: projectRoot });
662
666
  let codexUsage = null;
@@ -672,6 +676,16 @@ export function createConversationService(deps = {}) {
672
676
  claudeCatalog = readClaudeModelsImpl();
673
677
  }
674
678
  const decision = routeAsk({ adapters, codexUsage, claudeUsage, catalogs: { codex: codexCatalog, claude: claudeCatalog }, taskText: task });
679
+ return { decision, projectRoot };
680
+ },
681
+ /**
682
+ * Real read-only question -> real answer, via whichever provider is
683
+ * actually available/quota-healthy — no task, no plan, no approval
684
+ * gate. Throws (never returns a fabricated answer) if no provider can
685
+ * answer or the call itself fails.
686
+ */
687
+ async askQuestion({ cwd, task }) {
688
+ const { decision, projectRoot } = await this.planAsk({ cwd, task });
675
689
  if (decision.decision !== "ROUTED") throw new Error(`Cannot answer: ${decision.why}`);
676
690
  const result = await askProviderImpl({ provider: decision.provider, question: task, model: decision.model, cwd: projectRoot });
677
691
  if (result.status !== "answered") throw new Error(result.error ?? `${decision.provider} gave no answer.`);
@@ -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.22.1" },
92
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.23.1" },
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.22.1" },
154
+ clientInfo: { name: "kairo", title: "Kairo", version: "0.23.1" },
155
155
  capabilities: {}
156
156
  });
157
157
  });