@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.
@@ -19,7 +19,8 @@
19
19
  */
20
20
 
21
21
  import { spawn, execFileSync } from "node:child_process";
22
- import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
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
- const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, task, timeout, signal);
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
- const failed = code !== 0 || killed !== null || extraction.failed;
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
- const diff = worktreeDiff(worktree);
183
- if (diff !== null) {
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
- text += `\n diff:\n${diff}\n worktree kept at: ${worktree}`;
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) rmSync(worktree, { recursive: true, force: true });
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, task, timeout, signal) {
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.write(`${task}\nexit\n`);
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
- events = readFileSync(file, "utf8")
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
- .map((r) => r.event ?? r);
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
- /** The implementer's changes: git diff (with its --stat header) over the
340
- * worktree intent-to-add first so NEW files are part of the diff, not
341
- * silently invisible to `git diff`. null = no changes. */
342
- function worktreeDiff(worktree) {
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 stat = execFileSync("git", ["-C", worktree, "diff", "--stat"], { encoding: "utf8" }).trim();
346
- const diff = execFileSync("git", ["-C", worktree, "diff"], { encoding: "utf8" });
347
- if (stat === "" && diff.trim() === "") return null;
348
- return `${stat}\n${diff}`;
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
- return null;
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.0",
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.0",
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"