@vincemakes/kiso-subagent-ext 0.45.3 → 0.46.1

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
@@ -13,8 +13,28 @@ user-layer loader accepts exactly this shape.
13
13
 
14
14
  ## Configuration
15
15
 
16
- None. Every delegation is asked of the human (no auto-allow); depth is
17
- guarded so children can never nest.
16
+ None for a user. Every delegation is asked of the human (no auto-allow);
17
+ depth is guarded so children can never nest.
18
+
19
+ ## Background children (ADR-0058)
20
+
21
+ A host that passes its per-session task manager gets
22
+ `delegate({ …, background: true })`:
23
+
24
+ ```ts
25
+ createSubagentExtension({
26
+ tasks: (sessionId) => managerFor(sessionId), // the runtime's TaskManager
27
+ backgroundMax: 20, // live background children per session (default)
28
+ backgroundMaxTurns: 32, // a child's model requests before its wrap-up (default)
29
+ });
30
+ ```
31
+
32
+ Explorer and reviewer only. Each child is an agent task under the task
33
+ runner — it outlives the parent — and the call returns at once; the
34
+ runtime's task delivery tells the model when the turn's children have all
35
+ ended, with each child's answer (`result.md` beside its task). Without
36
+ `tasks`, the schema and behaviour are the foreground's, byte for byte. The
37
+ CLI wires it.
18
38
 
19
39
  ## Versioning
20
40
 
@@ -16,23 +16,41 @@
16
16
  * finding #8: this extension holds NO persistent resources — children are
17
17
  * spawned per call and exit on their own, the role-policy temp dirs are
18
18
  * cleaned in runChild's finally — so NO dispose is needed, explicitly.
19
+ *
20
+ * ADR-0058 3d — background children: a host that passes its task manager
21
+ * (`tasks`) gets `delegate({ …, background: true })`. Each child is then an
22
+ * agent TASK — the same child kiso, launched by argv under the task runner,
23
+ * so it outlives this process — and the call returns at once; the host's
24
+ * delivery tells the model when the children have ended. Explorer and
25
+ * reviewer only (D1), the whole batch within the session's cap or nothing
26
+ * (D4), no timeoutMs (D5), and a turn budget instead of a deadline (D6).
27
+ * Without `tasks` the schema and behaviour are exactly the foreground's.
19
28
  */
20
29
 
21
30
  import { spawn, execFileSync } from "node:child_process";
22
31
  import { createHash, randomBytes } from "node:crypto";
23
- import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
32
+ import { closeSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, rmSync, statSync, writeFileSync, writeSync } from "node:fs";
24
33
  import { homedir, tmpdir } from "node:os";
25
- import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
34
+ import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "node:path";
26
35
  import { fileURLToPath } from "node:url";
27
36
 
28
37
  /** Default per-child timeout (ms) — a subagent must never hang the parent. */
29
38
  const TIMEOUT_MS = 10 * 60 * 1000;
30
39
  /** Max simultaneous children — the concurrency cap (mapLimited). */
31
40
  const CONCURRENCY = 4;
41
+ /** ADR-0058 3d (D4): live background children per session, by default. */
42
+ const BACKGROUND_MAX = 20;
43
+ /** ADR-0058 3d (D6): a background child's model requests, by default — about
44
+ * twice the longest real explorer measured (17); 3f tunes it. */
45
+ const BACKGROUND_MAX_TURNS = 32;
46
+ /** ADR-0058 3d (D1): the roles whose unattended contract is defined. */
47
+ const BACKGROUND_ROLES = ["explorer", "reviewer"];
32
48
 
33
49
  const SIX_TOOLS = ["read_file", "list_dir", "search_text", "write_file", "edit_file", "shell"];
34
50
  const READ_ONLY = ["read_file", "list_dir", "search_text"];
35
- const ROLES = ["explorer", "implementer", "reviewer", "tester"];
51
+ // verifier: runs checks and reports evidence; its changes are never
52
+ // collected (ADR-0032 Amendment 1 — it was called tester until 0.46.0)
53
+ const ROLES = ["explorer", "implementer", "reviewer", "verifier"];
36
54
 
37
55
  // DT1a-F2 (owner dogfood 2026-09-08): the model's first delegate call was
38
56
  // refused twice — `scope` on an explorer, then an `acceptance` naming no
@@ -41,7 +59,7 @@ const ROLES = ["explorer", "implementer", "reviewer", "tester"];
41
59
  // check and profile names are written into the schema when the extension
42
60
  // loads (the tool table is snapshotted per request — F7b — so this is the
43
61
  // table the model reads).
