@kal-elsam/kairo-runtime 0.16.0 → 0.17.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +2 -1
  3. package/scripts/cockpit-smoke.mjs +1 -1
  4. package/scripts/ux-smoke-test.sh +3 -3
  5. package/src/cli.js +96 -11
  6. package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
  7. package/src/global/architect/architect-cli.js +76 -0
  8. package/src/global/architect/architect-codex.js +146 -0
  9. package/src/global/architect/architect-manager.js +125 -0
  10. package/src/global/architect/architect-store.js +377 -0
  11. package/src/global/architect/architect-types.js +47 -0
  12. package/src/global/cli-help.js +10 -1
  13. package/src/global/cockpit/app.js +475 -0
  14. package/src/global/cockpit/card.js +111 -0
  15. package/src/global/cockpit/cli.js +33 -0
  16. package/src/global/cockpit/gauge.js +31 -0
  17. package/src/global/cockpit/project-overlay.js +683 -0
  18. package/src/global/cockpit/rows.js +148 -0
  19. package/src/global/cockpit/theme.js +118 -0
  20. package/src/global/cockpit/view.js +1263 -0
  21. package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
  22. package/src/global/conversation/cli.js +53 -0
  23. package/src/global/conversation/codex-sandbox.js +230 -0
  24. package/src/global/conversation/cursor-sandbox.js +215 -0
  25. package/src/global/conversation/project-analysis.js +204 -0
  26. package/src/global/conversation/project-profile.js +178 -0
  27. package/src/global/conversation/project-router.js +149 -0
  28. package/src/global/conversation/project-strategy-store.js +64 -0
  29. package/src/global/conversation/project-strategy.js +514 -0
  30. package/src/global/conversation/sanitized-snapshot.js +169 -0
  31. package/src/global/conversation/secret-scanner.js +71 -0
  32. package/src/global/conversation/service.js +1063 -0
  33. package/src/global/conversation/session-store.js +75 -0
  34. package/src/global/conversation/transcript-store.js +79 -0
  35. package/src/global/conversation/ui.js +195 -0
  36. package/src/global/intelligence/capability-scoring.js +480 -0
  37. package/src/global/intelligence/execution-router.js +444 -0
  38. package/src/global/intelligence/kairo-telemetry-source.js +59 -0
  39. package/src/global/intelligence/kairobench-runner.js +85 -0
  40. package/src/global/intelligence/kairobench-source.js +34 -0
  41. package/src/global/intelligence/kairobench-tasks.js +47 -0
  42. package/src/global/intelligence/model-candidate-catalog.js +456 -0
  43. package/src/global/intelligence/model-capability-registry-sources.js +145 -0
  44. package/src/global/intelligence/model-capability-registry.js +125 -0
  45. package/src/global/intelligence/model-intelligence.js +1646 -0
  46. package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
  47. package/src/global/intelligence/quick-ask.js +149 -0
  48. package/src/global/intelligence/role-profiles.js +251 -0
  49. package/src/global/intelligence/skill-catalog.js +67 -0
  50. package/src/global/intelligence/subscription-pressure-source.js +41 -0
  51. package/src/global/mcp/kairo-mcp.js +51 -18
  52. package/src/global/mcp/work-snapshot-rule.js +4 -2
  53. package/src/global/mcp/workspace-binding.js +88 -0
  54. package/src/global/mcp/workspace-mcp-entry.js +74 -0
  55. package/src/global/mcp-install.js +8 -1
  56. package/src/global/observability/artificial-analysis-models.js +118 -0
  57. package/src/global/observability/claude-models.js +31 -0
  58. package/src/global/observability/claude-usage.js +112 -0
  59. package/src/global/observability/codex-models.js +96 -0
  60. package/src/global/observability/codex-usage.js +160 -0
  61. package/src/global/observability/cursor-auth.js +88 -0
  62. package/src/global/observability/cursor-models.js +101 -0
  63. package/src/global/observability/huggingface-leaderboard.js +97 -0
  64. package/src/global/observability/opencode-models.js +101 -0
  65. package/src/global/observability/opencode-usage.js +162 -0
  66. package/src/global/paths.js +49 -2
  67. package/src/global/profile.js +23 -1
  68. package/src/global/runtime/execution-adapters/claude.js +63 -30
  69. package/src/global/runtime/execution-adapters/codex.js +9 -2
  70. package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
  71. package/src/global/runtime/execution-adapters/opencode.js +83 -18
  72. package/src/global/runtime/execution-worktree-manager.js +924 -0
  73. package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
  74. package/src/global/runtime/execution-worktree-store.js +83 -0
  75. package/src/global/runtime/execution-worktree-types.js +45 -0
  76. package/src/global/runtime/run-events.js +38 -0
  77. package/src/global/runtime/run-manager.js +22 -6
  78. package/src/global/runtime/run-supervisor.js +41 -12
  79. package/src/global/runtime/usage-manager.js +96 -0
  80. package/src/global/runtime/usage-store.js +69 -0
  81. package/src/global/runtime/usage-types.js +62 -0
