@kal-elsam/kairo-runtime 0.7.0 → 0.8.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/README.md CHANGED
@@ -301,6 +301,18 @@ Launches `pi --mode json --no-session` (optional `--model`). `read-only` maps to
301
301
  to `--approve`). Custom `PI_CODING_AGENT_DIR` blocks config writes in 0.6.0 but does
302
302
  not block runtime. Kairo does not install Pi or assert subscription/entitlement.
303
303
 
304
+ ### Orchestrated Pi (Context Orchestration)
305
+
306
+ ```bash
307
+ kairo run --agent pi --strategy orchestrated --task "..."
308
+ ```
309
+
310
+ Loads the managed minion extension, injects `KAIRO_ORCH_*`, and persists a depth≤1
311
+ DAG under `~/.harness/runs/<rootRunId>/orchestration/state.json`. Normal completion
312
+ seals write-once `receipt.json`; interrupt recovery seals `recovered:true`. Limits:
313
+ concurrency 2, max attempts 2, context compact at 70% / stop at 90%, cascade cancel
314
+ on parent abort. No same-root resume. Direct `--strategy direct` (default) is unchanged.
315
+
304
316
  ### Bounded review (Codex / Pi)
305
317
 
306
318
  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.8.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",
@@ -26,7 +26,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), "..");
26
26
  const require = createRequire(import.meta.url);
27
27
  const pkg = require(join(root, "package.json"));
28
28
 
29
- assert.equal(pkg.version, "0.7.0");
29
+ assert.equal(pkg.version, "0.8.0");
30
30
  assert.ok(pkg.dependencies["ansi-escapes"]);
31
31
 
32
32
  assert.equal(resolveLayoutMode({ columns: 120, rows: 40 }), LAYOUT_MODES.WIDE);
package/src/cli.js CHANGED
@@ -39,6 +39,7 @@ import { runOrchestratorDiagnostics, runOrchestratorShell } from "./global/orche
39
39
  import { runIntelligenceCli } from "./global/intelligence-cli.js";
40
40
  import { runGlobalRun, runGlobalRuns } from "./global/runtime/run-cli.js";
41
41
  import { runGlobalReview, runGlobalReviews } from "./global/runtime/review/review-cli.js";
42
+ import { normalizeRunStrategy } from "./global/runtime/run-strategy.js";
42
43
  import {
43
44
  LEGACY_PACKAGE_NAME,
44
45
  PACKAGE_NAME,
@@ -386,6 +387,7 @@ export function parseArgs(argv) {
386
387
  agent: null,
387
388
  task: null,
388
389
  model: null,
390
+ strategy: "direct",
389
391
  intelligenceBackend: null,
390
392
  permissions: null,
391
393
  captureTranscript: false,
@@ -499,6 +501,13 @@ export function parseArgs(argv) {
499
501
  else if (arg.startsWith("--agent=")) options.agent = arg.slice("--agent=".length);
500
502
  else if (arg === "--model") options.model = args[++index];
501
503
  else if (arg.startsWith("--model=")) options.model = arg.slice("--model=".length);
504
+ else if (arg === "--strategy") {
505
+ options.strategy = normalizeRunStrategy(requireFlagValue("--strategy", args[++index]));
506
+ } else if (arg.startsWith("--strategy=")) {
507
+ options.strategy = normalizeRunStrategy(
508
+ requireFlagValue("--strategy", arg.slice("--strategy=".length))
509
+ );
510
+ }
502
511
  else if (arg === "--backend") options.intelligenceBackend = args[++index] ?? "";
503
512
  else if (arg.startsWith("--backend=")) options.intelligenceBackend = arg.slice("--backend=".length);
504
513
  else if (arg === "--permissions") options.permissions = parsePathList(args[++index]);
@@ -797,7 +806,7 @@ Usage:
797
806
  ${cli} --dry-run Setup dry-run (scriptable)
798
807
  ${cli} --version
799
808
  ${cli} shell Operations cockpit (TTY)
800
- ${cli} run --agent <id> --task "..." [--model <name>] [--cwd <dir>] [--permissions force] [--capture-transcript] [--follow] [--no-wait] [--json]
809
+ ${cli} run --agent <id> --task "..." [--strategy direct|orchestrated] [--model <name>] [--cwd <dir>] [--permissions force] [--capture-transcript] [--follow] [--no-wait] [--json]
801
810
  ${cli} runs list [--json] [--limit <n>] [--active-only]
802
811
  ${cli} runs show <runId> [--json] [--limit <n>] [--follow]
803
812
  ${cli} runs stop <runId> [--json]