@kal-elsam/kairo-runtime 0.7.0 → 0.9.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 (42) hide show
  1. package/README.md +43 -11
  2. package/global-template/components/catalog.json +4 -1
  3. package/global-template/components/orchestrator/extensions/pi/kairo-minion.js +604 -0
  4. package/package.json +1 -1
  5. package/scripts/cockpit-smoke.mjs +4 -4
  6. package/src/cli.js +34 -2
  7. package/src/global/adapters/pi.js +1 -1
  8. package/src/global/global-doctor.js +2 -0
  9. package/src/global/ink/cockpit/primitives.js +96 -31
  10. package/src/global/ink/cockpit-alerts.js +36 -0
  11. package/src/global/ink/cockpit-changes.js +59 -35
  12. package/src/global/ink/cockpit-control-center.js +129 -51
  13. package/src/global/ink/cockpit-controller.js +58 -11
  14. package/src/global/ink/cockpit-focus.js +4 -2
  15. package/src/global/ink/cockpit-models.js +89 -47
  16. package/src/global/ink/cockpit-palette.js +98 -0
  17. package/src/global/ink/cockpit-path-label.js +19 -0
  18. package/src/global/ink/cockpit-recovery.js +78 -15
  19. package/src/global/ink/cockpit-reviews.js +14 -10
  20. package/src/global/ink/cockpit-runs.js +13 -4
  21. package/src/global/ink/cockpit-settings.js +194 -0
  22. package/src/global/ink/cockpit-views.js +88 -57
  23. package/src/global/ink/orchestrator-app.js +137 -46
  24. package/src/global/ink/orchestrator-state.js +24 -14
  25. package/src/global/ink/use-orchestrator-data.js +58 -0
  26. package/src/global/paths.js +3 -0
  27. package/src/global/runtime/alerts/alert-store.js +216 -0
  28. package/src/global/runtime/alerts/alert-types.js +59 -0
  29. package/src/global/runtime/alerts/alert-validate.js +117 -0
  30. package/src/global/runtime/execution-adapters/pi.js +12 -2
  31. package/src/global/runtime/monitor/monitor-cli.js +62 -0
  32. package/src/global/runtime/monitor/monitor-platform.js +95 -0
  33. package/src/global/runtime/monitor/monitor.js +249 -0
  34. package/src/global/runtime/orchestration/index.js +23 -0
  35. package/src/global/runtime/orchestration/orch-receipts.js +234 -0
  36. package/src/global/runtime/orchestration/orch-types.js +173 -0
  37. package/src/global/runtime/orchestration/orch-validate.js +63 -0
  38. package/src/global/runtime/run-cli.js +1 -0
  39. package/src/global/runtime/run-manager.js +59 -4
  40. package/src/global/runtime/run-strategy.js +71 -0
  41. package/src/global/runtime/run-supervisor.js +22 -2
  42. package/src/global/runtime/run-types.js +5 -1
package/README.md CHANGED
@@ -35,23 +35,43 @@ kairo
35
35
  **First run** (no `~/.harness/state.json`): interactive onboarding → safe diagnosis →
36
36
  setup with confirmation → full-screen operations cockpit.
37
37
 
38
- **Later runs** (state present): full-screen Control Center that explains what Kairo
39
- does, shows coverage/integrity, evidence-backed proposals, and the next governance
40
- action (scan → evidence → preview → confirm → apply → re-scan → recovery). Runs stay
41
- secondary. Layout adapts to terminal size:
38
+ **Later runs** (state present): full-screen Control Center oriented around user
39
+ tasks — overall health, one next action, activity, pending alerts, and guided
40
+ governance (scan → evidence → preview → confirm → apply → re-scan → recovery).
41
+ Runs stay secondary. Layout adapts to terminal size:
42
42
 
43
43
  | Mode | Size | Layout |
44
44
  |------|------|--------|
45
- | Wide | ≥100 cols × ≥28 rows | Nav + Home/content + system |
46
- | Compact | ≥72×20 | Nav + Home/content (readiness embedded) |
47
- | Minimal | 60–71 cols or short height | Selected section + essential readiness/next/recent |
45
+ | Wide | ≥100 cols × ≥28 rows | TopBar + nav strip + main panel + footer |
46
+ | Compact | ≥72×20 | Same single-panel shell (tighter lists) |
47
+ | Minimal | 60–71 cols or short height | Selected section + essential readiness/next |
48
48
  | Below gate | <60 cols | Explicit TTY fallback (Ink disabled) |
