@kal-elsam/kairo-runtime 0.16.0 → 0.18.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 +79 -0
- package/package.json +2 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/scripts/ux-smoke-test.sh +3 -3
- package/src/cli.js +96 -11
- package/src/global/agent-capabilities/create-capability-adapter.js +2 -2
- package/src/global/architect/architect-cli.js +76 -0
- package/src/global/architect/architect-codex.js +146 -0
- package/src/global/architect/architect-manager.js +125 -0
- package/src/global/architect/architect-store.js +377 -0
- package/src/global/architect/architect-types.js +47 -0
- package/src/global/cli-help.js +10 -1
- package/src/global/cockpit/app.js +493 -0
- package/src/global/cockpit/card.js +111 -0
- package/src/global/cockpit/cli.js +33 -0
- package/src/global/cockpit/gauge.js +31 -0
- package/src/global/cockpit/project-overlay.js +693 -0
- package/src/global/cockpit/rows.js +148 -0
- package/src/global/cockpit/theme.js +118 -0
- package/src/global/cockpit/view.js +1298 -0
- package/src/global/conversation/bootstrap-analyzer-adapters.js +251 -0
- package/src/global/conversation/cli.js +53 -0
- package/src/global/conversation/codex-sandbox.js +230 -0
- package/src/global/conversation/cursor-sandbox.js +215 -0
- package/src/global/conversation/project-analysis.js +204 -0
- package/src/global/conversation/project-profile.js +178 -0
- package/src/global/conversation/project-router.js +149 -0
- package/src/global/conversation/project-strategy-store.js +64 -0
- package/src/global/conversation/project-strategy.js +514 -0
- package/src/global/conversation/sanitized-snapshot.js +169 -0
- package/src/global/conversation/secret-scanner.js +71 -0
- package/src/global/conversation/service.js +1090 -0
- package/src/global/conversation/session-store.js +75 -0
- package/src/global/conversation/transcript-store.js +79 -0
- package/src/global/conversation/ui.js +195 -0
- package/src/global/intelligence/capability-scoring.js +480 -0
- package/src/global/intelligence/execution-router.js +466 -0
- package/src/global/intelligence/kairo-telemetry-source.js +59 -0
- package/src/global/intelligence/kairobench-runner.js +85 -0
- package/src/global/intelligence/kairobench-source.js +34 -0
- package/src/global/intelligence/kairobench-tasks.js +47 -0
- package/src/global/intelligence/model-candidate-catalog.js +456 -0
- package/src/global/intelligence/model-capability-registry-sources.js +145 -0
- package/src/global/intelligence/model-capability-registry.js +125 -0
- package/src/global/intelligence/model-intelligence.js +1646 -0
- package/src/global/intelligence/official-benchmark-snapshots.js +162 -0
- package/src/global/intelligence/quick-ask.js +149 -0
- package/src/global/intelligence/role-profiles.js +251 -0
- package/src/global/intelligence/skill-catalog.js +67 -0
- package/src/global/intelligence/subscription-pressure-source.js +41 -0
- package/src/global/mcp/kairo-mcp.js +51 -18
- package/src/global/mcp/work-snapshot-rule.js +4 -2
- package/src/global/mcp/workspace-binding.js +88 -0
- package/src/global/mcp/workspace-mcp-entry.js +74 -0
- package/src/global/mcp-install.js +8 -1
- package/src/global/observability/artificial-analysis-models.js +118 -0
- package/src/global/observability/claude-models.js +31 -0
- package/src/global/observability/claude-usage.js +112 -0
- package/src/global/observability/codex-models.js +96 -0
- package/src/global/observability/codex-usage.js +160 -0
- package/src/global/observability/cursor-auth.js +88 -0
- package/src/global/observability/cursor-models.js +101 -0
- package/src/global/observability/huggingface-leaderboard.js +97 -0
- package/src/global/observability/opencode-models.js +101 -0
- package/src/global/observability/opencode-usage.js +162 -0
- package/src/global/paths.js +49 -2
- package/src/global/profile.js +23 -1
- package/src/global/runtime/execution-adapters/claude.js +63 -30
- package/src/global/runtime/execution-adapters/codex.js +9 -2
- package/src/global/runtime/execution-adapters/create-execution-adapter.js +6 -1
- package/src/global/runtime/execution-adapters/opencode.js +83 -18
- package/src/global/runtime/execution-worktree-manager.js +924 -0
- package/src/global/runtime/execution-worktree-orchestrator.js +194 -0
- package/src/global/runtime/execution-worktree-store.js +83 -0
- package/src/global/runtime/execution-worktree-types.js +45 -0
- package/src/global/runtime/run-events.js +38 -0
- package/src/global/runtime/run-manager.js +22 -6
- package/src/global/runtime/run-supervisor.js +41 -12
- package/src/global/runtime/usage-manager.js +96 -0
- package/src/global/runtime/usage-store.js +69 -0
- 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
|
+
}
|