@vincemakes/kiso-subagent-ext 0.26.0 → 0.26.2
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/dist/kiso-subagent.mjs +112 -25
- package/package.json +2 -2
package/dist/kiso-subagent.mjs
CHANGED
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { spawn, execFileSync } from "node:child_process";
|
|
22
|
-
import {
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
23
24
|
import { homedir, tmpdir } from "node:os";
|
|
24
25
|
import { join } from "node:path";
|
|
25
26
|
|
|
@@ -74,9 +75,17 @@ export default async function createSubagentExtension() {
|
|
|
74
75
|
const parentId = ctx.sessionId ?? discoverParentId(sessionsDir);
|
|
75
76
|
const bin = process.env.KISO_SUBAGENT_BIN ?? process.argv[1];
|
|
76
77
|
const timeout = Number.parseInt(process.env.KISO_SUBAGENT_TIMEOUT_MS ?? "", 10) || TIMEOUT_MS;
|
|
78
|
+
// CX-1 F6 (audit F6): every delegate invocation mints its own
|
|
79
|
+
// identity — ToolContext carries none — so two invocations never
|
|
80
|
+
// share a child session, and the result is located by identity.
|
|
81
|
+
const delegationId = randomBytes(12).toString("hex");
|
|
82
|
+
const manifestDir = join(sessionsDir, "subagent");
|
|
83
|
+
mkdirSync(manifestDir, { recursive: true });
|
|
77
84
|
const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
|
|
78
85
|
runChild({
|
|
79
|
-
childId: `sub-${parentId}-${i + 1}-${task.role}`,
|
|
86
|
+
childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
|
|
87
|
+
manifestDir,
|
|
88
|
+
manifest: { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now() },
|
|
80
89
|
role: task.role,
|
|
81
90
|
task: task.task,
|
|
82
91
|
sessionsDir,
|
|
@@ -144,15 +153,24 @@ function runLimited(items, limit, fn) {
|
|
|
144
153
|
return Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)).then(() => results);
|
|
145
154
|
}
|
|
146
155
|
|
|
147
|
-
async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal, parentCwd }) {
|
|
156
|
+
async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal, parentCwd, manifestDir, manifest }) {
|
|
148
157
|
// implementer isolation: a detached git worktree; the child works inside
|
|
149
158
|
// it and its diff comes back. Non-git parents fail the task HONESTLY.
|
|
159
|
+
// CX-1 F6: the manifest binds this invocation to its child session
|
|
160
|
+
// BEFORE anything runs — the durable record of "which run is mine".
|
|
161
|
+
if (manifestDir !== undefined) {
|
|
162
|
+
writeFileSync(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId })}\n`, "utf8");
|
|
163
|
+
}
|
|
150
164
|
let worktree = null;
|
|
165
|
+
let baseRev = null;
|
|
151
166
|
let childCwd = parentCwd;
|
|
152
167
|
if (role === "implementer") {
|
|
153
168
|
worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
|
|
154
169
|
try {
|
|
155
170
|
execFileSync("git", ["-C", parentCwd, "worktree", "add", "--detach", worktree], { stdio: "ignore" });
|
|
171
|
+
// CX-1 F2: the base revision — a child that COMMITS is compared
|
|
172
|
+
// against it, never read as "unchanged".
|
|
173
|
+
baseRev = execFileSync("git", ["-C", worktree, "rev-parse", "HEAD"], { encoding: "utf8" }).trim();
|
|
156
174
|
childCwd = worktree;
|
|
157
175
|
} catch (err) {
|
|
158
176
|
rmSync(worktree, { recursive: true, force: true });
|
|
@@ -164,9 +182,14 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
|
|
|
164
182
|
writeFileSync(join(policyDir, "policy.mjs"), rolePolicyContent(role), "utf8");
|
|
165
183
|
let keepWorktree = false;
|
|
166
184
|
try {
|
|
167
|
-
|
|
185
|
+
// CX-1 F5 (audit F5): the task travels as a FILE the child reads into
|
|
186
|
+
// exactly one user turn — never as stdin lines (the non-TTY path is a
|
|
187
|
+
// line-oriented readline: newlines were turns, `exit` ended input).
|
|
188
|
+
const taskPath = join(manifestDir ?? policyDir, `${childId}.task`);
|
|
189
|
+
writeFileSync(taskPath, task, "utf8");
|
|
190
|
+
const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal);
|
|
168
191
|
const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
|
|
169
|
-
|
|
192
|
+
let failed = code !== 0 || killed !== null || extraction.failed;
|
|
170
193
|
let text = `[subagent] ${role}: ${task}\n outcome: ${extraction.outcome}\n tools: ${extraction.toolCalls}`;
|
|
171
194
|
if (killed === "timeout") {
|
|
172
195
|
text += `\n FAILED: timed out after ${timeout}ms (the child process group was killed)`;
|
|
@@ -179,16 +202,28 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
|
|
|
179
202
|
}
|
|
180
203
|
if (extraction.text !== "") text += `\n${extraction.text}`;
|
|
181
204
|
if (role === "implementer") {
|
|
182
|
-
|
|
183
|
-
|
|
205
|
+
// CX-1 F2 (audit F2): tri-state collection. `collected` is earned
|
|
206
|
+
// (the patch file closed AND git exited 0); a failure PRESERVES the
|
|
207
|
+
// worktree and says so; "consumed" is undefined this batch, so a
|
|
208
|
+
// worktree with changes is always kept and named.
|
|
209
|
+
const patchPath = join(manifestDir ?? tmpdir(), `${childId}.patch`);
|
|
210
|
+
const col = await collectWorktree(worktree, baseRev, patchPath);
|
|
211
|
+
if (col.kind === "collected") {
|
|
212
|
+
keepWorktree = true;
|
|
213
|
+
text += `\n diff:\n${col.stat}\n patch: ${patchPath}`;
|
|
214
|
+
if (col.bytes <= INLINE_PATCH_BYTES) text += `\n${readFileSync(patchPath, "utf8")}`;
|
|
215
|
+
else text += `\n (patch is ${col.bytes} bytes — read it with the shell: cat ${patchPath}, or git -C ${worktree} diff ${baseRev})`;
|
|
216
|
+
text += `\n worktree kept at: ${worktree}`;
|
|
217
|
+
} else if (col.kind === "failed") {
|
|
184
218
|
keepWorktree = true;
|
|
185
|
-
|
|
219
|
+
failed = true;
|
|
220
|
+
text += `\n FAILED: collecting the worktree's changes: ${col.reason}${col.partialPath !== undefined ? ` (partial patch at ${col.partialPath})` : ""}\n worktree kept at: ${worktree}`;
|
|
186
221
|
}
|
|
187
222
|
}
|
|
188
223
|
return { failed, text, toolCalls: extraction.toolCalls };
|
|
189
224
|
} finally {
|
|
190
225
|
rmSync(policyDir, { recursive: true, force: true });
|
|
191
|
-
if (worktree !== null && !keepWorktree)
|
|
226
|
+
if (worktree !== null && !keepWorktree) removeWorktree(parentCwd, worktree);
|
|
192
227
|
}
|
|
193
228
|
}
|
|
194
229
|
|
|
@@ -204,9 +239,9 @@ async function runChild({ childId, role, task, sessionsDir, bin, timeout, signal
|
|
|
204
239
|
* spawn the human just approved in the ask tier, so the provider
|
|
205
240
|
* credentials the parent was trusted with ride along.
|
|
206
241
|
*/
|
|
207
|
-
function runProcess(childId, bin, cwd, policyDir,
|
|
242
|
+
function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal) {
|
|
208
243
|
const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
|
|
209
|
-
const child = spawn(process.execPath, [bin, "chat", childId], {
|
|
244
|
+
const child = spawn(process.execPath, [bin, "chat", childId, "--task-file", taskPath], {
|
|
210
245
|
cwd,
|
|
211
246
|
env: {
|
|
212
247
|
...process.env,
|
|
@@ -223,8 +258,7 @@ function runProcess(childId, bin, cwd, policyDir, task, timeout, signal) {
|
|
|
223
258
|
detached: true,
|
|
224
259
|
stdio: ["pipe", "pipe", "inherit"],
|
|
225
260
|
});
|
|
226
|
-
child.stdin.
|
|
227
|
-
child.stdin.end();
|
|
261
|
+
child.stdin.end(); // CX-1 F5: nothing rides stdin — the task is the file
|
|
228
262
|
let stdout = "";
|
|
229
263
|
child.stdout.on("data", (d) => {
|
|
230
264
|
stdout += String(d);
|
|
@@ -290,12 +324,19 @@ export async function extractChildResult(sessionsDir, childId, diag) {
|
|
|
290
324
|
try {
|
|
291
325
|
// The store's JSONL records are {runId, ts, event} wrappers —
|
|
292
326
|
// unwrap; bare events (fixtures) pass through.
|
|
293
|
-
|
|
327
|
+
const records = readFileSync(file, "utf8")
|
|
294
328
|
.trim()
|
|
295
329
|
.split("\n")
|
|
296
330
|
.filter((l) => l !== "")
|
|
297
|
-
.map((l) => JSON.parse(l))
|
|
298
|
-
|
|
331
|
+
.map((l) => JSON.parse(l));
|
|
332
|
+
// CX-1 F6: the child session is fresh by construction, so its log
|
|
333
|
+
// holds exactly ONE run — the result is located by that identity,
|
|
334
|
+
// never by position. More than one run is ambiguous, and reported.
|
|
335
|
+
const runIds = new Set(records.map((r) => r.runId).filter((id) => typeof id === "string"));
|
|
336
|
+
if (runIds.size > 1) {
|
|
337
|
+
return { outcome: "ambiguous", toolCalls: 0, text: "", failed: true, reason: `child session ${childId} holds ${runIds.size} runs — the result cannot be located by identity`, diag };
|
|
338
|
+
}
|
|
339
|
+
events = records.map((r) => r.event ?? r);
|
|
299
340
|
} catch (err) {
|
|
300
341
|
lastErr = err;
|
|
301
342
|
events = null;
|
|
@@ -336,18 +377,64 @@ function finalText(events) {
|
|
|
336
377
|
return text;
|
|
337
378
|
}
|
|
338
379
|
|
|
339
|
-
/**
|
|
340
|
-
*
|
|
341
|
-
|
|
342
|
-
|
|
380
|
+
/** CX-1 F2: a patch body this size or smaller rides inline in the section;
|
|
381
|
+
* larger ones are named by path (the parent reads them with the shell). */
|
|
382
|
+
const INLINE_PATCH_BYTES = 64 * 1024;
|
|
383
|
+
|
|
384
|
+
/** CX-1 F2: the implementer's changes against the BASE revision, streamed to
|
|
385
|
+
* a file — intent-to-add first so NEW files are part of the diff. Three
|
|
386
|
+
* states, never a null that means two things:
|
|
387
|
+
* { kind: "unchanged" }
|
|
388
|
+
* { kind: "collected", stat, bytes } — file closed AND git exited 0
|
|
389
|
+
* { kind: "failed", reason, partialPath? }
|
|
390
|
+
* Old shape: execFileSync buffered the patch, threw ENOBUFS above 1 MiB,
|
|
391
|
+
* and the catch returned null — "no changes" — so the worktree was
|
|
392
|
+
* deleted with the work in it. */
|
|
393
|
+
async function collectWorktree(worktree, baseRev, patchPath) {
|
|
394
|
+
let stat;
|
|
343
395
|
try {
|
|
344
396
|
execFileSync("git", ["-C", worktree, "add", "-N", "."], { stdio: "ignore" });
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
if (stat === ""
|
|
348
|
-
|
|
397
|
+
const base = baseRev ?? "HEAD";
|
|
398
|
+
stat = execFileSync("git", ["-C", worktree, "diff", "--stat", base], { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 }).trim();
|
|
399
|
+
if (stat === "") return { kind: "unchanged" };
|
|
400
|
+
} catch (err) {
|
|
401
|
+
return { kind: "failed", reason: `git diff --stat: ${msg(err)}` };
|
|
402
|
+
}
|
|
403
|
+
let fd = null;
|
|
404
|
+
try {
|
|
405
|
+
fd = openSync(patchPath, "w");
|
|
406
|
+
const code = await new Promise((resolve, reject) => {
|
|
407
|
+
const p = spawn("git", ["-C", worktree, "diff", baseRev ?? "HEAD"], { stdio: ["ignore", fd, "pipe"] });
|
|
408
|
+
let stderr = "";
|
|
409
|
+
p.stderr.on("data", (d) => {
|
|
410
|
+
stderr += String(d);
|
|
411
|
+
});
|
|
412
|
+
p.on("error", reject);
|
|
413
|
+
p.on("exit", (c) => resolve({ c, stderr }));
|
|
414
|
+
});
|
|
415
|
+
closeSync(fd);
|
|
416
|
+
fd = null;
|
|
417
|
+
if (code.c !== 0) return { kind: "failed", reason: `git diff exited ${code.c}: ${code.stderr.trim()}`, partialPath: patchPath };
|
|
418
|
+
return { kind: "collected", stat, bytes: statSync(patchPath).size };
|
|
419
|
+
} catch (err) {
|
|
420
|
+
if (fd !== null) closeSync(fd);
|
|
421
|
+
return { kind: "failed", reason: `git diff: ${msg(err)}`, partialPath: patchPath };
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** CX-1 F2: an UNCHANGED worktree leaves through git, so the registration
|
|
426
|
+
* under .git/worktrees goes with it; the directory fallback covers a git
|
|
427
|
+
* that no longer recognizes it. */
|
|
428
|
+
function removeWorktree(parentCwd, worktree) {
|
|
429
|
+
try {
|
|
430
|
+
execFileSync("git", ["-C", parentCwd, "worktree", "remove", "--force", worktree], { stdio: "ignore" });
|
|
349
431
|
} catch {
|
|
350
|
-
|
|
432
|
+
rmSync(worktree, { recursive: true, force: true });
|
|
433
|
+
try {
|
|
434
|
+
execFileSync("git", ["-C", parentCwd, "worktree", "prune"], { stdio: "ignore" });
|
|
435
|
+
} catch {
|
|
436
|
+
// nothing left to prune
|
|
437
|
+
}
|
|
351
438
|
}
|
|
352
439
|
}
|
|
353
440
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincemakes/kiso-subagent-ext",
|
|
3
|
-
"version": "0.26.
|
|
3
|
+
"version": "0.26.2",
|
|
4
4
|
"description": "kiso official subagent extension — child kiso processes with role policies, kernel untouched",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"test": "vitest run"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@vincemakes/kiso-core": "0.26.
|
|
26
|
+
"@vincemakes/kiso-core": "0.26.2",
|
|
27
27
|
"@types/node": "^26.1.2",
|
|
28
28
|
"typescript": "^5.7.2",
|
|
29
29
|
"vitest": "^3.0.0"
|