49
49
 
50
- Keys: `↑↓` navigate (selection explanation updates) · `Enter` open ·
51
- `Esc` back (exit only from Home) · `R` refresh/retry · `C` cancel run · `?` help.
52
- `Tab` switches region only when content is interactive (run lists / New run).
50
+ Keys: `↑↓` navigate · `Enter` open/activate · `/` actions palette ·
51
+ `Esc` back (exit only from Overview) · `R` refresh/retry · `C` cancel run ·
52
+ `?` help. `Tab` switches region when content is interactive (runs, alerts,
53
+ Activity, Settings, launch).
53
54
 
54
- Navigation labels: Home · Running now · History · Agents · New run · System health.
55
+ Navigation: Overview · Governance · Activity · Orchestration · Usage · Settings.
56
+
57
+ ### Monitor (opt-in)
58
+
59
+ ```bash
60
+ kairo monitor enable
61
+ kairo monitor status
62
+ kairo monitor tick
63
+ kairo monitor disable
64
+ ```
65
+
66
+ Scans governance drift and orphan/failed runs into the alert store. On macOS,
67
+ `enable` can install a LaunchAgent; other OS degrade honestly. Notifications fire
68
+ only when a new alert claim succeeds.
69
+
70
+ ### Settings (curated integrations)
71
+
72
+ Settings browses a curated catalog (pinned `pi-usage-widget@0.2.1`, MIT).
73
+ Preview → confirm shows an in-session confirmation receipt (`wroteFiles: false`);
74
+ it does not persist or install anything.
55
75
 
56
76
  Respects `NO_COLOR`, `HARNESS_ASCII=1`, and `HARNESS_INK=0`. Status is always labeled
57
77
  in text, never color alone.
@@ -301,6 +321,18 @@ Launches `pi --mode json --no-session` (optional `--model`). `read-only` maps to
301
321
  to `--approve`). Custom `PI_CODING_AGENT_DIR` blocks config writes in 0.6.0 but does
302
322
  not block runtime. Kairo does not install Pi or assert subscription/entitlement.
303
323
 
324
+ ### Orchestrated Pi (Context Orchestration)
325
+
326
+ ```bash
327
+ kairo run --agent pi --strategy orchestrated --task "..."
328
+ ```
329
+
330
+ Loads the managed minion extension, injects `KAIRO_ORCH_*`, and persists a depth≤1
331
+ DAG under `~/.harness/runs/<rootRunId>/orchestration/state.json`. Normal completion
332
+ seals write-once `receipt.json`; interrupt recovery seals `recovered:true`. Limits:
333
+ concurrency 2, max attempts 2, context compact at 70% / stop at 90%, cascade cancel
334
+ on parent abort. No same-root resume. Direct `--strategy direct` (default) is unchanged.
335
+
304
336
  ### Bounded review (Codex / Pi)
305
337
 
306
338
  Read-only native review against a Git snapshot. Never mutates the repo, never
@@ -13,7 +13,10 @@
13
13
  { "id": "assets", "type": "assets" },
14
14
  { "id": "drift", "type": "drift" }
15
15
  ],
16
- "assetFiles": ["orchestrator.md"],
16
+ "assetFiles": [
17
+ "orchestrator.md",
18
+ "extensions/pi/kairo-minion.js"
19
+ ],
17
20
  "adapterHints": {}
18
21
  },