44
- const delegateParameters = (cfg) => ({
62
+ const delegateParameters = (cfg, background) => ({
45
63
  type: "object",
46
64
  properties: {
47
65
  tasks: {
@@ -58,46 +76,67 @@ const delegateParameters = (cfg) => ({
58
76
  // acceptance names a configured check ({ check }) or a parent-held
59
77
  // evaluator ({ evaluator: absolute path outside the project }) —
60
78
  // never a command; model names a configured profile; after names
61
- // a completed implementer's childId (tester only): the tester runs
62
- // in that worktree.
79
+ // a completed implementer's childId (verifier only): the verifier
80
+ // runs in a copy of that worktree.
63
81
  scope: {
64
82
  type: "array",
65
83
  items: { type: "string", minLength: 1 },
66
84
  maxItems: 32,
67
- description: "implementer and tester tasks ONLY (an explorer or reviewer task with scope is refused): path globs the child may write; a scoped child has no shell and a write outside the scope is refused",
85
+ description: "implementer and verifier tasks ONLY (an explorer or reviewer task with scope is refused): path globs the child may write; a scoped child has no shell and a write outside the scope is refused",
68
86
  },
69
87
  acceptance: {
70
88
  type: "object",
71
89
  properties: { check: { type: "string", minLength: 1 }, evaluator: { type: "string", minLength: 1 } },
72
90
  additionalProperties: false,
73
- description: `optional; implementer and tester only. Exactly one of: { check } naming a check configured by the user (configured now: ${Object.keys(cfg.checks).length ? Object.keys(cfg.checks).join(", ") : "none configured — omit acceptance"}), or { evaluator } — an absolute path to an evaluator script OUTSIDE the project. Never a command: the parent runs the acceptance after the child completes`,
91
+ description: `optional; implementer and verifier only. Exactly one of: { check } naming a check configured by the user (configured now: ${Object.keys(cfg.checks).length ? Object.keys(cfg.checks).join(", ") : "none configured — omit acceptance"}), or { evaluator } — an absolute path to an evaluator script OUTSIDE the project. Never a command: the parent runs the acceptance after the child completes`,
74
92
  },
75
93
  model: { type: "string", minLength: 1, description: `a model profile configured by the user (configured now: ${cfg.profiles.length ? cfg.profiles.map((x) => (typeof x === "string" ? x : x.name ?? x.id ?? JSON.stringify(x))).join(", ") : "none — omit model"})` },
76
- after: { type: "string", minLength: 1, description: "tester only: the earlier task (by its index, 1-based) whose worktree this tester runs in" },
94
+ after: { type: "string", minLength: 1, description: "verifier only: the earlier task (by its index, 1-based) whose worktree this verifier runs in a copy of" },
77
95
  timeoutMs: { type: "integer", minimum: 1000, description: "the child's wall-clock budget in milliseconds" },
78
96
  },
79
97
  required: ["role", "task"],
80
98
  additionalProperties: false,
81
99
  },
82
100
  },
101
+ // ADR-0058 3d: present only when the host wired tasks — without them
102
+ // the schema is byte-for-byte the foreground one
103
+ ...(background
104
+ ? {
105
+ background: {
106
+ type: "boolean",
107
+ description:
108
+ "explorer and reviewer only: start the tasks in the background and return at once; you are told when all of them have ended. They read the workspace as it is while they run; task_stop stops one",
109
+ },
110
+ }
111
+ : {}),
83
112
  },
84
113
  required: ["tasks"],
85
114
  additionalProperties: false,
86
115
  });
87
116
 
88
- export default async function createSubagentExtension() {
117
+ /**
118
+ * @param {{ tasks?: (sessionId: string | undefined) => any, backgroundMax?: number, backgroundMaxTurns?: number }} [host]
119
+ * ADR-0058 3d: the host's per-session task manager (the runtime's
120
+ * TaskManager satisfies it structurally — no dependency), the cap on live
121
+ * background children per session, and a background child's turn budget.
122
+ */
123
+ export default async function createSubagentExtension(host = {}) {
89
124
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
90
125
  if (depth >= 1) return { name: "subagent", tools: [] }; // depth guard — no nesting
126
+ const tasksOf = typeof host.tasks === "function" ? host.tasks : undefined;
127
+ const background = tasksOf === undefined ? undefined : { tasksOf, max: host.backgroundMax ?? BACKGROUND_MAX, maxTurns: host.backgroundMaxTurns ?? BACKGROUND_MAX_TURNS, reserved: new Map() };
91
128
  return {
92
129
  name: "subagent",
93
130
  tools: [
94
131
  {
95
132
  name: "delegate",
96
- description: "run subagent tasks (explorer/implementer/reviewer/tester) in child kiso processes",
97
- parameters: delegateParameters(delegationConfig()),
133
+ description: "run subagent tasks (explorer/implementer/reviewer/verifier) in child kiso processes",
134
+ parameters: delegateParameters(delegationConfig(), background !== undefined),
98
135
  execute: async (input, ctx) => {
99
136
  const tasks = ((input ?? {}).tasks ?? []).slice(0, 8);
100
137
  if (tasks.length === 0) return { content: "delegate: no tasks", isError: true, errorKind: "precondition" };
138
+ const inBackground = (input ?? {}).background === true;
139
+ if (inBackground && background === undefined) return { content: "delegate: background is not available here — run the delegation in the foreground", isError: true, errorKind: "precondition" };
101
140
  // 0.40.0: the parent's own folder (one per project), handed over
102
141
  // by the CLI; a pinned KISO_SESSIONS_DIR, or the legacy folder,
103
142
  // when no CLI said (a direct load)
@@ -130,6 +169,7 @@ export default async function createSubagentExtension() {
130
169
  // applied" banner (loop.ts keys that banner on errorKind !== "precondition")
131
170
  if (why !== null) return { content: `delegate: refused — task ${i + 1}: ${why}`, isError: true, errorKind: "precondition" };
132
171
  }
172
+ if (inBackground) return startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir });
133
173
  const sections = await runLimited(tasks, CONCURRENCY, (task, i) =>
134
174
  runChild({
135
175
  childId: `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`,
@@ -163,6 +203,104 @@ export default async function createSubagentExtension() {
163
203
  };
164
204
  }
165
205
 
206
+ const refuse = (why) => ({ content: `delegate: refused — ${why}`, isError: true, errorKind: "precondition" });
207
+
208
+ /**
209
+ * ADR-0058 3d — start the children as agent tasks and return at once.
210
+ *
211
+ * Everything up to the reservation is synchronous: the check that the
212
+ * whole batch fits (live agent tasks + slots reserved by calls still
213
+ * starting + this batch ≤ max) and the reservation are one step, so two
214
+ * calls can never both fit into the last slots (D4). Each child's inputs —
215
+ * its task file, role policy and manifest — are written and fsynced before
216
+ * its task is planned (the runner and the child need nothing a `finally`
217
+ * here could remove). The child is the same invocation a foreground child
218
+ * runs, launched by argv (no shell), with its turn budget and its result
219
+ * file beside its task (D6).
220
+ */
221
+ async function startBackground({ tasks, ctx, background, sessionsDir, parentId, bin, delegationId, manifestDir }) {
222
+ for (const task of tasks) {
223
+ if (!BACKGROUND_ROLES.includes(task.role)) return refuse(`background is not supported for the ${task.role} role in this release; run this delegation in the foreground`);
224
+ if (task.timeoutMs !== undefined) return refuse("timeoutMs applies to a foreground delegation; a background child has no wall-clock kill — task_stop stops one");
225
+ }
226
+ const manager = background.tasksOf(ctx.sessionId);
227
+ if (manager === undefined) return refuse("background is not available here — run the delegation in the foreground");
228
+ const key = ctx.sessionId ?? "";
229
+ const live = manager.list().filter((t) => t.agent !== undefined && t.state.kind !== "ended" && t.state.kind !== "not_run").length + (background.reserved.get(key) ?? 0);
230
+ if (live + tasks.length > background.max) {
231
+ return refuse(`${live} background children are running in this session and the cap is ${background.max}; this call asks for ${tasks.length}. Do not retry — wait for some to end, or stop one with task_stop`);
232
+ }
233
+ background.reserved.set(key, (background.reserved.get(key) ?? 0) + tasks.length);
234
+ let unstarted = tasks.length;
235
+ const release = (n) => background.reserved.set(key, Math.max(0, (background.reserved.get(key) ?? 0) - n));
236
+ const lines = [];
237
+ let failures = 0;
238
+ try {
239
+ for (let i = 0; i < tasks.length; i += 1) {
240
+ const task = tasks[i];
241
+ const childId = `sub-${parentId}-${delegationId}-${i + 1}-${task.role}`;
242
+ try {
243
+ const inputs = writeChildInputs(manifestDir, childId, task, { parentId, delegationId, index: i + 1, role: task.role, startedAt: Date.now() });
244
+ const started = await manager.start({
245
+ command: `${task.role}: ${task.task.split("\n")[0].slice(0, 80)}`,
246
+ cwd: process.cwd(),
247
+ env: childEnv(inputs.policyDir, sessionsDir),
248
+ ...(ctx.executionId !== undefined ? { executionId: ctx.executionId } : {}),
249
+ agent: { role: task.role, session: childId },
250
+ exec: (dir) => ({ file: process.execPath, args: [...childArgs(bin, childId, inputs.taskPath, task.model), "--max-turns", String(background.maxTurns), "--result-file", join(dir, "result.md")] }),
251
+ });
252
+ lines.push(`${started.id} ${task.role} (session ${childId})`);
253
+ } catch (err) {
254
+ failures += 1;
255
+ lines.push(`task ${i + 1} (${task.role}) did not start: ${msg(err)}`);
256
+ }
257
+ unstarted -= 1;
258
+ release(1);
259
+ }
260
+ } finally {
261
+ release(unstarted);
262
+ }
263
+ const n = tasks.length - failures;
264
+ const content =
265
+ `started ${n} background ${n === 1 ? "child" : "children"}: ${lines.join(", ")}. ` +
266
+ "They read the workspace as it is while they run. You will be told when all of them have ended; task_stop stops one.";
267
+ return { content, isError: n === 0 };
268
+ }
269
+
270
+ /** The durable inputs of a background child: the task file (the fixed
271
+ * UNRESOLVED trailer included), its role policy, its manifest — fsynced
272
+ * with their directories before the task is planned. */
273
+ function writeChildInputs(manifestDir, childId, task, manifest) {
274
+ const taskPath = join(manifestDir, `${childId}.task`);
275
+ const policyDir = join(manifestDir, `${childId}.policy`);
276
+ mkdirSync(policyDir, { recursive: true });
277
+ writeDurable(join(policyDir, "policy.mjs"), rolePolicyContent(task.role, undefined));
278
+ writeDurable(taskPath, `${task.task}\n\n${UNRESOLVED_INSTRUCTION}\n`);
279
+ writeDurable(join(manifestDir, `${childId}.json`), `${JSON.stringify({ ...manifest, childId, task: task.task, background: true, ...(task.model !== undefined ? { model: task.model } : {}) })}\n`);
280
+ fsyncPath(policyDir);
281
+ fsyncPath(manifestDir);
282
+ return { taskPath, policyDir };
283
+ }
284
+
285
+ function writeDurable(path, content) {
286
+ const fd = openSync(path, "w");
287
+ try {
288
+ writeSync(fd, content);
289
+ fsyncSync(fd);
290
+ } finally {
291
+ closeSync(fd);
292
+ }
293
+ }
294
+
295
+ function fsyncPath(dir) {
296
+ const fd = openSync(dir, "r");
297
+ try {
298
+ fsyncSync(fd);
299
+ } finally {
300
+ closeSync(fd);
301
+ }
302
+ }
303
+
166
304
  /**
167
305
  * The parent session id for the child naming: the explicit
168
306
  * KISO_SESSION_ID wins; otherwise the NEWEST *.jsonl in the sessions dir
@@ -213,20 +351,34 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
213
351
  }
214
352
  // Isolation (DT-1a R2.2): implementer → its own detached worktree from
215
353
  // the parent's HEAD (the parent's UNCOMMITTED changes are not visible —
216
- // the section says so); tester → the implementer's kept worktree when
217
- // `after` names one, else a fresh worktree from HEAD; explorer and
218
- // reviewer → the parent's tree under a read-only policy. Non-git
219
- // parents fail the task HONESTLY.
354
+ // the section says so); verifier → a COPY of the implementer's kept
355
+ // worktree when `after` names one (a fresh worktree at the same base
356
+ // with the implementer's changes applied — the kept worktree the
357
+ // parent was handed is never touched), else a fresh worktree from HEAD;
358
+ // explorer and reviewer → the parent's tree under a read-only policy.
359
+ // Non-git parents fail the task HONESTLY.
220
360
  let worktree = null;
221
361
  let baseRev = null;
222
362
  let childCwd = parentCwd;
223
363
  let ownsWorktree = false;
224
- if (role === "tester" && after !== undefined) {
364
+ if (role === "verifier" && after !== undefined) {
225
365
  const prior = readResultFile(manifestDir, after);
226
- worktree = prior.worktree;
227
366
  baseRev = prior.baseRev;
228
- childCwd = worktree;
229
- } else if (role === "implementer" || role === "tester") {
367
+ worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
368
+ ownsWorktree = true;
369
+ try {
370
+ execFileSync("git", ["-C", parentCwd, "worktree", "add", "--detach", worktree, baseRev ?? "HEAD"], { stdio: "ignore" });
371
+ // the implementer's state against its base — committed or not,
372
+ // new files included (intent-to-add), binary-safe
373
+ execFileSync("git", ["-C", prior.worktree, "add", "-N", "."], { stdio: "ignore" });
374
+ const patch = execFileSync("git", ["-C", prior.worktree, "diff", "--binary", baseRev ?? "HEAD"], { maxBuffer: 256 * 1024 * 1024 });
375
+ if (patch.length > 0) execFileSync("git", ["-C", worktree, "apply", "--binary", "--whitespace=nowarn"], { input: patch, stdio: ["pipe", "ignore", "pipe"] });
376
+ childCwd = worktree;
377
+ } catch (err) {
378
+ removeWorktree(parentCwd, worktree);
379
+ return failSection(childId, role, task, `the verifier could not copy the implementer's worktree: ${msg(err)}`);
380
+ }
381
+ } else if (role === "implementer" || role === "verifier") {
230
382
  worktree = mkdtempSync(join(tmpdir(), "kiso-subagent-wt-"));
231
383
  ownsWorktree = true;
232
384
  try {
@@ -252,28 +404,34 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
252
404
  // the fixed UNRESOLVED instruction the result parser reads back.
253
405
  const taskPath = join(manifestDir ?? policyDir, `${childId}.task`);
254
406
  writeFileSync(taskPath, `${task}\n\n${UNRESOLVED_INSTRUCTION}\n`, "utf8");
255
- const { code, stdout, killed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal, model, sessionsDir);
407
+ const { code, stdout, killed, unconfirmed } = await runProcess(childId, bin, childCwd, policyDir, taskPath, timeout, signal, model, sessionsDir);
256
408
  const extraction = await extractChildResult(sessionsDir, childId, `exit ${code}\n${stdout}`);
257
409
  const status = killed === "timeout" ? "timeout" : killed === "abort" ? "killed" : code !== 0 && extraction.outcome === "missing" ? "spawn-failed" : extraction.outcome;
258
410
  let failed = code !== 0 || killed !== null || extraction.failed;
259
411
  const lines = [];
412
+ // Windows P6: a kill that did not take is never reported as a kill
413
+ const killNote = unconfirmed.length > 0 ? ` — could not confirm the child exited (pid ${unconfirmed.join(", ")}): treat its side effects as UNCERTAIN` : " (the child process group was killed)";
260
414
  if (killed === "timeout") {
261
- lines.push(` FAILED: timed out after ${timeout}ms (the child process group was killed)`);
415
+ lines.push(` FAILED: timed out after ${timeout}ms${killNote}`);
262
416
  } else if (killed === "abort") {
263
- lines.push(" FAILED: aborted by the parent run (the child process group was killed)");
417
+ lines.push(` FAILED: aborted by the parent run${killNote}`);
264
418
  } else if (code !== 0) {
265
419
  lines.push(` FAILED: the child exited with code ${code}\n${stdout}`);
266
420
  } else if (extraction.failed) {
267
421
  lines.push(` FAILED: ${extraction.reason}${extraction.diag !== "" ? `\n${extraction.diag}` : ""}`);
268
422
  }
269
423
  const unresolved = parseUnresolved(extraction.text);
270
- // the collection (implementers only — a tester's worktree is the
271
- // implementer's or a throwaway; its changes are not its result)
424
+ // the collection (implementers only — a verifier's worktree is a
425
+ // throwaway copy; its changes are not its result)
272
426
  let changedFiles = null;
273
427
  let patchPath = null;
274
428
  let patchBytes = null;
275
429
  let collection = null;
276
- if (role === "implementer") {
430
+ // Windows P6: a child that may still be running keeps its worktree,
431
+ // and nothing is collected from it — a patch of a tree that may
432
+ // still change is not the child's result
433
+ if (unconfirmed.length > 0 && worktree !== null) keepWorktree = true;
434
+ if (role === "implementer" && unconfirmed.length === 0) {
277
435
  // CX-1 F2 (audit F2): tri-state collection. `collected` is earned
278
436
  // (the patch file closed AND git exited 0); a failure PRESERVES the
279
437
  // worktree and says so; "consumed" is undefined, so a worktree
@@ -316,6 +474,7 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
316
474
  patchPath,
317
475
  patchBytes,
318
476
  verification,
477
+ ...(unconfirmed.length > 0 ? { unconfirmed } : {}),
319
478
  unresolved,
320
479
  answer: extraction.text,
321
480
  usage: extraction.usage,
@@ -330,7 +489,8 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
330
489
  if (scope !== undefined) text += `\n scoped: no shell · writes only under ${scope.join(", ")}`;
331
490
  if (baseRev !== null) text += `\n child saw HEAD ${baseRev.slice(0, 7)}; the parent's uncommitted changes were not visible`;
332
491
  if (verification !== null && verification.skipped === undefined) {
333
- text += `\n verification: ${verification.kind} ${verification.passed ? "PASSED" : "FAILED"} · exit ${verification.exitCode === null ? "killed" : verification.exitCode} · ${verification.durationMs}ms · ${verification.kind === "check" ? verification.command : verification.evaluator}`;
492
+ const exit = verification.unconfirmed !== undefined ? `killed, not confirmed (pid ${verification.unconfirmed.join(", ")} may still be running — treat its side effects as UNCERTAIN)` : verification.exitCode === null ? "killed" : verification.exitCode;
493
+ text += `\n verification: ${verification.kind} ${verification.passed ? "PASSED" : "FAILED"} · exit ${exit} · ${verification.durationMs}ms · ${verification.kind === "check" ? verification.command : verification.evaluator}`;
334
494
  if (!verification.passed && verification.tail !== "") text += `\n${verification.tail}`;
335
495
  }
336
496
  for (const l of lines) text += `\n${l}`;
@@ -345,6 +505,8 @@ async function runChild({ childId, role, task, scope, acceptance, model, after,
345
505
  } else if (collection.kind === "failed") {
346
506
  text += `\n FAILED: collecting the worktree's changes: ${collection.reason}${collection.partialPath !== undefined ? ` (partial patch at ${collection.partialPath})` : ""}\n worktree kept at: ${worktree}`;
347
507
  }
508
+ } else if (unconfirmed.length > 0 && worktree !== null) {
509
+ text += `\n worktree kept at: ${worktree} — the child may still be writing to it; nothing was collected`;
348
510
  }
349
511
  return { failed, ...(failed ? { failKind: failKindOf(status, verification) } : {}), text, toolCalls: extraction.toolCalls };
350
512
  } finally {
@@ -416,11 +578,12 @@ function artifactDir(home, sessionsDir) {
416
578
  /** DT-1a: every task's inputs are judged BEFORE any child runs; a string is the refusal. */
417
579
  export function validateTask(task, cfg, parentCwd, manifestDir) {
418
580
  if (task === null || typeof task !== "object") return "a task must be an object";
581
+ if (task.role === "tester") return 'unknown role "tester" — it is called "verifier" since 0.46.0';
419
582
  if (!ROLES.includes(task.role)) return `unknown role ${JSON.stringify(task.role)}`;
420
583
  if (typeof task.task !== "string" || task.task.trim() === "") return "task must be a non-empty string";
421
584
  if (task.scope !== undefined) {
422
585
  if (!Array.isArray(task.scope) || task.scope.length === 0 || task.scope.some((g) => typeof g !== "string" || g === "" || isAbsolute(g) || g.split("/").includes(".."))) return "scope must be a non-empty list of relative globs (no absolute paths, no ..)";
423
- if (task.role !== "implementer" && task.role !== "tester") return "scope applies to implementer and tester tasks only";
586
+ if (task.role !== "implementer" && task.role !== "verifier") return "scope applies to implementer and verifier tasks only";
424
587
  }
425
588
  if (task.acceptance !== undefined) {
426
589
  const a = task.acceptance;
@@ -449,7 +612,7 @@ export function validateTask(task, cfg, parentCwd, manifestDir) {
449
612
  }
450
613
  if (task.model !== undefined && !cfg.profiles.includes(task.model)) return `refused: unknown model profile ${JSON.stringify(task.model)} (configured: ${cfg.profiles.join(", ") || "none"})`;
451
614
  if (task.after !== undefined) {
452
- if (task.role !== "tester") return "refused: `after` is for tester tasks";
615
+ if (task.role !== "verifier") return "refused: `after` is for verifier tasks";
453
616
  let prior;
454
617
  try {
455
618
  prior = readResultFile(manifestDir, task.after);
@@ -555,9 +718,95 @@ export function parseChangedFiles(numstat, nameStatus) {
555
718
  const ACCEPTANCE_OUTPUT_CAP = 64 * 1024;
556
719
  const ACCEPTANCE_TAIL = 2 * 1024;
557
720
 
721
+ /** A child in its own process group, so a timeout or an abort kills it
722
+ * whole; win32 has no groups — the child is hidden, and its tree dies by
723
+ * taskkill /T (which walks it by parent). */
724
+ const ownGroup = () => (process.platform === "win32" ? { windowsHide: true } : { detached: true });
725
+
726
+ function killGroup(child) {
727
+ try {
728
+ if (process.platform === "win32") execFileSync("taskkill", ["/T", "/F", "/PID", String(child.pid)], { stdio: "ignore", windowsHide: true });
729
+ else process.kill(-child.pid, "SIGKILL");
730
+ } catch {
731
+ // already gone, or the kill was refused: the bounded wait decides
732
+ }
733
+ }
734
+
735
+ /** How long the wait after a kill lasts before the child's exit is
736
+ * declared unconfirmed — tools-node's killTree uses the same fallback. */
737
+ const KILL_CONFIRM_MS = 2_000;
738
+
739
+ /** The child's exit, supervised: the timeout and the parent's abort kill
740
+ * its group, and the wait after a kill is bounded (Windows P6). A kill
741
+ * that does not take — taskkill refused, a group SIGKILL refused with
742
+ * EPERM — leaves the child running; after KILL_CONFIRM_MS its pid comes
743
+ * back in `unconfirmed` (tools-node's contract: the caller must not
744
+ * report it gone) and the child is let go, so it never holds the parent. */
745
+ function superviseExit(child, timeout, signal) {
746
+ return new Promise((resolve) => {
747
+ let killed = null;
748
+ let done = false;
749
+ let confirm;
750
+ const settle = (code, unconfirmed) => {
751
+ if (done) return;
752
+ done = true;
753
+ clearTimeout(timer);
754
+ clearTimeout(confirm);
755
+ signal?.removeEventListener("abort", onAbort);
756
+ resolve({ code, killed, unconfirmed });
757
+ };
758
+ const kill = (why) => {
759
+ if (killed !== null) return;
760
+ killed = why;
761
+ killGroup(child);
762
+ if (done) return;
763
+ confirm = setTimeout(() => {
764
+ child.stdout?.destroy();
765
+ child.stderr?.destroy();
766
+ child.unref();
767
+ settle(null, [child.pid]);
768
+ }, KILL_CONFIRM_MS);
769
+ };
770
+ const timer = setTimeout(() => kill("timeout"), timeout);
771
+ const onAbort = () => kill("abort");
772
+ child.on("error", () => settle(null, []));
773
+ child.on("exit", (code) => settle(code, []));
774
+ if (signal?.aborted) onAbort();
775
+ else signal?.addEventListener("abort", onAbort, { once: true });
776
+ });
777
+ }
778
+
779
+ /** The bash a check runs through on win32 — this build's own copy of the
780
+ * process module's rule (tools-node `process.ts`; zero dependencies here),
781
+ * pinned to it by subagent-win32.test.ts. */
782
+ function resolveBash(env) {
783
+ const fail = (message) => {
784
+ throw Object.assign(new Error(message), { code: "KISO_NO_BASH" });
785
+ };
786
+ const override = env.KISO_BASH;
787
+ if (override !== undefined && override !== "") {
788
+ if (!/^bash(\.exe)?$/i.test(win32.basename(override))) {
789
+ fail(`KISO_BASH must name a bash executable (bash.exe), not ${win32.basename(override)}: kiso's shell commands are POSIX shell`);
790
+ }
791
+ if (!existsSync(override)) fail(`KISO_BASH is set to ${override}, which does not exist`);
792
+ return override;
793
+ }
794
+ for (const root of [env.ProgramFiles ?? "C:\\Program Files", env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)"]) {
795
+ const candidate = win32.join(root, "Git", "bin", "bash.exe");
796
+ if (existsSync(candidate)) return candidate;
797
+ }
798
+ const system32 = win32.join(env.SystemRoot ?? "C:\\Windows", "System32").toLowerCase();
799
+ for (const dir of (env.PATH ?? "").split(win32.delimiter)) {
800
+ if (dir === "" || win32.join(dir, ".").toLowerCase() === system32) continue;
801
+ const candidate = win32.join(dir, "bash.exe");
802
+ if (existsSync(candidate)) return candidate;
803
+ }
804
+ return fail("no bash found — kiso runs shell commands through Git Bash on Windows: install Git for Windows, or set KISO_BASH to the path of bash.exe");
805
+ }
806
+
558
807
  /** DT-1a R2.3: the parent runs the acceptance in the worktree — a configured check
559
- * through /bin/sh (user-authored), or the evaluator binary with the worktree as
560
- * its argument. Own process group (the abort and the timeout kill it whole), the
808
+ * through /bin/sh (bash on win32; user-authored), or the evaluator binary with the
809
+ * worktree as its argument. Own process group (the abort and the timeout kill it whole), the
561
810
  * output capped, the tail kept. The exit code proves the command RAN on the tree
562
811
  * as the child left it (patchSha256 names that state); only an evaluator proves
563
812
  * correctness — the child can edit a check's tests. */
@@ -573,37 +822,23 @@ export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout,
573
822
  } catch {
574
823
  // a non-git worktree: the state hash stays the empty one
575
824
  }
576
- const child = kind === "check" ? spawn("/bin/sh", ["-c", command], { cwd: worktree, detached: true, stdio: ["ignore", "pipe", "pipe"] }) : spawn(command, [worktree], { cwd: worktree, detached: true, stdio: ["ignore", "pipe", "pipe"] });
825
+ let child;
826
+ try {
827
+ const options = { cwd: worktree, ...ownGroup(), stdio: ["ignore", "pipe", "pipe"] };
828
+ child = kind === "check" ? spawn(process.platform === "win32" ? resolveBash(process.env) : "/bin/sh", ["-c", command], options) : spawn(command, [worktree], options);
829
+ } catch (err) {
830
+ // win32 with no usable bash: the check fails with a message the model can act on
831
+ if (err?.code !== "KISO_NO_BASH") throw err;
832
+ const name = kind === "check" ? { name: acceptance.check, command } : { evaluator: command };
833
+ return { kind, ...name, exitCode: null, passed: false, tail: err.message, durationMs: Date.now() - started, patchSha256, baseRev };
834
+ }
577
835
  let output = "";
578
836
  const capture = (d) => {
579
837
  if (output.length < ACCEPTANCE_OUTPUT_CAP) output += String(d).slice(0, ACCEPTANCE_OUTPUT_CAP - output.length);
580
838
  };
581
839
  child.stdout.on("data", capture);
582
840
  child.stderr.on("data", capture);
583
- let killed = null;
584
- const killGroup = () => {
585
- try {
586
- process.kill(-child.pid, "SIGKILL");
587
- } catch {
588
- // already gone
589
- }
590
- };
591
- const timer = setTimeout(() => {
592
- killed = "timeout";
593
- killGroup();
594
- }, timeout);
595
- const onAbort = () => {
596
- killed = "abort";
597
- killGroup();
598
- };
599
- if (signal?.aborted) onAbort();
600
- else signal?.addEventListener("abort", onAbort, { once: true });
601
- const exitCode = await new Promise((resolveExit) => {
602
- child.on("error", () => resolveExit(null));
603
- child.on("exit", (code) => resolveExit(code));
604
- });
605
- clearTimeout(timer);
606
- signal?.removeEventListener("abort", onAbort);
841
+ const { code: exitCode, killed, unconfirmed } = await superviseExit(child, timeout, signal);
607
842
  const code = killed !== null ? null : exitCode;
608
843
  return {
609
844
  kind,
@@ -611,6 +846,7 @@ export async function runAcceptance(acceptance, cfg, worktree, baseRev, timeout,
611
846
  exitCode: code,
612
847
  passed: code === 0,
613
848
  ...(killed !== null ? { killed } : {}),
849
+ ...(unconfirmed.length > 0 ? { unconfirmed } : {}),
614
850
  tail: output.slice(-ACCEPTANCE_TAIL),
615
851
  durationMs: Date.now() - started,
616
852
  patchSha256,
@@ -636,26 +872,32 @@ export function childArgs(bin, childId, taskPath, model) {
636
872
  return [bin, ...(model !== undefined ? ["--model", model] : []), "chat", childId, "--task-file", taskPath];
637
873
  }
638
874
 
639
- function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model, sessionsDir) {
875
+ /** A child's environment — the parent's, plus the depth guard, its role
876
+ * policy and the sessions folder (the foreground and background share it). */
877
+ function childEnv(policyDir, sessionsDir) {
640
878
  const depth = Number.parseInt(process.env.KISO_SUBAGENT_DEPTH ?? "0", 10) || 0;
879
+ return {
880
+ ...process.env,
881
+ KISO_SUBAGENT_DEPTH: String(depth + 1),
882
+ KISO_EXTENSIONS_DIR: policyDir,
883
+ // 0.40.0: the child writes its log beside its parent's — the
884
+ // parent reads it back from there — whatever cwd it runs in
885
+ KISO_SESSIONS_DIR: sessionsDir,
886
+ // Modes: a headless child has no human — the mode tiers'
887
+ // ask would stall it. Bypass is the neutral tier here; the
888
+ // role policy dir (allow/deny only — a child must never
889
+ // see an ask) stays the child's ONLY gate, exactly as
890
+ // before the mode tiers existed (deny>ask>allow honors its
891
+ // denials; the mode's all-allow never overrides them).
892
+ KISO_MODE: "bypass",
893
+ };
894
+ }
895
+
896
+ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, model, sessionsDir) {
641
897
  const child = spawn(process.execPath, childArgs(bin, childId, taskPath, model), {
642
898
  cwd,
643
- env: {
644
- ...process.env,
645
- KISO_SUBAGENT_DEPTH: String(depth + 1),
646
- KISO_EXTENSIONS_DIR: policyDir,
647
- // 0.40.0: the child writes its log beside its parent's — the
648
- // parent reads it back from there — whatever cwd it runs in
649
- KISO_SESSIONS_DIR: sessionsDir,
650
- // Modes: a headless child has no human — the mode tiers'
651
- // ask would stall it. Bypass is the neutral tier here; the
652
- // role policy dir (allow/deny only — a child must never
653
- // see an ask) stays the child's ONLY gate, exactly as
654
- // before the mode tiers existed (deny>ask>allow honors its
655
- // denials; the mode's all-allow never overrides them).
656
- KISO_MODE: "bypass",
657
- },
658
- detached: true,
899
+ env: childEnv(policyDir, sessionsDir),
900
+ ...ownGroup(),
659
901
  stdio: ["pipe", "pipe", "inherit"],
660
902
  });
661
903
  child.stdin.end(); // CX-1 F5: nothing rides stdin — the task is the file
@@ -663,41 +905,14 @@ function runProcess(childId, bin, cwd, policyDir, taskPath, timeout, signal, mod
663
905
  child.stdout.on("data", (d) => {
664
906
  stdout += String(d);
665
907
  });
666
- const exited = new Promise((resolve) => {
667
- child.on("exit", (code, sig) => resolve({ code: code ?? -1, signal: sig }));
668
- });
669
- let killed = null;
670
- const killGroup = () => {
671
- try {
672
- process.kill(-child.pid, "SIGKILL");
673
- } catch {
674
- // already gone
675
- }
676
- };
677
- const timer = setTimeout(() => {
678
- killed = "timeout";
679
- killGroup();
680
- }, timeout);
681
- const onAbort = () => {
682
- killed = "abort";
683
- killGroup();
684
- };
685
- if (signal?.aborted) onAbort();
686
- else signal?.addEventListener("abort", onAbort, { once: true });
687
- // The timeout and abort listener live until the child EXITS — clearing
688
- // them in a finally around the setup would disarm them before the exit.
689
- return exited.then(({ code }) => {
690
- clearTimeout(timer);
691
- signal?.removeEventListener("abort", onAbort);
692
- return { code, stdout, killed };
693
- });
908
+ return superviseExit(child, timeout, signal).then(({ code, killed, unconfirmed }) => ({ code: code ?? -1, stdout, killed, unconfirmed }));
694
909
  }
695
910
 
696
911
  /** The role policy: read-only for explorer/reviewer, the full six for
697
- * implementer/tester. Only allow/deny — NEVER ask (a headless child cannot
912
+ * implementer/verifier. Only allow/deny — NEVER ask (a headless child cannot
698
913
  * answer an approval prompt; ask would deadlock). */
699
914
  export function rolePolicyContent(role, scope) {
700
- const allowed = role === "implementer" || role === "tester" ? SIX_TOOLS : READ_ONLY;
915
+ const allowed = role === "implementer" || role === "verifier" ? SIX_TOOLS : READ_ONLY;
701
916
  if (scope === undefined) {
702
917
  return `export default { name: "subagent-${role}", approvals: [{
703
918
  decide(call) {
package/index.d.ts CHANGED
@@ -7,5 +7,28 @@
7
7
  */
8
8
  import type { KisoExtension } from "@vincemakes/kiso-core";
9
9
 
10
- declare const createSubagentExtension: () => KisoExtension | Promise<KisoExtension>;
10
+ /** ADR-0058 3d: what a background delegation needs of the host's per-session
11
+ * task manager — the runtime's TaskManager satisfies it structurally. */
12
+ export interface SubagentTasks {
13
+ list(): readonly { readonly agent?: unknown; readonly state: { readonly kind: string } }[];
14
+ start(options: {
15
+ readonly command: string;
16
+ readonly cwd: string;
17
+ readonly env?: Readonly<Record<string, string | undefined>>;
18
+ readonly executionId?: string;
19
+ readonly agent: { readonly role: string; readonly session: string };
20
+ readonly exec: (dir: string) => { readonly file: string; readonly args: readonly string[] };
21
+ }): Promise<{ readonly id: string }>;
22
+ }
23
+
24
+ export interface SubagentHost {
25
+ /** The session's task manager; with it, `delegate` offers `background`. */
26
+ readonly tasks?: (sessionId: string | undefined) => SubagentTasks | undefined;
27
+ /** Live background children per session (default 20). */
28
+ readonly backgroundMax?: number;
29
+ /** A background child's model requests before its wrap-up (default 32). */
30
+ readonly backgroundMaxTurns?: number;
31
+ }
32
+
33
+ declare const createSubagentExtension: (host?: SubagentHost) => KisoExtension | Promise<KisoExtension>;
11
34
  export default createSubagentExtension;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincemakes/kiso-subagent-ext",
3
- "version": "0.45.3",
3
+ "version": "0.46.1",
4
4
  "description": "kiso official subagent extension \u2014 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.45.3",
26
+ "@vincemakes/kiso-core": "0.46.1",
27
27
  "@types/node": "^26.1.2",
28
28
  "typescript": "^5.7.2",
29
29
  "vitest": "^3.0.0"