@@ -0,0 +1,75 @@
1
+ // Persists KairoSession — right now just the project's current WorkMode
2
+ // ("ask" | "plan" | "agent") plus identity/timestamps — under the same
3
+ // global `~/.harness/sessions/<projectKey>/` tree transcript-store.js
4
+ // already uses (see that file's header for why: matches Claude Code/Codex/
5
+ // OpenCode's own convention, never inside the project's own working
6
+ // directory). Deliberately a SEPARATE file from transcript.json rather than
7
+ // merging the two: messages are already durably persisted there, and this
8
+ // file only needs to exist/change when the mode itself changes — keeping
9
+ // them separate means switching modes never needs to read-modify-write the
10
+ // (potentially large) transcript, and a mode read never needs to parse it.
11
+
12
+ import { mkdir, readFile } from "node:fs/promises";
13
+ import { dirname, join } from "node:path";
14
+ import { harnessHomePaths } from "../paths.js";
15
+ import { projectKeyForPath } from "../next/project-key.js";
16
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
17
+
18
+ export const SESSION_SCHEMA = "kairo.session/v1";
19
+ export const WORK_MODES = ["ask", "plan", "agent"];
20
+ const DEFAULT_MODE = "ask";
21
+
22
+ function sessionPath(homeDir, projectRoot) {
23
+ const { sessionsDir } = harnessHomePaths(homeDir);
24
+ return join(sessionsDir, projectKeyForPath(projectRoot), "session.json");
25
+ }
26
+
27
+ function freshSession(projectRoot) {
28
+ const now = new Date().toISOString();
29
+ return { schema: SESSION_SCHEMA, id: projectKeyForPath(projectRoot), mode: DEFAULT_MODE, createdAt: now, updatedAt: now };
30
+ }
31
+
32
+ /**
33
+ * Reads the persisted KairoSession for a project. Fails closed to a fresh
34
+ * default session (mode "ask") on a missing or malformed file — this is
35
+ * also how every session that existed before WorkMode was introduced
36
+ * "migrates": it simply reads as ASK, the safest, strictly read-only
37
+ * default, the first time it's read after this shipped.
38
+ * @param {string} homeDir
39
+ * @param {string} projectRoot
40
+ * @returns {Promise<{schema: string, id: string, mode: "ask"|"plan"|"agent", createdAt: string, updatedAt: string}>}
41
+ */
42
+ export async function readSession(homeDir, projectRoot, deps = {}) {
43
+ const read = deps.readFile ?? readFile;
44
+ try {
45
+ const raw = await read(sessionPath(homeDir, projectRoot), "utf8");
46
+ const doc = JSON.parse(raw);
47
+ if (doc?.schema !== SESSION_SCHEMA || typeof doc.id !== "string" || !WORK_MODES.includes(doc.mode)) {
48
+ return freshSession(projectRoot);
49
+ }
50
+ return doc;
51
+ } catch {
52
+ return freshSession(projectRoot);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Persists a new WorkMode for this project's session — pure local state,
58
+ * never provider I/O, so switching modes (Shift+Tab, `/plan`) stays
59
+ * instant. Read-modify-write against the existing session so `id`/
60
+ * `createdAt` survive a mode change untouched.
61
+ * @param {string} homeDir
62
+ * @param {string} projectRoot
63
+ * @param {"ask"|"plan"|"agent"} mode
64
+ */
65
+ export async function writeSessionMode(homeDir, projectRoot, mode, deps = {}) {
66
+ if (!WORK_MODES.includes(mode)) throw new Error(`Unknown work mode "${mode}"`);
67
+ const mkdirImpl = deps.mkdir ?? mkdir;
68
+ const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
69
+ const existing = await readSession(homeDir, projectRoot, deps);
70
+ const path = sessionPath(homeDir, projectRoot);
71
+ const doc = { ...existing, schema: SESSION_SCHEMA, mode, updatedAt: new Date().toISOString() };
72
+ await mkdirImpl(dirname(path), { recursive: true });
73
+ await writeJson(path, doc);
74
+ return doc;
75
+ }
@@ -0,0 +1,79 @@
1
+ // Persists the `kairo start` chat transcript so it survives a restart — the
2
+ // whole reason a chat CLI is useful is that you don't lose the conversation.
3
+ //
4
+ // This lives under the global `~/.harness/sessions/<projectKey>/` tree, the
5
+ // same home Kairo already uses for run history and work snapshots — NOT
6
+ // inside the project's own working directory. Verified against Claude Code
7
+ // (`~/.claude/projects/`), Codex (`~/.codex/sessions/`), and OpenCode
8
+ // (`~/.local/share/opencode/storage`): none of them store session history
9
+ // inside the repo itself, all three key it by project path under the user's
10
+ // home. Writing this into the repo would risk it landing in git and would
11
+ // collide across separate clones of the same project.
12
+
13
+ import { mkdir, readFile } from "node:fs/promises";
14
+ import { dirname, join } from "node:path";
15
+ import { harnessHomePaths } from "../paths.js";
16
+ import { projectKeyForPath } from "../next/project-key.js";
17
+ import { writeAtomicJson } from "../runtime/write-atomic-json.js";
18
+
19
+ export const TRANSCRIPT_SCHEMA = "kairo.transcript/v1";
20
+
21
+ // A CLI session's transcript isn't meant to grow forever; bound it so the
22
+ // file (and every future read-modify-write) stays cheap, while still
23
+ // keeping far more real history than the old fixed on-screen cap of 8.
24
+ const MAX_STORED_ENTRIES = 500;
25
+
26
+ function transcriptPath(homeDir, projectRoot) {
27
+ const { sessionsDir } = harnessHomePaths(homeDir);
28
+ return join(sessionsDir, projectKeyForPath(projectRoot), "transcript.json");
29
+ }
30
+
31
+ /**
32
+ * Reads the persisted transcript for a project. Fails closed to an empty
33
+ * list on a missing or malformed file — a corrupt history file must never
34
+ * block the chat from starting.
35
+ * @param {string} homeDir
36
+ * @param {string} projectRoot
37
+ * @returns {Promise<Array<{role: "user"|"kairo", text: string, at: string}>>}
38
+ */
39
+ export async function readTranscript(homeDir, projectRoot, deps = {}) {
40
+ const read = deps.readFile ?? readFile;
41
+ try {
42
+ const raw = await read(transcriptPath(homeDir, projectRoot), "utf8");
43
+ const doc = JSON.parse(raw);
44
+ if (doc?.schema !== TRANSCRIPT_SCHEMA || !Array.isArray(doc.entries)) return [];
45
+ return doc.entries.filter((entry) => entry && typeof entry.text === "string"
46
+ && (entry.role === "user" || entry.role === "kairo"));
47
+ } catch {
48
+ return [];
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Appends one entry and persists the whole (bounded) transcript. Read-
54
+ * modify-write is fine here: this is interactive human typing speed, not a
55
+ * high-frequency log. A write failure never throws into the chat flow —
56
+ * the caller decides whether/how to surface it.
57
+ * @param {string} homeDir
58
+ * @param {string} projectRoot
59
+ * @param {{role: "user"|"kairo", text: string}} entry
60
+ */
61
+ export async function appendTranscriptEntry(homeDir, projectRoot, entry, deps = {}) {
62
+ const mkdirImpl = deps.mkdir ?? mkdir;
63
+ const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
64
+ const path = transcriptPath(homeDir, projectRoot);
65
+ const existing = await readTranscript(homeDir, projectRoot, deps);
66
+ const entries = [...existing, { role: entry.role, text: entry.text, at: new Date().toISOString() }]
67
+ .slice(-MAX_STORED_ENTRIES);
68
+ await mkdirImpl(dirname(path), { recursive: true });
69
+ await writeJson(path, { schema: TRANSCRIPT_SCHEMA, entries });
70
+ }
71
+
72
+ /** Persists an empty transcript — used by `/clear`, so a cleared chat stays cleared across a restart. */
73
+ export async function clearTranscript(homeDir, projectRoot, deps = {}) {
74
+ const mkdirImpl = deps.mkdir ?? mkdir;
75
+ const writeJson = deps.writeAtomicJson ?? writeAtomicJson;
76
+ const path = transcriptPath(homeDir, projectRoot);
77
+ await mkdirImpl(dirname(path), { recursive: true });
78
+ await writeJson(path, { schema: TRANSCRIPT_SCHEMA, entries: [] });
79
+ }
@@ -0,0 +1,195 @@
1
+ import { createServer } from "node:http";
2
+ import { randomBytes, timingSafeEqual } from "node:crypto";
3
+ import { createConversationService } from "./service.js";
4
+
5
+ const MAX_BODY_BYTES = 64 * 1024;
6
+
7
+ function equalToken(actual, expected) {
8
+ const left = Buffer.from(String(actual ?? ""));
9
+ const right = Buffer.from(String(expected));
10
+ return left.length === right.length && timingSafeEqual(left, right);
11
+ }
12
+
13
+ function sendJson(response, status, value) {
14
+ const body = JSON.stringify(value);
15
+ response.writeHead(status, {
16
+ "content-type": "application/json; charset=utf-8",
17
+ "content-length": Buffer.byteLength(body),
18
+ "cache-control": "no-store",
19
+ "x-content-type-options": "nosniff"
20
+ });
21
+ response.end(body);
22
+ }
23
+
24
+ function readJson(request) {
25
+ return new Promise((resolve, reject) => {
26
+ let body = "";
27
+ request.setEncoding("utf8");
28
+ request.on("data", (chunk) => {
29
+ body += chunk;
30
+ if (Buffer.byteLength(body) > MAX_BODY_BYTES) {
31
+ const error = new Error("Request body too large.");
32
+ error.statusCode = 413;
33
+ reject(error);
34
+ request.destroy();
35
+ }
36
+ });
37
+ request.on("end", () => {
38
+ try { resolve(body ? JSON.parse(body) : {}); }
39
+ catch { const error = new Error("Invalid JSON body."); error.statusCode = 400; reject(error); }
40
+ });
41
+ request.on("error", reject);
42
+ });
43
+ }
44
+
45
+ export function renderConversationHtml(token) {
46
+ return `<!doctype html><html lang="en"><head><meta charset="utf-8">
47
+ <meta name="viewport" content="width=device-width,initial-scale=1">
48
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'">
49
+ <title>Kairo Conversation</title><style>
50
+ :root{color-scheme:dark;font:15px system-ui;background:#111;color:#eee}body{max-width:900px;margin:0 auto;padding:24px}textarea{width:100%;min-height:90px;background:#1d1d1d;color:#fff;border:1px solid #555;padding:10px;box-sizing:border-box}button{margin:8px 8px 0 0;padding:8px 12px}.card{border:1px solid #444;border-radius:8px;padding:14px;margin:12px 0}.muted{color:#aaa}.error{color:#ff9b9b}.ok{color:#9ee6a8}pre{white-space:pre-wrap;background:#181818;padding:12px}</style></head><body>
51
+ <h1>Kairo Conversation</h1><p class="muted">Approval and execution are separate. Execution starts only after explicit confirmation.</p>
52
+ <form id="architect"><label for="task">Architecture task</label><textarea id="task" required></textarea><br><button>Create plan with Codex</button></form>
53
+ <p id="status" class="muted"></p><section id="timeline"></section>
54
+ <script>const TOKEN=${JSON.stringify(token)};
55
+ const statusEl=document.getElementById('status'), timeline=document.getElementById('timeline');
56
+ let latestSnapshot={timeline:[]};
57
+ async function api(path,options={}){const response=await fetch(path,{...options,headers:{authorization:'Bearer '+TOKEN,'content-type':'application/json',...(options.headers||{})}});const data=await response.json();if(!response.ok)throw new Error(data.error||'Request failed');return data}
58
+ function activeTeamRoles(){const strategy=latestSnapshot.projectStrategy;if(!strategy||strategy.status!=='active'||!Array.isArray(strategy.projectTeam))return[];return strategy.projectTeam.map(e=>e.role)}
59
+ async function handleExecute(id){
60
+ const roles=activeTeamRoles();
61
+ if(roles.length===0){statusEl.textContent='No active project team — run /project (or the conversation CLI) to analyze and approve one before executing.';statusEl.className='error';return}
62
+ const role=window.prompt('Which role is this task for?\\n'+roles.join(', '),roles[0]);
63
+ if(!role||!roles.includes(role)){statusEl.textContent='Execution cancelled.';return}
64
+ statusEl.textContent='Asking PROJECT TEAM who should execute this…';
65
+ let decision;
66
+ try{decision=await api('/api/plans/'+encodeURIComponent(id)+'/preview?role='+encodeURIComponent(role))}
67
+ catch(e){statusEl.textContent=e.message;statusEl.className='error';return}
68
+ if(!decision.confirmationTarget){statusEl.textContent=decision.why||'Cannot auto-execute this plan.';statusEl.className='error';return}
69
+ const confirmText=decision.decision==='ROUTED'
70
+ ? ('Execute "'+id+'" with '+decision.provider+' · '+(decision.model||'default')+'? '+decision.why)
71
+ : ('Assigned model unavailable for '+decision.role+'. Suggested alternative: '+(decision.suggestedAlternative&&decision.suggestedAlternative.provider)+' · '+(decision.suggestedAlternative&&decision.suggestedAlternative.model&&(decision.suggestedAlternative.model.displayName||decision.suggestedAlternative.model.modelId))+'. Confirm this alternative?');
72
+ if(!window.confirm(confirmText)){statusEl.textContent='Execution cancelled.';return}
73
+ statusEl.textContent='Executing…';
74
+ await api('/api/plans/'+encodeURIComponent(id)+'/execute',{method:'POST',body:JSON.stringify({confirmationTarget:decision.confirmationTarget})});
75
+ }
76
+ function button(label,action,id){const b=document.createElement('button');b.textContent=label;b.onclick=async()=>{
77
+ try{
78
+ if(action==='execute'){await handleExecute(id);await refresh();return}
79
+ statusEl.textContent=label+'…';
80
+ const options=action==='show'?{}:{method:'POST',body:'{}'};
81
+ const result=await api('/api/plans/'+encodeURIComponent(id)+'/'+action,options);
82
+ if(action==='show'){const pre=document.createElement('pre');pre.textContent=result.planMarkdown||'No plan artifact yet.';b.parentElement.appendChild(pre)}
83
+ await refresh()
84
+ }catch(e){statusEl.textContent=e.message;statusEl.className='error'}
85
+ };return b}
86
+ async function refresh(){const data=await api('/api/snapshot');latestSnapshot=data;timeline.replaceChildren();for(const plan of data.timeline){const card=document.createElement('article');card.className='card';const title=document.createElement('strong');title.textContent=plan.taskId+' · '+plan.state;card.append(title,document.createElement('br'));const note=document.createElement('span');note.className='muted';note.textContent=plan.execution.message;card.append(note,document.createElement('br'));if(plan.planReady)card.append(button('Open plan','show',plan.taskId));if(plan.state==='awaiting_approval'){card.append(button('Approve','approve',plan.taskId),button('Reject','reject',plan.taskId))}if(plan.state==='approved'&&plan.execution.state==='not_started')card.append(button('Execute','execute',plan.taskId));if(plan.execution.active)card.append(button('Cancel run','cancel',plan.taskId));timeline.append(card)}statusEl.textContent='';statusEl.className='muted'}
87
+ document.getElementById('architect').onsubmit=async(event)=>{event.preventDefault();const task=document.getElementById('task').value.trim();if(!task)return;statusEl.textContent='Codex is planning in read-only mode…';try{await api('/api/architect',{method:'POST',body:JSON.stringify({task})});document.getElementById('task').value='';await refresh()}catch(e){statusEl.textContent=e.message;statusEl.className='error'}};refresh().catch(e=>{statusEl.textContent=e.message;statusEl.className='error'});setInterval(()=>refresh().catch(()=>{}),2000);</script></body></html>`;
88
+ }
89
+
90
+ function requestAllowed(request, port, token, url) {
91
+ const allowedHosts = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
92
+ if (!allowedHosts.has(request.headers.host)) return { ok: false, status: 403, error: "Invalid Host header." };
93
+ const allowedOrigins = new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`]);
94
+ if (request.headers.origin && !allowedOrigins.has(request.headers.origin)) {
95
+ return { ok: false, status: 403, error: "Invalid Origin header." };
96
+ }
97
+ const bearer = String(request.headers.authorization ?? "").replace(/^Bearer\s+/i, "");
98
+ const supplied = url.pathname === "/" ? url.searchParams.get("token") : bearer;
99
+ if (!equalToken(supplied, token)) return { ok: false, status: 401, error: "Invalid UI token." };
100
+ return { ok: true };
101
+ }
102
+
103
+ const GET_PLAN_ACTIONS = new Set(["show", "preview"]);
104
+
105
+ function routeFor(method, pathname) {
106
+ if (method === "GET" && pathname === "/") return { action: "html" };
107
+ if (method === "GET" && pathname === "/api/snapshot") return { action: "snapshot" };
108
+ if (method === "POST" && pathname === "/api/architect") return { action: "architect" };
109
+ const match = pathname.match(/^\/api\/plans\/([a-z0-9][a-z0-9-]{0,95})\/(show|preview|approve|reject|execute|cancel)$/);
110
+ if (!match) return null;
111
+ if (GET_PLAN_ACTIONS.has(match[2]) !== (method === "GET")) return null;
112
+ return { action: match[2], taskId: match[1] };
113
+ }
114
+
115
+ export async function startLocalConversationUi({ cwd, port = 0, token, service } = {}) {
116
+ const authToken = token ?? randomBytes(24).toString("hex");
117
+ const app = service ?? createConversationService();
118
+ let boundPort = port;
119
+ const server = createServer(async (request, response) => {
120
+ const url = new URL(request.url ?? "/", `http://127.0.0.1:${boundPort}`);
121
+ const gate = requestAllowed(request, boundPort, authToken, url);
122
+ if (!gate.ok) return sendJson(response, gate.status, { error: gate.error });
123
+ const route = routeFor(request.method, url.pathname);
124
+ if (!route) return sendJson(response, 404, { error: "Unknown endpoint." });
125
+ try {
126
+ if (route.action === "html") {
127
+ const html = renderConversationHtml(authToken);
128
+ response.writeHead(200, {
129
+ "content-type": "text/html; charset=utf-8", "cache-control": "no-store",
130
+ "x-frame-options": "DENY", "x-content-type-options": "nosniff"
131
+ });
132
+ response.end(html);
133
+ } else if (route.action === "snapshot") sendJson(response, 200, await app.snapshot({ cwd }));
134
+ else if (route.action === "architect") {
135
+ const body = await readJson(request);
136
+ if (typeof body.task !== "string" || !body.task.trim() || body.task.length > 20_000) {
137
+ return sendJson(response, 400, { error: "Task must be a non-empty string up to 20000 characters." });
138
+ }
139
+ sendJson(response, 200, await app.submitArchitecture({ cwd, task: body.task, model: body.model ?? null }));
140
+ } else if (route.action === "show") sendJson(response, 200, await app.showPlan({ cwd, taskId: route.taskId }));
141
+ else if (route.action === "preview") {
142
+ // A real ProjectExecutionPreview — read-only, same as
143
+ // service.planExecution() itself: never reserves quota or starts
144
+ // a run. The browser fetches this before ever asking to confirm.
145
+ // PROJECT TEAM is the sole authority for execution — role is
146
+ // required; an omitted/empty one surfaces service.js's own clear
147
+ // "requires an explicit role" error as a 400, never silently
148
+ // falls back to guessing from the task's text.
149
+ const role = url.searchParams.get("role");
150
+ sendJson(response, 200, await app.planExecution({ cwd, taskId: route.taskId, role }));
151
+ } else if (route.action === "approve" || route.action === "reject") {
152
+ sendJson(response, 200, await app.decidePlan({
153
+ cwd, taskId: route.taskId, decision: route.action === "approve" ? "approved" : "rejected"
154
+ }));
155
+ } else if (route.action === "execute") {
156
+ // The browser only ever sends a `confirmationTarget` it just
157
+ // fetched from /preview — never a free-form provider/model choice.
158
+ // executePlan revalidates that target against a freshly recomputed
159
+ // route before reserving anything, and now requires it outright
160
+ // (see service.js's own doc) — a missing or falsy confirmationTarget
161
+ // (e.g. a caller hand-crafting the request, or forwarding a
162
+ // blocked preview's null target) surfaces service.js's own clear
163
+ // error as a 400, never silently substitutes anything.
164
+ const body = await readJson(request);
165
+ sendJson(response, 202, await app.executePlan({ cwd, taskId: route.taskId, confirmationTarget: body?.confirmationTarget }));
166
+ } else sendJson(response, 200, await app.cancelExecution({ cwd, taskId: route.taskId }));
167
+ } catch (error) {
168
+ if (!response.headersSent) sendJson(response, error.statusCode ?? 400, { error: error.message ?? String(error) });
169
+ }
170
+ });
171
+ await new Promise((resolve, reject) => {
172
+ server.once("error", reject);
173
+ server.listen(port, "127.0.0.1", resolve);
174
+ });
175
+ boundPort = server.address().port;
176
+ return {
177
+ server,
178
+ token: authToken,
179
+ port: boundPort,
180
+ url: `http://127.0.0.1:${boundPort}/?token=${authToken}`,
181
+ close: () => new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()))
182
+ };
183
+ }
184
+
185
+ export async function runUiCli(options, deps = {}) {
186
+ const ui = await (deps.start ?? startLocalConversationUi)({ cwd: options.cwd, port: options.port ?? 0 });
187
+ console.log(`Kairo UI: ${ui.url}`);
188
+ if (deps.waitForShutdown) return deps.waitForShutdown(ui);
189
+ await new Promise((resolve) => {
190
+ const stop = () => { process.off("SIGINT", stop); process.off("SIGTERM", stop); resolve(); };
191
+ process.on("SIGINT", stop);
192
+ process.on("SIGTERM", stop);
193
+ });
194
+ await ui.close();
195
+ }