19
22
  {
@@ -0,0 +1,604 @@
1
+ /**
2
+ * Kairo Pi extension under ~/.harness (explicit --extension load only).
3
+ * Parent: kairo_delegate + cascade cancel. Child: path + budget guards.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { mkdtemp, readFile, realpath, writeFile, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { isAbsolute, join, resolve, sep } from "node:path";
9
+ import { pathToFileURL, fileURLToPath } from "node:url";
10
+
11
+ export const MINION_CONCURRENCY = 2;
12
+ export const MINION_ABORT_GRACE_MS = 5_000;
13
+ export const MINION_TOOLS = "read,grep,find,ls";
14
+ export const MAX_TASK_ATTEMPTS = 2;
15
+ export const BUDGET_COMPACT_RATIO = 0.7;
16
+ export const BUDGET_STOP_RATIO = 0.9;
17
+ export const PATH_DENIED = "KAIRO_PATH_DENIED";
18
+ export const BUDGET_EXCEEDED = "budget_exceeded";
19
+ export const GUARDED_TOOLS = new Set(["read", "grep", "find", "ls"]);
20
+ export const GENERIC_MINION_TASK =
21
+ "Read brief JSON at KAIRO_MINION_BRIEF. Return JSON only: "
22
+ + "{\"taskId\",\"summary\",\"decisions\",\"files\",\"risks\",\"evidence\",\"usage\",\"compact\"}.";
23
+
24
+ export function resolveSelfExtensionPath() {
25
+ return fileURLToPath(import.meta.url);
26
+ }
27
+
28
+ export function isChildMinionMode(env = process.env) {
29
+ return typeof env.KAIRO_MINION_BRIEF === "string" && env.KAIRO_MINION_BRIEF.trim() !== "";
30
+ }
31
+
32
+ export function minionStatusPath(briefPath) {
33
+ return `${briefPath}.status.json`;
34
+ }
35
+
36
+ /** Cancelable concurrency: cancel rejects queued work and blocks new runs. */
37
+ export function createConcurrencyGate(limit = MINION_CONCURRENCY) {
38
+ let inFlight = 0;
39
+ const queue = [];
40
+ let cancelled = false;
41
+ const abortErr = () => Object.assign(new Error("Minion cancelled."), { code: "aborted" });
42
+ return {
43
+ get cancelled() { return cancelled; },
44
+ get active() { return inFlight; },
45
+ get queued() { return queue.length; },
46
+ cancel() {
47
+ cancelled = true;
48
+ while (queue.length) queue.shift().reject(abortErr());
49
+ },
50
+ async run(fn) {
51
+ if (cancelled) throw abortErr();
52
+ if (inFlight >= limit) {
53
+ await new Promise((resolve, reject) => queue.push({ resolve, reject }));
54
+ if (cancelled) throw abortErr();
55
+ }
56
+ inFlight += 1;
57
+ try { return await fn(); }
58
+ finally {
59
+ inFlight -= 1;
60
+ queue.shift()?.resolve();
61
+ }
62
+ }
63
+ };
64
+ }
65
+
66
+ export function createProcessRegistry({ abortGraceMs = MINION_ABORT_GRACE_MS } = {}) {
67
+ const active = new Set();
68
+ let cancelled = false;
69
+ return {
70
+ get cancelled() { return cancelled; },
71
+ get size() { return active.size; },
72
+ track(child) {
73
+ if (cancelled) {
74
+ safeKill(child, "SIGTERM");
75
+ return false;
76
+ }
77
+ active.add(child);
78
+ child.on?.("close", () => active.delete(child));
79
+ return true;
80
+ },
81
+ async cancelAll() {
82
+ cancelled = true;
83
+ for (const child of [...active]) safeKill(child, "SIGTERM");
84
+ if (abortGraceMs > 0) await new Promise((r) => setTimeout(r, abortGraceMs));
85
+ for (const child of [...active]) safeKill(child, "SIGKILL");
86
+ }
87
+ };
88
+ }
89
+
90
+ const defaultGate = createConcurrencyGate();
91
+ const defaultRegistry = createProcessRegistry();
92
+
93
+ export function buildMinionArgs({ extensionPath = resolveSelfExtensionPath() } = {}) {
94
+ return [
95
+ "--mode", "json", "-p", "--no-session",
96
+ "--tools", MINION_TOOLS,
97
+ "--no-extensions", "--extension", extensionPath,
98
+ "--no-skills", "--no-prompt-templates", "--no-context-files", "--no-approve",
99
+ GENERIC_MINION_TASK
100
+ ];
101
+ }
102
+
103
+ function hasDotDot(path) {
104
+ return /(^|[\\/])\.\.([\\/]|$)/.test(String(path ?? ""));
105
+ }
106
+
107
+ export function extractToolPaths(input) {
108
+ if (!input || typeof input !== "object") return [];
109
+ const out = [];
110
+ for (const key of ["path", "file", "target", "directory", "dir", "root"]) {
111
+ if (typeof input[key] === "string" && input[key].trim()) out.push(input[key]);
112
+ }
113
+ for (const key of ["paths", "files"]) {
114
+ if (!Array.isArray(input[key])) continue;
115
+ for (const entry of input[key]) {
116
+ if (typeof entry === "string" && entry.trim()) out.push(entry);
117
+ }
118
+ }
119
+ return out;
120
+ }
121
+
122
+ export async function resolveAdmittedRoots(admittedPaths, cwd) {
123
+ if (!Array.isArray(admittedPaths) || admittedPaths.length === 0) return null;
124
+ const roots = [];
125
+ for (const raw of admittedPaths) {
126
+ if (typeof raw !== "string" || !raw.trim() || hasDotDot(raw)) return null;
127
+ const abs = isAbsolute(raw) ? raw : resolve(cwd, raw);
128
+ try { roots.push(await realpath(abs)); }
129
+ catch { return null; }
130
+ }
131
+ return roots.length ? roots : null;
132
+ }
133
+
134
+ export async function isPathAdmitted(targetPath, roots, cwd) {
135
+ if (!Array.isArray(roots) || roots.length === 0) return false;
136
+ if (typeof targetPath !== "string" || !targetPath.trim() || hasDotDot(targetPath)) return false;
137
+ const abs = isAbsolute(targetPath) ? targetPath : resolve(cwd, targetPath);
138
+ let real;
139
+ try { real = await realpath(abs); }
140
+ catch { return false; }
141
+ for (const root of roots) {
142
+ if (real === root) return true;
143
+ const prefix = root.endsWith(sep) ? root : `${root}${sep}`;
144
+ if (real.startsWith(prefix)) return true;
145
+ }
146
+ return false;
147
+ }
148
+
149
+ export async function evaluateToolPathAccess({
150
+ toolName, input, admittedPaths, cwd = process.cwd()
151
+ }) {
152
+ if (!GUARDED_TOOLS.has(toolName)) return { allow: true };
153
+ const roots = await resolveAdmittedRoots(admittedPaths, cwd);
154
+ if (!roots) return { allow: false, reason: PATH_DENIED };
155
+ const paths = extractToolPaths(input);
156
+ const targets = paths.length ? paths : [cwd];
157
+ for (const target of targets) {
158
+ if (!(await isPathAdmitted(target, roots, cwd))) {
159
+ return { allow: false, reason: PATH_DENIED };
160
+ }
161
+ }
162
+ return { allow: true };
163
+ }
164
+
165
+ /** Ratio from percent (0–100) or tokens/contextWindow. */
166
+ export function contextUsageRatio(usage) {
167
+ if (!usage || typeof usage !== "object") return 0;
168
+ if (Number.isFinite(usage.percent)) return Math.max(0, usage.percent) / 100;
169
+ const tokens = Number(usage.tokens ?? usage.contextTokens ?? 0);
170
+ const limit = Number(usage.contextWindow ?? usage.contextLimit ?? 0);
171
+ return limit > 0 ? Math.max(0, tokens) / limit : 0;
172
+ }
173
+
174
+ export function evaluateContextBudget(usage, {
175
+ compactRatio = BUDGET_COMPACT_RATIO, stopRatio = BUDGET_STOP_RATIO
176
+ } = {}) {
177
+ const ratio = contextUsageRatio(usage);
178
+ if (ratio >= stopRatio) return { ratio, action: "stop" };
179
+ if (ratio >= compactRatio) return { ratio, action: "compact" };
180
+ return { ratio, action: "continue" };
181
+ }
182
+
183
+ export function createBudgetAttemptState() {
184
+ return { compactedThisAttempt: false, compactObserved: false, stopReason: null };
185
+ }
186
+
187
+ async function writeMinionStatus(statusPath, payload) {
188
+ if (!statusPath) return;
189
+ await writeFile(statusPath, JSON.stringify(payload), { mode: 0o600 });
190
+ }
191
+
192
+ /** Child: after each turn, compact once in [70,90) or abort at ≥90%. */
193
+ export function registerBudgetGuard(pi, {
194
+ statusPath = null,
195
+ state = createBudgetAttemptState(),
196
+ compactRatio = BUDGET_COMPACT_RATIO,
197
+ stopRatio = BUDGET_STOP_RATIO
198
+ } = {}) {
199
+ const persist = async () => {
200
+ if (!state.stopReason && !state.compactObserved) return;
201
+ await writeMinionStatus(statusPath, {
202
+ code: state.stopReason, compact: state.compactObserved
203
+ });
204
+ };
205
+
206
+ pi.on("compaction_end", async () => {
207
+ state.compactObserved = true;
208
+ await persist();
209
+ });
210
+
211
+ pi.on("turn_end", async (_event, ctx) => {
212
+ if (state.stopReason) return;
213
+ const usage = typeof ctx?.getContextUsage === "function" ? ctx.getContextUsage() : null;
214
+ const { action } = evaluateContextBudget(usage, { compactRatio, stopRatio });
215
+ if (action === "continue") return;
216
+ if (action === "compact") {
217
+ if (state.compactedThisAttempt) return;
218
+ state.compactedThisAttempt = true;
219
+ if (typeof ctx?.compact === "function") {
220
+ ctx.compact({
221
+ onComplete: async () => {
222
+ state.compactObserved = true;
223
+ await persist();
224
+ },
225
+ onError: () => {}
226
+ });
227
+ }
228
+ return;
229
+ }
230
+ state.stopReason = BUDGET_EXCEEDED;
231
+ await persist();
232
+ if (typeof ctx?.abort === "function") ctx.abort();
233
+ });
234
+
235
+ return state;
236
+ }
237
+
238
+ export function registerPathGuard(pi, {
239
+ briefPath = process.env.KAIRO_MINION_BRIEF,
240
+ cwd = process.cwd(),
241
+ loadBrief = async (path) => JSON.parse(await readFile(path, "utf8"))
242
+ } = {}) {
243
+ let admittedPromise = null;
244
+ const loadAdmitted = () => {
245
+ if (!admittedPromise) {
246
+ admittedPromise = (async () => {
247
+ if (typeof briefPath !== "string" || !briefPath.trim()) return [];
248
+ try {
249
+ const brief = await loadBrief(briefPath);
250
+ return Array.isArray(brief?.admittedPaths) ? brief.admittedPaths : [];
251
+ } catch {
252
+ return [];
253
+ }
254
+ })();
255
+ }
256
+ return admittedPromise;
257
+ };
258
+
259
+ pi.on("tool_call", async (event) => {
260
+ const toolName = event?.toolName ?? event?.name;
261
+ if (!GUARDED_TOOLS.has(toolName)) return;
262
+ const admittedPaths = await loadAdmitted();
263
+ const verdict = await evaluateToolPathAccess({
264
+ toolName, input: event?.input ?? {}, admittedPaths, cwd
265
+ });
266
+ if (!verdict.allow) return { block: true, reason: PATH_DENIED };
267
+ });
268
+ }
269
+
270
+ function assistantText(message) {
271
+ if (!Array.isArray(message?.content)) return null;
272
+ const text = message.content
273
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
274
+ .map((b) => b.text).join("");
275
+ return text.trim() === "" ? null : text;
276
+ }
277
+
278
+ function normalizeUsage(usage, acc = {}) {
279
+ if (!usage || typeof usage !== "object") return acc;
280
+ const add = (key, ...alts) => {
281
+ for (const a of alts) {
282
+ if (Number.isFinite(usage[a])) { acc[key] = (acc[key] ?? 0) + usage[a]; return; }
283
+ }
284
+ };
285
+ add("inputTokens", "input", "inputTokens", "input_tokens");
286
+ add("outputTokens", "output", "outputTokens", "output_tokens");
287
+ if (Number.isFinite(usage.totalTokens)) acc.totalTokens = (acc.totalTokens ?? 0) + usage.totalTokens;
288
+ else if (Number.isFinite(usage.total)) acc.totalTokens = (acc.totalTokens ?? 0) + usage.total;
289
+ const cost = typeof usage.cost === "number" ? usage.cost
290
+ : typeof usage.cost?.total === "number" ? usage.cost.total : null;
291
+ if (cost != null) acc.cost = (acc.cost ?? 0) + cost;
292
+ return acc;
293
+ }
294
+
295
+ function failHandoff(message, code = "invalid_handoff") {
296
+ const err = new Error(message);
297
+ err.code = code;
298
+ throw err;
299
+ }
300
+
301
+ export function parseMinionNdjson(stdout) {
302
+ let lastText = null;
303
+ let usage = {};
304
+ let streamError = null;
305
+ for (const line of String(stdout ?? "").split("\n")) {
306
+ const trimmed = line.trim();
307
+ if (!trimmed) continue;
308
+ let parsed;
309
+ try { parsed = JSON.parse(trimmed); } catch { continue; }
310
+ if (parsed?.type === "error") {
311
+ streamError = "Minion stream error.";
312
+ continue;
313
+ }
314
+ if (parsed?.type === "message_end" && parsed.message?.role === "assistant") {
315
+ const stop = parsed.message.stopReason;
316
+ if (stop === "error" || stop === "aborted") {
317
+ streamError = `Minion stopReason ${stop}.`;
318
+ } else {
319
+ const text = assistantText(parsed.message);
320
+ if (text) lastText = text;
321
+ }
322
+ }
323
+ if (parsed?.type === "turn_end" && parsed.usage) usage = normalizeUsage(parsed.usage, usage);
324
+ }
325
+ if (streamError) failHandoff(streamError);
326
+ return { text: lastText, usage: Object.keys(usage).length ? usage : null };
327
+ }
328
+
329
+ export function parseMinionResultJson(text, { taskId }) {
330
+ if (typeof text !== "string" || !text.trim()) failHandoff("Minion returned no assistant message.");
331
+ let parsed;
332
+ try {
333
+ const start = text.indexOf("{");
334
+ const end = text.lastIndexOf("}");
335
+ parsed = JSON.parse(start >= 0 && end > start ? text.slice(start, end + 1) : text);
336
+ } catch {
337
+ failHandoff("Minion returned invalid JSON handoff.");
338
+ }
339
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
340
+ failHandoff("Minion handoff must be an object.");
341
+ }
342
+ if (parsed.taskId && parsed.taskId !== taskId) failHandoff("Minion handoff taskId mismatch.");
343
+ if (typeof parsed.summary !== "string" || !parsed.summary.trim()) {
344
+ failHandoff("Minion handoff requires summary.");
345
+ }
346
+ for (const key of ["prompt", "stdout", "stderr", "transcript", "conversation", "toolArgs"]) {
347
+ if (key in parsed) failHandoff(`Minion handoff forbids field "${key}".`);
348
+ }
349
+ return {
350
+ taskId,
351
+ summary: parsed.summary.trim(),
352
+ decisions: (parsed.decisions ?? []).map(String),
353
+ files: (parsed.files ?? []).map(String),
354
+ risks: (parsed.risks ?? []).map(String),
355
+ evidence: (parsed.evidence ?? []).map(String),
356
+ usage: {
357
+ inputTokens: parsed.usage?.inputTokens ?? null,
358
+ outputTokens: parsed.usage?.outputTokens ?? null,
359
+ totalTokens: parsed.usage?.totalTokens ?? null,
360
+ cost: parsed.usage?.cost ?? null
361
+ },
362
+ compact: Boolean(parsed.compact)
363
+ };
364
+ }
365
+
366
+ function safeKill(child, signal) {
367
+ try { if (child.exitCode == null && child.signalCode == null) child.kill(signal); } catch { /* ignore */ }
368
+ }
369
+
370
+ async function readMinionStatus(statusPath) {
371
+ try {
372
+ return JSON.parse(await readFile(statusPath, "utf8"));
373
+ } catch {
374
+ return null;
375
+ }
376
+ }
377
+
378
+ export function spawnMinionProcess({
379
+ brief, cwd, env = process.env, signal = null, spawnImpl = spawn,
380
+ abortGraceMs = MINION_ABORT_GRACE_MS, gate = defaultGate,
381
+ registry = defaultRegistry, extensionPath = resolveSelfExtensionPath(),
382
+ readStatus = readMinionStatus
383
+ } = {}) {
384
+ return gate.run(async () => {
385
+ if (registry.cancelled || gate.cancelled || signal?.aborted) {
386
+ failHandoff("Minion cancelled.", "aborted");
387
+ }
388
+ const dir = await mkdtemp(join(tmpdir(), "kairo-minion-"));
389
+ const briefPath = join(dir, "brief.json");
390
+ const statusPath = minionStatusPath(briefPath);
391
+ let child = null;
392
+ let abortListener = null;
393
+ let killTimer = null;
394
+ try {
395
+ await writeFile(briefPath, JSON.stringify(brief), { mode: 0o600 });
396
+ child = spawnImpl("pi", buildMinionArgs({ extensionPath }), {
397
+ cwd, env: { ...env, KAIRO_MINION_BRIEF: briefPath },
398
+ shell: false, stdio: ["ignore", "pipe", "pipe"]
399
+ });
400
+ if (!registry.track(child)) failHandoff("Minion cancelled.", "aborted");
401
+ let stdout = "";
402
+ child.stdout?.on("data", (c) => { stdout += c; });
403
+ child.stderr?.on("data", () => {});
404
+ const closed = new Promise((resolveClose, reject) => {
405
+ child.on("error", (error) => {
406
+ const err = new Error(`Minion spawn failed: ${error.message}`);
407
+ err.code = "invalid_handoff";
408
+ reject(err);
409
+ });
410
+ child.on("close", (status, sig) => resolveClose({ status, signal: sig, stdout }));
411
+ });
412
+ if (signal) {
413
+ const onAbort = () => {
414
+ safeKill(child, "SIGTERM");
415
+ killTimer = setTimeout(() => safeKill(child, "SIGKILL"), abortGraceMs);
416
+ };
417
+ if (signal.aborted) onAbort();
418
+ else {
419
+ abortListener = onAbort;
420
+ signal.addEventListener("abort", onAbort, { once: true });
421
+ }
422
+ }
423
+ const result = await closed;
424
+ const status = await readStatus(statusPath);
425
+ if (status?.code === BUDGET_EXCEEDED) {
426
+ const err = new Error("Context budget exceeded.");
427
+ err.code = BUDGET_EXCEEDED;
428
+ err.compact = Boolean(status.compact);
429
+ throw err;
430
+ }
431
+ if (signal?.aborted || result.signal || registry.cancelled) {
432
+ failHandoff("Minion aborted.", "aborted");
433
+ }
434
+ if (result.status !== 0) failHandoff(`Minion exited with status ${result.status}.`);
435
+ const parsed = parseMinionNdjson(result.stdout);
436
+ const handoff = parseMinionResultJson(parsed.text, { taskId: brief.taskId });
437
+ if (status?.compact) handoff.compact = true;
438
+ if (parsed.usage) {
439
+ handoff.usage = {
440
+ inputTokens: handoff.usage.inputTokens ?? parsed.usage.inputTokens ?? null,
441
+ outputTokens: handoff.usage.outputTokens ?? parsed.usage.outputTokens ?? null,
442
+ totalTokens: handoff.usage.totalTokens ?? parsed.usage.totalTokens ?? null,
443
+ cost: handoff.usage.cost ?? parsed.usage.cost ?? null
444
+ };
445
+ }
446
+ return handoff;
447
+ } finally {
448
+ if (abortListener && signal) signal.removeEventListener("abort", abortListener);
449
+ if (killTimer) clearTimeout(killTimer);
450
+ await rm(dir, { recursive: true, force: true });
451
+ }
452
+ });
453
+ }
454
+
455
+ /** Retry transient failures only; never cancel or budget_exceeded. */
456
+ export async function runMinionWithRetries(opts, {
457
+ maxAttempts = MAX_TASK_ATTEMPTS, onAttempt = null
458
+ } = {}) {
459
+ let lastError = null;
460
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
461
+ try {
462
+ if (onAttempt) await onAttempt(attempt);
463
+ return await spawnMinionProcess(opts);
464
+ } catch (error) {
465
+ lastError = error;
466
+ if (error?.code === BUDGET_EXCEEDED || error?.code === "aborted") throw error;
467
+ if (opts.signal?.aborted) failHandoff("Minion aborted.", "aborted");
468
+ if (attempt >= maxAttempts) throw error;
469
+ }
470
+ }
471
+ throw lastError;
472
+ }
473
+
474
+ const ORCH_ENV = Object.freeze({
475
+ HOME: "KAIRO_ORCH_HOME",
476
+ ROOT_RUN_ID: "KAIRO_ORCH_ROOT_RUN_ID",
477
+ ROOT_TASK_ID: "KAIRO_ORCH_ROOT_TASK_ID",
478
+ MODULE: "KAIRO_ORCH_MODULE"
479
+ });
480
+
481
+ export async function resolveOrchPersist(env = process.env) {
482
+ const homeDir = env[ORCH_ENV.HOME];
483
+ const rootRunId = env[ORCH_ENV.ROOT_RUN_ID];
484
+ const rootTaskId = env[ORCH_ENV.ROOT_TASK_ID];
485
+ const modulePath = env[ORCH_ENV.MODULE];
486
+ if (![homeDir, rootRunId, rootTaskId, modulePath].every((v) => typeof v === "string" && v.trim())) {
487
+ return null;
488
+ }
489
+ const api = await import(pathToFileURL(modulePath).href);
490
+ return { homeDir, rootRunId, rootTaskId, api };
491
+ }
492
+
493
+ function registerDelegate(pi, {
494
+ gate = createConcurrencyGate(),
495
+ registry = createProcessRegistry(),
496
+ env = process.env,
497
+ spawnImpl = spawn,
498
+ readStatus = null
499
+ } = {}) {
500
+ const cascade = async () => {
501
+ gate.cancel();
502
+ await registry.cancelAll();
503
+ };
504
+ pi.on("session_shutdown", cascade);
505
+
506
+ pi.registerTool({
507
+ name: "kairo_delegate",
508
+ label: "Kairo Delegate",
509
+ description: "Delegate a bounded read-only subtask to an ephemeral Pi minion.",
510
+ parameters: {
511
+ type: "object",
512
+ properties: {
513
+ taskId: { type: "string" },
514
+ parentTaskId: { type: "string" },
515
+ objective: { type: "string" },
516
+ constraints: { type: "array", items: { type: "string" } },
517
+ admittedPaths: { type: "array", items: { type: "string" } },
518
+ exitCriteria: { type: "array", items: { type: "string" } }
519
+ },
520
+ required: ["taskId", "parentTaskId", "objective"]
521
+ },
522
+ async execute(_id, params, signal) {
523
+ if (signal) {
524
+ const onAbort = () => { void cascade(); };
525
+ if (signal.aborted) onAbort();
526
+ else signal.addEventListener("abort", onAbort, { once: true });
527
+ }
528
+ const orch = await resolveOrchPersist(env);
529
+ const parentTaskId = params.parentTaskId;
530
+ const taskId = params.taskId;
531
+ if (orch && (taskId === orch.rootTaskId || parentTaskId !== orch.rootTaskId)) {
532
+ throw Object.assign(new Error("Delegate lineage must honor KAIRO_ORCH_ROOT_TASK_ID."), { code: "invalid_lineage" });
533
+ }
534
+ let lastAttempt = 0;
535
+ const patch = orch
536
+ ? (fields) => orch.api.applyMinionDagUpdate(orch.rootRunId, {
537
+ homeDir: orch.homeDir, taskId, parentTaskId, ...fields
538
+ })
539
+ : null;
540
+ const objectiveDigest = orch
541
+ ? orch.api.digestAllowlisted({ objective: params.objective })
542
+ : null;
543
+ try {
544
+ if (patch) {
545
+ await patch({
546
+ state: orch.api.DAG_NODE_STATES.PENDING, attempt: 0, objectiveDigest
547
+ });
548
+ }
549
+ const handoff = await runMinionWithRetries({
550
+ brief: {
551
+ taskId, parentTaskId, objective: params.objective,
552
+ constraints: params.constraints ?? [],
553
+ admittedPaths: params.admittedPaths ?? [],
554
+ exitCriteria: params.exitCriteria ?? []
555
+ },
556
+ cwd: process.cwd(),
557
+ signal, gate, registry, env, spawnImpl,
558
+ ...(readStatus ? { readStatus } : {})
559
+ }, {
560
+ onAttempt: patch
561
+ ? async (attempt) => {
562
+ lastAttempt = attempt;
563
+ await patch({
564
+ state: orch.api.DAG_NODE_STATES.RUNNING, attempt, objectiveDigest
565
+ });
566
+ }
567
+ : null
568
+ });
569
+ if (patch) {
570
+ await patch({
571
+ state: orch.api.DAG_NODE_STATES.COMPLETED, attempt: lastAttempt || 1,
572
+ objectiveDigest, result: handoff
573
+ });
574
+ }
575
+ return handoff;
576
+ } catch (error) {
577
+ if (patch) {
578
+ const code = error?.code === BUDGET_EXCEEDED
579
+ ? BUDGET_EXCEEDED
580
+ : error?.code === "aborted" ? "aborted" : (error?.code ?? "invalid_handoff");
581
+ const state = code === "aborted"
582
+ ? orch.api.DAG_NODE_STATES.CANCELLED
583
+ : orch.api.DAG_NODE_STATES.FAILED;
584
+ await patch({
585
+ state, attempt: lastAttempt || 1, objectiveDigest, error: { code }
586
+ });
587
+ }
588
+ throw error;
589
+ }
590
+ }
591
+ });
592
+ return { gate, registry, cascade };
593
+ }
594
+
595
+ export default function registerKairoMinion(pi, env = process.env, opts = {}) {
596
+ if (isChildMinionMode(env)) {
597
+ const briefPath = env.KAIRO_MINION_BRIEF;
598
+ registerPathGuard(pi, { briefPath });
599
+ registerBudgetGuard(pi, { statusPath: minionStatusPath(briefPath) });
600
+ return { mode: "child" };
601
+ }
602
+ registerDelegate(pi, { env, ...opts });
603
+ return { mode: "parent" };
604
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kal-elsam/kairo-runtime",
3
- "version": "0.7.0",
3
+ "version": "0.9.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",