@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 +12 -0
- package/global-template/components/catalog.json +4 -1
- package/global-template/components/orchestrator/extensions/pi/kairo-minion.js +604 -0
- package/package.json +1 -1
- package/scripts/cockpit-smoke.mjs +1 -1
- package/src/cli.js +10 -1
- package/src/global/adapters/pi.js +1 -1
- package/src/global/runtime/execution-adapters/pi.js +12 -2
- package/src/global/runtime/orchestration/index.js +23 -0
- package/src/global/runtime/orchestration/orch-receipts.js +234 -0
- package/src/global/runtime/orchestration/orch-types.js +173 -0
- package/src/global/runtime/orchestration/orch-validate.js +63 -0
- package/src/global/runtime/run-cli.js +1 -0
- package/src/global/runtime/run-manager.js +59 -4
- package/src/global/runtime/run-strategy.js +71 -0
- package/src/global/runtime/run-supervisor.js +22 -2
- package/src/global/runtime/run-types.js +5 -1
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
|
|
@@ -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.
|
|
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.
|
|
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]
|