@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.
- package/README.md +43 -11
- 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 +4 -4
- package/src/cli.js +34 -2
- package/src/global/adapters/pi.js +1 -1
- package/src/global/global-doctor.js +2 -0
- package/src/global/ink/cockpit/primitives.js +96 -31
- package/src/global/ink/cockpit-alerts.js +36 -0
- package/src/global/ink/cockpit-changes.js +59 -35
- package/src/global/ink/cockpit-control-center.js +129 -51
- package/src/global/ink/cockpit-controller.js +58 -11
- package/src/global/ink/cockpit-focus.js +4 -2
- package/src/global/ink/cockpit-models.js +89 -47
- package/src/global/ink/cockpit-palette.js +98 -0
- package/src/global/ink/cockpit-path-label.js +19 -0
- package/src/global/ink/cockpit-recovery.js +78 -15
- package/src/global/ink/cockpit-reviews.js +14 -10
- package/src/global/ink/cockpit-runs.js +13 -4
- package/src/global/ink/cockpit-settings.js +194 -0
- package/src/global/ink/cockpit-views.js +88 -57
- package/src/global/ink/orchestrator-app.js +137 -46
- package/src/global/ink/orchestrator-state.js +24 -14
- package/src/global/ink/use-orchestrator-data.js +58 -0
- package/src/global/paths.js +3 -0
- package/src/global/runtime/alerts/alert-store.js +216 -0
- package/src/global/runtime/alerts/alert-types.js +59 -0
- package/src/global/runtime/alerts/alert-validate.js +117 -0
- package/src/global/runtime/execution-adapters/pi.js +12 -2
- package/src/global/runtime/monitor/monitor-cli.js +62 -0
- package/src/global/runtime/monitor/monitor-platform.js +95 -0
- package/src/global/runtime/monitor/monitor.js +249 -0
- 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
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
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 |
|
|
46
|
-
| Compact | ≥72×20 |
|
|
47
|
-
| Minimal | 60–71 cols or short height | Selected section + essential readiness/next
|
|
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
|
|
51
|
-
`Esc` back (exit only from
|
|
52
|
-
`Tab` switches region
|
|
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
|
|
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
|
|
@@ -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.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",
|